@inixiative/archive 0.5.0 → 0.6.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 +33 -9
- package/compose.yaml +16 -0
- package/package.json +4 -3
- package/src/agents.ts +3 -1
- package/src/cli.ts +24 -10
- package/src/collector.ts +6 -5
- package/src/import.ts +17 -0
- package/src/index.ts +21 -0
- package/src/local.ts +221 -15
- package/src/server.ts +89 -36
- package/src/tags.ts +96 -36
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ bun run archive init
|
|
|
29
29
|
bun run archive serve
|
|
30
30
|
```
|
|
31
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.
|
|
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; with `--sync` it also publishes to its destinations every 30 seconds from the same process. The server requires bearer authentication for data endpoints; `/health` has no session data.
|
|
33
33
|
|
|
34
34
|
```sh
|
|
35
35
|
bun run archive preview --directory /path/to/history --source codex
|
|
@@ -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. 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
|
|
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 keeps tags captured earlier. It does not upload: run sync separately.
|
|
43
43
|
|
|
44
44
|
## Connect to BYO hosting
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Every deployment runs the published image `ghcr.io/inixiative/archive` (tags: version, `latest`, commit SHA), built from main for amd64 and arm64. Give it a persistent volume at `/data`, HTTPS, and a unique `ARCHIVE_SERVER_TOKEN` of at least 32 characters; it needs nothing else and creates its store on first start. Compose (`compose.yaml`, also shipped in the npm package) 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. Pin a version with `ARCHIVE_VERSION`; build from a checkout with `docker compose -f compose.yaml -f compose.build.yaml up -d --build`. The Render blueprint runs the image with a dedicated disk and generated token. Railway deploys the image as a service with a `/data` volume (`railway.json` builds the Dockerfile when deploying from the repository instead).
|
|
47
47
|
|
|
48
48
|
Place the destination's token in a private file (`chmod 600`, owned by you) or an environment variable, then run:
|
|
49
49
|
|
|
@@ -76,7 +76,7 @@ archive agents status
|
|
|
76
76
|
|
|
77
77
|
`add-collector` upserts by `--name` (lowercase letters, digits, `.` and `-`); `--directory` defaults to `~/.claude/projects` or `~/.codex/sessions`, and `--project-root`, `--worktrees` and `--atlas` behave as for `collect`. `remove-collector --name N`, `serve on|off [--port P]` and `sync on|off` edit the same file. `install` writes one unit per agent: `com.inixiative.archive.local` (serve), `com.inixiative.archive.sync` (`sync --watch`) and `com.inixiative.archive.collect.<name>`. Each runs the current Bun with this package's own `src/cli.ts`, restarts on exit (30 second throttle), works in the archive home and logs to `logs/<label>.{out,err}.log` there. Re-running `install` reloads only changed or stopped units and removes Archive units no longer declared; other launchd agents are untouched. `uninstall` removes every Archive unit; `status` reports each unit's load state, PID and last log entry. One archive home per user account: units are named by agent, not by home. Sync reads destination token files itself, so no wrapper script is needed. Re-run `install` after upgrading the package if its install path changes.
|
|
78
78
|
|
|
79
|
-
Docker Compose is the alternative for the server: `docker compose up -d`
|
|
79
|
+
Docker Compose is the alternative for the server: `docker compose up -d` runs `serve --sync`, so the container publishes to the destinations in its own `/data/destinations.json` (token files must live under `/data` too). It is a separate store from the host agents': connect it as a destination and let the `sync` agent publish to it; do not bind-mount the store the host agents are writing, since SQLite locking across the Docker VM boundary is unreliable. Collectors run on the host, where the session histories are.
|
|
80
80
|
|
|
81
81
|
`@inixiative/archive/agents` exports the pure pieces for integrations: `agentsConfigSchema`, `agentUnits`, `renderPlist`, `renderSystemdUnit` and `planAgents`, plus `installAgents`, `uninstallAgents` and `agentStatus` with an injectable command runner and supervisor directory.
|
|
82
82
|
|
|
@@ -98,17 +98,41 @@ Kingdom takes the owner from the runtime credential; `--owner-model`, `--organiz
|
|
|
98
98
|
|
|
99
99
|
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.
|
|
100
100
|
|
|
101
|
-
## Routing
|
|
101
|
+
## Routing
|
|
102
102
|
|
|
103
|
-
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.
|
|
103
|
+
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. Tags, references and actors never change ownership or routing.
|
|
104
|
+
|
|
105
|
+
## Tags, references, actors and retention
|
|
106
|
+
|
|
107
|
+
The archive owns its tag system. It keeps two different things apart:
|
|
108
|
+
|
|
109
|
+
- **Tags** are concepts: the `atlas:<concept>` of repository files a session's tool calls touched (collectors with `--atlas`, from `atlas graph --json`), collector `--tag`s, and tags people add. Heuristic categories are offered as `suggestedTags`, computed on read and never stored.
|
|
110
|
+
- **References** point at items in an integration. Collectors record the session's GitHub repository and branch (`owner/repo`, `owner/repo/tree/branch`); linked GitHub pull requests, issues and commits (`owner/repo#24`, `owner/repo@sha`) and Linear issues (`KEY-12`) are found in the text and counted. A link is a mention, not proof of work. The archive's settings list the integrations it references: GitHub and Linear are recognized natively, and any other integration names the link prefix of its items (for example `https://acme.atlassian.net/browse/`); the item is what follows. Prefixes are literal, so settings cannot make matching slow.
|
|
104
111
|
|
|
105
112
|
```sh
|
|
106
|
-
bun run archive tag --id ARCHIVE_ID --tag reviewed
|
|
113
|
+
bun run archive tag --id ARCHIVE_ID --tag reviewed --untag debugging
|
|
107
114
|
```
|
|
108
115
|
|
|
109
|
-
|
|
116
|
+
Tag edits apply to that archive without a new revision, survive re-capture, and do not sync. Offered tags are defined archive-wide or for one actor. Each session can name its **actor**: a `user`, a `service` such as an API-key run, or a `job` (its id is the job name), with the `integrationId` it ran under when there is one. An organization's archive mostly holds its members' work, filterable per person. Entries the model produced record its `model` and `effort` (from Claude Code assistant records and Codex turn context, per turn); listings summarize them per session as `models` and filter by `model` and `effort`.
|
|
117
|
+
|
|
118
|
+
The **retention** setting (`retentionDays`, off by default) deletes archives whose latest capture is older than that, at server start and hourly. It deletes only from the archive it is set on; deletions never sync.
|
|
119
|
+
|
|
120
|
+
### HTTP API
|
|
121
|
+
|
|
122
|
+
All data endpoints are `POST /api/v1/archive/<action>` with a JSON body and `Authorization: Bearer <token>`:
|
|
123
|
+
|
|
124
|
+
| Action | Body | Returns |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `ingest` | `snapshot`, `previousDigest` | id, digest, revision |
|
|
127
|
+
| `read` | `archiveId`, `revision?` | snapshot and chunks |
|
|
128
|
+
| `list` | filters, `limit?`, `beforeId?` | metadata, `nextCursor` |
|
|
129
|
+
| `search` | filters, `query`, `budget?`, `limit?`, `beforeId?` | matching chunks within the token budget |
|
|
130
|
+
| `tag` | `archiveId`, `add?`, `remove?` | tags |
|
|
131
|
+
| `delete` | `archiveId` | deleted |
|
|
132
|
+
| `settings/read`, `settings/update` | `integrations?`, `retentionDays?` | settings |
|
|
133
|
+
| `tags/list`, `tags/define`, `tags/remove` | `tag`, `actorId?`, `description?` | offered tags with usage counts |
|
|
110
134
|
|
|
111
|
-
|
|
135
|
+
Filters are `projectId`, `source`, `tag`, `actorId`, `model`, `effort` and `reference` (`{integration, ref}`). Jev integration is not enabled. Session content must not be sent to Jev without selecting that service for the relevant ownership boundary.
|
|
112
136
|
|
|
113
137
|
## Limits
|
|
114
138
|
|
package/compose.yaml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
services:
|
|
2
|
+
archive:
|
|
3
|
+
image: ghcr.io/inixiative/archive:${ARCHIVE_VERSION:-latest}
|
|
4
|
+
command: ["bun", "run", "src/cli.ts", "serve", "--home", "/data", "--hostname", "0.0.0.0", "--sync"]
|
|
5
|
+
ports:
|
|
6
|
+
- "127.0.0.1:${ARCHIVE_PORT:-4411}:4411"
|
|
7
|
+
environment:
|
|
8
|
+
ARCHIVE_SERVER_TOKEN: ${ARCHIVE_SERVER_TOKEN:?Set a unique random token of at least 32 characters}
|
|
9
|
+
volumes:
|
|
10
|
+
- ${ARCHIVE_DATA_DIR:-./data}:/data
|
|
11
|
+
restart: unless-stopped
|
|
12
|
+
healthcheck:
|
|
13
|
+
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:4411/health').then(r=>{if(!r.ok)process.exit(1)})"]
|
|
14
|
+
interval: 30s
|
|
15
|
+
timeout: 5s
|
|
16
|
+
retries: 3
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/archive",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"./agents": "./src/agents.ts"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
|
-
"src"
|
|
21
|
+
"src",
|
|
22
|
+
"compose.yaml"
|
|
22
23
|
],
|
|
23
24
|
"scripts": {
|
|
24
25
|
"test": "bun test tests",
|
|
@@ -36,7 +37,7 @@
|
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"@biomejs/biome": "2.5.2",
|
|
39
|
-
"@inixiative/config": "^0.5.
|
|
40
|
+
"@inixiative/config": "^0.5.42",
|
|
40
41
|
"@types/bun": "1.4.2",
|
|
41
42
|
"lefthook": "2.1.9",
|
|
42
43
|
"typescript": "6.0.3"
|
package/src/agents.ts
CHANGED
|
@@ -357,7 +357,9 @@ function lastEntry(file: string) {
|
|
|
357
357
|
const buffer = Buffer.alloc(Math.min(size, 65_536));
|
|
358
358
|
readSync(fd, buffer, 0, buffer.length, size - buffer.length);
|
|
359
359
|
const lines = buffer.toString('utf8').trimEnd().split('\n');
|
|
360
|
-
|
|
360
|
+
// Source ships to consumers compiled against older libs, so no findLastIndex.
|
|
361
|
+
let start = lines.length - 1;
|
|
362
|
+
while (start >= 0 && lines[start] !== '{' && lines[start] !== '[') start--;
|
|
361
363
|
if (start >= 0)
|
|
362
364
|
try {
|
|
363
365
|
return JSON.stringify(JSON.parse(lines.slice(start).join('\n'))).slice(0, 500);
|
package/src/cli.ts
CHANGED
|
@@ -58,11 +58,13 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
58
58
|
worktrees: { type: 'boolean', default: false },
|
|
59
59
|
atlas: { type: 'boolean', default: false },
|
|
60
60
|
tag: { type: 'string', multiple: true },
|
|
61
|
+
untag: { type: 'string', multiple: true },
|
|
61
62
|
limit: { type: 'string', default: '100' },
|
|
62
63
|
port: { type: 'string' },
|
|
63
64
|
name: { type: 'string' },
|
|
64
65
|
hostname: { type: 'string', default: '127.0.0.1' },
|
|
65
66
|
watch: { type: 'boolean', default: false },
|
|
67
|
+
sync: { type: 'boolean', default: false },
|
|
66
68
|
remote: { type: 'boolean', default: false },
|
|
67
69
|
destination: { type: 'string' },
|
|
68
70
|
help: { type: 'boolean' },
|
|
@@ -78,9 +80,10 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
78
80
|
'Archive: init | serve | connect (setup) | preview | import | collect | list | export | tag | routes | sync | search | agents\n' +
|
|
79
81
|
'connect --url HTTPS_URL --project-id ID (--token-env ENV | --token-file PATH) [--kind kingdom [--connection-id ID] [--owner-model M --organization-id UUID --space-id UUID]]\n' +
|
|
80
82
|
'import --file PATH --source codex|claude-code --project-id ID [--tag TAG]\n' +
|
|
83
|
+
'tag --id ID [--tag TAG ...] [--untag TAG ...]\n' +
|
|
81
84
|
'collect --directory HISTORY --source codex|claude-code --project-root EXACT_CWD [--project-root ...] [--worktrees] [--atlas] --project-id ID [--watch]\n' +
|
|
82
85
|
'sync [--watch] | routes | search --query TEXT [--remote]\n' +
|
|
83
|
-
'serve --home PATH [--port 4411 --hostname 127.0.0.1]\n' +
|
|
86
|
+
'serve --home PATH [--port 4411 --hostname 127.0.0.1] [--sync]\n' +
|
|
84
87
|
'agents add-collector --name N --source codex|claude-code --project-id ID --project-root DIR [--project-root ...] [--directory HISTORY] [--worktrees] [--atlas]\n' +
|
|
85
88
|
'agents remove-collector --name N | serve on|off [--port P] | sync on|off | install | uninstall | status\n' +
|
|
86
89
|
'All commands accept --home PATH; integrations may use --store FILE --config FILE.',
|
|
@@ -109,8 +112,24 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
109
112
|
port: Number(v.port ?? process.env.PORT ?? 4411),
|
|
110
113
|
hostname: v.hostname,
|
|
111
114
|
});
|
|
112
|
-
output({ listening: instance.server.url.href, store: storePath });
|
|
115
|
+
output({ listening: instance.server.url.href, store: storePath, sync: v.sync });
|
|
116
|
+
let stopped = false;
|
|
117
|
+
// One process owns the store: the server and its sync share it, as in a container.
|
|
118
|
+
const sync = (async () => {
|
|
119
|
+
while (v.sync && !stopped) {
|
|
120
|
+
try {
|
|
121
|
+
const results = await syncArchives(instance.store, readDestinations(config));
|
|
122
|
+
const failed = results.filter((r) => r.status.startsWith('failed')).length;
|
|
123
|
+
if (failed) console.error(JSON.stringify({ sync: 'failed', archives: failed }));
|
|
124
|
+
} catch {
|
|
125
|
+
console.error(JSON.stringify({ sync: 'unavailable' }));
|
|
126
|
+
}
|
|
127
|
+
for (let i = 0; i < 30 && !stopped; i++) await Bun.sleep(1000);
|
|
128
|
+
}
|
|
129
|
+
})();
|
|
113
130
|
const stop = async () => {
|
|
131
|
+
stopped = true;
|
|
132
|
+
await sync;
|
|
114
133
|
await instance.close();
|
|
115
134
|
process.exit(0);
|
|
116
135
|
};
|
|
@@ -308,14 +327,9 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
308
327
|
if (!archive) throw new Error('Valid --id required');
|
|
309
328
|
if (command === 'export') output(archive.snapshot);
|
|
310
329
|
else {
|
|
311
|
-
if (!v.tag?.length
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
...archive.snapshot,
|
|
315
|
-
capturedAt: Date.now(),
|
|
316
|
-
tags: [...new Set([...archive.snapshot.tags, ...v.tag])],
|
|
317
|
-
}),
|
|
318
|
-
);
|
|
330
|
+
if (!v.tag?.length && !v.untag?.length)
|
|
331
|
+
throw new Error('Tag requires at least one --tag or --untag');
|
|
332
|
+
output(store.tag(archive.id, { add: v.tag, remove: v.untag }));
|
|
319
333
|
}
|
|
320
334
|
} else throw new Error('Unknown command; use --help');
|
|
321
335
|
} finally {
|
package/src/collector.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { isAbsolute, join, resolve } from 'node:path';
|
|
|
3
3
|
import { importTranscriptFile } from './import-file';
|
|
4
4
|
import { archiveKey } from './index';
|
|
5
5
|
import type { LocalArchiveStore } from './local';
|
|
6
|
-
import { type AtlasFileConcepts, atlasTags,
|
|
6
|
+
import { type AtlasFileConcepts, atlasTags, provenanceReferences } from './tags';
|
|
7
7
|
|
|
8
8
|
export interface CollectionSource {
|
|
9
9
|
directory: string;
|
|
@@ -138,13 +138,14 @@ export async function collectSessions(store: LocalArchiveStore, config: Collecti
|
|
|
138
138
|
...new Set([
|
|
139
139
|
...(old?.snapshot.tags ?? []),
|
|
140
140
|
...(config.tags ?? []),
|
|
141
|
-
...provenanceTags({
|
|
142
|
-
branch: meta.branch,
|
|
143
|
-
repository: meta.repository ?? repositories.get(cwd),
|
|
144
|
-
}),
|
|
145
141
|
...(graph ? atlasTags(snapshot, graph, roots) : []),
|
|
146
142
|
]),
|
|
147
143
|
].slice(0, 100);
|
|
144
|
+
const references = provenanceReferences({
|
|
145
|
+
branch: meta.branch,
|
|
146
|
+
repository: meta.repository ?? repositories.get(cwd),
|
|
147
|
+
});
|
|
148
|
+
if (references.length) snapshot.references = references;
|
|
148
149
|
if (store.capture(snapshot).changed) result.imported++;
|
|
149
150
|
else result.unchanged++;
|
|
150
151
|
} catch {
|
package/src/import.ts
CHANGED
|
@@ -21,6 +21,8 @@ export function importTranscriptLines(
|
|
|
21
21
|
const seen = new Map<string, string>();
|
|
22
22
|
let index = -1;
|
|
23
23
|
let contentBytes = 0;
|
|
24
|
+
// Codex states the model per turn; Claude Code per assistant record.
|
|
25
|
+
let turn: { model?: string; effort?: string } = {};
|
|
24
26
|
for (const rawLine of lines) {
|
|
25
27
|
index++;
|
|
26
28
|
const line = rawLine.trim();
|
|
@@ -48,6 +50,15 @@ export function importTranscriptLines(
|
|
|
48
50
|
if (sessionId && sessionId !== id) throw new Error('Mixed transcript sessions');
|
|
49
51
|
sessionId = id;
|
|
50
52
|
}
|
|
53
|
+
const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
|
|
54
|
+
if (options.source === 'codex' && row.type === 'turn_context')
|
|
55
|
+
turn = { model: text(row.payload?.model), effort: text(row.payload?.effort) };
|
|
56
|
+
const producer =
|
|
57
|
+
options.source === 'codex'
|
|
58
|
+
? turn
|
|
59
|
+
: row.type === 'assistant'
|
|
60
|
+
? { model: text(row.message?.model), effort: text(row.effort) }
|
|
61
|
+
: {};
|
|
51
62
|
const timestamp = typeof row.timestamp === 'number' ? row.timestamp : Date.parse(row.timestamp);
|
|
52
63
|
const add = (kind: ArchiveEntry['kind'], content: unknown, suffix: string, callId?: string) => {
|
|
53
64
|
if (typeof content !== 'string' || !content.length) return;
|
|
@@ -71,6 +82,12 @@ export function importTranscriptLines(
|
|
|
71
82
|
timestamp: Number.isFinite(timestamp) ? timestamp : null,
|
|
72
83
|
sourceRef: `line:${index + 1}`,
|
|
73
84
|
...(callId ? { callId } : {}),
|
|
85
|
+
...(['assistant', 'tool-call', 'reasoning-summary'].includes(kind)
|
|
86
|
+
? {
|
|
87
|
+
...(producer.model ? { model: producer.model } : {}),
|
|
88
|
+
...(producer.effort ? { effort: producer.effort } : {}),
|
|
89
|
+
}
|
|
90
|
+
: {}),
|
|
74
91
|
});
|
|
75
92
|
};
|
|
76
93
|
if (options.source === 'codex') {
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,22 @@ export const archiveEntrySchema = z.strictObject({
|
|
|
11
11
|
text: z.string().max(2_000_000),
|
|
12
12
|
timestamp: z.number().finite().nonnegative().nullable(),
|
|
13
13
|
sourceRef: z.string().min(1).max(512),
|
|
14
|
+
/** The model that produced the entry, and its effort level as the harness names it. */
|
|
15
|
+
model: z.string().min(1).max(200).optional(),
|
|
16
|
+
effort: z.string().min(1).max(40).optional(),
|
|
17
|
+
});
|
|
18
|
+
/** Who did the work: a person, a service such as an API-key run, or a job (id = job name). */
|
|
19
|
+
export const archiveActorSchema = z.strictObject({
|
|
20
|
+
kind: z.enum(['user', 'service', 'job']),
|
|
21
|
+
id: identifier,
|
|
22
|
+
name: z.string().min(1).max(200).optional(),
|
|
23
|
+
/** The integration the work ran under, when there is one. */
|
|
24
|
+
integrationId: identifier.optional(),
|
|
25
|
+
});
|
|
26
|
+
/** An item in an integration (a GitHub pull request, a Linear issue) the session is about. */
|
|
27
|
+
export const archiveReferenceSchema = z.strictObject({
|
|
28
|
+
integration: z.string().regex(/^[a-z0-9-]{1,64}$/),
|
|
29
|
+
ref: z.string().min(1).max(300),
|
|
14
30
|
});
|
|
15
31
|
export const archiveSnapshotSchema = z
|
|
16
32
|
.strictObject({
|
|
@@ -20,6 +36,9 @@ export const archiveSnapshotSchema = z
|
|
|
20
36
|
sessionId: identifier,
|
|
21
37
|
title: z.string().min(1).max(500),
|
|
22
38
|
projectId: identifier.optional(),
|
|
39
|
+
actor: archiveActorSchema.optional(),
|
|
40
|
+
/** Recorded references, such as the session's repository and branch. Linked ones are found in the text. */
|
|
41
|
+
references: z.array(archiveReferenceSchema).max(100).optional(),
|
|
23
42
|
tags: z.array(z.string().min(1).max(120)).max(100),
|
|
24
43
|
goalIds: z.array(identifier).max(100).default([]),
|
|
25
44
|
runIds: z.array(identifier).max(100).default([]),
|
|
@@ -39,6 +58,8 @@ export const archiveSnapshotSchema = z
|
|
|
39
58
|
});
|
|
40
59
|
export type ArchiveSnapshot = z.infer<typeof archiveSnapshotSchema>;
|
|
41
60
|
export type ArchiveEntry = z.infer<typeof archiveEntrySchema>;
|
|
61
|
+
export type ArchiveActor = z.infer<typeof archiveActorSchema>;
|
|
62
|
+
export type ArchiveReference = z.infer<typeof archiveReferenceSchema>;
|
|
42
63
|
export interface ArchiveChunk {
|
|
43
64
|
id: string;
|
|
44
65
|
entryId: string;
|
package/src/local.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { chmodSync, mkdirSync } from 'node:fs';
|
|
4
4
|
import { dirname } from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
5
6
|
import {
|
|
6
7
|
type ArchiveChunk,
|
|
7
8
|
type ArchiveSnapshot,
|
|
@@ -11,7 +12,46 @@ import {
|
|
|
11
12
|
selectChunks,
|
|
12
13
|
snapshotDigest,
|
|
13
14
|
} from './index';
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
archiveIntegrationSchema,
|
|
17
|
+
archiveReferences,
|
|
18
|
+
defaultIntegrations,
|
|
19
|
+
suggestTags,
|
|
20
|
+
} from './tags';
|
|
21
|
+
|
|
22
|
+
export const archiveSettingsSchema = z.strictObject({
|
|
23
|
+
/** Integrations sessions can reference; Kingdom and Foundry display these, Archive owns them. */
|
|
24
|
+
integrations: z
|
|
25
|
+
.array(archiveIntegrationSchema)
|
|
26
|
+
.max(50)
|
|
27
|
+
.refine(
|
|
28
|
+
(list) => new Set(list.map((i) => i.key)).size === list.length,
|
|
29
|
+
'Duplicate integration keys',
|
|
30
|
+
),
|
|
31
|
+
/** Archives whose latest capture is older than this are deleted here. Deletions never sync. */
|
|
32
|
+
retentionDays: z.number().int().positive().max(36_500).nullable(),
|
|
33
|
+
});
|
|
34
|
+
export type ArchiveSettings = z.infer<typeof archiveSettingsSchema>;
|
|
35
|
+
const defaultSettings: ArchiveSettings = { integrations: defaultIntegrations, retentionDays: null };
|
|
36
|
+
|
|
37
|
+
const tagSchema = z.string().min(1).max(120);
|
|
38
|
+
/** A conceptual tag this archive offers: archive-wide (no actor) or for one actor. */
|
|
39
|
+
export const tagDefinitionSchema = z.strictObject({
|
|
40
|
+
tag: tagSchema,
|
|
41
|
+
actorId: z.string().min(1).max(256).optional(),
|
|
42
|
+
description: z.string().max(500).optional(),
|
|
43
|
+
});
|
|
44
|
+
export type TagDefinition = z.infer<typeof tagDefinitionSchema>;
|
|
45
|
+
|
|
46
|
+
export interface ArchiveFilter {
|
|
47
|
+
projectId?: string;
|
|
48
|
+
source?: ArchiveSnapshot['source'];
|
|
49
|
+
tag?: string;
|
|
50
|
+
actorId?: string;
|
|
51
|
+
reference?: { integration: string; ref: string };
|
|
52
|
+
model?: string;
|
|
53
|
+
effort?: string;
|
|
54
|
+
}
|
|
15
55
|
|
|
16
56
|
export class LocalArchiveStore {
|
|
17
57
|
private readonly db: Database;
|
|
@@ -28,7 +68,11 @@ export class LocalArchiveStore {
|
|
|
28
68
|
CREATE TABLE IF NOT EXISTS receipts (archive_id TEXT NOT NULL REFERENCES archives(id), destination TEXT NOT NULL,
|
|
29
69
|
digest TEXT NOT NULL, PRIMARY KEY(archive_id, destination));
|
|
30
70
|
CREATE TABLE IF NOT EXISTS outbox (archive_id TEXT NOT NULL REFERENCES archives(id), destination TEXT NOT NULL,
|
|
31
|
-
pending TEXT NOT NULL, PRIMARY KEY(archive_id, destination))
|
|
71
|
+
pending TEXT NOT NULL, PRIMARY KEY(archive_id, destination));
|
|
72
|
+
CREATE TABLE IF NOT EXISTS tag_edits (archive_id TEXT NOT NULL REFERENCES archives(id), tag TEXT NOT NULL,
|
|
73
|
+
added INTEGER NOT NULL, PRIMARY KEY(archive_id, tag));
|
|
74
|
+
CREATE TABLE IF NOT EXISTS tag_definitions (actor_id TEXT NOT NULL, tag TEXT NOT NULL, description TEXT,
|
|
75
|
+
PRIMARY KEY(actor_id, tag));`);
|
|
32
76
|
this.db.query("INSERT OR IGNORE INTO settings VALUES ('sourceId', ?)").run(randomUUID());
|
|
33
77
|
this.sourceId = (
|
|
34
78
|
this.db.query("SELECT value FROM settings WHERE key='sourceId'").get() as { value: string }
|
|
@@ -82,22 +126,184 @@ export class LocalArchiveStore {
|
|
|
82
126
|
}
|
|
83
127
|
: undefined;
|
|
84
128
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
129
|
+
/** Computed per revision: references and suggestions scan the whole transcript. */
|
|
130
|
+
private summaries = new Map<
|
|
131
|
+
string,
|
|
132
|
+
ReturnType<LocalArchiveStore['summarize']> & { digest: string }
|
|
133
|
+
>();
|
|
134
|
+
private summarize(id: string, revision: number) {
|
|
135
|
+
const row = this.db
|
|
136
|
+
.query('SELECT snapshot FROM revisions WHERE archive_id=? AND revision=?')
|
|
137
|
+
.get(id, revision) as { snapshot: string };
|
|
138
|
+
const full = archiveSnapshotSchema.parse(JSON.parse(row.snapshot));
|
|
139
|
+
const { entries, tags, ...snapshot } = full;
|
|
140
|
+
const models = new Map<string, { model: string; effort?: string; entries: number }>();
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
if (!entry.model) continue;
|
|
143
|
+
const key = JSON.stringify([entry.model, entry.effort]);
|
|
144
|
+
const current = models.get(key) ?? {
|
|
145
|
+
model: entry.model,
|
|
146
|
+
...(entry.effort ? { effort: entry.effort } : {}),
|
|
147
|
+
entries: 0,
|
|
98
148
|
};
|
|
149
|
+
current.entries++;
|
|
150
|
+
models.set(key, current);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
snapshot,
|
|
154
|
+
capturedTags: tags,
|
|
155
|
+
entries: entries.length,
|
|
156
|
+
models: [...models.values()],
|
|
157
|
+
suggestedTags: suggestTags(full),
|
|
158
|
+
references: archiveReferences(full, this.settings().integrations),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Archive metadata, newest first. Tags are the captured tags with this archive's edits applied. */
|
|
162
|
+
list(filter: ArchiveFilter = {}) {
|
|
163
|
+
const rows = this.db
|
|
164
|
+
.query('SELECT id, revision, digest FROM archives ORDER BY rowid DESC')
|
|
165
|
+
.all() as { id: string; revision: number; digest: string }[];
|
|
166
|
+
return rows.flatMap(({ id, revision, digest }) => {
|
|
167
|
+
let summary = this.summaries.get(id);
|
|
168
|
+
if (summary?.digest !== digest) {
|
|
169
|
+
summary = { ...this.summarize(id, revision), digest };
|
|
170
|
+
this.summaries.set(id, summary);
|
|
171
|
+
}
|
|
172
|
+
const { snapshot, references, models } = summary;
|
|
173
|
+
const tags = this.tags(id, summary.capturedTags);
|
|
174
|
+
const reference = filter.reference;
|
|
175
|
+
if (
|
|
176
|
+
(filter.projectId && snapshot.projectId !== filter.projectId) ||
|
|
177
|
+
(filter.source && snapshot.source !== filter.source) ||
|
|
178
|
+
(filter.tag && !tags.includes(filter.tag)) ||
|
|
179
|
+
(filter.actorId && snapshot.actor?.id !== filter.actorId) ||
|
|
180
|
+
(filter.model && !models.some((m) => m.model === filter.model)) ||
|
|
181
|
+
(filter.effort &&
|
|
182
|
+
!models.some(
|
|
183
|
+
(m) => m.effort === filter.effort && (!filter.model || m.model === filter.model),
|
|
184
|
+
)) ||
|
|
185
|
+
(reference &&
|
|
186
|
+
!references.some(
|
|
187
|
+
(r) => r.integration === reference.integration && r.ref === reference.ref,
|
|
188
|
+
))
|
|
189
|
+
)
|
|
190
|
+
return [];
|
|
191
|
+
return [
|
|
192
|
+
{
|
|
193
|
+
id,
|
|
194
|
+
revision,
|
|
195
|
+
digest,
|
|
196
|
+
...snapshot,
|
|
197
|
+
tags,
|
|
198
|
+
entries: summary.entries,
|
|
199
|
+
models,
|
|
200
|
+
suggestedTags: summary.suggestedTags,
|
|
201
|
+
references,
|
|
202
|
+
},
|
|
203
|
+
];
|
|
99
204
|
});
|
|
100
205
|
}
|
|
206
|
+
private tags(id: string, captured: string[]) {
|
|
207
|
+
const edits = this.db.query('SELECT tag, added FROM tag_edits WHERE archive_id=?').all(id) as {
|
|
208
|
+
tag: string;
|
|
209
|
+
added: number;
|
|
210
|
+
}[];
|
|
211
|
+
const removed = new Set(edits.filter((e) => !e.added).map((e) => e.tag));
|
|
212
|
+
return [...new Set([...captured, ...edits.filter((e) => e.added).map((e) => e.tag)])].filter(
|
|
213
|
+
(tag) => !removed.has(tag),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
/** Tag or untag without a new revision. Edits belong to this archive and do not sync. */
|
|
217
|
+
tag(id: string, change: { add?: string[]; remove?: string[] }) {
|
|
218
|
+
const add = z
|
|
219
|
+
.array(tagSchema)
|
|
220
|
+
.max(100)
|
|
221
|
+
.parse(change.add ?? []);
|
|
222
|
+
const remove = z
|
|
223
|
+
.array(tagSchema)
|
|
224
|
+
.max(100)
|
|
225
|
+
.parse(change.remove ?? []);
|
|
226
|
+
const archive = this.read(id);
|
|
227
|
+
if (!archive) throw new Error('Archive unavailable');
|
|
228
|
+
const captured = new Set(archive.snapshot.tags);
|
|
229
|
+
const upsert = this.db.query(
|
|
230
|
+
'INSERT INTO tag_edits VALUES (?, ?, ?) ON CONFLICT(archive_id, tag) DO UPDATE SET added=excluded.added',
|
|
231
|
+
);
|
|
232
|
+
const clear = this.db.query('DELETE FROM tag_edits WHERE archive_id=? AND tag=?');
|
|
233
|
+
return this.db.transaction(() => {
|
|
234
|
+
for (const tag of add) captured.has(tag) ? clear.run(id, tag) : upsert.run(id, tag, 1);
|
|
235
|
+
for (const tag of remove) captured.has(tag) ? upsert.run(id, tag, 0) : clear.run(id, tag);
|
|
236
|
+
const tags = this.tags(id, archive.snapshot.tags);
|
|
237
|
+
z.array(tagSchema).max(100, 'An archive holds at most 100 tags').parse(tags);
|
|
238
|
+
return { id, tags };
|
|
239
|
+
})();
|
|
240
|
+
}
|
|
241
|
+
delete(id: string) {
|
|
242
|
+
return this.db.transaction(() => {
|
|
243
|
+
for (const table of ['tag_edits', 'receipts', 'outbox', 'revisions'])
|
|
244
|
+
this.db.query(`DELETE FROM ${table} WHERE archive_id=?`).run(id);
|
|
245
|
+
this.summaries.delete(id);
|
|
246
|
+
return this.db.query('DELETE FROM archives WHERE id=?').run(id).changes > 0;
|
|
247
|
+
})();
|
|
248
|
+
}
|
|
249
|
+
/** Applies the retention setting; returns the deleted archive ids. */
|
|
250
|
+
prune(now = Date.now()) {
|
|
251
|
+
const { retentionDays } = this.settings();
|
|
252
|
+
if (retentionDays === null) return [];
|
|
253
|
+
const cutoff = now - retentionDays * 86_400_000;
|
|
254
|
+
const expired = (this.db.query('SELECT id FROM archives').all() as { id: string }[]).filter(
|
|
255
|
+
({ id }) => this.read(id)!.snapshot.capturedAt < cutoff,
|
|
256
|
+
);
|
|
257
|
+
for (const { id } of expired) this.delete(id);
|
|
258
|
+
return expired.map(({ id }) => id);
|
|
259
|
+
}
|
|
260
|
+
settings(): ArchiveSettings {
|
|
261
|
+
const row = this.db.query("SELECT value FROM settings WHERE key='archive'").get() as {
|
|
262
|
+
value: string;
|
|
263
|
+
} | null;
|
|
264
|
+
return row ? archiveSettingsSchema.parse(JSON.parse(row.value)) : defaultSettings;
|
|
265
|
+
}
|
|
266
|
+
updateSettings(change: Partial<ArchiveSettings>) {
|
|
267
|
+
const next = archiveSettingsSchema.parse({ ...this.settings(), ...change });
|
|
268
|
+
this.db
|
|
269
|
+
.query(
|
|
270
|
+
"INSERT INTO settings VALUES ('archive', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
271
|
+
)
|
|
272
|
+
.run(JSON.stringify(next));
|
|
273
|
+
this.summaries.clear();
|
|
274
|
+
return next;
|
|
275
|
+
}
|
|
276
|
+
/** Offered tags with how many archives carry each; an actor sees archive-wide and their own. */
|
|
277
|
+
tagDefinitions(actorId?: string) {
|
|
278
|
+
const rows = this.db
|
|
279
|
+
.query(
|
|
280
|
+
"SELECT actor_id, tag, description FROM tag_definitions WHERE actor_id IN ('', ?) ORDER BY tag",
|
|
281
|
+
)
|
|
282
|
+
.all(actorId ?? '') as { actor_id: string; tag: string; description: string | null }[];
|
|
283
|
+
const archives = this.list(actorId ? { actorId } : {});
|
|
284
|
+
return rows.map((row) => ({
|
|
285
|
+
tag: row.tag,
|
|
286
|
+
...(row.actor_id ? { actorId: row.actor_id } : {}),
|
|
287
|
+
...(row.description ? { description: row.description } : {}),
|
|
288
|
+
archives: archives.filter((archive) => archive.tags.includes(row.tag)).length,
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
defineTag(input: TagDefinition) {
|
|
292
|
+
const definition = tagDefinitionSchema.parse(input);
|
|
293
|
+
this.db
|
|
294
|
+
.query(
|
|
295
|
+
'INSERT INTO tag_definitions VALUES (?, ?, ?) ON CONFLICT(actor_id, tag) DO UPDATE SET description=excluded.description',
|
|
296
|
+
)
|
|
297
|
+
.run(definition.actorId ?? '', definition.tag, definition.description ?? null);
|
|
298
|
+
return definition;
|
|
299
|
+
}
|
|
300
|
+
undefineTag(input: { tag: string; actorId?: string }) {
|
|
301
|
+
return (
|
|
302
|
+
this.db
|
|
303
|
+
.query('DELETE FROM tag_definitions WHERE actor_id=? AND tag=?')
|
|
304
|
+
.run(input.actorId ?? '', input.tag).changes > 0
|
|
305
|
+
);
|
|
306
|
+
}
|
|
101
307
|
search(ids: string[], query: string, budget = 2048) {
|
|
102
308
|
return ids.map((id) => {
|
|
103
309
|
const archive = this.read(id);
|
package/src/server.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
archiveKey,
|
|
5
|
+
archiveReferenceSchema,
|
|
6
|
+
archiveSnapshotSchema,
|
|
7
|
+
selectChunks,
|
|
8
|
+
snapshotDigest,
|
|
9
|
+
} from './index';
|
|
10
|
+
import { archiveSettingsSchema, LocalArchiveStore, tagDefinitionSchema } from './local';
|
|
5
11
|
|
|
6
12
|
const ingestSchema = z.strictObject({
|
|
7
13
|
snapshot: archiveSnapshotSchema,
|
|
@@ -10,18 +16,43 @@ const ingestSchema = z.strictObject({
|
|
|
10
16
|
.regex(/^[a-f0-9]{64}$/)
|
|
11
17
|
.nullable(),
|
|
12
18
|
});
|
|
19
|
+
const archiveId = z.string().regex(/^[a-f0-9]{64}$/);
|
|
20
|
+
const filterFields = {
|
|
21
|
+
projectId: z.string().max(256).optional(),
|
|
22
|
+
source: archiveSnapshotSchema.shape.source.optional(),
|
|
23
|
+
tag: z.string().min(1).max(120).optional(),
|
|
24
|
+
actorId: z.string().min(1).max(256).optional(),
|
|
25
|
+
reference: archiveReferenceSchema.optional(),
|
|
26
|
+
model: z.string().min(1).max(200).optional(),
|
|
27
|
+
effort: z.string().min(1).max(40).optional(),
|
|
28
|
+
limit: z.number().int().min(1).max(100).default(20),
|
|
29
|
+
beforeId: archiveId.optional(),
|
|
30
|
+
};
|
|
31
|
+
const listSchema = z.strictObject(filterFields);
|
|
13
32
|
const searchSchema = z.strictObject({
|
|
33
|
+
...filterFields,
|
|
14
34
|
query: z.string().max(1000).default(''),
|
|
15
|
-
tag: z.string().min(1).max(120).optional(),
|
|
16
35
|
budget: z.number().int().min(16).max(32768).default(2048),
|
|
17
|
-
projectId: z.string().max(256).optional(),
|
|
18
|
-
limit: z.number().int().min(1).max(100).default(20),
|
|
19
|
-
beforeId: z.string().optional(),
|
|
20
36
|
});
|
|
21
|
-
const readSchema = z.strictObject({
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
const readSchema = z.strictObject({ archiveId, revision: z.number().int().positive().optional() });
|
|
38
|
+
const tagSchema = z.strictObject({
|
|
39
|
+
archiveId,
|
|
40
|
+
add: z.array(z.string()).max(100).optional(),
|
|
41
|
+
remove: z.array(z.string()).max(100).optional(),
|
|
24
42
|
});
|
|
43
|
+
const tagListSchema = z.strictObject({ actorId: z.string().min(1).max(256).optional() });
|
|
44
|
+
const tagRemoveSchema = tagDefinitionSchema.pick({ tag: true, actorId: true });
|
|
45
|
+
|
|
46
|
+
/** Cursor paging over newest-first archives. */
|
|
47
|
+
function page<T extends { id: string }>(items: T[], limit: number, beforeId?: string) {
|
|
48
|
+
const cursor = beforeId ? items.findIndex((item) => item.id === beforeId) : -1;
|
|
49
|
+
if (beforeId && cursor < 0) return undefined;
|
|
50
|
+
const slice = items.slice(cursor + 1, cursor + 1 + limit);
|
|
51
|
+
return {
|
|
52
|
+
items: slice,
|
|
53
|
+
nextCursor: items.length > cursor + 1 + slice.length ? (slice.at(-1)?.id ?? null) : null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
25
56
|
|
|
26
57
|
/** One deployment, one ownership boundary. No shared cross-tenant database or admin API. */
|
|
27
58
|
export function createArchiveHandler(store: LocalArchiveStore, token: string) {
|
|
@@ -71,35 +102,29 @@ export function createArchiveHandler(store: LocalArchiveStore, token: string) {
|
|
|
71
102
|
archive = store.read(input.archiveId, input.revision);
|
|
72
103
|
return archive ? json({ data: archive }) : json({ error: 'Archive unavailable' }, 404);
|
|
73
104
|
}
|
|
105
|
+
if (path === '/api/v1/archive/list') {
|
|
106
|
+
const { limit, beforeId, ...filter } = listSchema.parse(body);
|
|
107
|
+
const result = page(store.list(filter), limit, beforeId);
|
|
108
|
+
if (!result) return json({ error: 'Invalid cursor' }, 400);
|
|
109
|
+
return json({ data: { archives: result.items, nextCursor: result.nextCursor } });
|
|
110
|
+
}
|
|
74
111
|
if (path === '/api/v1/archive/search') {
|
|
75
|
-
const
|
|
76
|
-
let remaining =
|
|
112
|
+
const { query, budget, limit, beforeId, ...filter } = searchSchema.parse(body);
|
|
113
|
+
let remaining = budget;
|
|
77
114
|
const matching = store
|
|
78
|
-
.list()
|
|
115
|
+
.list(filter)
|
|
79
116
|
.filter(
|
|
80
117
|
(a) =>
|
|
81
|
-
|
|
82
|
-
(
|
|
83
|
-
|
|
84
|
-
.map((a) => ({
|
|
85
|
-
a,
|
|
86
|
-
selected: selectChunks(store.read(a.id)!.chunks, input.query, input.budget),
|
|
87
|
-
}))
|
|
88
|
-
.filter(
|
|
89
|
-
({ a, selected }) =>
|
|
90
|
-
!input.query ||
|
|
91
|
-
selected.matchingChunks > 0 ||
|
|
92
|
-
[a.title, ...a.tags].some((text) =>
|
|
93
|
-
text.toLowerCase().includes(input.query.toLowerCase()),
|
|
94
|
-
),
|
|
118
|
+
!query ||
|
|
119
|
+
selectChunks(store.read(a.id)!.chunks, query, budget).matchingChunks > 0 ||
|
|
120
|
+
[a.title, ...a.tags].some((text) => text.toLowerCase().includes(query.toLowerCase())),
|
|
95
121
|
);
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
98
|
-
const
|
|
99
|
-
const archives = page.map(({ a }) => {
|
|
122
|
+
const result = page(matching, limit, beforeId);
|
|
123
|
+
if (!result) return json({ error: 'Invalid cursor' }, 400);
|
|
124
|
+
const archives = result.items.map((a) => {
|
|
100
125
|
const selected =
|
|
101
126
|
remaining >= 16
|
|
102
|
-
? selectChunks(store.read(a.id)!.chunks,
|
|
127
|
+
? selectChunks(store.read(a.id)!.chunks, query, remaining)
|
|
103
128
|
: { chunks: [], tokenCount: 0 };
|
|
104
129
|
remaining -= selected.tokenCount;
|
|
105
130
|
return {
|
|
@@ -108,20 +133,42 @@ export function createArchiveHandler(store: LocalArchiveStore, token: string) {
|
|
|
108
133
|
digest: a.digest,
|
|
109
134
|
title: a.title,
|
|
110
135
|
projectId: a.projectId,
|
|
136
|
+
actor: a.actor,
|
|
137
|
+
models: a.models,
|
|
111
138
|
tags: a.tags,
|
|
112
139
|
suggestedTags: a.suggestedTags,
|
|
140
|
+
references: a.references,
|
|
113
141
|
coverage: a.coverage,
|
|
114
142
|
...selected,
|
|
115
143
|
};
|
|
116
144
|
});
|
|
117
145
|
return json({
|
|
118
|
-
data: {
|
|
119
|
-
archives,
|
|
120
|
-
tokenCount: input.budget - remaining,
|
|
121
|
-
nextCursor: matching.length > cursor + 1 + page.length ? page.at(-1)?.a.id : null,
|
|
122
|
-
},
|
|
146
|
+
data: { archives, tokenCount: budget - remaining, nextCursor: result.nextCursor },
|
|
123
147
|
});
|
|
124
148
|
}
|
|
149
|
+
if (path === '/api/v1/archive/tag') {
|
|
150
|
+
const input = tagSchema.parse(body);
|
|
151
|
+
if (!store.read(input.archiveId)) return json({ error: 'Archive unavailable' }, 404);
|
|
152
|
+
return json({ data: store.tag(input.archiveId, input) });
|
|
153
|
+
}
|
|
154
|
+
if (path === '/api/v1/archive/delete') {
|
|
155
|
+
const input = readSchema.pick({ archiveId: true }).parse(body);
|
|
156
|
+
return store.delete(input.archiveId)
|
|
157
|
+
? json({ data: { archiveId: input.archiveId, deleted: true } })
|
|
158
|
+
: json({ error: 'Archive unavailable' }, 404);
|
|
159
|
+
}
|
|
160
|
+
if (path === '/api/v1/archive/settings/read') {
|
|
161
|
+
z.strictObject({}).parse(body);
|
|
162
|
+
return json({ data: store.settings() });
|
|
163
|
+
}
|
|
164
|
+
if (path === '/api/v1/archive/settings/update')
|
|
165
|
+
return json({ data: store.updateSettings(archiveSettingsSchema.partial().parse(body)) });
|
|
166
|
+
if (path === '/api/v1/archive/tags/list')
|
|
167
|
+
return json({ data: { tags: store.tagDefinitions(tagListSchema.parse(body).actorId) } });
|
|
168
|
+
if (path === '/api/v1/archive/tags/define')
|
|
169
|
+
return json({ data: store.defineTag(tagDefinitionSchema.parse(body)) });
|
|
170
|
+
if (path === '/api/v1/archive/tags/remove')
|
|
171
|
+
return json({ data: { removed: store.undefineTag(tagRemoveSchema.parse(body)) } });
|
|
125
172
|
return json({ error: 'Not found' }, 404);
|
|
126
173
|
} catch (error) {
|
|
127
174
|
if (error instanceof z.ZodError || error instanceof SyntaxError)
|
|
@@ -138,6 +185,10 @@ export function startArchiveServer(options: {
|
|
|
138
185
|
hostname?: string;
|
|
139
186
|
}) {
|
|
140
187
|
const store = new LocalArchiveStore(options.store);
|
|
188
|
+
// Retention runs at start and hourly; it only ever deletes from this archive.
|
|
189
|
+
store.prune();
|
|
190
|
+
const retention = setInterval(() => store.prune(), 3_600_000);
|
|
191
|
+
retention.unref();
|
|
141
192
|
try {
|
|
142
193
|
const server = Bun.serve({
|
|
143
194
|
port: options.port ?? 4411,
|
|
@@ -149,11 +200,13 @@ export function startArchiveServer(options: {
|
|
|
149
200
|
server,
|
|
150
201
|
store,
|
|
151
202
|
async close() {
|
|
203
|
+
clearInterval(retention);
|
|
152
204
|
await server.stop(true);
|
|
153
205
|
store.close();
|
|
154
206
|
},
|
|
155
207
|
};
|
|
156
208
|
} catch (error) {
|
|
209
|
+
clearInterval(retention);
|
|
157
210
|
store.close();
|
|
158
211
|
throw error;
|
|
159
212
|
}
|
package/src/tags.ts
CHANGED
|
@@ -1,49 +1,109 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type ArchiveReference, type ArchiveSnapshot, categorySuggestions } from './index';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
/** A conceptual tag the archive proposes; suggestions never route or authorize. */
|
|
5
|
+
export type TagSuggestion = { tag: string; origin: 'heuristic' };
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
7
|
+
/**
|
|
8
|
+
* An integration whose items sessions can reference. GitHub and Linear links are recognized
|
|
9
|
+
* natively; any other integration names the link prefix of its items, and the item is what
|
|
10
|
+
* follows. A literal prefix keeps matching linear in the text, whatever settings say.
|
|
11
|
+
*/
|
|
12
|
+
export const archiveIntegrationSchema = z.strictObject({
|
|
13
|
+
key: z.string().regex(/^[a-z0-9-]{1,64}$/),
|
|
14
|
+
name: z.string().min(1).max(100),
|
|
15
|
+
prefix: z.string().min(3).max(300).optional(),
|
|
16
|
+
});
|
|
17
|
+
export type ArchiveIntegration = z.infer<typeof archiveIntegrationSchema>;
|
|
18
|
+
|
|
19
|
+
export const defaultIntegrations: ArchiveIntegration[] = [
|
|
20
|
+
{ key: 'github', name: 'GitHub' },
|
|
21
|
+
{ key: 'linear', name: 'Linear' },
|
|
18
22
|
];
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
type Extractor = [RegExp, (match: RegExpExecArray) => string];
|
|
25
|
+
const nativeExtractors: Record<string, Extractor[]> = {
|
|
26
|
+
github: [
|
|
27
|
+
[
|
|
28
|
+
/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:pull|issues)\/(\d+)/g,
|
|
29
|
+
(m) => `${m[1]}/${m[2]}#${m[3]}`.toLowerCase(),
|
|
30
|
+
],
|
|
31
|
+
[
|
|
32
|
+
/github\.com\/([\w.-]+)\/([\w.-]+)\/commit\/([0-9a-f]{7,40})\b/g,
|
|
33
|
+
(m) => `${m[1]}/${m[2]}@${m[3].slice(0, 7)}`.toLowerCase(),
|
|
34
|
+
],
|
|
35
|
+
],
|
|
36
|
+
linear: [[/linear\.app\/[\w-]+\/issue\/([A-Za-z][A-Za-z0-9]*-\d+)/g, (m) => m[1].toUpperCase()]],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function extractors(integration: ArchiveIntegration): Extractor[] {
|
|
40
|
+
if (integration.prefix)
|
|
41
|
+
return [
|
|
42
|
+
[
|
|
43
|
+
new RegExp(`${RegExp.escape(integration.prefix)}([\\w.~#/-]{1,300})`, 'g'),
|
|
44
|
+
(m) => m[1].replace(/[./]+$/, ''),
|
|
45
|
+
],
|
|
46
|
+
];
|
|
47
|
+
return nativeExtractors[integration.key] ?? [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ArchiveReferenceCount = ArchiveReference & { recorded: boolean; mentions: number };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* References to the archive's integrations: recorded ones first, then linked ones, most-linked
|
|
54
|
+
* first. A link is not proof of work, so linked references are evidence, not assignments.
|
|
55
|
+
*/
|
|
56
|
+
export function archiveReferences(
|
|
57
|
+
snapshot: ArchiveSnapshot,
|
|
58
|
+
integrations: ArchiveIntegration[] = defaultIntegrations,
|
|
59
|
+
limit = 25,
|
|
60
|
+
): ArchiveReferenceCount[] {
|
|
61
|
+
const known = new Set(integrations.map((integration) => integration.key));
|
|
62
|
+
const found = new Map<string, ArchiveReferenceCount>();
|
|
63
|
+
const key = (reference: ArchiveReference) =>
|
|
64
|
+
JSON.stringify([reference.integration, reference.ref]);
|
|
65
|
+
for (const reference of snapshot.references ?? [])
|
|
66
|
+
if (known.has(reference.integration))
|
|
67
|
+
found.set(key(reference), { ...reference, recorded: true, mentions: 0 });
|
|
68
|
+
for (const integration of integrations)
|
|
69
|
+
for (const [pattern, ref] of extractors(integration))
|
|
70
|
+
for (const entry of snapshot.entries)
|
|
71
|
+
for (const match of entry.text.matchAll(pattern)) {
|
|
72
|
+
const reference = { integration: integration.key, ref: ref(match) };
|
|
73
|
+
if (!reference.ref) continue;
|
|
74
|
+
const current = found.get(key(reference)) ?? {
|
|
75
|
+
...reference,
|
|
76
|
+
recorded: false,
|
|
77
|
+
mentions: 0,
|
|
78
|
+
};
|
|
79
|
+
current.mentions++;
|
|
80
|
+
found.set(key(reference), current);
|
|
81
|
+
}
|
|
82
|
+
return [...found.values()]
|
|
83
|
+
.sort((a, b) => Number(b.recorded) - Number(a.recorded) || b.mentions - a.mentions)
|
|
84
|
+
.slice(0, limit);
|
|
33
85
|
}
|
|
34
86
|
|
|
35
|
-
/** Every computed suggestion for an archive.
|
|
87
|
+
/** Every computed tag suggestion for an archive. */
|
|
36
88
|
export function suggestTags(snapshot: ArchiveSnapshot): TagSuggestion[] {
|
|
37
|
-
return
|
|
89
|
+
return categorySuggestions(snapshot);
|
|
38
90
|
}
|
|
39
91
|
|
|
40
|
-
/** Recorded git context of a session, as
|
|
41
|
-
export function
|
|
42
|
-
|
|
92
|
+
/** Recorded git context of a session, as GitHub references to its repository and branch. */
|
|
93
|
+
export function provenanceReferences(context: {
|
|
94
|
+
repository?: string;
|
|
95
|
+
branch?: string;
|
|
96
|
+
}): ArchiveReference[] {
|
|
97
|
+
const repository = context.repository
|
|
98
|
+
?.match(/github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/)?.[1]
|
|
99
|
+
.toLowerCase();
|
|
100
|
+
if (!repository) return [];
|
|
43
101
|
return [
|
|
44
|
-
|
|
45
|
-
...(context.branch && context.branch !== 'HEAD'
|
|
46
|
-
|
|
102
|
+
{ integration: 'github', ref: repository },
|
|
103
|
+
...(context.branch && context.branch !== 'HEAD'
|
|
104
|
+
? [{ integration: 'github', ref: `${repository}/tree/${context.branch}` }]
|
|
105
|
+
: []),
|
|
106
|
+
].filter((reference) => reference.ref.length <= 300);
|
|
47
107
|
}
|
|
48
108
|
|
|
49
109
|
/** `atlas graph --json`: repository-relative file paths to the concepts they are part of. */
|