@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/README.md CHANGED
@@ -2,27 +2,85 @@
2
2
 
3
3
  MCP server for atrib. Lets agents query their own provable past from the local signed-record mirror with per-record signature verification.
4
4
 
5
- The consumer-side counterpart to `@atrib/emit`: emit produces signed records, recall reads them back and exposes them to the agent through one tool, `recall_my_attribution_history`. Each returned record carries a `signature_verified` boolean so a poorly-written agent treats tampered records as such.
5
+ The consumer-side counterpart to `@atrib/emit`: emit produces signed records, recall reads them back and exposes them to the agent through five MCP tools. Each returned record carries a `signature_verified` boolean so a poorly-written agent treats tampered records as such.
6
6
 
7
7
  ## Tool surface
8
8
 
9
+ Five MCP tools cover the cognitive surface of the local mirror.
10
+
11
+ ### `recall_my_attribution_history`
12
+
13
+ The base filter-rank-page tool over the local mirror.
14
+
9
15
  ```typescript
10
16
  mcp__atrib-recall__recall_my_attribution_history({
11
17
  // All optional
12
18
  context_id?: string, // 32-hex. Filter to records signed under this trace.
13
- event_type?: 'tool_call' | 'transaction', // Filter to a single event kind.
14
- // Short-form names are normalized to the URI form.
19
+ event_type?: 'tool_call' | 'transaction' | 'annotation' | 'revision',
20
+ // Filter to a single event kind. Short-form names are normalized
21
+ // to the URI form.
22
+ content_id?: string, // sha256:... exact match on §1.2.2 content_id.
23
+ tool_name?: string, // §8.2 disclosed tool name; records without disclosure excluded.
24
+ args_hash?: string, // sha256:... §8.3 args_hash exact match.
15
25
  limit?: number, // Default 25, max 200.
16
26
  offset?: number, // For pagination. Note pagination_caveat in the response.
17
- compact?: boolean, // Default true omits signature/content_id/chain_root/spec_version
27
+ compact?: boolean, // Default true - omits signature/content_id/chain_root/spec_version
18
28
  // fields. Set false for full record bytes (re-verification).
19
- include_unverified?: boolean, // Default false drops records whose signature didn't verify.
29
+ include_unverified?: boolean, // Default false - drops records whose signature didn't verify.
20
30
  // Set true ONLY when consuming the verbose mode AND explicitly
21
31
  // checking signature_verified per record.
32
+
33
+ // Annotation- and revision-driven filters. Records with no incident
34
+ // annotation are excluded when min_importance or topic_tags is set;
35
+ // records that have a revision pointing at them surface superseded_by
36
+ // by default and are hidden when include_revised=true.
37
+ min_importance?: 'critical' | 'high' | 'medium' | 'low' | 'noise',
38
+ topic_tags?: string[], // OR-match against annotation topic_tags.
39
+ include_revised?: boolean, // True hides records superseded by a D059 revision.
40
+ min_signers?: number, // Distinct-signer threshold; 1 for non-transaction records.
41
+
42
+ // Ranking.
43
+ rank_by?: 'timestamp' | 'relevance' | 'causal_distance',
44
+ // 'timestamp' (default): newest first.
45
+ // 'relevance': Park et al. weighted-sum scoring (recency +
46
+ // annotation-derived importance + BM25 against rank_anchor).
47
+ // 'causal_distance': BFS shortest-path from rank_anchor over
48
+ // the local derived graph (CHAIN_PRECEDES, INFORMED_BY,
49
+ // ANNOTATES, REVISES).
50
+ rank_anchor?: string, // record_hash for causal_distance, free-form query for relevance.
51
+
52
+ // Response shape.
53
+ toc?: boolean, // Default false. True returns the ~40-80-token-per-entry
54
+ // table-of-contents shape (record_hash, tool_name, summary,
55
+ // importance, topic_tags, timestamp, superseded_by) suitable
56
+ // for SessionStart auto-injected scaffolds.
22
57
  })
23
58
  ```
24
59
 
25
- Returns a `RecallResult` with `total`, `returned`, `filtered_out_by_verification`, `record_file`, `log_origin`, `pagination_caveat`, and `records` (compact or full per the flag).
60
+ Returns `{ total, returned, filtered_out_by_verification, record_files, record_file, log_origin, pagination_caveat, records }`. Each record carries `annotations` (when annotation records point at it) and `superseded_by` (when revision records point at it).
61
+
62
+ ### Sibling tools
63
+
64
+ - `mcp__atrib-recall__recall_walk({ from_record_hash, edge_types?, depth? })` - walks the local derived graph from `from_record_hash` up to `depth` hops (default 3), returning each reachable record_hash + weighted distance. 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.
65
+
66
+ - `mcp__atrib-recall__recall_annotations({ record_hash })` - returns the aggregated annotation summary (max_importance, union of topics, latest summary) for the target record. Returns `annotations: null` when no annotation points at the record.
67
+
68
+ - `mcp__atrib-recall__recall_revisions({ record_hash })` - returns the forward revision chain for the target record. Each entry's revises field points at the prior entry; the chain follows the first-by-timestamp revision at each step. Sibling fan-out (parallel revisions of the same target) requires calling `recall_my_attribution_history` with event_type=revision and inspecting `content.revises` manually.
69
+
70
+ - `mcp__atrib-recall__recall_by_content({ query, k? })` - BM25 free-form retrieval over each record's annotation summary + topic_tags, then reranked by Park et al. weighted-sum scoring (recency + importance + relevance). Default k=10, max 50. Records with no annotation contribute no relevance signal (will only surface via the recency + importance fallback). Layer 2 (sqlite-vec sidecar, separate ship) extends with embedding similarity over the same indexed text.
71
+
72
+ ### Tunable weights
73
+
74
+ The Park et al. ranking weights and recency time constant are environment-tunable for per-axis sensitivity studies:
75
+
76
+ | Env var | Default | Role |
77
+ |---|---|---|
78
+ | `ATRIB_RECALL_ALPHA` | 0.3 | Recency component weight |
79
+ | `ATRIB_RECALL_BETA` | 0.3 | Importance component weight |
80
+ | `ATRIB_RECALL_GAMMA` | 0.4 | Relevance (BM25) component weight |
81
+ | `ATRIB_RECALL_TAU_DAYS` | 7 | Exponential-decay time constant for recency |
82
+
83
+ The implementation does not enforce that alpha + beta + gamma sum to 1.0; the operator-facing defaults do.
26
84
 
27
85
  ## Trust scope
28
86
 
@@ -32,9 +90,12 @@ Signature verification is local-only. A passing `signature_verified` proves the
32
90
 
33
91
  | Env var | Required | Purpose |
34
92
  |---|---|---|
35
- | `ATRIB_RECORD_FILE` | optional | Path to the signed-record jsonl mirror to read. Default: `~/.atrib/records/mcp-wrap-claude-code.jsonl` |
93
+ | `ATRIB_RECORD_FILE` | optional | Path to a single signed-record jsonl mirror to read. When set, overrides directory scanning. Back-compat with pre-0.4.0 callers that pinned a specific producer's mirror. No default. |
94
+ | `ATRIB_MIRROR_DIR` | optional | Directory to scan; recall reads every `*.jsonl` inside. Default: `~/.atrib/records/` (the spec [§5.9](../../atrib-spec.md#59-local-mirror-conventions) well-known per-agent mirror namespace). When unset, this is the path used. |
36
95
  | `ATRIB_LOG_ORIGIN` | optional | Origin used in human-readable response messages. Default: `log.atrib.dev` |
37
96
 
97
+ **Mirror discovery priority** (per spec [§5.9](../../atrib-spec.md#59-local-mirror-conventions)): if `ATRIB_RECORD_FILE` is set, recall reads that single file. Otherwise recall scans `ATRIB_MIRROR_DIR` and merges every `*.jsonl` inside. The directory-scan default unifies recall across producers without recall having to know per-producer naming conventions; any producer that follows the spec convention just shows up.
98
+
38
99
  ## Installation in an MCP host
39
100
 
40
101
  ```jsonc
@@ -0,0 +1,114 @@
1
+ import type { AtribRecord } from '@atrib/mcp';
2
+ import type { ImportanceLabel } from './index.js';
3
+ /**
4
+ * A mirror record paired with its D062 sidecar content (when present) and
5
+ * its content-addressable record_hash (computed at load time). The
6
+ * record_hash form is `sha256:<64-hex>` per spec §2.3 — matches what the
7
+ * log entries commit to and what `informed_by` / `content.annotates` /
8
+ * `content.revises` reference.
9
+ *
10
+ * `content` is the deserialized `_local.content` from a D062 envelope mirror
11
+ * line. Producers writing bare AtribRecord lines (legacy / non-envelope)
12
+ * yield `content: undefined` here; annotation aggregation simply skips
13
+ * those records (the §8.1 posture: no body disclosed). Code that wants to
14
+ * read annotation importance / topics / summary MUST handle the undefined
15
+ * case.
16
+ */
17
+ export type LoadedRecord = {
18
+ record: AtribRecord;
19
+ record_hash: string;
20
+ content?: unknown;
21
+ };
22
+ /**
23
+ * Compute the spec §2.3 record_hash for an AtribRecord: sha256 over the
24
+ * JCS-canonical serialization including the signature field. Matches the
25
+ * pattern in `packages/openinference/test/informed-by.test.ts`
26
+ * and the in-tree atrib-span-processor build output. Kept local to
27
+ * atrib-recall for now — when a third caller appears, lift to `@atrib/mcp`.
28
+ */
29
+ export declare function computeRecordHash(record: AtribRecord): string;
30
+ /**
31
+ * Load a single jsonl mirror file as LoadedRecord[]. Each line's
32
+ * record_hash is computed once at load; subsequent lookups are O(1).
33
+ * Malformed lines are silently skipped (same contract as `loadRecords`).
34
+ */
35
+ export declare function loadLoaded(path: string): LoadedRecord[];
36
+ /**
37
+ * Mirror-discovery variant of `loadLoaded` that scans a directory of
38
+ * `*.jsonl` files. See loadRecordsFromDir in index.ts for the bare
39
+ * AtribRecord equivalent. Files that don't exist or aren't readable are silently
40
+ * skipped (a file rotated mid-scan shouldn't error the whole call).
41
+ * Returns the union of loaded entries plus the list of files scanned.
42
+ */
43
+ export declare function loadLoadedFromDir(dir: string): {
44
+ loaded: LoadedRecord[];
45
+ files: string[];
46
+ };
47
+ /**
48
+ * LoadedRecord variant of `discoverRecords` in index.ts. Same priority
49
+ * order: explicit `recordFile` arg > ATRIB_RECORD_FILE env > ATRIB_MIRROR_DIR
50
+ * scan. Re-evaluates env vars on each call so test harnesses that mutate
51
+ * process.env per-test get the value they set.
52
+ */
53
+ export declare function discoverLoaded(recordFile?: string): {
54
+ loaded: LoadedRecord[];
55
+ files: string[];
56
+ };
57
+ /**
58
+ * Per-record annotation summary: max importance across all annotations
59
+ * pointing at the record (or undefined if none), the union of all
60
+ * topic_tags arrays carried by those annotations, and the most-recent
61
+ * summary string. "Most recent" here means the last annotation by
62
+ * timestamp; ties (rare in practice) resolve to the last array index
63
+ * (mirror order).
64
+ *
65
+ * Matches the AnnotationSummary type declared in `index.ts`. Kept as a
66
+ * separate exported type so future callers (e.g. the public
67
+ * recall_annotations handler) can return this shape without depending
68
+ * on the recall server's internal types.
69
+ */
70
+ export type AnnotationSummary = {
71
+ max_importance?: ImportanceLabel;
72
+ topics?: string[];
73
+ summary?: string;
74
+ };
75
+ /**
76
+ * Walk loaded records, identify D058 annotation records, and bin them by
77
+ * `content.annotates` target. Returns Map<target_record_hash,
78
+ * AnnotationSummary>. Records with no annotations pointing at them
79
+ * receive no entry (callers should default to undefined).
80
+ *
81
+ * Annotation records WITHOUT a `_local.content` sidecar (legacy bare
82
+ * mirrors that didn't preserve content) are skipped entirely; the §8.1
83
+ * privacy posture means the annotation's importance / topics / summary
84
+ * are not knowable from the public AtribRecord alone.
85
+ *
86
+ * Spec references:
87
+ * - D058: annotation event_type byte 0x05, URI form annotation
88
+ * - §8.3: salted-commitment posture (body lives in _local; log has only content_id)
89
+ * - §1.2.4: event_type URI form (required for annotation records)
90
+ */
91
+ export declare function aggregateAnnotationsByRecord(loaded: LoadedRecord[]): Map<string, AnnotationSummary>;
92
+ /**
93
+ * Walk loaded records, identify D059 revision records, and bin them by
94
+ * `content.revises` target. Returns Map<target_record_hash,
95
+ * revision_record_hashes[]>. The value array contains the record_hashes
96
+ * of every revision pointing at the target (immediate revisions only;
97
+ * chain traversal is the caller's responsibility — the recall_revisions
98
+ * handler walks the chain by recursing on each revision's own hash).
99
+ *
100
+ * The returned value array is ordered by revision timestamp ascending so
101
+ * the caller sees revisions in the order they were issued. Ties resolve
102
+ * to mirror-iteration order.
103
+ *
104
+ * Revision records WITHOUT a `_local.content` sidecar are skipped per the
105
+ * §8.1 bare-record posture — without content, the `revises` target is
106
+ * unknowable. Revision records WITH content but no `revises` field are
107
+ * also skipped (the revision is malformed).
108
+ *
109
+ * Spec references:
110
+ * - D059: revision event_type byte 0x06, URI form revision
111
+ * - §8.3: salted-commitment posture (body lives in _local; log has only content_id)
112
+ * - §1.2.4: event_type URI form (required for revision records)
113
+ */
114
+ export declare function aggregateRevisionsByRecord(loaded: LoadedRecord[]): Map<string, string[]>;
@@ -0,0 +1,267 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Layer 1 aggregation helpers for the recall semantic surface.
4
+ *
5
+ * Annotation records (D058, event_type = EVENT_TYPE_ANNOTATION_URI) carry
6
+ * their importance + topic_tags + summary in `content.*`, with the target
7
+ * record_hash in `content.annotates`. Per the §8.3 privacy posture the
8
+ * public log carries only content_id (kind hash); the actual content body
9
+ * lives in the local mirror's D062 envelope at `_local.content`. To compute
10
+ * "what annotations does record X have?" the recall server must walk the
11
+ * mirror, recover `_local.content` per envelope, and bin annotations by
12
+ * their `content.annotates` target.
13
+ *
14
+ * This module isolates that walk + bin step plus the record_hash helper
15
+ * needed to key the result map. The existing `loadRecords` / `recall` paths
16
+ * in `index.ts` deal in bare AtribRecord[] and stay unchanged; the Layer 1
17
+ * filter / ranking wire-up will compose `loadLoaded` ->
18
+ * `aggregateAnnotationsByRecord` -> filter alongside the existing recall
19
+ * flow.
20
+ *
21
+ * Revision aggregation (D059) is a near-identical shape that will land
22
+ * alongside; the structure here is designed to extend.
23
+ */
24
+ import { canonicalRecord, sha256, hexEncode, EVENT_TYPE_ANNOTATION_URI, EVENT_TYPE_REVISION_URI, } from '@atrib/mcp';
25
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { IMPORTANCE_NUMERIC } from './index.js';
28
+ /**
29
+ * Compute the spec §2.3 record_hash for an AtribRecord: sha256 over the
30
+ * JCS-canonical serialization including the signature field. Matches the
31
+ * pattern in `packages/openinference/test/informed-by.test.ts`
32
+ * and the in-tree atrib-span-processor build output. Kept local to
33
+ * atrib-recall for now — when a third caller appears, lift to `@atrib/mcp`.
34
+ */
35
+ export function computeRecordHash(record) {
36
+ return `sha256:${hexEncode(sha256(canonicalRecord(record)))}`;
37
+ }
38
+ /**
39
+ * Pull the inner AtribRecord and the D062 `_local.content` out of one
40
+ * parsed mirror line. Returns null when the line is neither shape or is
41
+ * missing the required AtribRecord fields. The shape contract mirrors
42
+ * `extractRecord` in `index.ts` exactly; the only addition is the optional
43
+ * `content` field carried back. Kept as a separate function (rather than
44
+ * augmenting `extractRecord`) so the existing recall flow's signature
45
+ * stays AtribRecord[].
46
+ */
47
+ function extractLoaded(parsed) {
48
+ if (!parsed || typeof parsed !== 'object')
49
+ return null;
50
+ const obj = parsed;
51
+ const envelopeRecord = (typeof obj.record === 'object' && obj.record !== null)
52
+ ? obj.record
53
+ : null;
54
+ const candidate = envelopeRecord ?? obj;
55
+ if (typeof candidate.spec_version !== 'string' ||
56
+ typeof candidate.event_type !== 'string' ||
57
+ typeof candidate.context_id !== 'string' ||
58
+ typeof candidate.creator_key !== 'string' ||
59
+ typeof candidate.chain_root !== 'string' ||
60
+ typeof candidate.signature !== 'string') {
61
+ return null;
62
+ }
63
+ const record = candidate;
64
+ if (envelopeRecord !== null) {
65
+ const local = obj._local ?? undefined;
66
+ if (local && 'content' in local) {
67
+ return { record, content: local.content };
68
+ }
69
+ }
70
+ return { record };
71
+ }
72
+ /**
73
+ * Load a single jsonl mirror file as LoadedRecord[]. Each line's
74
+ * record_hash is computed once at load; subsequent lookups are O(1).
75
+ * Malformed lines are silently skipped (same contract as `loadRecords`).
76
+ */
77
+ export function loadLoaded(path) {
78
+ if (!existsSync(path))
79
+ return [];
80
+ const out = [];
81
+ const raw = readFileSync(path, 'utf8');
82
+ for (const line of raw.split('\n')) {
83
+ const trimmed = line.trim();
84
+ if (!trimmed)
85
+ continue;
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(trimmed);
89
+ }
90
+ catch {
91
+ continue;
92
+ }
93
+ const extracted = extractLoaded(parsed);
94
+ if (!extracted)
95
+ continue;
96
+ out.push({
97
+ record: extracted.record,
98
+ record_hash: computeRecordHash(extracted.record),
99
+ content: extracted.content,
100
+ });
101
+ }
102
+ return out;
103
+ }
104
+ /**
105
+ * Mirror-discovery variant of `loadLoaded` that scans a directory of
106
+ * `*.jsonl` files. See loadRecordsFromDir in index.ts for the bare
107
+ * AtribRecord equivalent. Files that don't exist or aren't readable are silently
108
+ * skipped (a file rotated mid-scan shouldn't error the whole call).
109
+ * Returns the union of loaded entries plus the list of files scanned.
110
+ */
111
+ export function loadLoadedFromDir(dir) {
112
+ if (!existsSync(dir))
113
+ return { loaded: [], files: [] };
114
+ let entries = [];
115
+ try {
116
+ entries = readdirSync(dir).filter((name) => name.endsWith('.jsonl'));
117
+ }
118
+ catch {
119
+ return { loaded: [], files: [] };
120
+ }
121
+ const loaded = [];
122
+ const files = [];
123
+ for (const name of entries) {
124
+ const full = join(dir, name);
125
+ try {
126
+ const stat = statSync(full);
127
+ if (!stat.isFile())
128
+ continue;
129
+ }
130
+ catch {
131
+ continue;
132
+ }
133
+ const partial = loadLoaded(full);
134
+ files.push(full);
135
+ if (partial.length > 0)
136
+ loaded.push(...partial);
137
+ }
138
+ return { loaded, files };
139
+ }
140
+ /**
141
+ * LoadedRecord variant of `discoverRecords` in index.ts. Same priority
142
+ * order: explicit `recordFile` arg > ATRIB_RECORD_FILE env > ATRIB_MIRROR_DIR
143
+ * scan. Re-evaluates env vars on each call so test harnesses that mutate
144
+ * process.env per-test get the value they set.
145
+ */
146
+ export function discoverLoaded(recordFile) {
147
+ const envFile = process.env.ATRIB_RECORD_FILE;
148
+ const envDir = process.env.ATRIB_MIRROR_DIR ??
149
+ join(process.env.HOME ?? '', '.atrib', 'records');
150
+ const explicit = recordFile ?? envFile;
151
+ if (explicit) {
152
+ return { loaded: loadLoaded(explicit), files: [explicit] };
153
+ }
154
+ return loadLoadedFromDir(envDir);
155
+ }
156
+ /**
157
+ * Walk loaded records, identify D058 annotation records, and bin them by
158
+ * `content.annotates` target. Returns Map<target_record_hash,
159
+ * AnnotationSummary>. Records with no annotations pointing at them
160
+ * receive no entry (callers should default to undefined).
161
+ *
162
+ * Annotation records WITHOUT a `_local.content` sidecar (legacy bare
163
+ * mirrors that didn't preserve content) are skipped entirely; the §8.1
164
+ * privacy posture means the annotation's importance / topics / summary
165
+ * are not knowable from the public AtribRecord alone.
166
+ *
167
+ * Spec references:
168
+ * - D058: annotation event_type byte 0x05, URI form annotation
169
+ * - §8.3: salted-commitment posture (body lives in _local; log has only content_id)
170
+ * - §1.2.4: event_type URI form (required for annotation records)
171
+ */
172
+ export function aggregateAnnotationsByRecord(loaded) {
173
+ const bins = new Map();
174
+ for (const lr of loaded) {
175
+ if (lr.record.event_type !== EVENT_TYPE_ANNOTATION_URI)
176
+ continue;
177
+ if (lr.content === undefined || lr.content === null)
178
+ continue;
179
+ if (typeof lr.content !== 'object')
180
+ continue;
181
+ const c = lr.content;
182
+ if (typeof c.annotates !== 'string' || c.annotates.length === 0)
183
+ continue;
184
+ const target = c.annotates;
185
+ const bin = bins.get(target) ?? {
186
+ importances: [],
187
+ topics: new Set(),
188
+ summary_ts: -Infinity,
189
+ };
190
+ if (typeof c.importance === 'string' && c.importance in IMPORTANCE_NUMERIC) {
191
+ bin.importances.push(c.importance);
192
+ }
193
+ if (Array.isArray(c.topic_tags)) {
194
+ for (const t of c.topic_tags) {
195
+ if (typeof t === 'string' && t.length > 0)
196
+ bin.topics.add(t);
197
+ }
198
+ }
199
+ if (typeof c.summary === 'string' && lr.record.timestamp >= bin.summary_ts) {
200
+ bin.summary = c.summary;
201
+ bin.summary_ts = lr.record.timestamp;
202
+ }
203
+ bins.set(target, bin);
204
+ }
205
+ const out = new Map();
206
+ for (const [target, bin] of bins) {
207
+ const max_importance = bin.importances.length === 0
208
+ ? undefined
209
+ : bin.importances.reduce((a, b) => IMPORTANCE_NUMERIC[a] >= IMPORTANCE_NUMERIC[b] ? a : b);
210
+ const summary = {};
211
+ if (max_importance !== undefined)
212
+ summary.max_importance = max_importance;
213
+ if (bin.topics.size > 0)
214
+ summary.topics = [...bin.topics].sort();
215
+ if (bin.summary !== undefined)
216
+ summary.summary = bin.summary;
217
+ out.set(target, summary);
218
+ }
219
+ return out;
220
+ }
221
+ /**
222
+ * Walk loaded records, identify D059 revision records, and bin them by
223
+ * `content.revises` target. Returns Map<target_record_hash,
224
+ * revision_record_hashes[]>. The value array contains the record_hashes
225
+ * of every revision pointing at the target (immediate revisions only;
226
+ * chain traversal is the caller's responsibility — the recall_revisions
227
+ * handler walks the chain by recursing on each revision's own hash).
228
+ *
229
+ * The returned value array is ordered by revision timestamp ascending so
230
+ * the caller sees revisions in the order they were issued. Ties resolve
231
+ * to mirror-iteration order.
232
+ *
233
+ * Revision records WITHOUT a `_local.content` sidecar are skipped per the
234
+ * §8.1 bare-record posture — without content, the `revises` target is
235
+ * unknowable. Revision records WITH content but no `revises` field are
236
+ * also skipped (the revision is malformed).
237
+ *
238
+ * Spec references:
239
+ * - D059: revision event_type byte 0x06, URI form revision
240
+ * - §8.3: salted-commitment posture (body lives in _local; log has only content_id)
241
+ * - §1.2.4: event_type URI form (required for revision records)
242
+ */
243
+ export function aggregateRevisionsByRecord(loaded) {
244
+ const bins = new Map();
245
+ for (const lr of loaded) {
246
+ if (lr.record.event_type !== EVENT_TYPE_REVISION_URI)
247
+ continue;
248
+ if (lr.content === undefined || lr.content === null)
249
+ continue;
250
+ if (typeof lr.content !== 'object')
251
+ continue;
252
+ const c = lr.content;
253
+ if (typeof c.revises !== 'string' || c.revises.length === 0)
254
+ continue;
255
+ const target = c.revises;
256
+ const list = bins.get(target) ?? [];
257
+ list.push({ hash: lr.record_hash, ts: lr.record.timestamp });
258
+ bins.set(target, list);
259
+ }
260
+ const out = new Map();
261
+ for (const [target, entries] of bins) {
262
+ entries.sort((a, b) => a.ts - b.ts);
263
+ out.set(target, entries.map((e) => e.hash));
264
+ }
265
+ return out;
266
+ }
267
+ //# sourceMappingURL=aggregations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aggregations.js","sourceRoot":"","sources":["../src/aggregations.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,63 @@
1
+ import type { LoadedRecord } from './aggregations.js';
2
+ export type EdgeType = 'CHAIN_PRECEDES' | 'INFORMED_BY' | 'ANNOTATES' | 'REVISES';
3
+ /**
4
+ * Layer 1 BFS edge weights. Direct causal links (CHAIN_PRECEDES,
5
+ * INFORMED_BY) are weight 1; annotation/revision relationships are
6
+ * weight 2. The recall_walk handler accepts an edge_types filter that
7
+ * intersects with these four; weights apply only when an edge type
8
+ * passes the filter.
9
+ */
10
+ export declare const EDGE_WEIGHTS: Record<EdgeType, number>;
11
+ export type GraphEdge = {
12
+ type: EdgeType;
13
+ target: string;
14
+ weight: number;
15
+ };
16
+ /**
17
+ * Undirected adjacency. For each record_hash, the value is the list of
18
+ * edges out of that record_hash. CHAIN_PRECEDES and INFORMED_BY edges
19
+ * are emitted in both directions (the BFS is for "how close is X to
20
+ * anchor", which is symmetric); ANNOTATES and REVISES likewise.
21
+ */
22
+ export type LocalGraph = Map<string, GraphEdge[]>;
23
+ /**
24
+ * Build the local Layer 1 graph from loaded records. Returns adjacency
25
+ * map keyed by record_hash. Records present in the loaded set but with
26
+ * no incident edges still appear as map keys with empty arrays — this
27
+ * keeps the BFS path consistent (graph.has(anchor) is still true even
28
+ * when the anchor has no neighbors).
29
+ *
30
+ * Time complexity: O(N) for the chain index pass + O(E) for edge
31
+ * emission. N = loaded.length, E = sum of informed_by entries + chain
32
+ * links + annotation+revision edges.
33
+ */
34
+ export declare function buildLocalGraph(loaded: LoadedRecord[]): LocalGraph;
35
+ /**
36
+ * BFS-shortest-path distances from `start` over the local graph.
37
+ * Returns a map record_hash -> distance (weighted). Records with no path
38
+ * to `start` are omitted (rather than mapped to Infinity) so callers
39
+ * iterate only reachable nodes.
40
+ *
41
+ * The traversal honors `edgeTypes` when provided: only edges whose type
42
+ * is in the set are followed. When edgeTypes is undefined or empty,
43
+ * ALL edge types are followed.
44
+ *
45
+ * `maxDepth` caps the traversal at that hop-count (NOT cumulative
46
+ * weight). Set to Infinity (the default) to traverse the full reachable
47
+ * subgraph. Useful for recall_walk's depth parameter.
48
+ *
49
+ * Since edge weights differ (1 or 2), the traversal uses Dijkstra (with
50
+ * a simple O(V^2) min-extraction since the candidate sets are small at
51
+ * Layer 1). This produces correct shortest paths in weighted graphs.
52
+ */
53
+ export declare function shortestDistances(graph: LocalGraph, start: string, edgeTypes?: Set<EdgeType>, maxHops?: number): Map<string, number>;
54
+ /**
55
+ * Walk the graph from `start`, returning every reachable record_hash
56
+ * within `maxHops` hops, filtered to the requested edge_types. Result
57
+ * is sorted by ascending distance from start. Used by the recall_walk
58
+ * MCP tool to surface the local causal neighborhood of an anchor.
59
+ */
60
+ export declare function walkFrom(graph: LocalGraph, start: string, edgeTypes?: Set<EdgeType>, maxHops?: number): Array<{
61
+ record_hash: string;
62
+ distance: number;
63
+ }>;