@feltdb/core 0.12.0 → 0.12.1

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.
Files changed (54) hide show
  1. package/dist/agent-state.d.ts +489 -0
  2. package/dist/agent-state.d.ts.map +1 -0
  3. package/dist/agent-state.js +993 -0
  4. package/dist/build-identity.json +19 -0
  5. package/dist/create/build-identity.json +19 -0
  6. package/dist/create/package-versions.js +1 -1
  7. package/dist/create/server-source/crates/feltdb/src/lib.rs +11 -0
  8. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +4 -1
  9. package/dist/create/server-source/crates/feltdb/src/state_model.rs +854 -155
  10. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +31 -13
  11. package/dist/create/server-source/crates/feltdb/tests/state_ref_authority.rs +918 -0
  12. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +15 -2
  13. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +4 -0
  14. package/dist/create/server-source/crates/feltdb-server/benches/query_shape_observer.rs +136 -0
  15. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +3 -0
  16. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
  17. package/dist/create/server-source/crates/feltdb-server/src/main.rs +74 -5
  18. package/dist/create/server-source/crates/feltdb-server/src/query_shape.rs +1137 -0
  19. package/dist/create/server-source/crates/feltdb-server/tests/fixtures/query-shape-trace.expected.json +300 -0
  20. package/dist/create/server-source/crates/feltdb-server/tests/fixtures/query-shape-trace.json +25 -0
  21. package/dist/create/server-source/crates/feltdb-server/tests/query_shape_trace.rs +128 -0
  22. package/dist/index-core.d.ts +1 -0
  23. package/dist/index-core.d.ts.map +1 -1
  24. package/dist/index-core.js +1 -0
  25. package/dist/studio/app.d.ts.map +1 -1
  26. package/dist/studio/components/GlobalSearch.d.ts.map +1 -1
  27. package/dist/studio/components/index.js +1 -1
  28. package/dist/studio/{components-BHTKlUgT.js → components-Bivm2lX6.js} +2 -1
  29. package/dist/studio/control-plane/Agents.d.ts.map +1 -1
  30. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  31. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  32. package/dist/studio/control-plane/Overview.d.ts.map +1 -1
  33. package/dist/studio/control-plane/Schema.d.ts.map +1 -1
  34. package/dist/studio/control-plane/State.d.ts +11 -0
  35. package/dist/studio/control-plane/State.d.ts.map +1 -1
  36. package/dist/studio/control-plane/index.d.ts +1 -1
  37. package/dist/studio/control-plane/index.d.ts.map +1 -1
  38. package/dist/studio/control-plane/index.js +2 -2
  39. package/dist/studio/control-plane/primitives.d.ts +14 -2
  40. package/dist/studio/control-plane/primitives.d.ts.map +1 -1
  41. package/dist/studio/control-plane-CLm69XwX.js +4121 -0
  42. package/dist/studio/index.js +266 -235
  43. package/dist/studio/studio.css +1 -1
  44. package/dist/studio-app/assets/{feltdb_wasm-DYfWAV0E.js → feltdb_wasm-DACcD-zs.js} +1 -1
  45. package/dist/studio-app/assets/feltdb_wasm_bg-BejThWID.wasm +0 -0
  46. package/dist/studio-app/assets/index-CiC8VY-a.css +1 -0
  47. package/dist/studio-app/assets/index-CoZaxnlm.js +30 -0
  48. package/dist/studio-app/index.html +2 -2
  49. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  50. package/package.json +2 -2
  51. package/dist/studio/control-plane-DP8ljTDO.js +0 -3207
  52. package/dist/studio-app/assets/feltdb_wasm_bg-DHiOUhoQ.wasm +0 -0
  53. package/dist/studio-app/assets/index-6f6KVx5R.js +0 -30
  54. package/dist/studio-app/assets/index-BrsF-kWL.css +0 -1
@@ -0,0 +1,993 @@
1
+ /**
2
+ * Durable agent state: tasks, checkpoints, scoped memory, and handoffs.
3
+ *
4
+ * Everything here is ordinary FeltDB state in four `_feltdb.Agent*`
5
+ * collections. No new storage, cache, or authority is introduced:
6
+ *
7
+ * - every write is one `db.transaction`, fenced by the authority's own
8
+ * `ifVersion` / `requireAbsent` / `expectedLeaseId` predicates, so a stale
9
+ * or concurrent write is refused by the authority rather than by this module;
10
+ * - every read is `collection.get` by id or a bounded `db.query` on a declared
11
+ * compound index; nothing reads a whole collection;
12
+ * - nothing is held in process memory between calls, so a fresh process (or a
13
+ * different agent, framework, or model provider) resumes from exactly what
14
+ * was committed.
15
+ *
16
+ * createTask ──▶ _feltdb.AgentTask (head: status, owner, checkpoint_version)
17
+ * _feltdb.AgentCheckpoint (immutable, one per version)
18
+ * checkpoint ──▶ task@N → N+1 + checkpoint vN+1 + evidence fences, one commit
19
+ * remember ──▶ _feltdb.AgentMemory (scoped; superseded, never rewritten)
20
+ * handoff ──▶ _feltdb.AgentHandoff (a reference to task@checkpoint)
21
+ * claim ──▶ handoff open → claimed (leased) + task owner, one commit
22
+ *
23
+ * Provider identifiers, token counts, and transcript references are optional
24
+ * `execution` metadata on a checkpoint. They never decide status, ownership,
25
+ * or what a resume returns.
26
+ *
27
+ * Contract and guarantees: docs/reference/agent-state.md.
28
+ */
29
+ import { ConditionalConflictError, } from './transaction.js';
30
+ export const FELTDB_AGENT_TASK_COLLECTION = '_feltdb.AgentTask';
31
+ export const FELTDB_AGENT_CHECKPOINT_COLLECTION = '_feltdb.AgentCheckpoint';
32
+ export const FELTDB_AGENT_MEMORY_COLLECTION = '_feltdb.AgentMemory';
33
+ export const FELTDB_AGENT_HANDOFF_COLLECTION = '_feltdb.AgentHandoff';
34
+ export const AGENT_CONTEXT_FORMAT = 'feltdb.agent-context@1';
35
+ /** Compound indexes every agent-state query is planned against. */
36
+ export const AGENT_STATE_INDEXES = [
37
+ {
38
+ collection: FELTDB_AGENT_CHECKPOINT_COLLECTION,
39
+ index: {
40
+ name: 'agent_checkpoints_by_task_version',
41
+ type: 'compound',
42
+ fields: ['task_id', 'version', 'id'],
43
+ directions: ['asc', 'desc', 'asc'],
44
+ persistent: true,
45
+ },
46
+ },
47
+ {
48
+ collection: FELTDB_AGENT_MEMORY_COLLECTION,
49
+ index: {
50
+ name: 'agent_memory_by_scope_status_created',
51
+ type: 'compound',
52
+ fields: ['scope_key', 'status', 'created_at', 'id'],
53
+ directions: ['asc', 'asc', 'desc', 'asc'],
54
+ persistent: true,
55
+ },
56
+ },
57
+ ];
58
+ const TASK_STATUSES = ['ready', 'running', 'blocked', 'completed', 'cancelled'];
59
+ const TERMINAL = ['completed', 'cancelled'];
60
+ /** Every refusal. A refused write committed nothing. */
61
+ export class AgentStateError extends Error {
62
+ code;
63
+ detail;
64
+ constructor(code, message, detail = {}) {
65
+ super(message);
66
+ this.code = code;
67
+ this.detail = detail;
68
+ this.name = 'AgentStateError';
69
+ }
70
+ }
71
+ const SEGMENT = /^[A-Za-z0-9._-]+$/;
72
+ const LIST_LIMIT = 200;
73
+ const CUMULATIVE_LIMIT = 10_000;
74
+ const TEXT_LIMIT = 4_000;
75
+ const CONTENT_LIMIT = 32 * 1024;
76
+ const DEFAULT_LEASE_MS = 5 * 60_000;
77
+ function fail(code, message, detail) {
78
+ return new AgentStateError(code, message, detail);
79
+ }
80
+ function identifier(value, label, max = 96) {
81
+ if (typeof value !== 'string' || !value || value.length > max || !SEGMENT.test(value)) {
82
+ throw fail('INVALID_REQUEST', `${label} must be 1-${max} characters of [A-Za-z0-9._-]`);
83
+ }
84
+ return value;
85
+ }
86
+ function name(value, label) {
87
+ if (typeof value !== 'string' || !value.trim() || value.length > 128) {
88
+ throw fail('INVALID_REQUEST', `${label} must be a non-empty string of at most 128 characters`);
89
+ }
90
+ return value;
91
+ }
92
+ function text(value, label, max = TEXT_LIMIT) {
93
+ if (typeof value !== 'string' || !value.trim() || value.length > max) {
94
+ throw fail('INVALID_REQUEST', `${label} must be a non-empty string of at most ${max} characters`);
95
+ }
96
+ return value;
97
+ }
98
+ function list(value, label) {
99
+ if (value === undefined)
100
+ return [];
101
+ if (!Array.isArray(value) || value.length > LIST_LIMIT) {
102
+ throw fail('INVALID_REQUEST', `${label} must be an array of at most ${LIST_LIMIT} strings`);
103
+ }
104
+ return value.map((entry, index) => text(entry, `${label}[${index}]`));
105
+ }
106
+ function taskScope(value) {
107
+ if (value === undefined)
108
+ return {};
109
+ if (!value || typeof value !== 'object' || Array.isArray(value))
110
+ throw fail('INVALID_REQUEST', 'scope must be an object');
111
+ const { project, workspace, ...rest } = value;
112
+ if (Object.keys(rest).length)
113
+ throw fail('INVALID_REQUEST', `scope has unknown fields: ${Object.keys(rest).join(', ')}`);
114
+ return {
115
+ ...(project === undefined ? {} : { project: name(project, 'scope.project') }),
116
+ ...(workspace === undefined ? {} : { workspace: name(workspace, 'scope.workspace') }),
117
+ };
118
+ }
119
+ const EXECUTION_TEXT = ['adapter', 'provider', 'model', 'conversation_id', 'transcript_ref'];
120
+ const EXECUTION_COUNT = ['input_tokens', 'output_tokens'];
121
+ function execution(value) {
122
+ if (value === undefined)
123
+ return undefined;
124
+ if (!value || typeof value !== 'object' || Array.isArray(value))
125
+ throw fail('INVALID_REQUEST', 'execution must be an object');
126
+ const result = {};
127
+ for (const [key, entry] of Object.entries(value)) {
128
+ if (EXECUTION_TEXT.includes(key)) {
129
+ result[key] = text(entry, `execution.${key}`, 512);
130
+ }
131
+ else if (EXECUTION_COUNT.includes(key)) {
132
+ if (!Number.isInteger(entry) || entry < 0)
133
+ throw fail('INVALID_REQUEST', `execution.${key} must be a non-negative integer`);
134
+ result[key] = entry;
135
+ }
136
+ else {
137
+ throw fail('INVALID_REQUEST', `execution.${key} is not recorded; execution metadata is ${[...EXECUTION_TEXT, ...EXECUTION_COUNT].join(', ')}`);
138
+ }
139
+ }
140
+ return result;
141
+ }
142
+ function cumulative(previous, next, label) {
143
+ const combined = [...previous, ...next];
144
+ if (combined.length > CUMULATIVE_LIMIT) {
145
+ throw fail('INVALID_REQUEST', `${label} would exceed ${CUMULATIVE_LIMIT} entries`);
146
+ }
147
+ return combined;
148
+ }
149
+ function strip(row) {
150
+ const { id: _id, __version, ...rest } = row;
151
+ return { ...rest, revision: typeof __version === 'number' ? __version : 1 };
152
+ }
153
+ function version(row) {
154
+ return typeof row.__version === 'number' ? row.__version : 1;
155
+ }
156
+ function newId(prefix) {
157
+ const random = globalThis.crypto?.randomUUID?.()
158
+ ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
159
+ return `${prefix}-${random}`;
160
+ }
161
+ function checkpointId(taskId, checkpointVersion) {
162
+ return `${taskId}.v${checkpointVersion}`;
163
+ }
164
+ function memoryScopeKey(scope) {
165
+ if (scope.kind === 'task')
166
+ return `task:${identifier(scope.taskId, 'scope.taskId')}`;
167
+ if (scope.kind === 'project')
168
+ return `project:${name(scope.project, 'scope.project')}`;
169
+ if (scope.kind === 'workspace')
170
+ return `workspace:${name(scope.workspace, 'scope.workspace')}`;
171
+ throw fail('INVALID_REQUEST', 'scope.kind must be task, project, or workspace');
172
+ }
173
+ function memoryContent(value) {
174
+ let serialized;
175
+ try {
176
+ serialized = JSON.stringify(value);
177
+ }
178
+ catch {
179
+ serialized = undefined;
180
+ }
181
+ if (serialized === undefined || value === null || value === '') {
182
+ throw fail('INVALID_REQUEST', 'content must be a non-empty JSON value');
183
+ }
184
+ if (bytes(serialized) > CONTENT_LIMIT)
185
+ throw fail('INVALID_REQUEST', `content must serialize to at most ${CONTENT_LIMIT} bytes`);
186
+ return JSON.parse(serialized);
187
+ }
188
+ const encoder = new TextEncoder();
189
+ function bytes(value) {
190
+ return encoder.encode(value).length;
191
+ }
192
+ function now() {
193
+ return new Date().toISOString();
194
+ }
195
+ /**
196
+ * Open the agent-state contract over a FeltDB database.
197
+ *
198
+ * Refuses a runtime without atomic transactions instead of emulating one.
199
+ */
200
+ export async function openAgentState(db, options = {}) {
201
+ const capabilities = db.capabilities?.();
202
+ if (capabilities && !capabilities.transactions) {
203
+ throw fail('CONTRACT_UNSUPPORTED', 'agent state requires a FeltDB runtime with atomic transactions');
204
+ }
205
+ if ((options.declareIndexes ?? true) && !capabilities?.remoteAuthority) {
206
+ for (const { collection, index } of AGENT_STATE_INDEXES) {
207
+ const handle = db.collection(collection);
208
+ if (handle.listIndexes().some(existing => existing.name === index.name))
209
+ continue;
210
+ // An index declared in an earlier process is already a durable
211
+ // definition; declaring it again would only rewrite it.
212
+ const planned = await db.explain?.({
213
+ collection,
214
+ where: [{ field: index.fields[0], eq: '' }],
215
+ limit: 1,
216
+ }).catch(() => undefined);
217
+ if (planned?.index === index.name)
218
+ continue;
219
+ handle.createIndex(index);
220
+ }
221
+ }
222
+ return new AgentStateStore(db);
223
+ }
224
+ /** The agent-state contract. Holds no state of its own between calls. */
225
+ export class AgentStateStore {
226
+ db;
227
+ constructor(db) {
228
+ this.db = db;
229
+ }
230
+ // ---------------------------------------------------------------- tasks
231
+ async createTask(input) {
232
+ const taskId = input.taskId === undefined ? newId('task') : identifier(input.taskId, 'taskId');
233
+ const actor = name(input.actor, 'actor');
234
+ const status = input.status ?? 'ready';
235
+ if (!['ready', 'running', 'blocked'].includes(status)) {
236
+ throw fail('INVALID_REQUEST', 'a task is created ready, running, or blocked');
237
+ }
238
+ const at = now();
239
+ const pending = list(input.pendingWork, 'pendingWork');
240
+ const constraints = list(input.constraints, 'constraints');
241
+ const task = {
242
+ task_id: taskId,
243
+ objective: text(input.objective, 'objective'),
244
+ scope: taskScope(input.scope),
245
+ status,
246
+ owner: actor,
247
+ checkpoint_version: 1,
248
+ pending_work: pending,
249
+ constraints,
250
+ created_at: at,
251
+ created_by: actor,
252
+ updated_at: at,
253
+ updated_by: actor,
254
+ };
255
+ const checkpoint = {
256
+ checkpoint_id: checkpointId(taskId, 1),
257
+ task_id: taskId,
258
+ version: 1,
259
+ status,
260
+ summary: input.summary === undefined ? 'Task created.' : text(input.summary, 'summary'),
261
+ completed: [],
262
+ completed_work: [],
263
+ pending_work: pending,
264
+ constraints,
265
+ decisions: [],
266
+ evidence: [],
267
+ actor,
268
+ ...(input.execution ? { execution: execution(input.execution) } : {}),
269
+ created_at: at,
270
+ };
271
+ try {
272
+ await this.db.transaction(tx => {
273
+ tx.collection(FELTDB_AGENT_TASK_COLLECTION).set(taskId, task, { requireAbsent: true });
274
+ tx.collection(FELTDB_AGENT_CHECKPOINT_COLLECTION).set(checkpoint.checkpoint_id, checkpoint, { requireAbsent: true });
275
+ });
276
+ }
277
+ catch (error) {
278
+ if (error instanceof ConditionalConflictError)
279
+ throw fail('VERSION_CONFLICT', `task ${taskId} already exists`, { taskId });
280
+ throw error;
281
+ }
282
+ return { task: { ...task, revision: 1 }, checkpoint };
283
+ }
284
+ /** The task head and its latest committed checkpoint, from durable state. */
285
+ async getTask(taskId) {
286
+ const row = await this.taskRow(identifier(taskId, 'taskId'));
287
+ if (!row)
288
+ return null;
289
+ const task = strip(row);
290
+ const checkpoint = await this.getCheckpoint(task.task_id, task.checkpoint_version);
291
+ if (!checkpoint) {
292
+ throw fail('NOT_FOUND', `task ${taskId} names checkpoint ${task.checkpoint_version}, which is not committed`);
293
+ }
294
+ return { task, checkpoint };
295
+ }
296
+ async getCheckpoint(taskId, checkpointVersion) {
297
+ const row = await this.db.collection(FELTDB_AGENT_CHECKPOINT_COLLECTION).get(checkpointId(identifier(taskId, 'taskId'), checkpointVersion));
298
+ if (!row)
299
+ return null;
300
+ const { revision: _revision, ...checkpoint } = strip(row);
301
+ return checkpoint;
302
+ }
303
+ /**
304
+ * Commit checkpoint N+1 atomically with the task head.
305
+ *
306
+ * Refused, with nothing written, when `expectedVersion` is not the committed
307
+ * version, when another writer commits first, when `actor` does not own the
308
+ * task, when the task is terminal, or when cited record evidence is absent.
309
+ */
310
+ async checkpoint(taskId, input) {
311
+ const id = identifier(taskId, 'taskId');
312
+ const actor = name(input.actor, 'actor');
313
+ const summary = text(input.summary, 'summary');
314
+ if (input.status !== undefined && !TASK_STATUSES.includes(input.status)) {
315
+ throw fail('INVALID_REQUEST', `status must be one of ${TASK_STATUSES.join(', ')}`);
316
+ }
317
+ const completed = list(input.completed, 'completed');
318
+ const decisions = list(input.decisions, 'decisions');
319
+ const pending = input.pendingWork === undefined ? undefined : list(input.pendingWork, 'pendingWork');
320
+ const constraints = input.constraints === undefined ? undefined : list(input.constraints, 'constraints');
321
+ const metadata = execution(input.execution);
322
+ if (input.evidence !== undefined && (!Array.isArray(input.evidence) || input.evidence.length > LIST_LIMIT)) {
323
+ throw fail('INVALID_REQUEST', `evidence must be an array of at most ${LIST_LIMIT} references`);
324
+ }
325
+ const row = await this.requireTask(id);
326
+ const task = strip(row);
327
+ if (input.expectedVersion !== undefined && input.expectedVersion !== task.checkpoint_version) {
328
+ throw fail('VERSION_CONFLICT', `task ${id} is at checkpoint ${task.checkpoint_version}, not ${input.expectedVersion}`, {
329
+ expected: input.expectedVersion, current: task.checkpoint_version,
330
+ });
331
+ }
332
+ this.assertOwner(task, actor);
333
+ if (TERMINAL.includes(task.status)) {
334
+ throw fail('INVALID_TRANSITION', `task ${id} is ${task.status}; a terminal task takes no further checkpoints`);
335
+ }
336
+ const previous = await this.getCheckpoint(id, task.checkpoint_version);
337
+ if (!previous)
338
+ throw fail('NOT_FOUND', `checkpoint ${task.checkpoint_version} of task ${id} is not committed`);
339
+ const nextVersion = task.checkpoint_version + 1;
340
+ const cited = await this.resolveEvidence(input.evidence ?? [], nextVersion);
341
+ const at = now();
342
+ const status = input.status ?? task.status;
343
+ const next = {
344
+ checkpoint_id: checkpointId(id, nextVersion),
345
+ task_id: id,
346
+ version: nextVersion,
347
+ status,
348
+ summary,
349
+ completed,
350
+ completed_work: cumulative(previous.completed_work, completed, 'completed_work'),
351
+ pending_work: pending ?? previous.pending_work,
352
+ constraints: constraints ?? previous.constraints,
353
+ decisions: cumulative(previous.decisions, decisions, 'decisions'),
354
+ evidence: [...previous.evidence, ...cited.map(entry => entry.evidence)],
355
+ actor,
356
+ ...(metadata ? { execution: metadata } : {}),
357
+ created_at: at,
358
+ };
359
+ if (next.evidence.length > CUMULATIVE_LIMIT)
360
+ throw fail('INVALID_REQUEST', `evidence would exceed ${CUMULATIVE_LIMIT} entries`);
361
+ const head = {
362
+ ...withoutRevision(task),
363
+ status,
364
+ checkpoint_version: nextVersion,
365
+ pending_work: next.pending_work,
366
+ constraints: next.constraints,
367
+ updated_at: at,
368
+ updated_by: actor,
369
+ };
370
+ try {
371
+ await this.db.transaction(tx => {
372
+ tx.collection(FELTDB_AGENT_TASK_COLLECTION).set(id, head, { ifVersion: version(row) });
373
+ tx.collection(FELTDB_AGENT_CHECKPOINT_COLLECTION).set(next.checkpoint_id, next, { requireAbsent: true });
374
+ for (const entry of cited) {
375
+ if (entry.evidence.kind === 'record') {
376
+ tx.require({ collection: entry.evidence.collection, id: entry.evidence.id, ifVersion: entry.evidence.version });
377
+ }
378
+ }
379
+ });
380
+ }
381
+ catch (error) {
382
+ if (error instanceof ConditionalConflictError) {
383
+ const current = await this.taskRow(id);
384
+ const failure = error.failure;
385
+ if (failure && failure.collection !== FELTDB_AGENT_TASK_COLLECTION && failure.collection !== FELTDB_AGENT_CHECKPOINT_COLLECTION) {
386
+ throw fail('VERSION_CONFLICT', `cited evidence ${failure.key} changed before the checkpoint committed`, { evidence: failure.key });
387
+ }
388
+ throw fail('VERSION_CONFLICT', `task ${id} moved to checkpoint ${current ? current.checkpoint_version : 'unknown'} before this write committed`, {
389
+ expected: task.checkpoint_version, current: current?.checkpoint_version,
390
+ });
391
+ }
392
+ throw error;
393
+ }
394
+ return { task: { ...head, revision: version(row) + 1 }, checkpoint: next };
395
+ }
396
+ /**
397
+ * Resume a task from durable state alone: verify that `actor` owns it, then
398
+ * assemble its context. Writes nothing; the next `checkpoint` should pass
399
+ * `expectedVersion: bundle.checkpoint.version`.
400
+ */
401
+ async resume(taskId, input) {
402
+ const row = await this.requireTask(identifier(taskId, 'taskId'));
403
+ this.assertOwner(strip(row), name(input.actor, 'actor'));
404
+ return this.assembleContext(taskId, input);
405
+ }
406
+ // --------------------------------------------------------------- memory
407
+ async remember(input) {
408
+ const actor = name(input.source?.actor, 'source.actor');
409
+ const taskId = input.taskId === undefined ? undefined : identifier(input.taskId, 'taskId');
410
+ if (!input.scope && !taskId) {
411
+ throw fail('SCOPE_REQUIRED', 'memory requires a scope, or a taskId whose task scope it defaults to; there is no global memory');
412
+ }
413
+ const scope = input.scope ?? { kind: 'task', taskId: taskId };
414
+ const scopeKey = memoryScopeKey(scope);
415
+ if (taskId || scope.kind === 'task') {
416
+ const owningTask = taskId ?? scope.taskId;
417
+ const task = strip(await this.requireTask(owningTask));
418
+ if (scope.kind === 'task' && scope.taskId !== task.task_id) {
419
+ throw fail('INVALID_REQUEST', `memory for task ${task.task_id} cannot be written to task scope ${scope.taskId}`);
420
+ }
421
+ if (scope.kind === 'project' && task.scope.project !== scope.project) {
422
+ throw fail('INVALID_REQUEST', `task ${task.task_id} is not in project ${scope.project}`);
423
+ }
424
+ if (scope.kind === 'workspace' && task.scope.workspace !== scope.workspace) {
425
+ throw fail('INVALID_REQUEST', `task ${task.task_id} is not in workspace ${scope.workspace}`);
426
+ }
427
+ }
428
+ const memoryId = input.memoryId === undefined ? newId('mem') : identifier(input.memoryId, 'memoryId', 128);
429
+ const at = now();
430
+ const memory = {
431
+ memory_id: memoryId,
432
+ scope: scope.kind === 'task' ? { kind: 'task', taskId: scope.taskId } : scope,
433
+ scope_key: scopeKey,
434
+ kind: input.kind === undefined ? 'note' : name(input.kind, 'kind'),
435
+ content: memoryContent(input.content),
436
+ source: memorySource(input.source, actor, taskId ?? (scope.kind === 'task' ? scope.taskId : undefined)),
437
+ status: 'active',
438
+ created_at: at,
439
+ updated_at: at,
440
+ };
441
+ try {
442
+ await this.db.transaction(tx => {
443
+ tx.collection(FELTDB_AGENT_MEMORY_COLLECTION).set(memoryId, memory, { requireAbsent: true });
444
+ });
445
+ }
446
+ catch (error) {
447
+ if (error instanceof ConditionalConflictError)
448
+ throw fail('VERSION_CONFLICT', `memory ${memoryId} already exists`);
449
+ throw error;
450
+ }
451
+ return { ...memory, revision: 1 };
452
+ }
453
+ async getMemory(memoryId) {
454
+ const row = await this.db.collection(FELTDB_AGENT_MEMORY_COLLECTION).get(identifier(memoryId, 'memoryId', 128));
455
+ return row ? strip(row) : null;
456
+ }
457
+ /**
458
+ * One bounded page of a single scope, newest first (ties by id).
459
+ * There is no unscoped recall.
460
+ */
461
+ async recall(input) {
462
+ if (!input?.scope)
463
+ throw fail('SCOPE_REQUIRED', 'recall requires a scope; there is no global memory');
464
+ const status = input.status ?? 'active';
465
+ if (!['active', 'superseded', 'invalidated'].includes(status))
466
+ throw fail('INVALID_REQUEST', 'status must be active, superseded, or invalidated');
467
+ const page = await this.db.query({
468
+ collection: FELTDB_AGENT_MEMORY_COLLECTION,
469
+ where: [{ field: 'scope_key', eq: memoryScopeKey(input.scope) }, { field: 'status', eq: status }],
470
+ orderBy: [{ field: 'created_at', direction: 'desc' }],
471
+ limit: boundedLimit(input.limit, 50, 1_000),
472
+ ...(input.cursor ? { cursor: input.cursor } : {}),
473
+ });
474
+ return {
475
+ memories: page.records.map(row => strip(row)),
476
+ ...(page.nextCursor && !page.exhausted ? { nextCursor: page.nextCursor } : {}),
477
+ };
478
+ }
479
+ /**
480
+ * Active memories visible to a task: its own scope, then its project, then
481
+ * its workspace. A memory scoped to another task is never returned.
482
+ */
483
+ async recallForTask(taskId, input = {}) {
484
+ const task = strip(await this.requireTask(identifier(taskId, 'taskId')));
485
+ const limit = boundedLimit(input.limit, 20, 100);
486
+ const scopes = [
487
+ { kind: 'task', taskId: task.task_id },
488
+ ...(task.scope.project ? [{ kind: 'project', project: task.scope.project }] : []),
489
+ ...(task.scope.workspace ? [{ kind: 'workspace', workspace: task.scope.workspace }] : []),
490
+ ];
491
+ const pages = await Promise.all(scopes.map(scope => this.recall({ scope, limit })));
492
+ return pages.flatMap(page => page.memories);
493
+ }
494
+ /** Replace a memory with a new one, atomically. The old record stays, marked superseded. */
495
+ async supersede(memoryId, input) {
496
+ const id = identifier(memoryId, 'memoryId', 128);
497
+ const actor = name(input.source?.actor, 'source.actor');
498
+ const row = await this.db.collection(FELTDB_AGENT_MEMORY_COLLECTION).get(id);
499
+ if (!row)
500
+ throw fail('NOT_FOUND', `memory ${id} does not exist`);
501
+ const old = strip(row);
502
+ if (input.expectedRevision !== undefined && input.expectedRevision !== old.revision) {
503
+ throw fail('VERSION_CONFLICT', `memory ${id} is at revision ${old.revision}, not ${input.expectedRevision}`);
504
+ }
505
+ if (old.status !== 'active') {
506
+ throw fail('INVALID_TRANSITION', `memory ${id} is ${old.status}${old.superseded_by ? ` by ${old.superseded_by}` : ''}`);
507
+ }
508
+ const nextId = input.memoryId === undefined ? newId('mem') : identifier(input.memoryId, 'memoryId', 128);
509
+ const at = now();
510
+ const replaced = { ...withoutRevision(old), status: 'superseded', superseded_by: nextId, updated_at: at };
511
+ const memory = {
512
+ memory_id: nextId,
513
+ scope: old.scope,
514
+ scope_key: old.scope_key,
515
+ kind: input.kind === undefined ? old.kind : name(input.kind, 'kind'),
516
+ content: memoryContent(input.content),
517
+ source: memorySource(input.source, actor, old.source.task_id),
518
+ status: 'active',
519
+ supersedes: id,
520
+ created_at: at,
521
+ updated_at: at,
522
+ };
523
+ try {
524
+ await this.db.transaction(tx => {
525
+ tx.collection(FELTDB_AGENT_MEMORY_COLLECTION).set(id, replaced, { ifVersion: old.revision });
526
+ tx.collection(FELTDB_AGENT_MEMORY_COLLECTION).set(nextId, memory, { requireAbsent: true });
527
+ });
528
+ }
529
+ catch (error) {
530
+ if (error instanceof ConditionalConflictError) {
531
+ throw fail('VERSION_CONFLICT', `memory ${id} changed before the supersession committed`);
532
+ }
533
+ throw error;
534
+ }
535
+ return { superseded: { ...replaced, revision: old.revision + 1 }, memory: { ...memory, revision: 1 } };
536
+ }
537
+ /** Withdraw a memory without replacing it. */
538
+ async invalidate(memoryId, input) {
539
+ const id = identifier(memoryId, 'memoryId', 128);
540
+ name(input.actor, 'actor');
541
+ const reason = text(input.reason, 'reason');
542
+ const row = await this.db.collection(FELTDB_AGENT_MEMORY_COLLECTION).get(id);
543
+ if (!row)
544
+ throw fail('NOT_FOUND', `memory ${id} does not exist`);
545
+ const old = strip(row);
546
+ if (input.expectedRevision !== undefined && input.expectedRevision !== old.revision) {
547
+ throw fail('VERSION_CONFLICT', `memory ${id} is at revision ${old.revision}, not ${input.expectedRevision}`);
548
+ }
549
+ if (old.status !== 'active')
550
+ throw fail('INVALID_TRANSITION', `memory ${id} is already ${old.status}`);
551
+ const next = { ...withoutRevision(old), status: 'invalidated', invalidated_reason: reason, updated_at: now() };
552
+ try {
553
+ await this.db.transaction(tx => {
554
+ tx.collection(FELTDB_AGENT_MEMORY_COLLECTION).set(id, next, { ifVersion: old.revision });
555
+ });
556
+ }
557
+ catch (error) {
558
+ if (error instanceof ConditionalConflictError)
559
+ throw fail('VERSION_CONFLICT', `memory ${id} changed before the invalidation committed`);
560
+ throw error;
561
+ }
562
+ return { ...next, revision: old.revision + 1 };
563
+ }
564
+ // ------------------------------------------------------------- handoffs
565
+ /**
566
+ * Record that the task's owner offers it, at its committed checkpoint, to
567
+ * another agent. The handoff is fenced on the task head, so it can only
568
+ * reference the checkpoint that is committed when it commits.
569
+ */
570
+ async createHandoff(taskId, input) {
571
+ const id = identifier(taskId, 'taskId');
572
+ const actor = name(input.actor, 'actor');
573
+ const to = input.to === undefined ? undefined : name(input.to, 'to');
574
+ const row = await this.requireTask(id);
575
+ const task = strip(row);
576
+ this.assertOwner(task, actor);
577
+ if (TERMINAL.includes(task.status))
578
+ throw fail('INVALID_TRANSITION', `task ${id} is ${task.status}; it cannot be handed off`);
579
+ if (input.expectedVersion !== undefined && input.expectedVersion !== task.checkpoint_version) {
580
+ throw fail('VERSION_CONFLICT', `task ${id} is at checkpoint ${task.checkpoint_version}, not ${input.expectedVersion}`, {
581
+ expected: input.expectedVersion, current: task.checkpoint_version,
582
+ });
583
+ }
584
+ const checkpoint = await this.getCheckpoint(id, task.checkpoint_version);
585
+ if (!checkpoint)
586
+ throw fail('NOT_FOUND', `checkpoint ${task.checkpoint_version} of task ${id} is not committed`);
587
+ const memoryRefs = [...new Set(list(input.memoryRefs, 'memoryRefs'))];
588
+ const visible = new Set([`task:${id}`, ...(task.scope.project ? [`project:${task.scope.project}`] : []), ...(task.scope.workspace ? [`workspace:${task.scope.workspace}`] : [])]);
589
+ for (const memoryId of memoryRefs) {
590
+ const memory = await this.getMemory(memoryId);
591
+ if (!memory)
592
+ throw fail('INVALID_REQUEST', `memoryRefs names ${memoryId}, which does not exist`);
593
+ if (!visible.has(memory.scope_key))
594
+ throw fail('INVALID_REQUEST', `memory ${memoryId} (${memory.scope_key}) is not visible to task ${id}`);
595
+ }
596
+ const evidence = input.evidenceRefs ?? checkpoint.evidence;
597
+ if (!Array.isArray(evidence) || evidence.length > CUMULATIVE_LIMIT)
598
+ throw fail('INVALID_REQUEST', 'evidenceRefs must be an array');
599
+ const known = new Set(checkpoint.evidence.map(evidenceKey));
600
+ for (const entry of evidence) {
601
+ if (!known.has(evidenceKey(entry))) {
602
+ throw fail('INVALID_REQUEST', `evidenceRefs must be evidence cited by checkpoint ${checkpoint.version}`);
603
+ }
604
+ }
605
+ const handoffId = input.handoffId === undefined ? newId('handoff') : identifier(input.handoffId, 'handoffId', 128);
606
+ const at = now();
607
+ const record = {
608
+ handoff_id: handoffId,
609
+ task_id: id,
610
+ checkpoint_version: task.checkpoint_version,
611
+ from: actor,
612
+ ...(to ? { to } : {}),
613
+ ...(input.note === undefined ? {} : { note: text(input.note, 'note') }),
614
+ memory_refs: memoryRefs,
615
+ evidence_refs: evidence,
616
+ artifact_refs: list(input.artifactRefs, 'artifactRefs'),
617
+ status: 'open',
618
+ lease: null,
619
+ created_at: at,
620
+ updated_at: at,
621
+ };
622
+ try {
623
+ await this.db.transaction(tx => {
624
+ tx.collection(FELTDB_AGENT_HANDOFF_COLLECTION).set(handoffId, record, { requireAbsent: true });
625
+ tx.require({ collection: FELTDB_AGENT_TASK_COLLECTION, id, ifVersion: version(row) });
626
+ });
627
+ }
628
+ catch (error) {
629
+ if (error instanceof ConditionalConflictError) {
630
+ throw fail('VERSION_CONFLICT', `task ${id} changed, or handoff ${handoffId} exists, before the handoff committed`);
631
+ }
632
+ throw error;
633
+ }
634
+ return resolveHandoff({ ...record, revision: 1 }, task, checkpoint);
635
+ }
636
+ async getHandoff(handoffId) {
637
+ const row = await this.db.collection(FELTDB_AGENT_HANDOFF_COLLECTION).get(identifier(handoffId, 'handoffId', 128));
638
+ if (!row)
639
+ return null;
640
+ const record = strip(row);
641
+ const task = strip(await this.requireTask(record.task_id));
642
+ const checkpoint = await this.getCheckpoint(record.task_id, record.checkpoint_version);
643
+ if (!checkpoint)
644
+ throw fail('NOT_FOUND', `handoff ${handoffId} references checkpoint ${record.checkpoint_version}, which is not committed`);
645
+ return resolveHandoff(record, task, checkpoint);
646
+ }
647
+ /**
648
+ * Take exclusive, leased ownership of a handoff and its task.
649
+ *
650
+ * One commit moves the handoff to `claimed` and the task's owner to the
651
+ * claimant, fenced on both records' versions: of concurrent claimants
652
+ * exactly one succeeds. A claim that is not accepted before its lease
653
+ * expires may be taken by another claimant (see the reference for the
654
+ * clock this uses).
655
+ */
656
+ async claimHandoff(handoffId, input) {
657
+ const id = identifier(handoffId, 'handoffId', 128);
658
+ const claimant = name(input.claimant, 'claimant');
659
+ const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS;
660
+ if (!Number.isInteger(leaseMs) || leaseMs < 1 || leaseMs > 24 * 60 * 60_000) {
661
+ throw fail('INVALID_REQUEST', 'leaseMs must be an integer between 1 and 86400000');
662
+ }
663
+ const handoffs = this.db.collection(FELTDB_AGENT_HANDOFF_COLLECTION);
664
+ const first = await handoffs.get(id);
665
+ if (!first)
666
+ throw fail('NOT_FOUND', `handoff ${id} does not exist`);
667
+ const task = strip(await this.requireTask(strip(first).task_id));
668
+ // Decide on a handoff read taken after the task read. A claim commits both
669
+ // records together, so this pair can never show a claimed task beside an
670
+ // open handoff, which would misreport a lost race as a stale handoff.
671
+ const handoff = strip((await handoffs.get(id)));
672
+ if (handoff.to && handoff.to !== claimant) {
673
+ throw fail('OWNERSHIP_CONFLICT', `handoff ${id} is addressed to ${handoff.to}`);
674
+ }
675
+ const clock = Date.now();
676
+ const leaseHeld = Boolean(handoff.lease && handoff.lease.expiresAt > clock);
677
+ if (handoff.status === 'accepted') {
678
+ throw fail('HANDOFF_CLAIMED', `handoff ${id} was accepted by ${handoff.accepted_by}`, { claimant: handoff.accepted_by });
679
+ }
680
+ if (handoff.status === 'claimed' && leaseHeld) {
681
+ if (handoff.claim?.claimant === claimant && task.owner === claimant) {
682
+ // A retry by the holder after an uncertain outcome: the claim stands.
683
+ return { handoff: await this.resolveRecord(handoff, task), leaseId: handoff.lease.leaseId, expiresAt: handoff.lease.expiresAt };
684
+ }
685
+ throw fail('HANDOFF_CLAIMED', `handoff ${id} is claimed by ${handoff.claim?.claimant}`, { claimant: handoff.claim?.claimant });
686
+ }
687
+ if (handoff.status === 'open') {
688
+ if (task.owner !== handoff.from || task.checkpoint_version !== handoff.checkpoint_version) {
689
+ throw fail('HANDOFF_STALE', `task ${task.task_id} moved past handoff ${id} (checkpoint ${task.checkpoint_version}, owner ${task.owner})`, {
690
+ handoffCheckpoint: handoff.checkpoint_version, currentCheckpoint: task.checkpoint_version,
691
+ });
692
+ }
693
+ }
694
+ else if (task.owner !== handoff.claim?.claimant) {
695
+ // An expired claim: only recoverable while the task is still with the
696
+ // claimant that abandoned it.
697
+ throw fail('HANDOFF_STALE', `task ${task.task_id} is owned by ${task.owner}, not the expired claimant`);
698
+ }
699
+ const leaseId = newId('lease');
700
+ const expiresAt = clock + leaseMs;
701
+ const at = now();
702
+ const claimed = {
703
+ ...withoutRevision(handoff),
704
+ status: 'claimed',
705
+ claim: { claimant, claimed_at: at, attempt: (handoff.claim?.attempt ?? 0) + 1 },
706
+ lease: { leaseId, expiresAt },
707
+ updated_at: at,
708
+ };
709
+ const head = { ...withoutRevision(task), owner: claimant, updated_at: at, updated_by: claimant };
710
+ try {
711
+ await this.db.transaction(tx => {
712
+ tx.collection(FELTDB_AGENT_HANDOFF_COLLECTION).set(id, claimed, { ifVersion: handoff.revision });
713
+ tx.collection(FELTDB_AGENT_TASK_COLLECTION).set(task.task_id, head, { ifVersion: task.revision });
714
+ });
715
+ }
716
+ catch (error) {
717
+ if (error instanceof ConditionalConflictError) {
718
+ const current = await this.db.collection(FELTDB_AGENT_HANDOFF_COLLECTION).get(id);
719
+ const winner = current ? strip(current) : undefined;
720
+ if (winner && winner.status !== 'open' && winner.claim?.claimant !== claimant) {
721
+ throw fail('HANDOFF_CLAIMED', `handoff ${id} was claimed by ${winner.claim?.claimant}`, { claimant: winner.claim?.claimant });
722
+ }
723
+ throw fail('VERSION_CONFLICT', `handoff ${id} or task ${task.task_id} changed before the claim committed`);
724
+ }
725
+ throw error;
726
+ }
727
+ return {
728
+ handoff: await this.resolveRecord({ ...claimed, revision: handoff.revision + 1 }, { ...head, revision: task.revision + 1 }),
729
+ leaseId,
730
+ expiresAt,
731
+ };
732
+ }
733
+ /**
734
+ * Make a claim final. The authority checks, inside the commit, that the
735
+ * caller's lease is the one recorded and has not expired.
736
+ */
737
+ async acceptHandoff(handoffId, input) {
738
+ const id = identifier(handoffId, 'handoffId', 128);
739
+ const claimant = name(input.claimant, 'claimant');
740
+ const leaseId = name(input.leaseId, 'leaseId');
741
+ const row = await this.db.collection(FELTDB_AGENT_HANDOFF_COLLECTION).get(id);
742
+ if (!row)
743
+ throw fail('NOT_FOUND', `handoff ${id} does not exist`);
744
+ const handoff = strip(row);
745
+ if (handoff.status !== 'claimed' || handoff.claim?.claimant !== claimant) {
746
+ throw fail('INVALID_TRANSITION', `handoff ${id} is ${handoff.status}${handoff.claim ? ` by ${handoff.claim.claimant}` : ''}; only its claimant can accept it`);
747
+ }
748
+ const at = now();
749
+ const accepted = {
750
+ ...withoutRevision(handoff), status: 'accepted', accepted_by: claimant, accepted_at: at, lease: null, updated_at: at,
751
+ };
752
+ try {
753
+ await this.db.transaction(tx => {
754
+ tx.collection(FELTDB_AGENT_HANDOFF_COLLECTION).set(id, accepted, { ifVersion: handoff.revision, expectedLeaseId: leaseId });
755
+ });
756
+ }
757
+ catch (error) {
758
+ if (error instanceof ConditionalConflictError) {
759
+ throw fail('HANDOFF_STALE', `the claim on handoff ${id} expired or was taken over before it was accepted`);
760
+ }
761
+ throw error;
762
+ }
763
+ const task = strip(await this.requireTask(handoff.task_id));
764
+ return this.resolveRecord({ ...accepted, revision: handoff.revision + 1 }, task);
765
+ }
766
+ // -------------------------------------------------------------- context
767
+ /**
768
+ * A provider-neutral, bounded context bundle assembled from committed
769
+ * state only.
770
+ *
771
+ * Required content — the task, the latest checkpoint's status, summary,
772
+ * pending work and constraints, every evidence reference, and the handoff —
773
+ * is never truncated: if it does not fit `maxBytes` the call fails with
774
+ * BUDGET_EXCEEDED. Decisions, memories, completed work and recent
775
+ * checkpoints fill the remainder newest first, and what did not fit is
776
+ * counted in `budget.omitted`.
777
+ */
778
+ async assembleContext(taskId, options) {
779
+ if (!Number.isInteger(options?.maxBytes) || options.maxBytes < 1) {
780
+ throw fail('INVALID_REQUEST', 'maxBytes must be a positive integer');
781
+ }
782
+ const loaded = await this.getTask(taskId);
783
+ if (!loaded)
784
+ throw fail('NOT_FOUND', `task ${taskId} does not exist`);
785
+ const { task, checkpoint } = loaded;
786
+ let handoff;
787
+ if (options.handoffId !== undefined) {
788
+ const resolved = await this.getHandoff(options.handoffId);
789
+ if (!resolved || resolved.task_id !== task.task_id)
790
+ throw fail('NOT_FOUND', `handoff ${options.handoffId} does not belong to task ${task.task_id}`);
791
+ handoff = {
792
+ handoff_id: resolved.handoff_id,
793
+ checkpoint_version: resolved.checkpoint_version,
794
+ status: resolved.status,
795
+ from: resolved.from,
796
+ ...(resolved.to ? { to: resolved.to } : {}),
797
+ ...(resolved.note ? { note: resolved.note } : {}),
798
+ memory_refs: resolved.memory_refs,
799
+ artifact_refs: resolved.artifact_refs,
800
+ evidence_refs: resolved.evidence_refs,
801
+ };
802
+ }
803
+ const memories = await this.recallForTask(task.task_id, { limit: options.memoryLimit });
804
+ const referenced = handoff ? new Set(handoff.memory_refs) : new Set();
805
+ // Memories the handoff names come first: the sender chose them.
806
+ memories.sort((left, right) => Number(referenced.has(right.memory_id)) - Number(referenced.has(left.memory_id)));
807
+ const recentPage = await this.db.query({
808
+ collection: FELTDB_AGENT_CHECKPOINT_COLLECTION,
809
+ where: [{ field: 'task_id', eq: task.task_id }],
810
+ orderBy: [{ field: 'version', direction: 'desc' }],
811
+ limit: boundedLimit(options.recentLimit, 5, 50),
812
+ });
813
+ const bundle = {
814
+ format: AGENT_CONTEXT_FORMAT,
815
+ task: {
816
+ task_id: task.task_id,
817
+ objective: task.objective,
818
+ scope: task.scope,
819
+ status: task.status,
820
+ owner: task.owner,
821
+ task_revision: task.revision,
822
+ checkpoint_version: task.checkpoint_version,
823
+ },
824
+ checkpoint: {
825
+ version: checkpoint.version,
826
+ status: checkpoint.status,
827
+ summary: checkpoint.summary,
828
+ actor: checkpoint.actor,
829
+ created_at: checkpoint.created_at,
830
+ pending_work: checkpoint.pending_work,
831
+ constraints: checkpoint.constraints,
832
+ completed_work: [],
833
+ decisions: [],
834
+ },
835
+ evidence: checkpoint.evidence,
836
+ ...(handoff ? { handoff } : {}),
837
+ memories: [],
838
+ recent: [],
839
+ budget: {
840
+ maxBytes: options.maxBytes,
841
+ usedBytes: 0,
842
+ unit: 'utf8-bytes',
843
+ omitted: { completed_work: 0, decisions: 0, memories: 0, recent: 0 },
844
+ },
845
+ };
846
+ // Measured with the widest possible counters so that filling them in
847
+ // later can never push a fitted bundle over the budget.
848
+ const widest = { completed_work: CUMULATIVE_LIMIT, decisions: CUMULATIVE_LIMIT, memories: CUMULATIVE_LIMIT, recent: CUMULATIVE_LIMIT };
849
+ const sizeOf = () => bytes(JSON.stringify({ ...bundle, budget: { ...bundle.budget, usedBytes: options.maxBytes, omitted: widest } }));
850
+ let used = sizeOf();
851
+ if (used > options.maxBytes) {
852
+ throw fail('BUDGET_EXCEEDED', `the required context for task ${task.task_id} is ${used} bytes, more than the ${options.maxBytes}-byte budget`, {
853
+ requiredBytes: used, maxBytes: options.maxBytes,
854
+ });
855
+ }
856
+ // Adding an element to a compact JSON array adds exactly its own
857
+ // serialization, plus one comma when the array is not empty.
858
+ const fill = (target, candidates, prepend) => {
859
+ let taken = 0;
860
+ for (const candidate of candidates) {
861
+ const cost = bytes(JSON.stringify(candidate)) + (target.length ? 1 : 0);
862
+ if (used + cost > options.maxBytes)
863
+ break;
864
+ if (prepend)
865
+ target.unshift(candidate);
866
+ else
867
+ target.push(candidate);
868
+ used += cost;
869
+ taken += 1;
870
+ }
871
+ return candidates.length - taken;
872
+ };
873
+ bundle.budget.omitted.decisions = fill(bundle.checkpoint.decisions, [...checkpoint.decisions].reverse(), true);
874
+ bundle.budget.omitted.memories = fill(bundle.memories, memories.map(memory => ({
875
+ memory_id: memory.memory_id,
876
+ scope_key: memory.scope_key,
877
+ kind: memory.kind,
878
+ content: memory.content,
879
+ source: memory.source,
880
+ created_at: memory.created_at,
881
+ })), false);
882
+ bundle.budget.omitted.completed_work = fill(bundle.checkpoint.completed_work, [...checkpoint.completed_work].reverse(), true);
883
+ bundle.budget.omitted.recent = fill(bundle.recent, recentPage.records.map(row => ({
884
+ version: row.version,
885
+ status: row.status,
886
+ summary: row.summary,
887
+ actor: row.actor,
888
+ created_at: row.created_at,
889
+ })), false);
890
+ bundle.budget.usedBytes = 0;
891
+ bundle.budget.usedBytes = bytes(JSON.stringify(bundle));
892
+ // usedBytes counts its own digits; one pass settles it.
893
+ bundle.budget.usedBytes = bytes(JSON.stringify(bundle));
894
+ return bundle;
895
+ }
896
+ // ------------------------------------------------------------- internal
897
+ async taskRow(taskId) {
898
+ return this.db.collection(FELTDB_AGENT_TASK_COLLECTION).get(taskId);
899
+ }
900
+ async requireTask(taskId) {
901
+ const row = await this.taskRow(taskId);
902
+ if (!row)
903
+ throw fail('NOT_FOUND', `task ${taskId} does not exist`);
904
+ return row;
905
+ }
906
+ assertOwner(task, actor) {
907
+ if (task.owner !== actor) {
908
+ throw fail('OWNERSHIP_CONFLICT', `task ${task.task_id} is owned by ${task.owner}, not ${actor}`, { owner: task.owner });
909
+ }
910
+ }
911
+ async resolveEvidence(inputs, checkpointVersion) {
912
+ const resolved = [];
913
+ for (const [index, input] of inputs.entries()) {
914
+ const label = `evidence[${index}]`;
915
+ const description = input?.description === undefined ? {} : { description: text(input.description, `${label}.description`, 1_000) };
916
+ if (input?.kind === 'record') {
917
+ const collection = identifier(input.collection, `${label}.collection`, 128);
918
+ const id = identifier(input.id, `${label}.id`, 128);
919
+ const row = await this.db.collection(collection).get(id);
920
+ if (!row)
921
+ throw fail('INVALID_REQUEST', `${label} cites ${collection}/${id}, which is not committed state`);
922
+ const actual = version(row);
923
+ if (input.version !== undefined && input.version !== actual) {
924
+ throw fail('VERSION_CONFLICT', `${label} cites ${collection}/${id} at version ${input.version}; it is at ${actual}`);
925
+ }
926
+ resolved.push({ evidence: { kind: 'record', collection, id, version: actual, verified: true, ...description, checkpoint_version: checkpointVersion } });
927
+ }
928
+ else if (input?.kind === 'external') {
929
+ resolved.push({
930
+ evidence: {
931
+ kind: 'external',
932
+ uri: text(input.uri, `${label}.uri`, 2_000),
933
+ ...(input.digest === undefined ? {} : { digest: text(input.digest, `${label}.digest`, 256) }),
934
+ verified: false,
935
+ ...description,
936
+ checkpoint_version: checkpointVersion,
937
+ },
938
+ });
939
+ }
940
+ else {
941
+ throw fail('INVALID_REQUEST', `${label}.kind must be record or external`);
942
+ }
943
+ }
944
+ return resolved;
945
+ }
946
+ async resolveRecord(record, task) {
947
+ const checkpoint = await this.getCheckpoint(record.task_id, record.checkpoint_version);
948
+ if (!checkpoint)
949
+ throw fail('NOT_FOUND', `handoff ${record.handoff_id} references checkpoint ${record.checkpoint_version}, which is not committed`);
950
+ return resolveHandoff(record, task, checkpoint);
951
+ }
952
+ }
953
+ function resolveHandoff(record, task, checkpoint) {
954
+ return {
955
+ ...record,
956
+ objective: task.objective,
957
+ scope: task.scope,
958
+ constraints: checkpoint.constraints,
959
+ pending_work: checkpoint.pending_work,
960
+ completed_work: checkpoint.completed_work,
961
+ summary: checkpoint.summary,
962
+ current_checkpoint_version: task.checkpoint_version,
963
+ current_owner: task.owner,
964
+ };
965
+ }
966
+ function memorySource(source, actor, taskId) {
967
+ if (source.checkpointVersion !== undefined && (!Number.isInteger(source.checkpointVersion) || source.checkpointVersion < 1)) {
968
+ throw fail('INVALID_REQUEST', 'source.checkpointVersion must be a positive integer');
969
+ }
970
+ return {
971
+ actor,
972
+ ...(taskId ? { task_id: taskId } : {}),
973
+ ...(source.checkpointVersion === undefined ? {} : { checkpoint_version: source.checkpointVersion }),
974
+ ...(source.adapter === undefined ? {} : { adapter: name(source.adapter, 'source.adapter') }),
975
+ ...(source.evidence === undefined ? {} : { evidence: list(source.evidence, 'source.evidence') }),
976
+ };
977
+ }
978
+ function evidenceKey(entry) {
979
+ return entry.kind === 'record'
980
+ ? `record:${entry.collection}/${entry.id}@${entry.version}`
981
+ : `external:${entry.uri}#${entry.digest ?? ''}`;
982
+ }
983
+ function withoutRevision(value) {
984
+ const { revision: _revision, ...rest } = value;
985
+ return rest;
986
+ }
987
+ function boundedLimit(value, fallback, max) {
988
+ if (value === undefined)
989
+ return fallback;
990
+ if (!Number.isInteger(value) || value < 1 || value > max)
991
+ throw fail('INVALID_REQUEST', `limit must be an integer between 1 and ${max}`);
992
+ return value;
993
+ }