bikky 0.4.2 → 0.4.4

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 (62) hide show
  1. package/README.md +64 -37
  2. package/dist/config.d.ts +15 -1
  3. package/dist/config.js +116 -20
  4. package/dist/daemon/capture-policy.d.ts +0 -1
  5. package/dist/daemon/capture-policy.js +0 -2
  6. package/dist/daemon/consolidation.d.ts +2 -1
  7. package/dist/daemon/consolidation.js +32 -15
  8. package/dist/daemon/entity-typing.js +10 -0
  9. package/dist/daemon/episode-summary.d.ts +4 -0
  10. package/dist/daemon/episode-summary.js +39 -8
  11. package/dist/daemon/extraction.d.ts +2 -2
  12. package/dist/daemon/extraction.js +65 -22
  13. package/dist/daemon/loop.js +8 -0
  14. package/dist/daemon/maintenance-state.d.ts +1 -1
  15. package/dist/daemon/maintenance-state.js +2 -0
  16. package/dist/daemon/qdrant.d.ts +32 -10
  17. package/dist/daemon/qdrant.js +199 -60
  18. package/dist/daemon/quality-rollups.d.ts +51 -0
  19. package/dist/daemon/quality-rollups.js +378 -0
  20. package/dist/daemon/relations.d.ts +3 -3
  21. package/dist/daemon/relations.js +28 -16
  22. package/dist/daemon/session-index.d.ts +5 -0
  23. package/dist/daemon/session-index.js +36 -9
  24. package/dist/daemon/session-summary.d.ts +3 -0
  25. package/dist/daemon/session-summary.js +48 -15
  26. package/dist/daemon/staleness.js +3 -3
  27. package/dist/daemon/transcript-sources.js +3 -2
  28. package/dist/daemon/watcher.js +2 -0
  29. package/dist/daemon/workstream-summary.d.ts +4 -0
  30. package/dist/daemon/workstream-summary.js +58 -16
  31. package/dist/install.d.ts +11 -0
  32. package/dist/install.js +38 -0
  33. package/dist/lifecycle.js +7 -5
  34. package/dist/llm/embedding/index.js +2 -1
  35. package/dist/llm/embedding/providers/openai.js +8 -2
  36. package/dist/llm/embedding/providers/portkey.js +9 -2
  37. package/dist/llm/inference/index.js +2 -1
  38. package/dist/llm/util.d.ts +12 -0
  39. package/dist/llm/util.js +18 -0
  40. package/dist/mcp/helpers.d.ts +8 -0
  41. package/dist/mcp/helpers.js +36 -3
  42. package/dist/mcp/taxonomy.d.ts +9 -13
  43. package/dist/mcp/taxonomy.js +59 -42
  44. package/dist/mcp/tools.js +351 -83
  45. package/dist/mcp/types.d.ts +35 -0
  46. package/dist/package-verifier.d.ts +19 -0
  47. package/dist/package-verifier.js +83 -0
  48. package/dist/prompts/brief.d.ts +2 -2
  49. package/dist/prompts/brief.js +0 -1
  50. package/dist/prompts/extraction.js +9 -11
  51. package/dist/provenance/origin.d.ts +57 -0
  52. package/dist/provenance/origin.js +254 -0
  53. package/dist/routing-context.d.ts +16 -0
  54. package/dist/routing-context.js +55 -0
  55. package/dist/status.d.ts +1 -0
  56. package/dist/status.js +7 -1
  57. package/docs/config/fully-hosted.md +33 -13
  58. package/docs/config/hosted-models.md +33 -13
  59. package/docs/config/hosted-qdrant-local-models.md +1 -0
  60. package/docs/config/local.md +1 -0
  61. package/docs/configuration.md +42 -17
  62. package/package.json +2 -2
@@ -13,6 +13,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
13
13
  import { join, dirname } from "node:path";
14
14
  import { createHash } from "node:crypto";
15
15
  import * as qdrant from "./qdrant.js";
16
+ import { buildOperationOrigin } from "../provenance/origin.js";
16
17
  import { chatCompletion } from "../llm/index.js";
17
18
  import { categoryValues, normalizeCategory, normalizeDomain } from "../mcp/taxonomy.js";
18
19
  import { distillPrompt, DISTILL_PROMPT_DESCRIPTOR, contradictionPrompt, briefPrompt, BRIEF_PROMPT_DESCRIPTOR, safeParseJson, } from "../prompts/index.js";
@@ -33,6 +34,8 @@ const autoDistill = async (_config, { minSummaries = 5 } = {}) => {
33
34
  if (!qdrant.isReady())
34
35
  return { distilled: false };
35
36
  try {
37
+ const destination = qdrant.resolveDestination({}).name;
38
+ const collection = qdrant.collectionForDestination(destination);
36
39
  // Find undistilled session summaries (support both legacy and new taxonomy)
37
40
  const legacyFilter = {
38
41
  must: [
@@ -47,12 +50,12 @@ const autoDistill = async (_config, { minSummaries = 5 } = {}) => {
47
50
  ],
48
51
  };
49
52
  const [legacyRes, newRes] = await Promise.all([
50
- qdrant.qdrantRequest("POST", `/collections/${qdrant.collection}/points/scroll`, {
53
+ qdrant.qdrantRequest("POST", `/collections/${collection}/points/scroll`, {
51
54
  filter: legacyFilter, limit: 50, with_payload: true,
52
- }),
53
- qdrant.qdrantRequest("POST", `/collections/${qdrant.collection}/points/scroll`, {
55
+ }, destination),
56
+ qdrant.qdrantRequest("POST", `/collections/${collection}/points/scroll`, {
54
57
  filter: newFilter, limit: 50, with_payload: true,
55
- }),
58
+ }, destination),
56
59
  ]);
57
60
  // Deduplicate by ID
58
61
  const seen = new Set();
@@ -107,7 +110,6 @@ const autoDistill = async (_config, { minSummaries = 5 } = {}) => {
107
110
  domain: normalizeDomain(pattern.domain ?? "software_engineering"),
108
111
  kind: "distilled",
109
112
  entities: Array.isArray(pattern.entities) ? pattern.entities.map(e => String(e).toLowerCase()) : [],
110
- source: "system",
111
113
  confidence: 0.85,
112
114
  importance: pattern.importance || 0.7,
113
115
  content_hash: hash,
@@ -116,11 +118,25 @@ const autoDistill = async (_config, { minSummaries = 5 } = {}) => {
116
118
  distilled_at: new Date().toISOString(),
117
119
  distilled_by_prompt: promptStamp,
118
120
  },
119
- });
121
+ origin: buildOperationOrigin({
122
+ interface: "daemon",
123
+ action: "create",
124
+ subsystem: "consolidation",
125
+ metadata: {
126
+ distilled_from_count: batch.length,
127
+ prompt: promptStamp,
128
+ },
129
+ }),
130
+ }, { destination });
120
131
  }
121
132
  // Supersede the source summaries
122
133
  for (const pt of batch) {
123
- await qdrant.supersedeFact(pt.id, `distilled:${new Date().toISOString()}`);
134
+ await qdrant.supersedeFact(pt.id, `distilled:${new Date().toISOString()}`, destination, buildOperationOrigin({
135
+ interface: "daemon",
136
+ action: "supersede",
137
+ subsystem: "consolidation",
138
+ metadata: { prompt: promptStamp },
139
+ }));
124
140
  }
125
141
  logFn("INFO", `Auto-distill: consolidated ${batch.length} summaries into ${patterns.length} patterns`);
126
142
  return { distilled: true, count: patterns.length };
@@ -143,12 +159,13 @@ const detectContradiction = async (fact, _config, telemetry) => {
143
159
  try {
144
160
  const vector = await qdrant.embed(fact.content);
145
161
  // Search across all categories because contradictions can cross category lines.
146
- const results = await qdrant.qdrantRequest("POST", `/collections/${qdrant.collection}/points/search`, {
162
+ const collection = qdrant.collectionForDestination(telemetry?.destination);
163
+ const results = await qdrant.qdrantRequest("POST", `/collections/${collection}/points/search`, {
147
164
  vector,
148
165
  filter: { must: [{ is_null: { key: "superseded_by" } }] },
149
166
  limit: 5,
150
167
  with_payload: true,
151
- });
168
+ }, telemetry?.destination);
152
169
  const candidates = (results.result || [])
153
170
  .filter(r => r.score >= 0.75 && r.score < 0.92);
154
171
  if (candidates.length === 0)
@@ -343,7 +360,6 @@ const formatHealthReport = (report) => {
343
360
  const CATEGORY_TO_HEADING = {
344
361
  engineering: "Engineering",
345
362
  product: "Product",
346
- human: "Human",
347
363
  system: "System",
348
364
  // Legacy stored categories remain readable before any data migration.
349
365
  codebase: "Engineering",
@@ -354,9 +370,10 @@ const CATEGORY_TO_HEADING = {
354
370
  projects: "System",
355
371
  observation: "Engineering",
356
372
  observations: "Engineering",
357
- preferences: "Human",
358
- people: "Human",
359
- team: "Human",
373
+ human: "Engineering",
374
+ preferences: "Engineering",
375
+ people: "Engineering",
376
+ team: "Engineering",
360
377
  };
361
378
  const generateMemoryBrief = async (_config) => {
362
379
  if (!qdrant.isReady())
@@ -448,8 +465,8 @@ const tick = async (config, opts = {}) => {
448
465
  }
449
466
  };
450
467
  /** Reset state (for testing). */
451
- const _reset = () => {
452
- consolidationTickCount = 0;
468
+ const _reset = (tickCount = 0) => {
469
+ consolidationTickCount = tickCount;
453
470
  };
454
471
  export { detectContradiction, tick, setLogger, _reset, };
455
472
  //# sourceMappingURL=consolidation.js.map
@@ -11,6 +11,7 @@ import { entityTypingPrompt, ENTITY_TYPING_PROMPT_DESCRIPTOR, safeParseJson } fr
11
11
  import * as qdrant from "./qdrant.js";
12
12
  import { isAttemptBackedOff, pruneRecentAttempts, readMaintenanceState, recordMaintenanceRun, shouldRunMaintenance, } from "./maintenance-state.js";
13
13
  import { isGenericEntity } from "./relations-vocab.js";
14
+ import { buildOperationOrigin } from "../provenance/origin.js";
14
15
  const FACTS_SCAN_LIMIT = 200;
15
16
  const FACTS_PER_ENTITY = 5;
16
17
  const DEFAULT_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -171,6 +172,15 @@ const upsertEntityTypePoint = async (candidate, classification, source) => {
171
172
  classified_at: now,
172
173
  updated_at: now,
173
174
  created_at: now,
175
+ origin: buildOperationOrigin({
176
+ interface: "daemon",
177
+ action: "create",
178
+ subsystem: "entity_typing",
179
+ metadata: {
180
+ entity_name: candidate.name,
181
+ classification_source: source,
182
+ },
183
+ }),
174
184
  source_fact_ids: candidate.factIds,
175
185
  ...(candidate.workstreamKeys.length > 0 ? { workstream_key: candidate.workstreamKeys[0] } : {}),
176
186
  metadata: {
@@ -2,6 +2,7 @@ import type { BikkyConfig } from "../config.js";
2
2
  import type { QdrantPayload } from "./qdrant.js";
3
3
  import { type RedactionSummary } from "../privacy/redaction.js";
4
4
  import { type WorkstreamRegistry } from "./workstream-resolver.js";
5
+ import { type OperationOrigin } from "../provenance/origin.js";
5
6
  export { buildEpisodeSummaryMessages } from "../prompts/index.js";
6
7
  export interface WorkspaceScope {
7
8
  workspaceId?: string;
@@ -33,6 +34,7 @@ export interface EpisodeSummaryWriteResult {
33
34
  action: "stored" | "updated" | "skipped";
34
35
  factId?: string;
35
36
  episodeId?: string;
37
+ destination?: string;
36
38
  workstreamKey?: string | null;
37
39
  reason?: string;
38
40
  }
@@ -57,6 +59,8 @@ export declare const buildEpisodeSummaryPayload: (input: {
57
59
  enabled: boolean;
58
60
  redactPii: boolean;
59
61
  };
62
+ config?: BikkyConfig;
63
+ origin?: OperationOrigin;
60
64
  }) => EpisodeSummaryPayloadResult;
61
65
  export declare const updateEpisodeSummary: (input: {
62
66
  segment: EpisodeSegment;
@@ -13,6 +13,7 @@ import { CAPTURE_POLICY_VERSION, CAPTURE_TRIGGERS, DEFAULT_CAPTURE_CONTEXT, PROM
13
13
  import * as qdrant from "./qdrant.js";
14
14
  import { combineRedactions, redactStorageText, } from "../privacy/redaction.js";
15
15
  import { resolveWorkstreamKey, } from "./workstream-resolver.js";
16
+ import { buildOperationOrigin } from "../provenance/origin.js";
16
17
  export { buildEpisodeSummaryMessages } from "../prompts/index.js";
17
18
  const DEFAULT_EPISODE_IMPORTANCE = 0.75;
18
19
  const contentHash = (text) => createHash("sha256").update(`episode:${text}`).digest("hex");
@@ -120,6 +121,17 @@ export const buildEpisodeSummaryPayload = (input) => {
120
121
  ]);
121
122
  const existingPayload = input.existing?.payload ?? {};
122
123
  const workstreamKey = input.draft.workstream_key ?? input.segment.workstream_key ?? null;
124
+ const operationOrigin = input.origin ?? buildOperationOrigin({
125
+ interface: "daemon",
126
+ action: input.existing ? "update" : "create",
127
+ subsystem: "episode_summary",
128
+ config: input.config,
129
+ metadata: {
130
+ session_id: input.sessionId,
131
+ episode_id: input.segment.episode_id,
132
+ event_count: input.segment.event_count,
133
+ },
134
+ });
123
135
  const payload = {
124
136
  ...existingPayload,
125
137
  content: redactedContent.text,
@@ -129,9 +141,9 @@ export const buildEpisodeSummaryPayload = (input) => {
129
141
  memory_subtype: "episode",
130
142
  layer: "episode",
131
143
  ...(input.scope.workspaceId ? { workspace_id: input.scope.workspaceId } : {}),
132
- ...(input.scope.actorId ? { actor_id: input.scope.actorId } : {}),
144
+ origin: existingPayload.origin ?? operationOrigin,
145
+ ...(input.existing ? { last_operation_origin: operationOrigin } : {}),
133
146
  entities: redactedEntities.map((entity) => entity.text.toLowerCase()),
134
- source: "system",
135
147
  confidence: 1.0,
136
148
  importance: input.draft.importance,
137
149
  content_hash: contentHash(redactedContent.text),
@@ -184,19 +196,36 @@ const summarizeEpisodeTranscript = async (input) => {
184
196
  throw new Error("Episode summary LLM returned null");
185
197
  return parseEpisodeSummaryDraft(result);
186
198
  };
187
- const findExistingEpisodeSummary = async (episodeId, scope) => {
188
- const result = await qdrant.qdrantRequest("POST", `/collections/${qdrant.collection}/points/scroll`, {
199
+ const findExistingEpisodeSummary = async (episodeId, scope, destination) => {
200
+ const collection = qdrant.collectionForDestination(destination);
201
+ const result = await qdrant.qdrantRequest("POST", `/collections/${collection}/points/scroll`, {
189
202
  filter: buildEpisodeSummaryFilter(episodeId, scope),
190
203
  limit: 1,
191
204
  with_payload: true,
192
- });
205
+ }, destination);
193
206
  return result.result?.points?.[0] ?? null;
194
207
  };
195
208
  export const updateEpisodeSummary = async (input) => {
196
209
  if (!input.segment.transcript.trim()) {
197
210
  return { action: "skipped", reason: "empty_transcript", episodeId: input.segment.episode_id };
198
211
  }
199
- const existing = await findExistingEpisodeSummary(input.segment.episode_id, input.scope);
212
+ const destination = qdrant.resolveDestination({
213
+ content: input.segment.transcript,
214
+ entities: [
215
+ input.sessionId,
216
+ ...(input.segment.workstream_key ? [input.segment.workstream_key] : []),
217
+ ],
218
+ metadata: {
219
+ session_id: input.sessionId,
220
+ episode_id: input.segment.episode_id,
221
+ ...(input.segment.workstream_key ? { workstream_key: input.segment.workstream_key } : {}),
222
+ memory_subtype: "episode",
223
+ kind: "summary",
224
+ origin_interface: "daemon",
225
+ origin_agent_type: "daemon",
226
+ },
227
+ });
228
+ const existing = await findExistingEpisodeSummary(input.segment.episode_id, input.scope, destination.name);
200
229
  const draft = await summarizeEpisodeTranscript({
201
230
  transcript: input.segment.transcript,
202
231
  sessionId: input.sessionId,
@@ -222,6 +251,7 @@ export const updateEpisodeSummary = async (input) => {
222
251
  scope: input.scope,
223
252
  now,
224
253
  existing,
254
+ config: input.config,
225
255
  redactionOptions: {
226
256
  enabled: true,
227
257
  redactPii: false,
@@ -229,13 +259,14 @@ export const updateEpisodeSummary = async (input) => {
229
259
  });
230
260
  const vector = await qdrant.embed(String(payload.content));
231
261
  const factId = existing?.id ?? randomUUID();
232
- await qdrant.qdrantRequest("PUT", `/collections/${qdrant.collection}/points`, {
262
+ await qdrant.qdrantRequest("PUT", `/collections/${destination.collection}/points`, {
233
263
  points: [{ id: factId, vector, payload }],
234
- });
264
+ }, destination.name);
235
265
  return {
236
266
  action: existing ? "updated" : "stored",
237
267
  factId,
238
268
  episodeId: input.segment.episode_id,
269
+ destination: destination.name,
239
270
  workstreamKey: resolved.key,
240
271
  };
241
272
  };
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Events-based memory extraction — reads supported coding-agent transcripts,
3
- * extracts facts via LLM, and stores them in Qdrant with source: "system".
3
+ * extracts facts via LLM, and stores them in Qdrant with daemon origin metadata.
4
4
  *
5
5
  * Uses a JSON file for extraction state (high-water byte offsets) instead of SQLite.
6
6
  * Copilot session detection uses lock files. Claude Code detection uses
@@ -10,7 +10,7 @@ import type { BikkyConfig } from "../config.js";
10
10
  import type { LogFn } from "./qdrant.js";
11
11
  import { type TranscriptSource } from "./transcript-sources.js";
12
12
  export declare const setLogger: (fn: LogFn) => void;
13
- export declare const DEFAULT_EXTRACTION_PROMPT = "You are Bikky's memory extraction agent for open-source coding agents. Extract durable, reusable facts that help a future agent continue work without rereading the whole transcript.\n\n## Core rule\nExtract fewer, sharper memories. A candidate fact must be independently useful after the session is gone.\n\n## Quality gate\nEvery fact must pass at least one gate:\n1. GREPPABLE: names a file path, package, symbol, config key, CLI flag, issue/PR, service, or API a future agent can search for.\n2. RUNNABLE: contains a command, URL, setting, port, or procedure that can be executed or checked.\n3. NAVIGABLE: tells a future agent where to look and what that location means.\n4. DECISIVE: records a durable decision, rationale, constraint, convention, or preference.\n5. DIAGNOSTIC: captures a repeatable failure mode, root cause, or troubleshooting gotcha.\n\n## Ontology\n- domain is the activity profile. For coding-agent captures use \"software_engineering\".\n- category is subject matter: engineering | product | human | system.\n- kind is object shape. For this prompt, emit only kind=\"fact\".\n- memory_subtype must be one of:\n codebase_map | architecture_decision | infra_topology | access_pattern | operational_procedure | domain_rule | product_decision | product_requirement | user_workflow | roadmap_item | success_metric | market_insight | troubleshooting_gotcha | preference | person_profile | ownership_note | working_agreement | activity_event.\n\n## Examples\nGOOD:\n- \"The UI smoke tests live in packages/ui/tests/smoke.spec.ts and run through npm run test:e2e with mocked /api/memory/* responses.\"\n- \"Use workspace_id as the tenancy/access boundary; domain is reserved for activity profile such as software_engineering.\"\n- \"If Qdrant order_by fails with a missing index error, create a datetime payload index for the sorted field before retrying.\"\n- \"The memory page should show categories and concrete subtype chips directly; a sub-tab layer makes the ontology harder to understand.\"\n- \"Saber prefers Node's built-in test runner for root tests; do not add Jest just for daemon unit tests.\"\n- \"Saber merged PR #85 after approving the subtype UX copy changes.\"\n\nBAD:\n- \"The tests were fixed.\" (status only)\n- \"We reviewed the code.\" (session narration)\n- \"The deployment succeeded.\" (transient and not reusable)\n- \"The agent used npm.\" (tool narration)\n- \"There was an error.\" (no root cause or reusable detail)\n\n## Output format\nReturn strict JSON:\n{\"facts\":[\n {\n \"content\":\"One self-contained durable fact.\",\n \"category\":\"engineering\",\n \"memory_subtype\":\"codebase_map\",\n \"action_actor\":\"optional actor for activity_event only\",\n \"action_type\":\"optional action verb for activity_event only\",\n \"action_object\":\"optional durable object for activity_event only\",\n \"action_outcome\":\"optional durable outcome for activity_event only\",\n \"entities\":[\"repo-or-tool\",\"specific-module\"],\n \"confidence\":0.9,\n \"importance\":0.7,\n \"quality_score\":0.8,\n \"confidence_reason\":\"Explicitly stated in the transcript.\",\n \"repo\":\"optional/repo-or-package\",\n \"branch\":\"optional-branch\",\n \"task_key\":\"optional issue/PR/task key\",\n \"workstream_key\":\"optional stable workstream key\"\n }\n]}\n\nScoring:\n- confidence: 0.9 explicit, 0.7 strong inference, 0.55 weak but useful inference.\n- importance: 0.8+ for decisions, infra, procedures, access, recurring failures, product requirements, ownership, and state-changing activity events; 0.6+ for useful codebase maps/preferences.\n- quality_score: 0.8+ passes multiple gates, 0.6+ passes one strong gate, below 0.6 should usually be omitted.\n\nIf nothing passes the quality gate, return {\"facts\":[]}.";
13
+ export declare const DEFAULT_EXTRACTION_PROMPT = "You are Bikky's memory extraction agent for open-source coding agents. Extract durable, reusable facts that help a future agent continue work without rereading the whole transcript.\n\n## Core rule\nExtract fewer, sharper memories. A candidate fact must be independently useful after the session is gone.\n\n## Quality gate\nEvery fact must pass at least one gate:\n1. GREPPABLE: names a file path, package, symbol, config key, CLI flag, issue/PR, service, or API a future agent can search for.\n2. RUNNABLE: contains a command, URL, setting, port, or procedure that can be executed or checked.\n3. NAVIGABLE: tells a future agent where to look and what that location means.\n4. DECISIVE: records a durable decision, rationale, constraint, convention, or preference.\n5. DIAGNOSTIC: captures a repeatable failure mode, root cause, or troubleshooting gotcha.\n\n## Ontology\n- domain is the activity profile. For coding-agent captures use \"software_engineering\".\n- category is subject matter: engineering | product | system.\n- kind is object shape. For this prompt, emit only kind=\"fact\".\n- memory_subtype must be one of:\n codebase_map | architecture_decision | infra_topology | access_pattern | operational_procedure | domain_rule | product_decision | product_requirement | user_workflow | roadmap_item | success_metric | market_insight | troubleshooting_gotcha | preference | person_profile | ownership_note | working_agreement | activity_event.\n\n## Examples\nGOOD:\n- \"The UI smoke tests live in packages/ui/tests/smoke.spec.ts and run through npm run test:e2e with mocked /api/memory/* responses.\"\n- \"Use workspace_id as the tenancy/access boundary; domain is reserved for activity profile such as software_engineering.\"\n- \"If Qdrant order_by fails with a missing index error, create a datetime payload index for the sorted field before retrying.\"\n- \"The memory page should show categories and concrete subtype chips directly; a sub-tab layer makes the ontology harder to understand.\"\n- \"Saber prefers Node's built-in test runner for root tests; do not add Jest just for daemon unit tests.\"\n- \"Saber merged PR #85 after approving the subtype UX copy changes.\"\n\nBAD:\n- \"The tests were fixed.\" (status only)\n- \"We reviewed the code.\" (session narration)\n- \"The deployment succeeded.\" (transient and not reusable)\n- \"The agent used npm.\" (tool narration)\n- \"There was an error.\" (no root cause or reusable detail)\n\n## Output format\nReturn strict JSON:\n{\"facts\":[\n {\n \"content\":\"One self-contained durable fact.\",\n \"category\":\"engineering\",\n \"memory_subtype\":\"codebase_map\",\n \"action_actor\":\"optional actor for activity_event only\",\n \"action_type\":\"optional action verb for activity_event only\",\n \"action_object\":\"optional durable object for activity_event only\",\n \"action_outcome\":\"optional durable outcome for activity_event only\",\n \"entities\":[\"repo-or-tool\",\"specific-module\"],\n \"confidence\":0.9,\n \"importance\":0.7,\n \"quality_score\":0.8,\n \"confidence_reason\":\"Explicitly stated in the transcript.\",\n \"repo\":\"optional/repo-or-package\",\n \"branch\":\"optional-branch\",\n \"task_key\":\"optional issue/PR/task key\",\n \"workstream_key\":\"optional stable workstream key\"\n }\n]}\n\nScoring:\n- confidence: 0.9 explicit, 0.7 strong inference, 0.55 weak but useful inference.\n- importance: 0.8+ for decisions, infra, procedures, access, recurring failures, product requirements, ownership, and state-changing activity events; 0.6+ for useful codebase maps/preferences.\n- quality_score: 0.8+ passes multiple gates, 0.6+ passes one strong gate, below 0.6 should usually be omitted.\n\nIf nothing passes the quality gate, return {\"facts\":[]}.";
14
14
  export type Volatility = "stable" | "evolving" | "transient" | "ephemeral";
15
15
  export interface ExtractedFact {
16
16
  content: string;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Events-based memory extraction — reads supported coding-agent transcripts,
3
- * extracts facts via LLM, and stores them in Qdrant with source: "system".
3
+ * extracts facts via LLM, and stores them in Qdrant with daemon origin metadata.
4
4
  *
5
5
  * Uses a JSON file for extraction state (high-water byte offsets) instead of SQLite.
6
6
  * Copilot session detection uses lock files. Claude Code detection uses
@@ -19,7 +19,7 @@ import { CAPTURE_POLICY_VERSION, CAPTURE_TRIGGERS, DEFAULT_CAPTURE_CONTEXT, QUAL
19
19
  import { shouldSummarizeEvents, updateSessionSummary } from "./session-summary.js";
20
20
  import { redactStorageText } from "../privacy/redaction.js";
21
21
  import { compareSubtype, hasTypedToken, verifyGrounding, verifyVolatilityCoherence } from "./extraction-rules.js";
22
- import { resolveActorIdentity } from "../provenance/actor.js";
22
+ import { buildOperationOrigin } from "../provenance/origin.js";
23
23
  import { discoverClaudeTranscriptMappings, discoverCopilotTranscriptMappings, extractionStateKey, readNewTranscriptEvents, transcriptLabel, } from "./transcript-sources.js";
24
24
  // ── Module state ─────────────────────────────────────────────────────────────
25
25
  let logFn = (() => { });
@@ -43,7 +43,7 @@ Every fact must pass at least one gate:
43
43
 
44
44
  ## Ontology
45
45
  - domain is the activity profile. For coding-agent captures use "software_engineering".
46
- - category is subject matter: engineering | product | human | system.
46
+ - category is subject matter: engineering | product | system.
47
47
  - kind is object shape. For this prompt, emit only kind="fact".
48
48
  - memory_subtype must be one of:
49
49
  codebase_map | architecture_decision | infra_topology | access_pattern | operational_procedure | domain_rule | product_decision | product_requirement | user_workflow | roadmap_item | success_metric | market_insight | troubleshooting_gotcha | preference | person_profile | ownership_note | working_agreement | activity_event.
@@ -210,8 +210,8 @@ export const factQualitySignals = (fact) => {
210
210
  const isPreferenceLike = subtype === "preference" || subtype === "domain_rule" || subtype === "working_agreement";
211
211
  const isDecisionLike = subtype === "architecture_decision" || subtype === "product_decision" || subtype === "troubleshooting_gotcha";
212
212
  const isProductLike = subtype === "product_requirement" || subtype === "user_workflow" || subtype === "roadmap_item" || subtype === "success_metric" || subtype === "market_insight";
213
- const isHumanLike = subtype === "person_profile" || subtype === "ownership_note" || subtype === "activity_event";
214
- const shortUseful = wordCount >= 7 && wordCount <= 22 && (isPreferenceLike || isDecisionLike || isProductLike || isHumanLike) && (entities.length > 0 || durableAnchor);
213
+ const isCollaborationLike = subtype === "person_profile" || subtype === "ownership_note" || subtype === "activity_event";
214
+ const shortUseful = wordCount >= 7 && wordCount <= 22 && (isPreferenceLike || isDecisionLike || isProductLike || isCollaborationLike) && (entities.length > 0 || durableAnchor);
215
215
  let score = 0.25;
216
216
  if (wordCount >= 8)
217
217
  score += 0.1;
@@ -219,7 +219,7 @@ export const factQualitySignals = (fact) => {
219
219
  score += 0.1;
220
220
  if (durableAnchor)
221
221
  score += 0.25;
222
- if (isPreferenceLike || isDecisionLike || isProductLike || isHumanLike)
222
+ if (isPreferenceLike || isDecisionLike || isProductLike || isCollaborationLike)
223
223
  score += 0.15;
224
224
  if ((fact.confidence ?? 0) >= 0.75)
225
225
  score += 0.1;
@@ -254,8 +254,16 @@ const subtypeForRawCategoryHint = (rawCategory, category) => {
254
254
  return "operational_procedure";
255
255
  if (hint.includes("decision"))
256
256
  return "architecture_decision";
257
- if (hint.includes("people") || hint.includes("preference") || hint.includes("owner"))
257
+ if (hint.includes("preference"))
258
258
  return "preference";
259
+ if (hint.includes("owner"))
260
+ return "ownership_note";
261
+ if (hint.includes("agreement"))
262
+ return "working_agreement";
263
+ if (hint.includes("activity") || hint.includes("actor"))
264
+ return "activity_event";
265
+ if (hint.includes("people") || hint.includes("person") || hint.includes("team"))
266
+ return "person_profile";
259
267
  if (hint.includes("product") || hint.includes("domain"))
260
268
  return "domain_rule";
261
269
  return subtypeForCategory(normalizeCategory(category));
@@ -415,11 +423,6 @@ const storeFacts = async (facts, sessionId, config, source) => {
415
423
  };
416
424
  if (source)
417
425
  baseMeta.extraction_source = source;
418
- const actor = resolveActorIdentity({ config });
419
- if (actor.actor_label)
420
- baseMeta.actor_label = actor.actor_label;
421
- if (actor.source)
422
- baseMeta.actor_source = actor.source;
423
426
  let stored = 0;
424
427
  for (const fact of facts) {
425
428
  const redactedContent = redactStorageText(fact.content);
@@ -429,12 +432,34 @@ const storeFacts = async (facts, sessionId, config, source) => {
429
432
  entities: fact.entities.map((entity) => entity.toLowerCase()),
430
433
  };
431
434
  const hash = contentHash(sanitizedFact.content);
435
+ const routeInput = {
436
+ content: sanitizedFact.content,
437
+ entities: sanitizedFact.entities,
438
+ metadata: {
439
+ ...baseMeta,
440
+ ...(fact.repo ? { repo: fact.repo } : {}),
441
+ ...(fact.branch ? { branch: fact.branch } : {}),
442
+ ...(fact.task_key ? { task_key: fact.task_key } : {}),
443
+ ...(fact.workstream_key ? { workstream_key: fact.workstream_key } : {}),
444
+ category: fact.category,
445
+ },
446
+ };
432
447
  try {
433
- const dedup = await qdrant.dedupCheck(sanitizedFact.content, hash);
448
+ const dedup = await qdrant.dedupCheck(sanitizedFact.content, hash, undefined, undefined, routeInput);
434
449
  if (dedup.action === "skip") {
435
450
  // Reinforce existing fact
436
451
  if (dedup.existingId) {
437
- await qdrant.reinforceFact(dedup.existingId, dedup.existingCount || 1);
452
+ await qdrant.reinforceFact(dedup.existingId, dedup.existingCount || 1, dedup.destination, buildOperationOrigin({
453
+ interface: "daemon",
454
+ action: "reinforce",
455
+ subsystem: "extraction",
456
+ config,
457
+ metadata: {
458
+ session_id: sessionId,
459
+ ...(source ? { transcript_source: source } : {}),
460
+ dedup_action: dedup.action,
461
+ },
462
+ }));
438
463
  }
439
464
  continue;
440
465
  }
@@ -444,6 +469,7 @@ const storeFacts = async (facts, sessionId, config, source) => {
444
469
  const contradiction = await detectContradiction(sanitizedFact, config, {
445
470
  sessionId,
446
471
  workstreamKey: sanitizedFact.workstream_key ?? undefined,
472
+ destination: dedup.destination,
447
473
  });
448
474
  if (contradiction.contradiction && contradiction.existingId) {
449
475
  logFn("INFO", `Extraction: contradiction detected for "${fact.content.slice(0, 60)}..." vs ${contradiction.existingId}: ${contradiction.reason}`);
@@ -533,7 +559,7 @@ const storeFacts = async (facts, sessionId, config, source) => {
533
559
  // Downgrade to candidate with reduced confidence rather than dropping
534
560
  // outright: similarity is a soft signal, not a hard reject.
535
561
  try {
536
- const badMatch = await qdrant.badExemplarCheck(sanitizedFact.content);
562
+ const badMatch = await qdrant.badExemplarCheck(sanitizedFact.content, undefined, routeInput);
537
563
  if (badMatch && badMatch.score >= 0.85) {
538
564
  effectiveConfidence = clamp01(effectiveConfidence - 0.2);
539
565
  reviewStatus = "candidate";
@@ -553,7 +579,6 @@ const storeFacts = async (facts, sessionId, config, source) => {
553
579
  domain: DEFAULT_CAPTURE_CONTEXT.domain,
554
580
  memory_subtype: effectiveSubtype,
555
581
  entities: sanitizedFact.entities,
556
- source: "system",
557
582
  kind: "fact",
558
583
  confidence: effectiveConfidence,
559
584
  importance: fact.importance,
@@ -571,17 +596,35 @@ const storeFacts = async (facts, sessionId, config, source) => {
571
596
  task_key: fact.task_key,
572
597
  workstream_key: fact.workstream_key,
573
598
  metadata: factMeta,
599
+ origin: buildOperationOrigin({
600
+ interface: "daemon",
601
+ action: "create",
602
+ subsystem: "extraction",
603
+ config,
604
+ metadata: {
605
+ session_id: sessionId,
606
+ ...(source ? { transcript_source: source } : {}),
607
+ capture_policy_version: CAPTURE_POLICY_VERSION,
608
+ },
609
+ }),
574
610
  };
575
- if (actor.actor_id) {
576
- storePayload.actor_id = actor.actor_id;
577
- }
578
611
  if (dedup.action === "supersede" && dedup.existingId) {
579
- const newId = await qdrant.storeFact(storePayload);
580
- await qdrant.supersedeFact(dedup.existingId, newId);
612
+ const newId = await qdrant.storeFact(storePayload, routeInput);
613
+ await qdrant.supersedeFact(dedup.existingId, newId, dedup.destination, buildOperationOrigin({
614
+ interface: "daemon",
615
+ action: "supersede",
616
+ subsystem: "extraction",
617
+ config,
618
+ metadata: {
619
+ session_id: sessionId,
620
+ new_fact_id: newId,
621
+ ...(source ? { transcript_source: source } : {}),
622
+ },
623
+ }));
581
624
  stored++;
582
625
  }
583
626
  else {
584
- await qdrant.storeFact(storePayload);
627
+ await qdrant.storeFact(storePayload, routeInput);
585
628
  stored++;
586
629
  }
587
630
  }
@@ -11,6 +11,7 @@ import { tick as extractionTick, setLogger as setExtractionLogger } from "./extr
11
11
  import { tick as consolidationTick, setLogger as setConsolidationLogger } from "./consolidation.js";
12
12
  import { tick as relationsTick, setLogger as setRelationsLogger } from "./relations.js";
13
13
  import { tick as entityTypingTick, setLogger as setEntityTypingLogger } from "./entity-typing.js";
14
+ import { tick as qualityRollupsTick, setLogger as setQualityRollupsLogger } from "./quality-rollups.js";
14
15
  import { scanStaleFacts, setLogger as setStalenessLogger } from "./staleness.js";
15
16
  import { inspectWatcherPaths, formatIssue } from "./watcher-health.js";
16
17
  // createLogger returns (LogLevel, ...args) but daemon modules accept (string, ...args).
@@ -34,6 +35,7 @@ export async function startDaemon() {
34
35
  setConsolidationLogger(log);
35
36
  setRelationsLogger(log);
36
37
  setEntityTypingLogger(log);
38
+ setQualityRollupsLogger(log);
37
39
  setStalenessLogger(log);
38
40
  // Initialize LLM client from config
39
41
  initLLM({
@@ -107,6 +109,12 @@ export async function startDaemon() {
107
109
  catch (e) {
108
110
  log("ERROR", `Entity typing tick failed: ${e.message}`);
109
111
  }
112
+ try {
113
+ await qualityRollupsTick(cfg);
114
+ }
115
+ catch (e) {
116
+ log("ERROR", `Memory quality rollups tick failed: ${e.message}`);
117
+ }
110
118
  // Staleness scans every 1000 ticks (~83 min at 5s interval)
111
119
  if (tickCount % 1000 === 0) {
112
120
  try {
@@ -1,6 +1,6 @@
1
1
  import type { LogFn } from "./qdrant.js";
2
2
  export declare const MAINTENANCE_STATE_PATH: string;
3
- export type MaintenanceJobName = "relation_inference" | "entity_typing";
3
+ export type MaintenanceJobName = "relation_inference" | "entity_typing" | "memory_quality_rollups";
4
4
  export interface MaintenanceRunSummary {
5
5
  job: MaintenanceJobName;
6
6
  ran_at: string;
@@ -13,6 +13,7 @@ export const defaultMaintenanceState = () => ({
13
13
  jobs: {
14
14
  relation_inference: defaultJobState(),
15
15
  entity_typing: defaultJobState(),
16
+ memory_quality_rollups: defaultJobState(),
16
17
  },
17
18
  });
18
19
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -42,6 +43,7 @@ export const readMaintenanceState = (log = () => { }) => {
42
43
  jobs: {
43
44
  relation_inference: coerceJobState(jobs.relation_inference),
44
45
  entity_typing: coerceJobState(jobs.entity_typing),
46
+ memory_quality_rollups: coerceJobState(jobs.memory_quality_rollups),
45
47
  },
46
48
  };
47
49
  }
@@ -7,9 +7,12 @@
7
7
  * `qdrant_api_key` is optional — leave unset for unauthenticated local /
8
8
  * self-hosted instances.
9
9
  */
10
+ import { type Destination } from "../config.js";
10
11
  import { embed } from "../llm/index.js";
11
12
  import type { InitEmbeddingInput } from "../llm/index.js";
13
+ import { type RoutingInput } from "../routing.js";
12
14
  import { type RedactionSummary } from "../privacy/redaction.js";
15
+ import { type OperationOrigin } from "../provenance/origin.js";
13
16
  export type LogFn = (level: string, ...args: unknown[]) => void;
14
17
  export interface QdrantPayload {
15
18
  content: string;
@@ -18,10 +21,15 @@ export interface QdrantPayload {
18
21
  kind: string;
19
22
  layer?: string | null;
20
23
  memory_subtype?: string | null;
24
+ origin?: OperationOrigin;
25
+ last_operation_origin?: OperationOrigin;
26
+ /** @deprecated Origin is canonical for new writes. */
21
27
  workspace_id?: string;
28
+ /** @deprecated Origin is canonical for new writes. */
22
29
  actor_id?: string;
23
30
  entities: string[];
24
- source: string;
31
+ /** @deprecated Origin is canonical for new writes. */
32
+ source?: string;
25
33
  confidence: number;
26
34
  importance: number;
27
35
  content_hash: string;
@@ -65,11 +73,15 @@ export interface StoreFact {
65
73
  layer?: string | null;
66
74
  memory_subtype?: string | null;
67
75
  entities: string[];
76
+ origin?: OperationOrigin;
77
+ last_operation_origin?: OperationOrigin;
78
+ /** @deprecated Origin is canonical for new writes. */
68
79
  source?: string;
69
80
  confidence?: number;
70
81
  importance?: number;
71
82
  content_hash: string;
72
83
  workspace_id?: string;
84
+ /** @deprecated Origin is canonical for new writes. */
73
85
  actor_id?: string;
74
86
  metadata?: Record<string, string | number | boolean | null>;
75
87
  session_id?: string | null;
@@ -100,6 +112,7 @@ export interface StoreFact {
100
112
  }
101
113
  export interface QdrantSearchResult {
102
114
  id: string;
115
+ destination?: string;
103
116
  score: number;
104
117
  content: string;
105
118
  category: string;
@@ -110,6 +123,7 @@ export interface QdrantSearchResult {
110
123
  }
111
124
  export interface QdrantScrollResult {
112
125
  id: string;
126
+ destination?: string;
113
127
  content: string;
114
128
  category: string;
115
129
  entities: string[];
@@ -151,6 +165,7 @@ export interface QdrantScrollFilters {
151
165
  export type DedupAction = "insert" | "skip" | "supersede";
152
166
  export interface DedupResult {
153
167
  action: DedupAction;
168
+ destination?: string;
154
169
  existingId?: string;
155
170
  existingCount?: number;
156
171
  score?: number;
@@ -162,16 +177,23 @@ export interface DedupThresholds {
162
177
  declare let collection: string;
163
178
  declare const setLogger: (fn: LogFn) => void;
164
179
  declare const setEmbeddingConfig: (overrides?: Partial<InitEmbeddingInput>) => void;
180
+ type DestinationRef = Destination | string | null | undefined;
181
+ declare const resolveDestination: (input?: RoutingInput) => Destination;
165
182
  declare const init: () => boolean;
166
183
  declare const isReady: () => boolean;
167
184
  declare const ensureCollection: () => Promise<void>;
168
- declare const qdrantRequest: (method: string, urlPath: string, body?: unknown) => Promise<Record<string, unknown>>;
169
- declare const searchFacts: (query: string, filters?: QdrantSearchFilters, limit?: number) => Promise<QdrantSearchResult[]>;
170
- declare const scrollFacts: (filters?: QdrantScrollFilters, limit?: number) => Promise<QdrantScrollResult[]>;
171
- declare const storeFact: (fact: StoreFact) => Promise<string>;
172
- declare const supersedeFact: (oldFactId: string, newFactId: string) => Promise<void>;
173
- declare const reinforceFact: (factId: string, currentCount: number) => Promise<void>;
174
- declare const dedupCheck: (content: string, contentHashVal: string, { exactThreshold, supersedeThreshold }?: DedupThresholds, workspaceId?: string) => Promise<DedupResult>;
185
+ declare const qdrantRequest: (method: string, urlPath: string, body?: unknown, destinationRef?: DestinationRef) => Promise<Record<string, unknown>>;
186
+ declare const collectionForDestination: (destinationRef?: DestinationRef) => string;
187
+ declare const destinationNames: () => string[];
188
+ declare const readyDestinations: () => Destination[];
189
+ declare const activeDestinations: () => Destination[];
190
+ declare const searchFacts: (query: string, filters?: QdrantSearchFilters, limit?: number, destinationRef?: DestinationRef) => Promise<QdrantSearchResult[]>;
191
+ declare const scrollFacts: (filters?: QdrantScrollFilters, limit?: number, destinationRef?: DestinationRef) => Promise<QdrantScrollResult[]>;
192
+ declare const scrollFactsAcrossDestinations: (filters?: QdrantScrollFilters, limit?: number) => Promise<QdrantScrollResult[]>;
193
+ declare const storeFact: (fact: StoreFact, routeInput?: RoutingInput) => Promise<string>;
194
+ declare const supersedeFact: (oldFactId: string, newFactId: string, destinationRef?: DestinationRef, origin?: OperationOrigin) => Promise<void>;
195
+ declare const reinforceFact: (factId: string, currentCount: number, destinationRef?: DestinationRef, origin?: OperationOrigin) => Promise<void>;
196
+ declare const dedupCheck: (content: string, contentHashVal: string, { exactThreshold, supersedeThreshold }?: DedupThresholds, workspaceId?: string, routeInput?: RoutingInput) => Promise<DedupResult>;
175
197
  /**
176
198
  * Check whether incoming content is similar to any fact previously marked as a
177
199
  * bad exemplar (via memory_forget). Returns the top similarity score, or null
@@ -181,10 +203,10 @@ declare const dedupCheck: (content: string, contentHashVal: string, { exactThres
181
203
  * No hardcoded vocabulary — purely embedding-similarity based, and the
182
204
  * exemplar set grows organically every time a user calls memory_forget.
183
205
  */
184
- declare const badExemplarCheck: (content: string, workspaceId?: string) => Promise<{
206
+ declare const badExemplarCheck: (content: string, workspaceId?: string, routeInput?: RoutingInput) => Promise<{
185
207
  score: number;
186
208
  exemplarId: string;
187
209
  reason?: string;
188
210
  } | null>;
189
- export { init, isReady, ensureCollection, setLogger, setEmbeddingConfig, qdrantRequest, embed, searchFacts, scrollFacts, storeFact, supersedeFact, reinforceFact, dedupCheck, badExemplarCheck, collection, };
211
+ export { init, isReady, ensureCollection, setLogger, setEmbeddingConfig, qdrantRequest, resolveDestination, collectionForDestination, destinationNames, readyDestinations, activeDestinations, embed, searchFacts, scrollFacts, scrollFactsAcrossDestinations, storeFact, supersedeFact, reinforceFact, dedupCheck, badExemplarCheck, collection, };
190
212
  //# sourceMappingURL=qdrant.d.ts.map