bikky 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,34 @@ This project uses npm package versions for release tracking:
9
9
 
10
10
  ## Unreleased
11
11
 
12
+ ## 0.4.6
13
+
14
+ - Added configurable `ignore` rules for memory writes using the same `cwd`, `entity`, `content`, and `metadata` filters as destination routing.
15
+ - Applied ignore checks before MCP writes, daemon writes, relation sidecars, superseding, telemetry upserts, embedding, deduplication, and Qdrant persistence.
16
+ - Documented ignore configuration and added regression coverage for config validation, routing precedence, MCP write paths, daemon writes, telemetry, and local MCP E2E behavior.
17
+
18
+ ## 0.4.5
19
+
20
+ - Fixed destination routing so daemon provenance and agent metadata no longer influence where unrelated facts are stored.
21
+ - Added regression coverage for non-Bikky facts with Bikky daemon origin metadata.
22
+ - Moved the README top diagram closer to the audience/context section.
23
+
24
+ ## 0.4.4
25
+
26
+ - Refreshed README positioning and configuration guidance for supported MCP clients, transcript capture, Qdrant storage, and built-in model providers.
27
+ - Added memory quality/usefulness signals and surfaced usefulness data in the UI.
28
+ - Fixed MCP and daemon write routing to use the full memory context consistently.
29
+ - Removed the old Human memory category and filtered internal dashboard memory rows.
30
+ - Added Playwright coverage for memory filters and edge cases.
31
+
32
+ ## 0.4.3
33
+
34
+ - Fixed daemon destination routing and dynamic config-path handling.
35
+ - Added canonical origin provenance metadata to memory writes.
36
+ - Recommended Portkey as the default cloud inference provider and canonicalized 1024-dimension embedding guidance.
37
+ - Fixed `bikky-ui` browser launches when the server binds to `localhost:0`.
38
+ - Added higher-priority test coverage across core routing, config, and UI behavior.
39
+
12
40
  ## 0.4.2
13
41
 
14
42
  - Republished the core package from current `main` so the npm package README uses GitHub documentation links instead of stale jsDelivr links.
package/dist/config.d.ts CHANGED
@@ -119,6 +119,14 @@ export interface Destination {
119
119
  /** Routing rules. Omit for a destination that is only reachable by override. */
120
120
  match?: DestinationMatch;
121
121
  }
122
+ export interface IgnoreRule {
123
+ /** Stable identifier returned in ignored-write responses and logs. */
124
+ name?: string;
125
+ /** Human-readable reason/guidance for why the rule exists. */
126
+ description?: string;
127
+ /** Match rules using the same semantics as destination routing. */
128
+ match: DestinationMatch;
129
+ }
122
130
  export type SearchScopeTarget = "routed" | "all" | string | string[];
123
131
  export interface SearchScopeDefinition {
124
132
  /** Stable scope name that MCP clients can pass as `search_scope`. */
@@ -143,6 +151,8 @@ export interface BikkyConfig {
143
151
  * default flag → first entry.
144
152
  */
145
153
  destinations: Destination[];
154
+ /** Memory write exclusion rules. First matching rule skips persistence. */
155
+ ignore: IgnoreRule[];
146
156
  /**
147
157
  * Default read/search scope. "routed" preserves historical behavior
148
158
  * (one destination via routing rules); "all" fans out to every destination;
package/dist/config.js CHANGED
@@ -55,6 +55,7 @@ const DEFAULTS = {
55
55
  qdrant_api_key: null,
56
56
  collection: "bikky",
57
57
  destinations: [],
58
+ ignore: [],
58
59
  default_search_scope: "routed",
59
60
  search_scopes: [],
60
61
  aws_profile: null,
@@ -241,6 +242,11 @@ const destinationFileSchema = z.object({
241
242
  default: z.boolean().optional(),
242
243
  match: destinationMatchSchema.optional(),
243
244
  }).passthrough();
245
+ const ignoreRuleFileSchema = z.object({
246
+ name: z.string().min(1).optional(),
247
+ description: z.string().optional(),
248
+ match: destinationMatchSchema,
249
+ }).passthrough();
244
250
  const searchScopeTargetSchema = z.union([
245
251
  z.string().min(1),
246
252
  z.array(z.string().min(1)).min(1),
@@ -255,6 +261,7 @@ const configFileSchema = z.object({
255
261
  qdrant_api_key: z.string().nullable().optional(),
256
262
  collection: z.string().optional(),
257
263
  destinations: z.array(destinationFileSchema).optional(),
264
+ ignore: z.array(ignoreRuleFileSchema).optional(),
258
265
  default_search_scope: searchScopeTargetSchema.optional(),
259
266
  search_scopes: z.array(searchScopeDefinitionFileSchema).optional(),
260
267
  aws_profile: z.string().nullable().optional(),
@@ -313,6 +320,58 @@ function validateUrlLike(value, pathName, issues) {
313
320
  });
314
321
  }
315
322
  }
323
+ function validateMatchBlock(match, base, issues) {
324
+ for (const field of ["cwd", "entity", "content"]) {
325
+ const value = match[field];
326
+ if (value === undefined)
327
+ continue;
328
+ if (!Array.isArray(value)) {
329
+ issues.push({ severity: "error", path: `${base}.${field}`, message: "must be an array of regex strings" });
330
+ continue;
331
+ }
332
+ value.forEach((pattern, pIdx) => {
333
+ if (typeof pattern !== "string") {
334
+ issues.push({ severity: "error", path: `${base}.${field}[${pIdx}]`, message: "must be a string" });
335
+ return;
336
+ }
337
+ try {
338
+ new RegExp(pattern);
339
+ }
340
+ catch (e) {
341
+ issues.push({
342
+ severity: "error",
343
+ path: `${base}.${field}[${pIdx}]`,
344
+ message: `invalid regex: ${e instanceof Error ? e.message : String(e)}`,
345
+ });
346
+ }
347
+ });
348
+ }
349
+ const metadata = childObject(match, "metadata");
350
+ if (metadata) {
351
+ for (const [key, value] of Object.entries(metadata)) {
352
+ if (!Array.isArray(value)) {
353
+ issues.push({ severity: "error", path: `${base}.metadata.${key}`, message: "must be an array of regex strings" });
354
+ continue;
355
+ }
356
+ value.forEach((pattern, pIdx) => {
357
+ if (typeof pattern !== "string") {
358
+ issues.push({ severity: "error", path: `${base}.metadata.${key}[${pIdx}]`, message: "must be a string" });
359
+ return;
360
+ }
361
+ try {
362
+ new RegExp(pattern);
363
+ }
364
+ catch (e) {
365
+ issues.push({
366
+ severity: "error",
367
+ path: `${base}.metadata.${key}[${pIdx}]`,
368
+ message: `invalid regex: ${e instanceof Error ? e.message : String(e)}`,
369
+ });
370
+ }
371
+ });
372
+ }
373
+ }
374
+ }
316
375
  export function validateConfigObject(raw) {
317
376
  const parsed = configFileSchema.safeParse(raw);
318
377
  const issues = [];
@@ -368,62 +427,28 @@ export function validateConfigObject(raw) {
368
427
  defaultCount++;
369
428
  const match = childObject(entry, "match");
370
429
  if (match) {
371
- for (const field of ["cwd", "entity", "content"]) {
372
- const value = match[field];
373
- if (value === undefined)
374
- continue;
375
- if (!Array.isArray(value)) {
376
- issues.push({ severity: "error", path: `${base}.match.${field}`, message: "must be an array of regex strings" });
377
- continue;
378
- }
379
- value.forEach((pattern, pIdx) => {
380
- if (typeof pattern !== "string") {
381
- issues.push({ severity: "error", path: `${base}.match.${field}[${pIdx}]`, message: "must be a string" });
382
- return;
383
- }
384
- try {
385
- new RegExp(pattern);
386
- }
387
- catch (e) {
388
- issues.push({
389
- severity: "error",
390
- path: `${base}.match.${field}[${pIdx}]`,
391
- message: `invalid regex: ${e instanceof Error ? e.message : String(e)}`,
392
- });
393
- }
394
- });
395
- }
396
- const metadata = childObject(match, "metadata");
397
- if (metadata) {
398
- for (const [key, value] of Object.entries(metadata)) {
399
- if (!Array.isArray(value)) {
400
- issues.push({ severity: "error", path: `${base}.match.metadata.${key}`, message: "must be an array of regex strings" });
401
- continue;
402
- }
403
- value.forEach((pattern, pIdx) => {
404
- if (typeof pattern !== "string") {
405
- issues.push({ severity: "error", path: `${base}.match.metadata.${key}[${pIdx}]`, message: "must be a string" });
406
- return;
407
- }
408
- try {
409
- new RegExp(pattern);
410
- }
411
- catch (e) {
412
- issues.push({
413
- severity: "error",
414
- path: `${base}.match.metadata.${key}[${pIdx}]`,
415
- message: `invalid regex: ${e instanceof Error ? e.message : String(e)}`,
416
- });
417
- }
418
- });
419
- }
420
- }
430
+ validateMatchBlock(match, `${base}.match`, issues);
421
431
  }
422
432
  });
423
433
  if (defaultCount > 1) {
424
434
  issues.push({ severity: "error", path: "destinations", message: `at most one destination may set 'default: true' (found ${defaultCount})` });
425
435
  }
426
436
  }
437
+ if (Array.isArray(raw.ignore)) {
438
+ raw.ignore.forEach((entry, idx) => {
439
+ const base = `ignore[${idx}]`;
440
+ if (!isObject(entry)) {
441
+ issues.push({ severity: "error", path: base, message: "must be an object" });
442
+ return;
443
+ }
444
+ const match = entry.match;
445
+ if (!isObject(match)) {
446
+ issues.push({ severity: "error", path: `${base}.match`, message: "must be an object" });
447
+ return;
448
+ }
449
+ validateMatchBlock(match, `${base}.match`, issues);
450
+ });
451
+ }
427
452
  const destinationNames = new Set();
428
453
  if (Array.isArray(raw.destinations)) {
429
454
  for (const entry of raw.destinations) {
@@ -100,11 +100,12 @@ const autoDistill = async (_config, { minSummaries = 5 } = {}) => {
100
100
  return { distilled: false };
101
101
  const promptStamp = `${DISTILL_PROMPT_DESCRIPTOR.id}@${DISTILL_PROMPT_DESCRIPTOR.version}`;
102
102
  // Store distilled patterns
103
+ let storedPatterns = 0;
103
104
  for (const pattern of patterns) {
104
105
  if (!pattern.content)
105
106
  continue;
106
107
  const hash = createHash("sha256").update(`distilled:${pattern.content}`).digest("hex");
107
- await qdrant.storeFact({
108
+ const storedId = await qdrant.storeFact({
108
109
  content: pattern.content,
109
110
  category: normalizeCategory(pattern.category ?? "engineering"),
110
111
  domain: normalizeDomain(pattern.domain ?? "software_engineering"),
@@ -128,6 +129,12 @@ const autoDistill = async (_config, { minSummaries = 5 } = {}) => {
128
129
  },
129
130
  }),
130
131
  }, { destination });
132
+ if (storedId)
133
+ storedPatterns++;
134
+ }
135
+ if (storedPatterns === 0) {
136
+ logFn("INFO", `Auto-distill: all ${patterns.length} patterns were ignored by config rules`);
137
+ return { distilled: false };
131
138
  }
132
139
  // Supersede the source summaries
133
140
  for (const pt of batch) {
@@ -138,8 +145,8 @@ const autoDistill = async (_config, { minSummaries = 5 } = {}) => {
138
145
  metadata: { prompt: promptStamp },
139
146
  }));
140
147
  }
141
- logFn("INFO", `Auto-distill: consolidated ${batch.length} summaries into ${patterns.length} patterns`);
142
- return { distilled: true, count: patterns.length };
148
+ logFn("INFO", `Auto-distill: consolidated ${batch.length} summaries into ${storedPatterns} patterns`);
149
+ return { distilled: true, count: storedPatterns };
143
150
  }
144
151
  catch (e) {
145
152
  logFn("ERROR", `Auto-distill failed: ${e.message}`);
@@ -446,6 +446,10 @@ const storeFacts = async (facts, sessionId, config, source) => {
446
446
  };
447
447
  try {
448
448
  const dedup = await qdrant.dedupCheck(sanitizedFact.content, hash, undefined, undefined, routeInput);
449
+ if (dedup.action === "ignore") {
450
+ logFn("DEBUG", `Extraction: ignoring fact due to config rule '${dedup.ignoreRule ?? "unknown"}': "${sanitizedFact.content.slice(0, 80)}…"`);
451
+ continue;
452
+ }
449
453
  if (dedup.action === "skip") {
450
454
  // Reinforce existing fact
451
455
  if (dedup.existingId) {
@@ -610,6 +614,8 @@ const storeFacts = async (facts, sessionId, config, source) => {
610
614
  };
611
615
  if (dedup.action === "supersede" && dedup.existingId) {
612
616
  const newId = await qdrant.storeFact(storePayload, routeInput);
617
+ if (!newId)
618
+ continue;
613
619
  await qdrant.supersedeFact(dedup.existingId, newId, dedup.destination, buildOperationOrigin({
614
620
  interface: "daemon",
615
621
  action: "supersede",
@@ -624,8 +630,9 @@ const storeFacts = async (facts, sessionId, config, source) => {
624
630
  stored++;
625
631
  }
626
632
  else {
627
- await qdrant.storeFact(storePayload, routeInput);
628
- stored++;
633
+ const newId = await qdrant.storeFact(storePayload, routeInput);
634
+ if (newId)
635
+ stored++;
629
636
  }
630
637
  }
631
638
  catch (e) {
@@ -162,10 +162,11 @@ export interface QdrantScrollFilters {
162
162
  direction: "asc" | "desc";
163
163
  };
164
164
  }
165
- export type DedupAction = "insert" | "skip" | "supersede";
165
+ export type DedupAction = "insert" | "skip" | "supersede" | "ignore";
166
166
  export interface DedupResult {
167
167
  action: DedupAction;
168
168
  destination?: string;
169
+ ignoreRule?: string;
169
170
  existingId?: string;
170
171
  existingCount?: number;
171
172
  score?: number;
@@ -190,7 +191,7 @@ declare const activeDestinations: () => Destination[];
190
191
  declare const searchFacts: (query: string, filters?: QdrantSearchFilters, limit?: number, destinationRef?: DestinationRef) => Promise<QdrantSearchResult[]>;
191
192
  declare const scrollFacts: (filters?: QdrantScrollFilters, limit?: number, destinationRef?: DestinationRef) => Promise<QdrantScrollResult[]>;
192
193
  declare const scrollFactsAcrossDestinations: (filters?: QdrantScrollFilters, limit?: number) => Promise<QdrantScrollResult[]>;
193
- declare const storeFact: (fact: StoreFact, routeInput?: RoutingInput) => Promise<string>;
194
+ declare const storeFact: (fact: StoreFact, routeInput?: RoutingInput) => Promise<string | null>;
194
195
  declare const supersedeFact: (oldFactId: string, newFactId: string, destinationRef?: DestinationRef, origin?: OperationOrigin) => Promise<void>;
195
196
  declare const reinforceFact: (factId: string, currentCount: number, destinationRef?: DestinationRef, origin?: OperationOrigin) => Promise<void>;
196
197
  declare const dedupCheck: (content: string, contentHashVal: string, { exactThreshold, supersedeThreshold }?: DedupThresholds, workspaceId?: string, routeInput?: RoutingInput) => Promise<DedupResult>;
@@ -11,7 +11,7 @@ import { createHash, randomUUID } from "node:crypto";
11
11
  import { getEffectiveDestinations, loadConfig } from "../config.js";
12
12
  import { embed, initEmbedding, getEmbeddingConfig } from "../llm/index.js";
13
13
  import { QdrantPool } from "../lib/qdrant-pool.js";
14
- import { buildResolver } from "../routing.js";
14
+ import { buildResolver, findMatchingIgnoreRule } from "../routing.js";
15
15
  import { DEFAULT_DOMAIN, QDRANT_INDEXES, categoryForMemorySubtype, layerForMemorySubtype, normalizeCategory, normalizeDomain, normalizeKind, validateMemorySubtype, } from "../mcp/taxonomy.js";
16
16
  import { combineRedactions, redactStorageText, } from "../privacy/redaction.js";
17
17
  import { buildOperationOrigin } from "../provenance/origin.js";
@@ -395,12 +395,18 @@ const storeFact = async (fact, routeInput) => {
395
395
  if (redaction.redacted) {
396
396
  payload.redaction = redaction;
397
397
  }
398
- const destination = resolveDestination(mergeRoutingInputs(routingInputForFact(fact, redactedContent.text, payload.entities, {
398
+ const writeInput = mergeRoutingInputs(routingInputForFact(fact, redactedContent.text, payload.entities, {
399
399
  category: normalizedCategory,
400
400
  domain: normalizedDomain,
401
401
  kind: normalizedKind,
402
402
  ...(normalizedSubtype ? { memory_subtype: normalizedSubtype } : {}),
403
- }), routeInput));
403
+ }), routeInput);
404
+ const ignored = findMatchingIgnoreRule(writeInput, loadConfig().ignore);
405
+ if (ignored) {
406
+ logFn("INFO", `Qdrant: ignored fact write due to ignore rule '${ignored.name}' [${normalizedCategory}] ${redactedContent.text.slice(0, 60)}`);
407
+ return null;
408
+ }
409
+ const destination = resolveDestination(writeInput);
404
410
  const vector = await embed(redactedContent.text);
405
411
  await qdrantRequest("PUT", `/collections/${destination.collection}/points`, {
406
412
  points: [{ id, vector, payload }],
@@ -446,7 +452,13 @@ const reinforceFact = async (factId, currentCount, destinationRef, origin) => {
446
452
  logFn("DEBUG", `Qdrant: reinforced fact ${factId} in '${destination.name}'`);
447
453
  };
448
454
  const dedupCheck = async (content, contentHashVal, { exactThreshold = 0.92, supersedeThreshold = 0.80 } = {}, workspaceId, routeInput) => {
449
- const destination = resolveDestination(routeInput ?? { content });
455
+ const input = mergeRoutingInputs({ content }, routeInput);
456
+ const ignored = findMatchingIgnoreRule(input, loadConfig().ignore);
457
+ if (ignored) {
458
+ logFn("INFO", `Qdrant: ignored dedup/write candidate due to ignore rule '${ignored.name}'`);
459
+ return { action: "ignore", ignoreRule: ignored.name };
460
+ }
461
+ const destination = resolveDestination(input);
450
462
  // First: hash-based exact check (fast, no embedding)
451
463
  try {
452
464
  const must = [
@@ -69,7 +69,7 @@ declare const storeRelation: (fromEntity: string, toEntity: string, relationType
69
69
  durability?: string;
70
70
  directionality_clarity?: string;
71
71
  };
72
- }) => Promise<string>;
72
+ }) => Promise<string | null>;
73
73
  declare const tick: (config: BikkyConfig) => Promise<void>;
74
74
  /** Reset state (for testing). */
75
75
  declare const _reset: () => void;
@@ -280,6 +280,10 @@ const storeRelation = async (fromEntity, toEntity, relationType, content, candid
280
280
  to: toEntity,
281
281
  },
282
282
  }, destination ? { destination } : undefined);
283
+ if (!id) {
284
+ logFn("INFO", `Relations: ignored ${fromEntity} —[${relationType}]→ ${toEntity} due to ignore rules`);
285
+ return null;
286
+ }
283
287
  logFn("INFO", `Relations: inferred ${fromEntity} —[${relationType}]→ ${toEntity} (id: ${id})`);
284
288
  return id;
285
289
  };
@@ -358,12 +362,17 @@ const tick = async (config) => {
358
362
  .update(`daemon-relation:${pairKey(result.from, result.to)}:${result.type}`)
359
363
  .digest("hex");
360
364
  const dedup = await qdrant.dedupCheck(result.content, hash, undefined, undefined, { destination });
365
+ if (dedup.action === "ignore") {
366
+ logFn("DEBUG", `Relations: ignoring ${candidate.entityA}↔${candidate.entityB} due to config rule '${dedup.ignoreRule ?? "unknown"}'`);
367
+ continue;
368
+ }
361
369
  if (dedup.action === "skip") {
362
370
  logFn("DEBUG", `Relations: skipping duplicate ${candidate.entityA}↔${candidate.entityB}`);
363
371
  continue;
364
372
  }
365
- await storeRelation(result.from, result.to, result.type, result.content, candidate, destination, { evidence: result.evidence, confidence: result.confidence, inVocabulary: result.inVocabulary, judgment: result.judgment });
366
- inferred++;
373
+ const storedId = await storeRelation(result.from, result.to, result.type, result.content, candidate, destination, { evidence: result.evidence, confidence: result.confidence, inVocabulary: result.inVocabulary, judgment: result.judgment });
374
+ if (storedId)
375
+ inferred++;
367
376
  }
368
377
  catch (e) {
369
378
  failures++;
package/dist/mcp/tools.js CHANGED
@@ -6,7 +6,7 @@ import { z } from "zod";
6
6
  import { STALENESS_DAYS, THRESHOLD_DUPLICATE, THRESHOLD_RELATED, QDRANT_INDEXES, categoryValues, categoryEnumDescription, domainValues, domainEnumDescription, kindValues, kindEnumDescription, memorySubtypeValues, memorySubtypeEnumDescription, DEFAULT_CATEGORY, DEFAULT_DOMAIN, DEFAULT_KIND, 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
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
+ import { DestinationNotFoundError, findMatchingIgnoreRule } from "../routing.js";
10
10
  import { saveConfig, loadConfig, EXTRACTION_HEALTH_PATH } from "../config.js";
11
11
  import { availableSearchScopes, resolveSearchScope, SearchScopeNotFoundError, } from "../search-scope.js";
12
12
  import { existsSync, readFileSync } from "node:fs";
@@ -114,6 +114,53 @@ function resolveDestOrError(input) {
114
114
  };
115
115
  }
116
116
  }
117
+ function ignoredWriteResult(tool, match) {
118
+ return {
119
+ content: [{ type: "text", text: JSON.stringify({
120
+ action: "ignored",
121
+ status: "ignored",
122
+ ignored: true,
123
+ tool,
124
+ rule: match.name,
125
+ rule_index: match.index,
126
+ description: match.rule.description ?? null,
127
+ message: `Memory write ignored by config rule '${match.name}'.`,
128
+ }) }],
129
+ };
130
+ }
131
+ function ignoreWriteOrError(input, tool) {
132
+ try {
133
+ const match = findMatchingIgnoreRule(input, loadConfig().ignore);
134
+ return match ? { match } : {};
135
+ }
136
+ catch (e) {
137
+ const msg = e instanceof Error ? e.message : String(e);
138
+ return {
139
+ error: {
140
+ content: [{ type: "text", text: JSON.stringify({
141
+ status: "error",
142
+ message: `Failed to evaluate ignore rules for ${tool}: ${msg}`,
143
+ }, null, 2) }],
144
+ isError: true,
145
+ },
146
+ };
147
+ }
148
+ }
149
+ function telemetryIgnored(input, subtype) {
150
+ try {
151
+ const match = findMatchingIgnoreRule(input, loadConfig().ignore);
152
+ if (!match)
153
+ return false;
154
+ log("INFO", `Skipped ${subtype} write due to ignore rule '${match.name}'`);
155
+ return true;
156
+ }
157
+ catch (e) {
158
+ log("WARN", `Failed to evaluate ignore rules for ${subtype}: ${e instanceof Error ? e.message : String(e)}`);
159
+ // Telemetry can include recall queries or feedback notes, so fail closed
160
+ // rather than risk persisting content that an ignore rule was meant to block.
161
+ return true;
162
+ }
163
+ }
117
164
  function withDestination(point, destination) {
118
165
  return { ...point, _destination: destination };
119
166
  }
@@ -167,6 +214,22 @@ async function recordRecallTelemetry(args) {
167
214
  const entities = [...new Set(points.flatMap((point) => point.payload.entities ?? []))].slice(0, 25);
168
215
  const eventContent = `Recall returned ${points.length} fact(s) from ${dest.name}: ${args.redactedQuery.text}`;
169
216
  const redactedEvent = redactStorageText(eventContent);
217
+ const eventRoutingInput = buildMemoryRoutingInput({
218
+ cwd: process.cwd(),
219
+ content: redactedEvent.text,
220
+ entities,
221
+ context: {
222
+ category: categoryForMemorySubtype("recall_event") ?? "system",
223
+ domain: "software_engineering",
224
+ kind: "telemetry",
225
+ memory_subtype: "recall_event",
226
+ layer: "memory_object",
227
+ search_scope: args.searchScope.name,
228
+ destination: dest.name,
229
+ },
230
+ });
231
+ if (telemetryIgnored(eventRoutingInput, "recall_event"))
232
+ continue;
170
233
  const eventPayload = {
171
234
  content: redactedEvent.text,
172
235
  category: categoryForMemorySubtype("recall_event") ?? "system",
@@ -698,7 +761,7 @@ export function registerTools(mcp) {
698
761
  type: redactedRelation.type.text,
699
762
  to: redactedRelation.to.text,
700
763
  } : null;
701
- const resolved = resolveDestOrError(memoryWriteRoutingInput({
764
+ const writeInput = memoryWriteRoutingInput({
702
765
  tool: "memory_store",
703
766
  destination,
704
767
  content: redactedContent.text,
@@ -719,7 +782,13 @@ export function registerTools(mcp) {
719
782
  review_status,
720
783
  supersedes,
721
784
  },
722
- }));
785
+ });
786
+ const ignored = ignoreWriteOrError(writeInput, "memory_store");
787
+ if (ignored.error)
788
+ return ignored.error;
789
+ if (ignored.match)
790
+ return ignoredWriteResult("memory_store", ignored.match);
791
+ const resolved = resolveDestOrError(writeInput);
723
792
  if (resolved.error)
724
793
  return resolved.error;
725
794
  const dest = resolved.dest;
@@ -1514,35 +1583,52 @@ export function registerTools(mcp) {
1514
1583
  });
1515
1584
  // Write a telemetry feedback_event row so the signal is also visible
1516
1585
  // to aggregations and review tooling.
1517
- const eventId = newId();
1518
1586
  const eventContent = note
1519
1587
  ? `Fact ${fact_id} marked useful: ${note}`
1520
1588
  : `Fact ${fact_id} marked useful.`;
1521
1589
  const redactedEvent = redactStorageText(eventContent);
1522
- const eventPayload = {
1590
+ let eventId = null;
1591
+ const eventInput = buildMemoryRoutingInput({
1592
+ cwd: process.cwd(),
1523
1593
  content: redactedEvent.text,
1524
- category: categoryForMemorySubtype("feedback_event") ?? "system",
1525
- domain: "software_engineering",
1526
- kind: "telemetry",
1527
- memory_subtype: "feedback_event",
1528
- layer: "memory_object",
1529
1594
  entities: [],
1530
- origin: feedbackOrigin,
1531
- confidence: 1.0,
1532
- importance: 0.3,
1533
- content_hash: contentHash("feedback_event", `${fact_id}:useful:${now}`),
1534
- target_fact_id: fact_id,
1535
- feedback_kind: "useful",
1536
- created_at: now,
1537
- updated_at: now,
1538
- };
1539
- addRedactionPayload(eventPayload, redactedEvent);
1540
- try {
1541
- const eventVector = await embed(redactedEvent.text);
1542
- await qdrantUpsert(dest, eventId, eventVector, eventPayload);
1543
- }
1544
- catch (e) {
1545
- log("WARN", `Failed to record feedback_event: ${e instanceof Error ? e.message : String(e)}`);
1595
+ context: {
1596
+ category: categoryForMemorySubtype("feedback_event") ?? "system",
1597
+ domain: "software_engineering",
1598
+ kind: "telemetry",
1599
+ memory_subtype: "feedback_event",
1600
+ layer: "memory_object",
1601
+ target_fact_id: fact_id,
1602
+ feedback_kind: "useful",
1603
+ },
1604
+ });
1605
+ if (!telemetryIgnored(eventInput, "feedback_event")) {
1606
+ eventId = newId();
1607
+ const eventPayload = {
1608
+ content: redactedEvent.text,
1609
+ category: categoryForMemorySubtype("feedback_event") ?? "system",
1610
+ domain: "software_engineering",
1611
+ kind: "telemetry",
1612
+ memory_subtype: "feedback_event",
1613
+ layer: "memory_object",
1614
+ entities: [],
1615
+ origin: feedbackOrigin,
1616
+ confidence: 1.0,
1617
+ importance: 0.3,
1618
+ content_hash: contentHash("feedback_event", `${fact_id}:useful:${now}`),
1619
+ target_fact_id: fact_id,
1620
+ feedback_kind: "useful",
1621
+ created_at: now,
1622
+ updated_at: now,
1623
+ };
1624
+ addRedactionPayload(eventPayload, redactedEvent);
1625
+ try {
1626
+ const eventVector = await embed(redactedEvent.text);
1627
+ await qdrantUpsert(dest, eventId, eventVector, eventPayload);
1628
+ }
1629
+ catch (e) {
1630
+ log("WARN", `Failed to record feedback_event: ${e instanceof Error ? e.message : String(e)}`);
1631
+ }
1546
1632
  }
1547
1633
  return {
1548
1634
  content: [{ type: "text", text: JSON.stringify({
@@ -1550,7 +1636,7 @@ export function registerTools(mcp) {
1550
1636
  fact_id,
1551
1637
  destination: dest.name,
1552
1638
  useful_count: newCount,
1553
- event_id: eventId,
1639
+ ...(eventId ? { event_id: eventId } : { event_ignored: true }),
1554
1640
  }) }],
1555
1641
  };
1556
1642
  }
@@ -1591,31 +1677,48 @@ export function registerTools(mcp) {
1591
1677
  updated_at: now,
1592
1678
  last_operation_origin: feedbackOrigin,
1593
1679
  });
1594
- const eventId = newId();
1595
1680
  const eventContent = notes
1596
1681
  ? `Fact ${fact_id} outcome=${outcome}: ${notes}`
1597
1682
  : `Fact ${fact_id} outcome=${outcome}.`;
1598
1683
  const redactedEvent = redactStorageText(eventContent);
1599
- const eventPayload = {
1684
+ let eventId = null;
1685
+ const eventInput = buildMemoryRoutingInput({
1686
+ cwd: process.cwd(),
1600
1687
  content: redactedEvent.text,
1601
- category: categoryForMemorySubtype("outcome_event") ?? "system",
1602
- domain: "software_engineering",
1603
- kind: "telemetry",
1604
- memory_subtype: "outcome_event",
1605
- layer: "memory_object",
1606
1688
  entities: [],
1607
- origin: feedbackOrigin,
1608
- confidence: 1.0,
1609
- importance: outcome === "wrong" || outcome === "misleading" ? 0.6 : 0.3,
1610
- content_hash: contentHash("outcome_event", `${fact_id}:${outcome}:${now}`),
1611
- target_fact_id: fact_id,
1612
- outcome,
1613
- created_at: now,
1614
- updated_at: now,
1615
- };
1616
- addRedactionPayload(eventPayload, redactedEvent);
1617
- const eventVector = await embed(redactedEvent.text);
1618
- await qdrantUpsert(dest, eventId, eventVector, eventPayload);
1689
+ context: {
1690
+ category: categoryForMemorySubtype("outcome_event") ?? "system",
1691
+ domain: "software_engineering",
1692
+ kind: "telemetry",
1693
+ memory_subtype: "outcome_event",
1694
+ layer: "memory_object",
1695
+ target_fact_id: fact_id,
1696
+ outcome,
1697
+ },
1698
+ });
1699
+ if (!telemetryIgnored(eventInput, "outcome_event")) {
1700
+ eventId = newId();
1701
+ const eventPayload = {
1702
+ content: redactedEvent.text,
1703
+ category: categoryForMemorySubtype("outcome_event") ?? "system",
1704
+ domain: "software_engineering",
1705
+ kind: "telemetry",
1706
+ memory_subtype: "outcome_event",
1707
+ layer: "memory_object",
1708
+ entities: [],
1709
+ origin: feedbackOrigin,
1710
+ confidence: 1.0,
1711
+ importance: outcome === "wrong" || outcome === "misleading" ? 0.6 : 0.3,
1712
+ content_hash: contentHash("outcome_event", `${fact_id}:${outcome}:${now}`),
1713
+ target_fact_id: fact_id,
1714
+ outcome,
1715
+ created_at: now,
1716
+ updated_at: now,
1717
+ };
1718
+ addRedactionPayload(eventPayload, redactedEvent);
1719
+ const eventVector = await embed(redactedEvent.text);
1720
+ await qdrantUpsert(dest, eventId, eventVector, eventPayload);
1721
+ }
1619
1722
  return {
1620
1723
  content: [{ type: "text", text: JSON.stringify({
1621
1724
  status: "outcome_recorded",
@@ -1623,7 +1726,7 @@ export function registerTools(mcp) {
1623
1726
  destination: dest.name,
1624
1727
  outcome,
1625
1728
  [counterField]: counterValue,
1626
- event_id: eventId,
1729
+ ...(eventId ? { event_id: eventId } : { event_ignored: true }),
1627
1730
  }) }],
1628
1731
  };
1629
1732
  }
@@ -1654,7 +1757,7 @@ export function registerTools(mcp) {
1654
1757
  try {
1655
1758
  const normalizedEntities = (entities ?? []).map((e) => e.trim().toLowerCase()).filter(Boolean);
1656
1759
  const redactedContent = redactStorageText(content);
1657
- const resolved = resolveDestOrError(memoryWriteRoutingInput({
1760
+ const writeInput = memoryWriteRoutingInput({
1658
1761
  tool: "memory_session_summary",
1659
1762
  destination,
1660
1763
  content: redactedContent.text,
@@ -1670,7 +1773,13 @@ export function registerTools(mcp) {
1670
1773
  task_key,
1671
1774
  repo,
1672
1775
  },
1673
- }));
1776
+ });
1777
+ const ignored = ignoreWriteOrError(writeInput, "memory_session_summary");
1778
+ if (ignored.error)
1779
+ return ignored.error;
1780
+ if (ignored.match)
1781
+ return ignoredWriteResult("memory_session_summary", ignored.match);
1782
+ const resolved = resolveDestOrError(writeInput);
1674
1783
  if (resolved.error)
1675
1784
  return resolved.error;
1676
1785
  const dest = resolved.dest;
@@ -1751,7 +1860,7 @@ export function registerTools(mcp) {
1751
1860
  try {
1752
1861
  const normalizedEntities = entities.map((e) => e.trim().toLowerCase()).filter(Boolean);
1753
1862
  const redactedContent = redactStorageText(content);
1754
- const resolved = resolveDestOrError(memoryWriteRoutingInput({
1863
+ const writeInput = memoryWriteRoutingInput({
1755
1864
  tool: "memory_distill",
1756
1865
  destination,
1757
1866
  content: redactedContent.text,
@@ -1766,7 +1875,13 @@ export function registerTools(mcp) {
1766
1875
  repo,
1767
1876
  supersedes,
1768
1877
  },
1769
- }));
1878
+ });
1879
+ const ignored = ignoreWriteOrError(writeInput, "memory_distill");
1880
+ if (ignored.error)
1881
+ return ignored.error;
1882
+ if (ignored.match)
1883
+ return ignoredWriteResult("memory_distill", ignored.match);
1884
+ const resolved = resolveDestOrError(writeInput);
1770
1885
  if (resolved.error)
1771
1886
  return resolved.error;
1772
1887
  const dest = resolved.dest;
@@ -1949,9 +2064,37 @@ export function registerTools(mcp) {
1949
2064
  const { dest, point } = located;
1950
2065
  const origPayload = point.payload;
1951
2066
  const redactedCorrected = redactStorageText(corrected_content);
2067
+ const origCategory = normalizeCategory(origPayload?.category ?? DEFAULT_CATEGORY);
2068
+ const origDomain = normalizeDomain(origPayload?.domain ?? DEFAULT_DOMAIN);
2069
+ const origKind = normalizeKind(origPayload?.kind ?? "fact");
2070
+ const correctedEntities = origPayload?.entities ?? [];
2071
+ const writeInput = memoryWriteRoutingInput({
2072
+ tool: "memory_review",
2073
+ destination: dest.name,
2074
+ content: redactedCorrected.text,
2075
+ entities: correctedEntities,
2076
+ metadata: origPayload?.metadata,
2077
+ context: {
2078
+ category: origCategory,
2079
+ domain: origDomain,
2080
+ kind: origKind,
2081
+ memory_subtype: origPayload?.memory_subtype,
2082
+ layer: origPayload?.layer,
2083
+ episode_id: origPayload?.episode_id,
2084
+ workstream_key: origPayload?.workstream_key,
2085
+ task_key: origPayload?.task_key,
2086
+ repo: origPayload?.repo,
2087
+ branch: origPayload?.branch,
2088
+ corrected_from: fact_id,
2089
+ },
2090
+ });
2091
+ const ignored = ignoreWriteOrError(writeInput, "memory_review");
2092
+ if (ignored.error)
2093
+ return ignored.error;
2094
+ if (ignored.match)
2095
+ return ignoredWriteResult("memory_review", ignored.match);
1952
2096
  const vector = await embed(redactedCorrected.text);
1953
2097
  const correctedId = crypto.randomUUID();
1954
- const origCategory = normalizeCategory(origPayload?.category ?? DEFAULT_CATEGORY);
1955
2098
  const hash = contentHash(origCategory, redactedCorrected.text);
1956
2099
  const correctOrigin = mcpOrigin({
1957
2100
  action: "correct",
@@ -1961,8 +2104,8 @@ export function registerTools(mcp) {
1961
2104
  const correctedPayload = {
1962
2105
  content: redactedCorrected.text,
1963
2106
  category: origCategory,
1964
- domain: normalizeDomain(origPayload?.domain ?? DEFAULT_DOMAIN),
1965
- kind: normalizeKind(origPayload?.kind ?? "fact"),
2107
+ domain: origDomain,
2108
+ kind: origKind,
1966
2109
  ...(origPayload?.memory_subtype ? { memory_subtype: origPayload.memory_subtype } : {}),
1967
2110
  ...(origPayload?.layer ? { layer: origPayload.layer } : {}),
1968
2111
  ...(origPayload?.episode_id ? { episode_id: origPayload.episode_id } : {}),
@@ -1970,7 +2113,7 @@ export function registerTools(mcp) {
1970
2113
  ...(origPayload?.task_key ? { task_key: origPayload.task_key } : {}),
1971
2114
  ...(origPayload?.repo ? { repo: origPayload.repo } : {}),
1972
2115
  ...(origPayload?.branch ? { branch: origPayload.branch } : {}),
1973
- entities: origPayload?.entities ?? [],
2116
+ entities: correctedEntities,
1974
2117
  origin: correctOrigin,
1975
2118
  confidence: 0.95,
1976
2119
  importance: origPayload?.importance ?? 0.5,
package/dist/routing.d.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Pure: no I/O, no globals. Easy to unit test.
17
17
  */
18
- import type { Destination } from "./config.js";
18
+ import type { Destination, DestinationMatch, IgnoreRule } from "./config.js";
19
19
  export interface RoutingInput {
20
20
  /** Explicit destination name override. Throws if it doesn't match a destination. */
21
21
  destination?: string | null;
@@ -36,6 +36,13 @@ export declare class DestinationNotFoundError extends Error {
36
36
  export declare class NoDestinationsConfiguredError extends Error {
37
37
  constructor();
38
38
  }
39
+ export interface IgnoreMatch {
40
+ rule: IgnoreRule;
41
+ index: number;
42
+ name: string;
43
+ }
44
+ export declare function routingInputMatches(match: DestinationMatch | undefined, input: RoutingInput): boolean;
45
+ export declare function findMatchingIgnoreRule(input: RoutingInput, ignoreRules: ReadonlyArray<IgnoreRule>): IgnoreMatch | null;
39
46
  /**
40
47
  * Resolve a destination from caller input + a (pre-loaded) list of destinations.
41
48
  *
package/dist/routing.js CHANGED
@@ -69,6 +69,21 @@ function destinationMatches(compiled, input) {
69
69
  }
70
70
  return false;
71
71
  }
72
+ export function routingInputMatches(match, input) {
73
+ return destinationMatches(compileMatch(match), input);
74
+ }
75
+ export function findMatchingIgnoreRule(input, ignoreRules) {
76
+ for (const [index, rule] of ignoreRules.entries()) {
77
+ if (routingInputMatches(rule.match, input)) {
78
+ return {
79
+ rule,
80
+ index,
81
+ name: rule.name?.trim() || `ignore[${index}]`,
82
+ };
83
+ }
84
+ }
85
+ return null;
86
+ }
72
87
  function pickFallback(destinations) {
73
88
  const explicit = destinations.find((d) => d.default === true);
74
89
  if (explicit)
@@ -362,6 +362,39 @@ Matching details:
362
362
  - Read/search tools also accept `search_scope`; call `memory_search_scopes` or `get_setup_status` to see available scopes and descriptions.
363
363
  - All destinations share one embedding provider, so every destination collection must use the same vector dimensions.
364
364
 
365
+ ### Ignore rules
366
+
367
+ Use top-level `ignore` rules when certain memories should not be stored at all. Ignore rules use the same `match` block shape as destination routing (`cwd`, `entity`, `content`, and `metadata` regex arrays), and memory-write `content` matching sees the same flattened memory context used by destination routing.
368
+
369
+ Ignore rules run before destination resolution, embedding, deduplication, superseding, relation sidecars, telemetry upserts, and Qdrant writes. They also take precedence over explicit `destination` overrides, so an ignored topic cannot be stored by selecting a destination manually.
370
+
371
+ ```jsonc
372
+ {
373
+ "ignore": [
374
+ {
375
+ "name": "personal-topics",
376
+ "description": "Do not persist personal-topic memories.",
377
+ "match": {
378
+ "entity": ["^[Rr]esume$"],
379
+ "content": ["\\b[Rr]esume\\b"],
380
+ "metadata": { "repo": ["^private/"] }
381
+ }
382
+ }
383
+ ]
384
+ }
385
+ ```
386
+
387
+ When an MCP write tool is ignored, it returns a structured non-error response such as:
388
+
389
+ ```json
390
+ {
391
+ "action": "ignored",
392
+ "status": "ignored",
393
+ "ignored": true,
394
+ "rule": "personal-topics"
395
+ }
396
+ ```
397
+
365
398
  Migrating from `workspace_id` pre-v0.4:
366
399
 
367
400
  - Existing top-level `qdrant_url`, `qdrant_api_key`, and `collection` configs still work as a single synthesized destination.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bikky",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Shared memory for AI coding sessions — MCP server + background daemon",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",