@inixiative/archive 0.2.0 → 0.3.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
@@ -39,11 +39,11 @@ bun run archive list
39
39
  bun run archive search --query 'migration'
40
40
  ```
41
41
 
42
- Use `--source claude-code` for Claude histories. Collection matches the exact working-directory metadata recorded by the provider. 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 initial collector rescans files every 30 seconds; it deduplicates normalized content rather than maintaining byte-offset ingestion cursors. It retains prior manually assigned tags. It does not upload: run sync separately.
42
+ 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 initial collector rescans files every 30 seconds; it deduplicates normalized content rather than maintaining byte-offset ingestion cursors. It retains prior manually assigned tags. It does not upload: run sync separately.
43
43
 
44
44
  ## Connect to BYO hosting
45
45
 
46
- Deploy the included Dockerfile with a persistent volume mounted at `/data`, HTTPS, and a unique `ARCHIVE_SERVER_TOKEN` of at least 32 characters. Compose binds only to loopback; put an HTTPS reverse proxy in front for remote use. Railway needs a `/data` volume. The Render blueprint provisions a dedicated disk and generated token.
46
+ Deploy the included Dockerfile with a persistent volume mounted at `/data`, HTTPS, and a unique `ARCHIVE_SERVER_TOKEN` of at least 32 characters. Compose bind-mounts `./data` (or `ARCHIVE_DATA_DIR`) at `/data`, so the store survives rebuilds, `down -v` and volume prunes, and binds only to loopback on port 4411 (or `ARCHIVE_PORT`); put an HTTPS reverse proxy in front for remote use. Railway needs a `/data` volume. The Render blueprint provisions a dedicated disk and generated token.
47
47
 
48
48
  Place the destination's token in an environment variable, then run:
49
49
 
@@ -61,13 +61,19 @@ bun run archive search --remote --query 'migration'
61
61
 
62
62
  ## Connect through Kingdom
63
63
 
64
- Use Kingdom's existing runtime enrollment for a user who manages the destination Kastle, then:
64
+ Pair a runtime with Kingdom (Foundry: Settings → Kingdom) and expose its `kastle_runtime_` credential in an environment variable, then either let Kingdom store the archives:
65
65
 
66
66
  ```sh
67
- bun run archive connect --kind kingdom --url https://your-kingdom.example --kastle-id UUID --project-id inixiative --token-env KINGDOM_ARCHIVE_TOKEN
67
+ bun run archive connect --kind kingdom --url https://your-kingdom.example --project-id inixiative --token-env KINGDOM_ARCHIVE_TOKEN
68
68
  ```
69
69
 
70
- Optional repeated `--keep-id UUID` places sessions in Keeps owned by that Kastle. A Kingdom runtime credential is distinct from a standalone Archive token. Kingdom retains responsibility for user memberships, shares, revocation and hosted browsing. Connecting directly to Archive does not automatically register that server as a Kastle resource or mirror it into Kingdom; these are separate destinations.
70
+ or forward them through Kingdom to a hosted Archive it has bound for that project:
71
+
72
+ ```sh
73
+ bun run archive connect --kind kingdom --url https://your-kingdom.example --connection-id inixiative --project-id inixiative --token-env KINGDOM_ARCHIVE_TOKEN
74
+ ```
75
+
76
+ Kingdom takes the owner from the runtime credential; `--owner-model`, `--organization-id` and `--space-id` narrow it to an organization or space that owner manages. A forwarding connection accepts only its bound `projectId`, and the hosted Archive's own token stays in Kingdom's server environment. A Kingdom runtime credential is distinct from a standalone Archive token. Kingdom retains responsibility for memberships, shares, revocation and hosted browsing.
71
77
 
72
78
  Foundry exposes the same commands through `bun run archive`. Its defaults remain `.foundry/archives/archives.sqlite` and `.foundry/archives.json`. Its durable journal capture automatically publishes matching projects once destination credentials are in the viewer's environment. Restart the viewer after changing destination configuration.
73
79
 
@@ -79,7 +85,9 @@ Only explicit `projectId` matches authorize publication. An archive with no matc
79
85
  bun run archive tag --id ARCHIVE_ID --tag reviewed
80
86
  ```
81
87
 
82
- Tags and heuristic category suggestions do not change ownership or routing. Jev integration is not enabled: the next step is labeled, shadow-mode tag/destination proposals, scored for accuracy and cross-organization mistakes before any automatic action. Session content must not be sent to Jev without selecting that service for the relevant ownership boundary.
88
+ Collectors add explicit tags from recorded session metadata: `repo:owner/name` and `branch:name` from git context, and with `--atlas` the `atlas:<concept>` of repository files the session's tool calls touched (from `atlas graph --json` in the session's checkout). Suggestions are computed on read and never stored: heuristic categories, plus `reference` suggestions for linked GitHub pull requests, issues and commits (`github:owner/repo#24`, `github:owner/repo@sha`) and Linear issues (`linear:KEY-12`). A link is a mention, not proof of work, so references stay suggestions.
89
+
90
+ Tags and suggestions do not change ownership or routing. Jev integration is not enabled: the next step is labeled, shadow-mode tag/destination proposals, scored for accuracy and cross-organization mistakes before any automatic action. Session content must not be sent to Jev without selecting that service for the relevant ownership boundary.
83
91
 
84
92
  ## Limits
85
93
 
@@ -98,3 +106,7 @@ bun run typecheck
98
106
  ```
99
107
 
100
108
  See [provenance](docs/PROVENANCE.md), [license](LICENSE), and [goals](tickets/README.md). The historical tickets describe earlier scope; this implementation now includes explicit provider collection and authenticated standalone hosting.
109
+
110
+ ### ChatGPT conversation text
111
+
112
+ `archive import --file conversation.json --source chatgpt --project-id personal` accepts the JSON result of the desktop ChatGPT conversation reader (`thread`, `page`, and `turns`). This is a partial text capture, not a ChatGPT account export: tools, attachments, alternate branches and resolved citation targets are unavailable. ChatGPT conversations retain their own source identity.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/archive",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -12,7 +12,9 @@
12
12
  "./import-file": "./src/import-file.ts",
13
13
  "./preview": "./src/preview.ts",
14
14
  "./cli": "./src/cli.ts",
15
- "./collector": "./src/collector.ts"
15
+ "./collector": "./src/collector.ts",
16
+ "./chatgpt": "./src/chatgpt.ts",
17
+ "./tags": "./src/tags.ts"
16
18
  },
17
19
  "files": [
18
20
  "src"
@@ -32,11 +34,11 @@
32
34
  "zod": "^4.3.6"
33
35
  },
34
36
  "devDependencies": {
35
- "@types/bun": "1.4.2",
36
- "typescript": "6.0.3",
37
37
  "@biomejs/biome": "2.5.2",
38
- "@inixiative/config": "^0.5.33",
39
- "lefthook": "2.1.9"
38
+ "@inixiative/config": "^0.5.36",
39
+ "@types/bun": "1.4.2",
40
+ "lefthook": "2.1.9",
41
+ "typescript": "6.0.3"
40
42
  },
41
43
  "bin": {
42
44
  "archive": "src/cli.ts"
package/src/chatgpt.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { z } from 'zod';
2
+ import { type ArchiveEntry, archiveSnapshotSchema } from './index';
3
+
4
+ const exportSchema = z.object({
5
+ thread: z.object({ id: z.string(), kind: z.literal('chatgpt'), title: z.string() }),
6
+ page: z.object({ order: z.literal('newest_first'), hasMore: z.boolean() }),
7
+ turns: z.array(
8
+ z.object({
9
+ id: z.string(),
10
+ startedAt: z.number().nullable(),
11
+ completedAt: z.number().nullable(),
12
+ items: z.array(
13
+ z.object({
14
+ type: z.string(),
15
+ id: z.string(),
16
+ text: z.string().optional(),
17
+ content: z.array(z.object({ type: z.string(), text: z.string().optional() })).optional(),
18
+ }),
19
+ ),
20
+ }),
21
+ ),
22
+ });
23
+
24
+ /** Import the text returned by the desktop ChatGPT conversation reader, not a native JSONL log. */
25
+ export function importChatGPTThread(
26
+ input: unknown,
27
+ options: { sourceId: string; projectId?: string; title?: string },
28
+ ) {
29
+ const data = exportSchema.parse(input);
30
+ const entries: ArchiveEntry[] = [];
31
+ for (const turn of [...data.turns].reverse()) {
32
+ for (const item of turn.items) {
33
+ if (!['userMessage', 'agentMessage'].includes(item.type)) continue;
34
+ const text =
35
+ item.text ??
36
+ item.content
37
+ ?.filter((b) => b.type === 'text')
38
+ .map((b) => b.text ?? '')
39
+ .join('\n');
40
+ if (!text) continue;
41
+ entries.push({
42
+ id: item.id,
43
+ turnId: turn.id,
44
+ kind: item.type === 'userMessage' ? 'user' : 'assistant',
45
+ text,
46
+ timestamp:
47
+ (item.type === 'userMessage' ? turn.startedAt : turn.completedAt) === null
48
+ ? null
49
+ : (item.type === 'userMessage' ? turn.startedAt! : turn.completedAt!) * 1000,
50
+ sourceRef: `chatgpt:${data.thread.id}:${item.id}`,
51
+ });
52
+ }
53
+ }
54
+ if (!entries.length) throw new Error('ChatGPT export contains no readable messages');
55
+ return archiveSnapshotSchema.parse({
56
+ schemaVersion: 1,
57
+ source: 'chatgpt',
58
+ sourceId: options.sourceId,
59
+ sessionId: data.thread.id,
60
+ title: options.title ?? data.thread.title,
61
+ projectId: options.projectId,
62
+ tags: [],
63
+ capturedAt: Date.now(),
64
+ entries,
65
+ coverage: {
66
+ reasoning: 'unavailable',
67
+ completeness: 'partial',
68
+ omissions: [
69
+ 'Desktop conversation text only; tool activity, attachments, alternate branches and citation targets are not exported. Reader text may be truncated.',
70
+ ...(data.page.hasMore ? ['Older conversation turns are not included in this page.'] : []),
71
+ ],
72
+ },
73
+ });
74
+ }
package/src/cli.ts CHANGED
@@ -4,8 +4,10 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'n
4
4
  import { homedir } from 'node:os';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
+ import { importChatGPTThread } from './chatgpt';
7
8
  import {
8
9
  archiveRequest,
10
+ kingdomFields,
9
11
  publishArchive,
10
12
  routingPreview,
11
13
  searchRemotes,
@@ -30,8 +32,10 @@ export async function runCli(args = Bun.argv.slice(2)) {
30
32
  url: { type: 'string' },
31
33
  kind: { type: 'string', default: 'archive' },
32
34
  'project-id': { type: 'string' },
33
- 'kastle-id': { type: 'string' },
34
- 'keep-id': { type: 'string', multiple: true },
35
+ 'connection-id': { type: 'string' },
36
+ 'owner-model': { type: 'string' },
37
+ 'organization-id': { type: 'string' },
38
+ 'space-id': { type: 'string' },
35
39
  'token-env': { type: 'string', default: 'ARCHIVE_TOKEN' },
36
40
  'token-file': { type: 'string' },
37
41
  file: { type: 'string' },
@@ -41,7 +45,9 @@ export async function runCli(args = Bun.argv.slice(2)) {
41
45
  'session-id': { type: 'string' },
42
46
  id: { type: 'string' },
43
47
  query: { type: 'string', default: '' },
44
- 'project-root': { type: 'string' },
48
+ 'project-root': { type: 'string', multiple: true },
49
+ worktrees: { type: 'boolean', default: false },
50
+ atlas: { type: 'boolean', default: false },
45
51
  tag: { type: 'string', multiple: true },
46
52
  limit: { type: 'string', default: '100' },
47
53
  port: { type: 'string', default: process.env.PORT ?? '4411' },
@@ -60,9 +66,9 @@ export async function runCli(args = Bun.argv.slice(2)) {
60
66
  if (v.help || !command) {
61
67
  console.log(
62
68
  'Archive: init | serve | connect (setup) | preview | import | collect | list | export | tag | routes | sync | search\n' +
63
- 'connect --url HTTPS_URL --project-id ID --token-env ENV [--kind kingdom --kastle-id UUID]\n' +
69
+ 'connect --url HTTPS_URL --project-id ID --token-env ENV [--kind kingdom [--connection-id ID] [--owner-model M --organization-id UUID --space-id UUID]]\n' +
64
70
  'import --file PATH --source codex|claude-code --project-id ID [--tag TAG]\n' +
65
- 'collect --directory HISTORY --source codex|claude-code --project-root EXACT_CWD --project-id ID [--watch]\n' +
71
+ 'collect --directory HISTORY --source codex|claude-code --project-root EXACT_CWD [--project-root ...] [--worktrees] [--atlas] --project-id ID [--watch]\n' +
66
72
  'sync [--watch] | routes | search --query TEXT [--remote]\n' +
67
73
  'serve --home PATH [--port 4411 --hostname 127.0.0.1]\n' +
68
74
  'All commands accept --home PATH; integrations may use --store FILE --config FILE.',
@@ -106,7 +112,14 @@ export async function runCli(args = Bun.argv.slice(2)) {
106
112
  url: v.url,
107
113
  tokenEnv: v['token-env'],
108
114
  kind: v.kind,
109
- ...(v.kind === 'kingdom' ? { kastleId: v['kastle-id'], keepIds: v['keep-id'] ?? [] } : {}),
115
+ ...(v.kind === 'kingdom'
116
+ ? {
117
+ connectionId: v['connection-id'],
118
+ ownerModel: v['owner-model'],
119
+ organizationId: v['organization-id'],
120
+ spaceId: v['space-id'],
121
+ }
122
+ : {}),
110
123
  });
111
124
  // Verify credentials and protocol before saving; no records uploaded by setup.
112
125
  const probe = await archiveRequest(destination, 'search', {
@@ -115,7 +128,7 @@ export async function runCli(args = Bun.argv.slice(2)) {
115
128
  limit: 1,
116
129
  ...(destination.kind === 'archive'
117
130
  ? { projectId: destination.projectId }
118
- : { kastleId: destination.kastleId }),
131
+ : kingdomFields(destination)),
119
132
  });
120
133
  if (!Array.isArray(probe.data?.archives))
121
134
  throw new Error('Destination is not an Archive-compatible endpoint');
@@ -142,15 +155,28 @@ export async function runCli(args = Bun.argv.slice(2)) {
142
155
  const store = new LocalArchiveStore(storePath);
143
156
  try {
144
157
  if (command === 'import') {
145
- if (!v.file || !['codex', 'claude-code'].includes(v.source ?? '') || !v['project-id'])
146
- throw new Error('Import requires --file, --source codex|claude-code and --project-id');
147
- const snapshot = importTranscriptFile(v.file, {
148
- source: v.source as 'codex' | 'claude-code',
149
- sourceId: store.sourceId,
150
- projectId: v['project-id'],
151
- sessionId: v['session-id'],
152
- title: v.title,
153
- });
158
+ if (
159
+ !v.file ||
160
+ !['codex', 'claude-code', 'chatgpt'].includes(v.source ?? '') ||
161
+ !v['project-id']
162
+ )
163
+ throw new Error(
164
+ 'Import requires --file, --source codex|claude-code|chatgpt and --project-id',
165
+ );
166
+ const snapshot =
167
+ v.source === 'chatgpt'
168
+ ? importChatGPTThread(JSON.parse(readFileSync(v.file, 'utf8')), {
169
+ sourceId: store.sourceId,
170
+ projectId: v['project-id'],
171
+ title: v.title,
172
+ })
173
+ : importTranscriptFile(v.file, {
174
+ source: v.source as 'codex' | 'claude-code',
175
+ sourceId: store.sourceId,
176
+ projectId: v['project-id'],
177
+ sessionId: v['session-id'],
178
+ title: v.title,
179
+ });
154
180
  snapshot.tags = [
155
181
  ...new Set([...(store.read(archiveKey(snapshot))?.snapshot.tags ?? []), ...(v.tag ?? [])]),
156
182
  ];
@@ -181,7 +207,9 @@ export async function runCli(args = Bun.argv.slice(2)) {
181
207
  const result = await collectSessions(store, {
182
208
  directory: v.directory!,
183
209
  source: v.source as 'codex' | 'claude-code',
184
- projectRoot: v['project-root']!,
210
+ projectRoots: v['project-root']!,
211
+ worktrees: v.worktrees,
212
+ atlas: v.atlas,
185
213
  projectId: v['project-id']!,
186
214
  tags: v.tag,
187
215
  });
package/src/client.ts CHANGED
@@ -8,6 +8,17 @@ import type { LocalArchiveStore } from './local';
8
8
 
9
9
  export { type ArchiveDestination, archiveDestinationSchema } from './config';
10
10
 
11
+ /** Identifies the Kingdom owner and forwarding connection; standalone Archive needs neither. */
12
+ export function kingdomFields(destination: ArchiveDestination) {
13
+ if (destination.kind === 'archive') return {};
14
+ const { ownerModel, organizationId, spaceId, connectionId } = destination;
15
+ return Object.fromEntries(
16
+ Object.entries({ ownerModel, organizationId, spaceId, connectionId }).filter(
17
+ ([, value]) => value !== undefined,
18
+ ),
19
+ );
20
+ }
21
+
11
22
  export async function archiveRequest(
12
23
  destination: ArchiveDestination,
13
24
  action: string,
@@ -16,9 +27,11 @@ export async function archiveRequest(
16
27
  ) {
17
28
  const url = destinationUrl(destination.url);
18
29
  const token = process.env[destination.tokenEnv];
19
- if (!token || (destination.kind !== 'archive' && !token.startsWith('kastle_runtime_')))
30
+ if (!token || (destination.kind === 'kingdom' && !token.startsWith('kastle_runtime_')))
20
31
  throw new Error('Archive runtime credential unavailable');
21
- const response = await transport(new URL(`api/v1/archive/${action}`, url), {
32
+ const path =
33
+ destination.kind === 'kingdom' && destination.connectionId ? `remote/${action}` : action;
34
+ const response = await transport(new URL(`api/v1/archive/${path}`, url), {
22
35
  method: 'POST',
23
36
  redirect: 'error',
24
37
  signal: AbortSignal.timeout(30_000),
@@ -41,19 +54,14 @@ export async function publishArchive(
41
54
  if (!archive || archive.snapshot.projectId !== destination.projectId)
42
55
  throw new Error('Archive is outside destination project');
43
56
  const receiptKey = destinationIdentity(destination);
44
- const keepIds = [...new Set(destination.keepIds)].sort();
45
57
  let sent = false;
46
58
  for (let attempt = 0; attempt < 8; attempt++) {
47
59
  const latest = store.read(id)!;
48
60
  const previousDigest = store.receipt(id, receiptKey);
49
61
  let pending = store.pending(id, receiptKey);
50
62
  if (!pending) {
51
- if (
52
- previousDigest === latest.digest &&
53
- store.receipt(id, `${receiptKey}:keeps`) === JSON.stringify(keepIds)
54
- )
55
- return { unchanged: !sent };
56
- store.enqueue(id, receiptKey, { revision: latest.revision, keepIds });
63
+ if (previousDigest === latest.digest) return { unchanged: !sent };
64
+ store.enqueue(id, receiptKey, { revision: latest.revision, keepIds: [] });
57
65
  pending = store.pending(id, receiptKey)!;
58
66
  }
59
67
  const queued = store.read(id, pending.revision);
@@ -63,16 +71,14 @@ export async function publishArchive(
63
71
  destination,
64
72
  'ingest',
65
73
  {
66
- ...(destination.kind === 'archive'
67
- ? {}
68
- : { kastleId: destination.kastleId, keepIds: pending.keepIds }),
74
+ ...kingdomFields(destination),
69
75
  previousDigest,
70
76
  snapshot: queued.snapshot,
71
77
  },
72
78
  transport,
73
79
  );
74
80
  if (body.data?.digest !== queued.digest) throw new Error('Archive acknowledgement mismatch');
75
- store.delivered(id, receiptKey, queued.digest, JSON.stringify(pending.keepIds));
81
+ store.delivered(id, receiptKey, queued.digest, '[]');
76
82
  sent = true;
77
83
  }
78
84
  throw new Error('Archive changed repeatedly during publication; retry sync');
@@ -87,9 +93,9 @@ export function routingPreview(store: LocalArchiveStore, destinations: ArchiveDe
87
93
  destinations: destinations
88
94
  .filter((d) => d.projectId === archive.projectId)
89
95
  .map((d) => ({
90
- kind: d.kind ?? 'kingdom',
96
+ kind: d.kind,
91
97
  url: d.url,
92
- ...(d.kind === 'archive' ? {} : { kastleId: d.kastleId }),
98
+ ...kingdomFields(d),
93
99
  })),
94
100
  }));
95
101
  }
@@ -129,7 +135,7 @@ export async function searchRemotes(
129
135
  budget,
130
136
  ...(destination.kind === 'archive'
131
137
  ? { projectId: destination.projectId }
132
- : { kastleId: destination.kastleId }),
138
+ : kingdomFields(destination)),
133
139
  });
134
140
  return {
135
141
  destination: destination.url,
package/src/collector.ts CHANGED
@@ -1,15 +1,19 @@
1
- import { createReadStream, lstatSync, readdirSync } from 'node:fs';
2
- import { isAbsolute, join, relative, resolve } from 'node:path';
3
- import { createInterface } from 'node:readline';
1
+ import { closeSync, existsSync, lstatSync, openSync, readdirSync, readSync } from 'node:fs';
2
+ import { isAbsolute, join, resolve } from 'node:path';
4
3
  import { importTranscriptFile } from './import-file';
5
4
  import { archiveKey } from './index';
6
5
  import type { LocalArchiveStore } from './local';
6
+ import { type AtlasFileConcepts, atlasTags, provenanceTags } from './tags';
7
7
 
8
8
  export interface CollectionSource {
9
9
  directory: string;
10
10
  source: 'codex' | 'claude-code';
11
- projectRoot: string;
11
+ projectRoots: string[];
12
12
  projectId: string;
13
+ /** Also match the git worktrees of each root; nested checkouts stay separate. */
14
+ worktrees?: boolean;
15
+ /** Tag sessions with the Atlas concepts of files they touched, from `atlas graph` in the session's checkout. */
16
+ atlas?: boolean;
13
17
  tags?: string[];
14
18
  }
15
19
 
@@ -25,36 +29,88 @@ function files(directory: string): string[] {
25
29
  return result;
26
30
  }
27
31
 
32
+ type SessionMetadata = { cwd: string; branch?: string; repository?: string };
33
+
28
34
  /** Match provider metadata, never text/tags/model suggestions. No implicit parent-directory routing. */
29
- async function sessionDirectory(file: string, source: CollectionSource['source']) {
30
- const input = createReadStream(file, { encoding: 'utf8', end: 1_000_000 });
31
- const lines = createInterface({ input, crlfDelay: Infinity });
35
+ function sessionMetadata(
36
+ file: string,
37
+ source: CollectionSource['source'],
38
+ ): SessionMetadata | undefined {
39
+ // A bounded synchronous read: streamed readline could leave a watch loop waiting forever.
40
+ const buffer = Buffer.alloc(Math.min(lstatSync(file).size, 1_000_000));
41
+ const fd = openSync(file, 'r');
32
42
  try {
33
- for await (const line of lines) {
34
- let row: any;
35
- try {
36
- row = JSON.parse(line);
37
- } catch {
38
- return undefined;
39
- }
40
- const cwd =
41
- source === 'codex' ? (row.type === 'session_meta' ? row.payload?.cwd : undefined) : row.cwd;
42
- if (typeof cwd === 'string' && isAbsolute(cwd)) return resolve(cwd);
43
- }
43
+ readSync(fd, buffer, 0, buffer.length, 0);
44
44
  } finally {
45
- lines.close();
46
- input.destroy();
45
+ closeSync(fd);
46
+ }
47
+ for (const line of buffer.toString('utf8').split('\n')) {
48
+ if (!line) continue;
49
+ let row: any;
50
+ try {
51
+ row = JSON.parse(line);
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ const meta = source === 'codex' ? (row.type === 'session_meta' ? row.payload : undefined) : row;
56
+ if (typeof meta?.cwd !== 'string' || !isAbsolute(meta.cwd)) continue;
57
+ const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
58
+ return source === 'codex'
59
+ ? {
60
+ cwd: resolve(meta.cwd),
61
+ branch: text(meta.git?.branch),
62
+ repository: text(meta.git?.repository_url),
63
+ }
64
+ : { cwd: resolve(meta.cwd), branch: text(meta.gitBranch) };
47
65
  }
48
66
  }
49
67
 
68
+ const git = (cwd: string, ...args: string[]) => {
69
+ const run = Bun.spawnSync(['git', '-C', cwd, ...args], { stderr: 'ignore' });
70
+ return run.success ? run.stdout.toString().trim() : undefined;
71
+ };
72
+
73
+ function atlasGraph(cwd: string): AtlasFileConcepts | undefined {
74
+ if (!existsSync(`${cwd}/.atlas`)) return;
75
+ const run = Bun.spawnSync([process.execPath, 'x', 'atlas', 'graph', '--json'], {
76
+ cwd,
77
+ stderr: 'ignore',
78
+ });
79
+ if (!run.success) return;
80
+ try {
81
+ return JSON.parse(run.stdout.toString()).fileToConcepts;
82
+ } catch {}
83
+ }
84
+
85
+ function worktreesOf(root: string): string[] {
86
+ const listed = Bun.spawnSync(['git', '-C', root, 'worktree', 'list', '--porcelain'], {
87
+ stderr: 'ignore',
88
+ });
89
+ if (!listed.success) return [];
90
+ return listed.stdout
91
+ .toString()
92
+ .split('\n')
93
+ .filter((line) => line.startsWith('worktree '))
94
+ .map((line) => resolve(line.slice('worktree '.length)));
95
+ }
96
+
97
+ /** Exact directories this collection accepts, re-read each scan so new worktrees join. */
98
+ export function collectionRoots(config: CollectionSource): Set<string> {
99
+ const roots = config.projectRoots.map((root) => resolve(root));
100
+ return new Set(config.worktrees ? [...roots, ...roots.flatMap(worktreesOf)] : roots);
101
+ }
102
+
50
103
  export async function collectSessions(store: LocalArchiveStore, config: CollectionSource) {
51
- const root = resolve(config.projectRoot);
104
+ const roots = collectionRoots(config);
52
105
  const result = { imported: 0, unchanged: 0, skipped: 0, failed: 0 };
106
+ // Per scan: checkouts change between scans.
107
+ const repositories = new Map<string, string | undefined>();
108
+ const graphs = new Map<string, AtlasFileConcepts | undefined>();
53
109
  for (const file of files(config.directory)) {
54
110
  try {
55
- const cwd = await sessionDirectory(file, config.source);
111
+ const meta = sessionMetadata(file, config.source);
56
112
  // Exact root selection avoids sweeping nested checkouts belonging to another organization.
57
- if (!cwd || relative(root, cwd) !== '') {
113
+ if (!meta || !roots.has(meta.cwd)) {
58
114
  result.skipped++;
59
115
  continue;
60
116
  }
@@ -74,7 +130,21 @@ export async function collectSessions(store: LocalArchiveStore, config: Collecti
74
130
  result.failed++;
75
131
  continue;
76
132
  }
77
- snapshot.tags = [...new Set([...(old?.snapshot.tags ?? []), ...(config.tags ?? [])])];
133
+ const { cwd } = meta;
134
+ if (!repositories.has(cwd)) repositories.set(cwd, git(cwd, 'remote', 'get-url', 'origin'));
135
+ if (config.atlas && !graphs.has(cwd)) graphs.set(cwd, atlasGraph(cwd));
136
+ const graph = graphs.get(cwd);
137
+ snapshot.tags = [
138
+ ...new Set([
139
+ ...(old?.snapshot.tags ?? []),
140
+ ...(config.tags ?? []),
141
+ ...provenanceTags({
142
+ branch: meta.branch,
143
+ repository: meta.repository ?? repositories.get(cwd),
144
+ }),
145
+ ...(graph ? atlasTags(snapshot, graph, roots) : []),
146
+ ]),
147
+ ].slice(0, 100);
78
148
  if (store.capture(snapshot).changed) result.imported++;
79
149
  else result.unchanged++;
80
150
  } catch {
package/src/config.ts CHANGED
@@ -8,17 +8,28 @@ const common = {
8
8
  url: z.url(),
9
9
  tokenEnv: z.string().regex(/^[A-Z][A-Z0-9_]+$/),
10
10
  };
11
+ /** Kingdom derives the owner from the runtime credential; these narrow it to a space or organization it manages. */
12
+ export const kingdomOwnerFields = {
13
+ ownerModel: z.enum(['User', 'OrganizationUser', 'Organization', 'Space', 'SpaceUser']).optional(),
14
+ organizationId: z.uuid().optional(),
15
+ spaceId: z.uuid().optional(),
16
+ };
11
17
  export const archiveDestinationSchema = z.union([
12
18
  z.strictObject({
13
19
  ...common,
14
20
  kind: z.literal('archive'),
15
- keepIds: z.array(z.string()).max(0).default([]),
21
+ keepIds: z.array(z.string()).max(0).optional(),
16
22
  }),
17
23
  z.strictObject({
18
24
  ...common,
19
- kind: z.literal('kingdom').optional(),
20
- kastleId: z.uuid(),
21
- keepIds: z.array(z.uuid()).max(50).default([]),
25
+ kind: z.literal('kingdom'),
26
+ /** A hosted Archive that Kingdom forwards to; omitted, Kingdom stores the archive itself. */
27
+ connectionId: z
28
+ .string()
29
+ .regex(/^[a-z0-9-]+$/)
30
+ .max(120)
31
+ .optional(),
32
+ ...kingdomOwnerFields,
22
33
  }),
23
34
  ]);
24
35
  export type ArchiveDestination = z.infer<typeof archiveDestinationSchema>;
@@ -52,7 +63,14 @@ export function destinationIdentity(destination: ArchiveDestination) {
52
63
  return JSON.stringify([
53
64
  url.origin,
54
65
  new URL(destination.url).pathname,
55
- destination.kind === 'archive' ? 'standalone' : destination.kastleId,
66
+ destination.kind === 'archive'
67
+ ? 'standalone'
68
+ : [
69
+ destination.connectionId ?? 'kingdom',
70
+ destination.ownerModel ?? null,
71
+ destination.organizationId ?? null,
72
+ destination.spaceId ?? null,
73
+ ],
56
74
  ]);
57
75
  }
58
76
  export function connectDestination(file: string, input: unknown) {
@@ -72,7 +90,7 @@ export function connectDestination(file: string, input: unknown) {
72
90
  chmodSync(file, 0o600);
73
91
  return {
74
92
  configured: true,
75
- kind: destination.kind ?? 'kingdom',
93
+ kind: destination.kind,
76
94
  projectId: destination.projectId,
77
95
  url: destination.url,
78
96
  tokenEnv: destination.tokenEnv,
package/src/import.ts CHANGED
@@ -40,8 +40,13 @@ export function importTranscriptLines(
40
40
  sessionId ??= id;
41
41
  }
42
42
  if (options.source === 'claude-code' && row.sessionId) {
43
- if (sessionId && sessionId !== row.sessionId) throw new Error('Mixed transcript sessions');
44
- sessionId = row.sessionId;
43
+ // Subagent transcripts reuse their parent's sessionId; each is its own archive.
44
+ const id =
45
+ row.isSidechain && typeof row.agentId === 'string'
46
+ ? `${row.sessionId}/agent-${row.agentId}`
47
+ : row.sessionId;
48
+ if (sessionId && sessionId !== id) throw new Error('Mixed transcript sessions');
49
+ sessionId = id;
45
50
  }
46
51
  const timestamp = typeof row.timestamp === 'number' ? row.timestamp : Date.parse(row.timestamp);
47
52
  const add = (kind: ArchiveEntry['kind'], content: unknown, suffix: string, callId?: string) => {
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@ export const archiveSnapshotSchema = z
16
16
  .strictObject({
17
17
  schemaVersion: z.literal(1),
18
18
  sourceId: z.uuid(),
19
- source: z.enum(['foundry', 'claude-code', 'codex']),
19
+ source: z.enum(['foundry', 'claude-code', 'codex', 'chatgpt']),
20
20
  sessionId: identifier,
21
21
  title: z.string().min(1).max(500),
22
22
  projectId: identifier.optional(),
package/src/local.ts CHANGED
@@ -7,11 +7,11 @@ import {
7
7
  type ArchiveSnapshot,
8
8
  archiveKey,
9
9
  archiveSnapshotSchema,
10
- categorySuggestions,
11
10
  chunkArchive,
12
11
  selectChunks,
13
12
  snapshotDigest,
14
13
  } from './index';
14
+ import { suggestTags } from './tags';
15
15
 
16
16
  export class LocalArchiveStore {
17
17
  private readonly db: Database;
@@ -94,7 +94,7 @@ export class LocalArchiveStore {
94
94
  digest: archive.digest,
95
95
  ...snapshot,
96
96
  entries: entries.length,
97
- suggestedTags: categorySuggestions(archive.snapshot),
97
+ suggestedTags: suggestTags(archive.snapshot),
98
98
  };
99
99
  });
100
100
  }
package/src/server.ts CHANGED
@@ -12,6 +12,7 @@ const ingestSchema = z.strictObject({
12
12
  });
13
13
  const searchSchema = z.strictObject({
14
14
  query: z.string().max(1000).default(''),
15
+ tag: z.string().min(1).max(120).optional(),
15
16
  budget: z.number().int().min(16).max(32768).default(2048),
16
17
  projectId: z.string().max(256).optional(),
17
18
  limit: z.number().int().min(1).max(100).default(20),
@@ -75,7 +76,11 @@ export function createArchiveHandler(store: LocalArchiveStore, token: string) {
75
76
  let remaining = input.budget;
76
77
  const matching = store
77
78
  .list()
78
- .filter((a) => !input.projectId || a.projectId === input.projectId)
79
+ .filter(
80
+ (a) =>
81
+ (!input.projectId || a.projectId === input.projectId) &&
82
+ (!input.tag || a.tags.includes(input.tag)),
83
+ )
79
84
  .map((a) => ({
80
85
  a,
81
86
  selected: selectChunks(store.read(a.id)!.chunks, input.query, input.budget),
package/src/tags.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { type ArchiveSnapshot, categorySuggestions } from './index';
2
+
3
+ export type TagSuggestion = { tag: string; origin: 'heuristic' | 'reference' };
4
+
5
+ const references: [RegExp, (match: RegExpExecArray) => string][] = [
6
+ [
7
+ /github\.com\/([\w.-]+)\/([\w.-]+)\/(?:pull|issues)\/(\d+)/g,
8
+ (m) => `github:${m[1]}/${m[2]}#${m[3]}`.toLowerCase(),
9
+ ],
10
+ [
11
+ /github\.com\/([\w.-]+)\/([\w.-]+)\/commit\/([0-9a-f]{7,40})\b/g,
12
+ (m) => `github:${m[1]}/${m[2]}@${m[3].slice(0, 7)}`.toLowerCase(),
13
+ ],
14
+ [
15
+ /linear\.app\/[\w-]+\/issue\/([A-Za-z][A-Za-z0-9]*-\d+)/g,
16
+ (m) => `linear:${m[1].toUpperCase()}`,
17
+ ],
18
+ ];
19
+
20
+ /** Issues, pull requests and commits the transcript links to, most-referenced first. A mention is not proof of work, so these stay suggestions. */
21
+ export function referenceSuggestions(snapshot: ArchiveSnapshot, limit = 25): TagSuggestion[] {
22
+ const counts = new Map<string, number>();
23
+ for (const entry of snapshot.entries)
24
+ for (const [pattern, tag] of references)
25
+ for (const match of entry.text.matchAll(pattern)) {
26
+ const value = tag(match);
27
+ counts.set(value, (counts.get(value) ?? 0) + 1);
28
+ }
29
+ return [...counts]
30
+ .sort((a, b) => b[1] - a[1])
31
+ .slice(0, limit)
32
+ .map(([tag]) => ({ tag, origin: 'reference' }));
33
+ }
34
+
35
+ /** Every computed suggestion for an archive. Suggestions never route or authorize. */
36
+ export function suggestTags(snapshot: ArchiveSnapshot): TagSuggestion[] {
37
+ return [...categorySuggestions(snapshot), ...referenceSuggestions(snapshot)];
38
+ }
39
+
40
+ /** Recorded git context of a session, as explicit tags. */
41
+ export function provenanceTags(context: { repository?: string; branch?: string }): string[] {
42
+ const repository = context.repository?.match(/github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/);
43
+ return [
44
+ ...(repository ? [`repo:${repository[1].toLowerCase()}`] : []),
45
+ ...(context.branch && context.branch !== 'HEAD' ? [`branch:${context.branch}`] : []),
46
+ ].filter((tag) => tag.length <= 120);
47
+ }
48
+
49
+ /** `atlas graph --json`: repository-relative file paths to the concepts they are part of. */
50
+ export type AtlasFileConcepts = Record<string, { partOf: string[] }>;
51
+
52
+ /** Atlas concepts of the repository files a session's tool calls touched. */
53
+ export function atlasTags(
54
+ snapshot: ArchiveSnapshot,
55
+ fileToConcepts: AtlasFileConcepts,
56
+ roots: Iterable<string>,
57
+ limit = 20,
58
+ ): string[] {
59
+ // Longest first: worktrees can live inside their main checkout.
60
+ const prefixes = [...roots]
61
+ .map((root) => `${root.replace(/\/+$/, '')}/`)
62
+ .sort((a, b) => b.length - a.length);
63
+ const counts = new Map<string, number>();
64
+ for (const entry of snapshot.entries) {
65
+ if (entry.kind !== 'tool-call') continue;
66
+ for (const [path] of entry.text.matchAll(/[\w@.~/-]+\.[A-Za-z]{1,6}\b/g)) {
67
+ const prefix = prefixes.find((candidate) => path.startsWith(candidate));
68
+ const relative = prefix ? path.slice(prefix.length) : path;
69
+ for (const concept of fileToConcepts[relative]?.partOf ?? [])
70
+ counts.set(concept, (counts.get(concept) ?? 0) + 1);
71
+ }
72
+ }
73
+ return [...counts]
74
+ .sort((a, b) => b[1] - a[1])
75
+ .slice(0, limit)
76
+ .map(([concept]) => `atlas:${concept}`)
77
+ .filter((tag) => tag.length <= 120);
78
+ }