bikky 0.3.2 → 0.3.5

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 (145) hide show
  1. package/README.md +83 -34
  2. package/dist/config.d.ts +1 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +6 -1
  5. package/dist/config.js.map +1 -1
  6. package/dist/config.test.d.ts +3 -2
  7. package/dist/config.test.d.ts.map +1 -1
  8. package/dist/config.test.js +12 -6
  9. package/dist/config.test.js.map +1 -1
  10. package/dist/daemon/capture-policy.d.ts +4 -4
  11. package/dist/daemon/capture-policy.d.ts.map +1 -1
  12. package/dist/daemon/capture-policy.js +8 -17
  13. package/dist/daemon/capture-policy.js.map +1 -1
  14. package/dist/daemon/capture-policy.test.js +2 -2
  15. package/dist/daemon/capture-policy.test.js.map +1 -1
  16. package/dist/daemon/entity-typing.d.ts +20 -0
  17. package/dist/daemon/entity-typing.d.ts.map +1 -0
  18. package/dist/daemon/entity-typing.js +166 -0
  19. package/dist/daemon/entity-typing.js.map +1 -0
  20. package/dist/daemon/episode-summary.d.ts +4 -6
  21. package/dist/daemon/episode-summary.d.ts.map +1 -1
  22. package/dist/daemon/episode-summary.js +24 -38
  23. package/dist/daemon/episode-summary.js.map +1 -1
  24. package/dist/daemon/episode-summary.test.js +5 -5
  25. package/dist/daemon/episode-summary.test.js.map +1 -1
  26. package/dist/daemon/extraction-quality.test.d.ts +2 -0
  27. package/dist/daemon/extraction-quality.test.d.ts.map +1 -0
  28. package/dist/daemon/extraction-quality.test.js +283 -0
  29. package/dist/daemon/extraction-quality.test.js.map +1 -0
  30. package/dist/daemon/extraction-rules.d.ts +131 -0
  31. package/dist/daemon/extraction-rules.d.ts.map +1 -0
  32. package/dist/daemon/extraction-rules.js +321 -0
  33. package/dist/daemon/extraction-rules.js.map +1 -0
  34. package/dist/daemon/extraction-rules.test.d.ts +2 -0
  35. package/dist/daemon/extraction-rules.test.d.ts.map +1 -0
  36. package/dist/daemon/extraction-rules.test.js +183 -0
  37. package/dist/daemon/extraction-rules.test.js.map +1 -0
  38. package/dist/daemon/extraction.d.ts +19 -1
  39. package/dist/daemon/extraction.d.ts.map +1 -1
  40. package/dist/daemon/extraction.js +169 -21
  41. package/dist/daemon/extraction.js.map +1 -1
  42. package/dist/daemon/extraction.test.js +96 -2
  43. package/dist/daemon/extraction.test.js.map +1 -1
  44. package/dist/daemon/loop.d.ts.map +1 -1
  45. package/dist/daemon/loop.js +14 -0
  46. package/dist/daemon/loop.js.map +1 -1
  47. package/dist/daemon/qdrant.d.ts +15 -1
  48. package/dist/daemon/qdrant.d.ts.map +1 -1
  49. package/dist/daemon/qdrant.js +45 -2
  50. package/dist/daemon/qdrant.js.map +1 -1
  51. package/dist/daemon/relations-vocab.d.ts +44 -0
  52. package/dist/daemon/relations-vocab.d.ts.map +1 -0
  53. package/dist/daemon/relations-vocab.js +168 -0
  54. package/dist/daemon/relations-vocab.js.map +1 -0
  55. package/dist/daemon/relations-vocab.test.d.ts +2 -0
  56. package/dist/daemon/relations-vocab.test.d.ts.map +1 -0
  57. package/dist/daemon/relations-vocab.test.js +69 -0
  58. package/dist/daemon/relations-vocab.test.js.map +1 -0
  59. package/dist/daemon/relations.d.ts +2 -0
  60. package/dist/daemon/relations.d.ts.map +1 -1
  61. package/dist/daemon/relations.js +15 -5
  62. package/dist/daemon/relations.js.map +1 -1
  63. package/dist/daemon/session-index.test.js +1 -1
  64. package/dist/daemon/session-index.test.js.map +1 -1
  65. package/dist/daemon/watcher-health.d.ts +20 -0
  66. package/dist/daemon/watcher-health.d.ts.map +1 -0
  67. package/dist/daemon/watcher-health.js +78 -0
  68. package/dist/daemon/watcher-health.js.map +1 -0
  69. package/dist/daemon/watcher-health.test.d.ts +5 -0
  70. package/dist/daemon/watcher-health.test.d.ts.map +1 -0
  71. package/dist/daemon/watcher-health.test.js +96 -0
  72. package/dist/daemon/watcher-health.test.js.map +1 -0
  73. package/dist/daemon/watcher.test.d.ts +3 -2
  74. package/dist/daemon/watcher.test.d.ts.map +1 -1
  75. package/dist/daemon/watcher.test.js +9 -19
  76. package/dist/daemon/watcher.test.js.map +1 -1
  77. package/dist/daemon/workstream-resolver.d.ts +76 -0
  78. package/dist/daemon/workstream-resolver.d.ts.map +1 -0
  79. package/dist/daemon/workstream-resolver.js +180 -0
  80. package/dist/daemon/workstream-resolver.js.map +1 -0
  81. package/dist/daemon/workstream-resolver.test.d.ts +2 -0
  82. package/dist/daemon/workstream-resolver.test.d.ts.map +1 -0
  83. package/dist/daemon/workstream-resolver.test.js +128 -0
  84. package/dist/daemon/workstream-resolver.test.js.map +1 -0
  85. package/dist/daemon/workstream-summary.d.ts +1 -8
  86. package/dist/daemon/workstream-summary.d.ts.map +1 -1
  87. package/dist/daemon/workstream-summary.js +4 -37
  88. package/dist/daemon/workstream-summary.js.map +1 -1
  89. package/dist/daemon/workstream-summary.test.js +4 -4
  90. package/dist/daemon/workstream-summary.test.js.map +1 -1
  91. package/dist/mcp/helpers.d.ts.map +1 -1
  92. package/dist/mcp/helpers.js +17 -2
  93. package/dist/mcp/helpers.js.map +1 -1
  94. package/dist/mcp/index.d.ts.map +1 -1
  95. package/dist/mcp/index.js +8 -1
  96. package/dist/mcp/index.js.map +1 -1
  97. package/dist/mcp/taxonomy.d.ts +20 -18
  98. package/dist/mcp/taxonomy.d.ts.map +1 -1
  99. package/dist/mcp/taxonomy.js +75 -25
  100. package/dist/mcp/taxonomy.js.map +1 -1
  101. package/dist/mcp/taxonomy.test.js +10 -5
  102. package/dist/mcp/taxonomy.test.js.map +1 -1
  103. package/dist/mcp/tools.d.ts.map +1 -1
  104. package/dist/mcp/tools.js +457 -93
  105. package/dist/mcp/tools.js.map +1 -1
  106. package/dist/mcp/tools.test.js +209 -0
  107. package/dist/mcp/tools.test.js.map +1 -1
  108. package/dist/prompts/distill.d.ts.map +1 -1
  109. package/dist/prompts/distill.js +36 -17
  110. package/dist/prompts/distill.js.map +1 -1
  111. package/dist/prompts/entity-typing.d.ts +18 -0
  112. package/dist/prompts/entity-typing.d.ts.map +1 -0
  113. package/dist/prompts/entity-typing.js +60 -0
  114. package/dist/prompts/entity-typing.js.map +1 -0
  115. package/dist/prompts/episode-summary.d.ts +15 -0
  116. package/dist/prompts/episode-summary.d.ts.map +1 -0
  117. package/dist/prompts/episode-summary.js +74 -0
  118. package/dist/prompts/episode-summary.js.map +1 -0
  119. package/dist/prompts/extraction.d.ts.map +1 -1
  120. package/dist/prompts/extraction.js +138 -6
  121. package/dist/prompts/extraction.js.map +1 -1
  122. package/dist/prompts/index.d.ts +3 -0
  123. package/dist/prompts/index.d.ts.map +1 -1
  124. package/dist/prompts/index.js +3 -0
  125. package/dist/prompts/index.js.map +1 -1
  126. package/dist/prompts/prompts.test.js +1 -1
  127. package/dist/prompts/prompts.test.js.map +1 -1
  128. package/dist/prompts/relations.d.ts.map +1 -1
  129. package/dist/prompts/relations.js +26 -4
  130. package/dist/prompts/relations.js.map +1 -1
  131. package/dist/prompts/workstream-summary.d.ts +17 -0
  132. package/dist/prompts/workstream-summary.d.ts.map +1 -0
  133. package/dist/prompts/workstream-summary.js +72 -0
  134. package/dist/prompts/workstream-summary.js.map +1 -0
  135. package/dist/render.d.ts.map +1 -1
  136. package/dist/render.js +19 -1
  137. package/dist/render.js.map +1 -1
  138. package/dist/render.test.js +37 -5
  139. package/dist/render.test.js.map +1 -1
  140. package/docs/diagrams/architecture.svg +87 -0
  141. package/docs/diagrams/team-memory.svg +250 -0
  142. package/docs/screenshots/dashboard.png +0 -0
  143. package/docs/screenshots/graph.png +0 -0
  144. package/docs/screenshots/memory.png +0 -0
  145. package/package.json +4 -2
package/dist/mcp/tools.js CHANGED
@@ -3,10 +3,12 @@
3
3
  */
4
4
  import crypto from "node:crypto";
5
5
  import { z } from "zod";
6
- import { STALENESS_DAYS, THRESHOLD_DUPLICATE, THRESHOLD_RELATED, QDRANT_INDEXES, categoryValues, domainValues, kindValues, memorySubtypeValues, sourceValues, DEFAULT_CATEGORY, DEFAULT_DOMAIN, DEFAULT_KIND, DEFAULT_SOURCE, categoryForMemorySubtype, layerForMemorySubtype, normalizeCategory, normalizeDomain, normalizeKind, validateMemorySubtype, } from "./taxonomy.js";
6
+ import { STALENESS_DAYS, THRESHOLD_DUPLICATE, THRESHOLD_RELATED, QDRANT_INDEXES, categoryValues, categoryEnumDescription, domainValues, domainEnumDescription, kindValues, kindEnumDescription, memorySubtypeValues, memorySubtypeEnumDescription, sourceValues, sourceEnumDescription, DEFAULT_CATEGORY, DEFAULT_DOMAIN, DEFAULT_KIND, DEFAULT_SOURCE, categoryForMemorySubtype, layerForMemorySubtype, normalizeCategory, normalizeDomain, normalizeKind, validateMemorySubtype, } from "./taxonomy.js";
7
7
  import { contentHash, daysSince, lastActivityDate, computeCombinedScore, buildFilter, formatFact, MEMORY_RECALL_EXCLUDED_KINDS, } from "./helpers.js";
8
8
  import { ready, qdrantUrl, qdrantApiKey, setupError, setQdrantUrl, setQdrantApiKey, setReady, getCollection, log, embed, getEmbeddingConfig, qdrantReq, ensureCollection, qdrantUpsert, qdrantSearch, qdrantScroll, qdrantSetPayload, qdrantGetPoints, } from "./api.js";
9
- import { saveConfig, loadConfig } from "../config.js";
9
+ import { saveConfig, loadConfig, EXTRACTION_HEALTH_PATH } from "../config.js";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { inspectWatcherPaths, formatIssue } from "../daemon/watcher-health.js";
10
12
  // ---------------------------------------------------------------------------
11
13
  // Runtime state
12
14
  // ---------------------------------------------------------------------------
@@ -166,7 +168,11 @@ async function graphTraversal(primaryResults, limit, scope) {
166
168
  // ---------------------------------------------------------------------------
167
169
  export function registerTools(mcp) {
168
170
  // ── get_setup_status ────────────────────────────────────────────────────
169
- mcp.tool("get_setup_status", "Check memory system status. Returns which credentials are configured and whether Qdrant and embeddings are reachable.", {}, async () => {
171
+ mcp.tool("get_setup_status", [
172
+ "Check whether the memory system is configured and reachable.",
173
+ "Use this when memory tools return a 'setup_required' error, or once at session start if you're not sure bikky is wired up. Reports which credentials are missing and includes onboarding instructions if anything is incomplete.",
174
+ "Read-only — safe to call any time.",
175
+ ].join(" "), {}, async () => {
170
176
  const status = {
171
177
  ready,
172
178
  qdrant_url: !!qdrantUrl,
@@ -195,6 +201,44 @@ export function registerTools(mcp) {
195
201
  status["embedding_connected"] = true;
196
202
  }
197
203
  catch { /* ignore */ }
204
+ // Watcher / extraction health (issue #58)
205
+ const warnings = [];
206
+ try {
207
+ const cfg = loadConfig();
208
+ status["watcher_path"] = cfg.watchers.copilot.path;
209
+ for (const issue of inspectWatcherPaths(cfg)) {
210
+ warnings.push(formatIssue(issue));
211
+ }
212
+ }
213
+ catch { /* ignore */ }
214
+ try {
215
+ if (existsSync(EXTRACTION_HEALTH_PATH)) {
216
+ const health = JSON.parse(readFileSync(EXTRACTION_HEALTH_PATH, "utf-8"));
217
+ status["extraction_last_tick_at"] = health.last_tick_at ?? null;
218
+ status["extraction_last_active_session_at"] = health.last_active_session_at ?? null;
219
+ status["extraction_active_session_count"] = health.active_session_count ?? 0;
220
+ if (health.last_active_session_at) {
221
+ const hours = (Date.now() - Date.parse(health.last_active_session_at)) / 3_600_000;
222
+ status["extraction_hours_since_active_session"] = Math.round(hours * 10) / 10;
223
+ if (hours > 6) {
224
+ warnings.push(`Watcher has not seen any active Copilot sessions for ${Math.round(hours)}h — ` +
225
+ `check watcher_path (${health.watcher_path ?? "unknown"}) and that the daemon is running.`);
226
+ }
227
+ }
228
+ else {
229
+ status["extraction_hours_since_active_session"] = null;
230
+ warnings.push("Daemon has never observed an active Copilot session — extraction may be stalled.");
231
+ }
232
+ }
233
+ else {
234
+ status["extraction_last_tick_at"] = null;
235
+ status["extraction_last_active_session_at"] = null;
236
+ status["extraction_hours_since_active_session"] = null;
237
+ }
238
+ }
239
+ catch { /* ignore */ }
240
+ if (warnings.length > 0)
241
+ status["warnings"] = warnings;
198
242
  if (!status["ready"] && missing.length > 0) {
199
243
  status["setup_instructions"] =
200
244
  "Run `bikky setup` or guide the user. Pick one Qdrant option:\n" +
@@ -206,7 +250,10 @@ export function registerTools(mcp) {
206
250
  return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
207
251
  });
208
252
  // ── configure_credentials ───────────────────────────────────────────────
209
- mcp.tool("configure_credentials", "Store Qdrant + embedding credentials in ~/.bikky/config.json. Tests connectivity and creates the collection if needed.", {
253
+ mcp.tool("configure_credentials", [
254
+ "Persist Qdrant and embedding credentials to ~/.bikky/config.json and bring the memory system online.",
255
+ "Call this only during onboarding (or when rotating credentials). After it succeeds, the collection is created if missing and embeddings are tested. For day-to-day use, prefer get_setup_status.",
256
+ ].join(" "), {
210
257
  qdrant_url: z.string().optional().describe("Qdrant REST URL — Qdrant Cloud (https://xxx.cloud.qdrant.io:6333), local Docker (http://localhost:6333), or self-hosted"),
211
258
  qdrant_api_key: z.string().optional().describe("Qdrant API key — required for Qdrant Cloud; optional / leave blank for unauthenticated local or self-hosted instances"),
212
259
  openai_api_key: z.string().optional().describe("OpenAI API key (for OpenAI embedding/LLM provider)"),
@@ -252,7 +299,11 @@ export function registerTools(mcp) {
252
299
  return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
253
300
  });
254
301
  // ── verify_connection ───────────────────────────────────────────────────
255
- mcp.tool("verify_connection", "Test that Qdrant is reachable, embeddings work, and the collection exists.", {}, async () => {
302
+ mcp.tool("verify_connection", [
303
+ "Confirm Qdrant is reachable, embeddings work, and the collection exists.",
304
+ "Use this to debug a sudden 'setup_required' or empty-recall after a network blip or credential change. Lighter than configure_credentials — does not write to disk.",
305
+ "Read-only.",
306
+ ].join(" "), {}, async () => {
256
307
  const results = { qdrant: false, embedding: false, collection: false };
257
308
  if (qdrantUrl) {
258
309
  try {
@@ -281,39 +332,36 @@ export function registerTools(mcp) {
281
332
  return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
282
333
  });
283
334
  // ── memory_store ────────────────────────────────────────────────────────
284
- mcp.tool("memory_store", "Store a new fact in memory. Automatically deduplicates via content hash and vector similarity. " +
285
- "Returns the action taken (inserted/reinforced/duplicate) and any similar facts found.", {
286
- content: z.string().describe("The fact to store (atomic, single piece of knowledge)"),
287
- category: z.enum(categoryValues())
288
- .describe("Subject matter: codebase, infrastructure, operations, decisions, product_domain, projects, people, preferences, observations"),
289
- entities: z.array(z.string()).describe("Related entities (lowercase, e.g. ['qdrant', 'platform'])"),
290
- domain: z.enum(domainValues()).default(DEFAULT_DOMAIN)
291
- .describe("Activity profile e.g. software_engineering, product_strategy, business_operations, research, personal_productivity"),
292
- kind: z.enum(kindValues()).default(DEFAULT_KIND)
293
- .describe("Knowledge form fact, summary, distilled, relation"),
294
- memory_subtype: z.enum(memorySubtypeValues()).optional()
295
- .describe("Optional subtype within kind, such as codebase_map, episode, workstream, convention, or recall_event"),
296
- workspace_id: z.string().optional()
297
- .describe("Optional workspace namespace for team memory."),
298
- episode_id: z.string().optional().describe("Optional coherent episode identifier"),
299
- workstream_key: z.string().optional().describe("Optional durable workstream key"),
300
- task_key: z.string().optional().describe("Optional task or issue key"),
301
- repo: z.string().optional().describe("Optional repository or project surface"),
302
- branch: z.string().optional().describe("Optional branch or working surface"),
303
- review_status: z.enum(["candidate", "reviewed", "approved", "rejected"]).optional()
304
- .describe("Optional review lifecycle status"),
305
- source: z.enum(sourceValues()).default(DEFAULT_SOURCE)
306
- .describe("Creator agent, daemon, system, user"),
307
- confidence: z.number().min(0).max(1).default(0.9).describe("How certain (0.0-1.0)"),
308
- importance: z.number().min(0).max(1).optional().describe("How important (0.0-1.0). Omit to default to 0.5."),
309
- supersedes: z.string().optional().describe("ID of a fact this one replaces"),
335
+ mcp.tool("memory_store", [
336
+ "Persist one atomic fact to long-term memory.",
337
+ "Call this whenever you learn something a future session would need: a service detail, a decision rationale, a workaround, a user preference, an ownership fact, a task-resume pointer. One fact per call split compound observations into separate calls.",
338
+ "Dedup is automatic (content hash + vector similarity), so you do NOT need to recall first. The tool returns one of: inserted (new fact), reinforced (exact or near-duplicate found — counters bumped), or — if there are similar-but-different facts — a list of potential conflicts so you can decide whether to use 'supersedes'.",
339
+ "To create a typed edge between two entities at the same time, set the optional 'relation' field — no separate tool call needed.",
340
+ "Do NOT use for ephemeral state (current cursor, in-flight todo). Use the harness task folder instead.",
341
+ ].join(" "), {
342
+ content: z.string().describe("The fact to store. Should be one atomic, self-contained statement (no compound 'A and B') that makes sense out of context."),
343
+ category: z.enum(categoryValues()).describe(categoryEnumDescription()),
344
+ entities: z.array(z.string()).describe("Lowercase entity names mentioned by this fact (e.g. ['qdrant', 'workspace_id']). Used for entity-scoped recall and graph traversal — keep them short and canonical."),
345
+ domain: z.enum(domainValues()).default(DEFAULT_DOMAIN).describe(domainEnumDescription()),
346
+ kind: z.enum(kindValues()).default(DEFAULT_KIND).describe(kindEnumDescription()),
347
+ memory_subtype: z.enum(memorySubtypeValues()).optional().describe(memorySubtypeEnumDescription()),
348
+ workspace_id: z.string().optional().describe("Workspace namespace for team-shared memory. Omit to use the default workspace from config."),
349
+ episode_id: z.string().optional().describe("Coherent activity-segment ID. Group facts captured during the same coherent task or transcript."),
350
+ workstream_key: z.string().optional().describe("Durable continuity key for a long-running objective (survives across sessions)."),
351
+ task_key: z.string().optional().describe("Task or issue key (e.g. GitHub issue number, JIRA key)."),
352
+ repo: z.string().optional().describe("Repository or project surface this fact relates to (e.g. 'bikky-dev/bikky')."),
353
+ branch: z.string().optional().describe("Branch or working surface (e.g. 'main', 'feat/x')."),
354
+ review_status: z.enum(["candidate", "reviewed", "approved", "rejected"]).optional().describe("Review lifecycle status. candidate=auto-extracted (daemon), reviewed=human-checked, approved=human-confirmed, rejected=incorrect. Agents normally leave this unset."),
355
+ source: z.enum(sourceValues()).default(DEFAULT_SOURCE).describe(sourceEnumDescription()),
356
+ confidence: z.number().min(0).max(1).default(0.9).describe("How certain you are this fact is correct (0.0-1.0). Default 0.9. Lower (~0.6) for inferred or unverified facts."),
357
+ importance: z.number().min(0).max(1).optional().describe("How important this fact is for future recall (0.0-1.0). Defaults to 0.5 if omitted. ≥0.8 surfaces in session briefings."),
358
+ supersedes: z.string().optional().describe("ID of an existing fact that this one replaces. The old fact is marked superseded and excluded from recall. Use this when a fact is updated; use memory_forget when a fact was simply wrong."),
310
359
  relation: z.object({
311
- from: z.string().describe("Source entity"),
312
- type: z.string().describe("Relation type (owns, uses, decided, prefers, works-on, etc.)"),
313
- to: z.string().describe("Target entity"),
314
- }).optional().describe("Optional typed relation between two entities"),
315
- metadata: z.record(z.string(), z.string()).optional()
316
- .describe("Optional key-value metadata. Stored with the fact and filterable via memory_recall."),
360
+ from: z.string().describe("Source entity (lowercase)."),
361
+ type: z.string().describe("Relation type (e.g. 'owns', 'uses', 'decided', 'prefers', 'works-on')."),
362
+ to: z.string().describe("Target entity (lowercase)."),
363
+ }).optional().describe("Optional typed edge between two entities — created in the same call. Use this whenever the fact also expresses a relationship; no separate tool call needed."),
364
+ metadata: z.record(z.string(), z.string()).optional().describe("Arbitrary key-value metadata. Stored with the fact and exact-match filterable via memory_recall.metadata_filter (all key/value pairs must match — AND logic)."),
317
365
  }, async ({ content, category, entities, domain, kind, memory_subtype, workspace_id, episode_id, workstream_key, task_key, repo, branch, review_status, source, confidence, importance, supersedes, relation, metadata, }) => {
318
366
  const guard = requireReady();
319
367
  if (guard)
@@ -556,29 +604,34 @@ export function registerTools(mcp) {
556
604
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
557
605
  });
558
606
  // ── memory_recall ───────────────────────────────────────────────────────
559
- mcp.tool("memory_recall", "Semantic search over memory. Returns facts ranked by relevance. " +
560
- "Use on session start with a broad query for context briefing.", {
561
- query: z.string().describe("What to search for (natural language)"),
562
- category: z.string().optional().describe("Filter by category"),
563
- domain: z.string().optional().describe("Filter by domain activity profile"),
564
- kind: z.string().optional().describe("Filter by kind (fact, summary, distilled, relation)"),
565
- memory_subtype: z.string().optional().describe("Filter by memory subtype"),
566
- workspace_id: z.string().optional().describe("Filter by optional workspace namespace."),
567
- include_legacy_workspace: z.boolean().optional()
568
- .describe("Include legacy facts without workspace_id in this workspace query."),
569
- entity: z.string().optional().describe("Filter by entity name"),
570
- episode_id: z.string().optional().describe("Filter by coherent episode ID"),
571
- workstream_key: z.string().optional().describe("Filter by durable workstream key"),
572
- task_key: z.string().optional().describe("Filter by task or issue key"),
573
- repo: z.string().optional().describe("Filter by repository or project surface"),
574
- branch: z.string().optional().describe("Filter by branch or working surface"),
575
- review_status: z.string().optional().describe("Filter by review lifecycle status"),
576
- since: z.string().optional().describe("Only facts created after this ISO date"),
577
- until: z.string().optional().describe("Only facts created before this ISO date"),
578
- limit: z.number().optional().default(10).describe("Max results (default 10)"),
579
- graph_depth: z.number().optional().default(0).describe("Entity graph traversal depth (0=none, 1=include 1-hop related entity facts)."),
580
- metadata_filter: z.record(z.string(), z.string()).optional()
581
- .describe("Filter by metadata key-value pairs. All pairs must match."),
607
+ mcp.tool("memory_recall", [
608
+ "Semantic + filtered search over memory. Returns facts ranked by relevance (vector similarity blended with recency, importance, and reinforcement).",
609
+ "Three main uses:",
610
+ " 1. Session-start briefing — broad query like 'session briefing: user preferences, active projects, recent decisions'.",
611
+ " 2. Per-prompt contextual recall — focused query derived from what the user just asked.",
612
+ " 3. Pre-store conflict check recall similar facts before storing, so you can use 'supersedes' if the new fact replaces an older one.",
613
+ "Combine the natural-language query with structured filters (category, domain, entity, date range, metadata) for tighter results.",
614
+ "If you have a known entity name and want everything about it, prefer memory_entity. For 'what does X own/use?' style questions, prefer memory_relations.",
615
+ ].join("\n"), {
616
+ query: z.string().describe("Natural-language description of what you're looking for. Embedded and matched semantically — full sentences work better than keyword lists."),
617
+ category: z.string().optional().describe("Filter by category (same vocabulary as memory_store.category). Optional."),
618
+ domain: z.string().optional().describe("Filter by domain activity profile (same vocabulary as memory_store.domain). Optional."),
619
+ kind: z.string().optional().describe("Filter by kind: fact, summary, distilled, relation. Optional. Telemetry is excluded by default."),
620
+ memory_subtype: z.string().optional().describe("Filter by memory subtype (must be valid for the chosen kind). Optional."),
621
+ workspace_id: z.string().optional().describe("Filter to facts in this workspace namespace. Omit to use the default workspace from config."),
622
+ include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility flag: also include legacy facts that have no workspace_id. Default false. Only set this if you suspect pre-migration data is missing from results."),
623
+ entity: z.string().optional().describe("Restrict to facts mentioning this entity (case-insensitive). For full entity context prefer memory_entity."),
624
+ episode_id: z.string().optional().describe("Filter by coherent episode ID."),
625
+ workstream_key: z.string().optional().describe("Filter by durable workstream key."),
626
+ task_key: z.string().optional().describe("Filter by task or issue key."),
627
+ repo: z.string().optional().describe("Filter by repository or project surface."),
628
+ branch: z.string().optional().describe("Filter by branch or working surface."),
629
+ review_status: z.string().optional().describe("Filter by review lifecycle status (candidate / reviewed / approved / rejected)."),
630
+ since: z.string().optional().describe("Only facts created on or after this ISO 8601 date or datetime."),
631
+ until: z.string().optional().describe("Only facts created on or before this ISO 8601 date or datetime."),
632
+ limit: z.number().optional().default(10).describe("Max results to return (default 10)."),
633
+ graph_depth: z.number().optional().default(0).describe("Entity-graph traversal depth. 0 = vector search only (fast, default). 1 = also surface 1-hop entity-related facts (slower; use when the user asks 'what's connected to X?')."),
634
+ metadata_filter: z.record(z.string(), z.string()).optional().describe("Exact-match filter on the metadata map stored with each fact. All key/value pairs must match (AND logic)."),
582
635
  }, async ({ query, category, domain, kind, memory_subtype, workspace_id, include_legacy_workspace, entity, episode_id, workstream_key, task_key, repo, branch, review_status, since, until, limit, graph_depth, metadata_filter, }) => {
583
636
  const guard = requireReady();
584
637
  if (guard)
@@ -641,18 +694,37 @@ export function registerTools(mcp) {
641
694
  return { content: [{ type: "text", text: lines.join("\n") }] };
642
695
  });
643
696
  // ── memory_entity ───────────────────────────────────────────────────────
644
- mcp.tool("memory_entity", "Get everything known about an entity — all facts mentioning it plus its relationships.", {
645
- name: z.string().describe("Entity name (e.g. 'qdrant', 'platform')"),
646
- limit: z.number().optional().default(20).describe("Max facts to return"),
647
- workspace_id: z.string().optional().describe("Filter by optional workspace namespace."),
648
- include_legacy_workspace: z.boolean().optional()
649
- .describe("Include legacy facts without workspace_id in this workspace query."),
697
+ mcp.tool("memory_entity", [
698
+ "Get everything bikky knows about a specific entity — facts mentioning it plus typed relations into and out of it.",
699
+ "Prefer this over memory_recall when the user asks 'tell me about X' or 'what do we know about X' and X is a known entity name (service, person, repo, concept). Faster and more complete than semantic search for entity-centric queries.",
700
+ "If you only have a fuzzy description, use memory_recall first to find the entity name.",
701
+ ].join(" "), {
702
+ name: z.string().describe("Entity name (case-insensitive, e.g. 'qdrant', 'workspace_id'). Should match the lowercase canonical form used when facts were stored."),
703
+ limit: z.number().optional().default(20).describe("Max facts to return (default 20). Relations are always returned in full, capped at 50 each direction."),
704
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
705
+ include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility: also include legacy facts with no workspace_id. Default false."),
650
706
  }, async ({ name, limit, workspace_id, include_legacy_workspace }) => {
651
707
  const guard = requireReady();
652
708
  if (guard)
653
709
  return guard;
654
710
  const entityName = name.toLowerCase();
655
711
  const scope = resolveScope(workspace_id, include_legacy_workspace);
712
+ // Look up the daemon-classified entity type, if any.
713
+ let entityType = null;
714
+ try {
715
+ const typeFilter = scopedFilter(scope) ?? { must: [] };
716
+ typeFilter.must.push({ key: "kind", match: { value: "entity_type" } });
717
+ typeFilter.must.push({ key: "entity_name", match: { value: entityName } });
718
+ const typePoints = await qdrantScroll(typeFilter, 1);
719
+ const typePoint = typePoints.result?.points?.[0];
720
+ const payload = typePoint?.payload;
721
+ if (payload?.entity_type) {
722
+ entityType = String(payload.entity_type);
723
+ }
724
+ }
725
+ catch {
726
+ // Type lookup is best-effort — never fails the request.
727
+ }
656
728
  const factsFilter = scopedFilter(scope) ?? { must: [] };
657
729
  factsFilter.must.push({ key: "entities", match: { value: entityName } });
658
730
  const facts = await qdrantScroll(factsFilter, limit ?? 20);
@@ -665,13 +737,19 @@ export function registerTools(mcp) {
665
737
  const output = [];
666
738
  const factPoints = facts.result?.points ?? [];
667
739
  if (factPoints.length > 0) {
668
- output.push(`## Facts about ${name} (${factPoints.length})`);
740
+ const header = entityType
741
+ ? `## Facts about ${name} [type: ${entityType}] (${factPoints.length})`
742
+ : `## Facts about ${name} (${factPoints.length})`;
743
+ output.push(header);
669
744
  for (const p of factPoints) {
670
745
  if (p.payload.category !== "relation") {
671
746
  output.push(`- ${formatFact(p)}`);
672
747
  }
673
748
  }
674
749
  }
750
+ else if (entityType) {
751
+ output.push(`## ${name} [type: ${entityType}]`);
752
+ }
675
753
  const allRelations = [
676
754
  ...(relationsFrom.result?.points ?? []),
677
755
  ...(relationsTo.result?.points ?? []),
@@ -696,14 +774,16 @@ export function registerTools(mcp) {
696
774
  return { content: [{ type: "text", text: output.join("\n") }] };
697
775
  });
698
776
  // ── memory_relations ────────────────────────────────────────────────────
699
- mcp.tool("memory_relations", "Query entity relationships. Returns typed edges between entities.", {
700
- entity: z.string().describe("Entity name to query"),
701
- relation_type: z.string().optional().describe("Filter by relation type (e.g. 'owns', 'uses', 'decided')"),
702
- direction: z.enum(["from", "to", "both"]).optional().default("both")
703
- .describe("Direction: 'from' (entity as source), 'to' (entity as target), 'both'"),
704
- workspace_id: z.string().optional().describe("Filter by optional workspace namespace."),
705
- include_legacy_workspace: z.boolean().optional()
706
- .describe("Include legacy facts without workspace_id in this workspace query."),
777
+ mcp.tool("memory_relations", [
778
+ "Query typed edges between entities. Returns 'A --[type]--> B' triples that semantic search alone wouldn't surface.",
779
+ "Use for 'what does X own / use / depend on?' and 'who owns Y?' style questions. Optionally filter by direction (from / to / both) and relation type.",
780
+ "To create relations, use memory_store with the 'relation' field — there is no separate create-relation tool.",
781
+ ].join(" "), {
782
+ entity: z.string().describe("Entity name to query (case-insensitive)."),
783
+ relation_type: z.string().optional().describe("Filter to a specific edge label (e.g. 'owns', 'uses', 'decided', 'prefers', 'works-on'). Optional."),
784
+ direction: z.enum(["from", "to", "both"]).optional().default("both").describe("Which side of the edge the entity is on. 'from' = entity is the source (X --[?]--> ?). 'to' = entity is the target (? --[?]--> X). 'both' = either (default)."),
785
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
786
+ include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility: also include legacy facts with no workspace_id. Default false."),
707
787
  }, async ({ entity, relation_type, direction, workspace_id, include_legacy_workspace }) => {
708
788
  const guard = requireReady();
709
789
  if (guard)
@@ -746,10 +826,13 @@ export function registerTools(mcp) {
746
826
  return { content: [{ type: "text", text: lines.join("\n") }] };
747
827
  });
748
828
  // ── memory_forget ───────────────────────────────────────────────────────
749
- mcp.tool("memory_forget", "Mark a fact as superseded/wrong. The fact remains but is excluded from recall results.", {
750
- fact_id: z.string().describe("ID of the fact to forget"),
751
- reason: z.string().describe("Why this fact is being superseded"),
752
- workspace_id: z.string().optional().describe("Optional workspace namespace."),
829
+ mcp.tool("memory_forget", [
830
+ "Mark a fact as superseded/wrong. The fact stays in storage (for audit) but is excluded from all recall results.",
831
+ "Use this when a fact was simply incorrect or no longer applies and there is no replacement. If you have a corrected version, use memory_store with 'supersedes: <fact_id>' instead — that way the new fact stays linked to the old one.",
832
+ ].join(" "), {
833
+ fact_id: z.string().describe("ID of the fact to forget (returned by memory_store / memory_recall as 'id')."),
834
+ reason: z.string().describe("Short human-readable reason this fact is being retired (stored in 'superseded_by' for future audit)."),
835
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
753
836
  }, async ({ fact_id, reason, workspace_id }) => {
754
837
  const guard = requireReady();
755
838
  if (guard)
@@ -766,6 +849,13 @@ export function registerTools(mcp) {
766
849
  superseded_by: `forgotten:${redactedReason.text}`,
767
850
  superseded_at: now,
768
851
  updated_at: now,
852
+ // Mark this fact's vector as a bad-exemplar centroid: future
853
+ // candidates with high cosine similarity will be auto-flagged
854
+ // for review. Forgotten facts keep their original vector — the
855
+ // is_bad_exemplar payload flag opts them into the centroid set
856
+ // without requiring a new point.
857
+ is_bad_exemplar: true,
858
+ bad_exemplar_reason: redactedReason.text,
769
859
  });
770
860
  return { content: [{ type: "text", text: JSON.stringify({
771
861
  status: "forgotten",
@@ -779,9 +869,13 @@ export function registerTools(mcp) {
779
869
  }
780
870
  });
781
871
  // ── memory_verify ───────────────────────────────────────────────────────
782
- mcp.tool("memory_verify", "Confirm a fact is still accurate. Resets the staleness clock and bumps verification count.", {
783
- fact_id: z.string().describe("ID of the fact to verify"),
784
- workspace_id: z.string().optional().describe("Optional workspace namespace."),
872
+ mcp.tool("memory_verify", [
873
+ "Confirm an existing fact is still accurate, without re-storing it. Resets the staleness clock and bumps a verification counter.",
874
+ "Use this when memory_heartbeat surfaces a stale fact ID and you can confirm it's still true (e.g. you just observed the system in that state). Lighter than memory_store(supersedes:) same content, fresh timestamp.",
875
+ "If the fact is no longer true, use memory_forget or memory_store(supersedes:) instead.",
876
+ ].join(" "), {
877
+ fact_id: z.string().describe("ID of the fact to verify (from memory_recall or memory_heartbeat)."),
878
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
785
879
  }, async ({ fact_id, workspace_id }) => {
786
880
  const guard = requireReady();
787
881
  if (guard)
@@ -818,17 +912,284 @@ export function registerTools(mcp) {
818
912
  return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
819
913
  }
820
914
  });
915
+ // ── memory_mark_useful ──────────────────────────────────────────────────
916
+ mcp.tool("memory_mark_useful", [
917
+ "Report that a previously recalled fact actually helped you answer the user's question or complete a task.",
918
+ "Bumps a 'useful_count' counter on the fact and writes a telemetry feedback_event row that future ranking work can aggregate.",
919
+ "Call this AFTER you used a fact from memory_recall / memory_entity and confirmed it was helpful — not for every recalled fact. If the fact was wrong or misleading, use memory_report_outcome with outcome='wrong' or 'misleading' instead.",
920
+ ].join(" "), {
921
+ fact_id: z.string().describe("ID of the fact that was useful (from memory_recall or memory_entity)."),
922
+ note: z.string().optional().describe("Optional short note about how the fact was useful (e.g. 'unblocked auth debug'). Stored on the telemetry event for future analysis."),
923
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
924
+ }, async ({ fact_id, note, workspace_id }) => {
925
+ const guard = requireReady();
926
+ if (guard)
927
+ return guard;
928
+ const now = nowISO();
929
+ try {
930
+ const scope = resolveScope(workspace_id);
931
+ const writable = await getPointForWorkspaceWrite(fact_id, scope);
932
+ if (writable.error) {
933
+ return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
934
+ }
935
+ const existingPt = writable.point;
936
+ const currentCount = existingPt?.payload.useful_count ?? 0;
937
+ const newCount = currentCount + 1;
938
+ await qdrantSetPayload([fact_id], {
939
+ useful_count: newCount,
940
+ last_useful_at: now,
941
+ updated_at: now,
942
+ });
943
+ // Write a telemetry feedback_event row so the signal is also visible
944
+ // to aggregations and review tooling.
945
+ const eventId = newId();
946
+ const eventContent = note
947
+ ? `Fact ${fact_id} marked useful: ${note}`
948
+ : `Fact ${fact_id} marked useful.`;
949
+ const eventPayload = {
950
+ content: eventContent,
951
+ category: "observations",
952
+ domain: "software_engineering",
953
+ kind: "telemetry",
954
+ memory_subtype: "feedback_event",
955
+ layer: "memory_object",
956
+ entities: [],
957
+ source: "agent",
958
+ confidence: 1.0,
959
+ importance: 0.3,
960
+ content_hash: contentHash("feedback_event", `${fact_id}:useful:${now}`),
961
+ target_fact_id: fact_id,
962
+ feedback_kind: "useful",
963
+ created_at: now,
964
+ updated_at: now,
965
+ };
966
+ addWorkspacePayload(eventPayload, scope);
967
+ try {
968
+ const eventVector = await embed(eventContent);
969
+ await qdrantUpsert(eventId, eventVector, eventPayload);
970
+ }
971
+ catch (e) {
972
+ log("WARN", `Failed to record feedback_event: ${e instanceof Error ? e.message : String(e)}`);
973
+ }
974
+ return {
975
+ content: [{ type: "text", text: JSON.stringify({
976
+ status: "marked_useful",
977
+ fact_id,
978
+ useful_count: newCount,
979
+ event_id: eventId,
980
+ }) }],
981
+ };
982
+ }
983
+ catch (e) {
984
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
985
+ }
986
+ });
987
+ // ── memory_report_outcome ───────────────────────────────────────────────
988
+ mcp.tool("memory_report_outcome", [
989
+ "Report the downstream outcome of using a recalled fact — useful, misleading, irrelevant, or wrong.",
990
+ "Writes a telemetry outcome_event row that future ranking and review work can aggregate. Unlike memory_mark_useful (positive-only, bumps a counter), this records a richer signal including negative outcomes and optional notes.",
991
+ "Use this when you can confidently judge whether a fact actually helped: 'useful' = helped you complete the task; 'misleading' = pointed in a wrong direction; 'irrelevant' = matched semantically but didn't help; 'wrong' = factually incorrect (also consider memory_forget for clearly wrong facts).",
992
+ ].join(" "), {
993
+ fact_id: z.string().describe("ID of the fact whose outcome you are reporting."),
994
+ outcome: z.enum(["useful", "misleading", "irrelevant", "wrong"]).describe("How the fact actually played out. 'useful' = helped you finish the task; 'misleading' = sent you the wrong way; 'irrelevant' = semantically matched but didn't help; 'wrong' = factually incorrect."),
995
+ notes: z.string().optional().describe("Optional short context for the outcome (e.g. 'API moved in v2', 'wrong port number'). Stored on the telemetry event for future analysis."),
996
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
997
+ }, async ({ fact_id, outcome, notes, workspace_id }) => {
998
+ const guard = requireReady();
999
+ if (guard)
1000
+ return guard;
1001
+ const now = nowISO();
1002
+ try {
1003
+ const scope = resolveScope(workspace_id);
1004
+ const target = await getPointForWorkspaceWrite(fact_id, scope);
1005
+ if (target.error) {
1006
+ return { content: [{ type: "text", text: JSON.stringify(target.error, null, 2) }], isError: true };
1007
+ }
1008
+ const eventId = newId();
1009
+ const eventContent = notes
1010
+ ? `Fact ${fact_id} outcome=${outcome}: ${notes}`
1011
+ : `Fact ${fact_id} outcome=${outcome}.`;
1012
+ const eventPayload = {
1013
+ content: eventContent,
1014
+ category: "observations",
1015
+ domain: "software_engineering",
1016
+ kind: "telemetry",
1017
+ memory_subtype: "outcome_event",
1018
+ layer: "memory_object",
1019
+ entities: [],
1020
+ source: "agent",
1021
+ confidence: 1.0,
1022
+ importance: outcome === "wrong" || outcome === "misleading" ? 0.6 : 0.3,
1023
+ content_hash: contentHash("outcome_event", `${fact_id}:${outcome}:${now}`),
1024
+ target_fact_id: fact_id,
1025
+ outcome,
1026
+ created_at: now,
1027
+ updated_at: now,
1028
+ };
1029
+ addWorkspacePayload(eventPayload, scope);
1030
+ const eventVector = await embed(eventContent);
1031
+ await qdrantUpsert(eventId, eventVector, eventPayload);
1032
+ return {
1033
+ content: [{ type: "text", text: JSON.stringify({
1034
+ status: "outcome_recorded",
1035
+ fact_id,
1036
+ outcome,
1037
+ event_id: eventId,
1038
+ }) }],
1039
+ };
1040
+ }
1041
+ catch (e) {
1042
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
1043
+ }
1044
+ });
1045
+ // ── memory_session_summary ──────────────────────────────────────────────
1046
+ mcp.tool("memory_session_summary", [
1047
+ "Persist a compact summary of the current session — what got done, what decisions were made, what's still open.",
1048
+ "Stored as kind='summary', memory_subtype='session_index', source='agent'. Keep it short (target 30-80 words). Future sessions retrieve these via memory_recall to bootstrap context faster than re-reading the original transcript.",
1049
+ "Call this near session close (or at major milestone boundaries) when the work is meaningful enough to want a future agent to inherit. Skip for trivial single-question sessions.",
1050
+ ].join(" "), {
1051
+ content: z.string().describe("The summary text. Atomic, self-contained, 30-80 words ideally. Should answer: what was the goal, what did we do, what remains?"),
1052
+ entities: z.array(z.string()).optional().describe("Lowercase entity names mentioned by the summary (services, repos, people, concepts). Used for entity-scoped recall later."),
1053
+ episode_id: z.string().optional().describe("Coherent activity-segment ID for grouping with related captures."),
1054
+ workstream_key: z.string().optional().describe("Durable continuity key for a long-running objective (survives across sessions)."),
1055
+ task_key: z.string().optional().describe("Task or issue key (e.g. GitHub issue number, JIRA key)."),
1056
+ repo: z.string().optional().describe("Repository or project surface this summary relates to."),
1057
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
1058
+ }, async ({ content, entities, episode_id, workstream_key, task_key, repo, workspace_id }) => {
1059
+ const guard = requireReady();
1060
+ if (guard)
1061
+ return guard;
1062
+ lastStoreTime = Date.now();
1063
+ const now = nowISO();
1064
+ try {
1065
+ const scope = resolveScope(workspace_id);
1066
+ const normalizedEntities = (entities ?? []).map((e) => e.trim().toLowerCase()).filter(Boolean);
1067
+ const summaryId = newId();
1068
+ const vector = await embed(content);
1069
+ const payload = {
1070
+ content,
1071
+ category: categoryForMemorySubtype("session_index") ?? "projects",
1072
+ domain: "software_engineering",
1073
+ kind: "summary",
1074
+ memory_subtype: "session_index",
1075
+ layer: layerForMemorySubtype("session_index") ?? "episode",
1076
+ entities: normalizedEntities,
1077
+ source: "agent",
1078
+ confidence: 0.9,
1079
+ importance: 0.6,
1080
+ content_hash: contentHash("summary", content),
1081
+ reinforcement_count: 1,
1082
+ last_reinforced_at: now,
1083
+ superseded_by: null,
1084
+ superseded_at: null,
1085
+ created_at: now,
1086
+ updated_at: now,
1087
+ };
1088
+ if (episode_id)
1089
+ payload["episode_id"] = episode_id;
1090
+ if (workstream_key)
1091
+ payload["workstream_key"] = workstream_key;
1092
+ if (task_key)
1093
+ payload["task_key"] = task_key;
1094
+ if (repo)
1095
+ payload["repo"] = repo;
1096
+ addWorkspacePayload(payload, scope);
1097
+ await qdrantUpsert(summaryId, vector, payload);
1098
+ return {
1099
+ content: [{ type: "text", text: JSON.stringify({
1100
+ status: "summary_stored",
1101
+ summary_id: summaryId,
1102
+ workspace_id: scope.workspaceId,
1103
+ }) }],
1104
+ };
1105
+ }
1106
+ catch (e) {
1107
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
1108
+ }
1109
+ });
1110
+ // ── memory_distill ──────────────────────────────────────────────────────
1111
+ mcp.tool("memory_distill", [
1112
+ "Persist a distilled convention — a reusable learning, pattern, or runbook synthesized from multiple prior memories.",
1113
+ "Stored as kind='distilled', memory_subtype='convention', source='agent'. Use this when you've noticed a pattern across several prior facts/sessions that's worth surfacing as its own atomic learning. The new memory will rank above raw facts in semantic recall because distilled patterns are higher-signal.",
1114
+ "Provide 'supersedes' if this distillation replaces an earlier convention. The original stays in storage but is excluded from recall.",
1115
+ ].join(" "), {
1116
+ content: z.string().describe("One-sentence reusable convention or pattern. Should be self-contained and applicable beyond a single situation."),
1117
+ entities: z.array(z.string()).describe("Lowercase entity names this distillation applies to (services, tools, concepts)."),
1118
+ supersedes: z.string().optional().describe("ID of an earlier distilled fact that this one replaces. Old fact is marked superseded and excluded from recall."),
1119
+ task_key: z.string().optional().describe("Task or issue key associated with this learning, if relevant."),
1120
+ repo: z.string().optional().describe("Repository or project surface this learning applies to."),
1121
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
1122
+ }, async ({ content, entities, supersedes, task_key, repo, workspace_id }) => {
1123
+ const guard = requireReady();
1124
+ if (guard)
1125
+ return guard;
1126
+ lastStoreTime = Date.now();
1127
+ const now = nowISO();
1128
+ try {
1129
+ const scope = resolveScope(workspace_id);
1130
+ const normalizedEntities = entities.map((e) => e.trim().toLowerCase()).filter(Boolean);
1131
+ const distilledId = newId();
1132
+ const vector = await embed(content);
1133
+ if (supersedes) {
1134
+ const existing = await getPointForWorkspaceWrite(supersedes, scope);
1135
+ if (existing.error) {
1136
+ return { content: [{ type: "text", text: JSON.stringify(existing.error, null, 2) }], isError: true };
1137
+ }
1138
+ await qdrantSetPayload([supersedes], {
1139
+ superseded_by: distilledId,
1140
+ superseded_at: now,
1141
+ });
1142
+ }
1143
+ const payload = {
1144
+ content,
1145
+ category: categoryForMemorySubtype("convention") ?? "observations",
1146
+ domain: "software_engineering",
1147
+ kind: "distilled",
1148
+ memory_subtype: "convention",
1149
+ layer: layerForMemorySubtype("convention") ?? "domain",
1150
+ entities: normalizedEntities,
1151
+ source: "agent",
1152
+ confidence: 0.9,
1153
+ importance: 0.7,
1154
+ content_hash: contentHash("distilled", content),
1155
+ reinforcement_count: 1,
1156
+ last_reinforced_at: now,
1157
+ superseded_by: null,
1158
+ superseded_at: null,
1159
+ created_at: now,
1160
+ updated_at: now,
1161
+ };
1162
+ if (task_key)
1163
+ payload["task_key"] = task_key;
1164
+ if (repo)
1165
+ payload["repo"] = repo;
1166
+ addWorkspacePayload(payload, scope);
1167
+ await qdrantUpsert(distilledId, vector, payload);
1168
+ return {
1169
+ content: [{ type: "text", text: JSON.stringify({
1170
+ status: "distilled_stored",
1171
+ distilled_id: distilledId,
1172
+ supersedes: supersedes ?? null,
1173
+ workspace_id: scope.workspaceId,
1174
+ }) }],
1175
+ };
1176
+ }
1177
+ catch (e) {
1178
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
1179
+ }
1180
+ });
821
1181
  // ── memory_review ───────────────────────────────────────────────────────
822
- mcp.tool("memory_review", "Review recent daemon-extracted facts. Supports approve (verify), reject (forget), or correct (supersede with edited text).", {
823
- limit: z.number().optional().default(10).describe("Max facts to return (default 10)"),
824
- action: z.enum(["list", "approve", "reject", "correct"]).optional().default("list")
825
- .describe("Action: list, approve, reject, correct"),
826
- fact_id: z.string().optional().describe("Fact ID (required for approve/reject/correct)"),
827
- reason: z.string().optional().describe("Reason for rejection"),
828
- corrected_content: z.string().optional().describe("Corrected fact text (for correct action)"),
829
- workspace_id: z.string().optional().describe("Filter by optional workspace namespace."),
830
- include_legacy_workspace: z.boolean().optional()
831
- .describe("Include legacy facts without workspace_id in this workspace query."),
1182
+ mcp.tool("memory_review", [
1183
+ "Triage facts that were extracted automatically by the bikky daemon (source='daemon').",
1184
+ "Only useful when the daemon is running and capturing memories from logs/transcripts; otherwise this returns an empty list. Supports four actions: list (default — show recent daemon facts), approve (mark verified), reject (mark superseded with reason), correct (replace with edited content as a new fact).",
1185
+ ].join(" "), {
1186
+ limit: z.number().optional().default(10).describe("Max facts to return when action=list (default 10)."),
1187
+ action: z.enum(["list", "approve", "reject", "correct"]).optional().default("list").describe("What to do. list = show recent daemon-extracted facts (default). approve = confirm a fact is correct (bumps verification count). reject = mark a fact as wrong (requires 'reason'). correct = supersede with an edited version (requires 'corrected_content')."),
1188
+ fact_id: z.string().optional().describe("Fact ID to act on. Required for approve / reject / correct."),
1189
+ reason: z.string().optional().describe("Required for action=reject. Short reason the fact is wrong."),
1190
+ corrected_content: z.string().optional().describe("Required for action=correct. The fixed fact text. Stored as a new fact that supersedes the original."),
1191
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
1192
+ include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility: also include legacy facts with no workspace_id. Default false."),
832
1193
  }, async ({ limit, action, fact_id, reason, corrected_content, workspace_id, include_legacy_workspace }) => {
833
1194
  const guard = requireReady();
834
1195
  if (guard)
@@ -947,7 +1308,10 @@ export function registerTools(mcp) {
947
1308
  return { content: [{ type: "text", text: `Unknown action: ${String(action)}` }] };
948
1309
  });
949
1310
  // ── memory_heartbeat ────────────────────────────────────────────────────
950
- mcp.tool("memory_heartbeat", "Lightweight reflection check. Returns memory nudge (if no stores in 10+ min), staleness alerts (every 3rd call), and reflection prompt.", {}, async () => {
1311
+ mcp.tool("memory_heartbeat", [
1312
+ "Reflection check-in. Returns up to three things: a memory nudge if you haven't stored anything in 10+ minutes, stale-fact alerts every 3rd call (with IDs you can pass to memory_verify or memory_forget), and a reflection prompt asking whether the last few minutes of work produced anything worth storing.",
1313
+ "Call periodically during interactive sessions — roughly every 10 minutes or every 3rd user prompt. No arguments. Cheap and read-only.",
1314
+ ].join(" "), {}, async () => {
951
1315
  heartbeatCount++;
952
1316
  const sections = [];
953
1317
  const nudge = buildMemoryNudge();