bikky 0.4.1 → 0.4.3

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 (50) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/CODE_OF_CONDUCT.md +1 -1
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.md +23 -17
  5. package/SUPPORT.md +3 -2
  6. package/dist/config.d.ts +11 -1
  7. package/dist/config.js +88 -20
  8. package/dist/daemon/capture-policy.d.ts +0 -1
  9. package/dist/daemon/capture-policy.js +0 -1
  10. package/dist/daemon/consolidation.d.ts +2 -1
  11. package/dist/daemon/consolidation.js +28 -11
  12. package/dist/daemon/entity-typing.js +10 -0
  13. package/dist/daemon/episode-summary.d.ts +4 -0
  14. package/dist/daemon/episode-summary.js +39 -8
  15. package/dist/daemon/extraction.d.ts +1 -1
  16. package/dist/daemon/extraction.js +52 -17
  17. package/dist/daemon/qdrant.d.ts +32 -10
  18. package/dist/daemon/qdrant.js +177 -60
  19. package/dist/daemon/relations.d.ts +3 -3
  20. package/dist/daemon/relations.js +27 -15
  21. package/dist/daemon/session-index.d.ts +5 -0
  22. package/dist/daemon/session-index.js +36 -9
  23. package/dist/daemon/session-summary.d.ts +3 -0
  24. package/dist/daemon/session-summary.js +48 -15
  25. package/dist/daemon/staleness.js +2 -2
  26. package/dist/daemon/transcript-sources.js +3 -2
  27. package/dist/daemon/watcher.js +2 -0
  28. package/dist/daemon/workstream-summary.d.ts +4 -0
  29. package/dist/daemon/workstream-summary.js +58 -16
  30. package/dist/install.d.ts +11 -0
  31. package/dist/install.js +38 -0
  32. package/dist/llm/embedding/index.js +2 -1
  33. package/dist/llm/embedding/providers/openai.js +8 -2
  34. package/dist/llm/embedding/providers/portkey.js +9 -2
  35. package/dist/llm/inference/index.js +2 -1
  36. package/dist/llm/util.d.ts +12 -0
  37. package/dist/llm/util.js +18 -0
  38. package/dist/mcp/helpers.d.ts +5 -0
  39. package/dist/mcp/helpers.js +27 -3
  40. package/dist/mcp/taxonomy.js +12 -1
  41. package/dist/mcp/tools.js +161 -57
  42. package/dist/mcp/types.d.ts +12 -0
  43. package/dist/package-verifier.d.ts +19 -0
  44. package/dist/package-verifier.js +83 -0
  45. package/dist/provenance/origin.d.ts +57 -0
  46. package/dist/provenance/origin.js +254 -0
  47. package/docs/config/fully-hosted.md +33 -13
  48. package/docs/config/hosted-models.md +33 -13
  49. package/docs/configuration.md +23 -5
  50. package/package.json +6 -2
@@ -8,6 +8,7 @@ import { createHash, randomUUID } from "node:crypto";
8
8
  import { CAPTURE_POLICY_VERSION, DEFAULT_CAPTURE_CONTEXT, PROMPT_VERSIONS } from "./capture-policy.js";
9
9
  import * as qdrant from "./qdrant.js";
10
10
  import { combineRedactions, redactStorageText, } from "../privacy/redaction.js";
11
+ import { buildOperationOrigin } from "../provenance/origin.js";
11
12
  const contentHash = (text) => createHash("sha256").update(`session-index:${text}`).digest("hex");
12
13
  export const buildSessionIndexDraft = (input) => {
13
14
  const episodeIds = input.episodeResults
@@ -48,6 +49,16 @@ export const buildSessionIndexPayload = (input) => {
48
49
  const redactedEntities = input.draft.entities.map((entity) => redactStorageText(entity, input.redactionOptions));
49
50
  const redaction = combineRedactions([redactedContent, ...redactedEntities]);
50
51
  const existingPayload = input.existing?.payload ?? {};
52
+ const operationOrigin = input.origin ?? buildOperationOrigin({
53
+ interface: "daemon",
54
+ action: input.existing ? "update" : "create",
55
+ subsystem: "session_index",
56
+ config: input.config,
57
+ metadata: {
58
+ session_id: input.sessionId,
59
+ event_count: input.eventCount,
60
+ },
61
+ });
51
62
  const payload = {
52
63
  ...existingPayload,
53
64
  content: redactedContent.text,
@@ -57,9 +68,9 @@ export const buildSessionIndexPayload = (input) => {
57
68
  memory_subtype: "session_index",
58
69
  layer: "episode",
59
70
  ...(input.scope.workspaceId ? { workspace_id: input.scope.workspaceId } : {}),
60
- ...(input.scope.actorId ? { actor_id: input.scope.actorId } : {}),
71
+ origin: existingPayload.origin ?? operationOrigin,
72
+ ...(input.existing ? { last_operation_origin: operationOrigin } : {}),
61
73
  entities: redactedEntities.map((entity) => entity.text.toLowerCase()),
62
- source: "system",
63
74
  confidence: 1.0,
64
75
  importance: input.draft.importance,
65
76
  content_hash: contentHash(redactedContent.text),
@@ -93,24 +104,38 @@ export const buildSessionIndexPayload = (input) => {
93
104
  }
94
105
  return { payload, redaction };
95
106
  };
96
- const findExistingSessionIndex = async (sessionId, scope) => {
97
- const result = await qdrant.qdrantRequest("POST", `/collections/${qdrant.collection}/points/scroll`, {
107
+ const findExistingSessionIndex = async (sessionId, scope, destination) => {
108
+ const collection = qdrant.collectionForDestination(destination);
109
+ const result = await qdrant.qdrantRequest("POST", `/collections/${collection}/points/scroll`, {
98
110
  filter: buildSessionIndexFilter(sessionId, scope),
99
111
  limit: 1,
100
112
  with_payload: true,
101
- });
113
+ }, destination);
102
114
  return result.result?.points?.[0] ?? null;
103
115
  };
104
116
  export const updateSessionIndex = async (input) => {
105
117
  if (input.episodeResults.length === 0) {
106
118
  return { action: "skipped", reason: "no_episode_results" };
107
119
  }
108
- const existing = await findExistingSessionIndex(input.sessionId, input.scope);
109
120
  const draft = buildSessionIndexDraft({
110
121
  sessionId: input.sessionId,
111
122
  eventCount: input.eventCount,
112
123
  episodeResults: input.episodeResults,
113
124
  });
125
+ const destination = input.destination
126
+ ?? input.episodeResults.find((result) => result.destination)?.destination
127
+ ?? qdrant.resolveDestination({
128
+ content: draft.content,
129
+ entities: draft.entities,
130
+ metadata: {
131
+ session_id: input.sessionId,
132
+ memory_subtype: "session_index",
133
+ kind: "summary",
134
+ origin_interface: "daemon",
135
+ origin_agent_type: "daemon",
136
+ },
137
+ }).name;
138
+ const existing = await findExistingSessionIndex(input.sessionId, input.scope, destination);
114
139
  const now = new Date().toISOString();
115
140
  const { payload } = buildSessionIndexPayload({
116
141
  draft,
@@ -119,6 +144,7 @@ export const updateSessionIndex = async (input) => {
119
144
  now,
120
145
  existing,
121
146
  eventCount: input.eventCount,
147
+ config: input.config,
122
148
  redactionOptions: {
123
149
  enabled: true,
124
150
  redactPii: false,
@@ -126,9 +152,10 @@ export const updateSessionIndex = async (input) => {
126
152
  });
127
153
  const vector = await qdrant.embed(String(payload.content));
128
154
  const factId = existing?.id ?? randomUUID();
129
- await qdrant.qdrantRequest("PUT", `/collections/${qdrant.collection}/points`, {
155
+ const collection = qdrant.collectionForDestination(destination);
156
+ await qdrant.qdrantRequest("PUT", `/collections/${collection}/points`, {
130
157
  points: [{ id: factId, vector, payload }],
131
- });
132
- return { action: existing ? "updated" : "stored", factId };
158
+ }, destination);
159
+ return { action: existing ? "updated" : "stored", factId, destination };
133
160
  };
134
161
  //# sourceMappingURL=session-index.js.map
@@ -1,6 +1,7 @@
1
1
  import type { BikkyConfig } from "../config.js";
2
2
  import type { QdrantPayload } from "./qdrant.js";
3
3
  import { type RedactionSummary } from "../privacy/redaction.js";
4
+ import { type OperationOrigin } from "../provenance/origin.js";
4
5
  export interface WorkspaceScope {
5
6
  workspaceId?: string;
6
7
  actorId?: string;
@@ -52,6 +53,8 @@ export declare const buildSessionSummaryPayload: (input: {
52
53
  enabled: boolean;
53
54
  redactPii: boolean;
54
55
  };
56
+ config?: BikkyConfig;
57
+ origin?: OperationOrigin;
55
58
  }) => SessionSummaryPayloadResult;
56
59
  export declare const updateSessionSummary: (input: {
57
60
  sessionId: string;
@@ -13,7 +13,7 @@ import { buildSessionIndexFilter, updateSessionIndex } from "./session-index.js"
13
13
  import { updateWorkstreamSummaries } from "./workstream-summary.js";
14
14
  import * as qdrant from "./qdrant.js";
15
15
  import { combineRedactions, redactStorageText, } from "../privacy/redaction.js";
16
- import { resolveActorIdentity } from "../provenance/actor.js";
16
+ import { buildOperationOrigin } from "../provenance/origin.js";
17
17
  const DEFAULT_SUMMARY_IMPORTANCE = 0.8;
18
18
  const contentHash = (text) => createHash("sha256").update(`summary:${text}`).digest("hex");
19
19
  const stripJsonFence = (raw) => raw.trim().replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "").trim();
@@ -92,6 +92,16 @@ export const buildSessionSummaryPayload = (input) => {
92
92
  ...redactedEntities,
93
93
  ]);
94
94
  const existingPayload = input.existing?.payload ?? {};
95
+ const operationOrigin = input.origin ?? buildOperationOrigin({
96
+ interface: "daemon",
97
+ action: input.existing ? "update" : "create",
98
+ subsystem: "session_summary",
99
+ config: input.config,
100
+ metadata: {
101
+ session_id: input.sessionId,
102
+ event_count: input.eventCount,
103
+ },
104
+ });
95
105
  const payload = {
96
106
  ...existingPayload,
97
107
  content: redactedContent.text,
@@ -101,9 +111,9 @@ export const buildSessionSummaryPayload = (input) => {
101
111
  memory_subtype: "session_index",
102
112
  layer: "episode",
103
113
  ...(input.scope.workspaceId ? { workspace_id: input.scope.workspaceId } : {}),
104
- ...(input.scope.actorId ? { actor_id: input.scope.actorId } : {}),
114
+ origin: existingPayload.origin ?? operationOrigin,
115
+ ...(input.existing ? { last_operation_origin: operationOrigin } : {}),
105
116
  entities: redactedEntities.map((entity) => entity.text.toLowerCase()),
106
- source: "system",
107
117
  confidence: 1.0,
108
118
  importance: input.draft.importance,
109
119
  content_hash: contentHash(redactedContent.text),
@@ -144,8 +154,7 @@ export const updateSessionSummary = async (input) => {
144
154
  return { action: "skipped", reason: "qdrant_not_ready" };
145
155
  }
146
156
  const config = input.config ?? loadConfig();
147
- const actor = resolveActorIdentity({ config });
148
- const scope = actor.actor_id ? { actorId: actor.actor_id } : {};
157
+ const scope = {};
149
158
  const segments = segmentTranscriptIntoEpisodes({
150
159
  sessionId: input.sessionId,
151
160
  transcript: input.transcript,
@@ -164,18 +173,42 @@ export const updateSessionSummary = async (input) => {
164
173
  if (episodeResults.length === 0) {
165
174
  return { action: "skipped", reason: "no_episode_summaries" };
166
175
  }
167
- const indexResult = await updateSessionIndex({
168
- sessionId: input.sessionId,
169
- eventCount: input.eventCount,
170
- episodeResults,
171
- scope,
172
- config,
173
- });
176
+ let indexResult = null;
177
+ const episodeResultsByDestination = new Map();
178
+ for (const result of episodeResults) {
179
+ const destination = result.destination ?? qdrant.resolveDestination({
180
+ content: result.workstreamKey ?? result.episodeId ?? input.sessionId,
181
+ entities: [result.workstreamKey, result.episodeId].filter((value) => Boolean(value)),
182
+ metadata: {
183
+ session_id: input.sessionId,
184
+ ...(result.episodeId ? { episode_id: result.episodeId } : {}),
185
+ ...(result.workstreamKey ? { workstream_key: result.workstreamKey } : {}),
186
+ kind: "summary",
187
+ memory_subtype: "session_index",
188
+ origin_interface: "daemon",
189
+ origin_agent_type: "daemon",
190
+ },
191
+ }).name;
192
+ const bucket = episodeResultsByDestination.get(destination) ?? [];
193
+ bucket.push(result);
194
+ episodeResultsByDestination.set(destination, bucket);
195
+ }
196
+ for (const [destination, destinationEpisodeResults] of episodeResultsByDestination) {
197
+ const result = await updateSessionIndex({
198
+ sessionId: input.sessionId,
199
+ eventCount: input.eventCount,
200
+ episodeResults: destinationEpisodeResults,
201
+ scope,
202
+ config,
203
+ destination,
204
+ });
205
+ indexResult ??= result;
206
+ }
174
207
  await updateWorkstreamSummaries({ episodeResults, scope, config });
175
208
  return {
176
- action: indexResult.action,
177
- factId: indexResult.factId,
178
- reason: indexResult.reason,
209
+ action: indexResult?.action ?? "skipped",
210
+ factId: indexResult?.factId,
211
+ reason: indexResult?.reason,
179
212
  };
180
213
  };
181
214
  //# sourceMappingURL=session-summary.js.map
@@ -11,7 +11,7 @@ let logFn = console.log;
11
11
  let lastStaleIds = "";
12
12
  const defaultDeps = {
13
13
  isReady: qdrantMod.isReady,
14
- scrollFacts: qdrantMod.scrollFacts,
14
+ scrollFacts: qdrantMod.scrollFactsAcrossDestinations,
15
15
  };
16
16
  /**
17
17
  * Scan for stale facts via direct Qdrant scroll query.
@@ -37,7 +37,7 @@ export const scanStaleFacts = async (config, deps = defaultDeps) => {
37
37
  }, limit);
38
38
  if (staleFacts.length > 0) {
39
39
  // Deduplicate: only log if the set of stale fact IDs has changed
40
- const currentIds = staleFacts.map((f) => f.id).sort().join(",");
40
+ const currentIds = staleFacts.map((f) => `${f.destination ?? "default"}:${f.id}`).sort().join(",");
41
41
  if (currentIds === lastStaleIds) {
42
42
  logFn("DEBUG", `Staleness scan: same ${staleFacts.length} fact(s) still stale, skipping duplicate log`);
43
43
  return;
@@ -166,11 +166,12 @@ export const parseClaudeTranscriptLine = (line) => {
166
166
  };
167
167
  export const readNewTranscriptEvents = async (eventsPath, byteOffset, source) => {
168
168
  const fileStat = await stat(eventsPath);
169
- if (fileStat.size <= byteOffset) {
169
+ if (fileStat.size === byteOffset) {
170
170
  return { events: [], newOffset: byteOffset, totalLines: 0 };
171
171
  }
172
+ const startOffset = fileStat.size < byteOffset ? 0 : byteOffset;
172
173
  const buf = await readFile(eventsPath);
173
- const newContent = buf.subarray(byteOffset).toString("utf-8");
174
+ const newContent = buf.subarray(startOffset).toString("utf-8");
174
175
  const events = [];
175
176
  let totalLines = 0;
176
177
  for (const line of newContent.split("\n")) {
@@ -6,6 +6,8 @@ import path from "node:path";
6
6
  import { loadConfig } from "../config.js";
7
7
  export function discoverSessions() {
8
8
  const cfg = loadConfig();
9
+ if (!cfg.watchers.copilot.enabled)
10
+ return [];
9
11
  const baseDir = cfg.watchers.copilot.path;
10
12
  if (!fs.existsSync(baseDir))
11
13
  return [];
@@ -2,6 +2,7 @@ import type { BikkyConfig } from "../config.js";
2
2
  import type { EpisodeSummaryWriteResult } from "./episode-summary.js";
3
3
  import type { QdrantPayload } from "./qdrant.js";
4
4
  import { type RedactionSummary } from "../privacy/redaction.js";
5
+ import { type OperationOrigin } from "../provenance/origin.js";
5
6
  export { buildWorkstreamSummaryMessages } from "../prompts/index.js";
6
7
  export interface WorkspaceScope {
7
8
  workspaceId?: string;
@@ -23,6 +24,7 @@ export interface WorkstreamSummaryPayloadResult {
23
24
  export interface WorkstreamUpdateResult {
24
25
  action: "stored" | "updated" | "skipped";
25
26
  factId?: string;
27
+ destination?: string;
26
28
  workstreamKey?: string;
27
29
  reason?: string;
28
30
  }
@@ -45,6 +47,8 @@ export declare const buildWorkstreamSummaryPayload: (input: {
45
47
  enabled: boolean;
46
48
  redactPii: boolean;
47
49
  };
50
+ config?: BikkyConfig;
51
+ origin?: OperationOrigin;
48
52
  }) => WorkstreamSummaryPayloadResult;
49
53
  export declare const updateWorkstreamSummaries: (input: {
50
54
  episodeResults: EpisodeSummaryWriteResult[];
@@ -11,6 +11,7 @@ import { workstreamSummaryPrompt } from "../prompts/index.js";
11
11
  import { CAPTURE_POLICY_VERSION, CAPTURE_TRIGGERS, DEFAULT_CAPTURE_CONTEXT, PROMPT_VERSIONS, } from "./capture-policy.js";
12
12
  import * as qdrant from "./qdrant.js";
13
13
  import { combineRedactions, redactStorageText, } from "../privacy/redaction.js";
14
+ import { buildOperationOrigin } from "../provenance/origin.js";
14
15
  export { buildWorkstreamSummaryMessages } from "../prompts/index.js";
15
16
  const contentHash = (text) => createHash("sha256").update(`workstream:${text}`).digest("hex");
16
17
  const stripJsonFence = (raw) => raw.trim().replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "").trim();
@@ -39,6 +40,20 @@ export const groupEpisodeResultsByWorkstream = (episodeResults) => {
39
40
  }
40
41
  return grouped;
41
42
  };
43
+ const groupEpisodeResultsByWorkstreamDestination = (episodeResults) => {
44
+ const grouped = new Map();
45
+ for (const result of episodeResults) {
46
+ const workstreamKey = result.workstreamKey?.trim();
47
+ if (!workstreamKey || !result.episodeId)
48
+ continue;
49
+ const destination = result.destination?.trim();
50
+ const groupKey = `${destination ?? ""}::${workstreamKey}`;
51
+ const existing = grouped.get(groupKey) ?? { workstreamKey, destination, results: [] };
52
+ existing.results.push(result);
53
+ grouped.set(groupKey, existing);
54
+ }
55
+ return grouped;
56
+ };
42
57
  export const parseWorkstreamSummaryDraft = (raw) => {
43
58
  const parsed = JSON.parse(stripJsonFence(raw));
44
59
  const contentValue = parsed.content ?? parsed.summary;
@@ -109,6 +124,17 @@ export const buildWorkstreamSummaryPayload = (input) => {
109
124
  ...redactedEntities,
110
125
  ]);
111
126
  const existingPayload = input.existing?.payload ?? {};
127
+ const operationOrigin = input.origin ?? buildOperationOrigin({
128
+ interface: "daemon",
129
+ action: input.existing ? "update" : "create",
130
+ subsystem: "workstream_summary",
131
+ config: input.config,
132
+ metadata: {
133
+ workstream_key: input.workstreamKey,
134
+ source_episode_count: input.sourceEpisodeIds.length,
135
+ ...(input.repo ? { repo: input.repo } : {}),
136
+ },
137
+ });
112
138
  const payload = {
113
139
  ...existingPayload,
114
140
  content: redactedContent.text,
@@ -118,10 +144,10 @@ export const buildWorkstreamSummaryPayload = (input) => {
118
144
  memory_subtype: "workstream",
119
145
  layer: "workstream",
120
146
  ...(input.scope.workspaceId ? { workspace_id: input.scope.workspaceId } : {}),
121
- ...(input.scope.actorId ? { actor_id: input.scope.actorId } : {}),
122
147
  ...(input.repo ? { repo: input.repo } : {}),
148
+ origin: existingPayload.origin ?? operationOrigin,
149
+ ...(input.existing ? { last_operation_origin: operationOrigin } : {}),
123
150
  entities: redactedEntities.map((entity) => entity.text.toLowerCase()),
124
- source: "system",
125
151
  confidence: 1.0,
126
152
  importance: input.draft.importance,
127
153
  content_hash: contentHash(redactedContent.text),
@@ -156,20 +182,22 @@ export const buildWorkstreamSummaryPayload = (input) => {
156
182
  }
157
183
  return { payload, redaction };
158
184
  };
159
- const findExistingWorkstreamSummary = async (workstreamKey, scope, repo) => {
160
- const result = await qdrant.qdrantRequest("POST", `/collections/${qdrant.collection}/points/scroll`, {
185
+ const findExistingWorkstreamSummary = async (workstreamKey, scope, repo, destination) => {
186
+ const collection = qdrant.collectionForDestination(destination);
187
+ const result = await qdrant.qdrantRequest("POST", `/collections/${collection}/points/scroll`, {
161
188
  filter: buildWorkstreamSummaryFilter(workstreamKey, scope, repo),
162
189
  limit: 1,
163
190
  with_payload: true,
164
- });
191
+ }, destination);
165
192
  return result.result?.points?.[0] ?? null;
166
193
  };
167
- const loadEpisodeSummaries = async (workstreamKey, scope) => {
168
- const result = await qdrant.qdrantRequest("POST", `/collections/${qdrant.collection}/points/scroll`, {
194
+ const loadEpisodeSummaries = async (workstreamKey, scope, destination) => {
195
+ const collection = qdrant.collectionForDestination(destination);
196
+ const result = await qdrant.qdrantRequest("POST", `/collections/${collection}/points/scroll`, {
169
197
  filter: buildWorkstreamEpisodeFilter(workstreamKey, scope),
170
198
  limit: CAPTURE_TRIGGERS.workstreamSummary.maxEpisodesPerUpdate,
171
199
  with_payload: true,
172
- });
200
+ }, destination);
173
201
  return result.result?.points ?? [];
174
202
  };
175
203
  const summarizeWorkstream = async (input) => {
@@ -183,20 +211,32 @@ const summarizeWorkstream = async (input) => {
183
211
  return parseWorkstreamSummaryDraft(result);
184
212
  };
185
213
  export const updateWorkstreamSummaries = async (input) => {
186
- const grouped = groupEpisodeResultsByWorkstream(input.episodeResults);
214
+ const grouped = groupEpisodeResultsByWorkstreamDestination(input.episodeResults);
187
215
  if (grouped.size === 0)
188
216
  return [{ action: "skipped", reason: "no_workstream_keys" }];
189
217
  const results = [];
190
- for (const [workstreamKey] of grouped) {
191
- const episodes = await loadEpisodeSummaries(workstreamKey, input.scope);
218
+ for (const { workstreamKey, destination: groupDestination } of grouped.values()) {
219
+ const destination = groupDestination
220
+ ?? qdrant.resolveDestination({
221
+ content: workstreamKey,
222
+ entities: [workstreamKey],
223
+ metadata: {
224
+ workstream_key: workstreamKey,
225
+ memory_subtype: "workstream",
226
+ kind: "summary",
227
+ origin_interface: "daemon",
228
+ origin_agent_type: "daemon",
229
+ },
230
+ }).name;
231
+ const episodes = await loadEpisodeSummaries(workstreamKey, input.scope, destination);
192
232
  const episodeSummaries = episodes
193
233
  .map((episode) => episode.payload?.content)
194
234
  .filter((content) => Boolean(content?.trim()));
195
235
  if (episodeSummaries.length < CAPTURE_TRIGGERS.workstreamSummary.minEpisodeCount) {
196
- results.push({ action: "skipped", reason: "not_enough_episodes", workstreamKey });
236
+ results.push({ action: "skipped", reason: "not_enough_episodes", workstreamKey, destination });
197
237
  continue;
198
238
  }
199
- const existing = await findExistingWorkstreamSummary(workstreamKey, input.scope, null);
239
+ const existing = await findExistingWorkstreamSummary(workstreamKey, input.scope, null, destination);
200
240
  const draft = await summarizeWorkstream({
201
241
  workstreamKey,
202
242
  existingSummary: existing?.payload?.content ?? null,
@@ -211,6 +251,7 @@ export const updateWorkstreamSummaries = async (input) => {
211
251
  existing,
212
252
  sourceEpisodeIds: episodes.map((episode) => episode.payload?.episode_id ?? episode.id).filter(Boolean),
213
253
  repo: null,
254
+ config: input.config,
214
255
  redactionOptions: {
215
256
  enabled: true,
216
257
  redactPii: false,
@@ -218,10 +259,11 @@ export const updateWorkstreamSummaries = async (input) => {
218
259
  });
219
260
  const vector = await qdrant.embed(String(payload.content));
220
261
  const factId = existing?.id ?? randomUUID();
221
- await qdrant.qdrantRequest("PUT", `/collections/${qdrant.collection}/points`, {
262
+ const collection = qdrant.collectionForDestination(destination);
263
+ await qdrant.qdrantRequest("PUT", `/collections/${collection}/points`, {
222
264
  points: [{ id: factId, vector, payload }],
223
- });
224
- results.push({ action: existing ? "updated" : "stored", factId, workstreamKey });
265
+ }, destination);
266
+ results.push({ action: existing ? "updated" : "stored", factId, workstreamKey, destination });
225
267
  }
226
268
  return results;
227
269
  };
package/dist/install.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Write MCP config entries for Copilot and/or Claude Code.
3
3
  */
4
+ import { type OriginIdentity } from "./provenance/origin.js";
4
5
  export interface InstallOptions {
5
6
  homeDir?: string;
6
7
  /**
@@ -8,6 +9,16 @@ export interface InstallOptions {
8
9
  * user config file directly.
9
10
  */
10
11
  claudeCommand?: string | null;
12
+ /**
13
+ * Defaults to true. Set to false in tests or advanced flows that only want
14
+ * MCP config files and do not want ~/.bikky/config.json touched.
15
+ */
16
+ provisionIdentity?: boolean;
17
+ env?: NodeJS.ProcessEnv;
18
+ cwd?: string;
19
+ hostname?: string;
20
+ shellUsername?: string | null;
11
21
  }
22
+ export declare function provisionUserIdentityConfig(options?: InstallOptions): OriginIdentity | null;
12
23
  export declare function writeInstallConfig(options?: InstallOptions): Promise<void>;
13
24
  //# sourceMappingURL=install.d.ts.map
package/dist/install.js CHANGED
@@ -5,6 +5,7 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import os from "node:os";
7
7
  import { spawnSync } from "node:child_process";
8
+ import { inferUserIdentity } from "./provenance/origin.js";
8
9
  const SERVER_NAME = "bikky";
9
10
  function readJsonConfig(filePath) {
10
11
  if (!fs.existsSync(filePath))
@@ -57,6 +58,40 @@ function writeClaudeCodeUserConfig(homeDir, entry) {
57
58
  writeJsonConfig(claudeConfigPath, config);
58
59
  console.log(`āœ… Written to ${claudeConfigPath}`);
59
60
  }
61
+ function bikkyConfigPath(homeDir, explicitHomeDir, env = process.env) {
62
+ const bikkyDir = explicitHomeDir ? path.join(homeDir, ".bikky") : env.BIKKY_HOME ?? path.join(homeDir, ".bikky");
63
+ return path.join(bikkyDir, "config.json");
64
+ }
65
+ function hasConfiguredValue(value) {
66
+ return typeof value === "string" && value.trim().length > 0;
67
+ }
68
+ export function provisionUserIdentityConfig(options = {}) {
69
+ const homeDir = options.homeDir ?? os.homedir();
70
+ const configPath = bikkyConfigPath(homeDir, options.homeDir !== undefined, options.env);
71
+ const config = readJsonConfig(configPath);
72
+ const existingIdentity = config.identity && typeof config.identity === "object" && !Array.isArray(config.identity)
73
+ ? config.identity
74
+ : {};
75
+ const hasUserId = hasConfiguredValue(existingIdentity.user_id);
76
+ const hasUserName = hasConfiguredValue(existingIdentity.user_name);
77
+ if (hasUserId && hasUserName)
78
+ return null;
79
+ const inferred = inferUserIdentity({
80
+ env: options.env,
81
+ cwd: options.cwd,
82
+ hostname: options.hostname,
83
+ shellUsername: options.shellUsername,
84
+ });
85
+ const nextIdentity = { ...existingIdentity };
86
+ if (!hasUserId)
87
+ nextIdentity.user_id = inferred.id;
88
+ if (!hasUserName)
89
+ nextIdentity.user_name = inferred.name;
90
+ config.identity = nextIdentity;
91
+ writeJsonConfig(configPath, config);
92
+ console.log(`āœ… Provisioned bikky user identity in ${configPath}`);
93
+ return inferred;
94
+ }
60
95
  export async function writeInstallConfig(options = {}) {
61
96
  const homeDir = options.homeDir ?? os.homedir();
62
97
  const entry = {
@@ -72,6 +107,9 @@ export async function writeInstallConfig(options = {}) {
72
107
  if (!registeredWithClaudeCli) {
73
108
  writeClaudeCodeUserConfig(homeDir, entry);
74
109
  }
110
+ if (options.provisionIdentity !== false) {
111
+ provisionUserIdentityConfig(options);
112
+ }
75
113
  console.log("\n🧠 bikky is now registered. Restart your editor to activate.");
76
114
  }
77
115
  //# sourceMappingURL=install.js.map
@@ -19,6 +19,7 @@
19
19
  import "./providers/index.js";
20
20
  import { getEmbeddingProvider } from "./registry.js";
21
21
  import { EmbeddingDimensionMismatchError } from "../errors.js";
22
+ import { firstNonEmptyString } from "../util.js";
22
23
  export { registerEmbeddingProvider, getEmbeddingProvider, listEmbeddingProviders, } from "./registry.js";
23
24
  const DEFAULT_TIMEOUT_MS = 30_000;
24
25
  const DEFAULT_RETRIES = 2;
@@ -32,7 +33,7 @@ export function initEmbedding(input) {
32
33
  provider: provider.name,
33
34
  model: input.model ?? provider.defaults.model,
34
35
  dimensions: input.dimensions ?? provider.defaults.dimensions,
35
- baseUrl: (input.baseUrl ?? provider.defaults.baseUrl ?? "").replace(/\/+$/, ""),
36
+ baseUrl: (firstNonEmptyString(input.baseUrl, provider.defaults.baseUrl) ?? "").replace(/\/+$/, ""),
36
37
  apiKey: input.apiKey ?? null,
37
38
  extra: input.extra ?? {},
38
39
  timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
@@ -10,12 +10,18 @@ export const openaiEmbeddingProvider = {
10
10
  browserCompatible: true,
11
11
  defaults: {
12
12
  model: "text-embedding-3-small",
13
- dimensions: 1536,
13
+ dimensions: 1024,
14
14
  baseUrl: "https://api.openai.com",
15
15
  },
16
16
  async embed(text, cfg) {
17
17
  if (!cfg.apiKey)
18
18
  throw new Error("Embedding failed [openai]: api key not configured");
19
+ const body = { model: cfg.model, input: text };
20
+ // The `dimensions` parameter (Matryoshka truncation) is only supported by
21
+ // the text-embedding-3-* family. Older models (e.g. ada-002) reject it.
22
+ if (cfg.dimensions && /text-embedding-3/.test(cfg.model)) {
23
+ body.dimensions = cfg.dimensions;
24
+ }
19
25
  const resp = await resilientFetch({
20
26
  url: `${cfg.baseUrl}/v1/embeddings`,
21
27
  init: {
@@ -24,7 +30,7 @@ export const openaiEmbeddingProvider = {
24
30
  "Content-Type": "application/json",
25
31
  Authorization: `Bearer ${cfg.apiKey}`,
26
32
  },
27
- body: JSON.stringify({ model: cfg.model, input: text }),
33
+ body: JSON.stringify(body),
28
34
  },
29
35
  timeoutMs: cfg.timeoutMs,
30
36
  retries: cfg.retries,
@@ -19,7 +19,7 @@ export const portkeyEmbeddingProvider = {
19
19
  browserCompatible: true,
20
20
  defaults: {
21
21
  model: "@openai/text-embedding-3-small",
22
- dimensions: 1536,
22
+ dimensions: 1024,
23
23
  baseUrl: "https://api.portkey.ai",
24
24
  },
25
25
  async embed(text, cfg) {
@@ -33,12 +33,19 @@ export const portkeyEmbeddingProvider = {
33
33
  headers["x-portkey-virtual-key"] = cfg.extra.virtual_key;
34
34
  if (cfg.extra.config_id)
35
35
  headers["x-portkey-config"] = cfg.extra.config_id;
36
+ const body = { model: cfg.model, input: text };
37
+ // The `dimensions` parameter (Matryoshka truncation) is supported by the
38
+ // OpenAI text-embedding-3-* family. Forward it when the model name matches
39
+ // so users get the configured vector size instead of the provider native.
40
+ if (cfg.dimensions && /text-embedding-3/.test(cfg.model)) {
41
+ body.dimensions = cfg.dimensions;
42
+ }
36
43
  const resp = await resilientFetch({
37
44
  url: `${cfg.baseUrl}/v1/embeddings`,
38
45
  init: {
39
46
  method: "POST",
40
47
  headers,
41
- body: JSON.stringify({ model: cfg.model, input: text }),
48
+ body: JSON.stringify(body),
42
49
  },
43
50
  timeoutMs: cfg.timeoutMs,
44
51
  retries: cfg.retries,
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import "./providers/index.js";
14
14
  import { getInferenceProvider, listInferenceProviders, registerInferenceProvider, } from "./registry.js";
15
+ import { firstNonEmptyString } from "../util.js";
15
16
  import { estimateTokens, writeTelemetry } from "../telemetry.js";
16
17
  const DEFAULT_TIMEOUT_MS = 30_000;
17
18
  const DEFAULT_RETRIES = 2;
@@ -27,7 +28,7 @@ export function initLLM(opts) {
27
28
  resolved = {
28
29
  provider: provider.name,
29
30
  model: opts.config.model ?? provider.defaults.model,
30
- baseUrl: (opts.config.baseUrl ?? provider.defaults.baseUrl ?? "").replace(/\/+$/, ""),
31
+ baseUrl: (firstNonEmptyString(opts.config.baseUrl, provider.defaults.baseUrl) ?? "").replace(/\/+$/, ""),
31
32
  apiKey: opts.config.apiKey ?? null,
32
33
  fallback: opts.config.fallback ?? null,
33
34
  extra: opts.config.extra ?? {},
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Tiny helpers shared by the embedding and inference layers.
3
+ */
4
+ /**
5
+ * Returns the first argument that is a non-empty trimmed string, or undefined.
6
+ *
7
+ * Useful when a value may come from a config layer that normalises absent
8
+ * fields to `""` instead of leaving them undefined — `??` alone won't fall
9
+ * through `""`, but this will. See issue #131.
10
+ */
11
+ export declare function firstNonEmptyString(...candidates: Array<string | undefined | null>): string | undefined;
12
+ //# sourceMappingURL=util.d.ts.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Tiny helpers shared by the embedding and inference layers.
3
+ */
4
+ /**
5
+ * Returns the first argument that is a non-empty trimmed string, or undefined.
6
+ *
7
+ * Useful when a value may come from a config layer that normalises absent
8
+ * fields to `""` instead of leaving them undefined — `??` alone won't fall
9
+ * through `""`, but this will. See issue #131.
10
+ */
11
+ export function firstNonEmptyString(...candidates) {
12
+ for (const c of candidates) {
13
+ if (typeof c === "string" && c.trim().length > 0)
14
+ return c;
15
+ }
16
+ return undefined;
17
+ }
18
+ //# sourceMappingURL=util.js.map