@inixiative/archive 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ archive list
33
33
  archive search --query 'migration'
34
34
  ```
35
35
 
36
- Use `--source claude-code` for Claude histories. Collection matches the exact working-directory metadata recorded by the provider. Repeat `--project-root` to map several checkouts to one project; `--worktrees` also accepts every current git worktree of each root, re-read on each scan. Nested checkouts are separate mappings. Unknown directories, symlinks and sessions without usable metadata are skipped. Incomplete or changing files remain at source and retry on the next scan. The collector rescans files every 30 seconds; it deduplicates normalized content rather than maintaining byte-offset ingestion cursors. A watching collector waits out a server that is still starting.
36
+ Use `--source claude-code` for Claude histories. Collection matches the exact working-directory metadata recorded by the provider. Repeat `--project-root` to map several checkouts to one project; `--worktrees` also accepts every current git worktree of each root, re-read on each scan. Nested checkouts are separate mappings unless `--subdirectories` lets a root also take the directories below it; the longest matching root wins. `--catch-all` (no roots) takes every session no other collector of that source claims, as projectId `unrouted`; no destination may take `unrouted`, so those sessions never leave the machine. Subdirectory and catch-all matches skip the exclusions: temp directories (test fixtures, agent scratchpads) and `.foundry/decision-receipts` by default, plus each `--exclude` (absolute paths match below them, relative ones match those path segments anywhere); an exact root is explicit and still matches. Unknown directories, symlinks and sessions without usable metadata are skipped. Incomplete or changing files remain at source and retry on the next scan. The collector rescans every 30 seconds. A session whose digest the server already holds is not sent again, and a watching collector does not re-read a file whose size and mtime have not moved since its last outcome (it re-checks a stored session against the server every 10 minutes, so one deleted there is captured again). A session is captured into one project; a collector refuses one another project holds, except one the catch-all holds as `unrouted`, which moves to the project that now claims it. Collectors compete only with collectors over the same history directory. A watching collector waits out a server that is still starting. `serve --sync` backs off failed deliveries: 30 seconds after the first failure, doubling to at most 30 minutes, cleared on delivery. An archive a destination refuses waits alone, and is tried again at once when it has a new revision; a destination that does not answer (or answers 5xx) is skipped for all its archives until it is due.
37
37
 
38
38
  To run the server without Docker, point `DATABASE_URL` at a Postgres with the `vector` and `pg_trgm` extensions available and run `archive serve [--port 4700] [--sync]`. Set `ARCHIVE_DEBUG=1` to see the underlying error when a command fails.
39
39
 
@@ -62,7 +62,7 @@ archive agents install
62
62
  archive agents status
63
63
  ```
64
64
 
65
- `add-collector` upserts by `--name` (lowercase letters, digits, `.` and `-`); `--directory` defaults to `~/.claude/projects` or `~/.codex/sessions`, and `--project-root`, `--worktrees` and `--atlas` behave as for `collect`. `remove-collector --name N` and `server --url URL` edit the same file. `install` writes one unit per collector, `com.inixiative.archive.collect.<name>`. Each runs the current Bun with this package's own `src/cli.ts`, restarts on exit (30 second throttle), works in the archive home and logs to `logs/<label>.{out,err}.log` there. Re-running `install` reloads only changed or stopped units and removes Archive units no longer declared (including the retired `local` and `sync` units); other launchd agents are untouched. `uninstall` removes every Archive unit; `status` reports each unit's load state, PID and last log entry. Re-run `install` after upgrading the package if its install path changes.
65
+ `add-collector` upserts by `--name` (lowercase letters, digits, `.` and `-`); `--directory` defaults to `~/.claude/projects` or `~/.codex/sessions`, and `--project-root`, `--worktrees`, `--subdirectories`, `--catch-all` and `--atlas` behave as for `collect`. `remove-collector --name N`, `exclude [--exclude PATH ...]` (replaces the extra exclusions) and `server --url URL` edit the same file. `install` writes one unit per collector, `com.inixiative.archive.collect.<name>`, running `collect --agent <name> --watch`: the unit re-reads `agents.json` on every scan and routes each session against every collector of its source, so edits to roots and exclusions apply without a reinstall. Each runs the current Bun with this package's own `src/cli.ts`, restarts on exit (30 second throttle), works in the archive home and logs to `logs/<label>.{out,err}.log` there. Re-running `install` reloads only changed or stopped units and removes Archive units no longer declared. Units are written and loaded one at a time; a unit that fails to load gets its previous file back, so the next `install` loads it again, and `install` reports every failure after trying the rest (including the retired `local` and `sync` units); other launchd agents are untouched. `uninstall` removes every Archive unit; `status` reports each unit's load state, PID and last log entry. Re-run `install` after upgrading the package if its install path changes.
66
66
 
67
67
  `@inixiative/archive/agents` exports the pure pieces for integrations: `agentsConfigSchema`, `agentUnits`, `renderPlist`, `renderSystemdUnit` and `planAgents`, plus `installAgents`, `uninstallAgents` and `agentStatus` with an injectable command runner and supervisor directory.
68
68
 
@@ -128,7 +128,7 @@ The archive owns its tag system. It keeps two different things apart:
128
128
  bun run archive tag --id ARCHIVE_ID --tag reviewed --untag debugging
129
129
  ```
130
130
 
131
- Tag edits apply to that archive without a new revision, survive re-capture, and do not sync. Offered tags are defined archive-wide or for one actor. Each session can name its **actor**: a `user`, a `service` such as an API-key run, or a `job` (its id is the job name), with the `integrationId` it ran under when there is one. An organization's archive mostly holds its members' work, filterable per person. Entries the model produced record its `model` and `effort` (from Claude Code assistant records and Codex turn context, per turn); listings summarize them per session as `models` and filter by `model` and `effort`.
131
+ Tag edits apply to that archive without a new revision, survive re-capture, and do not sync. Offered tags are defined archive-wide or for one actor. Each session can name its **actor**: a `user`, a `service` such as an API-key run, or a `job` (its id is the job name), with the `integrationId` it ran under when there is one. An organization's archive mostly holds its members' work, filterable per person. Entries the model produced record its `model` and `effort` (from Claude Code assistant records and Codex turn context, per turn); listings summarize them per session as `models` and filter by `model` and `effort`. Each session also records its `runtime`: `provider` (`anthropic` for Claude Code, Codex's `model_provider`) and `accessPath` (`subscription`, `api` or `unknown`), with `evidence` `transcript` when the transcript proves it (a subscription's rate-limit window: Claude Code `quotaLimits`, Codex `rate_limits.plan_type`) or `harness` when the collector read the harness login instead (Claude Code's `oauthAccount` unless its settings supply an API key; Codex `auth.json`). A subagent run is its own session with `lineage`: `kind` (`subagent` or `fork`), `parentSessionId`, and what the harness recorded (`agentType`, `agentName`, `description`, `toolUseId`, `depth`; Claude Code's `agent-<id>.meta.json`, Codex's subagent `session_meta`). Example: `models: [{model: 'claude-opus-5-5', effort: 'medium'}]`, `runtime: {provider: 'anthropic', accessPath: 'subscription', evidence: 'harness'}`.
132
132
 
133
133
  The **retention** setting (`retentionDays`, off by default) deletes archives whose latest capture is older than that, at server start and hourly. It deletes only from the archive it is set on; deletions never sync.
134
134
 
@@ -148,7 +148,7 @@ All data endpoints are `POST /api/v1/archive/<action>` with a JSON body and `Aut
148
148
  | `tags/list`, `tags/define`, `tags/remove` | `tag`, `actorId?`, `description?` | offered tags with usage counts |
149
149
  | `destinations/list`, `destinations/libraries`, `destinations/connect`, `destinations/remove`, `destinations/routes`, `destinations/sync` | see [Routing](#routing) | routes |
150
150
 
151
- Filters are `projectId`, `source`, `tag`, `actorId`, `model`, `effort` and `reference` (`{integration, ref}`). Jev integration is not enabled. Session content must not be sent to Jev without selecting that service for the relevant ownership boundary.
151
+ Filters are `projectId`, `source`, `tag`, `actorId`, `model`, `effort`, `provider`, `accessPath` and `reference` (`{integration, ref}`); `archive list` and `archive search` take them as flags. Jev integration is not enabled. Session content must not be sent to Jev without selecting that service for the relevant ownership boundary.
152
152
 
153
153
  ## Limits
154
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/archive",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -0,0 +1,10 @@
1
+ -- AlterTable
2
+ ALTER TABLE "archives" ADD COLUMN "accessPath" TEXT,
3
+ ADD COLUMN "provider" TEXT;
4
+
5
+ -- CreateIndex
6
+ CREATE INDEX "archives_provider_idx" ON "archives"("provider");
7
+
8
+ -- CreateIndex
9
+ CREATE INDEX "archives_accessPath_idx" ON "archives"("accessPath");
10
+
@@ -28,6 +28,9 @@ model Archive {
28
28
  source String
29
29
  projectId String?
30
30
  actorId String?
31
+ /// The model provider (anthropic, openai) and the access path (subscription, api, unknown).
32
+ provider String?
33
+ accessPath String?
31
34
  capturedAt Float
32
35
  summary Json
33
36
 
@@ -38,6 +41,8 @@ model Archive {
38
41
 
39
42
  @@index([projectId])
40
43
  @@index([actorId])
44
+ @@index([provider])
45
+ @@index([accessPath])
41
46
  @@map("archives")
42
47
  }
43
48
 
package/src/agents.ts CHANGED
@@ -17,6 +17,7 @@ import { homedir } from 'node:os';
17
17
  import { dirname, isAbsolute, join, resolve } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { z } from 'zod';
20
+ import { type CollectionSource, defaultExclusions, UNROUTED } from './collector';
20
21
 
21
22
  export const defaultHome = () => join(homedir(), '.local/share/archive');
22
23
  export const AGENT_LABEL_PREFIX = 'com.inixiative.archive.';
@@ -25,22 +26,40 @@ export const defaultHistoryDirectory = (source: 'claude-code' | 'codex') =>
25
26
  join(homedir(), source === 'codex' ? '.codex/sessions' : '.claude/projects');
26
27
 
27
28
  const absolute = z.string().max(4096).refine(isAbsolute, 'Path must be absolute');
28
- export const collectorAgentSchema = z.strictObject({
29
- name: z
30
- .string()
31
- .max(64)
32
- .regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/),
33
- source: z.enum(['claude-code', 'codex']),
34
- directory: absolute,
35
- projectId: z.string().min(1).max(256),
36
- projectRoots: z.array(absolute).min(1).max(100),
37
- worktrees: z.boolean().optional(),
38
- atlas: z.boolean().optional(),
39
- });
29
+ export const collectorAgentSchema = z
30
+ .strictObject({
31
+ name: z
32
+ .string()
33
+ .max(64)
34
+ .regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/),
35
+ source: z.enum(['claude-code', 'codex']),
36
+ directory: absolute,
37
+ projectId: z.string().min(1).max(256),
38
+ projectRoots: z.array(absolute).max(100),
39
+ worktrees: z.boolean().optional(),
40
+ subdirectories: z.boolean().optional(),
41
+ /** Every session no other collector of this source claims; projectId `unrouted`, never synced. */
42
+ catchAll: z.boolean().optional(),
43
+ atlas: z.boolean().optional(),
44
+ })
45
+ .superRefine((c, ctx) => {
46
+ if (c.catchAll && (c.projectId !== UNROUTED || c.projectRoots.length))
47
+ ctx.addIssue({
48
+ code: 'custom',
49
+ message: `A catch-all collector has projectId ${UNROUTED} and no roots`,
50
+ });
51
+ if (!c.catchAll && (c.projectId === UNROUTED || !c.projectRoots.length))
52
+ ctx.addIssue({
53
+ code: 'custom',
54
+ message: `A collector needs a root and a projectId other than ${UNROUTED}`,
55
+ });
56
+ });
40
57
  export const agentsConfigSchema = z
41
58
  .strictObject({
42
59
  /** The Archive server collectors write to; the token is the home's `server.token`. */
43
60
  server: z.strictObject({ url: z.url() }).optional(),
61
+ /** Added to the default exclusions (temp directories, Foundry decision receipts). */
62
+ exclude: z.array(z.string().min(1).max(4096)).max(100).optional(),
44
63
  collectors: z.array(collectorAgentSchema).max(100),
45
64
  })
46
65
  .refine(
@@ -82,21 +101,36 @@ export interface AgentUnitOptions {
82
101
  }
83
102
  export const packageCliPath = () => fileURLToPath(new URL('./cli.ts', import.meta.url));
84
103
 
85
- export function collectorArgs(c: CollectorAgent, server?: { url: string }) {
86
- return [
87
- 'collect',
88
- ...(server ? ['--url', server.url] : []),
89
- '--directory',
90
- c.directory,
91
- '--source',
92
- c.source,
93
- ...c.projectRoots.flatMap((root) => ['--project-root', root]),
94
- ...(c.worktrees ? ['--worktrees'] : []),
95
- ...(c.atlas ? ['--atlas'] : []),
96
- '--project-id',
97
- c.projectId,
98
- '--watch',
99
- ];
104
+ /** A unit runs one named collector; it re-reads agents.json each scan, so edits apply live. */
105
+ export function collectorArgs(c: CollectorAgent) {
106
+ return ['collect', '--agent', c.name, '--watch'];
107
+ }
108
+
109
+ /** A named collector, the peers it is routed against, its exclusions and server, from agents.json. */
110
+ export function collectorPlan(config: AgentsConfig, name: string) {
111
+ const collector = config.collectors.find((c) => c.name === name);
112
+ if (!collector) throw new Error(`No collector named ${name} in agents.json`);
113
+ const source = (c: CollectorAgent): CollectionSource => ({
114
+ directory: c.directory,
115
+ source: c.source,
116
+ projectRoots: c.projectRoots,
117
+ projectId: c.projectId,
118
+ ...(c.worktrees ? { worktrees: true } : {}),
119
+ ...(c.subdirectories ? { subdirectories: true } : {}),
120
+ ...(c.catchAll ? { catchAll: true } : {}),
121
+ ...(c.atlas ? { atlas: true } : {}),
122
+ });
123
+ // Collectors over the same history compete for its sessions; another directory is another history.
124
+ const named = config.collectors.filter(
125
+ (c) => c.source === collector.source && resolve(c.directory) === resolve(collector.directory),
126
+ );
127
+ const peers = named.map(source);
128
+ return {
129
+ collector: peers[named.indexOf(collector)]!,
130
+ peers,
131
+ exclude: [...defaultExclusions(), ...(config.exclude ?? [])],
132
+ server: config.server,
133
+ };
100
134
  }
101
135
 
102
136
  /** One supervised unit per declared agent. */
@@ -114,7 +148,7 @@ export function agentUnits(config: AgentsConfig, options: AgentUnitOptions): Age
114
148
  stderr: join(home, 'logs', `${label}.err.log`),
115
149
  };
116
150
  };
117
- return config.collectors.map((c) => unit(`collect.${c.name}`, collectorArgs(c, config.server)));
151
+ return config.collectors.map((c) => unit(`collect.${c.name}`, collectorArgs(c)));
118
152
  }
119
153
 
120
154
  const xml = (value: string) =>
@@ -310,23 +344,49 @@ export function installAgents(config: AgentsConfig, options: AgentHostOptions =
310
344
  }));
311
345
  const plan = planAgents(units, installed, h.platform, h.dir);
312
346
  h.remove(plan.remove);
313
- for (const { file, content } of plan.write) {
347
+ const previous = new Map(installed.map((i) => [i.label, i.content]));
348
+ const write = (file: string, content: string) => {
314
349
  const temp = `${file}.${randomUUID()}.tmp`;
315
350
  writeFileSync(temp, content, { mode: 0o644, flag: 'wx' });
316
351
  renameSync(temp, file);
317
- }
318
- if (h.platform === 'linux' && plan.write.length) h.systemctl('daemon-reload');
352
+ };
353
+ const loaded: string[] = [];
354
+ const failed: { label: string; error: string }[] = [];
355
+ // Write and load one unit at a time. A unit that fails to load gets its old file back (or none),
356
+ // so the next install sees it as changed and loads it, instead of trusting a stale running copy.
357
+ const install = (label: string, content?: string) => {
358
+ try {
359
+ if (content !== undefined) {
360
+ write(h.file(label), content);
361
+ if (h.platform === 'linux') h.systemctl('daemon-reload');
362
+ }
363
+ h.load(label);
364
+ loaded.push(label);
365
+ } catch (error) {
366
+ failed.push({ label, error: error instanceof Error ? error.message : String(error) });
367
+ if (content === undefined) return;
368
+ const old = previous.get(label);
369
+ try {
370
+ if (old === undefined) rmSync(h.file(label), { force: true });
371
+ else write(h.file(label), old);
372
+ if (h.platform === 'linux') h.systemctl('daemon-reload');
373
+ // Loading stopped the unit that ran before; bring it back as it was.
374
+ if (old !== undefined) h.load(label);
375
+ } catch {}
376
+ }
377
+ };
378
+ for (const { label, content } of plan.write) install(label, content);
319
379
  // Unchanged units that are not running are loaded again.
320
- const reload = [
321
- ...plan.write.map((w) => w.label),
322
- ...plan.unchanged.filter((label) => !h.loaded(label).loaded),
323
- ];
324
- for (const label of reload) h.load(label);
380
+ for (const label of plan.unchanged) if (!h.loaded(label).loaded) install(label);
381
+ if (failed.length)
382
+ throw new Error(
383
+ `Archive agents failed to load: ${failed.map((f) => `${f.label} (${f.error})`).join('; ')}`,
384
+ );
325
385
  return {
326
386
  platform: h.platform,
327
387
  dir: h.dir,
328
- loaded: reload,
329
- unchanged: plan.unchanged.filter((l) => !reload.includes(l)),
388
+ loaded,
389
+ unchanged: plan.unchanged.filter((l) => !loaded.includes(l)),
330
390
  removed: plan.remove,
331
391
  };
332
392
  }
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { parseArgs } from 'node:util';
8
8
  import {
9
9
  agentStatus,
10
+ collectorPlan,
10
11
  defaultHistoryDirectory,
11
12
  defaultHome,
12
13
  installAgents,
@@ -16,8 +17,8 @@ import {
16
17
  writeAgents,
17
18
  } from './agents';
18
19
  import { importChatGPTThread } from './chatgpt';
19
- import { archiveRequest, searchRemotes, syncArchives } from './client';
20
- import { collectSessions } from './collector';
20
+ import { archiveRequest, type SyncBackoff, searchRemotes, syncArchives } from './client';
21
+ import { type CollectorCache, collectSessions, defaultExclusions, UNROUTED } from './collector';
21
22
  import {
22
23
  archiveDestinationSchema,
23
24
  connectDestination,
@@ -66,6 +67,14 @@ export async function runCli(args = Bun.argv.slice(2)) {
66
67
  query: { type: 'string', default: '' },
67
68
  'project-root': { type: 'string', multiple: true },
68
69
  worktrees: { type: 'boolean', default: false },
70
+ subdirectories: { type: 'boolean', default: false },
71
+ 'catch-all': { type: 'boolean', default: false },
72
+ exclude: { type: 'string', multiple: true },
73
+ agent: { type: 'string' },
74
+ provider: { type: 'string' },
75
+ model: { type: 'string' },
76
+ effort: { type: 'string' },
77
+ 'access-path': { type: 'string' },
69
78
  atlas: { type: 'boolean', default: false },
70
79
  tag: { type: 'string', multiple: true },
71
80
  untag: { type: 'string', multiple: true },
@@ -91,14 +100,16 @@ export async function runCli(args = Bun.argv.slice(2)) {
91
100
  'up | down (the local Archive: bundled compose.yaml with Postgres, data in --home)\n' +
92
101
  'serve [--port 4700 --hostname 127.0.0.1] [--sync] (DATABASE_URL; token from ARCHIVE_SERVER_TOKEN or --token-file)\n' +
93
102
  'import --file PATH --source codex|claude-code|chatgpt --project-id ID [--tag TAG]\n' +
94
- 'collect --directory HISTORY --source codex|claude-code --project-root EXACT_CWD [--project-root ...] [--worktrees] [--atlas] --project-id ID [--watch]\n' +
95
- 'list | export --id ID | tag --id ID [--tag TAG ...] [--untag TAG ...] | search --query TEXT [--remote]\n' +
103
+ 'collect --directory HISTORY --source codex|claude-code (--project-root CWD [--project-root ...] --project-id ID [--worktrees] [--subdirectories] | --catch-all) [--exclude PATH ...] [--atlas] [--watch]\n' +
104
+ 'collect --agent NAME [--watch] (one collector from agents.json, routed against the others, re-read each scan)\n' +
105
+ 'list | search --query TEXT [--remote] [--project-id ID] [--source S] [--tag T] [--provider P] [--model M] [--effort E] [--access-path subscription|api|unknown]\n' +
106
+ 'export --id ID | tag --id ID [--tag TAG ...] [--untag TAG ...]\n' +
96
107
  'pair --kingdom KINGDOM_API_URL [--name NAME] [--yes] (register this Archive with Kingdom; claim the review code there, then confirm the owner)\n' +
97
108
  'connect --url HTTPS_URL --project-id ID (--token-env ENV | --token-file PATH) (a remote Archive, for serve --sync)\n' +
98
109
  'connect --kind kingdom --url KINGDOM_API_URL --credential-file PATH --integration-id UUID --resource-id UUID --project-id ID (a hosted Archive through Kingdom)\n' +
99
110
  'sync | routes (the server publishes to, or previews, its destinations once)\n' +
100
- 'agents add-collector --name N --source codex|claude-code --project-id ID --project-root DIR [--project-root ...] [--directory HISTORY] [--worktrees] [--atlas]\n' +
101
- 'agents remove-collector --name N | server --url URL | install | uninstall | status\n' +
111
+ 'agents add-collector --name N --source codex|claude-code (--project-id ID --project-root DIR [--project-root ...] [--worktrees] [--subdirectories] | --catch-all) [--directory HISTORY] [--atlas]\n' +
112
+ 'agents remove-collector --name N | exclude [--exclude PATH ...] | server --url URL | install | uninstall | status\n' +
102
113
  `Data commands talk to the Archive server at --url (default ${DEFAULT_URL}) with the token in --token-file (default <home>/server.token) or ARCHIVE_TOKEN.`,
103
114
  );
104
115
  return;
@@ -170,10 +181,12 @@ export async function runCli(args = Bun.argv.slice(2)) {
170
181
  output({ listening: instance.server.url.href, sync: v.sync });
171
182
  let stopped = false;
172
183
  // One process owns the database connection: the server and its sync share it.
184
+ // Failed deliveries back off across cycles instead of being re-sent every 30 seconds.
185
+ const backoff: SyncBackoff = new Map();
173
186
  const sync = (async () => {
174
187
  while (v.sync && !stopped) {
175
188
  try {
176
- const results = await syncArchives(instance.store, readDestinations(config));
189
+ const results = await syncArchives(instance.store, readDestinations(config), { backoff });
177
190
  const failed = results.filter((r) => r.status.startsWith('failed'));
178
191
  if (failed.length) console.error(JSON.stringify({ sync: 'failed', archives: failed }));
179
192
  } catch {
@@ -258,8 +271,11 @@ export async function runCli(args = Bun.argv.slice(2)) {
258
271
  const action = positionals[1];
259
272
  const base = { collectors: [], ...current };
260
273
  if (action === 'add-collector') {
261
- if (!v.name || !v['project-id'] || !v['project-root'] || !v.source)
262
- throw new Error('add-collector requires --name, --source, --project-id and --project-root');
274
+ const catchAll = v['catch-all'];
275
+ if (!v.name || !v.source || (!catchAll && (!v['project-id'] || !v['project-root'])))
276
+ throw new Error(
277
+ 'add-collector requires --name, --source, and --project-id with --project-root (or --catch-all)',
278
+ );
263
279
  const source = v.source as 'codex' | 'claude-code';
264
280
  output(
265
281
  writeAgents(
@@ -268,13 +284,18 @@ export async function runCli(args = Bun.argv.slice(2)) {
268
284
  name: v.name,
269
285
  source,
270
286
  directory: resolve(v.directory ?? defaultHistoryDirectory(source)),
271
- projectId: v['project-id'],
272
- projectRoots: v['project-root'].map((root) => resolve(root)),
287
+ projectId: catchAll ? UNROUTED : v['project-id']!,
288
+ projectRoots: catchAll ? [] : v['project-root']!.map((root) => resolve(root)),
273
289
  ...(v.worktrees ? { worktrees: true } : {}),
290
+ ...(v.subdirectories ? { subdirectories: true } : {}),
291
+ ...(catchAll ? { catchAll: true } : {}),
274
292
  ...(v.atlas ? { atlas: true } : {}),
275
293
  }),
276
294
  ),
277
295
  );
296
+ } else if (action === 'exclude') {
297
+ const { exclude: _, ...rest } = base;
298
+ output(writeAgents(file, { ...rest, ...(v.exclude?.length ? { exclude: v.exclude } : {}) }));
278
299
  } else if (action === 'remove-collector') {
279
300
  if (!v.name) throw new Error('remove-collector requires --name');
280
301
  output(
@@ -313,7 +334,21 @@ export async function runCli(args = Bun.argv.slice(2)) {
313
334
  }
314
335
  const token = process.env.ARCHIVE_TOKEN ?? readToken();
315
336
  if (!token) throw new Error('No Archive token: run `archive init` or set ARCHIVE_TOKEN');
337
+ const agentsFile = join(home, 'agents.json');
316
338
  const archive = new ArchiveClient({ url: v.url ?? DEFAULT_URL, token });
339
+ if ((command === 'list' || command === 'search') && (v.tag?.length ?? 0) > 1)
340
+ throw new Error('Filter by one --tag at a time');
341
+ const filter = {
342
+ ...(v['project-id'] ? { projectId: v['project-id'] } : {}),
343
+ ...(v.source ? { source: v.source as 'claude-code' | 'codex' | 'foundry' | 'chatgpt' } : {}),
344
+ ...(v.tag?.length ? { tag: v.tag[0] } : {}),
345
+ ...(v.provider ? { provider: v.provider } : {}),
346
+ ...(v.model ? { model: v.model } : {}),
347
+ ...(v.effort ? { effort: v.effort } : {}),
348
+ ...(v['access-path']
349
+ ? { accessPath: v['access-path'] as 'subscription' | 'api' | 'unknown' }
350
+ : {}),
351
+ };
317
352
  if (command === 'routes') output(await archive.routes());
318
353
  else if (command === 'sync') {
319
354
  const results = await archive.sync();
@@ -349,15 +384,48 @@ export async function runCli(args = Bun.argv.slice(2)) {
349
384
  entries: snapshot.entries.length,
350
385
  coverage: snapshot.coverage,
351
386
  });
352
- } else if (command === 'list') output(await archive.list());
387
+ } else if (command === 'list') output(await archive.list(filter));
353
388
  else if (command === 'collect') {
354
- if (
355
- !v.directory ||
356
- !v['project-root'] ||
357
- !v['project-id'] ||
358
- !['codex', 'claude-code'].includes(v.source ?? '')
359
- )
360
- throw new Error('Collect requires --directory, --source, --project-root and --project-id');
389
+ const flagged = () => {
390
+ const catchAll = v['catch-all'];
391
+ if (
392
+ !v.directory ||
393
+ !['codex', 'claude-code'].includes(v.source ?? '') ||
394
+ (!catchAll && (!v['project-root'] || !v['project-id']))
395
+ )
396
+ throw new Error(
397
+ 'Collect requires --agent, or --directory, --source and --project-root with --project-id (or --catch-all)',
398
+ );
399
+ const collector = {
400
+ directory: v.directory,
401
+ source: v.source as 'codex' | 'claude-code',
402
+ projectRoots: catchAll ? [] : v['project-root']!,
403
+ projectId: catchAll ? UNROUTED : v['project-id']!,
404
+ worktrees: v.worktrees,
405
+ subdirectories: v.subdirectories,
406
+ catchAll,
407
+ atlas: v.atlas,
408
+ tags: v.tag,
409
+ };
410
+ return {
411
+ collector,
412
+ peers: [collector],
413
+ exclude: [...defaultExclusions(), ...(v.exclude ?? [])],
414
+ server: undefined as { url: string } | undefined,
415
+ };
416
+ };
417
+ // A named collector re-reads agents.json each scan, so its routing follows edits without a restart.
418
+ const plan = () => {
419
+ if (!v.agent) return flagged();
420
+ const config = readAgents(agentsFile);
421
+ if (!config) throw new Error('No agents.json; declare agents first');
422
+ return collectorPlan(config, v.agent);
423
+ };
424
+ plan();
425
+ const cache: CollectorCache = new Map();
426
+ // A named collector writes to the server agents.json declares (unless --url), re-read each scan.
427
+ let client = archive;
428
+ let clientUrl = v.url ?? DEFAULT_URL;
361
429
  let stopped = false;
362
430
  const stop = () => {
363
431
  stopped = true;
@@ -365,17 +433,27 @@ export async function runCli(args = Bun.argv.slice(2)) {
365
433
  process.once('SIGINT', stop);
366
434
  process.once('SIGTERM', stop);
367
435
  do {
436
+ let current: ReturnType<typeof plan>;
437
+ try {
438
+ current = plan();
439
+ } catch (error) {
440
+ if (!v.watch) throw error;
441
+ console.error(
442
+ JSON.stringify({ collect: 'agents.json unusable', error: (error as Error).message }),
443
+ );
444
+ for (let i = 0; !stopped && i < 30; i++) await Bun.sleep(1000);
445
+ continue;
446
+ }
368
447
  try {
369
- const result = await collectSessions(archive, {
370
- directory: v.directory,
371
- source: v.source as 'codex' | 'claude-code',
372
- projectRoots: v['project-root'],
373
- worktrees: v.worktrees,
374
- atlas: v.atlas,
375
- projectId: v['project-id'],
376
- tags: v.tag,
377
- });
378
- output(result);
448
+ const { collector, peers, exclude, server } = current;
449
+ const url = v.url ?? server?.url ?? DEFAULT_URL;
450
+ if (url !== clientUrl) {
451
+ client = new ArchiveClient({ url, token });
452
+ clientUrl = url;
453
+ }
454
+ const started = Date.now();
455
+ const result = await collectSessions(client, collector, { peers, exclude, cache });
456
+ output({ ...result, ms: Date.now() - started });
379
457
  if (!v.watch && result.failed) process.exitCode = 1;
380
458
  } catch (error) {
381
459
  // The server may be starting; a watching collector retries.
@@ -390,6 +468,7 @@ export async function runCli(args = Bun.argv.slice(2)) {
390
468
  } else if (command === 'search')
391
469
  output(
392
470
  await archive.search({
471
+ ...filter,
393
472
  query: v.query,
394
473
  limit: Number(v.limit) > 100 ? 100 : Number(v.limit),
395
474
  }),
package/src/client.ts CHANGED
@@ -126,28 +126,81 @@ export async function routingPreview(
126
126
  }));
127
127
  }
128
128
 
129
+ /**
130
+ * Deliveries that failed, and when each may be tried again: per archive and destination, and per
131
+ * destination while it is unreachable. A retry waits 30 seconds, doubling per failure up to 30
132
+ * minutes; a new revision of the archive is tried at once.
133
+ */
134
+ export type SyncBackoff = Map<string, { failures: number; until: number; digest?: string }>;
135
+ const BACKOFF_BASE_MS = 30_000;
136
+ const BACKOFF_MAX_MS = 30 * 60_000;
137
+ /** No HTTP answer, or a 5xx: the destination is down, not refusing this archive. */
138
+ const outage = (message: string) => {
139
+ const status = Number(/\((\d{3})\)/.exec(message)?.[1]);
140
+ return !status || status >= 500;
141
+ };
142
+
129
143
  export async function syncArchives(
130
144
  store: ArchiveStore,
131
145
  destinations: ArchiveDestination[],
146
+ options: { backoff?: SyncBackoff; now?: () => number } = {},
132
147
  ): Promise<SyncResult[]> {
148
+ const now = options.now ?? Date.now;
149
+ const backoff = options.backoff;
133
150
  const results: SyncResult[] = [];
134
- for (const archive of await store.list())
151
+ const seen = new Set<string>();
152
+ const delay = (failures: number) =>
153
+ Math.min(BACKOFF_BASE_MS * 2 ** (failures - 1), BACKOFF_MAX_MS);
154
+ const archives = await store.list();
155
+ for (const archive of archives)
135
156
  for (const destination of destinations.filter((d) => d.projectId === archive.projectId)) {
157
+ const identity = destinationIdentity(destination);
158
+ const key = JSON.stringify([archive.id, identity]);
159
+ const down = JSON.stringify(['destination', identity]);
160
+ seen.add(key).add(down);
161
+ const deferred = (until: number) =>
162
+ results.push({
163
+ id: archive.id,
164
+ destination: destination.url,
165
+ status: `deferred until ${new Date(until).toISOString()}`,
166
+ });
167
+ const circuit = backoff?.get(down);
168
+ if (circuit && now() < circuit.until) {
169
+ deferred(circuit.until);
170
+ continue;
171
+ }
172
+ const waiting = backoff?.get(key);
173
+ if (waiting && now() < waiting.until && waiting.digest === archive.digest) {
174
+ deferred(waiting.until);
175
+ continue;
176
+ }
136
177
  try {
137
178
  const result = await publishArchive(store, archive.id, destination);
179
+ backoff?.delete(key);
180
+ backoff?.delete(down);
138
181
  results.push({
139
182
  id: archive.id,
140
183
  destination: destination.url,
141
184
  status: result.unchanged ? 'unchanged' : 'published',
142
185
  });
143
186
  } catch (error) {
187
+ const message = error instanceof Error ? error.message : String(error);
188
+ if (outage(message)) {
189
+ const failures = (circuit?.failures ?? 0) + 1;
190
+ backoff?.set(down, { failures, until: now() + delay(failures) });
191
+ } else {
192
+ const failures = (waiting?.failures ?? 0) + 1;
193
+ backoff?.set(key, { failures, until: now() + delay(failures), digest: archive.digest });
194
+ }
144
195
  results.push({
145
196
  id: archive.id,
146
197
  destination: destination.url,
147
- status: `failed; local revision retained: ${error instanceof Error ? error.message : String(error)}`,
198
+ status: `failed; local revision retained: ${message}`,
148
199
  });
149
200
  }
150
201
  }
202
+ // Archives that were deleted, or routes that were removed, leave no entries behind.
203
+ for (const key of backoff?.keys() ?? []) if (!seen.has(key)) backoff?.delete(key);
151
204
  return results;
152
205
  }
153
206
 
package/src/collector.ts CHANGED
@@ -1,10 +1,22 @@
1
- import { closeSync, existsSync, lstatSync, openSync, readdirSync, readSync } from 'node:fs';
2
- import { isAbsolute, join, resolve } from 'node:path';
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ lstatSync,
5
+ openSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ readSync,
9
+ realpathSync,
10
+ } from 'node:fs';
11
+ import { homedir, tmpdir } from 'node:os';
12
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
3
13
  import { importTranscriptFile } from './import-file';
4
- import { archiveKey } from './index';
14
+ import { archiveKey, archiveSnapshotSchema, snapshotDigest, UNROUTED } from './index';
5
15
  import type { ArchiveClient } from './remote';
6
16
  import { type AtlasFileConcepts, atlasTags, provenanceReferences } from './tags';
7
17
 
18
+ export { UNROUTED } from './index';
19
+
8
20
  export interface CollectionSource {
9
21
  directory: string;
10
22
  source: 'codex' | 'claude-code';
@@ -12,12 +24,42 @@ export interface CollectionSource {
12
24
  projectId: string;
13
25
  /** Also match the git worktrees of each root; nested checkouts stay separate. */
14
26
  worktrees?: boolean;
27
+ /** Also match directories below each root, unless a more specific root claims them. */
28
+ subdirectories?: boolean;
29
+ /** Take every session no other collector of this source claims, as projectId `unrouted`. */
30
+ catchAll?: boolean;
15
31
  /** Tag sessions with the Atlas concepts of files they touched, from `atlas graph` in the session's checkout. */
16
32
  atlas?: boolean;
17
33
  tags?: string[];
18
34
  }
19
35
 
20
- function files(directory: string): string[] {
36
+ /**
37
+ * Where sessions are never swept in by a subdirectory or catch-all match: temp directories (test
38
+ * fixtures, agent scratchpads) and Foundry's decision receipts. An exact root is explicit and wins.
39
+ * Absolute entries match the directory and below; relative ones match those path segments anywhere.
40
+ */
41
+ export const defaultExclusions = () => [
42
+ ...new Set(
43
+ ['/tmp', '/private/tmp', '/var/folders', '/private/var/folders', tmpdir()].flatMap((path) => {
44
+ try {
45
+ return [resolve(path), realpathSync(path)];
46
+ } catch {
47
+ return [resolve(path)];
48
+ }
49
+ }),
50
+ ),
51
+ '.foundry/decision-receipts',
52
+ ];
53
+ export function excluded(cwd: string, exclusions: string[]) {
54
+ return exclusions.some((entry) =>
55
+ isAbsolute(entry)
56
+ ? cwd === resolve(entry) || cwd.startsWith(`${resolve(entry)}/`)
57
+ : `${cwd}/`.includes(`/${entry.replace(/^\/+|\/+$/g, '')}/`),
58
+ );
59
+ }
60
+
61
+ /** Every transcript (`.jsonl`) under a directory, in order, without following links. */
62
+ export function transcriptFiles(directory: string): string[] {
21
63
  const result: string[] = [];
22
64
  const visit = (path: string) => {
23
65
  const stat = lstatSync(path);
@@ -29,10 +71,10 @@ function files(directory: string): string[] {
29
71
  return result;
30
72
  }
31
73
 
32
- type SessionMetadata = { cwd: string; branch?: string; repository?: string };
74
+ export type SessionMetadata = { cwd: string; branch?: string; repository?: string };
33
75
 
34
- /** Match provider metadata, never text/tags/model suggestions. No implicit parent-directory routing. */
35
- function sessionMetadata(
76
+ /** Provider metadata, never text/tags/model suggestions; `routeSession` decides where it goes. */
77
+ export function sessionMetadata(
36
78
  file: string,
37
79
  source: CollectionSource['source'],
38
80
  ): SessionMetadata | undefined {
@@ -96,42 +138,204 @@ function worktreesOf(root: string): string[] {
96
138
 
97
139
  /** Exact directories this collection accepts, re-read each scan so new worktrees join. */
98
140
  export function collectionRoots(config: CollectionSource): Set<string> {
99
- const roots = config.projectRoots.map((root) => resolve(root));
141
+ // Providers record the real path (macOS /tmp is /private/tmp), so roots match by it too.
142
+ const roots = config.projectRoots.flatMap((root) => {
143
+ try {
144
+ return [...new Set([resolve(root), realpathSync(root)])];
145
+ } catch {
146
+ return [resolve(root)];
147
+ }
148
+ });
100
149
  return new Set(config.worktrees ? [...roots, ...roots.flatMap(worktreesOf)] : roots);
101
150
  }
102
151
 
152
+ /**
153
+ * The collector a session's cwd belongs to. The longest matching root wins: a root matches its own
154
+ * directory (and worktrees), and with `subdirectories` the directories below it. Ties go to the
155
+ * first declared. Excluded directories match only exact roots. Unclaimed sessions go to the first
156
+ * catch-all.
157
+ */
158
+ export function routeSession(
159
+ cwd: string,
160
+ collectors: CollectionSource[],
161
+ options: { exclude?: string[]; roots?: (collector: CollectionSource) => Set<string> } = {},
162
+ ) {
163
+ const rootsOf = options.roots ?? collectionRoots;
164
+ const broad = !excluded(cwd, options.exclude ?? defaultExclusions());
165
+ let best: { collector: CollectionSource; length: number } | undefined;
166
+ for (const collector of collectors) {
167
+ if (collector.catchAll) continue;
168
+ for (const root of rootsOf(collector)) {
169
+ const matches =
170
+ cwd === root || (broad && collector.subdirectories && cwd.startsWith(`${root}/`));
171
+ if (matches && (!best || root.length > best.length))
172
+ best = { collector, length: root.length };
173
+ }
174
+ }
175
+ if (best) return best.collector;
176
+ return broad ? collectors.find((c) => c.catchAll) : undefined;
177
+ }
178
+
103
179
  /** Where collected sessions go: an Archive server, or a store in-process. */
104
180
  export type ArchiveWriter = Pick<ArchiveClient, 'sourceId' | 'head' | 'capture'>;
105
181
 
106
- export async function collectSessions(archive: ArchiveWriter, config: CollectionSource) {
182
+ /**
183
+ * The access path the harness's login implies when a transcript is silent, read from the harness
184
+ * home that owns the history directory (`<claude config>/projects`, `<codex home>/sessions`):
185
+ * Claude Code's subscription login (`oauthAccount`) unless its settings supply an API key, and
186
+ * Codex's `auth.json` mode. Undefined when neither says; throws when a file exists but cannot be
187
+ * read, so a bad read never relabels sessions.
188
+ */
189
+ export function harnessAccess(
190
+ source: CollectionSource['source'],
191
+ directory: string,
192
+ ): 'subscription' | 'api' | undefined {
193
+ const json = (path: string) =>
194
+ existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : undefined;
195
+ const harnessHome = dirname(resolve(directory));
196
+ if (source === 'claude-code') {
197
+ const settings = json(join(harnessHome, 'settings.json'));
198
+ if (settings?.apiKeyHelper || settings?.env?.ANTHROPIC_API_KEY) return 'api';
199
+ // The default config directory keeps its login beside it, in ~/.claude.json.
200
+ const login =
201
+ harnessHome === join(homedir(), '.claude')
202
+ ? join(homedir(), '.claude.json')
203
+ : join(harnessHome, '.claude.json');
204
+ const account = json(login)?.oauthAccount;
205
+ return account && typeof account === 'object' ? 'subscription' : undefined;
206
+ }
207
+ const auth = json(join(harnessHome, 'auth.json'));
208
+ if (auth?.auth_mode === 'chatgpt' || auth?.tokens) return 'subscription';
209
+ if (auth?.auth_mode === 'apikey' || auth?.OPENAI_API_KEY) return 'api';
210
+ return undefined;
211
+ }
212
+
213
+ /**
214
+ * What a watching collector remembers between scans: each file's size and mtime with the outcome,
215
+ * so an untouched file is neither re-read nor re-sent. Keyed by the routing and archive it was
216
+ * decided under; a stored session is re-checked against the server now and then, so one deleted
217
+ * there is captured again.
218
+ */
219
+ export type CollectorCache = Map<
220
+ string,
221
+ {
222
+ size: number;
223
+ mtimeMs: number;
224
+ routing: string;
225
+ outcome: 'unchanged' | 'skipped' | 'failed';
226
+ meta?: SessionMetadata;
227
+ stored?: { id: string; digest: string; checkedAt: number };
228
+ }
229
+ >;
230
+ const REVALIDATE_MS = 10 * 60_000;
231
+
232
+ export interface CollectOptions {
233
+ /** Every collector of this source and directory, this one included; the most specific wins. */
234
+ peers?: CollectionSource[];
235
+ exclude?: string[];
236
+ cache?: CollectorCache;
237
+ /** The harness login's access path; defaults to reading the history directory's harness home. */
238
+ harness?: (
239
+ source: CollectionSource['source'],
240
+ directory: string,
241
+ ) => 'subscription' | 'api' | undefined;
242
+ now?: () => number;
243
+ }
244
+
245
+ /** A rejection the same snapshot will get again: invalid input or a refused write, not an outage. */
246
+ const deterministic = (error: unknown) => {
247
+ const status = (error as { status?: unknown } | null)?.status;
248
+ return (
249
+ (error instanceof Error && error.name === 'ZodError') ||
250
+ (typeof status === 'number' && status >= 400 && status < 500 && status !== 409)
251
+ );
252
+ };
253
+
254
+ export async function collectSessions(
255
+ archive: ArchiveWriter,
256
+ config: CollectionSource,
257
+ options: CollectOptions = {},
258
+ ) {
107
259
  const sourceId = await archive.sourceId();
108
- const roots = collectionRoots(config);
260
+ const now = options.now ?? Date.now;
261
+ const peers = options.peers ?? [config];
262
+ const exclude = options.exclude ?? defaultExclusions();
263
+ const cache: CollectorCache = options.cache ?? new Map();
264
+ const routing = JSON.stringify([sourceId, config, peers, exclude]);
109
265
  const result = { imported: 0, unchanged: 0, skipped: 0, failed: 0 };
110
266
  // Per scan: checkouts change between scans.
267
+ const rootSets = new Map<CollectionSource, Set<string>>();
268
+ const roots = (collector: CollectionSource) => {
269
+ if (!rootSets.has(collector)) rootSets.set(collector, collectionRoots(collector));
270
+ return rootSets.get(collector)!;
271
+ };
272
+ const routes = new Map<string, CollectionSource | undefined>();
111
273
  const repositories = new Map<string, string | undefined>();
112
274
  const graphs = new Map<string, AtlasFileConcepts | undefined>();
113
- for (const file of files(config.directory)) {
275
+ let harness: 'subscription' | 'api' | undefined | null = null;
276
+ for (const file of transcriptFiles(config.directory)) {
114
277
  try {
115
- const meta = sessionMetadata(file, config.source);
116
- // Exact root selection avoids sweeping nested checkouts belonging to another organization.
117
- if (!meta || !roots.has(meta.cwd)) {
278
+ const before = lstatSync(file);
279
+ const known = cache.get(file);
280
+ if (
281
+ known?.size === before.size &&
282
+ known.mtimeMs === before.mtimeMs &&
283
+ known.routing === routing
284
+ ) {
285
+ const stored = known.stored;
286
+ if (!stored || now() - stored.checkedAt < REVALIDATE_MS) {
287
+ result[known.outcome]++;
288
+ continue;
289
+ }
290
+ if ((await archive.head(stored.id))?.digest === stored.digest) {
291
+ stored.checkedAt = now();
292
+ result.unchanged++;
293
+ continue;
294
+ }
295
+ }
296
+ const remember = (
297
+ outcome: 'unchanged' | 'skipped' | 'failed',
298
+ meta?: SessionMetadata,
299
+ stored?: { id: string; digest: string },
300
+ ) =>
301
+ cache.set(file, {
302
+ size: before.size,
303
+ mtimeMs: before.mtimeMs,
304
+ routing,
305
+ outcome,
306
+ meta,
307
+ ...(stored ? { stored: { ...stored, checkedAt: now() } } : {}),
308
+ });
309
+ // A transcript's first cwd never changes, so it is read once per file.
310
+ const meta = known?.meta ?? sessionMetadata(file, config.source);
311
+ if (!meta) {
118
312
  result.skipped++;
313
+ remember('skipped');
119
314
  continue;
120
315
  }
121
- const before = lstatSync(file);
122
- const snapshot = importTranscriptFile(file, {
123
- source: config.source,
124
- sourceId,
125
- projectId: config.projectId,
126
- });
127
- const after = lstatSync(file);
128
- if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
316
+ if (!routes.has(meta.cwd))
317
+ routes.set(meta.cwd, routeSession(meta.cwd, peers, { exclude, roots }));
318
+ if (routes.get(meta.cwd) !== config) {
129
319
  result.skipped++;
320
+ remember('skipped', meta);
130
321
  continue;
131
322
  }
132
- const old = await archive.head(archiveKey(snapshot));
133
- if (old && old.projectId !== config.projectId) {
323
+ let snapshot: ReturnType<typeof importTranscriptFile>;
324
+ try {
325
+ snapshot = importTranscriptFile(file, {
326
+ source: config.source,
327
+ sourceId,
328
+ projectId: config.projectId,
329
+ });
330
+ } catch {
331
+ // The same bytes fail the same way; a partial file is retried once it changes.
134
332
  result.failed++;
333
+ remember('failed', meta);
334
+ continue;
335
+ }
336
+ const after = lstatSync(file);
337
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
338
+ result.skipped++;
135
339
  continue;
136
340
  }
137
341
  const { cwd } = meta;
@@ -139,15 +343,54 @@ export async function collectSessions(archive: ArchiveWriter, config: Collection
139
343
  if (config.atlas && !graphs.has(cwd)) graphs.set(cwd, atlasGraph(cwd));
140
344
  const graph = graphs.get(cwd);
141
345
  snapshot.tags = [
142
- ...new Set([...(config.tags ?? []), ...(graph ? atlasTags(snapshot, graph, roots) : [])]),
346
+ ...new Set([
347
+ ...(config.tags ?? []),
348
+ ...(graph ? atlasTags(snapshot, graph, roots(config)) : []),
349
+ ]),
143
350
  ].slice(0, 100);
144
351
  const references = provenanceReferences({
145
352
  branch: meta.branch,
146
353
  repository: meta.repository ?? repositories.get(cwd),
147
354
  });
148
355
  if (references.length) snapshot.references = references;
149
- if ((await archive.capture(snapshot)).changed) result.imported++;
150
- else result.unchanged++;
356
+ if (snapshot.runtime?.accessPath === 'unknown') {
357
+ if (harness === null)
358
+ harness = (options.harness ?? harnessAccess)(config.source, config.directory);
359
+ if (harness)
360
+ snapshot.runtime = { ...snapshot.runtime, accessPath: harness, evidence: 'harness' };
361
+ }
362
+ const id = archiveKey(snapshot);
363
+ const old = await archive.head(id);
364
+ // A session belongs to one project; only one the catch-all held moves to its routed project.
365
+ if (old && old.projectId !== config.projectId && old.projectId !== UNROUTED) {
366
+ result.failed++;
367
+ remember('failed', meta);
368
+ continue;
369
+ }
370
+ // An unchanged session is not sent again: the server already holds this digest.
371
+ // Digest what the server will: the schema orders keys, and fields set above were appended.
372
+ let digest: string;
373
+ try {
374
+ digest = snapshotDigest(archiveSnapshotSchema.parse(snapshot));
375
+ } catch {
376
+ // Over the archive's limits: the same bytes are refused again.
377
+ result.failed++;
378
+ remember('failed', meta);
379
+ continue;
380
+ }
381
+ if (old?.digest === digest) result.unchanged++;
382
+ else {
383
+ try {
384
+ const captured = await archive.capture(snapshot);
385
+ digest = captured.digest;
386
+ result[captured.changed ? 'imported' : 'unchanged']++;
387
+ } catch (error) {
388
+ result.failed++;
389
+ if (deterministic(error)) remember('failed', meta);
390
+ continue;
391
+ }
392
+ }
393
+ remember('unchanged', meta, { id, digest });
151
394
  } catch {
152
395
  result.failed++;
153
396
  } // Partial/malformed files remain intact and retry on the next scan.
package/src/config.ts CHANGED
@@ -13,8 +13,13 @@ import {
13
13
  } from 'node:fs';
14
14
  import { dirname, isAbsolute, join } from 'node:path';
15
15
  import { z } from 'zod';
16
+ import { UNROUTED } from './index';
16
17
 
17
- const projectId = z.string().min(1).max(256);
18
+ const projectId = z
19
+ .string()
20
+ .min(1)
21
+ .max(256)
22
+ .refine((id) => id !== UNROUTED, `${UNROUTED} sessions stay on this machine`);
18
23
  /** A remote Archive this one syncs to directly with its server token. */
19
24
  const archiveDestination = z
20
25
  .strictObject({
@@ -1,5 +1,6 @@
1
- import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
1
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from 'node:fs';
2
2
  import { type ImportOptions, importTranscriptLines } from './import';
3
+ import { archiveLineageSchema } from './index';
3
4
 
4
5
  /** Bound raw record memory while skipping unsupported payloads from long native histories. */
5
6
  export function importTranscriptFile(file: string, options: ImportOptions) {
@@ -41,5 +42,31 @@ export function importTranscriptFile(file: string, options: ImportOptions) {
41
42
  closeSync(descriptor);
42
43
  }
43
44
  }
44
- return importTranscriptLines(lines(), options);
45
+ const snapshot = importTranscriptLines(lines(), options);
46
+ if (options.source === 'claude-code' && snapshot.lineage) {
47
+ const meta = subagentMeta(file);
48
+ if (meta) snapshot.lineage = archiveLineageSchema.parse({ ...snapshot.lineage, ...meta });
49
+ }
50
+ return snapshot;
51
+ }
52
+
53
+ /** Claude Code writes `agent-<id>.meta.json` beside a subagent transcript: its type, task and depth. */
54
+ function subagentMeta(file: string) {
55
+ const path = file.replace(/\.jsonl$/, '.meta.json');
56
+ if (path === file || !existsSync(path)) return undefined;
57
+ let meta: Record<string, unknown>;
58
+ try {
59
+ meta = JSON.parse(readFileSync(path, 'utf8'));
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ const text = (value: unknown, max: number) =>
64
+ typeof value === 'string' && value ? value.slice(0, max) : undefined;
65
+ const fields = {
66
+ agentType: text(meta.agentType, 200),
67
+ description: text(meta.description, 500),
68
+ toolUseId: text(meta.toolUseId, 256),
69
+ depth: Number.isInteger(meta.spawnDepth) ? (meta.spawnDepth as number) : undefined,
70
+ };
71
+ return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
45
72
  }
package/src/import.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { type ArchiveEntry, type ArchiveSnapshot, archiveSnapshotSchema } from './index';
1
+ import {
2
+ type ArchiveEntry,
3
+ type ArchiveLineage,
4
+ type ArchiveSnapshot,
5
+ archiveSnapshotSchema,
6
+ } from './index';
2
7
 
3
8
  export interface ImportOptions {
4
9
  source: 'codex' | 'claude-code';
@@ -102,6 +107,14 @@ export function importTranscriptLines(
102
107
  let turn: { model?: string; effort?: string } = {};
103
108
  // A Codex session started by a script (codex exec, a custom originator) has no typed prompts.
104
109
  let scripted = false;
110
+ let metaSeen = false;
111
+ let lineage: ArchiveLineage | undefined;
112
+ let provider: string | undefined = options.source === 'claude-code' ? 'anthropic' : undefined;
113
+ // Only a subscription's rate-limit windows prove the access path; an API key leaves no mark.
114
+ let subscription = false;
115
+ const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
116
+ const optional = <K extends string>(key: K, value: string | undefined) =>
117
+ (value ? { [key]: value } : {}) as Partial<Record<K, string>>;
105
118
  for (const rawLine of lines) {
106
119
  index++;
107
120
  const line = rawLine.trim();
@@ -115,17 +128,52 @@ export function importTranscriptLines(
115
128
  if (!row || typeof row !== 'object' || Array.isArray(row))
116
129
  throw new Error(`Invalid transcript record at line ${index + 1}`);
117
130
  if (options.source === 'codex' && row.type === 'session_meta') {
118
- const id = row.payload?.id;
131
+ const meta = row.payload ?? {};
132
+ const id = meta.id;
133
+ // A subagent or forked rollout starts with its own meta, then replays its parent's history,
134
+ // parent meta included. The first meta is the session; a later one is lineage, not identity.
135
+ if (metaSeen && lineage) continue;
119
136
  if (sessionId && id && id !== sessionId)
120
137
  throw new Error('Transcript session identity mismatch');
121
138
  sessionId ??= id;
139
+ metaSeen = true;
122
140
  // Subagent and guardian threads, and `codex exec`, are driven by an agent or a script.
123
- const { originator, source } = row.payload ?? {};
141
+ const { originator, source } = meta;
124
142
  if (typeof originator === 'string')
125
143
  scripted = !/^codex/i.test(originator) || /^codex_(?:exec|sdk|mcp)/.test(originator);
144
+ const spawn =
145
+ source && typeof source === 'object' && 'subagent' in source
146
+ ? source.subagent?.thread_spawn
147
+ : undefined;
126
148
  if (source === 'exec' || (source && typeof source === 'object' && 'subagent' in source))
127
149
  scripted = true;
150
+ const parent = text(spawn?.parent_thread_id) ?? text(meta.parent_thread_id);
151
+ const forked = text(meta.forked_from_id);
152
+ if (parent || forked)
153
+ lineage = {
154
+ kind: source && typeof source === 'object' && 'subagent' in source ? 'subagent' : 'fork',
155
+ parentSessionId: (parent ?? forked)!,
156
+ ...optional('agentType', text(spawn?.agent_role ?? meta.agent_role)),
157
+ ...optional('agentName', text(spawn?.agent_nickname ?? meta.agent_nickname)),
158
+ ...(Number.isInteger(spawn?.depth) ? { depth: spawn.depth } : {}),
159
+ };
160
+ const named = text(meta.model_provider)?.toLowerCase();
161
+ provider = named && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(named) ? named : 'openai';
128
162
  }
163
+ if (
164
+ options.source === 'codex' &&
165
+ row.type === 'event_msg' &&
166
+ row.payload?.type === 'token_count' &&
167
+ text(row.payload.rate_limits?.plan_type)
168
+ )
169
+ subscription = true;
170
+ if (
171
+ options.source === 'claude-code' &&
172
+ row.quotaLimits &&
173
+ typeof row.quotaLimits === 'object' &&
174
+ text(row.quotaLimits.rateLimitType)
175
+ )
176
+ subscription = true;
129
177
  if (options.source === 'claude-code' && row.sessionId) {
130
178
  // Subagent transcripts reuse their parent's sessionId; each is its own archive.
131
179
  const id =
@@ -134,8 +182,9 @@ export function importTranscriptLines(
134
182
  : row.sessionId;
135
183
  if (sessionId && sessionId !== id) throw new Error('Mixed transcript sessions');
136
184
  sessionId = id;
185
+ if (row.isSidechain && typeof row.agentId === 'string')
186
+ lineage ??= { kind: 'subagent', parentSessionId: row.sessionId };
137
187
  }
138
- const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
139
188
  if (options.source === 'codex' && row.type === 'turn_context')
140
189
  turn = { model: text(row.payload?.model), effort: text(row.payload?.effort) };
141
190
  const producer =
@@ -287,6 +336,12 @@ export function importTranscriptLines(
287
336
  sessionId,
288
337
  title,
289
338
  projectId: options.projectId,
339
+ runtime: {
340
+ provider: provider ?? 'openai',
341
+ accessPath: subscription ? 'subscription' : 'unknown',
342
+ ...(subscription ? { evidence: 'transcript' } : {}),
343
+ },
344
+ ...(lineage ? { lineage } : {}),
290
345
  tags: [],
291
346
  capturedAt: Date.now(),
292
347
  coverage: {
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@ import { getEncoding } from 'js-tiktoken';
3
3
  import { z } from 'zod';
4
4
 
5
5
  const identifier = z.string().min(1).max(256);
6
+ /** The projectId a catch-all collector gives sessions no other collector claims. It never leaves the machine. */
7
+ export const UNROUTED = 'unrouted';
6
8
  export const archiveEntrySchema = z.strictObject({
7
9
  id: identifier,
8
10
  turnId: identifier.optional(),
@@ -28,6 +30,26 @@ export const archiveReferenceSchema = z.strictObject({
28
30
  integration: z.string().regex(/^[a-z0-9-]{1,64}$/),
29
31
  ref: z.string().min(1).max(300),
30
32
  });
33
+ /**
34
+ * How the session reached its model: the provider, and whether it ran on a subscription or an API
35
+ * key. `evidence` says where the access path came from: the transcript itself, or the harness's
36
+ * login when the transcript is silent (its state at capture).
37
+ */
38
+ export const archiveRuntimeSchema = z.strictObject({
39
+ provider: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/),
40
+ accessPath: z.enum(['subscription', 'api', 'unknown']),
41
+ evidence: z.enum(['transcript', 'harness']).optional(),
42
+ });
43
+ /** The session that started this one: a subagent's parent, or the thread a fork copied. */
44
+ export const archiveLineageSchema = z.strictObject({
45
+ kind: z.enum(['subagent', 'fork']),
46
+ parentSessionId: identifier,
47
+ agentType: z.string().min(1).max(200).optional(),
48
+ agentName: z.string().min(1).max(200).optional(),
49
+ description: z.string().min(1).max(500).optional(),
50
+ toolUseId: identifier.optional(),
51
+ depth: z.number().int().nonnegative().max(100).optional(),
52
+ });
31
53
  export const archiveSnapshotSchema = z
32
54
  .strictObject({
33
55
  schemaVersion: z.literal(1),
@@ -37,6 +59,8 @@ export const archiveSnapshotSchema = z
37
59
  title: z.string().min(1).max(500),
38
60
  projectId: identifier.optional(),
39
61
  actor: archiveActorSchema.optional(),
62
+ runtime: archiveRuntimeSchema.optional(),
63
+ lineage: archiveLineageSchema.optional(),
40
64
  /** Recorded references, such as the session's repository and branch. Linked ones are found in the text. */
41
65
  references: z.array(archiveReferenceSchema).max(100).optional(),
42
66
  tags: z.array(z.string().min(1).max(120)).max(100),
@@ -60,6 +84,8 @@ export type ArchiveSnapshot = z.infer<typeof archiveSnapshotSchema>;
60
84
  export type ArchiveEntry = z.infer<typeof archiveEntrySchema>;
61
85
  export type ArchiveActor = z.infer<typeof archiveActorSchema>;
62
86
  export type ArchiveReference = z.infer<typeof archiveReferenceSchema>;
87
+ export type ArchiveRuntime = z.infer<typeof archiveRuntimeSchema>;
88
+ export type ArchiveLineage = z.infer<typeof archiveLineageSchema>;
63
89
  export interface ArchiveChunk {
64
90
  id: string;
65
91
  entryId: string;
package/src/schemas.ts CHANGED
@@ -37,4 +37,6 @@ export interface ArchiveFilter {
37
37
  reference?: { integration: string; ref: string };
38
38
  model?: string;
39
39
  effort?: string;
40
+ provider?: string;
41
+ accessPath?: 'subscription' | 'api' | 'unknown';
40
42
  }
package/src/server.ts CHANGED
@@ -29,6 +29,8 @@ const filterFields = {
29
29
  reference: archiveReferenceSchema.optional(),
30
30
  model: z.string().min(1).max(200).optional(),
31
31
  effort: z.string().min(1).max(40).optional(),
32
+ provider: z.string().min(1).max(64).optional(),
33
+ accessPath: z.enum(['subscription', 'api', 'unknown']).optional(),
32
34
  limit: z.number().int().min(1).max(100).default(20),
33
35
  beforeId: archiveId.optional(),
34
36
  };
@@ -145,6 +147,8 @@ export function createArchiveHandler(
145
147
  title: a.title,
146
148
  projectId: a.projectId,
147
149
  actor: a.actor,
150
+ runtime: a.runtime,
151
+ lineage: a.lineage,
148
152
  models: a.models,
149
153
  tags: a.tags,
150
154
  suggestedTags: a.suggestedTags,
package/src/store.ts CHANGED
@@ -144,6 +144,8 @@ export class ArchiveStore {
144
144
  source: snapshot.source,
145
145
  projectId: snapshot.projectId ?? null,
146
146
  actorId: snapshot.actor?.id ?? null,
147
+ provider: snapshot.runtime?.provider ?? null,
148
+ accessPath: snapshot.runtime?.accessPath ?? null,
147
149
  capturedAt: snapshot.capturedAt,
148
150
  summary: summarize(snapshot, settings) as Prisma.InputJsonValue,
149
151
  };
@@ -217,6 +219,8 @@ export class ArchiveStore {
217
219
  ...(filter.projectId ? { projectId: filter.projectId } : {}),
218
220
  ...(filter.source ? { source: filter.source } : {}),
219
221
  ...(filter.actorId ? { actorId: filter.actorId } : {}),
222
+ ...(filter.provider ? { provider: filter.provider } : {}),
223
+ ...(filter.accessPath ? { accessPath: filter.accessPath } : {}),
220
224
  ...(cursor === undefined ? {} : { seq: { lt: cursor } }),
221
225
  },
222
226
  include: { tagEdits: true },