Skip to content

Commit 186a721

Browse files
renezander030claude
andcommitted
composite adapter + semantic-layer positioning
- @reneza/ats-adapter-composite: query many backends as ONE fused corpus (bulkFetch unions every child, each task tagged with its backend). Makes the cross-source fusion claim real. 10/10 unit tests, conformance-clean. - README reframed: 'you already have connectors; ATS is the semantic layer' + task-first (your task manager is the memory, supporting tools fused in as context, not a pile of MEMORY.md files). Composite in tree + table. - assets/semantic-layer.png: non-tech explainer (built-in memory vs ATS) on first-try accuracy (1 in 5 -> 3 in 5, per bench) + steps to answer. - dropped 'second-brain' framing from the Notion adapter (task-first, not PKM). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 526387c commit 186a721

10 files changed

Lines changed: 676 additions & 13 deletions

File tree

README.md

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,13 @@ ats find "deployment runbook"
5757
</p>
5858

5959
<p align="center">
60-
<img src="assets/demo-fusion.gif" alt="ats find — one query fused across GitHub, Notion, and TickTick with RRF" width="760" />
61-
<br><em>One <code>ats find</code> across <strong>GitHub + Notion + TickTick</strong>, fused and ranked by RRF — the one thing no single-vendor MCP server can do.</em>
60+
<img src="assets/demo-fusion.gif" alt="ats find — one query fused across GitHub, Notion, and TickTick, ranked by RRF" width="760" />
61+
<br><em>One <code>ats find</code> across <strong>GitHub + Notion + TickTick</strong> via the <a href="packages/adapter-composite/">composite adapter</a>, ranked by RRF. Your connectors give the agent access; this is the semantic layer that lands the first query on the right answer.</em>
62+
</p>
63+
64+
<p align="center">
65+
<img src="assets/semantic-layer.png" alt="Claude's built-in memory vs the ATS semantic layer — right answer on the first try, fewer steps" width="760" />
66+
<br><em>ATS is <strong>task-first</strong>: your task manager is the agent's memory, with supporting tools (GitHub, Notion, docs) fused in as context — not a pile of <code>MEMORY.md</code> files the agent writes and forgets.</em>
6267
</p>
6368

6469
## Why this exists
@@ -206,6 +211,7 @@ agentic-task-system/
206211
│ ├── adapter-google/ # Google Sheets/Docs/Slides as a read-only corpus
207212
│ ├── adapter-notion/ # Notion databases + pages (page body as markdown)
208213
│ ├── adapter-github/ # GitHub issues + discussions as text records
214+
│ ├── adapter-composite/ # cross-source: query many backends as one fused corpus
209215
│ ├── cli/ # `ats` command
210216
│ └── mcp/ # `@reneza/ats-mcp` — MCP server
211217
├── docs/
@@ -249,13 +255,18 @@ Full spec: [`docs/adapter-interface.md`](docs/adapter-interface.md).
249255

250256
## Available adapters
251257

252-
**Why an adapter instead of pointing your agent straight at the backend's API?**
253-
A raw Airtable or Google call returns *that* backend's rows; an adapter makes the
254-
backend part of one retrieval surface:
255-
256-
- **Fused, ranked retrieval across every source.** One query returns a single
257-
RRF-ranked, deduped list spanning Airtable + Google + your task app — not N
258-
separate tool calls the model has to stitch together itself.
258+
**You already have connectors. ATS is the semantic layer they're missing.**
259+
Every vendor ships an official MCP connector now, so your agent can already *reach*
260+
Notion, GitHub, and your task app. What it can't do is *retrieve* — answer "what do
261+
I know about the auth migration?" ranked by relevance, across all of them. That's
262+
the layer ATS adds:
263+
264+
- **Ranked by meaning, not endpoints.** Hybrid keyword + dense + sparse retrieval,
265+
fused with RRF, so the first result is the relevant one — not whatever the model
266+
guessed to query.
267+
- **One query, every source.** The [composite adapter](packages/adapter-composite/)
268+
fuses GitHub + Notion + your task app into one ranked, deduped list, each hit
269+
tagged with its backend — the cross-source retrieval no single-vendor connector does.
259270
- **Top-k, not token dumps.** Core runs the hybrid keyword+dense retrieval and
260271
hands back only what's relevant, so the agent never loads a whole base into
261272
context or hand-writes `filterByFormula` / Sheets ranges it tends to get wrong.
@@ -283,6 +294,7 @@ with everything else.**
283294
| `google` | shipped v0.8 | Google Sheets / Docs / Slides (read-only corpus) |
284295
| `notion` | shipped v0.8 | Notion databases + pages (page body as markdown) |
285296
| `github` | shipped v0.8 | GitHub issues + discussions (repo = project, issue = task) |
297+
| `composite` | shipped v0.8 | many adapters fused as one cross-source corpus |
286298
| `things` | wishlist | Things URL scheme + AppleScript |
287299
| `apple-notes` | wishlist | AppleScript |
288300
| `google-tasks` | wishlist | Google Tasks API |
@@ -364,8 +376,8 @@ ats find "Q3 pricing model"
364376

365377
The [Notion adapter](packages/adapter-notion/README.md) maps a database to a
366378
project and a page to a task — the page title is the title, the block tree renders
367-
to a markdown body — so your "second brain" is searchable through `ats find` and
368-
fused with everything else. Auth is an internal integration token, and the
379+
to a markdown body — so the Notion specs and docs that *support your tasks* are
380+
searchable through `ats find` and fused in alongside them. Auth is an internal integration token, and the
369381
security boundary is Notion's own per-page sharing: the integration only sees the
370382
databases you explicitly share with it.
371383

assets/build-semantic-layer.mjs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* build-semantic-layer.mjs — generates assets/semantic-layer.png
3+
*
4+
* A non-technical, single-image explainer: Claude's built-in memory
5+
* (CLAUDE.md / memory files) vs the ATS semantic layer, in terms a
6+
* non-engineer cares about — getting the right answer on the FIRST try, fast.
7+
*
8+
* Reproduce: node assets/build-semantic-layer.mjs
9+
* Deps: rsvg-convert (librsvg)
10+
*/
11+
import { execFileSync } from 'node:child_process';
12+
import fs from 'node:fs';
13+
import os from 'node:os';
14+
import path from 'node:path';
15+
16+
const W = 1000;
17+
const H = 600;
18+
const OUT = path.join('assets', 'semantic-layer.png');
19+
20+
const SLATE = '#0f172a';
21+
const GRAY = '#64748b';
22+
const TEAL = '#0d9488';
23+
const TEAL_BG = '#d9f2ee';
24+
const AMBER = '#c2701c';
25+
const AMBER_BG = '#fbe8d3';
26+
27+
function panel(x, title, subtitle, headBg, accent, lines, mark, outcome, outcomeBg) {
28+
const w = 440;
29+
const rows = lines
30+
.map((t, i) => {
31+
const y = 214 + i * 34;
32+
return `<text x="${x + 22}" y="${y}" font-size="17" fill="${accent}" font-weight="bold">${mark}</text>
33+
<text x="${x + 48}" y="${y}" font-size="17" fill="${SLATE}">${t}</text>`;
34+
})
35+
.join('\n ');
36+
return `
37+
<rect x="${x}" y="120" width="${w}" height="222" rx="12" fill="#ffffff" stroke="${accent}" stroke-width="1.5"/>
38+
<path d="M${x} 132 a12 12 0 0 1 12 -12 h416 a12 12 0 0 1 12 12 v36 h-440 z" fill="${headBg}"/>
39+
<text x="${x + 22}" y="148" font-size="19" font-weight="bold" fill="${SLATE}">${title}</text>
40+
<text x="${x + 22}" y="170" font-size="13" fill="${GRAY}">${subtitle}</text>
41+
${rows}
42+
<rect x="${x + 22}" y="306" width="${outcome.length * 9.2 + 26}" height="26" rx="13" fill="${outcomeBg}"/>
43+
<text x="${x + 35}" y="324" font-size="14" font-weight="bold" fill="${accent}">${outcome}</text>`;
44+
}
45+
46+
function stat(x, label, before, after) {
47+
const w = 440;
48+
return `
49+
<rect x="${x}" y="392" width="${w}" height="168" rx="12" fill="#f8fafc" stroke="#e2e8f0"/>
50+
<text x="${x + 24}" y="428" font-size="16" fill="${GRAY}">${label}</text>
51+
<text x="${x + 24}" y="492" font-size="30" font-weight="bold" fill="${AMBER}" text-decoration="line-through">${before}</text>
52+
<path d="M${x + 200} 482 h54 m-12 -8 l12 8 l-12 8" fill="none" stroke="${GRAY}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
53+
<text x="${x + 270}" y="494" font-size="40" font-weight="bold" fill="${TEAL}">${after}</text>
54+
<text x="${x + 24}" y="534" font-size="13" fill="${GRAY}">built-in memory</text>
55+
<text x="${x + 270}" y="534" font-size="13" fill="${TEAL}" font-weight="bold">with ATS</text>`;
56+
}
57+
58+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
59+
<rect width="${W}" height="${H}" fill="#ffffff"/>
60+
<rect x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" rx="14" fill="#ffffff" stroke="#cbd5e1"/>
61+
<text x="${W / 2}" y="54" font-size="30" font-weight="bold" fill="${SLATE}" text-anchor="middle">Ask once. Get the right answer first.</text>
62+
<text x="${W / 2}" y="86" font-size="16" fill="${GRAY}" text-anchor="middle">The semantic layer ATS adds on top of the connectors your agent already has</text>
63+
64+
${panel(40, "Claude's built-in memory", 'CLAUDE.md + memory files', AMBER_BG, AMBER,
65+
['Matches the exact words only', 'Re-reads whole files each time', 'Misses, then asks again'],
66+
'✗', 'Often wrong first, slow', AMBER_BG)}
67+
68+
${panel(520, 'The ATS semantic layer', 'your curated tools + hybrid RRF search', TEAL_BG, TEAL,
69+
['Understands what you mean', 'Searches every tool at once', 'Right answer first, with sources'],
70+
'✓', 'Right first, fast', TEAL_BG)}
71+
72+
${stat(40, 'Right on the first try', '1 in 5', '3 in 5')}
73+
${stat(520, 'Steps to the answer', '3–4 tries', '1')}
74+
</svg>`;
75+
76+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sem-'));
77+
const svgPath = path.join(tmp, 'semantic.svg');
78+
fs.writeFileSync(svgPath, svg);
79+
execFileSync('rsvg-convert', ['-z', '2', svgPath, '-o', OUT]);
80+
fs.rmSync(tmp, { recursive: true, force: true });
81+
console.log(`wrote ${OUT} (${Math.round(fs.statSync(OUT).size / 1024)} KB)`);

assets/semantic-layer.png

195 KB
Loading
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# @reneza/ats-adapter-composite
2+
3+
**ats-adapter-composite** is the cross-source adapter for the [Agentic Task System](https://github.com/renezander030/agentic-task-system) (ATS). It queries several backends — GitHub, Notion, TickTick, Obsidian, and more — as **one fused corpus**, so a single `ats find` returns one RRF-ranked list across all of them, each result tagged with its backend.
4+
5+
![ats find — one query fused across GitHub, Notion, and TickTick, ranked by RRF](https://raw.githubusercontent.com/renezander030/agentic-task-system/main/assets/demo-fusion.gif)
6+
7+
## You already have connectors. This is the semantic layer they're missing.
8+
9+
Your agent can already reach Notion, GitHub, and your task app — every vendor ships an official MCP connector now. That gives the agent *access*: read this page, create that issue, list those tasks. What it does not give you is **retrieval**.
10+
11+
A connector answers "fetch page X." It cannot answer "what do I know about the auth migration?" across three tools, ranked by relevance. That is a search problem, and connectors don't solve it — they hand the model an API and hope it queries the right thing.
12+
13+
ats-adapter-composite is the **semantic layer over your connectors**:
14+
15+
- **Ranked by meaning, not endpoints.** Hybrid retrieval (keyword + dense + sparse) fused with Reciprocal Rank Fusion, so the first result is the relevant one — not whatever the model guessed to query.
16+
- **One query, every source.** GitHub issues, Notion pages, and your tasks come back in a single ranked list, deduped, each tagged with its backend. No connector does cross-source.
17+
- **Provenance.** Every hit says which retrieval branch found it and which backend it came from.
18+
- **No vector database.** Keyword + RRF works out of the box; dense search is optional (local Qdrant + Ollama), never a hosted vector store to maintain.
19+
20+
The connectors are the plumbing. This is the retrieval brain on top.
21+
22+
## Setup
23+
24+
Install the composite plus whichever child adapters you want fused:
25+
26+
```bash
27+
npm install -g @reneza/ats-cli @reneza/ats-mcp \
28+
@reneza/ats-adapter-composite \
29+
@reneza/ats-adapter-github @reneza/ats-adapter-notion @reneza/ats-adapter-ticktick
30+
```
31+
32+
List the children in `~/.config/ats/composite.json`:
33+
34+
```json
35+
{
36+
"adapters": [
37+
"@reneza/ats-adapter-github",
38+
"@reneza/ats-adapter-notion",
39+
"@reneza/ats-adapter-ticktick"
40+
]
41+
}
42+
```
43+
44+
Each child reads **its own** existing config and auth (`ATS_GITHUB_TOKEN`, `ATS_NOTION_TOKEN`, the TickTick OAuth login). The composite adds none of its own — it just namespaces ids and routes. Then:
45+
46+
```bash
47+
ats config use @reneza/ats-adapter-composite
48+
ats doctor # shows per-backend auth
49+
ats find "auth token migration" # one ranked list across all backends
50+
claude mcp add ats -e ATS_ADAPTER=@reneza/ats-adapter-composite -- ats-mcp
51+
```
52+
53+
## Mapping
54+
55+
| ATS | Composite |
56+
| ---------- | --------------------------------------------------------------- |
57+
| Project | a child project, id namespaced `<backend>:<projectId>` (e.g. `github:owner/repo`, `notion:db-1111`) |
58+
| Task | a child record, re-stamped with `source: <backend>` |
59+
| `find` | `bulkFetch()` concatenates every child's corpus → Core runs hybrid + RRF over the union |
60+
| writes | routed to the child named by the project id's `<backend>:` prefix |
61+
62+
## FAQ
63+
64+
**I already have a Notion MCP server and a GitHub MCP server. Why this?**
65+
Those are connectors — they give your agent access to one tool each. This is the retrieval layer: one query, ranked by meaning, fused across all of them, with provenance. Access is not search.
66+
67+
**Does it need a vector database?**
68+
No. Keyword + RRF works out of the box across every backend; dense retrieval is optional (local Qdrant + Ollama).
69+
70+
**How does auth work across backends?**
71+
Each child authenticates itself with its own token/OAuth. The composite aggregates status (`ats doctor`) but holds no credentials of its own.
72+
73+
## Part of the Agentic Task System
74+
75+
The composite fuses these backends (install the ones you use):
76+
77+
- [@reneza/ats-adapter-ticktick](https://www.npmjs.com/package/@reneza/ats-adapter-ticktick)
78+
- [@reneza/ats-adapter-obsidian](https://www.npmjs.com/package/@reneza/ats-adapter-obsidian)
79+
- [@reneza/ats-adapter-notion](https://www.npmjs.com/package/@reneza/ats-adapter-notion)
80+
- [@reneza/ats-adapter-github](https://www.npmjs.com/package/@reneza/ats-adapter-github)
81+
- [@reneza/ats-adapter-airtable](https://www.npmjs.com/package/@reneza/ats-adapter-airtable)
82+
- [@reneza/ats-adapter-google](https://www.npmjs.com/package/@reneza/ats-adapter-google)
83+
- [@reneza/ats-adapter-okf](https://www.npmjs.com/package/@reneza/ats-adapter-okf)
84+
- [@reneza/ats-adapter-taskmaster](https://www.npmjs.com/package/@reneza/ats-adapter-taskmaster)
85+
- [@reneza/ats-adapter-beads](https://www.npmjs.com/package/@reneza/ats-adapter-beads)
86+
87+
Main repo: [agentic-task-system](https://github.com/renezander030/agentic-task-system).
88+
89+
## Verify
90+
91+
```sh
92+
node --test # offline unit tests (fake child adapters)
93+
```

packages/adapter-composite/api.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* Helpers for @reneza/ats-adapter-composite — the cross-source adapter.
3+
*
4+
* The composite wraps N child ATS adapters and presents them as one corpus, so
5+
* `ats find` (which builds its corpus from a single adapter's bulkFetch) actually
6+
* fuses results across GitHub + Notion + TickTick + ... in one ranked list. Each
7+
* child keeps its own config/auth; the composite only namespaces ids and routes.
8+
*
9+
* Project ids are namespaced `<backendKey>:<childProjectId>` (e.g. `github:owner/repo`,
10+
* `notion:db-1111`). Every returned Task is tagged with `source: <backendKey>` so
11+
* the originating backend is visible in results and deep links.
12+
*
13+
* Zero runtime deps.
14+
*/
15+
16+
import fs from 'node:fs';
17+
import os from 'node:os';
18+
import path from 'node:path';
19+
20+
export function configPath() {
21+
return process.env.ATS_COMPOSITE_CONFIG || path.join(os.homedir(), '.config', 'ats', 'composite.json');
22+
}
23+
24+
/**
25+
* Config: a list of child adapters. Either env `ATS_COMPOSITE_ADAPTERS`
26+
* (comma-separated package names/paths) or ~/.config/ats/composite.json:
27+
* { "adapters": ["@reneza/ats-adapter-github", "@reneza/ats-adapter-notion"] }
28+
* or with explicit keys:
29+
* { "adapters": [{ "package": "@reneza/ats-adapter-github", "key": "github" }] }
30+
*/
31+
export function loadConfig() {
32+
let file = {};
33+
try {
34+
const p = configPath();
35+
if (fs.existsSync(p)) file = JSON.parse(fs.readFileSync(p, 'utf8')) || {};
36+
} catch {
37+
file = {};
38+
}
39+
const env = process.env.ATS_COMPOSITE_ADAPTERS;
40+
const raw = env ? env.split(',') : Array.isArray(file.adapters) ? file.adapters : [];
41+
const specs = raw
42+
.map((entry) => {
43+
if (typeof entry === 'string') {
44+
const pkg = entry.trim();
45+
return pkg ? { package: pkg, key: backendKeyFor(pkg) } : null;
46+
}
47+
if (entry && entry.package) return { package: String(entry.package).trim(), key: entry.key || backendKeyFor(entry.package) };
48+
return null;
49+
})
50+
.filter(Boolean);
51+
return { specs };
52+
}
53+
54+
/** Derive a short backend key from a package name or path: `@reneza/ats-adapter-github` -> `github`. */
55+
export function backendKeyFor(spec) {
56+
return String(spec)
57+
.replace(/\/+$/, '')
58+
.split('/')
59+
.pop()
60+
.replace(/^@[^/]+\//, '')
61+
.replace(/^ats-adapter-/, '')
62+
.replace(/[^a-z0-9]+/gi, '-')
63+
.replace(/^-+|-+$/g, '')
64+
.toLowerCase() || 'child';
65+
}
66+
67+
/** Dynamically import each configured child adapter. Failures are skipped, not fatal. */
68+
export async function loadChildren(cfg = loadConfig()) {
69+
const children = [];
70+
const seen = new Set();
71+
for (const spec of cfg.specs) {
72+
let key = spec.key;
73+
while (seen.has(key)) key = `${key}_`; // de-dupe keys
74+
seen.add(key);
75+
try {
76+
const mod = await import(spec.package);
77+
const adapter = mod.default || mod.adapter || mod;
78+
if (adapter && typeof adapter.listProjects === 'function') {
79+
children.push({ key, package: spec.package, adapter });
80+
}
81+
} catch {
82+
// child not installed / failed to import — skip it
83+
}
84+
}
85+
return children;
86+
}
87+
88+
const SEP = ':';
89+
90+
export function makeProjectId(key, childProjectId) {
91+
return `${key}${SEP}${childProjectId}`;
92+
}
93+
94+
/** Split a namespaced project id back into { key, childProjectId }. */
95+
export function splitProjectId(projectId) {
96+
const s = String(projectId);
97+
const i = s.indexOf(SEP);
98+
if (i < 0) return { key: '', childProjectId: s };
99+
return { key: s.slice(0, i), childProjectId: s.slice(i + 1) };
100+
}
101+
102+
/** Re-stamp a child's Task so its projectId is namespaced and its backend is tagged. */
103+
export function remapTask(key, task) {
104+
if (!task) return task;
105+
return {
106+
...task,
107+
projectId: makeProjectId(key, task.projectId),
108+
source: key,
109+
};
110+
}
111+
112+
export function remapProject(key, project) {
113+
return {
114+
id: makeProjectId(key, project.id),
115+
name: `[${key}] ${project.name}`,
116+
kind: project.kind,
117+
raw: { backend: key, ...(project.raw || {}) },
118+
};
119+
}
120+
121+
/** Find the child whose key matches a namespaced project id. */
122+
export function childForProject(children, projectId) {
123+
const { key, childProjectId } = splitProjectId(projectId);
124+
const child = children.find((c) => c.key === key);
125+
return { child, childProjectId, key };
126+
}

0 commit comments

Comments
 (0)