@atrib/recall 0.12.15 → 0.12.17

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
@@ -116,6 +116,7 @@ export const ATRIB_RECALL_NOISE_FLOOR = parseFloat(process.env.ATRIB_RECALL_NOIS
116
116
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
117
117
  import { homedir } from 'node:os';
118
118
  import { join } from 'node:path';
119
+ import { pathToFileURL } from 'node:url';
119
120
  import { z } from 'zod';
120
121
  import { aggregateAnnotationsByRecord, aggregateRevisionsByRecord, discoverLoaded, } from './aggregations.js';
121
122
  import { recencyScore, importanceScore, parkScore, buildBM25Index, bm25Score, tokenize, indexableTokensForRecord, } from './scoring.js';
@@ -188,7 +189,7 @@ export function loadRecords(path) {
188
189
  export function loadRecordsFromDir(dir) {
189
190
  if (!existsSync(dir))
190
191
  return { records: [], files: [] };
191
- let entries = [];
192
+ let entries;
192
193
  try {
193
194
  entries = readdirSync(dir).filter((name) => name.endsWith('.jsonl'));
194
195
  }
@@ -322,7 +323,7 @@ function rankByCausalDistance(filtered, all, rankAnchor) {
322
323
  }
323
324
  async function annotateVerification(loaded, annotationsByRecord, revisionsByRecord) {
324
325
  return Promise.all(loaded.map(async (lr) => {
325
- let ok = false;
326
+ let ok;
326
327
  try {
327
328
  ok = await verifyRecord(lr.record);
328
329
  }
@@ -623,10 +624,6 @@ function readPackageVersion() {
623
624
  return '0.0.0';
624
625
  }
625
626
  }
626
- const server = new McpServer({
627
- name: 'atrib-recall',
628
- version: readPackageVersion(),
629
- });
630
627
  // The recall semantic surface (as defined in the public protocol specification).
631
628
  // Eight distinct MCP tools: recall_my_attribution_history is the base
632
629
  // filter-and-page tool; recall_annotations + recall_revisions return
@@ -634,565 +631,581 @@ const server = new McpServer({
634
631
  // record_hash; recall_walk traverses the local Layer 1 derived graph;
635
632
  // recall_by_content runs BM25 free-form retrieval; recall_session_chain,
636
633
  // recall_orphans, and recall_by_signer cover common agent lookup shapes.
637
- server.registerTool('recall_my_attribution_history', {
638
- description: "Return signed atrib records from the local mirror. The agent's own past, with each record's " +
639
- 'Ed25519 signature verified locally. By default the response is compact (no signature bytes) and ' +
640
- 'includes only records that passed signature verification; both can be opted out of with ' +
641
- 'compact=false and include_unverified=true respectively. Local signature verification proves ' +
642
- '"this record was signed by that creator_key"; it does NOT prove log inclusion (fetch a log ' +
643
- 'inclusion proof to confirm). Filter by context_id (specific trace), event_type ' +
644
- '(tool_call|transaction|observation|annotation|revision|directory_anchor or a full URI), content_id (specific tool on specific server), tool_name (disclosed ' +
645
- 'name per §8.2), or args_hash (canonical-args commitment per §8.3). Filters are AND-combined; ' +
646
- 'omit all of them for cross-trace history. Results are sorted newest-first. Pagination uses ' +
647
- 'offset; new records appended between calls invalidate offset stability. See the ' +
648
- 'pagination_caveat in the response. The filtered_out_by_verification field reports how many ' +
649
- 'records were dropped due to signature failures (always 0 when include_unverified=true).',
650
- inputSchema: {
651
- context_id: z
652
- .string()
653
- .optional()
654
- .describe('Optional trace identifier (32 hex chars). Limits results to records signed within this trace. ' +
655
- 'Omit for cross-trace recall.'),
656
- creator_key: z
657
- .string()
658
- .optional()
659
- .describe('Optional exact match on record.creator_key (base64url-encoded Ed25519 public key, 43 chars). ' +
660
- 'Filters the local mirror to records signed by this specific creator. Omit to see all signers ' +
661
- "present in the mirror; the tool name says 'my attribution history' but the mirror may contain " +
662
- 'records from other creators when multi-agent flows ship records into a shared mirror. Use ' +
663
- 'your own creator_key (resolvable from the @atrib/cli key-show output, or `getPublicKey()` ' +
664
- 'called on your seed) when you want to scope strictly to your own past.'),
665
- event_type: EventTypeFilterSchema.optional().describe('Optional filter to a single event kind. Accepts atrib shorthand aliases ' +
666
- '(tool_call, transaction, observation, annotation, revision, directory_anchor) ' +
667
- 'or a full event_type URI. Most calls leave this unset.'),
668
- content_id: z
669
- .string()
670
- .optional()
671
- .describe('Optional exact match on record.content_id (sha256:<64-hex>). Per spec §1.2.2, content_id ' +
672
- 'is sha256(serverUrl + ":" + toolName), so filtering groups all records emitted by the same ' +
673
- 'tool on the same MCP server. Coarser than tool_name (different servers, same name -> ' +
674
- 'different content_id).'),
675
- tool_name: z
676
- .string()
677
- .optional()
678
- .describe('Optional exact match on the §8.2 disclosed tool_name. Records that did NOT opt in to ' +
679
- 'tool-name disclosure (the §8.1 default posture) carry no tool_name field and are excluded ' +
680
- 'from results when this filter is set.'),
681
- args_hash: z
682
- .string()
683
- .optional()
684
- .describe('Optional exact match on record.args_hash (sha256:<64-hex>). Per spec §8.3, args_hash commits ' +
685
- 'to canonical args bytes (salted or plain; both forms hash identically on the wire). Most ' +
686
- 'useful for replay detection or agent-side keyed lookup over a normalized probe hash.'),
687
- limit: z.number().optional().describe('Page size, default 10, max 200.'),
688
- offset: z
689
- .number()
690
- .optional()
691
- .describe('Pagination offset, default 0. Note: not stable when new records land between calls - see ' +
692
- 'pagination_caveat in the response.'),
693
- compact: z
694
- .boolean()
695
- .optional()
696
- .describe('Default true. When true, omit signature/content_id/chain_root/spec_version fields. ' +
697
- 'signature_verified is still included. Set to false (or use the equivalent verbose=true) ' +
698
- 'when you need the full record bytes for re-verification or downstream processing.'),
699
- include_unverified: z
700
- .boolean()
701
- .optional()
702
- .describe('Default false. When false, records with signature_verified=false are dropped from the ' +
703
- 'response (their count is reported in filtered_out_by_verification). Set to true to ' +
704
- 'include them - useful when investigating tampered or partial mirror state.'),
705
- // Layer 1 semantic filters. These are enforced in the handler below and
706
- // share the same response metadata path as the base filters.
707
- min_importance: z
708
- .enum(['critical', 'high', 'medium', 'low', 'noise'])
709
- .optional()
710
- .describe('Filter to records whose maximum annotation importance is at least this level. Annotation ' +
711
- 'importance comes from annotation records pointing at the record. Records with no ' +
712
- 'annotations at all are excluded when this filter is set.'),
713
- topic_tags: z
714
- .array(z.string())
715
- .optional()
716
- .describe('OR-match against annotation topic tags. Records are kept if at least one annotation pointing ' +
717
- 'at them carries at least one of the listed topics. Records with no annotations or no ' +
718
- 'topic overlap are excluded.'),
719
- include_revised: z
720
- .boolean()
721
- .optional()
722
- .describe('Default false: revised records remain visible with superseded_by populated. Set true to hide ' +
723
- 'records that have been superseded by a revision record (revises field equals this record).'),
724
- min_signers: z
725
- .number()
726
- .optional()
727
- .describe('Minimum count of distinct signers. Transaction records carry a signers[] array (cross- ' +
728
- 'attestation); the count is its length. Non-transaction records have a single signature; ' +
729
- 'their count is 1. Records below the threshold are excluded.'),
730
- rank_by: z
731
- .enum(['timestamp', 'relevance', 'causal_distance'])
732
- .optional()
733
- .describe('Result ordering. timestamp (default): newest first. relevance: Park et al. weighted-sum ' +
734
- 'scoring over recency + annotation-derived importance + BM25 relevance against rank_anchor ' +
735
- '(treated as a free-form query when not a record_hash; otherwise relevance component is 0). ' +
736
- 'causal_distance: BFS shortest path in the local derived graph from rank_anchor (a record_hash). ' +
737
- 'Records unreachable from the anchor sort to the end.'),
738
- rank_anchor: z
739
- .string()
740
- .optional()
741
- .describe('Anchor for non-timestamp rank_by modes. For rank_by=relevance: free-form text query for the ' +
742
- 'BM25 component (matched against annotation summary + topics of each candidate). For ' +
743
- 'rank_by=causal_distance: record_hash to BFS from (sha256:<64-hex>); falls back to timestamp ' +
744
- 'newest-first when not a valid record_hash.'),
745
- toc: z
746
- .boolean()
747
- .optional()
748
- .describe('Default false. When true, each returned record is the table-of-contents entry shape ' +
749
- '(record_hash, tool_name, summary, importance, topic_tags, timestamp, superseded_by) at ' +
750
- '~40-80 tokens per entry. Designed for SessionStart auto-injected scaffold and any other ' +
751
- 'cheap-to-scan candidate set the agent expands on demand via recall(content_id=...) or ' +
752
- 'recall_walk.'),
753
- },
754
- }, async (args) => logReadPrimitiveCall('recall_my_attribution_history', args, async () => {
755
- // All Layer 1 surface parameters are enforced by recall() above:
756
- // min_importance, topic_tags, include_revised, min_signers,
757
- // rank_by, rank_anchor, and toc.
758
- const result = await recall(args);
759
- return {
760
- content: [
761
- {
762
- type: 'text',
763
- text: JSON.stringify(result, null, 2),
764
- },
765
- ],
766
- };
767
- }, extractRecordHashFieldsFromMcpResult));
768
- // ─── Layer 1 sibling tools ───
769
- // Sibling tools expose common agent lookup shapes beyond the base
770
- // filter-and-page tool.
771
- server.registerTool('recall_walk', {
772
- 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.',
773
- inputSchema: {
774
- from_record_hash: z
775
- .string()
776
- .describe('Starting record hash (sha256:<64-hex>). The walk begins here and expands through the local derived graph.'),
777
- edge_types: z
778
- .array(z.enum(['CHAIN_PRECEDES', 'INFORMED_BY', 'ANNOTATES', 'REVISES']))
779
- .optional()
780
- .describe('Optional list of Layer 1 edge types to follow. Default: all four. Unknown values are rejected by the schema.'),
781
- depth: z
782
- .number()
783
- .optional()
784
- .describe('Maximum hop count (NOT cumulative weight). Default 3. Higher values may return many records; paginate downstream if needed.'),
785
- },
786
- }, async (args) => logReadPrimitiveCall('recall_walk', args, async () => {
787
- const { loaded } = discoverLoaded();
788
- const graph = buildLocalGraph(loaded);
789
- const edgeTypes = args.edge_types ? new Set(args.edge_types) : undefined;
790
- const depth = typeof args.depth === 'number' ? args.depth : 3;
791
- const walk = walkFrom(graph, args.from_record_hash, edgeTypes, depth);
792
- // Layer 1 v2 legibility: join walked hashes back to their loaded
793
- // records so each step in the walk carries a readable label
794
- // instead of just a hash + distance. Builds the index once for
795
- // O(N) lookup across the walk; for typical walks (depth=3, k<50)
796
- // this is fast.
797
- const byHash = new Map(loaded.map((lr) => [lr.record_hash, lr]));
798
- const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
799
- const now = Date.now();
800
- const enriched = walk.map((step) => {
801
- // walk derives from graph (built from `loaded`); byHash always has a hit.
802
- const lr = byHash.get(step.record_hash);
803
- const ann = annotationsByRecord.get(step.record_hash);
634
+ export function registerAtribRecallTools(server) {
635
+ server.registerTool('recall_my_attribution_history', {
636
+ description: "Return signed atrib records from the local mirror. The agent's own past, with each record's " +
637
+ 'Ed25519 signature verified locally. By default the response is compact (no signature bytes) and ' +
638
+ 'includes only records that passed signature verification; both can be opted out of with ' +
639
+ 'compact=false and include_unverified=true respectively. Local signature verification proves ' +
640
+ '"this record was signed by that creator_key"; it does NOT prove log inclusion (fetch a log ' +
641
+ 'inclusion proof to confirm). Filter by context_id (specific trace), event_type ' +
642
+ '(tool_call|transaction|observation|annotation|revision|directory_anchor or a full URI), content_id (specific tool on specific server), tool_name (disclosed ' +
643
+ 'name per §8.2), or args_hash (canonical-args commitment per §8.3). Filters are AND-combined; ' +
644
+ 'omit all of them for cross-trace history. Results are sorted newest-first. Pagination uses ' +
645
+ 'offset; new records appended between calls invalidate offset stability. See the ' +
646
+ 'pagination_caveat in the response. The filtered_out_by_verification field reports how many ' +
647
+ 'records were dropped due to signature failures (always 0 when include_unverified=true).',
648
+ inputSchema: {
649
+ context_id: z
650
+ .string()
651
+ .optional()
652
+ .describe('Optional trace identifier (32 hex chars). Limits results to records signed within this trace. ' +
653
+ 'Omit for cross-trace recall.'),
654
+ creator_key: z
655
+ .string()
656
+ .optional()
657
+ .describe('Optional exact match on record.creator_key (base64url-encoded Ed25519 public key, 43 chars). ' +
658
+ 'Filters the local mirror to records signed by this specific creator. Omit to see all signers ' +
659
+ "present in the mirror; the tool name says 'my attribution history' but the mirror may contain " +
660
+ 'records from other creators when multi-agent flows ship records into a shared mirror. Use ' +
661
+ 'your own creator_key (resolvable from the @atrib/cli key-show output, or `getPublicKey()` ' +
662
+ 'called on your seed) when you want to scope strictly to your own past.'),
663
+ event_type: EventTypeFilterSchema.optional().describe('Optional filter to a single event kind. Accepts atrib shorthand aliases ' +
664
+ '(tool_call, transaction, observation, annotation, revision, directory_anchor) ' +
665
+ 'or a full event_type URI. Most calls leave this unset.'),
666
+ content_id: z
667
+ .string()
668
+ .optional()
669
+ .describe('Optional exact match on record.content_id (sha256:<64-hex>). Per spec §1.2.2, content_id ' +
670
+ 'is sha256(serverUrl + ":" + toolName), so filtering groups all records emitted by the same ' +
671
+ 'tool on the same MCP server. Coarser than tool_name (different servers, same name -> ' +
672
+ 'different content_id).'),
673
+ tool_name: z
674
+ .string()
675
+ .optional()
676
+ .describe('Optional exact match on the §8.2 disclosed tool_name. Records that did NOT opt in to ' +
677
+ 'tool-name disclosure (the §8.1 default posture) carry no tool_name field and are excluded ' +
678
+ 'from results when this filter is set.'),
679
+ args_hash: z
680
+ .string()
681
+ .optional()
682
+ .describe('Optional exact match on record.args_hash (sha256:<64-hex>). Per spec §8.3, args_hash commits ' +
683
+ 'to canonical args bytes (salted or plain; both forms hash identically on the wire). Most ' +
684
+ 'useful for replay detection or agent-side keyed lookup over a normalized probe hash.'),
685
+ limit: z.number().optional().describe('Page size, default 10, max 200.'),
686
+ offset: z
687
+ .number()
688
+ .optional()
689
+ .describe('Pagination offset, default 0. Note: not stable when new records land between calls - see ' +
690
+ 'pagination_caveat in the response.'),
691
+ compact: z
692
+ .boolean()
693
+ .optional()
694
+ .describe('Default true. When true, omit signature/content_id/chain_root/spec_version fields. ' +
695
+ 'signature_verified is still included. Set to false (or use the equivalent verbose=true) ' +
696
+ 'when you need the full record bytes for re-verification or downstream processing.'),
697
+ include_unverified: z
698
+ .boolean()
699
+ .optional()
700
+ .describe('Default false. When false, records with signature_verified=false are dropped from the ' +
701
+ 'response (their count is reported in filtered_out_by_verification). Set to true to ' +
702
+ 'include them - useful when investigating tampered or partial mirror state.'),
703
+ // Layer 1 semantic filters. These are enforced in the handler below and
704
+ // share the same response metadata path as the base filters.
705
+ min_importance: z
706
+ .enum(['critical', 'high', 'medium', 'low', 'noise'])
707
+ .optional()
708
+ .describe('Filter to records whose maximum annotation importance is at least this level. Annotation ' +
709
+ 'importance comes from annotation records pointing at the record. Records with no ' +
710
+ 'annotations at all are excluded when this filter is set.'),
711
+ topic_tags: z
712
+ .array(z.string())
713
+ .optional()
714
+ .describe('OR-match against annotation topic tags. Records are kept if at least one annotation pointing ' +
715
+ 'at them carries at least one of the listed topics. Records with no annotations or no ' +
716
+ 'topic overlap are excluded.'),
717
+ include_revised: z
718
+ .boolean()
719
+ .optional()
720
+ .describe('Default false: revised records remain visible with superseded_by populated. Set true to hide ' +
721
+ 'records that have been superseded by a revision record (revises field equals this record).'),
722
+ min_signers: z
723
+ .number()
724
+ .optional()
725
+ .describe('Minimum count of distinct signers. Transaction records carry a signers[] array (cross- ' +
726
+ 'attestation); the count is its length. Non-transaction records have a single signature; ' +
727
+ 'their count is 1. Records below the threshold are excluded.'),
728
+ rank_by: z
729
+ .enum(['timestamp', 'relevance', 'causal_distance'])
730
+ .optional()
731
+ .describe('Result ordering. timestamp (default): newest first. relevance: Park et al. weighted-sum ' +
732
+ 'scoring over recency + annotation-derived importance + BM25 relevance against rank_anchor ' +
733
+ '(treated as a free-form query when not a record_hash; otherwise relevance component is 0). ' +
734
+ 'causal_distance: BFS shortest path in the local derived graph from rank_anchor (a record_hash). ' +
735
+ 'Records unreachable from the anchor sort to the end.'),
736
+ rank_anchor: z
737
+ .string()
738
+ .optional()
739
+ .describe('Anchor for non-timestamp rank_by modes. For rank_by=relevance: free-form text query for the ' +
740
+ 'BM25 component (matched against annotation summary + topics of each candidate). For ' +
741
+ 'rank_by=causal_distance: record_hash to BFS from (sha256:<64-hex>); falls back to timestamp ' +
742
+ 'newest-first when not a valid record_hash.'),
743
+ toc: z
744
+ .boolean()
745
+ .optional()
746
+ .describe('Default false. When true, each returned record is the table-of-contents entry shape ' +
747
+ '(record_hash, tool_name, summary, importance, topic_tags, timestamp, superseded_by) at ' +
748
+ '~40-80 tokens per entry. Designed for SessionStart auto-injected scaffold and any other ' +
749
+ 'cheap-to-scan candidate set the agent expands on demand via recall(content_id=...) or ' +
750
+ 'recall_walk.'),
751
+ },
752
+ }, async (args) => logReadPrimitiveCall('recall_my_attribution_history', args, async () => {
753
+ // All Layer 1 surface parameters are enforced by recall() above:
754
+ // min_importance, topic_tags, include_revised, min_signers,
755
+ // rank_by, rank_anchor, and toc.
756
+ const result = await recall(args);
804
757
  return {
805
- record_hash: step.record_hash,
806
- distance: step.distance,
807
- event_type: lr.record.event_type,
808
- timestamp: lr.record.timestamp,
809
- display_summary: synthesizeDisplaySummary(lr.record, lr.content, ann),
810
- display_producer: resolveDisplayProducer(lr.record, lr.producer),
811
- age: formatAge(lr.record.timestamp, now),
758
+ content: [
759
+ {
760
+ type: 'text',
761
+ text: JSON.stringify(result, null, 2),
762
+ },
763
+ ],
812
764
  };
813
- });
814
- return {
815
- content: [
816
- {
817
- type: 'text',
818
- text: JSON.stringify({
819
- from_record_hash: args.from_record_hash,
820
- edge_types: args.edge_types ?? [
821
- 'CHAIN_PRECEDES',
822
- 'INFORMED_BY',
823
- 'ANNOTATES',
824
- 'REVISES',
825
- ],
826
- depth,
827
- count: enriched.length,
828
- walk: enriched,
829
- }, null, 2),
830
- },
831
- ],
832
- };
833
- }, extractRecordHashFieldsFromMcpResult));
834
- server.registerTool('recall_annotations', {
835
- 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.",
836
- inputSchema: {
837
- record_hash: z
838
- .string()
839
- .describe('Record hash (sha256:<64-hex>) of the record whose annotations should be retrieved. Annotations are D058 records whose signed annotates field equals this hash.'),
840
- },
841
- }, async (args) => logReadPrimitiveCall('recall_annotations', args, async () => {
842
- const { loaded } = discoverLoaded();
843
- const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
844
- const summary = annotationsByRecord.get(args.record_hash) ?? null;
845
- return {
846
- content: [
847
- {
848
- type: 'text',
849
- text: JSON.stringify({ record_hash: args.record_hash, annotations: summary }, null, 2),
850
- },
851
- ],
852
- };
853
- }, extractRecordHashFieldsFromMcpResult));
854
- server.registerTool('recall_revisions', {
855
- description: "Return the D059 revision chain for a record, with per-entry content + sibling-fan-out awareness. Walks revises edges forward from the given record_hash, surfacing each revision in turn. Each entry carries the revision's record_hash, timestamp, and content (`new_position`, `reason`, `importance`) so the agent can read the chain inline without follow-up recall calls per entry. When more than one revision targets the same record, the chain follows the first-by-timestamp branch and lists the other branch heads as `sibling_hashes` on that entry so the agent learns about parallel revision branches (common in multi-agent flows). 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.",
856
- inputSchema: {
857
- record_hash: z
858
- .string()
859
- .describe('Record hash (sha256:<64-hex>) of the record whose revision chain should be retrieved. Revisions are D059 records whose signed revises field equals this hash (or chain back to it).'),
860
- },
861
- }, async (args) => logReadPrimitiveCall('recall_revisions', args, async () => {
862
- const { loaded } = discoverLoaded();
863
- const byHash = new Map();
864
- for (const lr of loaded)
865
- byHash.set(lr.record_hash, lr);
866
- const revisionsByRecord = aggregateRevisionsByRecord(loaded);
867
- const chain = [];
868
- const seen = new Set();
869
- let current = args.record_hash;
870
- while (!seen.has(current)) {
871
- seen.add(current);
872
- const next = revisionsByRecord.get(current);
873
- if (!next || next.length === 0)
874
- break;
875
- // Each entry in the map's value array is a revision pointing at
876
- // `current`. The chain follows the first-by-timestamp revision;
877
- // the remaining entries are surfaced as `sibling_hashes` so the
878
- // agent learns that branches exist without the chain shape
879
- // having to explode into a tree.
880
- const revHash = next[0];
881
- const siblings = next.slice(1);
882
- const revLr = byHash.get(revHash);
883
- const entry = { record_hash: revHash };
884
- if (revLr) {
885
- entry.timestamp = revLr.record.timestamp;
886
- const c = revLr.content;
887
- if (c && typeof c === 'object' && !Array.isArray(c)) {
888
- const cObj = c;
889
- if (typeof cObj.new_position === 'string')
890
- entry.new_position = cObj.new_position;
891
- if (typeof cObj.reason === 'string')
892
- entry.reason = cObj.reason;
893
- if (typeof cObj.importance === 'string')
894
- entry.importance = cObj.importance;
765
+ }, extractRecordHashFieldsFromMcpResult));
766
+ // ─── Layer 1 sibling tools ───
767
+ // Sibling tools expose common agent lookup shapes beyond the base
768
+ // filter-and-page tool.
769
+ server.registerTool('recall_walk', {
770
+ 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.',
771
+ inputSchema: {
772
+ from_record_hash: z
773
+ .string()
774
+ .describe('Starting record hash (sha256:<64-hex>). The walk begins here and expands through the local derived graph.'),
775
+ edge_types: z
776
+ .array(z.enum(['CHAIN_PRECEDES', 'INFORMED_BY', 'ANNOTATES', 'REVISES']))
777
+ .optional()
778
+ .describe('Optional list of Layer 1 edge types to follow. Default: all four. Unknown values are rejected by the schema.'),
779
+ depth: z
780
+ .number()
781
+ .optional()
782
+ .describe('Maximum hop count (NOT cumulative weight). Default 3. Higher values may return many records; paginate downstream if needed.'),
783
+ },
784
+ }, async (args) => logReadPrimitiveCall('recall_walk', args, async () => {
785
+ const { loaded } = discoverLoaded();
786
+ const graph = buildLocalGraph(loaded);
787
+ const edgeTypes = args.edge_types ? new Set(args.edge_types) : undefined;
788
+ const depth = typeof args.depth === 'number' ? args.depth : 3;
789
+ const walk = walkFrom(graph, args.from_record_hash, edgeTypes, depth);
790
+ // Layer 1 v2 legibility: join walked hashes back to their loaded
791
+ // records so each step in the walk carries a readable label
792
+ // instead of just a hash + distance. Builds the index once for
793
+ // O(N) lookup across the walk; for typical walks (depth=3, k<50)
794
+ // this is fast.
795
+ const byHash = new Map(loaded.map((lr) => [lr.record_hash, lr]));
796
+ const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
797
+ const now = Date.now();
798
+ const enriched = walk.map((step) => {
799
+ // walk derives from graph (built from `loaded`); byHash always has a hit.
800
+ const lr = byHash.get(step.record_hash);
801
+ const ann = annotationsByRecord.get(step.record_hash);
802
+ return {
803
+ record_hash: step.record_hash,
804
+ distance: step.distance,
805
+ event_type: lr.record.event_type,
806
+ timestamp: lr.record.timestamp,
807
+ display_summary: synthesizeDisplaySummary(lr.record, lr.content, ann),
808
+ display_producer: resolveDisplayProducer(lr.record, lr.producer),
809
+ age: formatAge(lr.record.timestamp, now),
810
+ };
811
+ });
812
+ return {
813
+ content: [
814
+ {
815
+ type: 'text',
816
+ text: JSON.stringify({
817
+ from_record_hash: args.from_record_hash,
818
+ edge_types: args.edge_types ?? [
819
+ 'CHAIN_PRECEDES',
820
+ 'INFORMED_BY',
821
+ 'ANNOTATES',
822
+ 'REVISES',
823
+ ],
824
+ depth,
825
+ count: enriched.length,
826
+ walk: enriched,
827
+ }, null, 2),
828
+ },
829
+ ],
830
+ };
831
+ }, extractRecordHashFieldsFromMcpResult));
832
+ server.registerTool('recall_annotations', {
833
+ 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.",
834
+ inputSchema: {
835
+ record_hash: z
836
+ .string()
837
+ .describe('Record hash (sha256:<64-hex>) of the record whose annotations should be retrieved. Annotations are D058 records whose signed annotates field equals this hash.'),
838
+ },
839
+ }, async (args) => logReadPrimitiveCall('recall_annotations', args, async () => {
840
+ const { loaded } = discoverLoaded();
841
+ const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
842
+ const summary = annotationsByRecord.get(args.record_hash) ?? null;
843
+ return {
844
+ content: [
845
+ {
846
+ type: 'text',
847
+ text: JSON.stringify({ record_hash: args.record_hash, annotations: summary }, null, 2),
848
+ },
849
+ ],
850
+ };
851
+ }, extractRecordHashFieldsFromMcpResult));
852
+ server.registerTool('recall_revisions', {
853
+ description: "Return the D059 revision chain for a record, with per-entry content + sibling-fan-out awareness. Walks revises edges forward from the given record_hash, surfacing each revision in turn. Each entry carries the revision's record_hash, timestamp, and content (`new_position`, `reason`, `importance`) so the agent can read the chain inline without follow-up recall calls per entry. When more than one revision targets the same record, the chain follows the first-by-timestamp branch and lists the other branch heads as `sibling_hashes` on that entry so the agent learns about parallel revision branches (common in multi-agent flows). 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.",
854
+ inputSchema: {
855
+ record_hash: z
856
+ .string()
857
+ .describe('Record hash (sha256:<64-hex>) of the record whose revision chain should be retrieved. Revisions are D059 records whose signed revises field equals this hash (or chain back to it).'),
858
+ },
859
+ }, async (args) => logReadPrimitiveCall('recall_revisions', args, async () => {
860
+ const { loaded } = discoverLoaded();
861
+ const byHash = new Map();
862
+ for (const lr of loaded)
863
+ byHash.set(lr.record_hash, lr);
864
+ const revisionsByRecord = aggregateRevisionsByRecord(loaded);
865
+ const chain = [];
866
+ const seen = new Set();
867
+ let current = args.record_hash;
868
+ while (!seen.has(current)) {
869
+ seen.add(current);
870
+ const next = revisionsByRecord.get(current);
871
+ if (!next || next.length === 0)
872
+ break;
873
+ // Each entry in the map's value array is a revision pointing at
874
+ // `current`. The chain follows the first-by-timestamp revision;
875
+ // the remaining entries are surfaced as `sibling_hashes` so the
876
+ // agent learns that branches exist without the chain shape
877
+ // having to explode into a tree.
878
+ const revHash = next[0];
879
+ const siblings = next.slice(1);
880
+ const revLr = byHash.get(revHash);
881
+ const entry = { record_hash: revHash };
882
+ if (revLr) {
883
+ entry.timestamp = revLr.record.timestamp;
884
+ const c = revLr.content;
885
+ if (c && typeof c === 'object' && !Array.isArray(c)) {
886
+ const cObj = c;
887
+ if (typeof cObj.new_position === 'string')
888
+ entry.new_position = cObj.new_position;
889
+ if (typeof cObj.reason === 'string')
890
+ entry.reason = cObj.reason;
891
+ if (typeof cObj.importance === 'string')
892
+ entry.importance = cObj.importance;
893
+ }
894
+ }
895
+ if (siblings.length > 0) {
896
+ entry.sibling_hashes = siblings;
895
897
  }
898
+ chain.push(entry);
899
+ current = revHash;
896
900
  }
897
- if (siblings.length > 0) {
898
- entry.sibling_hashes = siblings;
901
+ return {
902
+ content: [
903
+ {
904
+ type: 'text',
905
+ text: JSON.stringify({ record_hash: args.record_hash, revision_chain: chain }, null, 2),
906
+ },
907
+ ],
908
+ };
909
+ }, extractRecordHashFieldsFromMcpResult));
910
+ server.registerTool('recall_by_content', {
911
+ description: "Free-form text search over the agent's signed past. Returns top-k records by hybrid retrieval: BM25 over each record's per-event_type indexable text (observation `what + why_noted + intent + rationale + topics`; tool_call `tool_name + args + result`; annotation `summary + topics`; revision `prior_position + new_position + reason + topics`; transaction counterparty + memo; directory_anchor tree_root; extension URIs via generic recursive string-walk per D086/D118) plus the annotation summary + topics when present as a curator-quality lift. Reranked by Park et al. weighted-sum scoring with annotation-derived importance and recency signals; BM25 contribution clamped to [0, 1] so the documented Park-component bound is honored. 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?'.",
912
+ inputSchema: {
913
+ query: z
914
+ .string()
915
+ .describe("Free-form text query. Matches against each record's per-event_type content plus annotation summary and topics via BM25. Records with no indexable text contribute no relevance signal and only surface through the recency or importance fallback."),
916
+ k: z
917
+ .number()
918
+ .optional()
919
+ .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.'),
920
+ },
921
+ }, async (args) => logReadPrimitiveCall('recall_by_content', args, async () => {
922
+ const { loaded } = discoverLoaded();
923
+ const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
924
+ const queryTokens = tokenize(args.query);
925
+ const corpus = loaded.map((lr) => ({
926
+ id: lr.record_hash,
927
+ tokens: indexableTokensForRecord(lr, annotationsByRecord.get(lr.record_hash)),
928
+ }));
929
+ const idx = buildBM25Index(corpus);
930
+ const now = Date.now();
931
+ const scored = loaded.map((lr) => {
932
+ const r = recencyScore(lr.record.timestamp, now, ATRIB_RECALL_TAU_DAYS);
933
+ const i = importanceScore(annotationsByRecord.get(lr.record_hash));
934
+ const rel = queryTokens.length > 0 ? bm25Score(idx, lr.record_hash, queryTokens) : 0;
935
+ const score = parkScore(r, i, rel, ATRIB_RECALL_ALPHA, ATRIB_RECALL_BETA, ATRIB_RECALL_GAMMA);
936
+ return { lr, score, recency: r, importance: i, relevance: rel };
937
+ });
938
+ scored.sort((a, b) => {
939
+ if (b.score !== a.score)
940
+ return b.score - a.score;
941
+ return b.lr.record.timestamp - a.lr.record.timestamp;
942
+ });
943
+ const k = Math.max(1, Math.min(50, args.k ?? 10));
944
+ const top = scored.slice(0, k);
945
+ return {
946
+ content: [
947
+ {
948
+ type: 'text',
949
+ text: JSON.stringify({
950
+ query: args.query,
951
+ k,
952
+ count: top.length,
953
+ results: top.map(({ lr, score, recency, importance, relevance }) => {
954
+ const ann = annotationsByRecord.get(lr.record_hash);
955
+ return {
956
+ record_hash: lr.record_hash,
957
+ event_type: lr.record.event_type,
958
+ context_id: lr.record.context_id,
959
+ timestamp: lr.record.timestamp,
960
+ tool_name: lr.record.tool_name,
961
+ annotations: ann,
962
+ // Layer 1 v2 legibility fields (parity with compactify).
963
+ display_summary: synthesizeDisplaySummary(lr.record, lr.content, ann),
964
+ display_producer: resolveDisplayProducer(lr.record, lr.producer),
965
+ age: formatAge(lr.record.timestamp, now),
966
+ score,
967
+ components: { recency, importance, relevance },
968
+ };
969
+ }),
970
+ }, null, 2),
971
+ },
972
+ ],
973
+ };
974
+ }, extractRecordHashFieldsFromMcpResult));
975
+ // recall_session_chain: records in a context_id, ordered chronologically
976
+ // (the natural traversal of the CHAIN_PRECEDES topology). Doable via
977
+ // recall_my_attribution_history({context_id}) + a manual timestamp sort,
978
+ // but agents naturally think "what happened in this session?" and this
979
+ // gives them one call.
980
+ server.registerTool('recall_session_chain', {
981
+ description: "Return all records in a context_id, ordered chronologically (oldest-first). The natural traversal of the CHAIN_PRECEDES topology for a single session/trace. Each entry carries record_hash, event_type, timestamp, display_summary (per-event_type human-readable text from D086), and producer label. Useful for 'what happened in this session?' without manually sorting filter results. Sibling tool to recall_my_attribution_history with built-in context_id scoping + chain-ascending order.",
982
+ inputSchema: {
983
+ context_id: z
984
+ .string()
985
+ .optional()
986
+ .describe("32-hex context_id. When omitted, falls back to @atrib/mcp's resolveEnvContextId (ATRIB_CONTEXT_ID env or D083-registered harness env like CLAUDE_CODE_SESSION_ID)."),
987
+ limit: z
988
+ .number()
989
+ .optional()
990
+ .describe("Maximum records to return (default 50, max 500). Truncated from the END of the chain (oldest-first ordering preserves the chain's start)."),
991
+ include_content: z
992
+ .boolean()
993
+ .optional()
994
+ .describe('When true, include D062 local mirror body as local_content on each returned record. Defaults false to keep the session chain cheap.'),
995
+ },
996
+ }, async (args) => logReadPrimitiveCall('recall_session_chain', args, async () => {
997
+ const ctx = args.context_id ?? resolveEnvContextId();
998
+ if (!ctx) {
999
+ return {
1000
+ content: [
1001
+ {
1002
+ type: 'text',
1003
+ text: JSON.stringify({
1004
+ context_id: null,
1005
+ count: 0,
1006
+ records: [],
1007
+ warning: 'no context_id supplied or resolvable via env',
1008
+ }, null, 2),
1009
+ },
1010
+ ],
1011
+ };
899
1012
  }
900
- chain.push(entry);
901
- current = revHash;
902
- }
903
- return {
904
- content: [
905
- {
906
- type: 'text',
907
- text: JSON.stringify({ record_hash: args.record_hash, revision_chain: chain }, null, 2),
908
- },
909
- ],
910
- };
911
- }, extractRecordHashFieldsFromMcpResult));
912
- server.registerTool('recall_by_content', {
913
- description: "Free-form text search over the agent's signed past. Returns top-k records by hybrid retrieval: BM25 over each record's per-event_type indexable text (observation `what + why_noted + topics`; tool_call `tool_name + args + result`; annotation `summary + topics`; revision `prior_position + new_position + reason + topics`; transaction counterparty + memo; directory_anchor tree_root; extension URIs via generic recursive string-walk per D086) plus the annotation summary + topics when present as a curator-quality lift. Reranked by Park et al. weighted-sum scoring with annotation-derived importance and recency signals; BM25 contribution clamped to [0, 1] so the documented Park-component bound is honored. 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?'.",
914
- inputSchema: {
915
- query: z
916
- .string()
917
- .describe("Free-form text query. Matches against each record's per-event_type content plus annotation summary and topics via BM25. Records with no indexable text contribute no relevance signal and only surface through the recency or importance fallback."),
918
- k: z
919
- .number()
920
- .optional()
921
- .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.'),
922
- },
923
- }, async (args) => logReadPrimitiveCall('recall_by_content', args, async () => {
924
- const { loaded } = discoverLoaded();
925
- const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
926
- const queryTokens = tokenize(args.query);
927
- const corpus = loaded.map((lr) => ({
928
- id: lr.record_hash,
929
- tokens: indexableTokensForRecord(lr, annotationsByRecord.get(lr.record_hash)),
930
- }));
931
- const idx = buildBM25Index(corpus);
932
- const now = Date.now();
933
- const scored = loaded.map((lr) => {
934
- const r = recencyScore(lr.record.timestamp, now, ATRIB_RECALL_TAU_DAYS);
935
- const i = importanceScore(annotationsByRecord.get(lr.record_hash));
936
- const rel = queryTokens.length > 0 ? bm25Score(idx, lr.record_hash, queryTokens) : 0;
937
- const score = parkScore(r, i, rel, ATRIB_RECALL_ALPHA, ATRIB_RECALL_BETA, ATRIB_RECALL_GAMMA);
938
- return { lr, score, recency: r, importance: i, relevance: rel };
939
- });
940
- scored.sort((a, b) => {
941
- if (b.score !== a.score)
942
- return b.score - a.score;
943
- return b.lr.record.timestamp - a.lr.record.timestamp;
944
- });
945
- const k = Math.max(1, Math.min(50, args.k ?? 10));
946
- const top = scored.slice(0, k);
947
- return {
948
- content: [
949
- {
950
- type: 'text',
951
- text: JSON.stringify({
952
- query: args.query,
953
- k,
954
- count: top.length,
955
- results: top.map(({ lr, score, recency, importance, relevance }) => {
956
- const ann = annotationsByRecord.get(lr.record_hash);
957
- return {
958
- record_hash: lr.record_hash,
959
- event_type: lr.record.event_type,
960
- context_id: lr.record.context_id,
961
- timestamp: lr.record.timestamp,
962
- tool_name: lr.record.tool_name,
963
- annotations: ann,
964
- // Layer 1 v2 legibility fields (parity with compactify).
965
- display_summary: synthesizeDisplaySummary(lr.record, lr.content, ann),
966
- display_producer: resolveDisplayProducer(lr.record, lr.producer),
967
- age: formatAge(lr.record.timestamp, now),
968
- score,
969
- components: { recency, importance, relevance },
970
- };
971
- }),
972
- }, null, 2),
973
- },
974
- ],
975
- };
976
- }, extractRecordHashFieldsFromMcpResult));
977
- // recall_session_chain: records in a context_id, ordered chronologically
978
- // (the natural traversal of the CHAIN_PRECEDES topology). Doable via
979
- // recall_my_attribution_history({context_id}) + a manual timestamp sort,
980
- // but agents naturally think "what happened in this session?" and this
981
- // gives them one call.
982
- server.registerTool('recall_session_chain', {
983
- description: "Return all records in a context_id, ordered chronologically (oldest-first). The natural traversal of the CHAIN_PRECEDES topology for a single session/trace. Each entry carries record_hash, event_type, timestamp, display_summary (per-event_type human-readable text from D086), and producer label. Useful for 'what happened in this session?' without manually sorting filter results. Sibling tool to recall_my_attribution_history with built-in context_id scoping + chain-ascending order.",
984
- inputSchema: {
985
- context_id: z
986
- .string()
987
- .optional()
988
- .describe("32-hex context_id. When omitted, falls back to @atrib/mcp's resolveEnvContextId (ATRIB_CONTEXT_ID env or D083-registered harness env like CLAUDE_CODE_SESSION_ID)."),
989
- limit: z
990
- .number()
991
- .optional()
992
- .describe("Maximum records to return (default 50, max 500). Truncated from the END of the chain (oldest-first ordering preserves the chain's start)."),
993
- include_content: z
994
- .boolean()
995
- .optional()
996
- .describe('When true, include D062 local mirror body as local_content on each returned record. Defaults false to keep the session chain cheap.'),
997
- },
998
- }, async (args) => logReadPrimitiveCall('recall_session_chain', args, async () => {
999
- const ctx = args.context_id ?? resolveEnvContextId();
1000
- if (!ctx) {
1013
+ const { loaded } = discoverLoaded();
1014
+ const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
1015
+ const filtered = loaded
1016
+ .filter((lr) => lr.record.context_id === ctx)
1017
+ .sort((a, b) => a.record.timestamp - b.record.timestamp);
1018
+ const limit = Math.max(1, Math.min(500, args.limit ?? 50));
1019
+ const sliced = filtered.slice(0, limit);
1020
+ const now = Date.now();
1001
1021
  return {
1002
1022
  content: [
1003
1023
  {
1004
1024
  type: 'text',
1005
1025
  text: JSON.stringify({
1006
- context_id: null,
1007
- count: 0,
1008
- records: [],
1009
- warning: 'no context_id supplied or resolvable via env',
1026
+ context_id: ctx,
1027
+ total: filtered.length,
1028
+ returned: sliced.length,
1029
+ truncated: filtered.length > sliced.length,
1030
+ records: sliced.map((lr) => {
1031
+ const ann = annotationsByRecord.get(lr.record_hash);
1032
+ const entry = {
1033
+ record_hash: lr.record_hash,
1034
+ event_type: lr.record.event_type,
1035
+ timestamp: lr.record.timestamp,
1036
+ display_summary: synthesizeDisplaySummary(lr.record, lr.content, ann),
1037
+ display_producer: resolveDisplayProducer(lr.record, lr.producer),
1038
+ age: formatAge(lr.record.timestamp, now),
1039
+ };
1040
+ const informedBy = lr.record
1041
+ .informed_by;
1042
+ const toolName = lr.record.tool_name;
1043
+ const argsHash = lr.record.args_hash;
1044
+ const resultHash = lr.record
1045
+ .result_hash;
1046
+ if (Array.isArray(informedBy) && informedBy.length > 0) {
1047
+ entry.informed_by = informedBy;
1048
+ }
1049
+ if (toolName)
1050
+ entry.tool_name = toolName;
1051
+ if (argsHash)
1052
+ entry.args_hash = argsHash;
1053
+ if (resultHash)
1054
+ entry.result_hash = resultHash;
1055
+ if (args.include_content === true && lr.content !== undefined) {
1056
+ entry.local_content = lr.content;
1057
+ }
1058
+ if (args.include_content === true && lr.producer !== undefined) {
1059
+ entry.local_producer = lr.producer;
1060
+ }
1061
+ return entry;
1062
+ }),
1010
1063
  }, null, 2),
1011
1064
  },
1012
1065
  ],
1013
1066
  };
1014
- }
1015
- const { loaded } = discoverLoaded();
1016
- const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
1017
- const filtered = loaded
1018
- .filter((lr) => lr.record.context_id === ctx)
1019
- .sort((a, b) => a.record.timestamp - b.record.timestamp);
1020
- const limit = Math.max(1, Math.min(500, args.limit ?? 50));
1021
- const sliced = filtered.slice(0, limit);
1022
- const now = Date.now();
1023
- return {
1024
- content: [
1025
- {
1026
- type: 'text',
1027
- text: JSON.stringify({
1028
- context_id: ctx,
1029
- total: filtered.length,
1030
- returned: sliced.length,
1031
- truncated: filtered.length > sliced.length,
1032
- records: sliced.map((lr) => {
1033
- const ann = annotationsByRecord.get(lr.record_hash);
1034
- const entry = {
1035
- record_hash: lr.record_hash,
1036
- event_type: lr.record.event_type,
1037
- timestamp: lr.record.timestamp,
1038
- display_summary: synthesizeDisplaySummary(lr.record, lr.content, ann),
1039
- display_producer: resolveDisplayProducer(lr.record, lr.producer),
1040
- age: formatAge(lr.record.timestamp, now),
1041
- };
1042
- const informedBy = lr.record
1043
- .informed_by;
1044
- const toolName = lr.record.tool_name;
1045
- const argsHash = lr.record.args_hash;
1046
- const resultHash = lr.record
1047
- .result_hash;
1048
- if (Array.isArray(informedBy) && informedBy.length > 0) {
1049
- entry.informed_by = informedBy;
1050
- }
1051
- if (toolName)
1052
- entry.tool_name = toolName;
1053
- if (argsHash)
1054
- entry.args_hash = argsHash;
1055
- if (resultHash)
1056
- entry.result_hash = resultHash;
1057
- if (args.include_content === true && lr.content !== undefined) {
1058
- entry.local_content = lr.content;
1059
- }
1060
- if (args.include_content === true && lr.producer !== undefined) {
1061
- entry.local_producer = lr.producer;
1062
- }
1063
- return entry;
1064
- }),
1065
- }, null, 2),
1066
- },
1067
- ],
1068
- };
1069
- }, extractRecordHashFieldsFromMcpResult));
1070
- // recall_orphans: records nothing else cites via informed_by. Useful for
1071
- // "what decisions did I make and never act on?" — loose-end discovery.
1072
- server.registerTool('recall_orphans', {
1073
- description: "Return records that are NOT cited by any other record via informed_by (loose ends — decisions or observations the agent made but never followed up on). Surfaces records whose record_hash does NOT appear in any other record's informed_by[] array within the local mirror. Optionally scoped to one context_id, one event_type, or one creator_key. Useful for the agent to discover dropped balls (e.g. 'I noted X but never built on it').",
1074
- inputSchema: {
1075
- context_id: z
1076
- .string()
1077
- .optional()
1078
- .describe('Optional 32-hex context_id to scope orphan-discovery to one session/trace.'),
1079
- event_type: EventTypeFilterSchema.optional().describe("Optional filter to one event_type alias or full URI. Most useful with 'observation' to find unfollowed noting/discovery events."),
1080
- creator_key: z
1081
- .string()
1082
- .optional()
1083
- .describe('Optional exact match on record.creator_key (base64url). Filters orphan-discovery to records signed by one creator.'),
1084
- limit: z
1085
- .number()
1086
- .optional()
1087
- .describe('Maximum records to return (default 50, max 500), newest-first.'),
1088
- },
1089
- }, async (args) => logReadPrimitiveCall('recall_orphans', args, async () => {
1090
- const { loaded } = discoverLoaded();
1091
- // Build the set of all record_hashes that appear in any record's
1092
- // informed_by field. Anything in `loaded` whose record_hash is
1093
- // NOT in this set is an orphan.
1094
- const cited = new Set();
1095
- for (const lr of loaded) {
1096
- const ib = lr.record.informed_by;
1097
- if (Array.isArray(ib)) {
1098
- for (const h of ib)
1099
- if (typeof h === 'string')
1100
- cited.add(h);
1067
+ }, extractRecordHashFieldsFromMcpResult));
1068
+ // recall_orphans: records nothing else cites via informed_by. Useful for
1069
+ // "what decisions did I make and never act on?": loose-end discovery.
1070
+ server.registerTool('recall_orphans', {
1071
+ description: "Return records that are NOT cited by any other record via informed_by (loose ends: decisions or observations the agent made but never followed up on). Surfaces records whose record_hash does NOT appear in any other record's informed_by[] array within the local mirror. Optionally scoped to one context_id, one event_type, or one creator_key. Useful for the agent to discover dropped balls (e.g. 'I noted X but never built on it').",
1072
+ inputSchema: {
1073
+ context_id: z
1074
+ .string()
1075
+ .optional()
1076
+ .describe('Optional 32-hex context_id to scope orphan-discovery to one session/trace.'),
1077
+ event_type: EventTypeFilterSchema.optional().describe("Optional filter to one event_type alias or full URI. Most useful with 'observation' to find unfollowed noting/discovery events."),
1078
+ creator_key: z
1079
+ .string()
1080
+ .optional()
1081
+ .describe('Optional exact match on record.creator_key (base64url). Filters orphan-discovery to records signed by one creator.'),
1082
+ limit: z
1083
+ .number()
1084
+ .optional()
1085
+ .describe('Maximum records to return (default 50, max 500), newest-first.'),
1086
+ },
1087
+ }, async (args) => logReadPrimitiveCall('recall_orphans', args, async () => {
1088
+ const { loaded } = discoverLoaded();
1089
+ // Build the set of all record_hashes that appear in any record's
1090
+ // informed_by field. Anything in `loaded` whose record_hash is
1091
+ // NOT in this set is an orphan.
1092
+ const cited = new Set();
1093
+ for (const lr of loaded) {
1094
+ const ib = lr.record.informed_by;
1095
+ if (Array.isArray(ib)) {
1096
+ for (const h of ib)
1097
+ if (typeof h === 'string')
1098
+ cited.add(h);
1099
+ }
1101
1100
  }
1102
- }
1103
- let orphans = loaded.filter((lr) => !cited.has(lr.record_hash));
1104
- if (args.context_id) {
1105
- orphans = orphans.filter((lr) => lr.record.context_id === args.context_id);
1106
- }
1107
- if (args.creator_key) {
1108
- orphans = orphans.filter((lr) => lr.record.creator_key === args.creator_key);
1109
- }
1110
- if (args.event_type) {
1111
- const targetUri = normalizeEventType(args.event_type);
1112
- orphans = orphans.filter((lr) => lr.record.event_type === targetUri);
1113
- }
1114
- orphans.sort((a, b) => b.record.timestamp - a.record.timestamp);
1115
- const limit = Math.max(1, Math.min(500, args.limit ?? 50));
1116
- const sliced = orphans.slice(0, limit);
1117
- const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
1118
- const now = Date.now();
1119
- return {
1120
- content: [
1121
- {
1122
- type: 'text',
1123
- text: JSON.stringify({
1124
- total: orphans.length,
1125
- returned: sliced.length,
1126
- truncated: orphans.length > sliced.length,
1127
- records: sliced.map((lr) => {
1128
- const ann = annotationsByRecord.get(lr.record_hash);
1129
- return {
1130
- record_hash: lr.record_hash,
1131
- event_type: lr.record.event_type,
1132
- context_id: lr.record.context_id,
1133
- timestamp: lr.record.timestamp,
1134
- display_summary: synthesizeDisplaySummary(lr.record, lr.content, ann),
1135
- display_producer: resolveDisplayProducer(lr.record, lr.producer),
1136
- age: formatAge(lr.record.timestamp, now),
1137
- };
1138
- }),
1139
- }, null, 2),
1140
- },
1141
- ],
1142
- };
1143
- }, extractRecordHashFieldsFromMcpResult));
1144
- // recall_by_signer: aggregate the mirror by creator_key. Lets the agent
1145
- // ask "who else is in this mirror?" before deciding whether to scope
1146
- // queries with creator_key filters. Useful when the mirror is shared
1147
- // across multi-agent flows (transactions with counterparty signers,
1148
- // peer-shared records, etc.).
1149
- server.registerTool('recall_by_signer', {
1150
- description: "Aggregate the local mirror by creator_key. Returns the distinct creators present + per-creator record count + latest record timestamp. Useful when the mirror is multi-signer (multi-agent flows, transactions with counterparty signers) and the agent wants to discover who else's records are in scope before deciding whether to filter with creator_key. Pure aggregation; no records returned directly — use recall_my_attribution_history with the creator_key filter to drill into one creator's records.",
1151
- inputSchema: {
1152
- min_records: z
1153
- .number()
1154
- .optional()
1155
- .describe('Optional minimum record count to include a creator in the result. Default 1 (include all).'),
1156
- },
1157
- }, async (args) => logReadPrimitiveCall('recall_by_signer', args, async () => {
1158
- const { loaded } = discoverLoaded();
1159
- const byKey = new Map();
1160
- for (const lr of loaded) {
1161
- const key = lr.record.creator_key;
1162
- const existing = byKey.get(key);
1163
- if (existing) {
1164
- existing.count++;
1165
- if (lr.record.timestamp > existing.latest_timestamp)
1166
- existing.latest_timestamp = lr.record.timestamp;
1167
- if (lr.record.timestamp < existing.earliest_timestamp)
1168
- existing.earliest_timestamp = lr.record.timestamp;
1101
+ let orphans = loaded.filter((lr) => !cited.has(lr.record_hash));
1102
+ if (args.context_id) {
1103
+ orphans = orphans.filter((lr) => lr.record.context_id === args.context_id);
1169
1104
  }
1170
- else {
1171
- byKey.set(key, {
1172
- creator_key: key,
1173
- count: 1,
1174
- latest_timestamp: lr.record.timestamp,
1175
- earliest_timestamp: lr.record.timestamp,
1176
- });
1105
+ if (args.creator_key) {
1106
+ orphans = orphans.filter((lr) => lr.record.creator_key === args.creator_key);
1177
1107
  }
1178
- }
1179
- const minRecords = Math.max(1, args.min_records ?? 1);
1180
- const stats = [...byKey.values()]
1181
- .filter((s) => s.count >= minRecords)
1182
- .sort((a, b) => b.count - a.count);
1183
- return {
1184
- content: [
1185
- {
1186
- type: 'text',
1187
- text: JSON.stringify({
1188
- total_signers: stats.length,
1189
- total_records: loaded.length,
1190
- signers: stats,
1191
- }, null, 2),
1192
- },
1193
- ],
1194
- };
1195
- }, extractRecordHashFieldsFromMcpResult));
1196
- const transport = new StdioServerTransport();
1197
- await server.connect(transport);
1108
+ if (args.event_type) {
1109
+ const targetUri = normalizeEventType(args.event_type);
1110
+ orphans = orphans.filter((lr) => lr.record.event_type === targetUri);
1111
+ }
1112
+ orphans.sort((a, b) => b.record.timestamp - a.record.timestamp);
1113
+ const limit = Math.max(1, Math.min(500, args.limit ?? 50));
1114
+ const sliced = orphans.slice(0, limit);
1115
+ const annotationsByRecord = aggregateAnnotationsByRecord(loaded);
1116
+ const now = Date.now();
1117
+ return {
1118
+ content: [
1119
+ {
1120
+ type: 'text',
1121
+ text: JSON.stringify({
1122
+ total: orphans.length,
1123
+ returned: sliced.length,
1124
+ truncated: orphans.length > sliced.length,
1125
+ records: sliced.map((lr) => {
1126
+ const ann = annotationsByRecord.get(lr.record_hash);
1127
+ return {
1128
+ record_hash: lr.record_hash,
1129
+ event_type: lr.record.event_type,
1130
+ context_id: lr.record.context_id,
1131
+ timestamp: lr.record.timestamp,
1132
+ display_summary: synthesizeDisplaySummary(lr.record, lr.content, ann),
1133
+ display_producer: resolveDisplayProducer(lr.record, lr.producer),
1134
+ age: formatAge(lr.record.timestamp, now),
1135
+ };
1136
+ }),
1137
+ }, null, 2),
1138
+ },
1139
+ ],
1140
+ };
1141
+ }, extractRecordHashFieldsFromMcpResult));
1142
+ // recall_by_signer: aggregate the mirror by creator_key. Lets the agent
1143
+ // ask "who else is in this mirror?" before deciding whether to scope
1144
+ // queries with creator_key filters. Useful when the mirror is shared
1145
+ // across multi-agent flows (transactions with counterparty signers,
1146
+ // peer-shared records, etc.).
1147
+ server.registerTool('recall_by_signer', {
1148
+ description: "Aggregate the local mirror by creator_key. Returns the distinct creators present + per-creator record count + latest record timestamp. Useful when the mirror is multi-signer (multi-agent flows, transactions with counterparty signers) and the agent wants to discover who else's records are in scope before deciding whether to filter with creator_key. Pure aggregation; no records returned directly. Use recall_my_attribution_history with the creator_key filter to drill into one creator's records.",
1149
+ inputSchema: {
1150
+ min_records: z
1151
+ .number()
1152
+ .optional()
1153
+ .describe('Optional minimum record count to include a creator in the result. Default 1 (include all).'),
1154
+ },
1155
+ }, async (args) => logReadPrimitiveCall('recall_by_signer', args, async () => {
1156
+ const { loaded } = discoverLoaded();
1157
+ const byKey = new Map();
1158
+ for (const lr of loaded) {
1159
+ const key = lr.record.creator_key;
1160
+ const existing = byKey.get(key);
1161
+ if (existing) {
1162
+ existing.count++;
1163
+ if (lr.record.timestamp > existing.latest_timestamp)
1164
+ existing.latest_timestamp = lr.record.timestamp;
1165
+ if (lr.record.timestamp < existing.earliest_timestamp)
1166
+ existing.earliest_timestamp = lr.record.timestamp;
1167
+ }
1168
+ else {
1169
+ byKey.set(key, {
1170
+ creator_key: key,
1171
+ count: 1,
1172
+ latest_timestamp: lr.record.timestamp,
1173
+ earliest_timestamp: lr.record.timestamp,
1174
+ });
1175
+ }
1176
+ }
1177
+ const minRecords = Math.max(1, args.min_records ?? 1);
1178
+ const stats = [...byKey.values()]
1179
+ .filter((s) => s.count >= minRecords)
1180
+ .sort((a, b) => b.count - a.count);
1181
+ return {
1182
+ content: [
1183
+ {
1184
+ type: 'text',
1185
+ text: JSON.stringify({
1186
+ total_signers: stats.length,
1187
+ total_records: loaded.length,
1188
+ signers: stats,
1189
+ }, null, 2),
1190
+ },
1191
+ ],
1192
+ };
1193
+ }, extractRecordHashFieldsFromMcpResult));
1194
+ }
1195
+ export function createAtribRecallServer() {
1196
+ const mcp = new McpServer({
1197
+ name: 'atrib-recall',
1198
+ version: readPackageVersion(),
1199
+ });
1200
+ registerAtribRecallTools(mcp);
1201
+ return { mcp };
1202
+ }
1203
+ async function main() {
1204
+ const { mcp } = createAtribRecallServer();
1205
+ const transport = new StdioServerTransport();
1206
+ await mcp.connect(transport);
1207
+ }
1208
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1209
+ await main();
1210
+ }
1198
1211
  //# sourceMappingURL=index.js.map