@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/README.md +68 -7
- package/dist/aggregations.d.ts +114 -0
- package/dist/aggregations.js +267 -0
- package/dist/aggregations.js.map +1 -0
- package/dist/graph.d.ts +63 -0
- package/dist/graph.js +217 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.d.ts +136 -4
- package/dist/index.js +539 -37
- package/dist/index.js.map +1 -1
- package/dist/scoring.d.ts +109 -0
- package/dist/scoring.js +126 -0
- package/dist/scoring.js.map +1 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
30
|
-
* ATRIB_MIRROR_DIR
|
|
31
|
-
* ATRIB_LOG_ORIGIN
|
|
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
|
|
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
|
|
171
|
+
// does - readdirSync returned it).
|
|
141
172
|
files.push(full);
|
|
142
173
|
}
|
|
143
174
|
}
|
|
144
175
|
return { records, files };
|
|
145
176
|
}
|
|
146
|
-
|
|
147
|
-
|
|
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(
|
|
261
|
+
ok = await verifyRecord(lr.record);
|
|
151
262
|
}
|
|
152
263
|
catch {
|
|
153
264
|
ok = false;
|
|
154
265
|
}
|
|
155
|
-
|
|
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(
|
|
159
|
-
return
|
|
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:
|
|
288
|
+
signature_verified: b.signature_verified,
|
|
166
289
|
};
|
|
167
|
-
|
|
168
|
-
|
|
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 {
|
|
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 (
|
|
200
|
-
filtered = filtered.filter((
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
249
|
-
'(tool_call|transaction)
|
|
250
|
-
'
|
|
251
|
-
'
|
|
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
|
|
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
|
|
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(
|
|
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
|
};
|