@inixiative/archive 0.2.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/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +57 -0
- package/src/cli.ts +238 -0
- package/src/client.ts +148 -0
- package/src/collector.ts +85 -0
- package/src/config.ts +80 -0
- package/src/import-file.ts +45 -0
- package/src/import.ts +173 -0
- package/src/index.ts +172 -0
- package/src/local.ts +150 -0
- package/src/preview.ts +57 -0
- package/src/server.ts +155 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Inixiative
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Archive
|
|
2
|
+
|
|
3
|
+
MIT-licensed, local-first session storage for Claude Code, Codex and Foundry. Run it independently or connect through Foundry and Kingdom/Kastles.
|
|
4
|
+
|
|
5
|
+
## What runs today
|
|
6
|
+
|
|
7
|
+
- Public transcript extraction, exact-project collection, immutable SQLite revisions, local lexical search and lossless text chunks.
|
|
8
|
+
- A standalone authenticated HTTP server, Docker image, persistent Compose deployment, Railway configuration and a Render blueprint.
|
|
9
|
+
- Multiple explicit destinations, durable publication receipts/outbox, conflict detection and retry after interruption.
|
|
10
|
+
- Native Foundry capture and context retrieval; Kingdom's existing Archives browser, ownership and sharing APIs.
|
|
11
|
+
|
|
12
|
+
This is an initial pilot, not a general multi-user standalone hosting service. Each standalone deployment is one ownership boundary with one access token. Kingdom supplies account-based sharing. Use distinct storage and credentials for personal and organization archives.
|
|
13
|
+
|
|
14
|
+
## Local setup
|
|
15
|
+
|
|
16
|
+
Install Bun 1.4.2 or later, then install the published CLI:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
bun add --global @inixiative/archive
|
|
20
|
+
archive init
|
|
21
|
+
archive serve
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
For development from source:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
bun install
|
|
28
|
+
bun run archive init
|
|
29
|
+
bun run archive serve
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`init` creates a local database and a private `server.token` under `~/.local/share/archive`. It prints the token's file path, never its value. `serve` binds to loopback port 4411. The server requires bearer authentication for data endpoints; `/health` has no session data.
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
bun run archive preview --directory /path/to/history --source codex
|
|
36
|
+
bun run archive import --file /path/to/session.jsonl --source codex --project-id inixiative --tag coding
|
|
37
|
+
bun run archive collect --directory /path/to/history --source codex --project-root /exact/session/cwd --project-id inixiative --watch
|
|
38
|
+
bun run archive list
|
|
39
|
+
bun run archive search --query 'migration'
|
|
40
|
+
```
|
|
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.
|
|
43
|
+
|
|
44
|
+
## Connect to BYO hosting
|
|
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.
|
|
47
|
+
|
|
48
|
+
Place the destination's token in an environment variable, then run:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
bun run archive connect --url https://your-archive.example --project-id inixiative --token-env INIXIATIVE_ARCHIVE_TOKEN
|
|
52
|
+
bun run archive routes
|
|
53
|
+
bun run archive sync
|
|
54
|
+
bun run archive sync --watch
|
|
55
|
+
bun run archive search --remote --query 'migration'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`setup` is an alias for `connect`. Setup verifies access before saving configuration and does not upload. The URL locates the server; the token grants access. Configuration stores only the environment variable name. Secrets must be available in the collector/sync process environment. Redirects are refused; only HTTPS or loopback HTTP is allowed.
|
|
59
|
+
|
|
60
|
+
`--home PATH`, `--store FILE` and `--config FILE` support custom paths. Keep the database with its source UUID when moving machines. Back up the entire database; a new store creates a new source identity.
|
|
61
|
+
|
|
62
|
+
## Connect through Kingdom
|
|
63
|
+
|
|
64
|
+
Use Kingdom's existing runtime enrollment for a user who manages the destination Kastle, then:
|
|
65
|
+
|
|
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
|
|
68
|
+
```
|
|
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.
|
|
71
|
+
|
|
72
|
+
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
|
+
|
|
74
|
+
## Routing, tagging and Jev
|
|
75
|
+
|
|
76
|
+
Only explicit `projectId` matches authorize publication. An archive with no matching destination stays local. Multiple matches create deliberate separate copies. `routes` previews these destinations before upload.
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
bun run archive tag --id ARCHIVE_ID --tag reviewed
|
|
80
|
+
```
|
|
81
|
+
|
|
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.
|
|
83
|
+
|
|
84
|
+
## Limits
|
|
85
|
+
|
|
86
|
+
- Public text/tool records only; no reconstruction of private reasoning or unsupported media. Coverage accompanies each snapshot.
|
|
87
|
+
- No automatic secret scrubber. Publication includes recorded public session content.
|
|
88
|
+
- Sync currently publishes local records to destinations; it does not mirror remote records locally or propagate deletions.
|
|
89
|
+
- Local search budgets are per session; hosted search budgets are across a response. Remote search queries every configured route; each response identifies its destination.
|
|
90
|
+
- No automatic remote deletion when routing changes. Existing copies keep their original owner's access rules.
|
|
91
|
+
- Deployment credentials, operational receipts and machine-specific routing belong outside the repository.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
bun test
|
|
97
|
+
bun run typecheck
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
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.
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@inixiative/archive",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./local": "./src/local.ts",
|
|
8
|
+
"./import": "./src/import.ts",
|
|
9
|
+
"./client": "./src/client.ts",
|
|
10
|
+
"./config": "./src/config.ts",
|
|
11
|
+
"./server": "./src/server.ts",
|
|
12
|
+
"./import-file": "./src/import-file.ts",
|
|
13
|
+
"./preview": "./src/preview.ts",
|
|
14
|
+
"./cli": "./src/cli.ts",
|
|
15
|
+
"./collector": "./src/collector.ts"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "bun test tests",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"archive": "bun run src/cli.ts",
|
|
24
|
+
"start": "bun run src/cli.ts serve",
|
|
25
|
+
"check": "bun run typecheck && bun run lint && bun run test",
|
|
26
|
+
"prepublishOnly": "bun run check",
|
|
27
|
+
"lint": "biome check .",
|
|
28
|
+
"prepare": "lefthook install"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"js-tiktoken": "1.0.21",
|
|
32
|
+
"zod": "^4.3.6"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "1.4.2",
|
|
36
|
+
"typescript": "6.0.3",
|
|
37
|
+
"@biomejs/biome": "2.5.2",
|
|
38
|
+
"@inixiative/config": "^0.5.33",
|
|
39
|
+
"lefthook": "2.1.9"
|
|
40
|
+
},
|
|
41
|
+
"bin": {
|
|
42
|
+
"archive": "src/cli.ts"
|
|
43
|
+
},
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"description": "Portable Claude Code, Codex and Foundry session archives: extraction, local storage, search and hosted sync",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/inixiative/archive.git"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"bun": ">=1.4.2"
|
|
55
|
+
},
|
|
56
|
+
"packageManager": "bun@1.4.2"
|
|
57
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import {
|
|
8
|
+
archiveRequest,
|
|
9
|
+
publishArchive,
|
|
10
|
+
routingPreview,
|
|
11
|
+
searchRemotes,
|
|
12
|
+
syncArchives,
|
|
13
|
+
} from './client';
|
|
14
|
+
import { collectSessions } from './collector';
|
|
15
|
+
import { archiveDestinationSchema, connectDestination, readDestinations } from './config';
|
|
16
|
+
import { importTranscriptFile } from './import-file';
|
|
17
|
+
import { archiveKey } from './index';
|
|
18
|
+
import { LocalArchiveStore } from './local';
|
|
19
|
+
import { previewImports } from './preview';
|
|
20
|
+
import { startArchiveServer } from './server';
|
|
21
|
+
|
|
22
|
+
export async function runCli(args = Bun.argv.slice(2)) {
|
|
23
|
+
const { values: v, positionals } = parseArgs({
|
|
24
|
+
args,
|
|
25
|
+
allowPositionals: true,
|
|
26
|
+
options: {
|
|
27
|
+
home: { type: 'string', default: join(homedir(), '.local/share/archive') },
|
|
28
|
+
store: { type: 'string' },
|
|
29
|
+
config: { type: 'string' },
|
|
30
|
+
url: { type: 'string' },
|
|
31
|
+
kind: { type: 'string', default: 'archive' },
|
|
32
|
+
'project-id': { type: 'string' },
|
|
33
|
+
'kastle-id': { type: 'string' },
|
|
34
|
+
'keep-id': { type: 'string', multiple: true },
|
|
35
|
+
'token-env': { type: 'string', default: 'ARCHIVE_TOKEN' },
|
|
36
|
+
'token-file': { type: 'string' },
|
|
37
|
+
file: { type: 'string' },
|
|
38
|
+
directory: { type: 'string' },
|
|
39
|
+
source: { type: 'string' },
|
|
40
|
+
title: { type: 'string' },
|
|
41
|
+
'session-id': { type: 'string' },
|
|
42
|
+
id: { type: 'string' },
|
|
43
|
+
query: { type: 'string', default: '' },
|
|
44
|
+
'project-root': { type: 'string' },
|
|
45
|
+
tag: { type: 'string', multiple: true },
|
|
46
|
+
limit: { type: 'string', default: '100' },
|
|
47
|
+
port: { type: 'string', default: process.env.PORT ?? '4411' },
|
|
48
|
+
hostname: { type: 'string', default: '127.0.0.1' },
|
|
49
|
+
watch: { type: 'boolean', default: false },
|
|
50
|
+
remote: { type: 'boolean', default: false },
|
|
51
|
+
destination: { type: 'string' },
|
|
52
|
+
help: { type: 'boolean' },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const home = resolve(v.home!),
|
|
56
|
+
config = v.config ?? join(home, 'destinations.json');
|
|
57
|
+
const storePath = v.store ?? join(home, 'archives.sqlite');
|
|
58
|
+
const output = (value: unknown) => console.log(JSON.stringify(value, null, 2));
|
|
59
|
+
const command = positionals[0];
|
|
60
|
+
if (v.help || !command) {
|
|
61
|
+
console.log(
|
|
62
|
+
'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' +
|
|
64
|
+
'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' +
|
|
66
|
+
'sync [--watch] | routes | search --query TEXT [--remote]\n' +
|
|
67
|
+
'serve --home PATH [--port 4411 --hostname 127.0.0.1]\n' +
|
|
68
|
+
'All commands accept --home PATH; integrations may use --store FILE --config FILE.',
|
|
69
|
+
);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (command === 'init') {
|
|
73
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
74
|
+
const tokenFile = join(home, 'server.token');
|
|
75
|
+
if (!existsSync(tokenFile))
|
|
76
|
+
writeFileSync(tokenFile, randomBytes(32).toString('hex') + '\n', { mode: 0o600, flag: 'wx' });
|
|
77
|
+
chmodSync(tokenFile, 0o600);
|
|
78
|
+
const store = new LocalArchiveStore(storePath);
|
|
79
|
+
store.close();
|
|
80
|
+
output({ initialized: true, home, store: storePath, tokenFile });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (command === 'serve') {
|
|
84
|
+
const tokenFile = v['token-file'] ?? join(home, 'server.token');
|
|
85
|
+
const token =
|
|
86
|
+
process.env.ARCHIVE_SERVER_TOKEN ??
|
|
87
|
+
(existsSync(tokenFile) ? readFileSync(tokenFile, 'utf8').trim() : '');
|
|
88
|
+
const instance = startArchiveServer({
|
|
89
|
+
store: storePath,
|
|
90
|
+
token,
|
|
91
|
+
port: Number(v.port),
|
|
92
|
+
hostname: v.hostname,
|
|
93
|
+
});
|
|
94
|
+
output({ listening: instance.server.url.href, store: storePath });
|
|
95
|
+
const stop = async () => {
|
|
96
|
+
await instance.close();
|
|
97
|
+
process.exit(0);
|
|
98
|
+
};
|
|
99
|
+
process.once('SIGTERM', stop);
|
|
100
|
+
process.once('SIGINT', stop);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (command === 'connect' || command === 'setup') {
|
|
104
|
+
const destination = archiveDestinationSchema.parse({
|
|
105
|
+
projectId: v['project-id'],
|
|
106
|
+
url: v.url,
|
|
107
|
+
tokenEnv: v['token-env'],
|
|
108
|
+
kind: v.kind,
|
|
109
|
+
...(v.kind === 'kingdom' ? { kastleId: v['kastle-id'], keepIds: v['keep-id'] ?? [] } : {}),
|
|
110
|
+
});
|
|
111
|
+
// Verify credentials and protocol before saving; no records uploaded by setup.
|
|
112
|
+
const probe = await archiveRequest(destination, 'search', {
|
|
113
|
+
query: '',
|
|
114
|
+
budget: 16,
|
|
115
|
+
limit: 1,
|
|
116
|
+
...(destination.kind === 'archive'
|
|
117
|
+
? { projectId: destination.projectId }
|
|
118
|
+
: { kastleId: destination.kastleId }),
|
|
119
|
+
});
|
|
120
|
+
if (!Array.isArray(probe.data?.archives))
|
|
121
|
+
throw new Error('Destination is not an Archive-compatible endpoint');
|
|
122
|
+
output(connectDestination(config, destination));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (command === 'preview') {
|
|
126
|
+
if (
|
|
127
|
+
Boolean(v.file) === Boolean(v.directory) ||
|
|
128
|
+
!['codex', 'claude-code'].includes(v.source ?? '')
|
|
129
|
+
)
|
|
130
|
+
throw new Error('Preview requires one --file or --directory and --source codex|claude-code');
|
|
131
|
+
output(
|
|
132
|
+
previewImports(v.file ?? v.directory!, v.source as 'codex' | 'claude-code', Number(v.limit)),
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (command === 'search' && v.remote) {
|
|
137
|
+
const results = await searchRemotes(readDestinations(config), v.query!);
|
|
138
|
+
output(results);
|
|
139
|
+
if (results.some((r) => 'error' in r)) process.exitCode = 1;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const store = new LocalArchiveStore(storePath);
|
|
143
|
+
try {
|
|
144
|
+
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
|
+
});
|
|
154
|
+
snapshot.tags = [
|
|
155
|
+
...new Set([...(store.read(archiveKey(snapshot))?.snapshot.tags ?? []), ...(v.tag ?? [])]),
|
|
156
|
+
];
|
|
157
|
+
output({
|
|
158
|
+
...store.capture(snapshot),
|
|
159
|
+
entries: snapshot.entries.length,
|
|
160
|
+
coverage: snapshot.coverage,
|
|
161
|
+
});
|
|
162
|
+
} else if (command === 'list') output(store.list());
|
|
163
|
+
else if (command === 'routes') output(routingPreview(store, readDestinations(config)));
|
|
164
|
+
else if (command === 'sync' || command === 'collect') {
|
|
165
|
+
if (
|
|
166
|
+
command === 'collect' &&
|
|
167
|
+
(!v.directory ||
|
|
168
|
+
!v['project-root'] ||
|
|
169
|
+
!v['project-id'] ||
|
|
170
|
+
!['codex', 'claude-code'].includes(v.source ?? ''))
|
|
171
|
+
)
|
|
172
|
+
throw new Error('Collect requires --directory, --source, --project-root and --project-id');
|
|
173
|
+
let stopped = false;
|
|
174
|
+
const stop = () => {
|
|
175
|
+
stopped = true;
|
|
176
|
+
};
|
|
177
|
+
process.once('SIGINT', stop);
|
|
178
|
+
process.once('SIGTERM', stop);
|
|
179
|
+
do {
|
|
180
|
+
if (command === 'collect') {
|
|
181
|
+
const result = await collectSessions(store, {
|
|
182
|
+
directory: v.directory!,
|
|
183
|
+
source: v.source as 'codex' | 'claude-code',
|
|
184
|
+
projectRoot: v['project-root']!,
|
|
185
|
+
projectId: v['project-id']!,
|
|
186
|
+
tags: v.tag,
|
|
187
|
+
});
|
|
188
|
+
output(result);
|
|
189
|
+
if (!v.watch && result.failed) process.exitCode = 1;
|
|
190
|
+
} else {
|
|
191
|
+
const results = await syncArchives(store, readDestinations(config));
|
|
192
|
+
output(results);
|
|
193
|
+
if (!v.watch && results.some((r) => r.status.startsWith('failed'))) process.exitCode = 1;
|
|
194
|
+
}
|
|
195
|
+
if (v.watch && !stopped) await Bun.sleep(1000);
|
|
196
|
+
// Short waits allow prompt shutdown; network requests have their own timeout.
|
|
197
|
+
for (let i = 1; v.watch && !stopped && i < 30; i++) await Bun.sleep(1000);
|
|
198
|
+
} while (v.watch && !stopped);
|
|
199
|
+
process.removeListener('SIGINT', stop);
|
|
200
|
+
process.removeListener('SIGTERM', stop);
|
|
201
|
+
} else if (command === 'publish') {
|
|
202
|
+
if (!v.id || !v.destination) throw new Error('Publish requires --id and --destination');
|
|
203
|
+
output(
|
|
204
|
+
await publishArchive(
|
|
205
|
+
store,
|
|
206
|
+
v.id,
|
|
207
|
+
archiveDestinationSchema.parse(JSON.parse(readFileSync(v.destination, 'utf8'))),
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
} else if (command === 'search')
|
|
211
|
+
output(store.search(v.id ? [v.id] : store.list().map((a) => a.id), v.query!));
|
|
212
|
+
else if (command === 'export' || command === 'tag') {
|
|
213
|
+
const archive = v.id ? store.read(v.id) : undefined;
|
|
214
|
+
if (!archive) throw new Error('Valid --id required');
|
|
215
|
+
if (command === 'export') output(archive.snapshot);
|
|
216
|
+
else {
|
|
217
|
+
if (!v.tag?.length) throw new Error('Tag requires at least one --tag');
|
|
218
|
+
output(
|
|
219
|
+
store.capture({
|
|
220
|
+
...archive.snapshot,
|
|
221
|
+
capturedAt: Date.now(),
|
|
222
|
+
tags: [...new Set([...archive.snapshot.tags, ...v.tag])],
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
} else throw new Error('Unknown command; use --help');
|
|
227
|
+
} finally {
|
|
228
|
+
store.close();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (import.meta.main)
|
|
233
|
+
runCli().catch(() => {
|
|
234
|
+
console.error(
|
|
235
|
+
'Archive command failed. Check arguments, destination access and local paths. Use --help for usage.',
|
|
236
|
+
);
|
|
237
|
+
process.exitCode = 1;
|
|
238
|
+
});
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ArchiveDestination,
|
|
3
|
+
archiveDestinationSchema,
|
|
4
|
+
destinationIdentity,
|
|
5
|
+
destinationUrl,
|
|
6
|
+
} from './config';
|
|
7
|
+
import type { LocalArchiveStore } from './local';
|
|
8
|
+
|
|
9
|
+
export { type ArchiveDestination, archiveDestinationSchema } from './config';
|
|
10
|
+
|
|
11
|
+
export async function archiveRequest(
|
|
12
|
+
destination: ArchiveDestination,
|
|
13
|
+
action: string,
|
|
14
|
+
body: unknown,
|
|
15
|
+
transport: typeof fetch = fetch,
|
|
16
|
+
) {
|
|
17
|
+
const url = destinationUrl(destination.url);
|
|
18
|
+
const token = process.env[destination.tokenEnv];
|
|
19
|
+
if (!token || (destination.kind !== 'archive' && !token.startsWith('kastle_runtime_')))
|
|
20
|
+
throw new Error('Archive runtime credential unavailable');
|
|
21
|
+
const response = await transport(new URL(`api/v1/archive/${action}`, url), {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
redirect: 'error',
|
|
24
|
+
signal: AbortSignal.timeout(30_000),
|
|
25
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
});
|
|
28
|
+
if (!response.ok)
|
|
29
|
+
throw new Error(`Archive request rejected (${response.status}); local archive retained`);
|
|
30
|
+
return response.json() as Promise<{ data: any }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function publishArchive(
|
|
34
|
+
store: LocalArchiveStore,
|
|
35
|
+
id: string,
|
|
36
|
+
input: ArchiveDestination,
|
|
37
|
+
transport: typeof fetch = fetch,
|
|
38
|
+
) {
|
|
39
|
+
const destination = archiveDestinationSchema.parse(input);
|
|
40
|
+
const archive = store.read(id);
|
|
41
|
+
if (!archive || archive.snapshot.projectId !== destination.projectId)
|
|
42
|
+
throw new Error('Archive is outside destination project');
|
|
43
|
+
const receiptKey = destinationIdentity(destination);
|
|
44
|
+
const keepIds = [...new Set(destination.keepIds)].sort();
|
|
45
|
+
let sent = false;
|
|
46
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
47
|
+
const latest = store.read(id)!;
|
|
48
|
+
const previousDigest = store.receipt(id, receiptKey);
|
|
49
|
+
let pending = store.pending(id, receiptKey);
|
|
50
|
+
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 });
|
|
57
|
+
pending = store.pending(id, receiptKey)!;
|
|
58
|
+
}
|
|
59
|
+
const queued = store.read(id, pending.revision);
|
|
60
|
+
if (!queued || queued.snapshot.projectId !== destination.projectId)
|
|
61
|
+
throw new Error('Pending archive is outside destination project');
|
|
62
|
+
const body = await archiveRequest(
|
|
63
|
+
destination,
|
|
64
|
+
'ingest',
|
|
65
|
+
{
|
|
66
|
+
...(destination.kind === 'archive'
|
|
67
|
+
? {}
|
|
68
|
+
: { kastleId: destination.kastleId, keepIds: pending.keepIds }),
|
|
69
|
+
previousDigest,
|
|
70
|
+
snapshot: queued.snapshot,
|
|
71
|
+
},
|
|
72
|
+
transport,
|
|
73
|
+
);
|
|
74
|
+
if (body.data?.digest !== queued.digest) throw new Error('Archive acknowledgement mismatch');
|
|
75
|
+
store.delivered(id, receiptKey, queued.digest, JSON.stringify(pending.keepIds));
|
|
76
|
+
sent = true;
|
|
77
|
+
}
|
|
78
|
+
throw new Error('Archive changed repeatedly during publication; retry sync');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function routingPreview(store: LocalArchiveStore, destinations: ArchiveDestination[]) {
|
|
82
|
+
return store.list().map((archive) => ({
|
|
83
|
+
id: archive.id,
|
|
84
|
+
projectId: archive.projectId,
|
|
85
|
+
tags: archive.tags,
|
|
86
|
+
suggestedTags: archive.suggestedTags,
|
|
87
|
+
destinations: destinations
|
|
88
|
+
.filter((d) => d.projectId === archive.projectId)
|
|
89
|
+
.map((d) => ({
|
|
90
|
+
kind: d.kind ?? 'kingdom',
|
|
91
|
+
url: d.url,
|
|
92
|
+
...(d.kind === 'archive' ? {} : { kastleId: d.kastleId }),
|
|
93
|
+
})),
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function syncArchives(store: LocalArchiveStore, destinations: ArchiveDestination[]) {
|
|
98
|
+
const results: { id: string; destination: string; status: string }[] = [];
|
|
99
|
+
for (const archive of store.list())
|
|
100
|
+
for (const destination of destinations.filter((d) => d.projectId === archive.projectId)) {
|
|
101
|
+
try {
|
|
102
|
+
const result = await publishArchive(store, archive.id, destination);
|
|
103
|
+
results.push({
|
|
104
|
+
id: archive.id,
|
|
105
|
+
destination: destination.url,
|
|
106
|
+
status: result.unchanged ? 'unchanged' : 'published',
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
results.push({
|
|
110
|
+
id: archive.id,
|
|
111
|
+
destination: destination.url,
|
|
112
|
+
status: 'failed; local revision retained',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return results;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function searchRemotes(
|
|
120
|
+
destinations: ArchiveDestination[],
|
|
121
|
+
query: string,
|
|
122
|
+
budget = 2048,
|
|
123
|
+
) {
|
|
124
|
+
return Promise.all(
|
|
125
|
+
destinations.map(async (destination) => {
|
|
126
|
+
try {
|
|
127
|
+
const result = await archiveRequest(destination, 'search', {
|
|
128
|
+
query,
|
|
129
|
+
budget,
|
|
130
|
+
...(destination.kind === 'archive'
|
|
131
|
+
? { projectId: destination.projectId }
|
|
132
|
+
: { kastleId: destination.kastleId }),
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
destination: destination.url,
|
|
136
|
+
projectId: destination.projectId,
|
|
137
|
+
data: result.data,
|
|
138
|
+
};
|
|
139
|
+
} catch {
|
|
140
|
+
return {
|
|
141
|
+
destination: destination.url,
|
|
142
|
+
projectId: destination.projectId,
|
|
143
|
+
error: 'Remote unavailable',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
}
|
package/src/collector.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createReadStream, lstatSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { importTranscriptFile } from './import-file';
|
|
5
|
+
import { archiveKey } from './index';
|
|
6
|
+
import type { LocalArchiveStore } from './local';
|
|
7
|
+
|
|
8
|
+
export interface CollectionSource {
|
|
9
|
+
directory: string;
|
|
10
|
+
source: 'codex' | 'claude-code';
|
|
11
|
+
projectRoot: string;
|
|
12
|
+
projectId: string;
|
|
13
|
+
tags?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function files(directory: string): string[] {
|
|
17
|
+
const result: string[] = [];
|
|
18
|
+
const visit = (path: string) => {
|
|
19
|
+
const stat = lstatSync(path);
|
|
20
|
+
if (stat.isSymbolicLink()) return;
|
|
21
|
+
if (stat.isDirectory()) for (const name of readdirSync(path).sort()) visit(join(path, name));
|
|
22
|
+
else if (stat.isFile() && path.endsWith('.jsonl')) result.push(path);
|
|
23
|
+
};
|
|
24
|
+
visit(resolve(directory));
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 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 });
|
|
32
|
+
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
|
+
}
|
|
44
|
+
} finally {
|
|
45
|
+
lines.close();
|
|
46
|
+
input.destroy();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function collectSessions(store: LocalArchiveStore, config: CollectionSource) {
|
|
51
|
+
const root = resolve(config.projectRoot);
|
|
52
|
+
const result = { imported: 0, unchanged: 0, skipped: 0, failed: 0 };
|
|
53
|
+
for (const file of files(config.directory)) {
|
|
54
|
+
try {
|
|
55
|
+
const cwd = await sessionDirectory(file, config.source);
|
|
56
|
+
// Exact root selection avoids sweeping nested checkouts belonging to another organization.
|
|
57
|
+
if (!cwd || relative(root, cwd) !== '') {
|
|
58
|
+
result.skipped++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const before = lstatSync(file);
|
|
62
|
+
const snapshot = importTranscriptFile(file, {
|
|
63
|
+
source: config.source,
|
|
64
|
+
sourceId: store.sourceId,
|
|
65
|
+
projectId: config.projectId,
|
|
66
|
+
});
|
|
67
|
+
const after = lstatSync(file);
|
|
68
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
69
|
+
result.skipped++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const old = store.read(archiveKey(snapshot));
|
|
73
|
+
if (old && old.snapshot.projectId !== config.projectId) {
|
|
74
|
+
result.failed++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
snapshot.tags = [...new Set([...(old?.snapshot.tags ?? []), ...(config.tags ?? [])])];
|
|
78
|
+
if (store.capture(snapshot).changed) result.imported++;
|
|
79
|
+
else result.unchanged++;
|
|
80
|
+
} catch {
|
|
81
|
+
result.failed++;
|
|
82
|
+
} // Partial/malformed files remain intact and retry on the next scan.
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|