agentcache 0.1.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/dist/mcp.js ADDED
@@ -0,0 +1,884 @@
1
+ import {
2
+ evaluatePolicy
3
+ } from "./chunk-PYGRUQNL.js";
4
+ import {
5
+ parseTranscript
6
+ } from "./chunk-QGG25FWV.js";
7
+ import {
8
+ findProjectRoot,
9
+ getDbPath,
10
+ getGitContext,
11
+ getProjectId,
12
+ isLoopInitialized
13
+ } from "./chunk-VPEEZXLK.js";
14
+ import {
15
+ SqliteKnowledgeRepository
16
+ } from "./chunk-MMSMDJ4O.js";
17
+ import "./chunk-MLKGABMK.js";
18
+
19
+ // src/mcp.ts
20
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
21
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
+ import { CallToolRequestSchema, ListToolsRequestSchema, RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
23
+
24
+ // src/knowledge/compiler.ts
25
+ import { randomUUID as randomUUID3 } from "crypto";
26
+
27
+ // src/knowledge/passes/1-extractor.ts
28
+ import { randomUUID } from "crypto";
29
+ var EXTRACT_PROMPT_VERSION = "extract-v1";
30
+ function buildExtractionPrompt(events) {
31
+ const transcript = events.filter((e) => e.content || e.tool_name).map((e) => {
32
+ if (e.role) return `[${e.role}]: ${e.content}`;
33
+ if (e.tool_name) return `[tool:${e.tool_name}]: ${JSON.stringify(e.tool_input).slice(0, 500)}`;
34
+ return "";
35
+ }).filter(Boolean).join("\n");
36
+ return `You are a knowledge extraction engine. Analyze this coding session transcript and extract distinct learnings.
37
+
38
+ Extract into four types:
39
+ - rule: a standing instruction or constraint the developer expressed
40
+ - lesson: a mistake made and what fixed it
41
+ - decision: an architectural or design choice with rationale
42
+ - context: current task state, open threads, what was left in progress
43
+
44
+ Return ONLY valid JSON: { "observations": [{ "type": "rule"|"lesson"|"decision"|"context", "content": "...", "sourceQuote": "...", "confidence": "high"|"medium" }] }
45
+
46
+ Only return high and medium confidence items. Ignore conversational noise, tool outputs, and implementation details that aren't generalizable.
47
+
48
+ <transcript>
49
+ ${transcript}
50
+ </transcript>`;
51
+ }
52
+ function parseExtractionResponse(text, sessionId, project) {
53
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
54
+ if (!jsonMatch) return [];
55
+ const parsed = JSON.parse(jsonMatch[0]);
56
+ if (!parsed.observations || !Array.isArray(parsed.observations)) return [];
57
+ const now = Date.now();
58
+ return parsed.observations.filter((o) => o.type && o.content && o.confidence).filter((o) => ["high", "medium"].includes(o.confidence)).map((o) => ({
59
+ id: `obs_${randomUUID().slice(0, 8)}`,
60
+ sessionId,
61
+ timestamp: now,
62
+ type: o.type,
63
+ content: o.content,
64
+ sourceQuote: o.sourceQuote || "",
65
+ confidence: o.confidence,
66
+ project,
67
+ scope: o.scope || (o.type === "rule" || o.type === "lesson" ? "global" : "project")
68
+ }));
69
+ }
70
+
71
+ // src/knowledge/passes/2-normalizer.ts
72
+ var FILLER_PATTERNS = [
73
+ /^i (noticed|realized|learned|found|discovered|think) that /i,
74
+ /^it (seems|appears|looks) (like|that) /i,
75
+ /^we should /i,
76
+ /^you should /i,
77
+ /^basically,? /i,
78
+ /^essentially,? /i,
79
+ /^actually,? /i
80
+ ];
81
+ var IMPERATIVE_RULES = [
82
+ [/^you should never /i, "Never "],
83
+ [/^we should never /i, "Never "],
84
+ [/^don't ever /i, "Never "],
85
+ [/^never /i, "Never "],
86
+ [/^you should always /i, "Always "],
87
+ [/^we should always /i, "Always "],
88
+ [/^always /i, "Always "]
89
+ ];
90
+ function normalize(observations) {
91
+ const normalized = observations.map((obs) => ({
92
+ ...obs,
93
+ content: normalizeContent(obs.content, obs.type)
94
+ }));
95
+ const seen = /* @__PURE__ */ new Set();
96
+ return normalized.filter((obs) => {
97
+ const key = obs.content.toLowerCase().trim();
98
+ if (seen.has(key)) return false;
99
+ seen.add(key);
100
+ return true;
101
+ });
102
+ }
103
+ function normalizeContent(content, type) {
104
+ let text = content.trim();
105
+ for (const pattern of FILLER_PATTERNS) {
106
+ text = text.replace(pattern, "");
107
+ }
108
+ if (type === "rule") {
109
+ for (const [pattern, replacement] of IMPERATIVE_RULES) {
110
+ if (pattern.test(text)) {
111
+ text = text.replace(pattern, replacement);
112
+ break;
113
+ }
114
+ }
115
+ }
116
+ text = text.charAt(0).toUpperCase() + text.slice(1);
117
+ const firstSentenceEnd = text.search(/\. [A-Z]/);
118
+ if (firstSentenceEnd > 0) {
119
+ text = text.slice(0, firstSentenceEnd + 1);
120
+ }
121
+ return text;
122
+ }
123
+
124
+ // src/knowledge/passes/3-canonicalizer.ts
125
+ import { createHash } from "crypto";
126
+ var STOP_WORDS = /* @__PURE__ */ new Set([
127
+ "a",
128
+ "an",
129
+ "the",
130
+ "is",
131
+ "are",
132
+ "was",
133
+ "were",
134
+ "be",
135
+ "been",
136
+ "being",
137
+ "have",
138
+ "has",
139
+ "had",
140
+ "do",
141
+ "does",
142
+ "did",
143
+ "will",
144
+ "would",
145
+ "could",
146
+ "should",
147
+ "may",
148
+ "might",
149
+ "shall",
150
+ "can",
151
+ "need",
152
+ "must",
153
+ "to",
154
+ "of",
155
+ "in",
156
+ "for",
157
+ "on",
158
+ "with",
159
+ "at",
160
+ "by",
161
+ "from",
162
+ "as",
163
+ "into",
164
+ "through",
165
+ "during",
166
+ "before",
167
+ "after",
168
+ "above",
169
+ "below",
170
+ "this",
171
+ "that",
172
+ "these",
173
+ "those",
174
+ "it",
175
+ "its",
176
+ "and",
177
+ "but",
178
+ "or",
179
+ "nor",
180
+ "not",
181
+ "so",
182
+ "yet",
183
+ "all",
184
+ "each",
185
+ "every",
186
+ "both",
187
+ "few",
188
+ "more",
189
+ "most",
190
+ "i",
191
+ "we",
192
+ "you",
193
+ "they",
194
+ "he",
195
+ "she"
196
+ ]);
197
+ var ANTONYM_MAP = [
198
+ [/\bnever\b/g, "forbidden"],
199
+ [/\bdon'?t\b/g, "forbidden"],
200
+ [/\bavoid\b/g, "forbidden"],
201
+ [/\bprohibit(ed)?\b/g, "forbidden"],
202
+ [/\balways\b/g, "required"],
203
+ [/\bmust\b/g, "required"],
204
+ [/\brequire(d)?\b/g, "required"],
205
+ [/\buse\b/g, "use"],
206
+ [/\bprefer\b/g, "use"]
207
+ ];
208
+ function canonicalize(observations, existingCanonicalKeys) {
209
+ const canonicalized = observations.map((obs) => ({
210
+ ...obs,
211
+ canonicalKey: computeCanonicalKey(obs.content)
212
+ }));
213
+ const existingSet = new Set(existingCanonicalKeys || []);
214
+ const autoReinforced = [];
215
+ const needsClustering = [];
216
+ for (const obs of canonicalized) {
217
+ if (existingSet.has(obs.canonicalKey)) {
218
+ autoReinforced.push(obs);
219
+ } else {
220
+ needsClustering.push(obs);
221
+ }
222
+ }
223
+ return { observations: canonicalized, autoReinforced, needsClustering };
224
+ }
225
+ function computeCanonicalKey(content) {
226
+ let text = content.toLowerCase().trim();
227
+ for (const [pattern, replacement] of ANTONYM_MAP) {
228
+ text = text.replace(pattern, replacement);
229
+ }
230
+ text = text.replace(/[^\w\s]/g, " ");
231
+ const tokens = text.split(/\s+/).filter((t) => !STOP_WORDS.has(t) && t.length > 1).sort();
232
+ return tokens.join(" ");
233
+ }
234
+ function computeCanonicalHash(content) {
235
+ const key = computeCanonicalKey(content);
236
+ return createHash("sha256").update(key).digest("hex").slice(0, 16);
237
+ }
238
+
239
+ // src/knowledge/passes/4-clusterer.ts
240
+ var CLUSTER_PROMPT_VERSION = "cluster-v1";
241
+ function buildClusteringPrompt(observations, existingItems) {
242
+ const obsJson = observations.map((o) => ({
243
+ id: o.id,
244
+ type: o.type,
245
+ content: o.content,
246
+ canonicalKey: o.canonicalKey
247
+ }));
248
+ const itemsJson = existingItems.filter((i) => i.status === "active").map((i) => ({
249
+ id: i.id,
250
+ type: i.type,
251
+ content: i.content,
252
+ canonicalHash: i.canonicalHash
253
+ }));
254
+ return `You are a knowledge clustering engine. Determine whether new observations create new knowledge or relate to existing items. Be conservative.
255
+
256
+ For each observation, assign an action:
257
+ CREATE \u2014 genuinely new knowledge, no existing item covers it
258
+ REINFORCE \u2014 confirms an existing item (provide targetKnowledgeItemId)
259
+ SUPERSEDE \u2014 replaces/corrects an existing item (provide targetKnowledgeItemId)
260
+ DEPRECATE \u2014 makes an existing item irrelevant (provide targetKnowledgeItemId)
261
+ IGNORE \u2014 duplicate, trivial, or too vague to keep
262
+
263
+ New observations:
264
+ ${JSON.stringify(obsJson, null, 2)}
265
+
266
+ Existing knowledge items:
267
+ ${JSON.stringify(itemsJson, null, 2)}
268
+
269
+ Return ONLY valid JSON: { "clusters": [{ "observationId": "...", "action": "CREATE"|"REINFORCE"|"SUPERSEDE"|"DEPRECATE"|"IGNORE", "targetKnowledgeItemId": "..." (only if action targets an existing item), "reasoning": "..." }] }`;
270
+ }
271
+ function parseClusteringResponse(text, observations) {
272
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
273
+ if (!jsonMatch) {
274
+ return observations.map((o) => ({ observationId: o.id, action: "CREATE", reasoning: "Parse failure \u2014 defaulting to CREATE" }));
275
+ }
276
+ const parsed = JSON.parse(jsonMatch[0]);
277
+ if (!parsed.clusters || !Array.isArray(parsed.clusters)) {
278
+ return observations.map((o) => ({ observationId: o.id, action: "CREATE", reasoning: "Parse failure \u2014 defaulting to CREATE" }));
279
+ }
280
+ return parsed.clusters.map((c) => ({
281
+ observationId: c.observationId,
282
+ action: c.action || "CREATE",
283
+ targetKnowledgeItemId: c.targetKnowledgeItemId || void 0,
284
+ reasoning: c.reasoning || ""
285
+ }));
286
+ }
287
+
288
+ // src/knowledge/passes/5-contradiction.ts
289
+ var CONTRADICTION_PROMPT_VERSION = "contradiction-v1";
290
+
291
+ // src/knowledge/passes/6-compile.ts
292
+ import { randomUUID as randomUUID2 } from "crypto";
293
+ function calculateConfidence(count) {
294
+ if (count >= 7) return "high";
295
+ if (count >= 3) return "medium";
296
+ return "low";
297
+ }
298
+ function compileKnowledge(clusters, existingItems, observations, project, now) {
299
+ const itemMap = new Map(existingItems.map((i) => [i.id, { ...i }]));
300
+ const obsMap = new Map(observations.map((o) => [o.id, o]));
301
+ const result = {
302
+ created: [],
303
+ reinforced: [],
304
+ superseded: [],
305
+ deprecated: [],
306
+ ignored: 0
307
+ };
308
+ for (const cluster of clusters) {
309
+ const obs = obsMap.get(cluster.observationId);
310
+ if (!obs) continue;
311
+ switch (cluster.action) {
312
+ case "CREATE": {
313
+ const newItem = {
314
+ id: `ki_${randomUUID2().slice(0, 8)}`,
315
+ canonicalHash: computeCanonicalHash(obs.content),
316
+ type: obs.type,
317
+ title: obs.content.slice(0, 80),
318
+ content: obs.content,
319
+ confidence: "low",
320
+ observationCount: 1,
321
+ authority: "AUTO",
322
+ status: "active",
323
+ supersededById: void 0,
324
+ enforce: false,
325
+ project,
326
+ scope: obs.scope || (obs.type === "rule" || obs.type === "lesson" ? "global" : "project"),
327
+ createdAt: now,
328
+ updatedAt: now,
329
+ lastSeenAt: now,
330
+ metadata: {}
331
+ };
332
+ result.created.push(newItem);
333
+ break;
334
+ }
335
+ case "REINFORCE": {
336
+ const target = itemMap.get(cluster.targetKnowledgeItemId);
337
+ if (!target) break;
338
+ target.observationCount += 1;
339
+ target.lastSeenAt = now;
340
+ target.updatedAt = now;
341
+ target.confidence = calculateConfidence(target.observationCount);
342
+ result.reinforced.push(target);
343
+ break;
344
+ }
345
+ case "SUPERSEDE": {
346
+ const target = itemMap.get(cluster.targetKnowledgeItemId);
347
+ if (target) {
348
+ const newItem = {
349
+ id: `ki_${randomUUID2().slice(0, 8)}`,
350
+ canonicalHash: computeCanonicalHash(obs.content),
351
+ type: obs.type,
352
+ title: obs.content.slice(0, 80),
353
+ content: obs.content,
354
+ confidence: "low",
355
+ observationCount: 1,
356
+ authority: "AUTO",
357
+ status: "active",
358
+ supersededById: void 0,
359
+ enforce: false,
360
+ project,
361
+ scope: obs.scope || (obs.type === "rule" || obs.type === "lesson" ? "global" : "project"),
362
+ createdAt: now,
363
+ updatedAt: now,
364
+ lastSeenAt: now,
365
+ metadata: {}
366
+ };
367
+ target.status = "superseded";
368
+ target.updatedAt = now;
369
+ target.supersededById = newItem.id;
370
+ result.superseded.push(target);
371
+ result.created.push(newItem);
372
+ }
373
+ break;
374
+ }
375
+ case "DEPRECATE": {
376
+ const target = itemMap.get(cluster.targetKnowledgeItemId);
377
+ if (target) {
378
+ target.status = "deprecated";
379
+ target.updatedAt = now;
380
+ result.deprecated.push(target);
381
+ }
382
+ break;
383
+ }
384
+ case "IGNORE":
385
+ result.ignored += 1;
386
+ break;
387
+ }
388
+ }
389
+ return result;
390
+ }
391
+
392
+ // src/knowledge/compiler.ts
393
+ var COMPILER_VERSION = "0.1.0";
394
+ function startCompile(events, sessionId, project, projectRoot, repo, transcriptPath) {
395
+ const git = getGitContext(projectRoot);
396
+ const session = {
397
+ id: sessionId,
398
+ project,
399
+ startedAt: Date.now() - 6e4,
400
+ endedAt: Date.now(),
401
+ gitBranch: git.branch,
402
+ gitCommit: git.commit,
403
+ provider: "agent",
404
+ model: "host-agent",
405
+ transcriptPath: transcriptPath || "",
406
+ observationCount: 0
407
+ };
408
+ repo.saveSession(session);
409
+ const prompt = buildExtractionPrompt(events);
410
+ return { sessionId, project, projectRoot, prompt };
411
+ }
412
+ function processExtraction(repo, responseText, sessionId, project, projectRoot) {
413
+ const rawObservations = parseExtractionResponse(responseText, sessionId, project);
414
+ const normalized = normalize(rawObservations);
415
+ const existingItems = repo.getKnowledgeItems(project, { status: "active" });
416
+ const existingKeys = existingItems.map((i) => computeCanonicalKey(i.content));
417
+ const canonicalized = canonicalize(normalized, existingKeys);
418
+ for (const obs of canonicalized.autoReinforced) {
419
+ const matchingItem = existingItems.find(
420
+ (item) => computeCanonicalKey(item.content) === obs.canonicalKey
421
+ );
422
+ if (matchingItem) {
423
+ const newCount = matchingItem.observationCount + 1;
424
+ const confidence = newCount >= 7 ? "high" : newCount >= 3 ? "medium" : "low";
425
+ repo.updateKnowledgeItem(matchingItem.id, {
426
+ observationCount: newCount,
427
+ lastSeenAt: Date.now(),
428
+ updatedAt: Date.now(),
429
+ confidence
430
+ });
431
+ }
432
+ }
433
+ repo.saveObservations(normalized);
434
+ if (canonicalized.needsClustering.length === 0) {
435
+ saveCompileRun(repo, sessionId, project, normalized.length, canonicalized.autoReinforced.length, 0, 0, 0, 0, 0, Date.now());
436
+ return {
437
+ status: "complete",
438
+ diagnostics: formatDiagnostics(normalized.length, canonicalized.autoReinforced.length, 0, 0, 0, 0, 0, project, sessionId)
439
+ };
440
+ }
441
+ const clusteringPrompt = buildClusteringPrompt(canonicalized.needsClustering, existingItems);
442
+ return {
443
+ status: "needs_clustering",
444
+ clusteringPrompt,
445
+ sessionId
446
+ };
447
+ }
448
+ function processClustering(repo, responseText, sessionId, project, projectRoot) {
449
+ const startedAt = Date.now();
450
+ const existingItems = repo.getKnowledgeItems(project, { status: "active" });
451
+ const observations = repo.getObservations(project);
452
+ const sessionObs = observations.filter((o) => o.sessionId === sessionId);
453
+ const canonicalized = canonicalize(sessionObs);
454
+ const needsClustering = canonicalized.needsClustering;
455
+ const clusters = parseClusteringResponse(responseText, needsClustering);
456
+ const contradictions = [];
457
+ const supersedeActions = clusters.filter((c) => c.action === "SUPERSEDE");
458
+ for (const s of supersedeActions) {
459
+ if (s.targetKnowledgeItemId) {
460
+ const target = existingItems.find((i) => i.id === s.targetKnowledgeItemId);
461
+ if (target) {
462
+ contradictions.push({
463
+ id: `con_${randomUUID3().slice(0, 8)}`,
464
+ project,
465
+ itemAId: target.id,
466
+ itemBId: s.observationId,
467
+ topic: target.title.slice(0, 50),
468
+ description: `"${target.content}" superseded by new observation`,
469
+ recommendation: "keep_newer",
470
+ resolved: false,
471
+ createdAt: Date.now()
472
+ });
473
+ }
474
+ }
475
+ }
476
+ for (const c of contradictions) {
477
+ repo.saveContradiction(c);
478
+ }
479
+ const now = Date.now();
480
+ const compiled = compileKnowledge(clusters, existingItems, needsClustering, project, now);
481
+ for (const item of compiled.created) repo.saveKnowledgeItem(item);
482
+ for (const item of compiled.reinforced) {
483
+ repo.updateKnowledgeItem(item.id, {
484
+ observationCount: item.observationCount,
485
+ lastSeenAt: item.lastSeenAt,
486
+ updatedAt: item.updatedAt,
487
+ confidence: item.confidence
488
+ });
489
+ }
490
+ for (const item of compiled.superseded) {
491
+ repo.updateKnowledgeItem(item.id, {
492
+ status: item.status,
493
+ updatedAt: item.updatedAt,
494
+ supersededById: item.supersededById
495
+ });
496
+ }
497
+ for (const item of compiled.deprecated) {
498
+ repo.updateKnowledgeItem(item.id, { status: item.status, updatedAt: item.updatedAt });
499
+ }
500
+ const totalObs = sessionObs.length;
501
+ saveCompileRun(repo, sessionId, project, totalObs, 0, compiled.created.length, compiled.reinforced.length, compiled.superseded.length, compiled.deprecated.length, compiled.ignored, startedAt);
502
+ return {
503
+ status: "complete",
504
+ diagnostics: formatDiagnostics(totalObs, 0, compiled.created.length, compiled.reinforced.length, compiled.superseded.length, compiled.deprecated.length, compiled.ignored, project, sessionId)
505
+ };
506
+ }
507
+ function saveCompileRun(repo, sessionId, project, observationsProcessed, autoReinforced, created, reinforced, superseded, deprecated, ignored, startedAt) {
508
+ const endedAt = Date.now();
509
+ const run = {
510
+ id: `cr_${randomUUID3().slice(0, 8)}`,
511
+ project,
512
+ sessionId,
513
+ compilerVersion: COMPILER_VERSION,
514
+ promptVersions: { extract: EXTRACT_PROMPT_VERSION, cluster: CLUSTER_PROMPT_VERSION, contradiction: CONTRADICTION_PROMPT_VERSION },
515
+ startedAt,
516
+ endedAt,
517
+ durationMs: endedAt - startedAt,
518
+ observationsProcessed,
519
+ knowledgeCreated: created,
520
+ knowledgeReinforced: reinforced + autoReinforced,
521
+ knowledgeDeprecated: deprecated,
522
+ knowledgeSuperseded: superseded,
523
+ knowledgeIgnored: ignored,
524
+ contradictionsDetected: 0,
525
+ diagnostics: ""
526
+ };
527
+ repo.saveCompileRun(run);
528
+ }
529
+ function formatDiagnostics(extracted, autoReinforced, created, reinforced, superseded, deprecated, ignored, project, sessionId) {
530
+ return [
531
+ `Loop Compiler v${COMPILER_VERSION}`,
532
+ `Project: ${project} | Session: ${sessionId}`,
533
+ ` ${extracted} observations processed`,
534
+ autoReinforced > 0 ? ` ${autoReinforced} auto-reinforced (no LLM needed)` : "",
535
+ ` ${created} new knowledge items`,
536
+ ` ${reinforced} reinforced`,
537
+ superseded > 0 ? ` ${superseded} superseded` : "",
538
+ deprecated > 0 ? ` ${deprecated} deprecated` : "",
539
+ ignored > 0 ? ` ${ignored} ignored` : ""
540
+ ].filter(Boolean).join("\n");
541
+ }
542
+
543
+ // src/mcp.ts
544
+ import { existsSync } from "fs";
545
+ import { randomUUID as randomUUID4 } from "crypto";
546
+ function defaultScope(type) {
547
+ return type === "rule" || type === "lesson" ? "global" : "project";
548
+ }
549
+ var cachedProjectRoot = null;
550
+ async function resolveRoots(server) {
551
+ try {
552
+ const result = await server.listRoots();
553
+ if (result.roots.length > 0) {
554
+ const uri = result.roots[0].uri;
555
+ if (uri.startsWith("file://")) {
556
+ cachedProjectRoot = uri.slice(7);
557
+ }
558
+ }
559
+ } catch {
560
+ }
561
+ }
562
+ function getResolvedProjectRoot() {
563
+ return cachedProjectRoot || findProjectRoot();
564
+ }
565
+ function getResolvedProjectId() {
566
+ return getProjectId(getResolvedProjectRoot());
567
+ }
568
+ async function startMcpServer() {
569
+ const server = new Server(
570
+ { name: "agentcache", version: "0.1.0" },
571
+ {
572
+ capabilities: { tools: {} },
573
+ instructions: "AgentCache is your knowledge cache. At the START of every session, call loop_inject_context to load compiled rules, lessons, decisions, and context. Submit observations INCREMENTALLY via loop_compile_submit as you learn them \u2014 do not wait until session end."
574
+ }
575
+ );
576
+ server.oninitialized = async () => {
577
+ await resolveRoots(server);
578
+ };
579
+ server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
580
+ await resolveRoots(server);
581
+ });
582
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
583
+ tools: [
584
+ {
585
+ name: "loop_inject_context",
586
+ description: "Get compiled engineering knowledge for this project. Returns global rules/lessons (apply everywhere) + project-specific decisions/context. Call this at the START of every session.",
587
+ inputSchema: {
588
+ type: "object",
589
+ properties: {
590
+ project: { type: "string", description: "Project identifier. Auto-detected from workspace roots if omitted." }
591
+ },
592
+ required: []
593
+ }
594
+ },
595
+ {
596
+ name: "loop_compile_submit",
597
+ description: "Submit observations extracted from your session. Call this INCREMENTALLY \u2014 each time you learn a rule, lesson, decision, or context item. Do NOT batch until end of session; sessions can terminate without warning.",
598
+ inputSchema: {
599
+ type: "object",
600
+ properties: {
601
+ observations: {
602
+ type: "array",
603
+ description: "Observations extracted from the session",
604
+ items: {
605
+ type: "object",
606
+ properties: {
607
+ type: { type: "string", enum: ["rule", "lesson", "decision", "context"], description: "rule=standing constraint, lesson=mistake+fix, decision=arch choice+rationale, context=current state" },
608
+ content: { type: "string", description: "The observation content" },
609
+ sourceQuote: { type: "string", description: "Optional quote from conversation that triggered this" },
610
+ confidence: { type: "string", enum: ["high", "medium"], description: "How confident: high=explicitly stated, medium=inferred" },
611
+ scope: { type: "string", enum: ["global", "project"], description: "global=applies to all projects, project=this project only. Defaults: rule/lesson->global, decision/context->project" }
612
+ },
613
+ required: ["type", "content", "confidence"]
614
+ }
615
+ },
616
+ project: { type: "string", description: "Project identifier. Auto-detected if omitted." }
617
+ },
618
+ required: ["observations"]
619
+ }
620
+ },
621
+ {
622
+ name: "loop_compile_cluster",
623
+ description: "Submit clustering decisions when loop_compile_submit returns needs_clustering. Determines whether observations create new knowledge or relate to existing items.",
624
+ inputSchema: {
625
+ type: "object",
626
+ properties: {
627
+ sessionId: { type: "string", description: "Session ID from loop_compile_submit response" },
628
+ clusters: {
629
+ type: "array",
630
+ items: {
631
+ type: "object",
632
+ properties: {
633
+ observationId: { type: "string" },
634
+ action: { type: "string", enum: ["CREATE", "REINFORCE", "SUPERSEDE", "DEPRECATE", "IGNORE"] },
635
+ targetKnowledgeItemId: { type: "string", description: "Required for REINFORCE, SUPERSEDE, DEPRECATE" },
636
+ reasoning: { type: "string" }
637
+ },
638
+ required: ["observationId", "action", "reasoning"]
639
+ }
640
+ },
641
+ project: { type: "string", description: "Project identifier. Auto-detected if omitted." }
642
+ },
643
+ required: ["sessionId", "clusters"]
644
+ }
645
+ },
646
+ {
647
+ name: "loop_compile_extract",
648
+ description: "For PREVIOUS sessions stored as transcript files. Reads a queued transcript and returns an extraction prompt for you to process. After processing, call loop_compile_submit with the results.",
649
+ inputSchema: {
650
+ type: "object",
651
+ properties: {
652
+ project: { type: "string", description: "Project identifier. Auto-detected if omitted." }
653
+ },
654
+ required: []
655
+ }
656
+ },
657
+ {
658
+ name: "loop_enforce",
659
+ description: "Check if a tool call is allowed by Loop's policy rules. Call this BEFORE executing risky operations (file deletions, force pushes, etc). Returns allow or block with reason.",
660
+ inputSchema: {
661
+ type: "object",
662
+ properties: {
663
+ tool_name: { type: "string", description: "Name of the tool being called (e.g. 'Bash', 'Write')" },
664
+ tool_input: { type: "object", description: "The tool's input parameters" },
665
+ project: { type: "string", description: "Project identifier. Auto-detected if omitted." }
666
+ },
667
+ required: ["tool_name"]
668
+ }
669
+ },
670
+ {
671
+ name: "loop_save_observation",
672
+ description: "Save a single observation immediately with USER authority (never overwritten by compiler). Use for important rules or decisions that should persist permanently.",
673
+ inputSchema: {
674
+ type: "object",
675
+ properties: {
676
+ type: { type: "string", enum: ["rule", "lesson", "decision", "context"] },
677
+ content: { type: "string", description: "The observation content" },
678
+ enforce: { type: "boolean", description: "If true, this rule will BLOCK tool calls that violate it" },
679
+ scope: { type: "string", enum: ["global", "project"], description: "Defaults: rule/lesson->global, decision/context->project" },
680
+ project: { type: "string", description: "Project identifier. Auto-detected if omitted." }
681
+ },
682
+ required: ["type", "content"]
683
+ }
684
+ },
685
+ {
686
+ name: "loop_get_knowledge",
687
+ description: "Query knowledge items from Loop's compiled database.",
688
+ inputSchema: {
689
+ type: "object",
690
+ properties: {
691
+ type: { type: "string", enum: ["rule", "lesson", "decision", "context"] },
692
+ status: { type: "string", enum: ["active", "deprecated", "superseded", "archived"] },
693
+ scope: { type: "string", enum: ["global", "project"] },
694
+ project: { type: "string", description: "Filter by project. Omit to see all." }
695
+ },
696
+ required: []
697
+ }
698
+ },
699
+ {
700
+ name: "loop_deprecate_knowledge",
701
+ description: "Mark a knowledge item as deprecated. Use when a rule, lesson, or decision is no longer valid. Works on both auto-compiled and user-saved items.",
702
+ inputSchema: {
703
+ type: "object",
704
+ properties: {
705
+ id: { type: "string", description: "Knowledge item ID to deprecate" },
706
+ reason: { type: "string", description: "Why this item is being deprecated" }
707
+ },
708
+ required: ["id", "reason"]
709
+ }
710
+ }
711
+ ]
712
+ }));
713
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
714
+ if (!isLoopInitialized()) {
715
+ return { content: [{ type: "text", text: "Loop not initialized. Run: agentcache setup" }], isError: true };
716
+ }
717
+ const repo = new SqliteKnowledgeRepository(getDbPath());
718
+ const projectRoot = getResolvedProjectRoot();
719
+ const detectedProject = getResolvedProjectId();
720
+ try {
721
+ switch (request.params.name) {
722
+ case "loop_inject_context": {
723
+ const args = request.params.arguments || {};
724
+ const project = args.project || detectedProject;
725
+ const items = repo.getKnowledgeForContext(project);
726
+ const rules = items.filter((i) => i.type === "rule").slice(0, 20);
727
+ const lessons = items.filter((i) => i.type === "lesson").slice(0, 10);
728
+ const decisions = items.filter((i) => i.type === "decision").slice(0, 10);
729
+ const context = items.filter((i) => i.type === "context").slice(0, 5);
730
+ let output = "";
731
+ if (rules.length) {
732
+ output += "# Rules\n" + rules.map((r) => `- ${r.content}`).join("\n") + "\n\n";
733
+ }
734
+ if (lessons.length) {
735
+ output += "# Lessons\n" + lessons.map((l) => `- ${l.content}`).join("\n") + "\n\n";
736
+ }
737
+ if (decisions.length) {
738
+ output += "# Decisions\n" + decisions.map((d) => `- ${d.content}`).join("\n") + "\n\n";
739
+ }
740
+ if (context.length) {
741
+ output += "# Context\n" + context.map((c) => `- ${c.content}`).join("\n") + "\n\n";
742
+ }
743
+ if (!output) output = "No compiled knowledge yet. This will populate as you use Loop across sessions.\n";
744
+ const pendingCount = repo.getPendingCount();
745
+ if (pendingCount > 0) {
746
+ output = `<!-- ${pendingCount} previous session(s) pending compilation. Call loop_compile_extract to process. -->
747
+
748
+ ` + output;
749
+ }
750
+ output += "\n---\nIMPORTANT: Submit observations incrementally as they happen during this session.\nWhen you learn something (rule, lesson, decision, context), call loop_compile_submit immediately.\nDo NOT wait until the end \u2014 sessions can terminate without warning.\n";
751
+ return { content: [{ type: "text", text: output.trim() }] };
752
+ }
753
+ case "loop_compile_submit": {
754
+ const args = request.params.arguments;
755
+ const project = args.project || detectedProject;
756
+ const sessionId = `sess_${randomUUID4().slice(0, 8)}`;
757
+ const observationsWithScope = args.observations.map((o) => ({
758
+ ...o,
759
+ scope: o.scope || defaultScope(o.type)
760
+ }));
761
+ const responseText = JSON.stringify({ observations: observationsWithScope });
762
+ startCompile([], sessionId, project, projectRoot, repo);
763
+ const result = processExtraction(repo, responseText, sessionId, project, projectRoot);
764
+ if (result.status === "complete") {
765
+ return { content: [{ type: "text", text: JSON.stringify({ status: "complete", diagnostics: result.diagnostics }) }] };
766
+ }
767
+ return {
768
+ content: [{ type: "text", text: JSON.stringify({ status: "needs_clustering", sessionId: result.sessionId, clusteringContext: result.clusteringPrompt }) }]
769
+ };
770
+ }
771
+ case "loop_compile_cluster": {
772
+ const args = request.params.arguments;
773
+ const project = args.project || detectedProject;
774
+ const responseText = JSON.stringify({ clusters: args.clusters });
775
+ const result = processClustering(repo, responseText, args.sessionId, project, projectRoot);
776
+ return { content: [{ type: "text", text: JSON.stringify({ status: "complete", diagnostics: result.diagnostics }) }] };
777
+ }
778
+ case "loop_compile_extract": {
779
+ const args = request.params.arguments || {};
780
+ const entry = repo.popPendingTranscript();
781
+ if (!entry) {
782
+ return { content: [{ type: "text", text: JSON.stringify({ message: "No pending sessions to compile." }) }] };
783
+ }
784
+ if (!existsSync(entry.transcriptPath)) {
785
+ return { content: [{ type: "text", text: JSON.stringify({ message: `Transcript not found: ${entry.transcriptPath}, skipped.` }) }] };
786
+ }
787
+ const events = parseTranscript(entry.transcriptPath);
788
+ if (events.length === 0) {
789
+ return { content: [{ type: "text", text: JSON.stringify({ message: "Empty transcript, skipped." }) }] };
790
+ }
791
+ const sessionId = `sess_${randomUUID4().slice(0, 8)}`;
792
+ const project = entry.project || (args.project || detectedProject);
793
+ const state = startCompile(events, sessionId, project, entry.projectRoot || projectRoot, repo, entry.transcriptPath);
794
+ return { content: [{ type: "text", text: JSON.stringify({ sessionId: state.sessionId, prompt: state.prompt }) }] };
795
+ }
796
+ case "loop_enforce": {
797
+ const args = request.params.arguments;
798
+ const project = args.project || detectedProject;
799
+ const input = { tool_name: args.tool_name, tool_input: args.tool_input || {} };
800
+ const result = evaluatePolicy(input, repo.getEnforcedRules(project));
801
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
802
+ }
803
+ case "loop_save_observation": {
804
+ const args = request.params.arguments;
805
+ const project = args.project || detectedProject;
806
+ const scope = args.scope || defaultScope(args.type);
807
+ const sessionId = `manual_${randomUUID4().slice(0, 8)}`;
808
+ repo.saveSession({
809
+ id: sessionId,
810
+ project,
811
+ startedAt: Date.now(),
812
+ endedAt: Date.now(),
813
+ gitBranch: "",
814
+ gitCommit: "",
815
+ provider: "manual",
816
+ model: "manual",
817
+ transcriptPath: "",
818
+ observationCount: 1
819
+ });
820
+ const obs = {
821
+ id: `obs_${randomUUID4().slice(0, 8)}`,
822
+ sessionId,
823
+ timestamp: Date.now(),
824
+ type: args.type,
825
+ content: args.content,
826
+ sourceQuote: "manual entry via MCP",
827
+ confidence: "high",
828
+ project,
829
+ scope
830
+ };
831
+ repo.saveObservation(obs);
832
+ repo.saveKnowledgeItem({
833
+ id: `ki_${randomUUID4().slice(0, 8)}`,
834
+ canonicalHash: computeCanonicalHash(args.content),
835
+ type: args.type,
836
+ title: args.content.slice(0, 80),
837
+ content: args.content,
838
+ confidence: "high",
839
+ observationCount: 1,
840
+ authority: "USER",
841
+ status: "active",
842
+ enforce: args.enforce || false,
843
+ project,
844
+ scope,
845
+ createdAt: Date.now(),
846
+ updatedAt: Date.now(),
847
+ lastSeenAt: Date.now(),
848
+ metadata: { source: "mcp" }
849
+ });
850
+ return { content: [{ type: "text", text: JSON.stringify({ saved: true, scope }) }] };
851
+ }
852
+ case "loop_get_knowledge": {
853
+ const args = request.params.arguments || {};
854
+ const project = args.project || detectedProject;
855
+ const items = repo.getKnowledgeItems(project, {
856
+ type: args.type,
857
+ status: args.status || "active"
858
+ });
859
+ const filtered = args.scope ? items.filter((i) => i.scope === args.scope) : items;
860
+ const summary = filtered.map((i) => `[${i.id}] [${i.scope}/${i.confidence}] (${i.type}) ${i.content}`).join("\n");
861
+ return { content: [{ type: "text", text: summary || "No knowledge items found." }] };
862
+ }
863
+ case "loop_deprecate_knowledge": {
864
+ const args = request.params.arguments;
865
+ const item = repo.getKnowledgeItem(args.id);
866
+ if (!item) {
867
+ return { content: [{ type: "text", text: JSON.stringify({ error: `Item not found: ${args.id}` }) }], isError: true };
868
+ }
869
+ repo.updateKnowledgeItem(args.id, { status: "deprecated", updatedAt: Date.now() });
870
+ return { content: [{ type: "text", text: JSON.stringify({ deprecated: true, id: args.id, content: item.content, reason: args.reason }) }] };
871
+ }
872
+ default:
873
+ return { content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }], isError: true };
874
+ }
875
+ } finally {
876
+ repo.close();
877
+ }
878
+ });
879
+ const transport = new StdioServerTransport();
880
+ await server.connect(transport);
881
+ }
882
+ export {
883
+ startMcpServer
884
+ };