@cairnkeep/cli 2.16.0 → 2.17.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 (59) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +1 -0
  3. package/docs/README.md +8 -0
  4. package/docs/agents.md +20 -0
  5. package/docs/context-intelligence.md +143 -0
  6. package/docs/context-packs.md +33 -0
  7. package/docs/domain-knowledge.md +29 -0
  8. package/docs/learning/CURRICULUM-MAP.md +3 -1
  9. package/docs/learning/FEATURE-GUIDE.md +22 -3
  10. package/docs/learning/README.md +1 -0
  11. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  12. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  13. package/docs/learning/lessons/L02-installation.md +2 -2
  14. package/docs/learning/lessons/L03-first-project.md +1 -1
  15. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  16. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  17. package/docs/learning/lessons/L21-context-packs.md +1 -1
  18. package/docs/learning/lessons/L22-native-windows.md +2 -2
  19. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  20. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  21. package/docs/learning/lessons/L25-playbooks.md +1 -1
  22. package/docs/learning/lessons/L26-context-intelligence.md +180 -0
  23. package/docs/learning/tracks/evidence-and-evaluation.md +1 -0
  24. package/docs/learning/tracks/operator.md +1 -0
  25. package/docs/learning/tracks/practitioner.md +1 -0
  26. package/docs/learning/video-scripts/V26-context-intelligence.md +144 -0
  27. package/docs/operating.md +44 -0
  28. package/docs/privacy-and-data-flow.md +22 -0
  29. package/docs/storage.md +21 -0
  30. package/docs/work-evidence.md +14 -0
  31. package/examples/eval/bundled-fake.json +1 -1
  32. package/mcp-memory-server/dist/context-pack-cli.js +3 -1
  33. package/mcp-memory-server/dist/context-pack-retrieval.js +413 -0
  34. package/mcp-memory-server/dist/context-pack.js +171 -3
  35. package/mcp-memory-server/dist/domain-retrieval-provider.js +257 -0
  36. package/mcp-memory-server/dist/index.js +73 -387
  37. package/mcp-memory-server/dist/mcp-tool-catalog.js +2 -0
  38. package/mcp-memory-server/dist/memory-extraction.js +87 -0
  39. package/mcp-memory-server/dist/memory-proposal-cli.js +110 -0
  40. package/mcp-memory-server/dist/memory-proposal-schema.js +78 -0
  41. package/mcp-memory-server/dist/memory-proposal-store.js +273 -0
  42. package/mcp-memory-server/dist/platform-security.js +32 -26
  43. package/mcp-memory-server/dist/retrieval-benchmark.js +199 -0
  44. package/mcp-memory-server/dist/reviewed-memory-store.js +281 -0
  45. package/mcp-memory-server/dist/work-evidence-schema.js +17 -0
  46. package/mcp-memory-server/dist/work-evidence-store.js +57 -1
  47. package/mcp-memory-server/package.json +8 -1
  48. package/package.json +1 -1
  49. package/schemas/context-usage-receipt.schema.json +41 -0
  50. package/schemas/memory-proposal.schema.json +65 -0
  51. package/schemas/retrieval-benchmark.schema.json +64 -0
  52. package/scripts/cairn-cli.mjs +2 -0
  53. package/scripts/completion.sh +17 -6
  54. package/scripts/doctor.sh +4 -2
  55. package/scripts/test-completion.sh +8 -1
  56. package/scripts/test-doctor.sh +21 -0
  57. package/scripts/test-release-sbom.sh +3 -0
  58. package/scripts/test-windows-native.mjs +30 -0
  59. package/scripts/windows-platform.mjs +8 -5
@@ -14,13 +14,16 @@ import { z } from "zod";
14
14
  import { artifactIdSchema, artifactKindSchema, artifactMediaTypeSchema, artifactNodeRefSchema, artifactProvenanceSchema, artifactSessionRefSchema, compactionSummaryInputContentSchema, diffContentSchema, generatedFileInputContentSchema, isArtifactHttpEnabled, isArtifactStoreEnabled, testOutputContentSchema, } from "./artifact-schema.js";
15
15
  import { isCapabilityContractEnabled, resolveCapabilityStatus, } from "./capability-config.js";
16
16
  import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText, } from "./embeddings.js";
17
+ import { queryDomainKnowledge, resolveDomainRetrievalProvider } from "./domain-retrieval-provider.js";
17
18
  import { computeRepoState, exploreCacheKey, normalizeExploreQuery, readExploreCache, writeExploreCache, } from "./explore-cache.js";
18
19
  import { canonicalTagsSchema, isTypedMemoryNodesEnabled, memoryImportEnvelopeSchema, noteNodeSchema, nodeTypeSchema, } from "./node-schema.js";
19
- import { attachNodeMetadata, applyReviewedTypedNode, commitMemoryImport, createTypedNode, deleteTypedNode, getTypedNode, invalidateReviewedTypedNode, listTypedHistory, planMemoryImport, supersedeTypedNode, } from "./node-store.js";
20
- import { contextPackHttpEnabled, contextPacksEnabled, listVisibleContext, readVisibleContext, relatedVisibleContext, searchVisibleContext } from "./context-pack.js";
20
+ import { attachNodeMetadata, commitMemoryImport, createTypedNode, deleteTypedNode, getTypedNode, listTypedHistory, planMemoryImport, supersedeTypedNode, } from "./node-store.js";
21
+ import { contextPackHttpEnabled, contextPacksEnabled, listVisibleContext, readVisibleContext, relatedVisibleContext, searchVisibleContext, treeVisibleContext } from "./context-pack.js";
21
22
  import { metadataForTool } from "./mcp-tool-catalog.js";
22
23
  import { profileAllowsTool, resolveMcpToolProfile } from "./mcp-tool-profile.js";
23
24
  import { isWorkEvidenceEnabled } from "./work-evidence-schema.js";
25
+ import { extractMemoryCandidates as extractMemoryCandidatesFromProvider } from "./memory-extraction.js";
26
+ import { applyReviewedMemory, invalidateReviewedMemory } from "./reviewed-memory-store.js";
24
27
  const MINIMUM_NODE_MAJOR = 22;
25
28
  const nodeMajor = Number.parseInt(process.versions.node, 10);
26
29
  if (!Number.isInteger(nodeMajor) || nodeMajor < MINIMUM_NODE_MAJOR) {
@@ -223,33 +226,6 @@ function historyPrefix(baseKey) {
223
226
  function historySnapshotKey(baseKey, timestamp) {
224
227
  return `${historyPrefix(baseKey)}${timestamp}-${randomUUID()}`;
225
228
  }
226
- const reviewedRecordSchema = z.object({
227
- schema_version: z.literal(1),
228
- review_id: z.string(),
229
- key: z.string(),
230
- value_hash: z.string(),
231
- state: z.enum(["active", "superseded", "invalidated"]),
232
- applied_at: z.string(),
233
- superseded_at: z.string().optional(),
234
- superseded_by: z.string().optional(),
235
- invalidated_at: z.string().optional(),
236
- invalidation_reason: z.string().optional(),
237
- });
238
- function reviewedRecordKey(reviewId) {
239
- return `${REVIEWED_NAMESPACE}/${reviewId}`;
240
- }
241
- function parseReviewedRecord(value) {
242
- const parsed = reviewedRecordSchema.safeParse(value);
243
- if (!parsed.success) {
244
- throw new Error("Stored reviewed-memory provenance is invalid; refusing to continue.");
245
- }
246
- return parsed.data;
247
- }
248
- async function inImmediateScopeTransaction(agent, operation) {
249
- const transaction = agent.getDatabase().transaction(operation);
250
- const immediate = transaction.immediate;
251
- return immediate();
252
- }
253
229
  function visibleEntries(entries, includeHistory) {
254
230
  if (includeHistory) {
255
231
  return entries;
@@ -340,113 +316,6 @@ function truncateOutput(value, maxLength = 12000) {
340
316
  }
341
317
  return `${value.slice(0, maxLength)}\n...[truncated ${value.length - maxLength} chars]`;
342
318
  }
343
- function stripMarkdownFences(value) {
344
- return value
345
- .trim()
346
- .replace(/^```(?:json)?\s*/i, "")
347
- .replace(/\s*```$/, "")
348
- .trim();
349
- }
350
- function parseJsonResponse(value) {
351
- const stripped = stripMarkdownFences(value);
352
- try {
353
- return JSON.parse(stripped);
354
- }
355
- catch {
356
- const firstBrace = stripped.indexOf("{");
357
- const lastBrace = stripped.lastIndexOf("}");
358
- if (firstBrace !== -1 && lastBrace > firstBrace) {
359
- return JSON.parse(stripped.slice(firstBrace, lastBrace + 1));
360
- }
361
- throw new Error(`Failed to parse JSON response: ${truncateOutput(stripped, 1000)}`);
362
- }
363
- }
364
- function sanitizeExtractionCandidates(value, fallbackCategory) {
365
- if (!Array.isArray(value)) {
366
- return [];
367
- }
368
- return value
369
- .map((item) => {
370
- if (!item || typeof item !== "object") {
371
- return null;
372
- }
373
- const raw = item;
374
- const key = typeof raw.key === "string" ? raw.key.trim() : "";
375
- const candidateValue = typeof raw.value === "string" ? raw.value.trim() : "";
376
- if (!key || !candidateValue) {
377
- return null;
378
- }
379
- const category = typeof raw.category === "string" && raw.category.trim()
380
- ? raw.category.trim()
381
- : fallbackCategory;
382
- const importance = typeof raw.importance === "number"
383
- ? Math.max(0, Math.min(1, raw.importance))
384
- : undefined;
385
- return {
386
- key,
387
- value: candidateValue,
388
- category,
389
- importance,
390
- };
391
- })
392
- .filter((candidate) => candidate !== null);
393
- }
394
- async function extractMemoryCandidates(content, modelOverride, category) {
395
- const apiKey = process.env.CAIRN_LLM_API_KEY;
396
- if (!apiKey) {
397
- throw new Error("CAIRN_LLM_API_KEY is not set.");
398
- }
399
- const rawUrl = process.env.CAIRN_LLM_API_URL;
400
- if (!rawUrl) {
401
- throw new Error("CAIRN_LLM_API_URL is not set.");
402
- }
403
- const apiUrl = rawUrl.trim().replace(/\/+$/, "");
404
- const model = (modelOverride ?? process.env.CAIRN_LLM_EXTRACTION_MODEL)?.trim();
405
- if (!model) {
406
- throw new Error("CAIRN_LLM_EXTRACTION_MODEL is not set.");
407
- }
408
- const systemPrompt = [
409
- "You extract durable memory candidates from development notes.",
410
- "Return ONLY valid JSON, no markdown fences.",
411
- "Schema: {\"candidates\":[{\"key\":\"decisions/cache-rule\",\"value\":\"...\",\"category\":\"decision\",\"importance\":0.92}]}",
412
- "Only include genuinely reusable knowledge.",
413
- "Skip trivial status notes, temporary branch details, and duplicated points.",
414
- "Prefer short kebab-case keys with a useful prefix such as decisions/, pitfalls/, patterns/, bugs/, constraints/, preferences/, conventions/.",
415
- "Do not invent dates unless they are explicitly present in the source text.",
416
- category ? `Bias extraction toward category: ${category}.` : "",
417
- ].filter(Boolean).join(" ");
418
- const response = await fetch(`${apiUrl}/chat/completions`, {
419
- method: "POST",
420
- headers: {
421
- Authorization: `Bearer ${apiKey}`,
422
- "Content-Type": "application/json",
423
- },
424
- body: JSON.stringify({
425
- model,
426
- messages: [
427
- { role: "system", content: systemPrompt },
428
- { role: "user", content },
429
- ],
430
- temperature: 0.1,
431
- max_tokens: 1200,
432
- }),
433
- signal: AbortSignal.timeout(120000),
434
- });
435
- if (!response.ok) {
436
- const text = await response.text();
437
- throw new Error(`Extraction request failed with ${response.status}: ${text}`);
438
- }
439
- const payload = (await response.json());
440
- const rawContent = payload.choices?.[0]?.message?.content;
441
- if (!rawContent) {
442
- throw new Error("Extraction model returned no content.");
443
- }
444
- const parsed = parseJsonResponse(rawContent);
445
- return {
446
- model,
447
- candidates: sanitizeExtractionCandidates(parsed.candidates, category),
448
- };
449
- }
450
319
  async function runCommand(command, args, timeoutMs, env = process.env) {
451
320
  return new Promise((resolvePromise, reject) => {
452
321
  const child = spawn(command, args, {
@@ -492,40 +361,6 @@ async function readStdin() {
492
361
  function defaultAnythingLLMWorkspace(config) {
493
362
  return config.anythingllm_workspaces?.find((workspace) => workspace !== "engineering-patterns");
494
363
  }
495
- async function callAnythingLLM(workspace, query) {
496
- const apiKey = process.env.ANYTHINGLLM_API_KEY;
497
- if (!apiKey) {
498
- throw new Error("ANYTHINGLLM_API_KEY is not set.");
499
- }
500
- const baseUrl = process.env.ANYTHINGLLM_BASE_URL ?? "http://localhost:3001";
501
- const response = await fetch(`${baseUrl}/api/v1/workspace/${encodeURIComponent(workspace)}/chat`, {
502
- method: "POST",
503
- headers: {
504
- Authorization: `Bearer ${apiKey}`,
505
- "Content-Type": "application/json",
506
- },
507
- body: JSON.stringify({
508
- message: query,
509
- mode: "query",
510
- }),
511
- signal: AbortSignal.timeout(120000),
512
- });
513
- if (!response.ok) {
514
- const text = await response.text();
515
- throw new Error(`AnythingLLM request failed with ${response.status}: ${text}`);
516
- }
517
- const payload = (await response.json());
518
- const directText = [
519
- payload.textResponse,
520
- payload.response,
521
- payload.message,
522
- payload.text,
523
- ].find((value) => typeof value === "string");
524
- if (typeof directText === "string") {
525
- return directText;
526
- }
527
- return JSON.stringify(payload, null, 2);
528
- }
529
364
  function entryText(entry) {
530
365
  const metadata = [entry.node_type, ...(entry.tags ?? [])].filter(Boolean).join("\n");
531
366
  return [entry.key, entry.value, metadata].filter(Boolean).join("\n");
@@ -873,6 +708,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
873
708
  const artifactToolsEnabled = isArtifactStoreEnabled() && (!context.remote || isArtifactHttpEnabled());
874
709
  const contextPackToolsEnabled = contextPacksEnabled() && (!context.remote || contextPackHttpEnabled());
875
710
  const workEvidenceToolsEnabled = isWorkEvidenceEnabled() && !context.remote;
711
+ const contextUsageToolsEnabled = process.env.CAIRN_CONTEXT_USAGE === "1" && workEvidenceToolsEnabled;
876
712
  if (artifactToolsEnabled && context.remote && !context.projectId) {
877
713
  throw new ClientContextError("Remote artifact access requires X-Cairn-Project.");
878
714
  }
@@ -1317,7 +1153,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1317
1153
  category: z.enum(["decision", "preference", "pattern", "pitfall", "constraint", "bug", "convention"]).optional(),
1318
1154
  }),
1319
1155
  }, async ({ scope, content, model, category }) => {
1320
- const extracted = await extractMemoryCandidates(content, model, category);
1156
+ const extracted = await extractMemoryCandidatesFromProvider(content, model, category);
1321
1157
  const payload = {
1322
1158
  scope,
1323
1159
  model: extracted.model,
@@ -1424,102 +1260,17 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1424
1260
  value: z.string(),
1425
1261
  }),
1426
1262
  }, async ({ scope, review_id, key, value }) => {
1427
- assertWritableMemoryKey(key);
1428
- const agent = await openScope(scope, true, scopeOptions);
1429
- if (!agent) {
1430
- throw new Error(`Unable to open scope ${scope}.`);
1431
- }
1432
- try {
1433
- const payload = await inImmediateScopeTransaction(agent, async () => {
1434
- const recordKey = reviewedRecordKey(review_id);
1435
- const valueHash = hashText(value);
1436
- const stored = await agent.kv.get(recordKey);
1437
- if (stored !== undefined) {
1438
- const existing = parseReviewedRecord(stored);
1439
- if (existing.key !== key || existing.value_hash !== valueHash) {
1440
- throw new Error(`Review id ${review_id} was already used with different content.`);
1441
- }
1442
- if (existing.state !== "active") {
1443
- throw new Error(`Review id ${review_id} is ${existing.state} and cannot be reapplied.`);
1444
- }
1445
- return {
1446
- ok: true,
1447
- scope,
1448
- review_id,
1449
- key,
1450
- applied: false,
1451
- idempotent: true,
1452
- snapshot_key: null,
1453
- };
1454
- }
1455
- const appliedAt = new Date().toISOString();
1456
- const provenance = await agent.kv.list(`${REVIEWED_NAMESPACE}/`);
1457
- for (const entry of provenance) {
1458
- const previousRecord = parseReviewedRecord(entry.value);
1459
- if (previousRecord.state === "active" && previousRecord.key === key) {
1460
- await agent.kv.set(entry.key, {
1461
- ...previousRecord,
1462
- state: "superseded",
1463
- superseded_at: appliedAt,
1464
- superseded_by: review_id,
1465
- });
1466
- }
1467
- }
1468
- let snapshotKey = null;
1469
- if (typedNodesEnabled) {
1470
- const changed = await applyReviewedTypedNode({
1471
- agent,
1472
- scope,
1473
- review_id,
1474
- key,
1475
- value,
1476
- node_type: "memory",
1477
- tags: [],
1478
- in_transaction: true,
1479
- });
1480
- snapshotKey = changed.snapshot_key;
1481
- }
1482
- else {
1483
- const previous = await agent.kv.get(key);
1484
- const previousValue = previous === undefined ? undefined : normalizeValue(previous);
1485
- if (previousValue !== undefined && previousValue !== value) {
1486
- snapshotKey = historySnapshotKey(key, appliedAt);
1487
- await agent.kv.set(snapshotKey, {
1488
- value: previousValue,
1489
- superseded_at: appliedAt,
1490
- superseded_reason: `reviewed memory ${review_id}`,
1491
- });
1492
- }
1493
- await agent.kv.set(key, value);
1494
- }
1495
- await agent.kv.set(recordKey, {
1496
- schema_version: 1,
1497
- review_id,
1498
- key,
1499
- value_hash: valueHash,
1500
- state: "active",
1501
- applied_at: appliedAt,
1502
- });
1503
- return {
1504
- ok: true,
1505
- scope,
1506
- review_id,
1507
- key,
1508
- applied: true,
1509
- idempotent: false,
1510
- snapshot_key: snapshotKey,
1511
- };
1512
- });
1513
- const { linkActiveWorkEvidence } = await import("./work-evidence-store.js");
1514
- await linkActiveWorkEvidence(process.cwd(), { kind: "reviewed_memory", scope, review_id, key });
1515
- return {
1516
- content: [{ type: "text", text: asToolText(payload) }],
1517
- structuredContent: payload,
1518
- };
1519
- }
1520
- finally {
1521
- await agent.close();
1522
- }
1263
+ const payload = await applyReviewedMemory(scope, review_id, key, value, {
1264
+ cwd: process.cwd(),
1265
+ projectId: context.projectId,
1266
+ typed: typedNodesEnabled,
1267
+ });
1268
+ const { linkActiveWorkEvidence } = await import("./work-evidence-store.js");
1269
+ await linkActiveWorkEvidence(process.cwd(), { kind: "reviewed_memory", scope, review_id, key });
1270
+ return {
1271
+ content: [{ type: "text", text: asToolText(payload) }],
1272
+ structuredContent: payload,
1273
+ };
1523
1274
  });
1524
1275
  registerTool("memory_invalidate_reviewed", {
1525
1276
  description: "Invalidate a reviewed revision, removing it only when the live value still matches.",
@@ -1530,121 +1281,15 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1530
1281
  reason: z.string().max(512).optional(),
1531
1282
  }),
1532
1283
  }, async ({ scope, review_id, key, reason }) => {
1533
- assertWritableMemoryKey(key);
1534
- const agent = await openScope(scope, true, scopeOptions);
1535
- if (!agent) {
1536
- throw new Error(`Unable to open scope ${scope}.`);
1537
- }
1538
- try {
1539
- const payload = await inImmediateScopeTransaction(agent, async () => {
1540
- const recordKey = reviewedRecordKey(review_id);
1541
- const stored = await agent.kv.get(recordKey);
1542
- if (stored === undefined) {
1543
- const invalidatedAt = new Date().toISOString();
1544
- await agent.kv.set(recordKey, {
1545
- schema_version: 1,
1546
- review_id,
1547
- key,
1548
- value_hash: "",
1549
- state: "invalidated",
1550
- applied_at: invalidatedAt,
1551
- invalidated_at: invalidatedAt,
1552
- ...(reason ? { invalidation_reason: reason } : {}),
1553
- });
1554
- return {
1555
- ok: true,
1556
- scope,
1557
- review_id,
1558
- key,
1559
- invalidated: true,
1560
- idempotent: false,
1561
- missing: true,
1562
- removed: false,
1563
- current_changed: false,
1564
- snapshot_key: null,
1565
- };
1566
- }
1567
- const record = parseReviewedRecord(stored);
1568
- if (record.key !== key) {
1569
- throw new Error(`Review id ${review_id} belongs to a different memory key.`);
1570
- }
1571
- if (record.state === "invalidated") {
1572
- return {
1573
- ok: true,
1574
- scope,
1575
- review_id,
1576
- key: record.key,
1577
- invalidated: false,
1578
- idempotent: true,
1579
- missing: record.value_hash === "",
1580
- removed: false,
1581
- current_changed: false,
1582
- snapshot_key: null,
1583
- };
1584
- }
1585
- const invalidatedAt = new Date().toISOString();
1586
- let currentChanged = false;
1587
- let removed = false;
1588
- let snapshotKey = null;
1589
- if (record.state === "active") {
1590
- const current = await agent.kv.get(record.key);
1591
- if (current !== undefined) {
1592
- const currentValue = normalizeValue(current);
1593
- if (hashText(currentValue) === record.value_hash) {
1594
- if (typedNodesEnabled) {
1595
- const changed = await invalidateReviewedTypedNode({
1596
- agent,
1597
- scope,
1598
- review_id,
1599
- key: record.key,
1600
- reason,
1601
- in_transaction: true,
1602
- });
1603
- snapshotKey = changed.snapshot_key;
1604
- }
1605
- else {
1606
- snapshotKey = historySnapshotKey(record.key, invalidatedAt);
1607
- await agent.kv.set(snapshotKey, {
1608
- value: currentValue,
1609
- superseded_at: invalidatedAt,
1610
- superseded_reason: reason ?? `reviewed memory ${review_id} invalidated`,
1611
- });
1612
- await agent.kv.delete(record.key);
1613
- }
1614
- removed = true;
1615
- }
1616
- else {
1617
- currentChanged = true;
1618
- }
1619
- }
1620
- }
1621
- await agent.kv.set(recordKey, {
1622
- ...record,
1623
- state: "invalidated",
1624
- invalidated_at: invalidatedAt,
1625
- ...(reason ? { invalidation_reason: reason } : {}),
1626
- });
1627
- return {
1628
- ok: true,
1629
- scope,
1630
- review_id,
1631
- key: record.key,
1632
- invalidated: true,
1633
- idempotent: false,
1634
- missing: false,
1635
- removed,
1636
- current_changed: currentChanged,
1637
- snapshot_key: snapshotKey,
1638
- };
1639
- });
1640
- return {
1641
- content: [{ type: "text", text: asToolText(payload) }],
1642
- structuredContent: payload,
1643
- };
1644
- }
1645
- finally {
1646
- await agent.close();
1647
- }
1284
+ const payload = await invalidateReviewedMemory(scope, review_id, key, reason, {
1285
+ cwd: process.cwd(),
1286
+ projectId: context.projectId,
1287
+ typed: typedNodesEnabled,
1288
+ });
1289
+ return {
1290
+ content: [{ type: "text", text: asToolText(payload) }],
1291
+ structuredContent: payload,
1292
+ };
1648
1293
  });
1649
1294
  registerTool("memory_history", {
1650
1295
  description: "Read prior versions of a memory entry from the hidden history namespace.",
@@ -1756,7 +1401,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1756
1401
  });
1757
1402
  }
1758
1403
  registerTool("domain_knowledge_query", {
1759
- description: "Query an AnythingLLM workspace in query mode for domain knowledge.",
1404
+ description: "Query the configured read-only domain retrieval provider for domain knowledge.",
1760
1405
  inputSchema: z.object({
1761
1406
  workspace: z.string().min(1).optional(),
1762
1407
  query: z.string().min(1),
@@ -1769,7 +1414,11 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1769
1414
  if (!workspaceSlug) {
1770
1415
  throw new Error("No AnythingLLM workspace provided and no project workspace found in memory config.");
1771
1416
  }
1772
- const answer = await callAnythingLLM(workspaceSlug, query);
1417
+ const answer = await queryDomainKnowledge({
1418
+ workspace: workspaceSlug,
1419
+ query,
1420
+ remote: context.remote,
1421
+ });
1773
1422
  return {
1774
1423
  content: [{ type: "text", text: answer }],
1775
1424
  structuredContent: { workspace: workspaceSlug, answer },
@@ -1784,6 +1433,9 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1784
1433
  timeout_seconds: z.number().int().min(30).max(3600).optional(),
1785
1434
  }),
1786
1435
  }, async ({ workspace, mode, confirm_replace, timeout_seconds }) => {
1436
+ if (resolveDomainRetrievalProvider() === "openviking") {
1437
+ throw new Error("domain_knowledge_sync is unavailable for the read-only OpenViking provider.");
1438
+ }
1787
1439
  const syncMode = mode ?? "incremental";
1788
1440
  const config = memoryConfig();
1789
1441
  const workspaceSlug = workspace ?? defaultAnythingLLMWorkspace(config);
@@ -1912,14 +1564,28 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1912
1564
  const payload = await listVisibleContext(contextPackProject);
1913
1565
  return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1914
1566
  });
1567
+ registerTool("context_pack_tree", {
1568
+ description: "Browse a deterministic abstract or overview hierarchy of enabled context-pack files and approved skills.",
1569
+ inputSchema: z.object({
1570
+ pack: z.string().min(1).max(128).optional(),
1571
+ detail: z.enum(["abstract", "overview"]).optional(),
1572
+ }).strict(),
1573
+ }, async ({ pack, detail }) => {
1574
+ const payload = await treeVisibleContext({ ...contextPackProject, pack, detail });
1575
+ return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1576
+ });
1915
1577
  registerTool("context_pack_search", {
1916
1578
  description: "Search documents and explicitly approved skills in context packs enabled for this project.",
1917
1579
  inputSchema: z.object({
1918
1580
  query: z.string().min(1).max(4096),
1919
1581
  limit: z.number().int().min(1).max(100).optional(),
1582
+ strategy: z.enum(["flat", "hierarchical"]).optional(),
1583
+ detail: z.enum(["abstract", "overview", "content"]).optional(),
1584
+ explain: z.boolean().optional(),
1585
+ include_refs: z.boolean().optional(),
1920
1586
  }).strict(),
1921
- }, async ({ query, limit }) => {
1922
- const payload = await searchVisibleContext(query, { ...contextPackProject, limit });
1587
+ }, async ({ query, limit, strategy, detail, explain, include_refs }) => {
1588
+ const payload = await searchVisibleContext(query, { ...contextPackProject, limit, strategy, detail, explain, includeRefs: include_refs });
1923
1589
  return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1924
1590
  });
1925
1591
  registerTool("context_pack_read", {
@@ -1947,6 +1613,26 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1947
1613
  return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1948
1614
  });
1949
1615
  }
1616
+ if (contextUsageToolsEnabled) {
1617
+ registerTool("context_usage_record", {
1618
+ description: "Record a privacy-minimized context result receipt against work evidence.",
1619
+ inputSchema: z.object({
1620
+ evidence_id: z.string().min(4).max(40).regex(/^wev_[0-9a-f-]*$/i).optional(),
1621
+ task_digest: z.string().regex(/^[a-f0-9]{64}$/),
1622
+ result_digest: z.string().regex(/^[a-f0-9]{64}$/),
1623
+ outcome: z.enum(["used", "unused", "unknown"]),
1624
+ }).strict(),
1625
+ }, async ({ evidence_id, task_digest, result_digest, outcome }) => {
1626
+ const { appendContextUsageReceipt, linkActiveContextUsageReceipt } = await import("./work-evidence-store.js");
1627
+ const receipt = evidence_id
1628
+ ? await appendContextUsageReceipt(process.cwd(), evidence_id, { task_digest, result_digest, outcome })
1629
+ : await linkActiveContextUsageReceipt(process.cwd(), { task_digest, result_digest, outcome });
1630
+ const payload = receipt
1631
+ ? { schema_version: 1, recorded: true, receipt }
1632
+ : { schema_version: 1, recorded: false, receipt: null };
1633
+ return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1634
+ });
1635
+ }
1950
1636
  if (workEvidenceToolsEnabled) {
1951
1637
  registerTool("work_evidence_list", {
1952
1638
  description: "List bounded local Git-linked work-evidence records for this project.",
@@ -2031,7 +1717,7 @@ if (cliCommand === "extract") {
2031
1717
  if (!content) {
2032
1718
  throw new Error("No input provided on stdin.");
2033
1719
  }
2034
- const extracted = await extractMemoryCandidates(content, model, category);
1720
+ const extracted = await extractMemoryCandidatesFromProvider(content, model, category);
2035
1721
  output.write(`${JSON.stringify({
2036
1722
  model: extracted.model,
2037
1723
  count: extracted.candidates.length,
@@ -33,9 +33,11 @@ export const MCP_TOOL_CATALOG = {
33
33
  context_explore: observation("Explore repository context", true),
34
34
  route_check: observation("Check route", true),
35
35
  context_pack_list: observation("List context packs"),
36
+ context_pack_tree: observation("Browse context pack hierarchy"),
36
37
  context_pack_search: observation("Search context packs"),
37
38
  context_pack_read: observation("Read context pack file"),
38
39
  context_pack_related: observation("Traverse context pack links"),
40
+ context_usage_record: additive("Record context usage"),
39
41
  work_evidence_list: observation("List work evidence"),
40
42
  work_evidence_read: observation("Read work evidence"),
41
43
  };
@@ -0,0 +1,87 @@
1
+ function truncateOutput(value, maxLength = 12000) {
2
+ return value.length <= maxLength
3
+ ? value
4
+ : `${value.slice(0, maxLength)}\n...[truncated ${value.length - maxLength} chars]`;
5
+ }
6
+ function stripMarkdownFences(value) {
7
+ return value.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
8
+ }
9
+ function parseJsonResponse(value) {
10
+ const stripped = stripMarkdownFences(value);
11
+ try {
12
+ return JSON.parse(stripped);
13
+ }
14
+ catch {
15
+ const firstBrace = stripped.indexOf("{");
16
+ const lastBrace = stripped.lastIndexOf("}");
17
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
18
+ return JSON.parse(stripped.slice(firstBrace, lastBrace + 1));
19
+ }
20
+ throw new Error(`Failed to parse JSON response: ${truncateOutput(stripped, 1000)}`);
21
+ }
22
+ }
23
+ export function sanitizeExtractionCandidates(value, fallbackCategory) {
24
+ if (!Array.isArray(value))
25
+ return [];
26
+ return value
27
+ .map((item) => {
28
+ if (!item || typeof item !== "object")
29
+ return null;
30
+ const raw = item;
31
+ const key = typeof raw.key === "string" ? raw.key.trim() : "";
32
+ const candidateValue = typeof raw.value === "string" ? raw.value.trim() : "";
33
+ if (!key || !candidateValue)
34
+ return null;
35
+ const category = typeof raw.category === "string" && raw.category.trim()
36
+ ? raw.category.trim()
37
+ : fallbackCategory;
38
+ const importance = typeof raw.importance === "number"
39
+ ? Math.max(0, Math.min(1, raw.importance))
40
+ : undefined;
41
+ return { key, value: candidateValue, category, importance };
42
+ })
43
+ .filter((candidate) => candidate !== null);
44
+ }
45
+ export async function extractMemoryCandidates(content, modelOverride, category) {
46
+ const apiKey = process.env.CAIRN_LLM_API_KEY;
47
+ if (!apiKey)
48
+ throw new Error("CAIRN_LLM_API_KEY is not set.");
49
+ const rawUrl = process.env.CAIRN_LLM_API_URL;
50
+ if (!rawUrl)
51
+ throw new Error("CAIRN_LLM_API_URL is not set.");
52
+ const apiUrl = rawUrl.trim().replace(/\/+$/, "");
53
+ const model = (modelOverride ?? process.env.CAIRN_LLM_EXTRACTION_MODEL)?.trim();
54
+ if (!model)
55
+ throw new Error("CAIRN_LLM_EXTRACTION_MODEL is not set.");
56
+ const systemPrompt = [
57
+ "You extract durable memory candidates from development notes.",
58
+ "Return ONLY valid JSON, no markdown fences.",
59
+ "Schema: {\"candidates\":[{\"key\":\"decisions/cache-rule\",\"value\":\"...\",\"category\":\"decision\",\"importance\":0.92}]}",
60
+ "Only include genuinely reusable knowledge.",
61
+ "Skip trivial status notes, temporary branch details, and duplicated points.",
62
+ "Prefer short kebab-case keys with a useful prefix such as decisions/, pitfalls/, patterns/, bugs/, constraints/, preferences/, conventions/.",
63
+ "Do not invent dates unless they are explicitly present in the source text.",
64
+ category ? `Bias extraction toward category: ${category}.` : "",
65
+ ].filter(Boolean).join(" ");
66
+ const response = await fetch(`${apiUrl}/chat/completions`, {
67
+ method: "POST",
68
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
69
+ body: JSON.stringify({
70
+ model,
71
+ messages: [{ role: "system", content: systemPrompt }, { role: "user", content }],
72
+ temperature: 0.1,
73
+ max_tokens: 1200,
74
+ }),
75
+ signal: AbortSignal.timeout(120000),
76
+ });
77
+ if (!response.ok) {
78
+ const text = await response.text();
79
+ throw new Error(`Extraction request failed with ${response.status}: ${text}`);
80
+ }
81
+ const payload = (await response.json());
82
+ const rawContent = payload.choices?.[0]?.message?.content;
83
+ if (!rawContent)
84
+ throw new Error("Extraction model returned no content.");
85
+ const parsed = parseJsonResponse(rawContent);
86
+ return { model, candidates: sanitizeExtractionCandidates(parsed.candidates, category) };
87
+ }