bikky 0.3.12 → 0.4.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 (67) hide show
  1. package/CONTRIBUTING.md +206 -0
  2. package/README.md +116 -154
  3. package/dist/config.d.ts +49 -1
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +125 -4
  6. package/dist/config.js.map +1 -1
  7. package/dist/daemon/extraction.d.ts.map +1 -1
  8. package/dist/daemon/extraction.js +24 -19
  9. package/dist/daemon/extraction.js.map +1 -1
  10. package/dist/daemon/loop.d.ts.map +1 -1
  11. package/dist/daemon/loop.js +15 -1
  12. package/dist/daemon/loop.js.map +1 -1
  13. package/dist/daemon/qdrant.d.ts.map +1 -1
  14. package/dist/daemon/qdrant.js +0 -1
  15. package/dist/daemon/qdrant.js.map +1 -1
  16. package/dist/lib/qdrant-pool.d.ts +57 -0
  17. package/dist/lib/qdrant-pool.d.ts.map +1 -0
  18. package/dist/lib/qdrant-pool.js +104 -0
  19. package/dist/lib/qdrant-pool.js.map +1 -0
  20. package/dist/mcp/api.d.ts +56 -19
  21. package/dist/mcp/api.d.ts.map +1 -1
  22. package/dist/mcp/api.js +133 -72
  23. package/dist/mcp/api.js.map +1 -1
  24. package/dist/mcp/helpers.d.ts +0 -1
  25. package/dist/mcp/helpers.d.ts.map +1 -1
  26. package/dist/mcp/helpers.js +2 -15
  27. package/dist/mcp/helpers.js.map +1 -1
  28. package/dist/mcp/helpers.test.js +3 -21
  29. package/dist/mcp/helpers.test.js.map +1 -1
  30. package/dist/mcp/index.d.ts.map +1 -1
  31. package/dist/mcp/index.js +29 -14
  32. package/dist/mcp/index.js.map +1 -1
  33. package/dist/mcp/tools.d.ts +0 -7
  34. package/dist/mcp/tools.d.ts.map +1 -1
  35. package/dist/mcp/tools.js +337 -219
  36. package/dist/mcp/tools.js.map +1 -1
  37. package/dist/mcp/types.d.ts +0 -3
  38. package/dist/mcp/types.d.ts.map +1 -1
  39. package/dist/routing.d.ts +53 -0
  40. package/dist/routing.d.ts.map +1 -0
  41. package/dist/routing.js +129 -0
  42. package/dist/routing.js.map +1 -0
  43. package/dist/routing.test.d.ts +2 -0
  44. package/dist/routing.test.d.ts.map +1 -0
  45. package/dist/routing.test.js +79 -0
  46. package/dist/routing.test.js.map +1 -0
  47. package/docs/config/fully-hosted.md +57 -0
  48. package/docs/config/hosted-models.md +50 -0
  49. package/docs/config/hosted-qdrant-local-models.md +39 -0
  50. package/docs/config/local.md +34 -0
  51. package/docs/configuration.md +374 -0
  52. package/docs/screenshots/dashboard.png +0 -0
  53. package/docs/screenshots/graph.png +0 -0
  54. package/docs/screenshots/memory.png +0 -0
  55. package/package.json +7 -4
  56. package/dist/mcp/api.test.d.ts +0 -6
  57. package/dist/mcp/api.test.d.ts.map +0 -1
  58. package/dist/mcp/api.test.js +0 -130
  59. package/dist/mcp/api.test.js.map +0 -1
  60. package/dist/mcp/tools.integration.itest.d.ts +0 -23
  61. package/dist/mcp/tools.integration.itest.d.ts.map +0 -1
  62. package/dist/mcp/tools.integration.itest.js +0 -171
  63. package/dist/mcp/tools.integration.itest.js.map +0 -1
  64. package/dist/mcp/tools.test.d.ts +0 -16
  65. package/dist/mcp/tools.test.d.ts.map +0 -1
  66. package/dist/mcp/tools.test.js +0 -908
  67. package/dist/mcp/tools.test.js.map +0 -1
package/dist/mcp/tools.js CHANGED
@@ -5,7 +5,8 @@ import crypto from "node:crypto";
5
5
  import { z } from "zod";
6
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, structuredFact, MEMORY_RECALL_EXCLUDED_KINDS, } from "./helpers.js";
8
- import { ready, qdrantUrl, qdrantApiKey, setupError, setQdrantUrl, setQdrantApiKey, setReady, getCollection, log, embed, getEmbeddingConfig, qdrantReq, ensureCollection, qdrantUpsert, qdrantSearch, qdrantScroll, qdrantSetPayload, qdrantGetPoints, } from "./api.js";
8
+ import { ready, setupError, setReady, log, embed, getEmbeddingConfig, qdrantReq, ensureCollectionsAll, qdrantUpsert, qdrantSearch, qdrantScroll, qdrantSetPayload, qdrantGetPoints, rebuildPool, hasPool, listDestinations, resolveDest, findPointById, } from "./api.js";
9
+ import { DestinationNotFoundError } from "../routing.js";
9
10
  import { saveConfig, loadConfig, EXTRACTION_HEALTH_PATH } from "../config.js";
10
11
  import { existsSync, readFileSync } from "node:fs";
11
12
  import { inspectWatcherPaths, formatIssue, repairSuspiciousWatcherPaths } from "../daemon/watcher-health.js";
@@ -28,32 +29,48 @@ function nowISO() {
28
29
  function newId() {
29
30
  return crypto.randomUUID();
30
31
  }
31
- export function resolveScope(workspaceId, includeLegacyWorkspace = false, actorId) {
32
- const resolved = workspaceId?.trim()
33
- || process.env.BIKKY_WORKSPACE?.trim()
34
- || loadConfig().default_workspace?.trim()
35
- || undefined;
36
- // The literal "default" workspace also includes legacy facts that have no
37
- // workspace_id payload (pre-migration data). Any other named workspace stays
38
- // strict. An explicit includeLegacyWorkspace=true from the caller still wins.
39
- const isDefault = resolved === "default";
32
+ // Build a RoutingInput from the standard memory-tool fields.
33
+ function routingInput(args) {
40
34
  return {
41
- workspaceId: resolved,
42
- actorId: normalizeActorId(actorId),
43
- includeLegacy: includeLegacyWorkspace || isDefault,
35
+ destination: args.destination,
36
+ cwd: process.cwd(),
37
+ content: args.content,
38
+ entities: args.entities,
39
+ metadata: args.metadata,
44
40
  };
45
41
  }
46
- function scopedFilter(scope, extra = {}) {
47
- return buildFilter({
48
- ...extra,
49
- workspace_id: scope.workspaceId,
50
- includeLegacyWorkspace: scope.includeLegacy,
51
- });
42
+ // Resolve a destination from a routing input, returning either the destination
43
+ // or an MCP error result if the override is invalid / no destinations exist.
44
+ function resolveDestOrError(input) {
45
+ try {
46
+ return { dest: resolveDest(input) };
47
+ }
48
+ catch (e) {
49
+ const msg = e instanceof Error ? e.message : String(e);
50
+ if (e instanceof DestinationNotFoundError) {
51
+ return {
52
+ error: {
53
+ content: [{ type: "text", text: JSON.stringify({
54
+ status: "destination_not_found",
55
+ message: msg,
56
+ available_destinations: listDestinations().map((d) => d.name),
57
+ }, null, 2) }],
58
+ isError: true,
59
+ },
60
+ };
61
+ }
62
+ return {
63
+ error: {
64
+ content: [{ type: "text", text: JSON.stringify({ status: "error", message: msg }, null, 2) }],
65
+ isError: true,
66
+ },
67
+ };
68
+ }
52
69
  }
53
- function addWorkspacePayload(payload, scope, actor) {
54
- if (scope.workspaceId)
55
- payload["workspace_id"] = scope.workspaceId;
56
- const actorId = actor?.actor_id ?? scope.actorId;
70
+ // Add actor identity payload fields. Workspace was removed in v0.4.0 — physical
71
+ // separation now happens via routing destinations (see routing.ts).
72
+ function addActorPayload(payload, actor, actorIdOverride) {
73
+ const actorId = actor?.actor_id ?? normalizeActorId(actorIdOverride);
57
74
  if (actorId)
58
75
  payload["actor_id"] = actorId;
59
76
  if (actor?.actor_label) {
@@ -66,19 +83,34 @@ function addWorkspacePayload(payload, scope, actor) {
66
83
  payload["metadata"] = metadata;
67
84
  }
68
85
  }
69
- async function getPointForWorkspaceWrite(factId, _scope) {
70
- const existing = await qdrantGetPoints([factId]);
86
+ async function getPointForWrite(dest, factId) {
87
+ const existing = await qdrantGetPoints(dest, [factId]);
71
88
  const point = existing.result?.[0];
72
89
  if (!point) {
73
90
  return { error: { status: "not_found", fact_id: factId } };
74
91
  }
75
92
  return { point };
76
93
  }
94
+ // Locate which destination owns a fact ID (fan-out across pool). Used by
95
+ // ID-based ops where the caller doesn't know upfront which destination holds
96
+ // the point (memory_forget, memory_verify, memory_report_outcome, etc.).
97
+ async function locatePoint(factId) {
98
+ const found = await findPointById(factId);
99
+ if (!found)
100
+ return null;
101
+ return { dest: found.destination, point: found.point };
102
+ }
103
+ function notFoundResult(factId) {
104
+ return {
105
+ content: [{ type: "text", text: JSON.stringify({ status: "not_found", fact_id: factId }, null, 2) }],
106
+ isError: true,
107
+ };
108
+ }
77
109
  function requireReady() {
78
110
  if (!ready) {
79
111
  const missing = [];
80
- if (!qdrantUrl)
81
- missing.push("qdrant-url");
112
+ if (!hasPool())
113
+ missing.push("destinations");
82
114
  return {
83
115
  content: [{
84
116
  type: "text",
@@ -86,9 +118,6 @@ function requireReady() {
86
118
  status: "setup_required",
87
119
  ready: false,
88
120
  missing,
89
- // Surface the underlying init failure (embedding / Qdrant) when
90
- // present so users see an actionable reason instead of a generic
91
- // "setup required" message.
92
121
  ...(setupError ? { setup_error: setupError } : {}),
93
122
  setup_instructions: "Memory is not configured. Run `bikky setup` or call configure_credentials:\n" +
94
123
  "1. Go to cloud.qdrant.io → sign up (free tier: 1GB, no credit card)\n" +
@@ -126,7 +155,7 @@ function clampRecallLimit(limit) {
126
155
  /**
127
156
  * Entity-graph traversal for memory_recall.
128
157
  */
129
- async function graphTraversal(primaryResults, limit, scope) {
158
+ async function graphTraversal(dest, primaryResults, limit) {
130
159
  try {
131
160
  const primaryEntities = new Set();
132
161
  const primaryIds = new Set();
@@ -140,16 +169,16 @@ async function graphTraversal(primaryResults, limit, scope) {
140
169
  return { points: [] };
141
170
  const relatedEntities = new Set();
142
171
  for (const entity of primaryEntities) {
143
- const outgoingFilter = scopedFilter(scope, { excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
172
+ const outgoingFilter = buildFilter({ excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
144
173
  outgoingFilter.must.push({ key: "from_entity", match: { value: entity } });
145
- const outgoing = await qdrantScroll(outgoingFilter, 10).catch(() => ({ result: { points: [] } }));
174
+ const outgoing = await qdrantScroll(dest, outgoingFilter, 10).catch(() => ({ result: { points: [] } }));
146
175
  for (const pt of (outgoing.result?.points ?? [])) {
147
176
  if (pt.payload.to_entity)
148
177
  relatedEntities.add(pt.payload.to_entity);
149
178
  }
150
- const incomingFilter = scopedFilter(scope, { excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
179
+ const incomingFilter = buildFilter({ excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
151
180
  incomingFilter.must.push({ key: "to_entity", match: { value: entity } });
152
- const incoming = await qdrantScroll(incomingFilter, 10).catch(() => ({ result: { points: [] } }));
181
+ const incoming = await qdrantScroll(dest, incomingFilter, 10).catch(() => ({ result: { points: [] } }));
153
182
  for (const pt of (incoming.result?.points ?? [])) {
154
183
  if (pt.payload.from_entity)
155
184
  relatedEntities.add(pt.payload.from_entity);
@@ -162,9 +191,9 @@ async function graphTraversal(primaryResults, limit, scope) {
162
191
  const relatedFacts = [];
163
192
  const maxPerEntity = Math.max(2, Math.floor(limit / relatedEntities.size));
164
193
  for (const entity of relatedEntities) {
165
- const filter = scopedFilter(scope, { excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
194
+ const filter = buildFilter({ excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
166
195
  filter.must.push({ key: "entities", match: { value: entity } });
167
- const result = await qdrantScroll(filter, maxPerEntity).catch(() => ({ result: { points: [] } }));
196
+ const result = await qdrantScroll(dest, filter, maxPerEntity).catch(() => ({ result: { points: [] } }));
168
197
  for (const pt of (result.result?.points ?? [])) {
169
198
  if (!primaryIds.has(pt.id)) {
170
199
  relatedFacts.push(pt);
@@ -189,33 +218,51 @@ export function registerTools(mcp) {
189
218
  "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.",
190
219
  "Read-only — safe to call any time.",
191
220
  ].join(" "), {}, async () => {
192
- const activeWorkspace = process.env.BIKKY_WORKSPACE?.trim()
193
- || loadConfig().default_workspace?.trim()
194
- || null;
221
+ const dests = listDestinations();
195
222
  const status = {
196
223
  ready,
197
- qdrant_url: !!qdrantUrl,
198
- qdrant_api_key: !!qdrantApiKey,
224
+ destinations_configured: dests.length,
199
225
  missing: [],
200
- qdrant_connected: false,
201
226
  embedding_connected: false,
202
227
  embedding_provider: getEmbeddingConfig().provider,
203
228
  embedding_model: getEmbeddingConfig().model,
204
229
  embedding_dimensions: getEmbeddingConfig().dimensions,
205
- ...(activeWorkspace ? { active_workspace: activeWorkspace } : {}),
206
230
  ...(setupError ? { setup_error: setupError } : {}),
207
231
  };
208
232
  const missing = status["missing"];
209
- if (!qdrantUrl)
210
- missing.push("qdrant-url");
211
- // qdrant-api-key is optional (local / self-hosted Qdrant doesn't need it).
212
- if (qdrantUrl) {
233
+ if (dests.length === 0)
234
+ missing.push("destinations");
235
+ // Per-destination health
236
+ const destStatus = [];
237
+ for (const d of dests) {
238
+ const block = {
239
+ name: d.name,
240
+ qdrant_url_host: (() => { try {
241
+ return new URL(d.qdrant_url).host;
242
+ }
243
+ catch {
244
+ return d.qdrant_url;
245
+ } })(),
246
+ collection: d.collection,
247
+ default: d.default ?? false,
248
+ connected: false,
249
+ collection_exists: false,
250
+ };
213
251
  try {
214
- await qdrantReq("GET", "/collections");
215
- status["qdrant_connected"] = true;
252
+ await qdrantReq(d, "GET", "/collections");
253
+ block["connected"] = true;
254
+ }
255
+ catch (e) {
256
+ block["last_error"] = e instanceof Error ? e.message : String(e);
257
+ }
258
+ try {
259
+ await qdrantReq(d, "GET", `/collections/${d.collection}`);
260
+ block["collection_exists"] = true;
216
261
  }
217
262
  catch { /* ignore */ }
263
+ destStatus.push(block);
218
264
  }
265
+ status["destinations"] = destStatus;
219
266
  try {
220
267
  await embed("test");
221
268
  status["embedding_connected"] = true;
@@ -283,12 +330,10 @@ export function registerTools(mcp) {
283
330
  if (qdrant_url) {
284
331
  const url = qdrant_url.replace(/\/+$/, "");
285
332
  cfg.qdrant_url = url;
286
- setQdrantUrl(url);
287
333
  results["qdrant_url"] = "stored ✓";
288
334
  }
289
335
  if (qdrant_api_key) {
290
336
  cfg.qdrant_api_key = qdrant_api_key;
291
- setQdrantApiKey(qdrant_api_key);
292
337
  results["qdrant_api_key"] = "stored ✓";
293
338
  }
294
339
  if (openai_api_key) {
@@ -301,14 +346,22 @@ export function registerTools(mcp) {
301
346
  results["watcher_path_repairs"] = watcherRepairs;
302
347
  }
303
348
  saveConfig(cfg);
304
- if (qdrantUrl) {
305
- try {
306
- await ensureCollection(QDRANT_INDEXES);
307
- results["qdrant_collection"] = `'${getCollection()}' ready ✓`;
308
- }
309
- catch (e) {
310
- results["qdrant_collection"] = `error: ${e instanceof Error ? e.message : String(e)}`;
311
- }
349
+ // Rebuild the destination pool from the updated config so the
350
+ // synthesized default destination picks up the new url/key.
351
+ try {
352
+ rebuildPool();
353
+ }
354
+ catch (e) {
355
+ results["pool_rebuild"] = `error: ${e instanceof Error ? e.message : String(e)}`;
356
+ }
357
+ if (hasPool()) {
358
+ const ensured = await ensureCollectionsAll(QDRANT_INDEXES);
359
+ results["destinations"] = ensured.map((r) => ({
360
+ name: r.destination.name,
361
+ collection: r.destination.collection,
362
+ ok: r.ok,
363
+ ...(r.error ? { error: r.error } : {}),
364
+ }));
312
365
  }
313
366
  try {
314
367
  await embed("memory system test");
@@ -318,7 +371,7 @@ export function registerTools(mcp) {
318
371
  catch (e) {
319
372
  results["embedding"] = `error: ${e instanceof Error ? e.message : String(e)}`;
320
373
  }
321
- setReady(!!qdrantUrl);
374
+ setReady(hasPool());
322
375
  results["ready"] = ready;
323
376
  return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
324
377
  });
@@ -328,21 +381,31 @@ export function registerTools(mcp) {
328
381
  "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.",
329
382
  "Read-only.",
330
383
  ].join(" "), {}, async () => {
331
- const results = { qdrant: false, embedding: false, collection: false };
332
- if (qdrantUrl) {
384
+ const results = { embedding: false };
385
+ const dests = listDestinations();
386
+ const destResults = [];
387
+ for (const d of dests) {
388
+ const block = {
389
+ name: d.name,
390
+ collection: d.collection,
391
+ qdrant: false,
392
+ collection_exists: false,
393
+ };
333
394
  try {
334
- await qdrantReq("GET", "/collections");
335
- results["qdrant"] = true;
395
+ await qdrantReq(d, "GET", "/collections");
396
+ block["qdrant"] = true;
336
397
  }
337
398
  catch (e) {
338
- results["qdrant_error"] = e instanceof Error ? e.message : String(e);
399
+ block["qdrant_error"] = e instanceof Error ? e.message : String(e);
339
400
  }
340
401
  try {
341
- await qdrantReq("GET", `/collections/${getCollection()}`);
342
- results["collection"] = true;
402
+ await qdrantReq(d, "GET", `/collections/${d.collection}`);
403
+ block["collection_exists"] = true;
343
404
  }
344
405
  catch { /* ignore */ }
406
+ destResults.push(block);
345
407
  }
408
+ results["destinations"] = destResults;
346
409
  try {
347
410
  await embed("connection test");
348
411
  results["embedding"] = true;
@@ -350,7 +413,8 @@ export function registerTools(mcp) {
350
413
  catch (e) {
351
414
  results["embedding_error"] = e instanceof Error ? e.message : String(e);
352
415
  }
353
- const allReady = results["qdrant"] === true && results["embedding"] === true && results["collection"] === true;
416
+ const allReady = results["embedding"] === true && destResults.length > 0
417
+ && destResults.every((b) => b["qdrant"] === true && b["collection_exists"] === true);
354
418
  results["ready"] = allReady;
355
419
  setReady(allReady);
356
420
  return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
@@ -369,7 +433,8 @@ export function registerTools(mcp) {
369
433
  domain: z.enum(domainValues()).default(DEFAULT_DOMAIN).describe(domainEnumDescription()),
370
434
  kind: z.enum(kindValues()).default(DEFAULT_KIND).describe(kindEnumDescription()),
371
435
  memory_subtype: z.enum(memorySubtypeValues()).optional().describe(memorySubtypeEnumDescription()),
372
- workspace_id: z.string().optional().describe("Workspace namespace for team-shared memory. Omit to use the default workspace from config."),
436
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op. Routing now uses destinations see destination."),
437
+ destination: z.string().optional().describe("Optional destination override. When set, routes to that destination by name. Hard-errors if no such destination exists. Omit to let routing rules in ~/.bikky/config.json decide based on cwd/entities/content/metadata."),
373
438
  actor_id: z.string().optional().describe("Stable actor/person/agent identity associated with this capture. Overrides identity config/env/Git-derived fallback for this write."),
374
439
  episode_id: z.string().optional().describe("Coherent activity-segment ID. Group facts captured during the same coherent task or transcript."),
375
440
  workstream_key: z.string().optional().describe("Durable continuity key for a long-running objective (survives across sessions)."),
@@ -387,13 +452,21 @@ export function registerTools(mcp) {
387
452
  to: z.string().describe("Target entity (lowercase)."),
388
453
  }).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."),
389
454
  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)."),
390
- }, async ({ content, category, entities, domain, kind, memory_subtype, workspace_id, actor_id, episode_id, workstream_key, task_key, repo, branch, review_status, source, confidence, importance, supersedes, relation, metadata, }) => {
455
+ }, async ({ content, category, entities, domain, kind, memory_subtype, workspace_id: _workspace_id, destination, actor_id, episode_id, workstream_key, task_key, repo, branch, review_status, source, confidence, importance, supersedes, relation, metadata, }) => {
391
456
  const guard = requireReady();
392
457
  if (guard)
393
458
  return guard;
394
459
  lastStoreTime = Date.now();
395
460
  const now = nowISO();
396
- const scope = resolveScope(workspace_id);
461
+ const resolved = resolveDestOrError(routingInput({
462
+ destination,
463
+ content,
464
+ entities,
465
+ metadata,
466
+ }));
467
+ if (resolved.error)
468
+ return resolved.error;
469
+ const dest = resolved.dest;
397
470
  const actor = resolveActorIdentity({ actorId: actor_id, config: loadConfig() });
398
471
  const normalizedKind = normalizeKind(kind);
399
472
  let normalizedSubtype = null;
@@ -439,14 +512,14 @@ export function registerTools(mcp) {
439
512
  } : null;
440
513
  // 1. Exact dedup via content hash
441
514
  try {
442
- const hashFilter = scopedFilter(scope) ?? { must: [] };
515
+ const hashFilter = buildFilter({}) ?? { must: [] };
443
516
  hashFilter.must.push({ key: "content_hash", match: { value: hash } });
444
- const existing = await qdrantScroll(hashFilter, 1);
517
+ const existing = await qdrantScroll(dest, hashFilter, 1);
445
518
  const existingPoint = existing.result?.points?.[0];
446
519
  if (existingPoint) {
447
520
  const point = existingPoint;
448
521
  const count = (point.payload.reinforcement_count || 1) + 1;
449
- await qdrantSetPayload([point.id], {
522
+ await qdrantSetPayload(dest, [point.id], {
450
523
  reinforcement_count: count,
451
524
  last_reinforced_at: now,
452
525
  updated_at: now,
@@ -470,18 +543,18 @@ export function registerTools(mcp) {
470
543
  let similarFacts = [];
471
544
  let potentialConflicts = [];
472
545
  try {
473
- const filter = scopedFilter(scope) ?? { must: [] };
546
+ const filter = buildFilter({}) ?? { must: [] };
474
547
  if (normalizedEntities.length > 0) {
475
548
  filter.must.push({ key: "entities", match: { any: normalizedEntities } });
476
549
  }
477
- const results = await qdrantSearch(vector, filter, 3);
550
+ const results = await qdrantSearch(dest, vector, filter, 3);
478
551
  const firstResult = results.result?.[0];
479
552
  if (results.result?.length > 0 && firstResult) {
480
553
  const topScore = firstResult.score ?? 0;
481
554
  if (topScore > THRESHOLD_DUPLICATE) {
482
555
  const point = firstResult;
483
556
  const count = (point.payload.reinforcement_count || 1) + 1;
484
- await qdrantSetPayload([point.id], {
557
+ await qdrantSetPayload(dest, [point.id], {
485
558
  reinforcement_count: count,
486
559
  last_reinforced_at: now,
487
560
  updated_at: now,
@@ -531,11 +604,11 @@ export function registerTools(mcp) {
531
604
  // 5. Supersede old fact if requested
532
605
  if (supersedes) {
533
606
  try {
534
- const existing = await getPointForWorkspaceWrite(supersedes, scope);
607
+ const existing = await getPointForWrite(dest, supersedes);
535
608
  if (existing.error) {
536
609
  return { content: [{ type: "text", text: JSON.stringify(existing.error, null, 2) }], isError: true };
537
610
  }
538
- await qdrantSetPayload([supersedes], {
611
+ await qdrantSetPayload(dest, [supersedes], {
539
612
  superseded_by: factId,
540
613
  superseded_at: now,
541
614
  });
@@ -583,9 +656,9 @@ export function registerTools(mcp) {
583
656
  }
584
657
  if (review_status)
585
658
  payload["review_status"] = review_status;
586
- addWorkspacePayload(payload, scope, actor);
659
+ addActorPayload(payload, actor);
587
660
  addRedactionPayload(payload, factRedactionSummary);
588
- await qdrantUpsert(factId, vector, payload);
661
+ await qdrantUpsert(dest, factId, vector, payload);
589
662
  // 7. Insert relation point if provided
590
663
  let relationId = null;
591
664
  if (sanitizedRelation) {
@@ -612,14 +685,14 @@ export function registerTools(mcp) {
612
685
  relation_type: sanitizedRelation.type.toLowerCase(),
613
686
  to_entity: sanitizedRelation.to.toLowerCase(),
614
687
  };
615
- addWorkspacePayload(relPayload, scope, actor);
688
+ addActorPayload(relPayload, actor);
616
689
  addRedactionPayload(relPayload, relationRedactionSummary);
617
- await qdrantUpsert(relationId, relVector, relPayload);
690
+ await qdrantUpsert(dest, relationId, relVector, relPayload);
618
691
  }
619
692
  const result = {
620
693
  action: "inserted",
621
694
  fact_id: factId,
622
- workspace_id: scope.workspaceId,
695
+ destination: dest.name,
623
696
  };
624
697
  if (actor.actor_id)
625
698
  result["actor_id"] = actor.actor_id;
@@ -653,9 +726,10 @@ export function registerTools(mcp) {
653
726
  domain: z.string().optional().describe("Filter by domain activity profile (same vocabulary as memory_store.domain). Optional."),
654
727
  kind: z.string().optional().describe("Filter by kind: fact, summary, distilled, relation. Optional. Telemetry is excluded by default."),
655
728
  memory_subtype: z.string().optional().describe("Filter by memory subtype (must be valid for the chosen kind). Optional."),
656
- workspace_id: z.string().optional().describe("Filter to facts in this workspace namespace. Omit to use the default workspace from config."),
729
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
730
+ destination: z.string().optional().describe("Optional destination override. When set, queries that destination by name. Hard-errors if no such destination exists. Omit to let routing rules decide."),
657
731
  actor_id: z.string().optional().describe("Filter to facts captured by or associated with this stable actor identity. Optional."),
658
- 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."),
732
+ include_legacy_workspace: z.boolean().optional().describe("[Removed in v0.4.0] No-op."),
659
733
  entity: z.string().optional().describe("Restrict to facts mentioning this entity (case-insensitive). For full entity context prefer memory_entity."),
660
734
  episode_id: z.string().optional().describe("Filter by coherent episode ID."),
661
735
  workstream_key: z.string().optional().describe("Filter by durable workstream key."),
@@ -669,14 +743,22 @@ export function registerTools(mcp) {
669
743
  graph_depth: z.number().optional().default(0).describe("Entity-graph traversal depth. 0 = vector search only (fast, default). 1 = also surface up to ceil(limit / 2) extra 1-hop entity-related facts (slower; use when the user asks 'what's connected to X?'). In JSON output these are returned separately as related."),
670
744
  output_format: z.enum(["text", "json"]).optional().default("text").describe("Response format. text = backward-compatible human-readable lines (default). json = parseable object with query, limit metadata, results, related, counts, and optional nudge."),
671
745
  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)."),
672
- }, async ({ query, category, domain, kind, memory_subtype, workspace_id, actor_id, include_legacy_workspace, entity, episode_id, workstream_key, task_key, repo, branch, review_status, since, until, limit, graph_depth, output_format, metadata_filter, }) => {
746
+ }, async ({ query, category, domain, kind, memory_subtype, workspace_id: _workspace_id, destination, actor_id, include_legacy_workspace: _include_legacy_workspace, entity, episode_id, workstream_key, task_key, repo, branch, review_status, since, until, limit, graph_depth, output_format, metadata_filter, }) => {
673
747
  const guard = requireReady();
674
748
  if (guard)
675
749
  return guard;
676
750
  const requestedLimit = limit ?? MEMORY_RECALL_DEFAULT_LIMIT;
677
751
  const effectiveLimit = clampRecallLimit(limit);
678
752
  const actorFilter = resolveActorIdentity({ actorId: actor_id, useGitFallback: false });
679
- const scope = resolveScope(workspace_id, include_legacy_workspace, actorFilter.actor_id);
753
+ const resolved = resolveDestOrError(routingInput({
754
+ destination,
755
+ content: query,
756
+ entities: entity ? [entity] : [],
757
+ metadata: metadata_filter,
758
+ }));
759
+ if (resolved.error)
760
+ return resolved.error;
761
+ const dest = resolved.dest;
680
762
  const redactedQuery = redactStorageText(query);
681
763
  const vector = await embed(redactedQuery.text);
682
764
  const normalizedKind = kind ? normalizeKind(kind) : undefined;
@@ -692,11 +774,12 @@ export function registerTools(mcp) {
692
774
  };
693
775
  }
694
776
  }
695
- const filter = scopedFilter(scope, {
777
+ const filter = buildFilter({
696
778
  category: category ? normalizeCategory(category) : undefined,
697
779
  domain: domain ? normalizeDomain(domain) : undefined,
698
780
  kind: normalizedKind,
699
781
  memory_subtype: normalizedSubtype,
782
+ actor_id: actorFilter.actor_id,
700
783
  entity,
701
784
  episode_id,
702
785
  workstream_key,
@@ -709,7 +792,7 @@ export function registerTools(mcp) {
709
792
  metadata: metadata_filter,
710
793
  excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS,
711
794
  });
712
- const results = await qdrantSearch(vector, filter, effectiveLimit * 2);
795
+ const results = await qdrantSearch(dest, vector, filter, effectiveLimit * 2);
713
796
  if (!results.result?.length) {
714
797
  const nudge = buildMemoryNudge();
715
798
  if (output_format === "json") {
@@ -738,7 +821,7 @@ export function registerTools(mcp) {
738
821
  const lines = ranked.map((r) => formatFact(r));
739
822
  let related = { points: [] };
740
823
  if ((graph_depth ?? 0) >= 1) {
741
- related = await graphTraversal(ranked, effectiveLimit, scope);
824
+ related = await graphTraversal(dest, ranked, effectiveLimit);
742
825
  if (related.points.length > 0) {
743
826
  lines.push("", "── Related (1-hop) ──");
744
827
  lines.push(...related.points.map((r) => formatFact(r)));
@@ -777,21 +860,25 @@ export function registerTools(mcp) {
777
860
  ].join(" "), {
778
861
  name: z.string().describe("Entity name (case-insensitive, e.g. 'qdrant', 'workspace_id'). Should match the lowercase canonical form used when facts were stored."),
779
862
  limit: z.number().optional().default(20).describe("Max facts to return (default 20). Relations are always returned in full, capped at 50 each direction."),
780
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
781
- include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility: also include legacy facts with no workspace_id. Default false."),
782
- }, async ({ name, limit, workspace_id, include_legacy_workspace }) => {
863
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
864
+ destination: z.string().optional().describe("Optional destination override. Omit to let routing rules decide."),
865
+ include_legacy_workspace: z.boolean().optional().describe("[Removed in v0.4.0] No-op."),
866
+ }, async ({ name, limit, workspace_id: _workspace_id, destination, include_legacy_workspace: _include_legacy_workspace }) => {
783
867
  const guard = requireReady();
784
868
  if (guard)
785
869
  return guard;
786
870
  const entityName = name.toLowerCase();
787
- const scope = resolveScope(workspace_id, include_legacy_workspace);
871
+ const resolved = resolveDestOrError(routingInput({ destination, entities: [entityName] }));
872
+ if (resolved.error)
873
+ return resolved.error;
874
+ const dest = resolved.dest;
788
875
  // Look up the daemon-classified entity type, if any.
789
876
  let entityType = null;
790
877
  try {
791
- const typeFilter = scopedFilter(scope) ?? { must: [] };
878
+ const typeFilter = buildFilter({}) ?? { must: [] };
792
879
  typeFilter.must.push({ key: "kind", match: { value: "entity_type" } });
793
880
  typeFilter.must.push({ key: "entity_name", match: { value: entityName } });
794
- const typePoints = await qdrantScroll(typeFilter, 1);
881
+ const typePoints = await qdrantScroll(dest, typeFilter, 1);
795
882
  const typePoint = typePoints.result?.points?.[0];
796
883
  const payload = typePoint?.payload;
797
884
  if (payload?.entity_type) {
@@ -801,15 +888,15 @@ export function registerTools(mcp) {
801
888
  catch {
802
889
  // Type lookup is best-effort — never fails the request.
803
890
  }
804
- const factsFilter = scopedFilter(scope) ?? { must: [] };
891
+ const factsFilter = buildFilter({}) ?? { must: [] };
805
892
  factsFilter.must.push({ key: "entities", match: { value: entityName } });
806
- const facts = await qdrantScroll(factsFilter, limit ?? 20);
807
- const fromFilter = scopedFilter(scope) ?? { must: [] };
893
+ const facts = await qdrantScroll(dest, factsFilter, limit ?? 20);
894
+ const fromFilter = buildFilter({}) ?? { must: [] };
808
895
  fromFilter.must.push({ key: "from_entity", match: { value: entityName } });
809
- const relationsFrom = await qdrantScroll(fromFilter, 50);
810
- const toFilter = scopedFilter(scope) ?? { must: [] };
896
+ const relationsFrom = await qdrantScroll(dest, fromFilter, 50);
897
+ const toFilter = buildFilter({}) ?? { must: [] };
811
898
  toFilter.must.push({ key: "to_entity", match: { value: entityName } });
812
- const relationsTo = await qdrantScroll(toFilter, 50);
899
+ const relationsTo = await qdrantScroll(dest, toFilter, 50);
813
900
  const output = [];
814
901
  const factPoints = facts.result?.points ?? [];
815
902
  if (factPoints.length > 0) {
@@ -858,31 +945,35 @@ export function registerTools(mcp) {
858
945
  entity: z.string().describe("Entity name to query (case-insensitive)."),
859
946
  relation_type: z.string().optional().describe("Filter to a specific edge label (e.g. 'owns', 'uses', 'decided', 'prefers', 'works-on'). Optional."),
860
947
  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)."),
861
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
862
- include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility: also include legacy facts with no workspace_id. Default false."),
863
- }, async ({ entity, relation_type, direction, workspace_id, include_legacy_workspace }) => {
948
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
949
+ destination: z.string().optional().describe("Optional destination override. Omit to let routing rules decide."),
950
+ include_legacy_workspace: z.boolean().optional().describe("[Removed in v0.4.0] No-op."),
951
+ }, async ({ entity, relation_type, direction, workspace_id: _workspace_id, destination, include_legacy_workspace: _include_legacy_workspace }) => {
864
952
  const guard = requireReady();
865
953
  if (guard)
866
954
  return guard;
867
955
  const entityName = entity.toLowerCase();
868
- const scope = resolveScope(workspace_id, include_legacy_workspace);
956
+ const resolved = resolveDestOrError(routingInput({ destination, entities: [entityName] }));
957
+ if (resolved.error)
958
+ return resolved.error;
959
+ const dest = resolved.dest;
869
960
  const results = [];
870
961
  if (direction === "from" || direction === "both") {
871
- const filter = scopedFilter(scope) ?? { must: [] };
962
+ const filter = buildFilter({}) ?? { must: [] };
872
963
  filter.must.push({ key: "from_entity", match: { value: entityName } });
873
964
  if (relation_type) {
874
965
  filter.must.push({ key: "relation_type", match: { value: relation_type.toLowerCase() } });
875
966
  }
876
- const r = await qdrantScroll(filter, 50);
967
+ const r = await qdrantScroll(dest, filter, 50);
877
968
  results.push(...(r.result?.points ?? []));
878
969
  }
879
970
  if (direction === "to" || direction === "both") {
880
- const filter = scopedFilter(scope) ?? { must: [] };
971
+ const filter = buildFilter({}) ?? { must: [] };
881
972
  filter.must.push({ key: "to_entity", match: { value: entityName } });
882
973
  if (relation_type) {
883
974
  filter.must.push({ key: "relation_type", match: { value: relation_type.toLowerCase() } });
884
975
  }
885
- const r = await qdrantScroll(filter, 50);
976
+ const r = await qdrantScroll(dest, filter, 50);
886
977
  results.push(...(r.result?.points ?? []));
887
978
  }
888
979
  const seen = new Set();
@@ -908,21 +999,19 @@ export function registerTools(mcp) {
908
999
  ].join(" "), {
909
1000
  fact_id: z.string().describe("ID of the fact to forget (returned by memory_store / memory_recall as 'id')."),
910
1001
  reason: z.string().describe("Short human-readable reason this fact is being retired (stored in 'superseded_by' for future audit)."),
911
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
912
- }, async ({ fact_id, reason, workspace_id }) => {
1002
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1003
+ }, async ({ fact_id, reason, workspace_id: _workspace_id }) => {
913
1004
  const guard = requireReady();
914
1005
  if (guard)
915
1006
  return guard;
916
1007
  const now = nowISO();
917
1008
  try {
918
- const scope = resolveScope(workspace_id);
919
- const _actor = resolveActorIdentity({ config: loadConfig() });
920
- const existing = await getPointForWorkspaceWrite(fact_id, scope);
921
- if (existing.error) {
922
- return { content: [{ type: "text", text: JSON.stringify(existing.error, null, 2) }], isError: true };
923
- }
1009
+ const located = await locatePoint(fact_id);
1010
+ if (!located)
1011
+ return notFoundResult(fact_id);
1012
+ const { dest } = located;
924
1013
  const redactedReason = redactStorageText(reason);
925
- await qdrantSetPayload([fact_id], {
1014
+ await qdrantSetPayload(dest, [fact_id], {
926
1015
  superseded_by: `forgotten:${redactedReason.text}`,
927
1016
  superseded_at: now,
928
1017
  updated_at: now,
@@ -937,6 +1026,7 @@ export function registerTools(mcp) {
937
1026
  return { content: [{ type: "text", text: JSON.stringify({
938
1027
  status: "forgotten",
939
1028
  fact_id,
1029
+ destination: dest.name,
940
1030
  reason: redactedReason.text,
941
1031
  ...(redactedReason.redacted ? { redaction: redactedReason } : {}),
942
1032
  }) }] };
@@ -952,26 +1042,20 @@ export function registerTools(mcp) {
952
1042
  "If the fact is no longer true, use memory_forget or memory_store(supersedes:) instead.",
953
1043
  ].join(" "), {
954
1044
  fact_id: z.string().describe("ID of the fact to verify (from memory_recall or memory_heartbeat)."),
955
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
956
- }, async ({ fact_id, workspace_id }) => {
1045
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1046
+ }, async ({ fact_id, workspace_id: _workspace_id }) => {
957
1047
  const guard = requireReady();
958
1048
  if (guard)
959
1049
  return guard;
960
1050
  const now = nowISO();
961
1051
  try {
962
- const scope = resolveScope(workspace_id);
963
- const _actor = resolveActorIdentity({ config: loadConfig() });
964
- const writable = await getPointForWorkspaceWrite(fact_id, scope);
965
- if (writable.error) {
966
- return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
967
- }
968
- let currentCount = 0;
969
- const existingPt = writable.point;
970
- if (existingPt) {
971
- currentCount = existingPt.payload.verification_count ?? 0;
972
- }
1052
+ const located = await locatePoint(fact_id);
1053
+ if (!located)
1054
+ return notFoundResult(fact_id);
1055
+ const { dest, point } = located;
1056
+ const currentCount = point.payload.verification_count ?? 0;
973
1057
  const newCount = currentCount + 1;
974
- await qdrantSetPayload([fact_id], {
1058
+ await qdrantSetPayload(dest, [fact_id], {
975
1059
  last_verified_at: now,
976
1060
  last_reinforced_at: now,
977
1061
  verification_count: newCount,
@@ -981,6 +1065,7 @@ export function registerTools(mcp) {
981
1065
  content: [{ type: "text", text: JSON.stringify({
982
1066
  status: "verified",
983
1067
  fact_id,
1068
+ destination: dest.name,
984
1069
  verification_count: newCount,
985
1070
  message: "Fact confirmed as still accurate. Staleness clock reset.",
986
1071
  }) }],
@@ -998,23 +1083,21 @@ export function registerTools(mcp) {
998
1083
  ].join(" "), {
999
1084
  fact_id: z.string().describe("ID of the fact that was useful (from memory_recall or memory_entity)."),
1000
1085
  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."),
1001
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
1002
- }, async ({ fact_id, note, workspace_id }) => {
1086
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1087
+ }, async ({ fact_id, note, workspace_id: _workspace_id }) => {
1003
1088
  const guard = requireReady();
1004
1089
  if (guard)
1005
1090
  return guard;
1006
1091
  const now = nowISO();
1007
1092
  try {
1008
- const scope = resolveScope(workspace_id);
1093
+ const located = await locatePoint(fact_id);
1094
+ if (!located)
1095
+ return notFoundResult(fact_id);
1096
+ const { dest, point } = located;
1009
1097
  const actor = resolveActorIdentity({ config: loadConfig() });
1010
- const writable = await getPointForWorkspaceWrite(fact_id, scope);
1011
- if (writable.error) {
1012
- return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
1013
- }
1014
- const existingPt = writable.point;
1015
- const currentCount = existingPt?.payload.useful_count ?? 0;
1098
+ const currentCount = point.payload.useful_count ?? 0;
1016
1099
  const newCount = currentCount + 1;
1017
- await qdrantSetPayload([fact_id], {
1100
+ await qdrantSetPayload(dest, [fact_id], {
1018
1101
  useful_count: newCount,
1019
1102
  last_useful_at: now,
1020
1103
  updated_at: now,
@@ -1043,11 +1126,11 @@ export function registerTools(mcp) {
1043
1126
  created_at: now,
1044
1127
  updated_at: now,
1045
1128
  };
1046
- addWorkspacePayload(eventPayload, scope, actor);
1129
+ addActorPayload(eventPayload, actor);
1047
1130
  addRedactionPayload(eventPayload, redactedEvent);
1048
1131
  try {
1049
1132
  const eventVector = await embed(redactedEvent.text);
1050
- await qdrantUpsert(eventId, eventVector, eventPayload);
1133
+ await qdrantUpsert(dest, eventId, eventVector, eventPayload);
1051
1134
  }
1052
1135
  catch (e) {
1053
1136
  log("WARN", `Failed to record feedback_event: ${e instanceof Error ? e.message : String(e)}`);
@@ -1056,6 +1139,7 @@ export function registerTools(mcp) {
1056
1139
  content: [{ type: "text", text: JSON.stringify({
1057
1140
  status: "marked_useful",
1058
1141
  fact_id,
1142
+ destination: dest.name,
1059
1143
  useful_count: newCount,
1060
1144
  event_id: eventId,
1061
1145
  }) }],
@@ -1074,19 +1158,18 @@ export function registerTools(mcp) {
1074
1158
  fact_id: z.string().describe("ID of the fact whose outcome you are reporting."),
1075
1159
  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."),
1076
1160
  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."),
1077
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
1078
- }, async ({ fact_id, outcome, notes, workspace_id }) => {
1161
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1162
+ }, async ({ fact_id, outcome, notes, workspace_id: _workspace_id }) => {
1079
1163
  const guard = requireReady();
1080
1164
  if (guard)
1081
1165
  return guard;
1082
1166
  const now = nowISO();
1083
1167
  try {
1084
- const scope = resolveScope(workspace_id);
1168
+ const located = await locatePoint(fact_id);
1169
+ if (!located)
1170
+ return notFoundResult(fact_id);
1171
+ const { dest } = located;
1085
1172
  const actor = resolveActorIdentity({ config: loadConfig() });
1086
- const target = await getPointForWorkspaceWrite(fact_id, scope);
1087
- if (target.error) {
1088
- return { content: [{ type: "text", text: JSON.stringify(target.error, null, 2) }], isError: true };
1089
- }
1090
1173
  const eventId = newId();
1091
1174
  const eventContent = notes
1092
1175
  ? `Fact ${fact_id} outcome=${outcome}: ${notes}`
@@ -1109,14 +1192,15 @@ export function registerTools(mcp) {
1109
1192
  created_at: now,
1110
1193
  updated_at: now,
1111
1194
  };
1112
- addWorkspacePayload(eventPayload, scope, actor);
1195
+ addActorPayload(eventPayload, actor);
1113
1196
  addRedactionPayload(eventPayload, redactedEvent);
1114
1197
  const eventVector = await embed(redactedEvent.text);
1115
- await qdrantUpsert(eventId, eventVector, eventPayload);
1198
+ await qdrantUpsert(dest, eventId, eventVector, eventPayload);
1116
1199
  return {
1117
1200
  content: [{ type: "text", text: JSON.stringify({
1118
1201
  status: "outcome_recorded",
1119
1202
  fact_id,
1203
+ destination: dest.name,
1120
1204
  outcome,
1121
1205
  event_id: eventId,
1122
1206
  }) }],
@@ -1138,16 +1222,24 @@ export function registerTools(mcp) {
1138
1222
  workstream_key: z.string().optional().describe("Durable continuity key for a long-running objective (survives across sessions)."),
1139
1223
  task_key: z.string().optional().describe("Task or issue key (e.g. GitHub issue number, JIRA key)."),
1140
1224
  repo: z.string().optional().describe("Repository or project surface this summary relates to."),
1141
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
1225
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1226
+ destination: z.string().optional().describe("Optional destination override. Omit to let routing rules decide."),
1142
1227
  actor_id: z.string().optional().describe("Stable actor identity associated with this session summary. Overrides identity config/env/Git fallback."),
1143
- }, async ({ content, entities, episode_id, workstream_key, task_key, repo, workspace_id, actor_id }) => {
1228
+ }, async ({ content, entities, episode_id, workstream_key, task_key, repo, workspace_id: _workspace_id, destination, actor_id }) => {
1144
1229
  const guard = requireReady();
1145
1230
  if (guard)
1146
1231
  return guard;
1147
1232
  lastStoreTime = Date.now();
1148
1233
  const now = nowISO();
1149
1234
  try {
1150
- const scope = resolveScope(workspace_id);
1235
+ const resolved = resolveDestOrError(routingInput({
1236
+ destination,
1237
+ content,
1238
+ entities: entities ?? [],
1239
+ }));
1240
+ if (resolved.error)
1241
+ return resolved.error;
1242
+ const dest = resolved.dest;
1151
1243
  const actor = resolveActorIdentity({ actorId: actor_id, config: loadConfig() });
1152
1244
  const normalizedEntities = (entities ?? []).map((e) => e.trim().toLowerCase()).filter(Boolean);
1153
1245
  const summaryId = newId();
@@ -1180,14 +1272,14 @@ export function registerTools(mcp) {
1180
1272
  payload["task_key"] = task_key;
1181
1273
  if (repo)
1182
1274
  payload["repo"] = repo;
1183
- addWorkspacePayload(payload, scope, actor);
1275
+ addActorPayload(payload, actor);
1184
1276
  addRedactionPayload(payload, redactedContent);
1185
- await qdrantUpsert(summaryId, vector, payload);
1277
+ await qdrantUpsert(dest, summaryId, vector, payload);
1186
1278
  return {
1187
1279
  content: [{ type: "text", text: JSON.stringify({
1188
1280
  status: "summary_stored",
1189
1281
  summary_id: summaryId,
1190
- workspace_id: scope.workspaceId,
1282
+ destination: dest.name,
1191
1283
  actor_id: actor.actor_id,
1192
1284
  }) }],
1193
1285
  };
@@ -1207,27 +1299,35 @@ export function registerTools(mcp) {
1207
1299
  supersedes: z.string().optional().describe("ID of an earlier distilled fact that this one replaces. Old fact is marked superseded and excluded from recall."),
1208
1300
  task_key: z.string().optional().describe("Task or issue key associated with this learning, if relevant."),
1209
1301
  repo: z.string().optional().describe("Repository or project surface this learning applies to."),
1210
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
1302
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1303
+ destination: z.string().optional().describe("Optional destination override. Omit to let routing rules decide."),
1211
1304
  actor_id: z.string().optional().describe("Stable actor identity associated with this distillation. Overrides identity config/env/Git fallback."),
1212
- }, async ({ content, entities, supersedes, task_key, repo, workspace_id, actor_id }) => {
1305
+ }, async ({ content, entities, supersedes, task_key, repo, workspace_id: _workspace_id, destination, actor_id }) => {
1213
1306
  const guard = requireReady();
1214
1307
  if (guard)
1215
1308
  return guard;
1216
1309
  lastStoreTime = Date.now();
1217
1310
  const now = nowISO();
1218
1311
  try {
1219
- const scope = resolveScope(workspace_id);
1312
+ const resolved = resolveDestOrError(routingInput({
1313
+ destination,
1314
+ content,
1315
+ entities,
1316
+ }));
1317
+ if (resolved.error)
1318
+ return resolved.error;
1319
+ const dest = resolved.dest;
1220
1320
  const actor = resolveActorIdentity({ actorId: actor_id, config: loadConfig() });
1221
1321
  const normalizedEntities = entities.map((e) => e.trim().toLowerCase()).filter(Boolean);
1222
1322
  const distilledId = newId();
1223
1323
  const redactedContent = redactStorageText(content);
1224
1324
  const vector = await embed(redactedContent.text);
1225
1325
  if (supersedes) {
1226
- const existing = await getPointForWorkspaceWrite(supersedes, scope);
1227
- if (existing.error) {
1228
- return { content: [{ type: "text", text: JSON.stringify(existing.error, null, 2) }], isError: true };
1229
- }
1230
- await qdrantSetPayload([supersedes], {
1326
+ // Supersede may live in a different destination — locate it.
1327
+ const located = await locatePoint(supersedes);
1328
+ if (!located)
1329
+ return notFoundResult(supersedes);
1330
+ await qdrantSetPayload(located.dest, [supersedes], {
1231
1331
  superseded_by: distilledId,
1232
1332
  superseded_at: now,
1233
1333
  });
@@ -1255,15 +1355,15 @@ export function registerTools(mcp) {
1255
1355
  payload["task_key"] = task_key;
1256
1356
  if (repo)
1257
1357
  payload["repo"] = repo;
1258
- addWorkspacePayload(payload, scope, actor);
1358
+ addActorPayload(payload, actor);
1259
1359
  addRedactionPayload(payload, redactedContent);
1260
- await qdrantUpsert(distilledId, vector, payload);
1360
+ await qdrantUpsert(dest, distilledId, vector, payload);
1261
1361
  return {
1262
1362
  content: [{ type: "text", text: JSON.stringify({
1263
1363
  status: "distilled_stored",
1264
1364
  distilled_id: distilledId,
1365
+ destination: dest.name,
1265
1366
  supersedes: supersedes ?? null,
1266
- workspace_id: scope.workspaceId,
1267
1367
  actor_id: actor.actor_id,
1268
1368
  }) }],
1269
1369
  };
@@ -1282,26 +1382,37 @@ export function registerTools(mcp) {
1282
1382
  fact_id: z.string().optional().describe("Fact ID to act on. Required for approve / reject / correct."),
1283
1383
  reason: z.string().optional().describe("Required for action=reject. Short reason the fact is wrong."),
1284
1384
  corrected_content: z.string().optional().describe("Required for action=correct. The fixed fact text. Stored as a new fact that supersedes the original."),
1285
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
1286
- include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility: also include legacy facts with no workspace_id. Default false."),
1287
- }, async ({ limit, action, fact_id, reason, corrected_content, workspace_id, include_legacy_workspace }) => {
1385
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1386
+ include_legacy_workspace: z.boolean().optional().describe("[Removed in v0.4.0] No-op."),
1387
+ }, async ({ limit, action, fact_id, reason, corrected_content, workspace_id: _workspace_id, include_legacy_workspace: _include_legacy_workspace }) => {
1288
1388
  const guard = requireReady();
1289
1389
  if (guard)
1290
1390
  return guard;
1291
- const scope = resolveScope(workspace_id, include_legacy_workspace);
1292
1391
  if (action === "list") {
1293
- const filter = scopedFilter(scope) ?? { must: [] };
1294
- filter.must.push({ key: "source", match: { any: ["system", "daemon"] } });
1295
- const result = await qdrantScroll(filter, (limit ?? 10) * 2);
1296
- const points = (result.result?.points ?? [])
1297
- .sort((a, b) => (b.payload.created_at ?? "").localeCompare(a.payload.created_at ?? ""))
1392
+ // List spans all destinations.
1393
+ const destinations = listDestinations();
1394
+ const allPoints = [];
1395
+ const filter = { must: [{ key: "source", match: { any: ["system", "daemon"] } }] };
1396
+ for (const dest of destinations) {
1397
+ try {
1398
+ const result = await qdrantScroll(dest, filter, (limit ?? 10) * 2);
1399
+ const points = result.result?.points ?? [];
1400
+ for (const pt of points)
1401
+ allPoints.push({ dest: dest.name, point: pt });
1402
+ }
1403
+ catch (e) {
1404
+ log("WARN", `memory_review list scroll failed on ${dest.name}: ${e instanceof Error ? e.message : String(e)}`);
1405
+ }
1406
+ }
1407
+ const sorted = allPoints
1408
+ .sort((a, b) => (b.point.payload.created_at ?? "").localeCompare(a.point.payload.created_at ?? ""))
1298
1409
  .slice(0, limit ?? 10);
1299
- if (points.length === 0) {
1410
+ if (sorted.length === 0) {
1300
1411
  return { content: [{ type: "text", text: "No system-captured facts found." }] };
1301
1412
  }
1302
- const lines = points.map((pt) => {
1413
+ const lines = sorted.map(({ dest, point: pt }) => {
1303
1414
  const p = pt.payload;
1304
- return `[${p.category}] ${p.content}\n id: ${pt.id} | confidence: ${p.confidence} | importance: ${p.importance} | entities: ${(p.entities ?? []).join(", ")} | created: ${p.created_at}`;
1415
+ return `[${p.category}] ${p.content}\n id: ${pt.id} | dest: ${dest} | confidence: ${p.confidence} | importance: ${p.importance} | entities: ${(p.entities ?? []).join(", ")} | created: ${p.created_at}`;
1305
1416
  });
1306
1417
  return { content: [{ type: "text", text: lines.join("\n\n") }] };
1307
1418
  }
@@ -1310,32 +1421,28 @@ export function registerTools(mcp) {
1310
1421
  }
1311
1422
  const now = nowISO();
1312
1423
  if (action === "approve") {
1313
- const writable = await getPointForWorkspaceWrite(fact_id, scope);
1314
- if (writable.error) {
1315
- return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
1316
- }
1317
- let currentCount = 0;
1318
- const approvePt = writable.point;
1319
- if (approvePt) {
1320
- currentCount = approvePt.payload.verification_count ?? 0;
1321
- }
1322
- await qdrantSetPayload([fact_id], {
1424
+ const located = await locatePoint(fact_id);
1425
+ if (!located)
1426
+ return notFoundResult(fact_id);
1427
+ const { dest, point } = located;
1428
+ const currentCount = point.payload.verification_count ?? 0;
1429
+ await qdrantSetPayload(dest, [fact_id], {
1323
1430
  last_verified_at: now,
1324
1431
  verification_count: currentCount + 1,
1325
1432
  updated_at: now,
1326
1433
  });
1327
- return { content: [{ type: "text", text: JSON.stringify({ status: "approved", fact_id }) }] };
1434
+ return { content: [{ type: "text", text: JSON.stringify({ status: "approved", fact_id, destination: dest.name }) }] };
1328
1435
  }
1329
1436
  if (action === "reject") {
1330
1437
  if (!reason) {
1331
1438
  return { content: [{ type: "text", text: "Error: reason is required for reject action." }] };
1332
1439
  }
1333
- const writable = await getPointForWorkspaceWrite(fact_id, scope);
1334
- if (writable.error) {
1335
- return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
1336
- }
1440
+ const located = await locatePoint(fact_id);
1441
+ if (!located)
1442
+ return notFoundResult(fact_id);
1443
+ const { dest } = located;
1337
1444
  const redactedReason = redactStorageText(reason);
1338
- await qdrantSetPayload([fact_id], {
1445
+ await qdrantSetPayload(dest, [fact_id], {
1339
1446
  superseded_by: `rejected:${redactedReason.text}`,
1340
1447
  superseded_at: now,
1341
1448
  updated_at: now,
@@ -1343,6 +1450,7 @@ export function registerTools(mcp) {
1343
1450
  return { content: [{ type: "text", text: JSON.stringify({
1344
1451
  status: "rejected",
1345
1452
  fact_id,
1453
+ destination: dest.name,
1346
1454
  reason: redactedReason.text,
1347
1455
  ...(redactedReason.redacted ? { redaction: redactedReason } : {}),
1348
1456
  }) }] };
@@ -1351,15 +1459,12 @@ export function registerTools(mcp) {
1351
1459
  if (!corrected_content) {
1352
1460
  return { content: [{ type: "text", text: "Error: corrected_content is required for correct action." }] };
1353
1461
  }
1354
- const writable = await getPointForWorkspaceWrite(fact_id, scope);
1355
- if (writable.error) {
1356
- return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
1357
- }
1358
- const origPayload = writable.point?.payload;
1462
+ const located = await locatePoint(fact_id);
1463
+ if (!located)
1464
+ return notFoundResult(fact_id);
1465
+ const { dest, point } = located;
1466
+ const origPayload = point.payload;
1359
1467
  const redactedCorrected = redactStorageText(corrected_content);
1360
- const correctionScope = origPayload?.workspace_id
1361
- ? resolveScope(origPayload.workspace_id, false)
1362
- : scope;
1363
1468
  const actor = resolveActorIdentity({ config: loadConfig() });
1364
1469
  const vector = await embed(redactedCorrected.text);
1365
1470
  const correctedId = crypto.randomUUID();
@@ -1390,15 +1495,15 @@ export function registerTools(mcp) {
1390
1495
  updated_at: now,
1391
1496
  metadata: { ...(origPayload?.metadata ?? {}), corrected_from: fact_id },
1392
1497
  };
1393
- addWorkspacePayload(correctedPayload, correctionScope, actor);
1498
+ addActorPayload(correctedPayload, actor);
1394
1499
  addRedactionPayload(correctedPayload, redactedCorrected);
1395
- await qdrantUpsert(correctedId, vector, correctedPayload);
1396
- await qdrantSetPayload([fact_id], {
1500
+ await qdrantUpsert(dest, correctedId, vector, correctedPayload);
1501
+ await qdrantSetPayload(dest, [fact_id], {
1397
1502
  superseded_by: correctedId,
1398
1503
  superseded_at: now,
1399
1504
  updated_at: now,
1400
1505
  });
1401
- return { content: [{ type: "text", text: JSON.stringify({ status: "corrected", old_fact_id: fact_id, new_fact_id: correctedId }) }] };
1506
+ return { content: [{ type: "text", text: JSON.stringify({ status: "corrected", old_fact_id: fact_id, new_fact_id: correctedId, destination: dest.name }) }] };
1402
1507
  }
1403
1508
  return { content: [{ type: "text", text: `Unknown action: ${String(action)}` }] };
1404
1509
  });
@@ -1415,8 +1520,7 @@ export function registerTools(mcp) {
1415
1520
  if (heartbeatCount % 3 === 0 && ready) {
1416
1521
  try {
1417
1522
  const staleThreshold = new Date(Date.now() - STALENESS_DAYS * 86400000).toISOString();
1418
- const scope = resolveScope();
1419
- const staleFilter = scopedFilter(scope) ?? { must: [] };
1523
+ const staleFilter = { must: [] };
1420
1524
  staleFilter.must.push({ key: "category", match: { any: ["engineering", "product", "human", "system"] } });
1421
1525
  staleFilter.should = [
1422
1526
  { key: "last_reinforced_at", range: { lte: staleThreshold } },
@@ -1425,10 +1529,24 @@ export function registerTools(mcp) {
1425
1529
  staleFilter.must_not = [
1426
1530
  { key: "last_verified_at", range: { gte: staleThreshold } },
1427
1531
  ];
1428
- const staleResults = await qdrantScroll(staleFilter, 3);
1429
- const staleFacts = staleResults.result?.points ?? [];
1430
- if (staleFacts.length > 0) {
1431
- const staleLines = staleFacts.map((f) => {
1532
+ // Aggregate stale facts across all destinations.
1533
+ const staleFacts = [];
1534
+ for (const dest of listDestinations()) {
1535
+ try {
1536
+ const r = await qdrantScroll(dest, staleFilter, 3);
1537
+ const pts = r.result?.points ?? [];
1538
+ for (const pt of pts)
1539
+ staleFacts.push(pt);
1540
+ if (staleFacts.length >= 3)
1541
+ break;
1542
+ }
1543
+ catch (e) {
1544
+ log("WARN", `Staleness check failed on ${dest.name}: ${e instanceof Error ? e.message : String(e)}`);
1545
+ }
1546
+ }
1547
+ const trimmed = staleFacts.slice(0, 3);
1548
+ if (trimmed.length > 0) {
1549
+ const staleLines = trimmed.map((f) => {
1432
1550
  const d = Math.round(daysSince(lastActivityDate(f.payload)));
1433
1551
  return ` • [${f.payload.category}] ${f.payload.content} (${d}d old, id: ${f.id})`;
1434
1552
  });