@inixiative/archive 0.5.1 → 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 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 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 keeps tags captured earlier. 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 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.
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` bind-mounts `./data` (or `ARCHIVE_DATA_DIR`) at `/data` and binds loopback port 4411. It is a separate store, so 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.
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, tagging and Jev
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
- 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.
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
- 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.
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.5.1",
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.39",
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/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) throw new Error('Tag requires at least one --tag');
312
- output(
313
- store.capture({
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, provenanceTags } from './tags';
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 { suggestTags } from './tags';
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
- list() {
86
- return (
87
- this.db.query('SELECT id FROM archives ORDER BY rowid DESC').all() as { id: string }[]
88
- ).map(({ id }) => {
89
- const archive = this.read(id)!;
90
- const { entries, ...snapshot } = archive.snapshot;
91
- return {
92
- id,
93
- revision: archive.revision,
94
- digest: archive.digest,
95
- ...snapshot,
96
- entries: entries.length,
97
- suggestedTags: suggestTags(archive.snapshot),
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 { archiveKey, archiveSnapshotSchema, selectChunks, snapshotDigest } from './index';
4
- import { LocalArchiveStore } from './local';
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
- archiveId: z.string().regex(/^[a-f0-9]{64}$/),
23
- revision: z.number().int().positive().optional(),
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 input = searchSchema.parse(body);
76
- let remaining = input.budget;
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
- (!input.projectId || a.projectId === input.projectId) &&
82
- (!input.tag || a.tags.includes(input.tag)),
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 cursor = input.beforeId ? matching.findIndex(({ a }) => a.id === input.beforeId) : -1;
97
- if (input.beforeId && cursor < 0) return json({ error: 'Invalid cursor' }, 400);
98
- const page = matching.slice(cursor + 1, cursor + 1 + input.limit);
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, input.query, remaining)
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 { type ArchiveSnapshot, categorySuggestions } from './index';
1
+ import { z } from 'zod';
2
+ import { type ArchiveReference, type ArchiveSnapshot, categorySuggestions } from './index';
2
3
 
3
- export type TagSuggestion = { tag: string; origin: 'heuristic' | 'reference' };
4
+ /** A conceptual tag the archive proposes; suggestions never route or authorize. */
5
+ export type TagSuggestion = { tag: string; origin: 'heuristic' };
4
6
 
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
- ],
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
- /** 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' }));
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. Suggestions never route or authorize. */
87
+ /** Every computed tag suggestion for an archive. */
36
88
  export function suggestTags(snapshot: ArchiveSnapshot): TagSuggestion[] {
37
- return [...categorySuggestions(snapshot), ...referenceSuggestions(snapshot)];
89
+ return categorySuggestions(snapshot);
38
90
  }
39
91
 
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)?\/?$/);
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
- ...(repository ? [`repo:${repository[1].toLowerCase()}`] : []),
45
- ...(context.branch && context.branch !== 'HEAD' ? [`branch:${context.branch}`] : []),
46
- ].filter((tag) => tag.length <= 120);
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. */