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.
- package/CHANGELOG.md +66 -0
- package/LICENSE +21 -0
- package/README.md +532 -0
- package/dist/breadcrumbs.d.ts +123 -0
- package/dist/breadcrumbs.js +135 -0
- package/dist/cli/classify-edges.d.ts +2 -0
- package/dist/cli/classify-edges.js +130 -0
- package/dist/cli/compile-wiki.d.ts +2 -0
- package/dist/cli/compile-wiki.js +173 -0
- package/dist/cli/dump-edges-json.d.ts +2 -0
- package/dist/cli/dump-edges-json.js +21 -0
- package/dist/cli/extract-entities.d.ts +17 -0
- package/dist/cli/extract-entities.js +166 -0
- package/dist/cli/lint-memory.d.ts +16 -0
- package/dist/cli/lint-memory.js +94 -0
- package/dist/cli/migrate.d.ts +17 -0
- package/dist/cli/migrate.js +146 -0
- package/dist/cli/setup-helpers.d.ts +7 -0
- package/dist/cli/setup-helpers.js +72 -0
- package/dist/cli/setup.d.ts +15 -0
- package/dist/cli/setup.js +95 -0
- package/dist/compiler/clustering.d.ts +29 -0
- package/dist/compiler/clustering.js +66 -0
- package/dist/compiler/frontmatter.d.ts +35 -0
- package/dist/compiler/frontmatter.js +168 -0
- package/dist/compiler/manifest.d.ts +32 -0
- package/dist/compiler/manifest.js +82 -0
- package/dist/compiler/prompts.d.ts +17 -0
- package/dist/compiler/prompts.js +82 -0
- package/dist/compiler/run.d.ts +52 -0
- package/dist/compiler/run.js +186 -0
- package/dist/compiler/tier.d.ts +10 -0
- package/dist/compiler/tier.js +85 -0
- package/dist/compiler/topic.d.ts +16 -0
- package/dist/compiler/topic.js +105 -0
- package/dist/compiler/types.d.ts +101 -0
- package/dist/compiler/types.js +4 -0
- package/dist/db.d.ts +10 -0
- package/dist/db.js +46 -0
- package/dist/edges/candidate-pairs.d.ts +24 -0
- package/dist/edges/candidate-pairs.js +35 -0
- package/dist/edges/classifier.d.ts +45 -0
- package/dist/edges/classifier.js +172 -0
- package/dist/edges/signals.d.ts +13 -0
- package/dist/edges/signals.js +45 -0
- package/dist/edges/stage1-haiku.d.ts +21 -0
- package/dist/edges/stage1-haiku.js +33 -0
- package/dist/edges/stage2-opus.d.ts +41 -0
- package/dist/edges/stage2-opus.js +101 -0
- package/dist/edges/state.d.ts +44 -0
- package/dist/edges/state.js +79 -0
- package/dist/edges/types.d.ts +20 -0
- package/dist/edges/types.js +1 -0
- package/dist/embeddings.d.ts +13 -0
- package/dist/embeddings.js +54 -0
- package/dist/entities/db.d.ts +49 -0
- package/dist/entities/db.js +109 -0
- package/dist/entities/extractor.d.ts +14 -0
- package/dist/entities/extractor.js +154 -0
- package/dist/entities/normalize.d.ts +5 -0
- package/dist/entities/normalize.js +7 -0
- package/dist/entities/prompt.d.ts +19 -0
- package/dist/entities/prompt.js +100 -0
- package/dist/entities/state.d.ts +44 -0
- package/dist/entities/state.js +99 -0
- package/dist/entities/types.d.ts +62 -0
- package/dist/entities/types.js +6 -0
- package/dist/env.d.ts +13 -0
- package/dist/env.js +32 -0
- package/dist/fingerprint.d.ts +2 -0
- package/dist/fingerprint.js +12 -0
- package/dist/graph-rebuild.d.ts +6 -0
- package/dist/graph-rebuild.js +20 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +403 -0
- package/dist/instrumentation.d.ts +10 -0
- package/dist/instrumentation.js +37 -0
- package/dist/lint/checks/contradictions.d.ts +30 -0
- package/dist/lint/checks/contradictions.js +52 -0
- package/dist/lint/checks/drift.d.ts +5 -0
- package/dist/lint/checks/drift.js +34 -0
- package/dist/lint/checks/orphans.d.ts +5 -0
- package/dist/lint/checks/orphans.js +25 -0
- package/dist/lint/checks/stale.d.ts +6 -0
- package/dist/lint/checks/stale.js +29 -0
- package/dist/lint/checks/superseded-unflagged.d.ts +5 -0
- package/dist/lint/checks/superseded-unflagged.js +47 -0
- package/dist/lint/run.d.ts +11 -0
- package/dist/lint/run.js +95 -0
- package/dist/lint/types.d.ts +60 -0
- package/dist/lint/types.js +13 -0
- package/dist/mcp-response.d.ts +7 -0
- package/dist/mcp-response.js +13 -0
- package/dist/providers/anthropic.d.ts +13 -0
- package/dist/providers/anthropic.js +56 -0
- package/dist/providers/claude-code.d.ts +35 -0
- package/dist/providers/claude-code.js +175 -0
- package/dist/providers/errors.d.ts +12 -0
- package/dist/providers/errors.js +19 -0
- package/dist/providers/index.d.ts +30 -0
- package/dist/providers/index.js +71 -0
- package/dist/providers/openrouter.d.ts +19 -0
- package/dist/providers/openrouter.js +76 -0
- package/dist/providers/semaphore.d.ts +19 -0
- package/dist/providers/semaphore.js +51 -0
- package/dist/providers/types.d.ts +27 -0
- package/dist/providers/types.js +7 -0
- package/dist/schema.sql +116 -0
- package/dist/server-instructions.d.ts +9 -0
- package/dist/server-instructions.js +20 -0
- package/dist/telemetry.d.ts +39 -0
- package/dist/telemetry.js +130 -0
- package/dist/tools/classify.d.ts +44 -0
- package/dist/tools/classify.js +121 -0
- package/dist/tools/compile.d.ts +31 -0
- package/dist/tools/compile.js +132 -0
- package/dist/tools/dump-edges-sidecar.d.ts +37 -0
- package/dist/tools/dump-edges-sidecar.js +80 -0
- package/dist/tools/extract-entities.d.ts +53 -0
- package/dist/tools/extract-entities.js +169 -0
- package/dist/tools/lint.d.ts +10 -0
- package/dist/tools/lint.js +13 -0
- package/dist/tools/meditate.d.ts +25 -0
- package/dist/tools/meditate.js +128 -0
- package/dist/tools/recall.d.ts +66 -0
- package/dist/tools/recall.js +409 -0
- package/dist/tools/reject.d.ts +10 -0
- package/dist/tools/reject.js +24 -0
- package/dist/tools/remember.d.ts +26 -0
- package/dist/tools/remember.js +140 -0
- package/dist/tools/search.d.ts +30 -0
- package/dist/tools/search.js +69 -0
- package/dist/tools/spawn-cli.d.ts +14 -0
- package/dist/tools/spawn-cli.js +41 -0
- package/dist/tools/stats.d.ts +31 -0
- package/dist/tools/stats.js +88 -0
- package/package.json +86 -0
- 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
|
+
}
|