@inixiative/archive 0.11.0 → 0.12.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
@@ -21,7 +21,7 @@ archive up # creates ~/.local/share/archive/server.token, then docker compo
21
21
  archive down
22
22
  ```
23
23
 
24
- `up` runs the `compose.yaml` that ships in the package (project `archive`) with `ARCHIVE_DATA_DIR` set to the archive home and the server token from its `server.token`. Postgres data lives in `$ARCHIVE_DATA_DIR/postgres` and the server's configuration (`destinations.json`, destination token files, the Kingdom pairing) in `$ARCHIVE_DATA_DIR/config`, so both survive rebuilds and volume prunes. Compose mounts the config directory at the same absolute path, so the CLI and the server read one `destinations.json` and every path in it resolves for both. Running Compose directly requires an absolute `ARCHIVE_DATA_DIR`. Pin a version with `ARCHIVE_VERSION`; build from a checkout with `docker compose -f compose.yaml -f compose.build.yaml up -d --build`.
24
+ `up` runs the `compose.yaml` that ships in the package (project `archive`) with `ARCHIVE_DATA_DIR` set to the archive home and the server token from its `server.token`. Postgres data lives in `$ARCHIVE_DATA_DIR/postgres` and the server's configuration (`destinations.json`, destination token files, the Kingdom pairing) in `$ARCHIVE_DATA_DIR/config`, so both survive rebuilds and volume prunes. Compose mounts the config directory at the same absolute path, so the CLI and the server read one `destinations.json` and every path in it resolves for both. `up` runs the image tagged with the CLI's own version, so a cached `latest` can never sit under a newer config layout; set `ARCHIVE_VERSION` to run another. Running Compose directly requires an absolute `ARCHIVE_DATA_DIR` and an `ARCHIVE_VERSION`; build from a checkout with `ARCHIVE_VERSION=local docker compose -f compose.yaml -f compose.build.yaml up -d --build`.
25
25
 
26
26
  The server applies its schema migrations on start and requires bearer authentication for data endpoints; `/health` has no session data. Every other command talks to it over HTTP at `--url` (default `http://127.0.0.1:4700`) with the token in `--token-file` (default `~/.local/share/archive/server.token`) or `ARCHIVE_TOKEN`:
27
27
 
@@ -48,7 +48,7 @@ archive connect --url https://your-archive.example --project-id inixiative --tok
48
48
  archive search --remote --query 'migration'
49
49
  ```
50
50
 
51
- `setup` is an alias for `connect`. Setup verifies access before saving `<home>/config/destinations.json` and does not upload; the Compose server (`serve --sync`) reads it and publishes every 30 seconds. `sync` and `routes` run the same publication, or preview it, once against `DATABASE_URL`. The URL locates the server; the token grants access. Configuration stores only the token file path (`tokenFile`) or environment variable name (`--token-env`, `tokenEnv`), never the token. A token file is read on every request, so rotating it needs no restart; symlinks and files readable by group or others are refused. Redirects are refused; only HTTPS or loopback HTTP is allowed.
51
+ `setup` is an alias for `connect`. Setup verifies access before saving `<home>/config/destinations.json` and does not upload; the Compose server (`serve --sync`) reads it and publishes every 30 seconds. `sync` and `routes` ask that server to run the same publication, or preview it, once. The URL locates the server; the token grants access. Configuration stores only the token file path (`tokenFile`) or environment variable name (`--token-env`, `tokenEnv`), never the token. A token file is read on every request, so rotating it needs no restart; symlinks and files readable by group or others are refused. Redirects are refused; only HTTPS or loopback HTTP is allowed.
52
52
 
53
53
  ## Always-on local archiving
54
54
 
@@ -112,8 +112,10 @@ The server manages routes over HTTP too, so Foundry can read and set them. Only
112
112
  | `destinations/libraries` | none | `paired`, and the `libraries` (`integrationId`, `resourceId`, `name`) the held Signets may write to |
113
113
  | `destinations/connect` | `projectId`, `integrationId`, `resourceId` | the saved destination, after Kingdom confirms `sessions.write` on the library (403 otherwise) |
114
114
  | `destinations/remove` | `projectId`, `integrationId`, `resourceId` | `removed` |
115
+ | `destinations/routes` | none | each archive with the destinations its project routes to; nothing is sent |
116
+ | `destinations/sync` | none | one publication to every destination: `id`, `destination`, `status` per archive and route |
115
117
 
116
- `@inixiative/archive/remote`'s `ArchiveClient` wraps these as `destinations()`, `libraries()`, `connectDestination()` and `removeDestination()`. The sync loop picks up changes within 30 seconds.
118
+ `@inixiative/archive/remote`'s `ArchiveClient` wraps these as `destinations()`, `libraries()`, `connectDestination()`, `removeDestination()`, `routes()` and `sync()`. The sync loop picks up changes within 30 seconds.
117
119
 
118
120
  ## Tags, references, actors and retention
119
121
 
@@ -144,7 +146,7 @@ All data endpoints are `POST /api/v1/archive/<action>` with a JSON body and `Aut
144
146
  | `delete` | `archiveId` | deleted |
145
147
  | `settings/read`, `settings/update` | `integrations?`, `retentionDays?` | settings |
146
148
  | `tags/list`, `tags/define`, `tags/remove` | `tag`, `actorId?`, `description?` | offered tags with usage counts |
147
- | `destinations/list`, `destinations/libraries`, `destinations/connect`, `destinations/remove` | see [Routing](#routing) | routes |
149
+ | `destinations/list`, `destinations/libraries`, `destinations/connect`, `destinations/remove`, `destinations/routes`, `destinations/sync` | see [Routing](#routing) | routes |
148
150
 
149
151
  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.
150
152
 
package/compose.yaml CHANGED
@@ -19,7 +19,7 @@ services:
19
19
  timeout: 5s
20
20
  retries: 20
21
21
  archive:
22
- image: ghcr.io/inixiative/archive:${ARCHIVE_VERSION:-latest}
22
+ image: ghcr.io/inixiative/archive:${ARCHIVE_VERSION:?archive up sets ARCHIVE_VERSION to the CLI's own version}
23
23
  command: ["bun", "run", "src/cli.ts", "serve", "--home", "${ARCHIVE_DATA_DIR}", "--hostname", "0.0.0.0", "--sync"]
24
24
  depends_on:
25
25
  postgres:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/archive",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "devDependencies": {
45
45
  "@biomejs/biome": "2.5.2",
46
- "@inixiative/config": "^0.5.64",
46
+ "@inixiative/config": "^0.5.66",
47
47
  "@types/bun": "1.4.2",
48
48
  "lefthook": "2.1.9",
49
49
  "typescript": "6.0.3"
package/src/cli.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  writeAgents,
17
17
  } from './agents';
18
18
  import { importChatGPTThread } from './chatgpt';
19
- import { archiveRequest, routingPreview, searchRemotes, syncArchives } from './client';
19
+ import { archiveRequest, searchRemotes, syncArchives } from './client';
20
20
  import { collectSessions } from './collector';
21
21
  import {
22
22
  archiveDestinationSchema,
@@ -30,7 +30,6 @@ import { pairWithKingdom, verifyKingdomDestination } from './kingdom';
30
30
  import { previewImports } from './preview';
31
31
  import { ArchiveClient, DEFAULT_URL } from './remote';
32
32
  import { startArchiveServer } from './server';
33
- import { ArchiveStore } from './store';
34
33
 
35
34
  /** Applies pending schema migrations to the archive database. */
36
35
  export function migrate(databaseUrl: string) {
@@ -97,7 +96,7 @@ export async function runCli(args = Bun.argv.slice(2)) {
97
96
  'pair --kingdom KINGDOM_API_URL [--name NAME] [--yes] (register this Archive with Kingdom; claim the review code there, then confirm the owner)\n' +
98
97
  'connect --url HTTPS_URL --project-id ID (--token-env ENV | --token-file PATH) (a remote Archive, for serve --sync)\n' +
99
98
  'connect --kind kingdom --url KINGDOM_API_URL --credential-file PATH --integration-id UUID --resource-id UUID --project-id ID (a hosted Archive through Kingdom)\n' +
100
- 'sync | routes (DATABASE_URL: publish to, or preview, destinations once)\n' +
99
+ 'sync | routes (the server publishes to, or previews, its destinations once)\n' +
101
100
  'agents add-collector --name N --source codex|claude-code --project-id ID --project-root DIR [--project-root ...] [--directory HISTORY] [--worktrees] [--atlas]\n' +
102
101
  'agents remove-collector --name N | server --url URL | install | uninstall | status\n' +
103
102
  `Data commands talk to the Archive server at --url (default ${DEFAULT_URL}) with the token in --token-file (default <home>/server.token) or ARCHIVE_TOKEN.`,
@@ -117,6 +116,12 @@ export async function runCli(args = Bun.argv.slice(2)) {
117
116
  output({ initialized: true, home, tokenFile });
118
117
  return;
119
118
  }
119
+ const packageVersion = () =>
120
+ (
121
+ JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
122
+ version: string;
123
+ }
124
+ ).version;
120
125
  if (command === 'up' || command === 'down') {
121
126
  // The machine's one local Archive: the bundled compose file, with its data in the home.
122
127
  if (command === 'up') init();
@@ -135,6 +140,7 @@ export async function runCli(args = Bun.argv.slice(2)) {
135
140
  env: {
136
141
  ...process.env,
137
142
  ARCHIVE_DATA_DIR: home,
143
+ ARCHIVE_VERSION: process.env.ARCHIVE_VERSION ?? packageVersion(),
138
144
  // Compose requires the variable even to stop; down never needs the real token.
139
145
  ARCHIVE_SERVER_TOKEN: readToken() ?? 'x'.repeat(32),
140
146
  },
@@ -305,24 +311,15 @@ export async function runCli(args = Bun.argv.slice(2)) {
305
311
  if (results.some((r) => 'error' in r)) process.exitCode = 1;
306
312
  return;
307
313
  }
308
- if (command === 'sync' || command === 'routes') {
309
- const store = new ArchiveStore(databaseUrl());
310
- try {
311
- if (command === 'routes') output(await routingPreview(store, readDestinations(config)));
312
- else {
313
- const results = await syncArchives(store, readDestinations(config));
314
- output(results);
315
- if (results.some((r) => r.status.startsWith('failed'))) process.exitCode = 1;
316
- }
317
- } finally {
318
- await store.close();
319
- }
320
- return;
321
- }
322
314
  const token = process.env.ARCHIVE_TOKEN ?? readToken();
323
315
  if (!token) throw new Error('No Archive token: run `archive init` or set ARCHIVE_TOKEN');
324
316
  const archive = new ArchiveClient({ url: v.url ?? DEFAULT_URL, token });
325
- if (command === 'import') {
317
+ if (command === 'routes') output(await archive.routes());
318
+ else if (command === 'sync') {
319
+ const results = await archive.sync();
320
+ output(results);
321
+ if (results.some((r) => r.status.startsWith('failed'))) process.exitCode = 1;
322
+ } else if (command === 'import') {
326
323
  if (
327
324
  !v.file ||
328
325
  !['codex', 'claude-code', 'chatgpt'].includes(v.source ?? '') ||
package/src/import.ts CHANGED
@@ -7,6 +7,47 @@ export interface ImportOptions {
7
7
  title?: string;
8
8
  projectId?: string;
9
9
  }
10
+ /** Content the harness wraps in or adds to a user message; the person never typed it. */
11
+ const injectedTags = [
12
+ 'system-reminder',
13
+ 'local-command-caveat',
14
+ 'local-command-stdout',
15
+ 'local-command-stderr',
16
+ 'command-name',
17
+ 'command-message',
18
+ 'command-args',
19
+ 'bash-input',
20
+ 'bash-stdout',
21
+ 'bash-stderr',
22
+ 'task-notification',
23
+ 'user-prompt-submit-hook',
24
+ 'fork-boilerplate',
25
+ 'recommended_plugins',
26
+ 'environment_context',
27
+ 'user_instructions',
28
+ 'permissions',
29
+ 'INSTRUCTIONS',
30
+ ].join('|');
31
+ const injectedBlock = new RegExp(`<(${injectedTags})(?:\\s[^>]*)?>[\\s\\S]*?</\\1>`, 'g');
32
+ const injectedOpening = new RegExp(`^<(?:${injectedTags})(?:>|\\s)`);
33
+ /** Foundry's native-session envelope; the person's text is the first User Message section. */
34
+ const foundryUserMessage =
35
+ /^# User Message[ \t]*\n([\s\S]*?)(?=\n# (?:User Message|Assistant Message|Foundry Injection)[ \t]*\n|(?![\s\S]))/m;
36
+
37
+ /** What the person typed in a user message, without injected content; undefined when nothing is left. */
38
+ function typedPrompt(text: string) {
39
+ let prompt = text
40
+ .replace(injectedBlock, '')
41
+ .replace(/<\/?pasted_content(?:\s[^>]*)?>/g, '')
42
+ .replace(/^\s*# AGENTS\.md instructions for [^\n]*/, '')
43
+ .trim();
44
+ if (/^# (?:System Context|User Message)[ \t]*\n/.test(prompt))
45
+ prompt = prompt.match(foundryUserMessage)?.[1].trim() ?? '';
46
+ if (!prompt || injectedOpening.test(prompt) || /^\[Request interrupted by user/.test(prompt))
47
+ return undefined;
48
+ return prompt;
49
+ }
50
+
10
51
  export function importTranscript(text: string, options: ImportOptions): ArchiveSnapshot {
11
52
  return importTranscriptLines(text.split('\n'), options);
12
53
  }
@@ -19,6 +60,8 @@ export function importTranscriptLines(
19
60
  let sessionId = options.sessionId;
20
61
  let reasoning = false;
21
62
  const seen = new Map<string, string>();
63
+ let prompt: string | undefined;
64
+ let metaPrompt: string | undefined;
22
65
  let index = -1;
23
66
  let contentBytes = 0;
24
67
  // Codex states the model per turn; Claude Code per assistant record.
@@ -75,6 +118,11 @@ export function importTranscriptLines(
75
118
  'Supported transcript content exceeds the archive size limit; no archive imported',
76
119
  );
77
120
  seen.set(id, identity);
121
+ // Claude Code marks expanded commands and caveats as meta: a title only when nothing was typed.
122
+ if (kind === 'user' && !prompt && !row.isCompactSummary) {
123
+ if (!row.isMeta) prompt = typedPrompt(content);
124
+ else metaPrompt ??= typedPrompt(content);
125
+ }
78
126
  entries.push({
79
127
  id,
80
128
  kind,
@@ -165,14 +213,7 @@ export function importTranscriptLines(
165
213
  }
166
214
  if (!sessionId || !entries.length)
167
215
  throw new Error('Transcript must identify a session and contain supported records');
168
- const firstPrompt = entries.find(
169
- (entry) =>
170
- entry.kind === 'user' &&
171
- !/^<(?:recommended_plugins|environment_context|user_instructions|permissions)(?:>|\s)/.test(
172
- entry.text.trimStart(),
173
- ),
174
- );
175
- const title = options.title ?? firstPrompt?.text.slice(0, 160) ?? sessionId;
216
+ const title = options.title ?? (prompt ?? metaPrompt)?.slice(0, 160) ?? sessionId;
176
217
  return archiveSnapshotSchema.parse({
177
218
  schemaVersion: 1,
178
219
  sourceId: options.sourceId,
package/src/remote.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { defaultHome } from './agents';
4
+ import type { routingPreview, syncArchives } from './client';
4
5
  import type { DescribedDestination } from './config';
5
6
  import type { ArchiveChunk, ArchiveSnapshot } from './index';
6
7
  import { archiveKey, archiveSnapshotSchema } from './index';
@@ -22,6 +23,8 @@ export type DestinationStatus = DescribedDestination & { delivered: number; pend
22
23
  /** A hosted Archive library a held Signet may write to. */
23
24
  export type ArchiveLibrary = { integrationId: string; resourceId: string; name: string };
24
25
  export type ArchiveLibraries = { paired: boolean; libraries: ArchiveLibrary[] };
26
+ export type RoutingPreview = Awaited<ReturnType<typeof routingPreview>>;
27
+ export type SyncResult = Awaited<ReturnType<typeof syncArchives>>[number];
25
28
  /** A project routed to a hosted Archive library through Kingdom. */
26
29
  export type KingdomRoute = { projectId: string; integrationId: string; resourceId: string };
27
30
 
@@ -162,6 +165,14 @@ export class ArchiveClient {
162
165
  libraries() {
163
166
  return this.request<ArchiveLibraries>('destinations/libraries');
164
167
  }
168
+ /** Each archive and the destinations its project routes to; nothing is sent. */
169
+ routes() {
170
+ return this.request<RoutingPreview>('destinations/routes');
171
+ }
172
+ /** Publishes every archive to its project's destinations once. */
173
+ sync() {
174
+ return this.request<SyncResult[]>('destinations/sync', {}, { timeoutMs: 600_000 });
175
+ }
165
176
  /** Routes a project to a library; the server verifies its Signet grants sessions.write first. */
166
177
  connectDestination(route: KingdomRoute) {
167
178
  return this.request<DescribedDestination>('destinations/connect', route);
package/src/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { timingSafeEqual } from 'node:crypto';
2
2
  import { z } from 'zod';
3
+ import { routingPreview, syncArchives } from './client';
3
4
  import {
4
5
  connectDestination,
5
6
  describeDestination,
@@ -195,6 +196,16 @@ export function createArchiveHandler(
195
196
  });
196
197
  return json({ data: { destinations } });
197
198
  }
199
+ if (config && path === '/api/v1/archive/destinations/routes') {
200
+ z.strictObject({}).parse(body);
201
+ return json({
202
+ data: await routingPreview(store, readDestinations(config.destinationsFile)),
203
+ });
204
+ }
205
+ if (config && path === '/api/v1/archive/destinations/sync') {
206
+ z.strictObject({}).parse(body);
207
+ return json({ data: await syncArchives(store, readDestinations(config.destinationsFile)) });
208
+ }
198
209
  if (config && path === '/api/v1/archive/destinations/libraries') {
199
210
  z.strictObject({}).parse(body);
200
211
  return json({ data: await pairedLibraries(config.kingdomDirectory) });