@atrib/recall 0.3.1 → 0.5.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,11 +42,35 @@ 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';
@@ -105,7 +129,7 @@ export function loadRecords(path) {
105
129
  * mirror namespace; every producer running under one identity writes a
106
130
  * file there with the convention `<producer>-<agent>.jsonl`. Scanning the
107
131
  * directory unifies recall across producers without recall having to know
108
- * the naming scheme any producer that follows §5.9 just shows up.
132
+ * the naming scheme - any producer that follows §5.9 just shows up.
109
133
  */
110
134
  export function loadRecordsFromDir(dir) {
111
135
  if (!existsSync(dir))
@@ -137,35 +161,145 @@ export function loadRecordsFromDir(dir) {
137
161
  else {
138
162
  // Surface empty/unreadable files too so the operator can see them in
139
163
  // the response if they care, but only if the file existed (which it
140
- // does readdirSync returned it).
164
+ // does - readdirSync returned it).
141
165
  files.push(full);
142
166
  }
143
167
  }
144
168
  return { records, files };
145
169
  }
146
- async function annotateVerification(records) {
147
- return Promise.all(records.map(async (r) => {
170
+ /**
171
+ * Sort `filtered` in-place by Park et al. parkScore descending. Builds
172
+ * the BM25 index over each loaded record's annotation summary + topics
173
+ * (the indexable Layer 1 text per the design); when rank_anchor is a
174
+ * non-empty string, treats it as the query and adds the relevance
175
+ * component. When rank_anchor is empty or a record_hash (the
176
+ * causal_distance shape), relevance is 0 for every record and the score
177
+ * collapses to alpha*recency + beta*importance.
178
+ *
179
+ * Uses now=Date.now() inside the function so the recall response reflects
180
+ * the moment of evaluation. Determinism is preserved at the per-call
181
+ * level (two recall() calls in the same millisecond produce identical
182
+ * scores given identical input).
183
+ */
184
+ function rankByRelevance(filtered, annotationsByRecord, rankAnchor) {
185
+ const now = Date.now();
186
+ // Treat rank_anchor as a free-form query unless it parses as a record_hash
187
+ // (sha256:<64-hex>). Future: when rank_by='causal_distance' wires up,
188
+ // record_hash anchors go to the BFS path; here, record_hash anchors
189
+ // contribute 0 relevance (recency + importance only).
190
+ const looksLikeRecordHash = typeof rankAnchor === 'string' && /^sha256:[0-9a-f]{64}$/.test(rankAnchor);
191
+ const queryTokens = rankAnchor && !looksLikeRecordHash ? tokenize(rankAnchor) : [];
192
+ // Build the BM25 index over the filtered set's indexable text. Index
193
+ // construction is O(total token count); for Layer 1 corpus sizes this
194
+ // is negligible (a few hundred records × tens of tokens each).
195
+ const corpus = filtered.map((lr) => ({
196
+ id: lr.record_hash,
197
+ tokens: indexableTextFromAnnotation(annotationsByRecord.get(lr.record_hash)),
198
+ }));
199
+ const idx = buildBM25Index(corpus);
200
+ const scores = new Map();
201
+ for (const lr of filtered) {
202
+ const r = recencyScore(lr.record.timestamp, now, ATRIB_RECALL_TAU_DAYS);
203
+ const i = importanceScore(annotationsByRecord.get(lr.record_hash));
204
+ const rel = queryTokens.length > 0
205
+ ? bm25Score(idx, lr.record_hash, queryTokens)
206
+ : 0;
207
+ scores.set(lr.record_hash, parkScore(r, i, rel, ATRIB_RECALL_ALPHA, ATRIB_RECALL_BETA, ATRIB_RECALL_GAMMA));
208
+ }
209
+ filtered.sort((a, b) => {
210
+ const sa = scores.get(a.record_hash) ?? 0;
211
+ const sb = scores.get(b.record_hash) ?? 0;
212
+ if (sb !== sa)
213
+ return sb - sa;
214
+ // Stable tie-break on timestamp newest-first.
215
+ return b.record.timestamp - a.record.timestamp;
216
+ });
217
+ }
218
+ /**
219
+ * Sort `filtered` in-place by BFS shortest-path distance from rank_anchor.
220
+ * The graph is built from the FULL `all` set (not just filtered) so the
221
+ * BFS can traverse through records that the post-filter pipeline would
222
+ * later drop — the agent's question is "what's causally near this
223
+ * anchor", not "what's causally near and also matches my filters".
224
+ *
225
+ * Records unreachable from rank_anchor are sorted to the end (Infinity
226
+ * distance) with a stable timestamp tie-break newest-first.
227
+ *
228
+ * If rank_anchor is missing or doesn't parse as a record_hash, the
229
+ * function leaves `filtered` in input order. (Callers passing a free-form
230
+ * query meant rank_by='relevance' instead; we don't second-guess.)
231
+ */
232
+ function rankByCausalDistance(filtered, all, rankAnchor) {
233
+ if (!rankAnchor || !/^sha256:[0-9a-f]{64}$/.test(rankAnchor)) {
234
+ // Fall back to timestamp newest-first when the anchor is unusable;
235
+ // matches the existing pre-Layer-1 default rather than leaving an
236
+ // arbitrary order.
237
+ filtered.sort((a, b) => b.record.timestamp - a.record.timestamp);
238
+ return;
239
+ }
240
+ const graph = buildLocalGraph(all);
241
+ const dist = shortestDistances(graph, rankAnchor);
242
+ filtered.sort((a, b) => {
243
+ const da = dist.get(a.record_hash) ?? Number.POSITIVE_INFINITY;
244
+ const db = dist.get(b.record_hash) ?? Number.POSITIVE_INFINITY;
245
+ if (da !== db)
246
+ return da - db;
247
+ return b.record.timestamp - a.record.timestamp;
248
+ });
249
+ }
250
+ async function annotateVerification(loaded, annotationsByRecord, revisionsByRecord) {
251
+ return Promise.all(loaded.map(async (lr) => {
148
252
  let ok = false;
149
253
  try {
150
- ok = await verifyRecord(r);
254
+ ok = await verifyRecord(lr.record);
151
255
  }
152
256
  catch {
153
257
  ok = false;
154
258
  }
155
- return { ...r, signature_verified: ok };
259
+ const out = {
260
+ record: lr.record,
261
+ record_hash: lr.record_hash,
262
+ signature_verified: ok,
263
+ };
264
+ const ann = annotationsByRecord.get(lr.record_hash);
265
+ if (ann)
266
+ out.annotations = ann;
267
+ const supers = revisionsByRecord.get(lr.record_hash);
268
+ if (supers && supers.length > 0)
269
+ out.superseded_by = supers;
270
+ return out;
156
271
  }));
157
272
  }
158
- function compactify(records) {
159
- return records.map((r) => {
273
+ function compactify(bundles) {
274
+ return bundles.map((b) => {
275
+ const r = b.record;
160
276
  const out = {
161
277
  event_type: r.event_type,
162
278
  context_id: r.context_id,
163
279
  creator_key: r.creator_key,
164
280
  timestamp: r.timestamp,
165
- signature_verified: r.signature_verified,
281
+ signature_verified: b.signature_verified,
166
282
  };
167
- if (r.session_token)
168
- out.session_token = r.session_token;
283
+ const sessionToken = r.session_token;
284
+ const toolName = r.tool_name;
285
+ if (sessionToken)
286
+ out.session_token = sessionToken;
287
+ if (toolName)
288
+ out.tool_name = toolName;
289
+ if (b.annotations)
290
+ out.annotations = b.annotations;
291
+ if (b.superseded_by)
292
+ out.superseded_by = b.superseded_by;
293
+ return out;
294
+ });
295
+ }
296
+ function fullify(bundles) {
297
+ return bundles.map((b) => {
298
+ const out = { ...b.record, signature_verified: b.signature_verified };
299
+ if (b.annotations)
300
+ out.annotations = b.annotations;
301
+ if (b.superseded_by)
302
+ out.superseded_by = b.superseded_by;
169
303
  return out;
170
304
  });
171
305
  }
@@ -194,24 +328,76 @@ export async function recall(args, recordFile) {
194
328
  // would otherwise treat tampered records as provable. Default to safe.
195
329
  const compact = args.compact !== false;
196
330
  const includeUnverified = args.include_unverified === true;
197
- const { records: all, files } = discoverRecords(recordFile);
331
+ const { loaded: all, files } = discoverLoaded(recordFile);
332
+ const annotationsByRecord = aggregateAnnotationsByRecord(all);
333
+ const revisionsByRecord = aggregateRevisionsByRecord(all);
198
334
  let filtered = all;
199
335
  if (args.context_id)
200
- filtered = filtered.filter((r) => r.context_id === args.context_id);
336
+ filtered = filtered.filter((lr) => lr.record.context_id === args.context_id);
201
337
  if (args.event_type) {
202
338
  // Schema accepts short form ('tool_call'|'transaction'); records carry
203
339
  // the URI form. Normalize before comparison; pass URIs through as-is so
204
340
  // a forward-compatible caller passing the URI directly still matches.
205
341
  const targetUri = EVENT_TYPE_SHORT_TO_URI[args.event_type] ?? args.event_type;
206
- filtered = filtered.filter((r) => r.event_type === targetUri);
342
+ filtered = filtered.filter((lr) => lr.record.event_type === targetUri);
343
+ }
344
+ if (args.content_id)
345
+ filtered = filtered.filter((lr) => lr.record.content_id === args.content_id);
346
+ if (args.tool_name)
347
+ filtered = filtered.filter((lr) => lr.record.tool_name === args.tool_name);
348
+ if (args.args_hash)
349
+ filtered = filtered.filter((lr) => lr.record.args_hash === args.args_hash);
350
+ // Layer 1 filters (consume the annotation + revision aggregations).
351
+ if (args.min_importance) {
352
+ const minScore = IMPORTANCE_NUMERIC[args.min_importance];
353
+ filtered = filtered.filter((lr) => {
354
+ const ann = annotationsByRecord.get(lr.record_hash);
355
+ if (!ann || !ann.max_importance)
356
+ return false;
357
+ return IMPORTANCE_NUMERIC[ann.max_importance] >= minScore;
358
+ });
359
+ }
360
+ if (args.topic_tags && args.topic_tags.length > 0) {
361
+ const wanted = new Set(args.topic_tags);
362
+ filtered = filtered.filter((lr) => {
363
+ const ann = annotationsByRecord.get(lr.record_hash);
364
+ return !!ann?.topics?.some((t) => wanted.has(t));
365
+ });
366
+ }
367
+ // include_revised is misnamed: `true` HIDES records that have revisions
368
+ // pointing at them. `false` / undefined keeps them visible (the default;
369
+ // they appear with superseded_by populated). See the schema description.
370
+ if (args.include_revised === true) {
371
+ filtered = filtered.filter((lr) => !revisionsByRecord.has(lr.record_hash));
372
+ }
373
+ // min_signers: distinct-signer count is signers?.length (transaction records
374
+ // per D052) or 1 (the implicit creator's single signature on every other
375
+ // event_type). Records below the threshold are excluded.
376
+ if (typeof args.min_signers === 'number') {
377
+ const min = args.min_signers;
378
+ filtered = filtered.filter((lr) => {
379
+ const signersField = lr.record.signers;
380
+ const count = Array.isArray(signersField) ? signersField.length : 1;
381
+ return count >= min;
382
+ });
383
+ }
384
+ // Sort: timestamp (default, newest first), Park et al. relevance, or
385
+ // BFS shortest-path causal distance from rank_anchor.
386
+ if (args.rank_by === 'relevance') {
387
+ rankByRelevance(filtered, annotationsByRecord, args.rank_anchor);
388
+ }
389
+ else if (args.rank_by === 'causal_distance') {
390
+ rankByCausalDistance(filtered, all, args.rank_anchor);
391
+ }
392
+ else {
393
+ // Newest first - the agent typically wants its most-recent provable
394
+ // actions, not the genesis of the log.
395
+ filtered.sort((a, b) => b.record.timestamp - a.record.timestamp);
207
396
  }
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
397
  const offset = Math.max(0, args.offset ?? 0);
212
398
  const limit = Math.max(1, Math.min(200, args.limit ?? 25));
213
399
  const page = filtered.slice(offset, offset + limit);
214
- let verified = await annotateVerification(page);
400
+ let verified = await annotateVerification(page, annotationsByRecord, revisionsByRecord);
215
401
  // Apply verification filter post-paging so `total` reflects the unfiltered
216
402
  // count (matches user expectation of "how many records exist that match
217
403
  // your context_id+event_type filters?"). filtered_out distinguishes the
@@ -222,7 +408,37 @@ export async function recall(args, recordFile) {
222
408
  verified = verified.filter((r) => r.signature_verified === true);
223
409
  filteredOutByVerification = before - verified.length;
224
410
  }
225
- const records = compact ? compactify(verified) : verified;
411
+ // toc=true: ~40-80-token-per-entry shape suitable for SessionStart
412
+ // auto-injection. Pulls the cheap-to-scan fields and drops everything
413
+ // else. Implicit signature_verified is preserved-by-omission (only
414
+ // records that passed the verification filter make it here, unless
415
+ // the caller also set include_unverified=true).
416
+ const toc = args.toc === true;
417
+ let records;
418
+ if (toc) {
419
+ records = verified.map((b) => {
420
+ const out = { timestamp: b.record.timestamp };
421
+ out.record_hash = b.record_hash;
422
+ const toolName = b.record.tool_name;
423
+ if (toolName)
424
+ out.tool_name = toolName;
425
+ if (b.annotations?.summary)
426
+ out.summary = b.annotations.summary;
427
+ if (b.annotations?.max_importance)
428
+ out.importance = b.annotations.max_importance;
429
+ if (b.annotations?.topics)
430
+ out.topic_tags = b.annotations.topics;
431
+ if (b.superseded_by)
432
+ out.superseded_by = b.superseded_by;
433
+ return out;
434
+ });
435
+ }
436
+ else if (compact) {
437
+ records = compactify(verified);
438
+ }
439
+ else {
440
+ records = fullify(verified);
441
+ }
226
442
  return {
227
443
  total: filtered.length,
228
444
  returned: verified.length,
@@ -237,18 +453,31 @@ export async function recall(args, recordFile) {
237
453
  }
238
454
  const server = new McpServer({
239
455
  name: 'atrib-recall',
240
- version: '0.3.0',
456
+ // Keep in sync with package.json. The Layer 1 stub scaffolding ships
457
+ // under the 0.4.0 surface (additive optional schema params + 4 stub
458
+ // tools that return "Layer 1 in progress" notice); the version bump
459
+ // happens via the queued changeset on next publication run.
460
+ version: '0.4.0',
241
461
  });
462
+ // The recall semantic surface (as defined in the public protocol specification).
463
+ // Five distinct MCP tools: recall_my_attribution_history is the base
464
+ // filter-and-page tool; recall_annotations + recall_revisions return
465
+ // aggregated annotation summaries / revision chains for a specific
466
+ // record_hash; recall_walk traverses the local Layer 1 derived graph;
467
+ // recall_by_content runs BM25 free-form retrieval over annotation
468
+ // summaries + topic tags.
242
469
  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 " +
470
+ description: "Return signed atrib records from the local mirror. The agent's own past, with each record's " +
244
471
  '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 ' +
472
+ 'includes only records that passed signature verification; both can be opted out of with ' +
246
473
  'compact=false and include_unverified=true respectively. Local signature verification proves ' +
247
474
  '"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 ' +
475
+ 'inclusion proof to confirm). Filter by context_id (specific trace), event_type ' +
476
+ '(tool_call|transaction), content_id (specific tool on specific server), tool_name (disclosed ' +
477
+ 'name per §8.2), or args_hash (canonical-args commitment per §8.3). Filters are AND-combined; ' +
478
+ 'omit all of them for cross-trace history. Results are sorted newest-first. Pagination uses ' +
479
+ 'offset; new records appended between calls invalidate offset stability. See the ' +
480
+ 'pagination_caveat in the response. The filtered_out_by_verification field reports how many ' +
252
481
  'records were dropped due to signature failures (always 0 when include_unverified=true).',
253
482
  inputSchema: {
254
483
  context_id: z
@@ -260,11 +489,30 @@ server.registerTool('recall_my_attribution_history', {
260
489
  .enum(['tool_call', 'transaction'])
261
490
  .optional()
262
491
  .describe('Optional filter to a single event kind. Most calls leave this unset.'),
492
+ content_id: z
493
+ .string()
494
+ .optional()
495
+ .describe('Optional exact match on record.content_id (sha256:<64-hex>). Per spec §1.2.2, content_id ' +
496
+ 'is sha256(serverUrl + ":" + toolName), so filtering groups all records emitted by the same ' +
497
+ 'tool on the same MCP server. Coarser than tool_name (different servers, same name -> ' +
498
+ 'different content_id).'),
499
+ tool_name: z
500
+ .string()
501
+ .optional()
502
+ .describe('Optional exact match on the §8.2 disclosed tool_name. Records that did NOT opt in to ' +
503
+ 'tool-name disclosure (the §8.1 default posture) carry no tool_name field and are excluded ' +
504
+ 'from results when this filter is set.'),
505
+ args_hash: z
506
+ .string()
507
+ .optional()
508
+ .describe('Optional exact match on record.args_hash (sha256:<64-hex>). Per spec §8.3, args_hash commits ' +
509
+ 'to canonical args bytes (salted or plain; both forms hash identically on the wire). Most ' +
510
+ 'useful for replay detection or agent-side keyed lookup over a normalized probe hash.'),
263
511
  limit: z.number().optional().describe('Page size, default 25, max 200.'),
264
512
  offset: z
265
513
  .number()
266
514
  .optional()
267
- .describe('Pagination offset, default 0. Note: not stable when new records land between calls see ' +
515
+ .describe('Pagination offset, default 0. Note: not stable when new records land between calls - see ' +
268
516
  'pagination_caveat in the response.'),
269
517
  compact: z
270
518
  .boolean()
@@ -277,15 +525,257 @@ server.registerTool('recall_my_attribution_history', {
277
525
  .optional()
278
526
  .describe('Default false. When false, records with signature_verified=false are dropped from the ' +
279
527
  '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.'),
528
+ 'include them - useful when investigating tampered or partial mirror state.'),
529
+ // ─── New schema params: accepted now; enforcement in flight. Each ───
530
+ // of the seven params below is currently STUB-ACCEPTED: the schema
531
+ // validates the value and the handler ignores it (returns the same
532
+ // results it would return without the param). The response payload
533
+ // includes a layer_1_warnings array listing which stub-accepted
534
+ // params were silently ignored, so callers can detect the pre-impl
535
+ // state without having to read source. Full enforcement implementation
536
+ // lands in upcoming releases.
537
+ min_importance: z
538
+ .enum(['critical', 'high', 'medium', 'low', 'noise'])
539
+ .optional()
540
+ .describe('Filter to records whose maximum annotation importance is at least this level. Annotation ' +
541
+ 'importance comes from annotation records pointing at the record. Records with no ' +
542
+ 'annotations at all are excluded when this filter is set.'),
543
+ topic_tags: z
544
+ .array(z.string())
545
+ .optional()
546
+ .describe('OR-match against annotation topic tags. Records are kept if at least one annotation pointing ' +
547
+ 'at them carries at least one of the listed topics. Records with no annotations or no ' +
548
+ 'topic overlap are excluded.'),
549
+ include_revised: z
550
+ .boolean()
551
+ .optional()
552
+ .describe('Default false: revised records remain visible with superseded_by populated. Set true to hide ' +
553
+ 'records that have been superseded by a revision record (revises field equals this record).'),
554
+ min_signers: z
555
+ .number()
556
+ .optional()
557
+ .describe('Minimum count of distinct signers. Transaction records carry a signers[] array (cross- ' +
558
+ 'attestation); the count is its length. Non-transaction records have a single signature; ' +
559
+ 'their count is 1. Records below the threshold are excluded.'),
560
+ rank_by: z
561
+ .enum(['timestamp', 'relevance', 'causal_distance'])
562
+ .optional()
563
+ .describe('Result ordering. timestamp (default): newest first. relevance: Park et al. weighted-sum ' +
564
+ 'scoring over recency + annotation-derived importance + BM25 relevance against rank_anchor ' +
565
+ '(treated as a free-form query when not a record_hash; otherwise relevance component is 0). ' +
566
+ 'causal_distance: BFS shortest path in the local derived graph from rank_anchor (a record_hash). ' +
567
+ 'Records unreachable from the anchor sort to the end.'),
568
+ rank_anchor: z
569
+ .string()
570
+ .optional()
571
+ .describe('Anchor for non-timestamp rank_by modes. For rank_by=relevance: free-form text query for the ' +
572
+ 'BM25 component (matched against annotation summary + topics of each candidate). For ' +
573
+ 'rank_by=causal_distance: record_hash to BFS from (sha256:<64-hex>); falls back to timestamp ' +
574
+ 'newest-first when not a valid record_hash.'),
575
+ toc: z
576
+ .boolean()
577
+ .optional()
578
+ .describe('Default false. When true, each returned record is the table-of-contents entry shape ' +
579
+ '(record_hash, tool_name, summary, importance, topic_tags, timestamp, superseded_by) at ' +
580
+ '~40-80 tokens per entry. Designed for SessionStart auto-injected scaffold and any other ' +
581
+ 'cheap-to-scan candidate set the agent expands on demand via recall(content_id=...) or ' +
582
+ 'recall_walk.'),
281
583
  },
282
584
  }, async (args) => {
585
+ // Layer 1 stub-acceptance: detect newly-accepted Layer 1 params, run the
586
+ // existing 0.4.0 recall path (which ignores them), and return the
587
+ // result with a layer_1_warnings array listing exactly which stub-
588
+ // accepted params were silently ignored. Callers can detect the
589
+ // pre-implementation state without having to read source.
590
+ // All seven Layer 1 surface parameters are now enforced
591
+ // (min_importance, topic_tags, include_revised, min_signers,
592
+ // rank_by, rank_anchor, toc). The layer_1_warnings array stays in
593
+ // the response shape (per the original wire contract) but is now
594
+ // always empty unless a future Layer extension lands more
595
+ // stub-accepted params.
596
+ const ignored = [];
283
597
  const result = await recall(args);
598
+ const augmented = ignored.length > 0
599
+ ? {
600
+ ...result,
601
+ layer_1_warnings: ignored.map((k) => ({
602
+ param: k,
603
+ status: 'stub-accepted',
604
+ 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.`,
605
+ })),
606
+ }
607
+ : result;
608
+ return {
609
+ content: [
610
+ {
611
+ type: 'text',
612
+ text: JSON.stringify(augmented, null, 2),
613
+ },
614
+ ],
615
+ };
616
+ });
617
+ // ─── Layer 1 sibling tools ───
618
+ // recall_walk, recall_annotations, recall_revisions, recall_by_content
619
+ // expose the cognitive surface beyond the base filter-and-page tool.
620
+ server.registerTool('recall_walk', {
621
+ 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.",
622
+ inputSchema: {
623
+ from_record_hash: z
624
+ .string()
625
+ .describe("Starting record hash (sha256:<64-hex>). The walk begins here and expands through the local derived graph."),
626
+ edge_types: z
627
+ .array(z.enum(['CHAIN_PRECEDES', 'INFORMED_BY', 'ANNOTATES', 'REVISES']))
628
+ .optional()
629
+ .describe("Optional list of Layer 1 edge types to follow. Default: all four. Unknown values are rejected by the schema."),
630
+ depth: z
631
+ .number()
632
+ .optional()
633
+ .describe("Maximum hop count (NOT cumulative weight). Default 3. Higher values may return many records; paginate downstream if needed."),
634
+ },
635
+ }, async (args) => {
636
+ const { loaded } = discoverLoaded();
637
+ const graph = buildLocalGraph(loaded);
638
+ const edgeTypes = args.edge_types
639
+ ? new Set(args.edge_types)
640
+ : undefined;
641
+ const depth = typeof args.depth === 'number' ? args.depth : 3;
642
+ const walk = walkFrom(graph, args.from_record_hash, edgeTypes, depth);
643
+ return {
644
+ content: [
645
+ {
646
+ type: 'text',
647
+ text: JSON.stringify({
648
+ from_record_hash: args.from_record_hash,
649
+ edge_types: args.edge_types ?? [
650
+ 'CHAIN_PRECEDES',
651
+ 'INFORMED_BY',
652
+ 'ANNOTATES',
653
+ 'REVISES',
654
+ ],
655
+ depth,
656
+ count: walk.length,
657
+ walk,
658
+ }, null, 2),
659
+ },
660
+ ],
661
+ };
662
+ });
663
+ server.registerTool('recall_annotations', {
664
+ 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.",
665
+ inputSchema: {
666
+ record_hash: z
667
+ .string()
668
+ .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."),
669
+ },
670
+ }, async (args) => {
671
+ const { loaded } = discoverLoaded();
672
+ const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
673
+ const summary = annotationsByRecord.get(args.record_hash) ?? null;
674
+ return {
675
+ content: [
676
+ {
677
+ type: 'text',
678
+ text: JSON.stringify({ record_hash: args.record_hash, annotations: summary }, null, 2),
679
+ },
680
+ ],
681
+ };
682
+ });
683
+ server.registerTool('recall_revisions', {
684
+ 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.",
685
+ inputSchema: {
686
+ record_hash: z
687
+ .string()
688
+ .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)."),
689
+ },
690
+ }, async (args) => {
691
+ const { loaded } = discoverLoaded();
692
+ const revisionsByRecord = aggregateRevisionsByRecord(loaded);
693
+ // Walk the chain forward: the input record may be revised by R1;
694
+ // R1 may be revised by R2; collect them in order. Bounded by the
695
+ // mirror size (no cycles since timestamps are monotonic per
696
+ // signer; defensive seen-set anyway).
697
+ const chain = [];
698
+ const seen = new Set();
699
+ let current = args.record_hash;
700
+ while (!seen.has(current)) {
701
+ seen.add(current);
702
+ const next = revisionsByRecord.get(current);
703
+ if (!next || next.length === 0)
704
+ break;
705
+ // Each entry in the map's value array is a revision pointing at
706
+ // `current`. Convention: the chain follows the first-by-timestamp
707
+ // revision; agents wanting the full sibling fan-out (parallel
708
+ // revisions at the same target) should call recall_my_attribution_history
709
+ // with event_type=revision and inspect their revises field manually.
710
+ const revHash = next[0];
711
+ chain.push(revHash);
712
+ current = revHash;
713
+ }
714
+ return {
715
+ content: [
716
+ {
717
+ type: 'text',
718
+ text: JSON.stringify({ record_hash: args.record_hash, revision_chain: chain }, null, 2),
719
+ },
720
+ ],
721
+ };
722
+ });
723
+ server.registerTool('recall_by_content', {
724
+ 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?'.",
725
+ inputSchema: {
726
+ query: z
727
+ .string()
728
+ .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)."),
729
+ k: z
730
+ .number()
731
+ .optional()
732
+ .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."),
733
+ },
734
+ }, async (args) => {
735
+ const { loaded } = discoverLoaded();
736
+ const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
737
+ const queryTokens = tokenize(args.query);
738
+ const corpus = loaded.map((lr) => ({
739
+ id: lr.record_hash,
740
+ tokens: indexableTextFromAnnotation(annotationsByRecord.get(lr.record_hash)),
741
+ }));
742
+ const idx = buildBM25Index(corpus);
743
+ const now = Date.now();
744
+ const scored = loaded.map((lr) => {
745
+ const r = recencyScore(lr.record.timestamp, now, ATRIB_RECALL_TAU_DAYS);
746
+ const i = importanceScore(annotationsByRecord.get(lr.record_hash));
747
+ const rel = queryTokens.length > 0
748
+ ? bm25Score(idx, lr.record_hash, queryTokens)
749
+ : 0;
750
+ const score = parkScore(r, i, rel, ATRIB_RECALL_ALPHA, ATRIB_RECALL_BETA, ATRIB_RECALL_GAMMA);
751
+ return { lr, score, recency: r, importance: i, relevance: rel };
752
+ });
753
+ scored.sort((a, b) => {
754
+ if (b.score !== a.score)
755
+ return b.score - a.score;
756
+ return b.lr.record.timestamp - a.lr.record.timestamp;
757
+ });
758
+ const k = Math.max(1, Math.min(50, args.k ?? 10));
759
+ const top = scored.slice(0, k);
284
760
  return {
285
761
  content: [
286
762
  {
287
763
  type: 'text',
288
- text: JSON.stringify(result, null, 2),
764
+ text: JSON.stringify({
765
+ query: args.query,
766
+ k,
767
+ count: top.length,
768
+ results: top.map(({ lr, score, recency, importance, relevance }) => ({
769
+ record_hash: lr.record_hash,
770
+ event_type: lr.record.event_type,
771
+ context_id: lr.record.context_id,
772
+ timestamp: lr.record.timestamp,
773
+ tool_name: lr.record.tool_name,
774
+ annotations: annotationsByRecord.get(lr.record_hash),
775
+ score,
776
+ components: { recency, importance, relevance },
777
+ })),
778
+ }, null, 2),
289
779
  },
290
780
  ],
291
781
  };