r2mcp 0.2.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.
Files changed (138) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/LICENSE +21 -0
  3. package/README.md +532 -0
  4. package/dist/breadcrumbs.d.ts +123 -0
  5. package/dist/breadcrumbs.js +135 -0
  6. package/dist/cli/classify-edges.d.ts +2 -0
  7. package/dist/cli/classify-edges.js +130 -0
  8. package/dist/cli/compile-wiki.d.ts +2 -0
  9. package/dist/cli/compile-wiki.js +173 -0
  10. package/dist/cli/dump-edges-json.d.ts +2 -0
  11. package/dist/cli/dump-edges-json.js +21 -0
  12. package/dist/cli/extract-entities.d.ts +17 -0
  13. package/dist/cli/extract-entities.js +166 -0
  14. package/dist/cli/lint-memory.d.ts +16 -0
  15. package/dist/cli/lint-memory.js +94 -0
  16. package/dist/cli/migrate.d.ts +17 -0
  17. package/dist/cli/migrate.js +146 -0
  18. package/dist/cli/setup-helpers.d.ts +7 -0
  19. package/dist/cli/setup-helpers.js +72 -0
  20. package/dist/cli/setup.d.ts +15 -0
  21. package/dist/cli/setup.js +95 -0
  22. package/dist/compiler/clustering.d.ts +29 -0
  23. package/dist/compiler/clustering.js +66 -0
  24. package/dist/compiler/frontmatter.d.ts +35 -0
  25. package/dist/compiler/frontmatter.js +168 -0
  26. package/dist/compiler/manifest.d.ts +32 -0
  27. package/dist/compiler/manifest.js +82 -0
  28. package/dist/compiler/prompts.d.ts +17 -0
  29. package/dist/compiler/prompts.js +82 -0
  30. package/dist/compiler/run.d.ts +52 -0
  31. package/dist/compiler/run.js +186 -0
  32. package/dist/compiler/tier.d.ts +10 -0
  33. package/dist/compiler/tier.js +85 -0
  34. package/dist/compiler/topic.d.ts +16 -0
  35. package/dist/compiler/topic.js +105 -0
  36. package/dist/compiler/types.d.ts +101 -0
  37. package/dist/compiler/types.js +4 -0
  38. package/dist/db.d.ts +10 -0
  39. package/dist/db.js +46 -0
  40. package/dist/edges/candidate-pairs.d.ts +24 -0
  41. package/dist/edges/candidate-pairs.js +35 -0
  42. package/dist/edges/classifier.d.ts +45 -0
  43. package/dist/edges/classifier.js +172 -0
  44. package/dist/edges/signals.d.ts +13 -0
  45. package/dist/edges/signals.js +45 -0
  46. package/dist/edges/stage1-haiku.d.ts +21 -0
  47. package/dist/edges/stage1-haiku.js +33 -0
  48. package/dist/edges/stage2-opus.d.ts +41 -0
  49. package/dist/edges/stage2-opus.js +101 -0
  50. package/dist/edges/state.d.ts +44 -0
  51. package/dist/edges/state.js +79 -0
  52. package/dist/edges/types.d.ts +20 -0
  53. package/dist/edges/types.js +1 -0
  54. package/dist/embeddings.d.ts +13 -0
  55. package/dist/embeddings.js +54 -0
  56. package/dist/entities/db.d.ts +49 -0
  57. package/dist/entities/db.js +109 -0
  58. package/dist/entities/extractor.d.ts +14 -0
  59. package/dist/entities/extractor.js +154 -0
  60. package/dist/entities/normalize.d.ts +5 -0
  61. package/dist/entities/normalize.js +7 -0
  62. package/dist/entities/prompt.d.ts +19 -0
  63. package/dist/entities/prompt.js +100 -0
  64. package/dist/entities/state.d.ts +44 -0
  65. package/dist/entities/state.js +99 -0
  66. package/dist/entities/types.d.ts +62 -0
  67. package/dist/entities/types.js +6 -0
  68. package/dist/env.d.ts +13 -0
  69. package/dist/env.js +32 -0
  70. package/dist/fingerprint.d.ts +2 -0
  71. package/dist/fingerprint.js +12 -0
  72. package/dist/graph-rebuild.d.ts +6 -0
  73. package/dist/graph-rebuild.js +20 -0
  74. package/dist/index.d.ts +4 -0
  75. package/dist/index.js +403 -0
  76. package/dist/instrumentation.d.ts +10 -0
  77. package/dist/instrumentation.js +37 -0
  78. package/dist/lint/checks/contradictions.d.ts +30 -0
  79. package/dist/lint/checks/contradictions.js +52 -0
  80. package/dist/lint/checks/drift.d.ts +5 -0
  81. package/dist/lint/checks/drift.js +34 -0
  82. package/dist/lint/checks/orphans.d.ts +5 -0
  83. package/dist/lint/checks/orphans.js +25 -0
  84. package/dist/lint/checks/stale.d.ts +6 -0
  85. package/dist/lint/checks/stale.js +29 -0
  86. package/dist/lint/checks/superseded-unflagged.d.ts +5 -0
  87. package/dist/lint/checks/superseded-unflagged.js +47 -0
  88. package/dist/lint/run.d.ts +11 -0
  89. package/dist/lint/run.js +95 -0
  90. package/dist/lint/types.d.ts +60 -0
  91. package/dist/lint/types.js +13 -0
  92. package/dist/mcp-response.d.ts +7 -0
  93. package/dist/mcp-response.js +13 -0
  94. package/dist/providers/anthropic.d.ts +13 -0
  95. package/dist/providers/anthropic.js +56 -0
  96. package/dist/providers/claude-code.d.ts +35 -0
  97. package/dist/providers/claude-code.js +175 -0
  98. package/dist/providers/errors.d.ts +12 -0
  99. package/dist/providers/errors.js +19 -0
  100. package/dist/providers/index.d.ts +30 -0
  101. package/dist/providers/index.js +71 -0
  102. package/dist/providers/openrouter.d.ts +19 -0
  103. package/dist/providers/openrouter.js +76 -0
  104. package/dist/providers/semaphore.d.ts +19 -0
  105. package/dist/providers/semaphore.js +51 -0
  106. package/dist/providers/types.d.ts +27 -0
  107. package/dist/providers/types.js +7 -0
  108. package/dist/schema.sql +116 -0
  109. package/dist/server-instructions.d.ts +9 -0
  110. package/dist/server-instructions.js +20 -0
  111. package/dist/telemetry.d.ts +39 -0
  112. package/dist/telemetry.js +130 -0
  113. package/dist/tools/classify.d.ts +44 -0
  114. package/dist/tools/classify.js +121 -0
  115. package/dist/tools/compile.d.ts +31 -0
  116. package/dist/tools/compile.js +132 -0
  117. package/dist/tools/dump-edges-sidecar.d.ts +37 -0
  118. package/dist/tools/dump-edges-sidecar.js +80 -0
  119. package/dist/tools/extract-entities.d.ts +53 -0
  120. package/dist/tools/extract-entities.js +169 -0
  121. package/dist/tools/lint.d.ts +10 -0
  122. package/dist/tools/lint.js +13 -0
  123. package/dist/tools/meditate.d.ts +25 -0
  124. package/dist/tools/meditate.js +128 -0
  125. package/dist/tools/recall.d.ts +66 -0
  126. package/dist/tools/recall.js +409 -0
  127. package/dist/tools/reject.d.ts +10 -0
  128. package/dist/tools/reject.js +24 -0
  129. package/dist/tools/remember.d.ts +26 -0
  130. package/dist/tools/remember.js +140 -0
  131. package/dist/tools/search.d.ts +30 -0
  132. package/dist/tools/search.js +69 -0
  133. package/dist/tools/spawn-cli.d.ts +14 -0
  134. package/dist/tools/spawn-cli.js +41 -0
  135. package/dist/tools/stats.d.ts +31 -0
  136. package/dist/tools/stats.js +88 -0
  137. package/package.json +86 -0
  138. package/skills/remember/SKILL.md +357 -0
@@ -0,0 +1,172 @@
1
+ import { pairHash } from './state.js';
2
+ import { Semaphore } from '../providers/semaphore.js';
3
+ // Conservative pre-call cost estimates per stage (USD).
4
+ // Ground truth lives in AnthropicProvider.priceForTokens; revisit if pricing changes.
5
+ const STAGE1_EST_COST_USD = 0.0005;
6
+ const STAGE2_EST_COST_USD = 0.04;
7
+ export async function runClassifier(opts, deps) {
8
+ const startedAt = new Date().toISOString();
9
+ const candidates = await deps.findCandidatePairs({ sinceDays: opts.sinceDays });
10
+ // Resume: skip terminal pair_hashes
11
+ const terminals = await deps.state.terminalPairs(opts.runId);
12
+ const counters = {
13
+ stage1Total: 0,
14
+ stage1Pass: 0,
15
+ stage1Skip: 0,
16
+ stage2Total: 0,
17
+ stage2Classified: 0,
18
+ edgesWritten: 0,
19
+ totalCost: 0,
20
+ hitCap: false,
21
+ };
22
+ if (opts.dryRun) {
23
+ let estimate = 0;
24
+ if (deps.estimateCost) {
25
+ estimate = await deps.estimateCost(candidates);
26
+ }
27
+ else {
28
+ estimate = candidates.length * 0.018;
29
+ }
30
+ process.stdout.write(`Estimated cost for full run: $${estimate.toFixed(2)} (${candidates.length} candidate pairs after pre-filter)\n`);
31
+ return {
32
+ run_id: opts.runId,
33
+ started_at: startedAt,
34
+ ended_at: new Date().toISOString(),
35
+ candidate_pairs: candidates.length,
36
+ stage1_total: 0,
37
+ stage1_pass: 0,
38
+ stage1_skip: 0,
39
+ stage2_total: 0,
40
+ stage2_classified: 0,
41
+ edges_written: 0,
42
+ total_cost_usd: 0,
43
+ hit_cost_cap: false,
44
+ provider: deps.providerName,
45
+ };
46
+ }
47
+ await deps.state.markActiveRun(opts.runId);
48
+ const concurrency = Math.max(1, deps.concurrencyLimit ?? 1);
49
+ const semaphore = new Semaphore(concurrency);
50
+ const inFlight = new Set();
51
+ const launch = (cand) => {
52
+ const task = semaphore.withPermit(() => processPair(cand, opts, deps, counters, terminals));
53
+ inFlight.add(task);
54
+ void task.finally(() => inFlight.delete(task));
55
+ return task;
56
+ };
57
+ // Dispatcher loop — keep up to `concurrency` pairs in flight at once.
58
+ for (const cand of candidates) {
59
+ if (counters.hitCap)
60
+ break;
61
+ if (inFlight.size >= concurrency) {
62
+ await Promise.race(inFlight);
63
+ }
64
+ if (counters.hitCap)
65
+ break;
66
+ launch(cand);
67
+ }
68
+ await Promise.all([...inFlight]);
69
+ if (counters.hitCap) {
70
+ process.stdout.write(`Cost cap reached at $${counters.totalCost.toFixed(4)}. Resume with: npm run edges:classify -- --resume=${opts.runId}\n`);
71
+ }
72
+ const summary = {
73
+ run_id: opts.runId,
74
+ started_at: startedAt,
75
+ ended_at: new Date().toISOString(),
76
+ candidate_pairs: candidates.length,
77
+ stage1_total: counters.stage1Total,
78
+ stage1_pass: counters.stage1Pass,
79
+ stage1_skip: counters.stage1Skip,
80
+ stage2_total: counters.stage2Total,
81
+ stage2_classified: counters.stage2Classified,
82
+ edges_written: counters.edgesWritten,
83
+ total_cost_usd: counters.totalCost,
84
+ hit_cost_cap: counters.hitCap,
85
+ provider: deps.providerName,
86
+ };
87
+ await deps.summaryWriter.write(summary);
88
+ return summary;
89
+ }
90
+ async function processPair(cand, opts, deps, counters, terminals) {
91
+ if (counters.hitCap)
92
+ return;
93
+ const ph = pairHash(cand.from_id, cand.to_id);
94
+ if (terminals.has(ph))
95
+ return;
96
+ const fromMem = await deps.fetchMemoryById(cand.from_id);
97
+ const toMem = await deps.fetchMemoryById(cand.to_id);
98
+ if (!fromMem || !toMem)
99
+ return;
100
+ // Pre-call cap check for Stage 1. Approximate under concurrency.
101
+ if (counters.totalCost + STAGE1_EST_COST_USD > opts.maxCostUsd) {
102
+ counters.hitCap = true;
103
+ await deps.state.append({
104
+ run_id: opts.runId,
105
+ pair_hash: ph,
106
+ stage: 'cap_reached',
107
+ timestamp: new Date().toISOString(),
108
+ });
109
+ return;
110
+ }
111
+ counters.stage1Total++;
112
+ const s1 = await deps.stage1Filter({ from: fromMem, to: toMem });
113
+ counters.totalCost += s1.cost_usd;
114
+ if (!s1.pass) {
115
+ counters.stage1Skip++;
116
+ await deps.state.append({
117
+ run_id: opts.runId,
118
+ pair_hash: ph,
119
+ stage: 'haiku_skip',
120
+ timestamp: new Date().toISOString(),
121
+ cost_usd: s1.cost_usd,
122
+ });
123
+ return;
124
+ }
125
+ counters.stage1Pass++;
126
+ await deps.state.append({
127
+ run_id: opts.runId,
128
+ pair_hash: ph,
129
+ stage: 'haiku_pass',
130
+ timestamp: new Date().toISOString(),
131
+ cost_usd: s1.cost_usd,
132
+ });
133
+ // Pre-call cap check for Stage 2.
134
+ if (counters.totalCost + STAGE2_EST_COST_USD > opts.maxCostUsd) {
135
+ counters.hitCap = true;
136
+ await deps.state.append({
137
+ run_id: opts.runId,
138
+ pair_hash: ph,
139
+ stage: 'cap_reached',
140
+ timestamp: new Date().toISOString(),
141
+ });
142
+ return;
143
+ }
144
+ counters.stage2Total++;
145
+ const s2 = await deps.stage2Classify({ from: fromMem, to: toMem });
146
+ counters.totalCost += s2.cost_usd;
147
+ counters.stage2Classified++;
148
+ if (s2.downgraded) {
149
+ process.stdout.write(`AC10 GUARD: downgraded contradicts→none for rejection pair {${fromMem.id} (${fromMem.type})}, {${toMem.id} (${toMem.type})}\n`);
150
+ }
151
+ if (s2.relation !== 'none' && s2.confidence > 0) {
152
+ const edgeId = await deps.insertEdge(fromMem.id, toMem.id, s2.relation, s2.confidence, s2.rationale, deps.classifierVersion);
153
+ counters.edgesWritten++;
154
+ await deps.state.append({
155
+ run_id: opts.runId,
156
+ pair_hash: ph,
157
+ stage: 'opus_complete',
158
+ timestamp: new Date().toISOString(),
159
+ cost_usd: s2.cost_usd,
160
+ edge_id: edgeId,
161
+ });
162
+ }
163
+ else {
164
+ await deps.state.append({
165
+ run_id: opts.runId,
166
+ pair_hash: ph,
167
+ stage: 'opus_complete',
168
+ timestamp: new Date().toISOString(),
169
+ cost_usd: s2.cost_usd,
170
+ });
171
+ }
172
+ }
@@ -0,0 +1,13 @@
1
+ import type pg from 'pg';
2
+ import type { RecallSignal } from './types.js';
3
+ /**
4
+ * Build the signals[] array for a recall response. For every returned memory ID:
5
+ * - emit a `contradicts` signal for each outgoing `contradicts` edge
6
+ * - emit a `superseded_by` signal for each outgoing `supersedes` edge (returned memory is the newer one; signal's `from_id` will be the DB row's `to_memory_id`)
7
+ * AND for each incoming `supersedes` edge (returned memory is the older one; signal's `from_id` will be its own ID)
8
+ *
9
+ * Direction semantics: in the DB, `(from=newer, to=older, relation=supersedes)` means
10
+ * "newer supersedes older". The recall signal `superseded_by` always points
11
+ * from the older memory to the newer one, regardless of which side appeared in results[].
12
+ */
13
+ export declare function getSignalsForMemoryIds(pool: pg.Pool, memoryIds: string[]): Promise<RecallSignal[]>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Build the signals[] array for a recall response. For every returned memory ID:
3
+ * - emit a `contradicts` signal for each outgoing `contradicts` edge
4
+ * - emit a `superseded_by` signal for each outgoing `supersedes` edge (returned memory is the newer one; signal's `from_id` will be the DB row's `to_memory_id`)
5
+ * AND for each incoming `supersedes` edge (returned memory is the older one; signal's `from_id` will be its own ID)
6
+ *
7
+ * Direction semantics: in the DB, `(from=newer, to=older, relation=supersedes)` means
8
+ * "newer supersedes older". The recall signal `superseded_by` always points
9
+ * from the older memory to the newer one, regardless of which side appeared in results[].
10
+ */
11
+ export async function getSignalsForMemoryIds(pool, memoryIds) {
12
+ if (memoryIds.length === 0)
13
+ return [];
14
+ // Outgoing contradicts (memory in results -> some other memory)
15
+ const contradictsRes = await pool.query(`SELECT from_memory_id, to_memory_id, rationale, confidence
16
+ FROM memory_edges
17
+ WHERE from_memory_id = ANY($1) AND relation = 'contradicts' AND valid_until IS NULL`, [memoryIds]);
18
+ // Supersession: surface in either direction so the consumer always sees it
19
+ const supersedesRes = await pool.query(`SELECT from_memory_id, to_memory_id, rationale, confidence
20
+ FROM memory_edges
21
+ WHERE (from_memory_id = ANY($1) OR to_memory_id = ANY($1))
22
+ AND relation = 'supersedes' AND valid_until IS NULL`, [memoryIds]);
23
+ const signals = [];
24
+ for (const row of contradictsRes.rows) {
25
+ signals.push({
26
+ kind: 'contradicts',
27
+ from_id: row.from_memory_id,
28
+ to_id: row.to_memory_id,
29
+ rationale: row.rationale,
30
+ confidence: Number(row.confidence),
31
+ });
32
+ }
33
+ for (const row of supersedesRes.rows) {
34
+ // DB: from=newer, to=older (per classifier convention)
35
+ // Signal: from=older, to=newer (per spec AC8 — "from_id is older")
36
+ signals.push({
37
+ kind: 'superseded_by',
38
+ from_id: row.to_memory_id,
39
+ to_id: row.from_memory_id,
40
+ rationale: row.rationale,
41
+ confidence: Number(row.confidence),
42
+ });
43
+ }
44
+ return signals;
45
+ }
@@ -0,0 +1,21 @@
1
+ import type { LLMProvider } from '../providers/types.js';
2
+ export interface PairForFilter {
3
+ from: {
4
+ id: string;
5
+ content: string;
6
+ };
7
+ to: {
8
+ id: string;
9
+ content: string;
10
+ };
11
+ }
12
+ export interface Stage1Result {
13
+ pass: boolean;
14
+ comment: string;
15
+ cost_usd: number;
16
+ }
17
+ export declare function parseStage1Response(text: string): {
18
+ pass: boolean;
19
+ comment: string;
20
+ };
21
+ export declare function stage1HaikuFilter(provider: LLMProvider, pair: PairForFilter): Promise<Stage1Result>;
@@ -0,0 +1,33 @@
1
+ import { withLLMCallSpan } from '../telemetry.js';
2
+ const STAGE1_SYSTEM = `You are a filter that decides whether two memories MIGHT have a meaningful structural relation worth deeper analysis.
3
+
4
+ Reply with one line in this exact format:
5
+ YES — <brief reason>
6
+ NO — <brief reason>
7
+
8
+ Say YES if the two memories appear to make claims about overlapping things — e.g., they recommend or contradict each other on the same subject, one is a refinement of the other, or one depends on the other. Say NO if they describe distinct, non-conflicting things despite sharing topic tags. Reply with ONLY the single line — no other text.`;
9
+ const STAGE1_MAX_OUTPUT_TOKENS = 64;
10
+ export function parseStage1Response(text) {
11
+ const trimmed = text.trim();
12
+ const match = trimmed.match(/^(YES|NO)(?:\s*[—\-:]\s*(.*))?$/i);
13
+ if (!match) {
14
+ throw new Error(`Stage 1 response not parseable: ${JSON.stringify(text)}`);
15
+ }
16
+ return {
17
+ pass: match[1].toUpperCase() === 'YES',
18
+ comment: (match[2] ?? '').trim(),
19
+ };
20
+ }
21
+ export async function stage1HaikuFilter(provider, pair) {
22
+ const userPrompt = `Memory A (id=${pair.from.id}): ${pair.from.content}\n\nMemory B (id=${pair.to.id}): ${pair.to.content}`;
23
+ // claw-1ejd: wrap the LLM call so the parent OTEL_TRACEPARENT context
24
+ // has a concrete child span to inherit when this runs as a subprocess.
25
+ const result = await withLLMCallSpan('memory.classify_edges.call', { provider: provider.name, model: 'haiku' }, () => provider.complete({
26
+ model: 'haiku',
27
+ system: STAGE1_SYSTEM,
28
+ prompt: userPrompt,
29
+ max_tokens: STAGE1_MAX_OUTPUT_TOKENS,
30
+ }));
31
+ const parsed = parseStage1Response(result.response);
32
+ return { pass: parsed.pass, comment: parsed.comment, cost_usd: result.cost_usd };
33
+ }
@@ -0,0 +1,41 @@
1
+ import type { LLMProvider } from '../providers/types.js';
2
+ import type { EdgeRelation } from './types.js';
3
+ export interface MemoryForClassify {
4
+ id: string;
5
+ content: string;
6
+ type: string;
7
+ }
8
+ export interface PairForClassify {
9
+ from: MemoryForClassify;
10
+ to: MemoryForClassify;
11
+ }
12
+ export type Stage2Result = {
13
+ kind: 'classified';
14
+ relation: EdgeRelation | 'none';
15
+ confidence: number;
16
+ rationale: string;
17
+ cost_usd: number;
18
+ downgraded?: boolean;
19
+ };
20
+ export declare const STAGE2_RELATIONS: ReadonlyArray<EdgeRelation | 'none'>;
21
+ /**
22
+ * AC10: rejection-typed memories are out-of-vocabulary for the `contradicts` relation
23
+ * (a rejection is a meta-statement, not a factual claim). They CAN participate in
24
+ * other relations like related_to, evolved_into, supersedes — a rejection often
25
+ * pairs with a preference saying the same thing in positive form.
26
+ *
27
+ * Used by stage2OpusClassify as a post-call guard: if the LLM returns "contradicts"
28
+ * for a rejection pair (despite being instructed otherwise in the system prompt),
29
+ * the result is downgraded to "none".
30
+ */
31
+ export declare function isRejectionPair(a: {
32
+ type: string;
33
+ }, b: {
34
+ type: string;
35
+ }): boolean;
36
+ export declare function parseStage2Response(text: string): {
37
+ relation: EdgeRelation | 'none';
38
+ confidence: number;
39
+ rationale: string;
40
+ };
41
+ export declare function stage2OpusClassify(provider: LLMProvider, pair: PairForClassify): Promise<Stage2Result>;
@@ -0,0 +1,101 @@
1
+ import { withLLMCallSpan } from '../telemetry.js';
2
+ export const STAGE2_RELATIONS = [
3
+ 'supports',
4
+ 'contradicts',
5
+ 'supersedes',
6
+ 'evolved_into',
7
+ 'depends_on',
8
+ 'related_to',
9
+ 'none',
10
+ ];
11
+ const STAGE2_SYSTEM = `You classify the structural relation between two memories. Respond with JSON only.
12
+
13
+ Possible relations:
14
+ - supports: B reinforces or is consistent with A
15
+ - contradicts: A and B make conflicting factual claims about the same subject
16
+ - supersedes: A explicitly replaces B as the current framing (newer A, older B)
17
+ - evolved_into: A is a refined version of B with the same core intent
18
+ - depends_on: A presupposes B's truth
19
+ - related_to: A and B are about the same general topic but no stronger relation applies
20
+ - none: no meaningful relation
21
+
22
+ Use "supersedes" with from=A=newer, to=B=older.
23
+
24
+ Use "contradicts" only for genuinely conflicting claims. Do NOT mark superseded pairs as contradictions.
25
+
26
+ Rejection-typed memories rule: if memory A or B has type=rejection, you must NOT use "contradicts". A rejection ("don't do X") is a meta-statement about what to avoid, not a factual claim that can conflict with another claim. For rejection-involving pairs, prefer "related_to", "evolved_into", "supersedes", "depends_on", or "none".
27
+
28
+ Avoid "related_to" unless you are sure no stronger relation fits — it is the weakest signal.
29
+
30
+ Reply with a single JSON object: {"relation": <one of the seven>, "confidence": <0..1>, "rationale": "<one sentence>"}`;
31
+ const STAGE2_MAX_OUTPUT_TOKENS = 256;
32
+ /**
33
+ * AC10: rejection-typed memories are out-of-vocabulary for the `contradicts` relation
34
+ * (a rejection is a meta-statement, not a factual claim). They CAN participate in
35
+ * other relations like related_to, evolved_into, supersedes — a rejection often
36
+ * pairs with a preference saying the same thing in positive form.
37
+ *
38
+ * Used by stage2OpusClassify as a post-call guard: if the LLM returns "contradicts"
39
+ * for a rejection pair (despite being instructed otherwise in the system prompt),
40
+ * the result is downgraded to "none".
41
+ */
42
+ export function isRejectionPair(a, b) {
43
+ return a.type === 'rejection' || b.type === 'rejection';
44
+ }
45
+ export function parseStage2Response(text) {
46
+ // Strip optional ```json fences
47
+ const cleaned = text
48
+ .trim()
49
+ .replace(/^```(?:json)?\s*/, '')
50
+ .replace(/\s*```\s*$/, '')
51
+ .trim();
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(cleaned);
55
+ }
56
+ catch {
57
+ throw new Error(`Stage 2 response is not JSON: ${JSON.stringify(text).slice(0, 200)}`);
58
+ }
59
+ if (!parsed || typeof parsed !== 'object') {
60
+ throw new Error('Stage 2 response is not an object');
61
+ }
62
+ const obj = parsed;
63
+ const relation = String(obj.relation ?? '').toLowerCase();
64
+ if (!STAGE2_RELATIONS.includes(relation)) {
65
+ throw new Error(`Stage 2 returned invalid relation: ${obj.relation}`);
66
+ }
67
+ const confidence = Number(obj.confidence ?? 0);
68
+ const rationale = String(obj.rationale ?? '').trim();
69
+ return { relation, confidence, rationale };
70
+ }
71
+ export async function stage2OpusClassify(provider, pair) {
72
+ const userPrompt = `Memory A (id=${pair.from.id}, type=${pair.from.type}): ${pair.from.content}\n\nMemory B (id=${pair.to.id}, type=${pair.to.type}): ${pair.to.content}`;
73
+ // claw-1ejd: wrap the LLM call so the parent OTEL_TRACEPARENT context
74
+ // has a concrete child span to inherit when this runs as a subprocess.
75
+ const result = await withLLMCallSpan('memory.classify_edges.call', { provider: provider.name, model: 'opus' }, () => provider.complete({
76
+ model: 'opus',
77
+ system: STAGE2_SYSTEM,
78
+ prompt: userPrompt,
79
+ max_tokens: STAGE2_MAX_OUTPUT_TOKENS,
80
+ }));
81
+ const parsed = parseStage2Response(result.response);
82
+ // AC10 guard: if the LLM returns contradicts despite being told not to for rejection
83
+ // pairs, downgrade to 'none'. The system prompt is the primary defense; this is a fallback.
84
+ if (parsed.relation === 'contradicts' && isRejectionPair(pair.from, pair.to)) {
85
+ return {
86
+ kind: 'classified',
87
+ relation: 'none',
88
+ confidence: parsed.confidence,
89
+ rationale: `[AC10] downgraded contradicts→none for rejection pair; original rationale: ${parsed.rationale}`,
90
+ cost_usd: result.cost_usd,
91
+ downgraded: true,
92
+ };
93
+ }
94
+ return {
95
+ kind: 'classified',
96
+ relation: parsed.relation,
97
+ confidence: parsed.confidence,
98
+ rationale: parsed.rationale,
99
+ cost_usd: result.cost_usd,
100
+ };
101
+ }
@@ -0,0 +1,44 @@
1
+ export interface StageRecord {
2
+ run_id: string;
3
+ pair_hash: string;
4
+ stage: 'haiku_pass' | 'haiku_skip' | 'opus_complete' | 'cap_reached' | 'rejection_skip';
5
+ timestamp: string;
6
+ cost_usd?: number;
7
+ edge_id?: string;
8
+ }
9
+ export declare function pairHash(idA: string, idB: string): string;
10
+ export declare class StateStore {
11
+ private readonly path;
12
+ private readonly lastRunPath?;
13
+ constructor(path: string, lastRunPath?: string | undefined);
14
+ append(record: StageRecord): Promise<void>;
15
+ markActiveRun(runId: string): Promise<void>;
16
+ /**
17
+ * Read all terminal stage records for a run_id, returning the set of pair_hashes
18
+ * that should NOT be re-classified on resume. Tolerates a truncated final line
19
+ * (jsonl partial write from a hard kill).
20
+ */
21
+ terminalPairs(runId: string): Promise<Set<string>>;
22
+ }
23
+ export interface RunSummary {
24
+ run_id: string;
25
+ started_at: string;
26
+ ended_at: string;
27
+ candidate_pairs: number;
28
+ stage1_total: number;
29
+ stage1_pass: number;
30
+ stage1_skip: number;
31
+ stage2_total: number;
32
+ stage2_classified: number;
33
+ edges_written: number;
34
+ total_cost_usd: number;
35
+ hit_cost_cap: boolean;
36
+ error?: string;
37
+ /** Active LLM provider (D.R3, surfaces in run summary). */
38
+ provider?: string;
39
+ }
40
+ export declare class RunSummaryWriter {
41
+ private readonly dir;
42
+ constructor(dir: string);
43
+ write(summary: RunSummary): Promise<string>;
44
+ }
@@ -0,0 +1,79 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ const TERMINAL_STAGES = new Set([
6
+ 'opus_complete',
7
+ 'haiku_skip',
8
+ 'cap_reached',
9
+ 'rejection_skip',
10
+ ]);
11
+ export function pairHash(idA, idB) {
12
+ const [first, second] = [idA, idB].sort();
13
+ return createHash('sha256').update(`${first}|${second}`).digest('hex').slice(0, 16);
14
+ }
15
+ export class StateStore {
16
+ path;
17
+ lastRunPath;
18
+ constructor(path, lastRunPath) {
19
+ this.path = path;
20
+ this.lastRunPath = lastRunPath;
21
+ }
22
+ async append(record) {
23
+ await mkdir(dirname(this.path), { recursive: true });
24
+ await appendFile(this.path, JSON.stringify(record) + '\n', 'utf-8');
25
+ }
26
+ async markActiveRun(runId) {
27
+ if (!this.lastRunPath)
28
+ return;
29
+ await mkdir(dirname(this.lastRunPath), { recursive: true });
30
+ await writeFile(this.lastRunPath, runId, 'utf-8');
31
+ }
32
+ /**
33
+ * Read all terminal stage records for a run_id, returning the set of pair_hashes
34
+ * that should NOT be re-classified on resume. Tolerates a truncated final line
35
+ * (jsonl partial write from a hard kill).
36
+ */
37
+ async terminalPairs(runId) {
38
+ if (!existsSync(this.path))
39
+ return new Set();
40
+ const raw = await readFile(this.path, 'utf-8');
41
+ const lines = raw.split('\n');
42
+ const terminals = new Set();
43
+ for (let i = 0; i < lines.length; i++) {
44
+ const line = lines[i];
45
+ if (!line)
46
+ continue;
47
+ // The final element of split is '' when the file ends in '\n' (good).
48
+ // If the file does NOT end in '\n', the final element is a partial line —
49
+ // skip it because it was truncated mid-write.
50
+ if (i === lines.length - 1 && !raw.endsWith('\n'))
51
+ continue;
52
+ try {
53
+ const rec = JSON.parse(line);
54
+ if (rec.run_id !== runId)
55
+ continue;
56
+ if (TERMINAL_STAGES.has(rec.stage)) {
57
+ terminals.add(rec.pair_hash);
58
+ }
59
+ }
60
+ catch {
61
+ // Defensive: skip malformed line rather than crash on resume
62
+ continue;
63
+ }
64
+ }
65
+ return terminals;
66
+ }
67
+ }
68
+ export class RunSummaryWriter {
69
+ dir;
70
+ constructor(dir) {
71
+ this.dir = dir;
72
+ }
73
+ async write(summary) {
74
+ await mkdir(this.dir, { recursive: true });
75
+ const path = join(this.dir, `${summary.run_id}.json`);
76
+ await writeFile(path, JSON.stringify(summary, null, 2), 'utf-8');
77
+ return path;
78
+ }
79
+ }
@@ -0,0 +1,20 @@
1
+ export type EdgeRelation = 'supports' | 'contradicts' | 'supersedes' | 'evolved_into' | 'depends_on' | 'related_to';
2
+ export interface MemoryEdge {
3
+ id: string;
4
+ from_memory_id: string;
5
+ to_memory_id: string;
6
+ relation: EdgeRelation;
7
+ confidence: number;
8
+ rationale: string;
9
+ classifier_version: string;
10
+ valid_from: Date;
11
+ valid_until: Date | null;
12
+ }
13
+ export type SignalKind = 'contradicts' | 'superseded_by';
14
+ export interface RecallSignal {
15
+ kind: SignalKind;
16
+ from_id: string;
17
+ to_id: string;
18
+ rationale: string;
19
+ confidence: number;
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ /**
2
+ * claw-8cjf.2: a null embedding must be explainable in tool responses instead
3
+ * of silently degrading the headline semantic-search feature.
4
+ */
5
+ export declare const EMBEDDINGS_DISABLED_WARNING: string;
6
+ export declare const EMBEDDING_FAILED_WARNING: string;
7
+ /**
8
+ * Explains a null embedding: disabled (no key) vs failed (key present).
9
+ * Returns null when the embedding is present — no warning needed.
10
+ */
11
+ export declare function embeddingWarning(embedding: ReadonlyArray<number> | null): string | null;
12
+ export declare function embedBatch(texts: string[], model?: string): Promise<number[][] | null>;
13
+ export declare function embedText(text: string, model?: string): Promise<number[] | null>;
@@ -0,0 +1,54 @@
1
+ import { withEmbeddingSpan } from './telemetry.js';
2
+ const OPENROUTER_URL = 'https://openrouter.ai/api/v1/embeddings';
3
+ const DEFAULT_MODEL = 'openai/text-embedding-3-small';
4
+ /**
5
+ * claw-8cjf.2: a null embedding must be explainable in tool responses instead
6
+ * of silently degrading the headline semantic-search feature.
7
+ */
8
+ export const EMBEDDINGS_DISABLED_WARNING = 'embeddings disabled: R2MCP_OPENROUTER_API_KEY is not set — memories store without ' +
9
+ 'embeddings and recall falls back to full-text. Set it in your .mcp.json "env" block ' +
10
+ 'or .env to enable semantic search.';
11
+ export const EMBEDDING_FAILED_WARNING = 'embedding generation failed (see server stderr for the OpenRouter error) — this ' +
12
+ 'operation completed without an embedding; re-saving the content later will backfill it.';
13
+ /**
14
+ * Explains a null embedding: disabled (no key) vs failed (key present).
15
+ * Returns null when the embedding is present — no warning needed.
16
+ */
17
+ export function embeddingWarning(embedding) {
18
+ if (embedding !== null)
19
+ return null;
20
+ return process.env.R2MCP_OPENROUTER_API_KEY
21
+ ? EMBEDDING_FAILED_WARNING
22
+ : EMBEDDINGS_DISABLED_WARNING;
23
+ }
24
+ export async function embedBatch(texts, model = DEFAULT_MODEL) {
25
+ const apiKey = process.env.R2MCP_OPENROUTER_API_KEY;
26
+ if (!apiKey) {
27
+ return null;
28
+ }
29
+ const totalChars = texts.reduce((sum, t) => sum + t.length, 0);
30
+ return withEmbeddingSpan(texts.length, async () => {
31
+ const res = await fetch(OPENROUTER_URL, {
32
+ method: 'POST',
33
+ headers: {
34
+ 'Content-Type': 'application/json',
35
+ Authorization: `Bearer ${apiKey}`,
36
+ 'HTTP-Referer': 'https://github.com/DMokong/r2mcp',
37
+ 'X-Title': 'r2mcp',
38
+ },
39
+ body: JSON.stringify({ model, input: texts }),
40
+ });
41
+ if (!res.ok) {
42
+ console.error(`OpenRouter embedding error ${res.status}: ${await res.text()}`);
43
+ return null;
44
+ }
45
+ const data = await res.json();
46
+ return data.data
47
+ .sort((a, b) => a.index - b.index)
48
+ .map((d) => d.embedding);
49
+ }, totalChars);
50
+ }
51
+ export async function embedText(text, model = DEFAULT_MODEL) {
52
+ const result = await embedBatch([text], model);
53
+ return result ? result[0] : null;
54
+ }