@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 +4 -4
- package/package.json +1 -1
- package/prisma/migrations/20261012120000_runtime/migration.sql +10 -0
- package/prisma/schema.prisma +5 -0
- package/src/agents.ts +98 -38
- package/src/cli.ts +108 -29
- package/src/client.ts +55 -2
- package/src/collector.ts +270 -27
- package/src/config.ts +6 -1
- package/src/import-file.ts +29 -2
- package/src/import.ts +59 -4
- package/src/index.ts +26 -0
- package/src/schemas.ts +2 -0
- package/src/server.ts +4 -0
- package/src/store.ts +4 -0
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
|
|
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
|
|
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
|
@@ -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
|
+
|
package/prisma/schema.prisma
CHANGED
|
@@ -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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
c.
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
329
|
-
unchanged: plan.unchanged.filter((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
|
|
95
|
-
'
|
|
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 ...] [--
|
|
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
|
-
|
|
262
|
-
|
|
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']
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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: ${
|
|
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 {
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
275
|
+
let harness: 'subscription' | 'api' | undefined | null = null;
|
|
276
|
+
for (const file of transcriptFiles(config.directory)) {
|
|
114
277
|
try {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
if (
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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([
|
|
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 (
|
|
150
|
-
|
|
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
|
|
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({
|
package/src/import-file.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
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 } =
|
|
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
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 },
|