@inixiative/archive 0.6.0 → 0.7.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/src/remote.ts ADDED
@@ -0,0 +1,166 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { defaultHome } from './agents';
4
+ import type { ArchiveChunk, ArchiveSnapshot } from './index';
5
+ import { archiveKey, archiveSnapshotSchema } from './index';
6
+ import type { ArchiveFilter, ArchiveSettings, TagDefinition } from './schemas';
7
+ import type { ArchiveReferenceCount, TagSuggestion } from './tags';
8
+
9
+ export type ArchiveListing = Omit<ArchiveSnapshot, 'entries'> & {
10
+ id: string;
11
+ revision: number;
12
+ digest: string;
13
+ entries: number;
14
+ models: { model: string; effort?: string; entries: number }[];
15
+ suggestedTags: TagSuggestion[];
16
+ references: ArchiveReferenceCount[];
17
+ };
18
+
19
+ export class ArchiveRequestError extends Error {
20
+ constructor(
21
+ readonly status: number,
22
+ message: string,
23
+ ) {
24
+ super(message);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * An Archive server over HTTP. Collectors, the CLI and Foundry write through this; only the
30
+ * server holds the database.
31
+ */
32
+ export class ArchiveClient {
33
+ private cachedSourceId?: string;
34
+ constructor(private readonly options: { url: string; token: string; fetch?: typeof fetch }) {}
35
+ async request<T>(action: string, body: unknown = {}): Promise<T> {
36
+ const response = await (this.options.fetch ?? fetch)(
37
+ new URL(`api/v1/archive/${action}`, this.options.url),
38
+ {
39
+ method: 'POST',
40
+ redirect: 'error',
41
+ signal: AbortSignal.timeout(30_000),
42
+ headers: {
43
+ authorization: `Bearer ${this.options.token}`,
44
+ 'content-type': 'application/json',
45
+ },
46
+ body: JSON.stringify(body),
47
+ },
48
+ );
49
+ const payload = (await response.json().catch(() => ({}))) as { data?: T; error?: string };
50
+ if (!response.ok)
51
+ throw new ArchiveRequestError(response.status, payload.error ?? `Archive ${action} failed`);
52
+ return payload.data as T;
53
+ }
54
+ async sourceId() {
55
+ this.cachedSourceId ??= (await this.request<{ sourceId: string }>('info')).sourceId;
56
+ return this.cachedSourceId;
57
+ }
58
+ head(id: string) {
59
+ return this.request<{ id: string; digest: string; revision: number; projectId: string | null }>(
60
+ 'head',
61
+ { archiveId: id },
62
+ ).catch((error) => {
63
+ if (error instanceof ArchiveRequestError && error.status === 404) return undefined;
64
+ throw error;
65
+ });
66
+ }
67
+ /** Captures a snapshot as the next revision, retrying when another writer got there first. */
68
+ async capture(input: unknown) {
69
+ const snapshot = archiveSnapshotSchema.parse(input);
70
+ const id = archiveKey(snapshot);
71
+ for (let attempt = 0; ; attempt++) {
72
+ const head = await this.head(id);
73
+ try {
74
+ return await this.request<{
75
+ id: string;
76
+ digest: string;
77
+ revision: number;
78
+ changed: boolean;
79
+ }>('ingest', { snapshot, previousDigest: head?.digest ?? null });
80
+ } catch (error) {
81
+ const conflict =
82
+ error instanceof ArchiveRequestError &&
83
+ error.status === 409 &&
84
+ error.message === 'Revision conflict';
85
+ if (!conflict || attempt >= 3) throw error;
86
+ }
87
+ }
88
+ }
89
+ read(id: string, revision?: number) {
90
+ return this.request<{
91
+ id: string;
92
+ revision: number;
93
+ digest: string;
94
+ snapshot: ArchiveSnapshot;
95
+ chunks: ArchiveChunk[];
96
+ }>('read', { archiveId: id, ...(revision ? { revision } : {}) }).catch((error) => {
97
+ if (error instanceof ArchiveRequestError && error.status === 404) return undefined;
98
+ throw error;
99
+ });
100
+ }
101
+ /** Every listing matching the filter, following cursors. */
102
+ async list(filter: ArchiveFilter = {}) {
103
+ const archives: ArchiveListing[] = [];
104
+ let beforeId: string | undefined;
105
+ do {
106
+ const page = await this.request<{ archives: ArchiveListing[]; nextCursor: string | null }>(
107
+ 'list',
108
+ { ...filter, limit: 100, ...(beforeId ? { beforeId } : {}) },
109
+ );
110
+ archives.push(...page.archives);
111
+ beforeId = page.nextCursor ?? undefined;
112
+ } while (beforeId);
113
+ return archives;
114
+ }
115
+ search(
116
+ body: ArchiveFilter & { query?: string; budget?: number; limit?: number; beforeId?: string },
117
+ ) {
118
+ return this.request<{
119
+ archives: (Omit<ArchiveListing, 'id' | 'entries'> & {
120
+ archiveId: string;
121
+ chunks: ArchiveChunk[];
122
+ tokenCount: number;
123
+ })[];
124
+ tokenCount: number;
125
+ nextCursor: string | null;
126
+ }>('search', body);
127
+ }
128
+ tag(id: string, change: { add?: string[]; remove?: string[] }) {
129
+ return this.request<{ id: string; tags: string[] }>('tag', { archiveId: id, ...change });
130
+ }
131
+ delete(id: string) {
132
+ return this.request<{ deleted: boolean }>('delete', { archiveId: id });
133
+ }
134
+ settings() {
135
+ return this.request<ArchiveSettings>('settings/read');
136
+ }
137
+ updateSettings(change: Partial<ArchiveSettings>) {
138
+ return this.request<ArchiveSettings>('settings/update', change);
139
+ }
140
+ tagDefinitions(actorId?: string) {
141
+ return this.request<{ tags: (TagDefinition & { archives: number })[] }>(
142
+ 'tags/list',
143
+ actorId ? { actorId } : {},
144
+ );
145
+ }
146
+ }
147
+
148
+ /** The machine's local Archive server (the `archive` block in @inixiative/config ports). */
149
+ export const DEFAULT_URL = 'http://127.0.0.1:4700';
150
+
151
+ /**
152
+ * A client for this machine's Archive: `ARCHIVE_URL` or the default address, with `ARCHIVE_TOKEN`
153
+ * or the home's `server.token`. Undefined until `archive up` (or `init`) has made a token.
154
+ */
155
+ export function localArchive(options: { home?: string; url?: string; fetch?: typeof fetch } = {}) {
156
+ const tokenFile = join(options.home ?? defaultHome(), 'server.token');
157
+ const token =
158
+ process.env.ARCHIVE_TOKEN ??
159
+ (existsSync(tokenFile) ? readFileSync(tokenFile, 'utf8').trim() : undefined);
160
+ if (!token) return undefined;
161
+ return new ArchiveClient({
162
+ url: options.url ?? process.env.ARCHIVE_URL ?? DEFAULT_URL,
163
+ token,
164
+ fetch: options.fetch,
165
+ });
166
+ }
package/src/schemas.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ import type { ArchiveSnapshot } from './index';
3
+ import { archiveIntegrationSchema, defaultIntegrations } from './tags';
4
+
5
+ export const archiveSettingsSchema = z.strictObject({
6
+ /** Integrations sessions can reference; Kingdom and Foundry display these, Archive owns them. */
7
+ integrations: z
8
+ .array(archiveIntegrationSchema)
9
+ .max(50)
10
+ .refine(
11
+ (list) => new Set(list.map((i) => i.key)).size === list.length,
12
+ 'Duplicate integration keys',
13
+ ),
14
+ /** Archives whose latest capture is older than this are deleted here. Deletions never sync. */
15
+ retentionDays: z.number().int().positive().max(36_500).nullable(),
16
+ });
17
+ export type ArchiveSettings = z.infer<typeof archiveSettingsSchema>;
18
+ export const defaultSettings: ArchiveSettings = {
19
+ integrations: defaultIntegrations,
20
+ retentionDays: null,
21
+ };
22
+
23
+ export const tagSchema = z.string().min(1).max(120);
24
+ /** A conceptual tag this archive offers: archive-wide (no actor) or for one actor. */
25
+ export const tagDefinitionSchema = z.strictObject({
26
+ tag: tagSchema,
27
+ actorId: z.string().min(1).max(256).optional(),
28
+ description: z.string().max(500).optional(),
29
+ });
30
+ export type TagDefinition = z.infer<typeof tagDefinitionSchema>;
31
+
32
+ export interface ArchiveFilter {
33
+ projectId?: string;
34
+ source?: ArchiveSnapshot['source'];
35
+ tag?: string;
36
+ actorId?: string;
37
+ reference?: { integration: string; ref: string };
38
+ model?: string;
39
+ effort?: string;
40
+ }
package/src/server.ts CHANGED
@@ -1,13 +1,7 @@
1
1
  import { timingSafeEqual } from 'node:crypto';
2
2
  import { z } from 'zod';
3
- import {
4
- archiveKey,
5
- archiveReferenceSchema,
6
- archiveSnapshotSchema,
7
- selectChunks,
8
- snapshotDigest,
9
- } from './index';
10
- import { archiveSettingsSchema, LocalArchiveStore, tagDefinitionSchema } from './local';
3
+ import { archiveReferenceSchema, archiveSnapshotSchema, selectChunks } from './index';
4
+ import { ArchiveConflict, ArchiveStore, archiveSettingsSchema, tagDefinitionSchema } from './store';
11
5
 
12
6
  const ingestSchema = z.strictObject({
13
7
  snapshot: archiveSnapshotSchema,
@@ -43,19 +37,8 @@ const tagSchema = z.strictObject({
43
37
  const tagListSchema = z.strictObject({ actorId: z.string().min(1).max(256).optional() });
44
38
  const tagRemoveSchema = tagDefinitionSchema.pick({ tag: true, actorId: true });
45
39
 
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
- }
56
-
57
40
  /** One deployment, one ownership boundary. No shared cross-tenant database or admin API. */
58
- export function createArchiveHandler(store: LocalArchiveStore, token: string) {
41
+ export function createArchiveHandler(store: ArchiveStore, token: string) {
59
42
  if (token.length < 32)
60
43
  throw new Error('ARCHIVE_SERVER_TOKEN must contain at least 32 characters');
61
44
  const expected = Buffer.from(`Bearer ${token}`);
@@ -86,48 +69,53 @@ export function createArchiveHandler(store: LocalArchiveStore, token: string) {
86
69
  chunks.push(item.value);
87
70
  }
88
71
  const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
72
+ if (path === '/api/v1/archive/info') {
73
+ z.strictObject({}).parse(body);
74
+ return json({ data: { sourceId: await store.sourceId(), protocol: 2 } });
75
+ }
89
76
  if (path === '/api/v1/archive/ingest') {
90
77
  const { snapshot, previousDigest } = ingestSchema.parse(body);
91
- const id = archiveKey(snapshot),
92
- old = store.read(id),
93
- next = snapshotDigest(snapshot);
94
- if (old?.digest !== next && (old?.digest ?? null) !== previousDigest)
95
- return json({ error: 'Revision conflict' }, 409);
96
- if (old && old.digest !== next && old.snapshot.capturedAt > snapshot.capturedAt)
97
- return json({ error: 'Stale capture' }, 409);
98
- return json({ data: store.capture(snapshot) });
78
+ return json({ data: await store.capture(snapshot, { previousDigest }) });
79
+ }
80
+ if (path === '/api/v1/archive/head') {
81
+ const head = await store.head(readSchema.pick({ archiveId: true }).parse(body).archiveId);
82
+ return head ? json({ data: head }) : json({ error: 'Archive unavailable' }, 404);
99
83
  }
100
84
  if (path === '/api/v1/archive/read') {
101
85
  const input = readSchema.parse(body),
102
- archive = store.read(input.archiveId, input.revision);
86
+ archive = await store.read(input.archiveId, input.revision);
103
87
  return archive ? json({ data: archive }) : json({ error: 'Archive unavailable' }, 404);
104
88
  }
105
89
  if (path === '/api/v1/archive/list') {
106
90
  const { limit, beforeId, ...filter } = listSchema.parse(body);
107
- const result = page(store.list(filter), limit, beforeId);
91
+ const result = await store.page(filter, limit, beforeId);
108
92
  if (!result) return json({ error: 'Invalid cursor' }, 400);
109
93
  return json({ data: { archives: result.items, nextCursor: result.nextCursor } });
110
94
  }
111
95
  if (path === '/api/v1/archive/search') {
112
96
  const { query, budget, limit, beforeId, ...filter } = searchSchema.parse(body);
113
- let remaining = budget;
114
- const matching = store
115
- .list(filter)
116
- .filter(
117
- (a) =>
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())),
97
+ const lower = query.toLowerCase();
98
+ const result = await store.page(filter, limit, beforeId, async (listings) => {
99
+ if (!query) return new Set(listings.map((a) => a.id));
100
+ const hits = await store.matching(
101
+ listings.map((a) => a.id),
102
+ query,
121
103
  );
122
- const result = page(matching, limit, beforeId);
104
+ for (const a of listings)
105
+ if ([a.title, ...a.tags].some((text) => text.toLowerCase().includes(lower)))
106
+ hits.add(a.id);
107
+ return hits;
108
+ });
123
109
  if (!result) return json({ error: 'Invalid cursor' }, 400);
124
- const archives = result.items.map((a) => {
110
+ let remaining = budget;
111
+ const archives = [];
112
+ for (const a of result.items) {
125
113
  const selected =
126
114
  remaining >= 16
127
- ? selectChunks(store.read(a.id)!.chunks, query, remaining)
115
+ ? selectChunks((await store.read(a.id))!.chunks, query, remaining)
128
116
  : { chunks: [], tokenCount: 0 };
129
117
  remaining -= selected.tokenCount;
130
- return {
118
+ archives.push({
131
119
  archiveId: a.id,
132
120
  revision: a.revision,
133
121
  digest: a.digest,
@@ -140,37 +128,43 @@ export function createArchiveHandler(store: LocalArchiveStore, token: string) {
140
128
  references: a.references,
141
129
  coverage: a.coverage,
142
130
  ...selected,
143
- };
144
- });
131
+ });
132
+ }
145
133
  return json({
146
134
  data: { archives, tokenCount: budget - remaining, nextCursor: result.nextCursor },
147
135
  });
148
136
  }
149
137
  if (path === '/api/v1/archive/tag') {
150
138
  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) });
139
+ if (!(await store.read(input.archiveId)))
140
+ return json({ error: 'Archive unavailable' }, 404);
141
+ return json({ data: await store.tag(input.archiveId, input) });
153
142
  }
154
143
  if (path === '/api/v1/archive/delete') {
155
144
  const input = readSchema.pick({ archiveId: true }).parse(body);
156
- return store.delete(input.archiveId)
145
+ return (await store.delete(input.archiveId))
157
146
  ? json({ data: { archiveId: input.archiveId, deleted: true } })
158
147
  : json({ error: 'Archive unavailable' }, 404);
159
148
  }
160
149
  if (path === '/api/v1/archive/settings/read') {
161
150
  z.strictObject({}).parse(body);
162
- return json({ data: store.settings() });
151
+ return json({ data: await store.settings() });
163
152
  }
164
153
  if (path === '/api/v1/archive/settings/update')
165
- return json({ data: store.updateSettings(archiveSettingsSchema.partial().parse(body)) });
154
+ return json({
155
+ data: await store.updateSettings(archiveSettingsSchema.partial().parse(body)),
156
+ });
166
157
  if (path === '/api/v1/archive/tags/list')
167
- return json({ data: { tags: store.tagDefinitions(tagListSchema.parse(body).actorId) } });
158
+ return json({
159
+ data: { tags: await store.tagDefinitions(tagListSchema.parse(body).actorId) },
160
+ });
168
161
  if (path === '/api/v1/archive/tags/define')
169
- return json({ data: store.defineTag(tagDefinitionSchema.parse(body)) });
162
+ return json({ data: await store.defineTag(tagDefinitionSchema.parse(body)) });
170
163
  if (path === '/api/v1/archive/tags/remove')
171
- return json({ data: { removed: store.undefineTag(tagRemoveSchema.parse(body)) } });
164
+ return json({ data: { removed: await store.undefineTag(tagRemoveSchema.parse(body)) } });
172
165
  return json({ error: 'Not found' }, 404);
173
166
  } catch (error) {
167
+ if (error instanceof ArchiveConflict) return json({ error: error.message }, 409);
174
168
  if (error instanceof z.ZodError || error instanceof SyntaxError)
175
169
  return json({ error: 'Invalid archive request' }, 400);
176
170
  return json({ error: 'Archive operation failed' }, 500);
@@ -178,20 +172,20 @@ export function createArchiveHandler(store: LocalArchiveStore, token: string) {
178
172
  };
179
173
  }
180
174
 
181
- export function startArchiveServer(options: {
182
- store: string;
175
+ export async function startArchiveServer(options: {
176
+ databaseUrl: string;
183
177
  token: string;
184
178
  port?: number;
185
179
  hostname?: string;
186
180
  }) {
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();
181
+ const store = new ArchiveStore(options.databaseUrl);
192
182
  try {
183
+ // Retention runs at start and hourly; it only ever deletes from this archive.
184
+ await store.prune();
185
+ const retention = setInterval(() => void store.prune().catch(() => {}), 3_600_000);
186
+ retention.unref();
193
187
  const server = Bun.serve({
194
- port: options.port ?? 4411,
188
+ port: options.port ?? 4700,
195
189
  hostname: options.hostname ?? '127.0.0.1',
196
190
  maxRequestBodySize: 65_000_000,
197
191
  fetch: createArchiveHandler(store, options.token),
@@ -202,12 +196,11 @@ export function startArchiveServer(options: {
202
196
  async close() {
203
197
  clearInterval(retention);
204
198
  await server.stop(true);
205
- store.close();
199
+ await store.close();
206
200
  },
207
201
  };
208
202
  } catch (error) {
209
- clearInterval(retention);
210
- store.close();
203
+ await store.close();
211
204
  throw error;
212
205
  }
213
206
  }