@inixiative/archive 0.2.1 → 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 +14 -6
- package/package.json +4 -3
- package/src/cli.ts +22 -8
- package/src/client.ts +22 -16
- package/src/collector.ts +94 -24
- package/src/config.ts +24 -6
- package/src/import.ts +7 -2
- package/src/local.ts +2 -2
- package/src/tags.ts +78 -0
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
|
-
|
|
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 --
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/archive",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"./preview": "./src/preview.ts",
|
|
14
14
|
"./cli": "./src/cli.ts",
|
|
15
15
|
"./collector": "./src/collector.ts",
|
|
16
|
-
"./chatgpt": "./src/chatgpt.ts"
|
|
16
|
+
"./chatgpt": "./src/chatgpt.ts",
|
|
17
|
+
"./tags": "./src/tags.ts"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
19
20
|
"src"
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
37
|
"@biomejs/biome": "2.5.2",
|
|
37
|
-
"@inixiative/config": "^0.5.
|
|
38
|
+
"@inixiative/config": "^0.5.36",
|
|
38
39
|
"@types/bun": "1.4.2",
|
|
39
40
|
"lefthook": "2.1.9",
|
|
40
41
|
"typescript": "6.0.3"
|
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { parseArgs } from 'node:util';
|
|
|
7
7
|
import { importChatGPTThread } from './chatgpt';
|
|
8
8
|
import {
|
|
9
9
|
archiveRequest,
|
|
10
|
+
kingdomFields,
|
|
10
11
|
publishArchive,
|
|
11
12
|
routingPreview,
|
|
12
13
|
searchRemotes,
|
|
@@ -31,8 +32,10 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
31
32
|
url: { type: 'string' },
|
|
32
33
|
kind: { type: 'string', default: 'archive' },
|
|
33
34
|
'project-id': { type: 'string' },
|
|
34
|
-
'
|
|
35
|
-
'
|
|
35
|
+
'connection-id': { type: 'string' },
|
|
36
|
+
'owner-model': { type: 'string' },
|
|
37
|
+
'organization-id': { type: 'string' },
|
|
38
|
+
'space-id': { type: 'string' },
|
|
36
39
|
'token-env': { type: 'string', default: 'ARCHIVE_TOKEN' },
|
|
37
40
|
'token-file': { type: 'string' },
|
|
38
41
|
file: { type: 'string' },
|
|
@@ -42,7 +45,9 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
42
45
|
'session-id': { type: 'string' },
|
|
43
46
|
id: { type: 'string' },
|
|
44
47
|
query: { type: 'string', default: '' },
|
|
45
|
-
'project-root': { type: 'string' },
|
|
48
|
+
'project-root': { type: 'string', multiple: true },
|
|
49
|
+
worktrees: { type: 'boolean', default: false },
|
|
50
|
+
atlas: { type: 'boolean', default: false },
|
|
46
51
|
tag: { type: 'string', multiple: true },
|
|
47
52
|
limit: { type: 'string', default: '100' },
|
|
48
53
|
port: { type: 'string', default: process.env.PORT ?? '4411' },
|
|
@@ -61,9 +66,9 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
61
66
|
if (v.help || !command) {
|
|
62
67
|
console.log(
|
|
63
68
|
'Archive: init | serve | connect (setup) | preview | import | collect | list | export | tag | routes | sync | search\n' +
|
|
64
|
-
'connect --url HTTPS_URL --project-id ID --token-env ENV [--kind kingdom --
|
|
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' +
|
|
65
70
|
'import --file PATH --source codex|claude-code --project-id ID [--tag TAG]\n' +
|
|
66
|
-
'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' +
|
|
67
72
|
'sync [--watch] | routes | search --query TEXT [--remote]\n' +
|
|
68
73
|
'serve --home PATH [--port 4411 --hostname 127.0.0.1]\n' +
|
|
69
74
|
'All commands accept --home PATH; integrations may use --store FILE --config FILE.',
|
|
@@ -107,7 +112,14 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
107
112
|
url: v.url,
|
|
108
113
|
tokenEnv: v['token-env'],
|
|
109
114
|
kind: v.kind,
|
|
110
|
-
...(v.kind === 'kingdom'
|
|
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
|
+
: {}),
|
|
111
123
|
});
|
|
112
124
|
// Verify credentials and protocol before saving; no records uploaded by setup.
|
|
113
125
|
const probe = await archiveRequest(destination, 'search', {
|
|
@@ -116,7 +128,7 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
116
128
|
limit: 1,
|
|
117
129
|
...(destination.kind === 'archive'
|
|
118
130
|
? { projectId: destination.projectId }
|
|
119
|
-
:
|
|
131
|
+
: kingdomFields(destination)),
|
|
120
132
|
});
|
|
121
133
|
if (!Array.isArray(probe.data?.archives))
|
|
122
134
|
throw new Error('Destination is not an Archive-compatible endpoint');
|
|
@@ -195,7 +207,9 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
195
207
|
const result = await collectSessions(store, {
|
|
196
208
|
directory: v.directory!,
|
|
197
209
|
source: v.source as 'codex' | 'claude-code',
|
|
198
|
-
|
|
210
|
+
projectRoots: v['project-root']!,
|
|
211
|
+
worktrees: v.worktrees,
|
|
212
|
+
atlas: v.atlas,
|
|
199
213
|
projectId: v['project-id']!,
|
|
200
214
|
tags: v.tag,
|
|
201
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
|
|
30
|
+
if (!token || (destination.kind === 'kingdom' && !token.startsWith('kastle_runtime_')))
|
|
20
31
|
throw new Error('Archive runtime credential unavailable');
|
|
21
|
-
const
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
96
|
+
kind: d.kind,
|
|
91
97
|
url: d.url,
|
|
92
|
-
...(d
|
|
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
|
-
:
|
|
138
|
+
: kingdomFields(destination)),
|
|
133
139
|
});
|
|
134
140
|
return {
|
|
135
141
|
destination: destination.url,
|
package/src/collector.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { isAbsolute, join,
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
111
|
+
const meta = sessionMetadata(file, config.source);
|
|
56
112
|
// Exact root selection avoids sweeping nested checkouts belonging to another organization.
|
|
57
|
-
if (!
|
|
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
|
-
|
|
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).
|
|
21
|
+
keepIds: z.array(z.string()).max(0).optional(),
|
|
16
22
|
}),
|
|
17
23
|
z.strictObject({
|
|
18
24
|
...common,
|
|
19
|
-
kind: z.literal('kingdom')
|
|
20
|
-
|
|
21
|
-
|
|
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'
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
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/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:
|
|
97
|
+
suggestedTags: suggestTags(archive.snapshot),
|
|
98
98
|
};
|
|
99
99
|
});
|
|
100
100
|
}
|
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
|
+
}
|