@atrib/recall 0.3.2 → 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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  /**
4
- * @atrib/recall recall_my_attribution_history MCP server.
4
+ * @atrib/recall - recall_my_attribution_history MCP server.
5
5
  *
6
6
  * Exposes a single tool to the host agent: recall_my_attribution_history.
7
7
  * Reads signed-record jsonl mirrors (per spec §5.9), VERIFIES the Ed25519
@@ -10,9 +10,9 @@
10
10
  * or partial mirror state.
11
11
  *
12
12
  * Mirror discovery (in priority order):
13
- * 1. ATRIB_RECORD_FILE single explicit jsonl file. Back-compat with
13
+ * 1. ATRIB_RECORD_FILE - single explicit jsonl file. Back-compat with
14
14
  * pre-0.4.0 callers that pinned a specific producer's mirror.
15
- * 2. ATRIB_MIRROR_DIR directory; recall reads every `*.jsonl` inside.
15
+ * 2. ATRIB_MIRROR_DIR - directory; recall reads every `*.jsonl` inside.
16
16
  * Default: ~/.atrib/records (the spec §5.9 well-known mirror namespace).
17
17
  *
18
18
  * Two on-disk shapes are accepted, matching D062 / spec §5.9:
@@ -26,14 +26,14 @@
26
26
  * should fetch the inclusion proof from the log API.
27
27
  *
28
28
  * Configuration via environment variables:
29
- * ATRIB_RECORD_FILE single explicit file (overrides directory scan).
30
- * ATRIB_MIRROR_DIR directory to scan. Default: ~/.atrib/records.
31
- * ATRIB_LOG_ORIGIN origin used in human-readable messages.
29
+ * ATRIB_RECORD_FILE - single explicit file (overrides directory scan).
30
+ * ATRIB_MIRROR_DIR - directory to scan. Default: ~/.atrib/records.
31
+ * ATRIB_LOG_ORIGIN - origin used in human-readable messages.
32
32
  * Default: log.atrib.dev
33
33
  */
34
34
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
35
35
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
36
- import { verifyRecord, EVENT_TYPE_TOOL_CALL_URI, EVENT_TYPE_TRANSACTION_URI, } from '@atrib/mcp';
36
+ import { verifyRecord, EVENT_TYPE_TOOL_CALL_URI, EVENT_TYPE_TRANSACTION_URI, EVENT_TYPE_ANNOTATION_URI, EVENT_TYPE_REVISION_URI, } from '@atrib/mcp';
37
37
  // Short-form event_type names accepted by the recall MCP schema map onto
38
38
  // their atrib-normative URI form (spec §1.2.4). Records sign the URI form
39
39
  // per §1.4.5 + isValidEventTypeUri; without this mapping, a recall caller
@@ -42,14 +42,45 @@ import { verifyRecord, EVENT_TYPE_TOOL_CALL_URI, EVENT_TYPE_TRANSACTION_URI, } f
42
42
  const EVENT_TYPE_SHORT_TO_URI = {
43
43
  tool_call: EVENT_TYPE_TOOL_CALL_URI,
44
44
  transaction: EVENT_TYPE_TRANSACTION_URI,
45
+ annotation: EVENT_TYPE_ANNOTATION_URI,
46
+ revision: EVENT_TYPE_REVISION_URI,
45
47
  };
48
+ export const IMPORTANCE_NUMERIC = {
49
+ critical: 5,
50
+ high: 4,
51
+ medium: 3,
52
+ low: 2,
53
+ noise: 1,
54
+ };
55
+ // Layer 1 ranking weights per the recall semantic surface design. Park et al. 2023
56
+ // "Generative Agents" defaults; tunable via env for experiment-time
57
+ // per-axis sensitivity studies. Values must sum to 1.0; the implementation
58
+ // does not enforce this but the operator-facing default does. Exported so
59
+ // future releases implementing the parkScore function can import them.
60
+ export const ATRIB_RECALL_ALPHA = parseFloat(process.env.ATRIB_RECALL_ALPHA ?? '0.3');
61
+ export const ATRIB_RECALL_BETA = parseFloat(process.env.ATRIB_RECALL_BETA ?? '0.3');
62
+ export const ATRIB_RECALL_GAMMA = parseFloat(process.env.ATRIB_RECALL_GAMMA ?? '0.4');
63
+ // Recency time constant (in days) for the exponential-decay scoring
64
+ // component. 7-day default per design; longer windows favor older records,
65
+ // shorter windows favor very-recent records. Tunable per experiment.
66
+ export const ATRIB_RECALL_TAU_DAYS = parseFloat(process.env.ATRIB_RECALL_TAU_DAYS ?? '7');
46
67
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
47
68
  import { homedir } from 'node:os';
48
69
  import { join } from 'node:path';
49
70
  import { z } from 'zod';
71
+ import { aggregateAnnotationsByRecord, aggregateRevisionsByRecord, discoverLoaded, } from './aggregations.js';
72
+ import { recencyScore, importanceScore, parkScore, buildBM25Index, bm25Score, tokenize, indexableTextFromAnnotation, } from './scoring.js';
73
+ import { buildLocalGraph, shortestDistances, walkFrom, } from './graph.js';
50
74
  const ATRIB_RECORD_FILE = process.env.ATRIB_RECORD_FILE;
51
75
  const ATRIB_MIRROR_DIR = process.env.ATRIB_MIRROR_DIR ?? join(homedir(), '.atrib', 'records');
52
76
  const ATRIB_LOG_ORIGIN = process.env.ATRIB_LOG_ORIGIN ?? 'log.atrib.dev';
77
+ // 32-hex context_id pattern per spec §1.2.3. Read once at module-init so
78
+ // every recall invocation honors the same value (the env var is a per-run
79
+ // declaration; changing it mid-process is not supported).
80
+ const HEX_32_CONTEXT_ID = /^[0-9a-f]{32}$/;
81
+ const ATRIB_CONTEXT_ID_DEFAULT = process.env.ATRIB_CONTEXT_ID && HEX_32_CONTEXT_ID.test(process.env.ATRIB_CONTEXT_ID)
82
+ ? process.env.ATRIB_CONTEXT_ID
83
+ : undefined;
53
84
  /**
54
85
  * Pull the inner AtribRecord out of either on-disk shape (D062 envelope or
55
86
  * legacy bare record). Returns null when the line is neither shape or is
@@ -105,7 +136,7 @@ export function loadRecords(path) {
105
136
  * mirror namespace; every producer running under one identity writes a
106
137
  * file there with the convention `<producer>-<agent>.jsonl`. Scanning the
107
138
  * directory unifies recall across producers without recall having to know
108
- * the naming scheme any producer that follows §5.9 just shows up.
139
+ * the naming scheme - any producer that follows §5.9 just shows up.
109
140
  */
110
141
  export function loadRecordsFromDir(dir) {
111
142
  if (!existsSync(dir))
@@ -137,35 +168,145 @@ export function loadRecordsFromDir(dir) {
137
168
  else {
138
169
  // Surface empty/unreadable files too so the operator can see them in
139
170
  // the response if they care, but only if the file existed (which it
140
- // does readdirSync returned it).
171
+ // does - readdirSync returned it).
141
172
  files.push(full);
142
173
  }
143
174
  }
144
175
  return { records, files };
145
176
  }
146
- async function annotateVerification(records) {
147
- return Promise.all(records.map(async (r) => {
177
+ /**
178
+ * Sort `filtered` in-place by Park et al. parkScore descending. Builds
179
+ * the BM25 index over each loaded record's annotation summary + topics
180
+ * (the indexable Layer 1 text per the design); when rank_anchor is a
181
+ * non-empty string, treats it as the query and adds the relevance
182
+ * component. When rank_anchor is empty or a record_hash (the
183
+ * causal_distance shape), relevance is 0 for every record and the score
184
+ * collapses to alpha*recency + beta*importance.
185
+ *
186
+ * Uses now=Date.now() inside the function so the recall response reflects
187
+ * the moment of evaluation. Determinism is preserved at the per-call
188
+ * level (two recall() calls in the same millisecond produce identical
189
+ * scores given identical input).
190
+ */
191
+ function rankByRelevance(filtered, annotationsByRecord, rankAnchor) {
192
+ const now = Date.now();
193
+ // Treat rank_anchor as a free-form query unless it parses as a record_hash
194
+ // (sha256:<64-hex>). Future: when rank_by='causal_distance' wires up,
195
+ // record_hash anchors go to the BFS path; here, record_hash anchors
196
+ // contribute 0 relevance (recency + importance only).
197
+ const looksLikeRecordHash = typeof rankAnchor === 'string' && /^sha256:[0-9a-f]{64}$/.test(rankAnchor);
198
+ const queryTokens = rankAnchor && !looksLikeRecordHash ? tokenize(rankAnchor) : [];
199
+ // Build the BM25 index over the filtered set's indexable text. Index
200
+ // construction is O(total token count); for Layer 1 corpus sizes this
201
+ // is negligible (a few hundred records × tens of tokens each).
202
+ const corpus = filtered.map((lr) => ({
203
+ id: lr.record_hash,
204
+ tokens: indexableTextFromAnnotation(annotationsByRecord.get(lr.record_hash)),
205
+ }));
206
+ const idx = buildBM25Index(corpus);
207
+ const scores = new Map();
208
+ for (const lr of filtered) {
209
+ const r = recencyScore(lr.record.timestamp, now, ATRIB_RECALL_TAU_DAYS);
210
+ const i = importanceScore(annotationsByRecord.get(lr.record_hash));
211
+ const rel = queryTokens.length > 0
212
+ ? bm25Score(idx, lr.record_hash, queryTokens)
213
+ : 0;
214
+ scores.set(lr.record_hash, parkScore(r, i, rel, ATRIB_RECALL_ALPHA, ATRIB_RECALL_BETA, ATRIB_RECALL_GAMMA));
215
+ }
216
+ filtered.sort((a, b) => {
217
+ const sa = scores.get(a.record_hash) ?? 0;
218
+ const sb = scores.get(b.record_hash) ?? 0;
219
+ if (sb !== sa)
220
+ return sb - sa;
221
+ // Stable tie-break on timestamp newest-first.
222
+ return b.record.timestamp - a.record.timestamp;
223
+ });
224
+ }
225
+ /**
226
+ * Sort `filtered` in-place by BFS shortest-path distance from rank_anchor.
227
+ * The graph is built from the FULL `all` set (not just filtered) so the
228
+ * BFS can traverse through records that the post-filter pipeline would
229
+ * later drop — the agent's question is "what's causally near this
230
+ * anchor", not "what's causally near and also matches my filters".
231
+ *
232
+ * Records unreachable from rank_anchor are sorted to the end (Infinity
233
+ * distance) with a stable timestamp tie-break newest-first.
234
+ *
235
+ * If rank_anchor is missing or doesn't parse as a record_hash, the
236
+ * function leaves `filtered` in input order. (Callers passing a free-form
237
+ * query meant rank_by='relevance' instead; we don't second-guess.)
238
+ */
239
+ function rankByCausalDistance(filtered, all, rankAnchor) {
240
+ if (!rankAnchor || !/^sha256:[0-9a-f]{64}$/.test(rankAnchor)) {
241
+ // Fall back to timestamp newest-first when the anchor is unusable;
242
+ // matches the existing pre-Layer-1 default rather than leaving an
243
+ // arbitrary order.
244
+ filtered.sort((a, b) => b.record.timestamp - a.record.timestamp);
245
+ return;
246
+ }
247
+ const graph = buildLocalGraph(all);
248
+ const dist = shortestDistances(graph, rankAnchor);
249
+ filtered.sort((a, b) => {
250
+ const da = dist.get(a.record_hash) ?? Number.POSITIVE_INFINITY;
251
+ const db = dist.get(b.record_hash) ?? Number.POSITIVE_INFINITY;
252
+ if (da !== db)
253
+ return da - db;
254
+ return b.record.timestamp - a.record.timestamp;
255
+ });
256
+ }
257
+ async function annotateVerification(loaded, annotationsByRecord, revisionsByRecord) {
258
+ return Promise.all(loaded.map(async (lr) => {
148
259
  let ok = false;
149
260
  try {
150
- ok = await verifyRecord(r);
261
+ ok = await verifyRecord(lr.record);
151
262
  }
152
263
  catch {
153
264
  ok = false;
154
265
  }
155
- return { ...r, signature_verified: ok };
266
+ const out = {
267
+ record: lr.record,
268
+ record_hash: lr.record_hash,
269
+ signature_verified: ok,
270
+ };
271
+ const ann = annotationsByRecord.get(lr.record_hash);
272
+ if (ann)
273
+ out.annotations = ann;
274
+ const supers = revisionsByRecord.get(lr.record_hash);
275
+ if (supers && supers.length > 0)
276
+ out.superseded_by = supers;
277
+ return out;
156
278
  }));
157
279
  }
158
- function compactify(records) {
159
- return records.map((r) => {
280
+ function compactify(bundles) {
281
+ return bundles.map((b) => {
282
+ const r = b.record;
160
283
  const out = {
161
284
  event_type: r.event_type,
162
285
  context_id: r.context_id,
163
286
  creator_key: r.creator_key,
164
287
  timestamp: r.timestamp,
165
- signature_verified: r.signature_verified,
288
+ signature_verified: b.signature_verified,
166
289
  };
167
- if (r.session_token)
168
- out.session_token = r.session_token;
290
+ const sessionToken = r.session_token;
291
+ const toolName = r.tool_name;
292
+ if (sessionToken)
293
+ out.session_token = sessionToken;
294
+ if (toolName)
295
+ out.tool_name = toolName;
296
+ if (b.annotations)
297
+ out.annotations = b.annotations;
298
+ if (b.superseded_by)
299
+ out.superseded_by = b.superseded_by;
300
+ return out;
301
+ });
302
+ }
303
+ function fullify(bundles) {
304
+ return bundles.map((b) => {
305
+ const out = { ...b.record, signature_verified: b.signature_verified };
306
+ if (b.annotations)
307
+ out.annotations = b.annotations;
308
+ if (b.superseded_by)
309
+ out.superseded_by = b.superseded_by;
169
310
  return out;
170
311
  });
171
312
  }
@@ -194,24 +335,81 @@ export async function recall(args, recordFile) {
194
335
  // would otherwise treat tampered records as provable. Default to safe.
195
336
  const compact = args.compact !== false;
196
337
  const includeUnverified = args.include_unverified === true;
197
- const { records: all, files } = discoverRecords(recordFile);
338
+ const { loaded: all, files } = discoverLoaded(recordFile);
339
+ const annotationsByRecord = aggregateAnnotationsByRecord(all);
340
+ const revisionsByRecord = aggregateRevisionsByRecord(all);
341
+ // Apply ATRIB_CONTEXT_ID env-var default when the caller omits the
342
+ // context_id filter. Lets Inspect-style harnesses scope recall to a
343
+ // per-arm context_id without threading it through every tool call;
344
+ // an explicit args.context_id always wins (explicit beats implicit).
345
+ const effectiveContextId = args.context_id ?? ATRIB_CONTEXT_ID_DEFAULT;
198
346
  let filtered = all;
199
- if (args.context_id)
200
- filtered = filtered.filter((r) => r.context_id === args.context_id);
347
+ if (effectiveContextId)
348
+ filtered = filtered.filter((lr) => lr.record.context_id === effectiveContextId);
201
349
  if (args.event_type) {
202
350
  // Schema accepts short form ('tool_call'|'transaction'); records carry
203
351
  // the URI form. Normalize before comparison; pass URIs through as-is so
204
352
  // a forward-compatible caller passing the URI directly still matches.
205
353
  const targetUri = EVENT_TYPE_SHORT_TO_URI[args.event_type] ?? args.event_type;
206
- filtered = filtered.filter((r) => r.event_type === targetUri);
354
+ filtered = filtered.filter((lr) => lr.record.event_type === targetUri);
355
+ }
356
+ if (args.content_id)
357
+ filtered = filtered.filter((lr) => lr.record.content_id === args.content_id);
358
+ if (args.tool_name)
359
+ filtered = filtered.filter((lr) => lr.record.tool_name === args.tool_name);
360
+ if (args.args_hash)
361
+ filtered = filtered.filter((lr) => lr.record.args_hash === args.args_hash);
362
+ // Layer 1 filters (consume the annotation + revision aggregations).
363
+ if (args.min_importance) {
364
+ const minScore = IMPORTANCE_NUMERIC[args.min_importance];
365
+ filtered = filtered.filter((lr) => {
366
+ const ann = annotationsByRecord.get(lr.record_hash);
367
+ if (!ann || !ann.max_importance)
368
+ return false;
369
+ return IMPORTANCE_NUMERIC[ann.max_importance] >= minScore;
370
+ });
371
+ }
372
+ if (args.topic_tags && args.topic_tags.length > 0) {
373
+ const wanted = new Set(args.topic_tags);
374
+ filtered = filtered.filter((lr) => {
375
+ const ann = annotationsByRecord.get(lr.record_hash);
376
+ return !!ann?.topics?.some((t) => wanted.has(t));
377
+ });
378
+ }
379
+ // include_revised is misnamed: `true` HIDES records that have revisions
380
+ // pointing at them. `false` / undefined keeps them visible (the default;
381
+ // they appear with superseded_by populated). See the schema description.
382
+ if (args.include_revised === true) {
383
+ filtered = filtered.filter((lr) => !revisionsByRecord.has(lr.record_hash));
384
+ }
385
+ // min_signers: distinct-signer count is signers?.length (transaction records
386
+ // per D052) or 1 (the implicit creator's single signature on every other
387
+ // event_type). Records below the threshold are excluded.
388
+ if (typeof args.min_signers === 'number') {
389
+ const min = args.min_signers;
390
+ filtered = filtered.filter((lr) => {
391
+ const signersField = lr.record.signers;
392
+ const count = Array.isArray(signersField) ? signersField.length : 1;
393
+ return count >= min;
394
+ });
395
+ }
396
+ // Sort: timestamp (default, newest first), Park et al. relevance, or
397
+ // BFS shortest-path causal distance from rank_anchor.
398
+ if (args.rank_by === 'relevance') {
399
+ rankByRelevance(filtered, annotationsByRecord, args.rank_anchor);
400
+ }
401
+ else if (args.rank_by === 'causal_distance') {
402
+ rankByCausalDistance(filtered, all, args.rank_anchor);
403
+ }
404
+ else {
405
+ // Newest first - the agent typically wants its most-recent provable
406
+ // actions, not the genesis of the log.
407
+ filtered.sort((a, b) => b.record.timestamp - a.record.timestamp);
207
408
  }
208
- // Newest first — the agent typically wants its most-recent provable
209
- // actions, not the genesis of the log.
210
- filtered.sort((a, b) => b.timestamp - a.timestamp);
211
409
  const offset = Math.max(0, args.offset ?? 0);
212
410
  const limit = Math.max(1, Math.min(200, args.limit ?? 25));
213
411
  const page = filtered.slice(offset, offset + limit);
214
- let verified = await annotateVerification(page);
412
+ let verified = await annotateVerification(page, annotationsByRecord, revisionsByRecord);
215
413
  // Apply verification filter post-paging so `total` reflects the unfiltered
216
414
  // count (matches user expectation of "how many records exist that match
217
415
  // your context_id+event_type filters?"). filtered_out distinguishes the
@@ -222,7 +420,37 @@ export async function recall(args, recordFile) {
222
420
  verified = verified.filter((r) => r.signature_verified === true);
223
421
  filteredOutByVerification = before - verified.length;
224
422
  }
225
- const records = compact ? compactify(verified) : verified;
423
+ // toc=true: ~40-80-token-per-entry shape suitable for SessionStart
424
+ // auto-injection. Pulls the cheap-to-scan fields and drops everything
425
+ // else. Implicit signature_verified is preserved-by-omission (only
426
+ // records that passed the verification filter make it here, unless
427
+ // the caller also set include_unverified=true).
428
+ const toc = args.toc === true;
429
+ let records;
430
+ if (toc) {
431
+ records = verified.map((b) => {
432
+ const out = { timestamp: b.record.timestamp };
433
+ out.record_hash = b.record_hash;
434
+ const toolName = b.record.tool_name;
435
+ if (toolName)
436
+ out.tool_name = toolName;
437
+ if (b.annotations?.summary)
438
+ out.summary = b.annotations.summary;
439
+ if (b.annotations?.max_importance)
440
+ out.importance = b.annotations.max_importance;
441
+ if (b.annotations?.topics)
442
+ out.topic_tags = b.annotations.topics;
443
+ if (b.superseded_by)
444
+ out.superseded_by = b.superseded_by;
445
+ return out;
446
+ });
447
+ }
448
+ else if (compact) {
449
+ records = compactify(verified);
450
+ }
451
+ else {
452
+ records = fullify(verified);
453
+ }
226
454
  return {
227
455
  total: filtered.length,
228
456
  returned: verified.length,
@@ -237,18 +465,31 @@ export async function recall(args, recordFile) {
237
465
  }
238
466
  const server = new McpServer({
239
467
  name: 'atrib-recall',
240
- version: '0.3.0',
468
+ // Keep in sync with package.json. The Layer 1 stub scaffolding ships
469
+ // under the 0.4.0 surface (additive optional schema params + 4 stub
470
+ // tools that return "Layer 1 in progress" notice); the version bump
471
+ // happens via the queued changeset on next publication run.
472
+ version: '0.4.0',
241
473
  });
474
+ // The recall semantic surface (as defined in the public protocol specification).
475
+ // Five distinct MCP tools: recall_my_attribution_history is the base
476
+ // filter-and-page tool; recall_annotations + recall_revisions return
477
+ // aggregated annotation summaries / revision chains for a specific
478
+ // record_hash; recall_walk traverses the local Layer 1 derived graph;
479
+ // recall_by_content runs BM25 free-form retrieval over annotation
480
+ // summaries + topic tags.
242
481
  server.registerTool('recall_my_attribution_history', {
243
- description: "Return signed atrib records from the local mirror the agent's own past, with each record's " +
482
+ description: "Return signed atrib records from the local mirror. The agent's own past, with each record's " +
244
483
  'Ed25519 signature verified locally. By default the response is compact (no signature bytes) and ' +
245
- 'includes only records that passed signature verification both can be opted out of with ' +
484
+ 'includes only records that passed signature verification; both can be opted out of with ' +
246
485
  'compact=false and include_unverified=true respectively. Local signature verification proves ' +
247
486
  '"this record was signed by that creator_key"; it does NOT prove log inclusion (fetch a log ' +
248
- 'inclusion proof to confirm). Filter by context_id (specific trace) or event_type ' +
249
- '(tool_call|transaction); omit filters for cross-trace history. Results are sorted newest-first. ' +
250
- 'Pagination uses offset; new records appended between calls invalidate offset stability see ' +
251
- 'the pagination_caveat in the response. The filtered_out_by_verification field reports how many ' +
487
+ 'inclusion proof to confirm). Filter by context_id (specific trace), event_type ' +
488
+ '(tool_call|transaction), content_id (specific tool on specific server), tool_name (disclosed ' +
489
+ 'name per §8.2), or args_hash (canonical-args commitment per §8.3). Filters are AND-combined; ' +
490
+ 'omit all of them for cross-trace history. Results are sorted newest-first. Pagination uses ' +
491
+ 'offset; new records appended between calls invalidate offset stability. See the ' +
492
+ 'pagination_caveat in the response. The filtered_out_by_verification field reports how many ' +
252
493
  'records were dropped due to signature failures (always 0 when include_unverified=true).',
253
494
  inputSchema: {
254
495
  context_id: z
@@ -260,11 +501,30 @@ server.registerTool('recall_my_attribution_history', {
260
501
  .enum(['tool_call', 'transaction'])
261
502
  .optional()
262
503
  .describe('Optional filter to a single event kind. Most calls leave this unset.'),
504
+ content_id: z
505
+ .string()
506
+ .optional()
507
+ .describe('Optional exact match on record.content_id (sha256:<64-hex>). Per spec §1.2.2, content_id ' +
508
+ 'is sha256(serverUrl + ":" + toolName), so filtering groups all records emitted by the same ' +
509
+ 'tool on the same MCP server. Coarser than tool_name (different servers, same name -> ' +
510
+ 'different content_id).'),
511
+ tool_name: z
512
+ .string()
513
+ .optional()
514
+ .describe('Optional exact match on the §8.2 disclosed tool_name. Records that did NOT opt in to ' +
515
+ 'tool-name disclosure (the §8.1 default posture) carry no tool_name field and are excluded ' +
516
+ 'from results when this filter is set.'),
517
+ args_hash: z
518
+ .string()
519
+ .optional()
520
+ .describe('Optional exact match on record.args_hash (sha256:<64-hex>). Per spec §8.3, args_hash commits ' +
521
+ 'to canonical args bytes (salted or plain; both forms hash identically on the wire). Most ' +
522
+ 'useful for replay detection or agent-side keyed lookup over a normalized probe hash.'),
263
523
  limit: z.number().optional().describe('Page size, default 25, max 200.'),
264
524
  offset: z
265
525
  .number()
266
526
  .optional()
267
- .describe('Pagination offset, default 0. Note: not stable when new records land between calls see ' +
527
+ .describe('Pagination offset, default 0. Note: not stable when new records land between calls - see ' +
268
528
  'pagination_caveat in the response.'),
269
529
  compact: z
270
530
  .boolean()
@@ -277,15 +537,257 @@ server.registerTool('recall_my_attribution_history', {
277
537
  .optional()
278
538
  .describe('Default false. When false, records with signature_verified=false are dropped from the ' +
279
539
  'response (their count is reported in filtered_out_by_verification). Set to true to ' +
280
- 'include them useful when investigating tampered or partial mirror state.'),
540
+ 'include them - useful when investigating tampered or partial mirror state.'),
541
+ // ─── New schema params: accepted now; enforcement in flight. Each ───
542
+ // of the seven params below is currently STUB-ACCEPTED: the schema
543
+ // validates the value and the handler ignores it (returns the same
544
+ // results it would return without the param). The response payload
545
+ // includes a layer_1_warnings array listing which stub-accepted
546
+ // params were silently ignored, so callers can detect the pre-impl
547
+ // state without having to read source. Full enforcement implementation
548
+ // lands in upcoming releases.
549
+ min_importance: z
550
+ .enum(['critical', 'high', 'medium', 'low', 'noise'])
551
+ .optional()
552
+ .describe('Filter to records whose maximum annotation importance is at least this level. Annotation ' +
553
+ 'importance comes from annotation records pointing at the record. Records with no ' +
554
+ 'annotations at all are excluded when this filter is set.'),
555
+ topic_tags: z
556
+ .array(z.string())
557
+ .optional()
558
+ .describe('OR-match against annotation topic tags. Records are kept if at least one annotation pointing ' +
559
+ 'at them carries at least one of the listed topics. Records with no annotations or no ' +
560
+ 'topic overlap are excluded.'),
561
+ include_revised: z
562
+ .boolean()
563
+ .optional()
564
+ .describe('Default false: revised records remain visible with superseded_by populated. Set true to hide ' +
565
+ 'records that have been superseded by a revision record (revises field equals this record).'),
566
+ min_signers: z
567
+ .number()
568
+ .optional()
569
+ .describe('Minimum count of distinct signers. Transaction records carry a signers[] array (cross- ' +
570
+ 'attestation); the count is its length. Non-transaction records have a single signature; ' +
571
+ 'their count is 1. Records below the threshold are excluded.'),
572
+ rank_by: z
573
+ .enum(['timestamp', 'relevance', 'causal_distance'])
574
+ .optional()
575
+ .describe('Result ordering. timestamp (default): newest first. relevance: Park et al. weighted-sum ' +
576
+ 'scoring over recency + annotation-derived importance + BM25 relevance against rank_anchor ' +
577
+ '(treated as a free-form query when not a record_hash; otherwise relevance component is 0). ' +
578
+ 'causal_distance: BFS shortest path in the local derived graph from rank_anchor (a record_hash). ' +
579
+ 'Records unreachable from the anchor sort to the end.'),
580
+ rank_anchor: z
581
+ .string()
582
+ .optional()
583
+ .describe('Anchor for non-timestamp rank_by modes. For rank_by=relevance: free-form text query for the ' +
584
+ 'BM25 component (matched against annotation summary + topics of each candidate). For ' +
585
+ 'rank_by=causal_distance: record_hash to BFS from (sha256:<64-hex>); falls back to timestamp ' +
586
+ 'newest-first when not a valid record_hash.'),
587
+ toc: z
588
+ .boolean()
589
+ .optional()
590
+ .describe('Default false. When true, each returned record is the table-of-contents entry shape ' +
591
+ '(record_hash, tool_name, summary, importance, topic_tags, timestamp, superseded_by) at ' +
592
+ '~40-80 tokens per entry. Designed for SessionStart auto-injected scaffold and any other ' +
593
+ 'cheap-to-scan candidate set the agent expands on demand via recall(content_id=...) or ' +
594
+ 'recall_walk.'),
281
595
  },
282
596
  }, async (args) => {
597
+ // Layer 1 stub-acceptance: detect newly-accepted Layer 1 params, run the
598
+ // existing 0.4.0 recall path (which ignores them), and return the
599
+ // result with a layer_1_warnings array listing exactly which stub-
600
+ // accepted params were silently ignored. Callers can detect the
601
+ // pre-implementation state without having to read source.
602
+ // All seven Layer 1 surface parameters are now enforced
603
+ // (min_importance, topic_tags, include_revised, min_signers,
604
+ // rank_by, rank_anchor, toc). The layer_1_warnings array stays in
605
+ // the response shape (per the original wire contract) but is now
606
+ // always empty unless a future Layer extension lands more
607
+ // stub-accepted params.
608
+ const ignored = [];
283
609
  const result = await recall(args);
610
+ const augmented = ignored.length > 0
611
+ ? {
612
+ ...result,
613
+ layer_1_warnings: ignored.map((k) => ({
614
+ param: k,
615
+ status: 'stub-accepted',
616
+ note: `Layer 1 param '${k}' was supplied; handler ignored it (full enforcement lands in upcoming release). Result reflects 0.4.0 behavior as if the param was not set.`,
617
+ })),
618
+ }
619
+ : result;
620
+ return {
621
+ content: [
622
+ {
623
+ type: 'text',
624
+ text: JSON.stringify(augmented, null, 2),
625
+ },
626
+ ],
627
+ };
628
+ });
629
+ // ─── Layer 1 sibling tools ───
630
+ // recall_walk, recall_annotations, recall_revisions, recall_by_content
631
+ // expose the cognitive surface beyond the base filter-and-page tool.
632
+ server.registerTool('recall_walk', {
633
+ description: "Walk the local derived graph from a starting record_hash. Returns records reachable via the requested edge types up to the given hop depth, ordered by ascending weighted distance. Layer 1 covers four edge types: CHAIN_PRECEDES (weight 1), INFORMED_BY (weight 1), ANNOTATES (weight 2), REVISES (weight 2). SESSION_PRECEDES, SESSION_PARALLEL, CONVERGES_ON, CROSS_SESSION, and PROVENANCE_OF are deferred to subsequent releases. Useful for tracing the local causal neighborhood of a record before re-attempting a similar action.",
634
+ inputSchema: {
635
+ from_record_hash: z
636
+ .string()
637
+ .describe("Starting record hash (sha256:<64-hex>). The walk begins here and expands through the local derived graph."),
638
+ edge_types: z
639
+ .array(z.enum(['CHAIN_PRECEDES', 'INFORMED_BY', 'ANNOTATES', 'REVISES']))
640
+ .optional()
641
+ .describe("Optional list of Layer 1 edge types to follow. Default: all four. Unknown values are rejected by the schema."),
642
+ depth: z
643
+ .number()
644
+ .optional()
645
+ .describe("Maximum hop count (NOT cumulative weight). Default 3. Higher values may return many records; paginate downstream if needed."),
646
+ },
647
+ }, async (args) => {
648
+ const { loaded } = discoverLoaded();
649
+ const graph = buildLocalGraph(loaded);
650
+ const edgeTypes = args.edge_types
651
+ ? new Set(args.edge_types)
652
+ : undefined;
653
+ const depth = typeof args.depth === 'number' ? args.depth : 3;
654
+ const walk = walkFrom(graph, args.from_record_hash, edgeTypes, depth);
655
+ return {
656
+ content: [
657
+ {
658
+ type: 'text',
659
+ text: JSON.stringify({
660
+ from_record_hash: args.from_record_hash,
661
+ edge_types: args.edge_types ?? [
662
+ 'CHAIN_PRECEDES',
663
+ 'INFORMED_BY',
664
+ 'ANNOTATES',
665
+ 'REVISES',
666
+ ],
667
+ depth,
668
+ count: walk.length,
669
+ walk,
670
+ }, null, 2),
671
+ },
672
+ ],
673
+ };
674
+ });
675
+ server.registerTool('recall_annotations', {
676
+ description: "Return the aggregated annotation summary for a record: maximum annotation importance across all D058 annotation records pointing at it, the union of their topic_tags, and the most recent summary string. Useful for surfacing the agent's prior critique on a record before re-attempting a similar action. Returns null annotations field when no annotation points at the record.",
677
+ inputSchema: {
678
+ record_hash: z
679
+ .string()
680
+ .describe("Record hash (sha256:<64-hex>) of the record whose annotations should be retrieved. Annotations are D058 records whose content.annotates field equals this hash."),
681
+ },
682
+ }, async (args) => {
683
+ const { loaded } = discoverLoaded();
684
+ const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
685
+ const summary = annotationsByRecord.get(args.record_hash) ?? null;
686
+ return {
687
+ content: [
688
+ {
689
+ type: 'text',
690
+ text: JSON.stringify({ record_hash: args.record_hash, annotations: summary }, null, 2),
691
+ },
692
+ ],
693
+ };
694
+ });
695
+ server.registerTool('recall_revisions', {
696
+ description: "Return the D059 revision chain for a record. Walks revises edges forward from the given record_hash, surfacing each revision in turn. The chain is the linked list of revisions where each revision's revises field points at the prior entry. Useful for checking whether a position the agent previously held has been revised before acting on it. Returns an empty chain when no revision points at the record.",
697
+ inputSchema: {
698
+ record_hash: z
699
+ .string()
700
+ .describe("Record hash (sha256:<64-hex>) of the record whose revision chain should be retrieved. Revisions are D059 records whose content.revises field equals this hash (or chain back to it)."),
701
+ },
702
+ }, async (args) => {
703
+ const { loaded } = discoverLoaded();
704
+ const revisionsByRecord = aggregateRevisionsByRecord(loaded);
705
+ // Walk the chain forward: the input record may be revised by R1;
706
+ // R1 may be revised by R2; collect them in order. Bounded by the
707
+ // mirror size (no cycles since timestamps are monotonic per
708
+ // signer; defensive seen-set anyway).
709
+ const chain = [];
710
+ const seen = new Set();
711
+ let current = args.record_hash;
712
+ while (!seen.has(current)) {
713
+ seen.add(current);
714
+ const next = revisionsByRecord.get(current);
715
+ if (!next || next.length === 0)
716
+ break;
717
+ // Each entry in the map's value array is a revision pointing at
718
+ // `current`. Convention: the chain follows the first-by-timestamp
719
+ // revision; agents wanting the full sibling fan-out (parallel
720
+ // revisions at the same target) should call recall_my_attribution_history
721
+ // with event_type=revision and inspect their revises field manually.
722
+ const revHash = next[0];
723
+ chain.push(revHash);
724
+ current = revHash;
725
+ }
726
+ return {
727
+ content: [
728
+ {
729
+ type: 'text',
730
+ text: JSON.stringify({ record_hash: args.record_hash, revision_chain: chain }, null, 2),
731
+ },
732
+ ],
733
+ };
734
+ });
735
+ server.registerTool('recall_by_content', {
736
+ description: "Free-form text search over the agent's signed past. Returns top-k records by hybrid retrieval: BM25 over each record's annotation summary + topics, then reranked by Park et al. weighted-sum scoring with annotation-derived importance and recency signals. Layer 2 (sqlite-vec sidecar, separate ship) extends with embedding similarity. Useful when the agent has no specific filter and needs to ask 'what do I know about X?'.",
737
+ inputSchema: {
738
+ query: z
739
+ .string()
740
+ .describe("Free-form text query. Matches against each record's annotation summary + topic_tags via BM25. Records with no annotation contribute no relevance signal (will only surface via the recency + importance fallback)."),
741
+ k: z
742
+ .number()
743
+ .optional()
744
+ .describe("Top-k results to return (default 10, max 50). Final ordering uses Park et al. weighted-sum scoring: alpha*recency + beta*importance + gamma*BM25_relevance. Weights are tunable via ATRIB_RECALL_ALPHA/BETA/GAMMA env vars."),
745
+ },
746
+ }, async (args) => {
747
+ const { loaded } = discoverLoaded();
748
+ const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
749
+ const queryTokens = tokenize(args.query);
750
+ const corpus = loaded.map((lr) => ({
751
+ id: lr.record_hash,
752
+ tokens: indexableTextFromAnnotation(annotationsByRecord.get(lr.record_hash)),
753
+ }));
754
+ const idx = buildBM25Index(corpus);
755
+ const now = Date.now();
756
+ const scored = loaded.map((lr) => {
757
+ const r = recencyScore(lr.record.timestamp, now, ATRIB_RECALL_TAU_DAYS);
758
+ const i = importanceScore(annotationsByRecord.get(lr.record_hash));
759
+ const rel = queryTokens.length > 0
760
+ ? bm25Score(idx, lr.record_hash, queryTokens)
761
+ : 0;
762
+ const score = parkScore(r, i, rel, ATRIB_RECALL_ALPHA, ATRIB_RECALL_BETA, ATRIB_RECALL_GAMMA);
763
+ return { lr, score, recency: r, importance: i, relevance: rel };
764
+ });
765
+ scored.sort((a, b) => {
766
+ if (b.score !== a.score)
767
+ return b.score - a.score;
768
+ return b.lr.record.timestamp - a.lr.record.timestamp;
769
+ });
770
+ const k = Math.max(1, Math.min(50, args.k ?? 10));
771
+ const top = scored.slice(0, k);
284
772
  return {
285
773
  content: [
286
774
  {
287
775
  type: 'text',
288
- text: JSON.stringify(result, null, 2),
776
+ text: JSON.stringify({
777
+ query: args.query,
778
+ k,
779
+ count: top.length,
780
+ results: top.map(({ lr, score, recency, importance, relevance }) => ({
781
+ record_hash: lr.record_hash,
782
+ event_type: lr.record.event_type,
783
+ context_id: lr.record.context_id,
784
+ timestamp: lr.record.timestamp,
785
+ tool_name: lr.record.tool_name,
786
+ annotations: annotationsByRecord.get(lr.record_hash),
787
+ score,
788
+ components: { recency, importance, relevance },
789
+ })),
790
+ }, null, 2),
289
791
  },
290
792
  ],
291
793
  };