om-memory-system 4.24.0-next.147 → 4.25.1

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 (57) hide show
  1. package/dist/adapters/opencode/user-prompt.js +6 -1
  2. package/dist/config.d.ts +9 -0
  3. package/dist/config.js +5 -44
  4. package/dist/core/retrieval.js +1 -1
  5. package/dist/services/chat/chat-answer.d.ts +56 -0
  6. package/dist/services/chat/chat-answer.js +88 -0
  7. package/dist/services/chat/chat-plan.d.ts +60 -0
  8. package/dist/services/chat/chat-plan.js +92 -0
  9. package/dist/services/chat/chat-retrieve.d.ts +36 -0
  10. package/dist/services/chat/chat-retrieve.js +82 -0
  11. package/dist/services/chat/chat-routes.d.ts +12 -0
  12. package/dist/services/chat/chat-routes.js +145 -0
  13. package/dist/services/chat/chat-runtime.d.ts +21 -0
  14. package/dist/services/chat/chat-runtime.js +104 -0
  15. package/dist/services/chat/chat-service.d.ts +42 -0
  16. package/dist/services/chat/chat-service.js +130 -0
  17. package/dist/services/chat/chat-status.d.ts +16 -0
  18. package/dist/services/chat/chat-status.js +23 -0
  19. package/dist/services/chat/chat-store.d.ts +60 -0
  20. package/dist/services/chat/chat-store.js +352 -0
  21. package/dist/services/chat/chat-types.d.ts +59 -0
  22. package/dist/services/chat/chat-types.js +8 -0
  23. package/dist/services/client.d.ts +1 -0
  24. package/dist/services/client.js +8 -3
  25. package/dist/services/context.d.ts +3 -1
  26. package/dist/services/context.js +4 -2
  27. package/dist/services/global-config-writer.js +8 -1
  28. package/dist/services/privacy.js +2 -2
  29. package/dist/services/settings-snapshot.js +1 -0
  30. package/dist/services/turso/exact-scan.d.ts +6 -0
  31. package/dist/services/turso/exact-scan.js +23 -0
  32. package/dist/services/turso/keyword-search.d.ts +35 -0
  33. package/dist/services/turso/keyword-search.js +153 -0
  34. package/dist/services/turso/rank-fusion.d.ts +34 -0
  35. package/dist/services/turso/rank-fusion.js +45 -0
  36. package/dist/services/turso/tag-match.d.ts +7 -0
  37. package/dist/services/turso/tag-match.js +29 -0
  38. package/dist/services/turso/types.d.ts +2 -0
  39. package/dist/services/turso/vector-search.d.ts +3 -3
  40. package/dist/services/turso/vector-search.js +23 -39
  41. package/dist/services/user-profile/profile-context.d.ts +6 -1
  42. package/dist/services/user-profile/profile-context.js +43 -7
  43. package/dist/services/user-profile/profile-relevance.d.ts +37 -0
  44. package/dist/services/user-profile/profile-relevance.js +75 -0
  45. package/dist/services/web-route-modules.d.ts +8 -0
  46. package/dist/services/web-route-modules.js +15 -0
  47. package/dist/services/web-server.js +5 -4
  48. package/dist/utils/keyword-boost.d.ts +17 -0
  49. package/dist/utils/keyword-boost.js +32 -0
  50. package/dist/utils/reranker-settings.d.ts +13 -0
  51. package/dist/utils/reranker-settings.js +48 -0
  52. package/dist/web/assets/index-CjfE2byA.js +126 -0
  53. package/dist/web/assets/index-SK0gAreR.css +2 -0
  54. package/dist/web/index.html +2 -2
  55. package/package.json +1 -1
  56. package/dist/web/assets/index-B_2GCxJJ.js +0 -127
  57. package/dist/web/assets/index-JAZ-QY-Z.css +0 -2
@@ -1,6 +1,7 @@
1
1
  import { isStructuredSummaryPromptMessage } from "../../core/internal-prompt.js";
2
2
  import { isInternalStructuredSession } from "./opencode-provider.js";
3
3
  import { userPromptManager } from "../../services/user-prompt/user-prompt-manager.js";
4
+ import { isFullyPrivate, stripPrivateContent } from "../../services/privacy.js";
4
5
  export { isStructuredSummaryPromptMessage };
5
6
  /** True when a prompt is omms's own internal traffic and must not be recorded or searched. */
6
7
  export function isInternalPrompt(sessionID, userMessage) {
@@ -14,6 +15,10 @@ export function isInternalPrompt(sessionID, userMessage) {
14
15
  export async function recordUserPrompt(sessionID, messageID, directory, userMessage) {
15
16
  if (!userMessage.trim() || isInternalPrompt(sessionID, userMessage))
16
17
  return false;
17
- await userPromptManager.savePrompt(sessionID, messageID, directory, userMessage);
18
+ // Profile learning quotes saved prompts as evidence, so <private> text must
19
+ // never reach the store (the Claude Code hook strips it the same way).
20
+ if (isFullyPrivate(userMessage))
21
+ return false;
22
+ await userPromptManager.savePrompt(sessionID, messageID, directory, stripPrivateContent(userMessage));
18
23
  return true;
19
24
  }
package/dist/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isPlaceholderApiKey } from "./services/ai/api-key-placeholder.js";
2
+ import { type KeywordMode } from "./utils/keyword-boost.js";
2
3
  interface OmmsConfig {
3
4
  storagePath?: string;
4
5
  userEmailOverride?: string;
@@ -20,6 +21,10 @@ interface OmmsConfig {
20
21
  rerankerModel?: string;
21
22
  rerankerApiKey?: string;
22
23
  rerankerTimeoutMs?: number;
24
+ /** Extra similarity for a memory that holds a word of the prompt, 0 to 0.5. 0 turns it off. */
25
+ keywordBoost?: number;
26
+ /** How word matches join the results: `boost` (default) or `rrf` (experimental). */
27
+ keywordMode?: KeywordMode;
23
28
  /** Shared budget for automatic memory context in approximate tokens (ceil(UTF-8 bytes / 4)). */
24
29
  retrievalMaxTokens?: number;
25
30
  maxProfileItems?: number;
@@ -134,6 +139,8 @@ declare function buildConfig(fileConfig: OmmsConfig): {
134
139
  rerankerModel: string | undefined;
135
140
  rerankerApiKey: string | undefined;
136
141
  rerankerTimeoutMs: number;
142
+ keywordBoost: number;
143
+ keywordMode: KeywordMode;
137
144
  maxMemories: number;
138
145
  retrievalMaxTokens: number;
139
146
  maxProfileItems: number;
@@ -237,6 +244,8 @@ export declare let CONFIG: {
237
244
  rerankerModel: string | undefined;
238
245
  rerankerApiKey: string | undefined;
239
246
  rerankerTimeoutMs: number;
247
+ keywordBoost: number;
248
+ keywordMode: KeywordMode;
240
249
  maxMemories: number;
241
250
  retrievalMaxTokens: number;
242
251
  maxProfileItems: number;
package/dist/config.js CHANGED
@@ -11,7 +11,9 @@ import { parseBackfillModel } from "./importer/backfill-model.js";
11
11
  import { parseIgnoredDirectories, parseImportPathMaps } from "./importer/import-path-maps.js";
12
12
  import { checkedMemoryLimit, MEMORY_LIMITS } from "./utils/memory-limits.js";
13
13
  import { defaultSimilarityThreshold } from "./utils/threshold-default.js";
14
- import { RERANKER_TIMEOUT_DEFAULT_MS, RERANKER_TIMEOUT_MAX_MS, RERANKER_TIMEOUT_MIN_MS, } from "./services/request-timeouts.js";
14
+ import { RERANKER_TIMEOUT_DEFAULT_MS } from "./services/request-timeouts.js";
15
+ import { checkedRerankerSettings } from "./utils/reranker-settings.js";
16
+ import { checkedKeywordBoost, checkedKeywordMode, } from "./utils/keyword-boost.js";
15
17
  import { resolveDefaultStoragePath, runLegacyStoreMigration, legacyMigrationPaths, } from "./services/legacy-migration.js";
16
18
  const OMMS_CONFIG_DIR = join(homedir(), ".config", "omms");
17
19
  const DATA_DIR = join(homedir(), ".omms");
@@ -245,49 +247,6 @@ export function normalizeCaptureRetryRetentionHours(value) {
245
247
  }
246
248
  return Math.min(720, Math.max(0, Math.floor(value)));
247
249
  }
248
- /** The reranker key must be a reference, never a literal secret in the file. */
249
- function isRerankerKeyReference(value) {
250
- return /^(env:\/\/[A-Za-z_][A-Za-z0-9_]*|file:\/\/.+)$/.test(value.trim());
251
- }
252
- /**
253
- * Validate the five opt-in reranker keys when present (openspec change
254
- * retrieval-threshold-and-reranker). Messages name the setting and its
255
- * accepted values only; the value is never quoted back.
256
- */
257
- function checkedRerankerSettings(fileConfig) {
258
- const invalid = (setting, accepted) => new Error(`Invalid ${setting} config (accepted: ${accepted})`);
259
- if (fileConfig.rerankerEnabled !== undefined && typeof fileConfig.rerankerEnabled !== "boolean") {
260
- throw invalid("rerankerEnabled", "boolean");
261
- }
262
- for (const key of ["rerankerApiUrl", "rerankerModel"]) {
263
- const value = fileConfig[key];
264
- if (value === undefined)
265
- continue;
266
- if (typeof value !== "string" || value.trim().length === 0) {
267
- throw invalid(key, "a non-empty string");
268
- }
269
- }
270
- if (fileConfig.rerankerApiUrl !== undefined &&
271
- !/^https?:\/\/\S+$/.test(fileConfig.rerankerApiUrl.trim())) {
272
- throw invalid("rerankerApiUrl", "an http:// or https:// URL");
273
- }
274
- if (fileConfig.rerankerApiKey !== undefined) {
275
- if (typeof fileConfig.rerankerApiKey !== "string" ||
276
- !isRerankerKeyReference(fileConfig.rerankerApiKey)) {
277
- throw invalid("rerankerApiKey", "an env:// or file:// secret reference");
278
- }
279
- }
280
- if (fileConfig.rerankerTimeoutMs !== undefined) {
281
- const value = fileConfig.rerankerTimeoutMs;
282
- const ok = typeof value === "number" &&
283
- Number.isSafeInteger(value) &&
284
- value >= RERANKER_TIMEOUT_MIN_MS &&
285
- value <= RERANKER_TIMEOUT_MAX_MS;
286
- if (!ok) {
287
- throw invalid("rerankerTimeoutMs", `integers from ${RERANKER_TIMEOUT_MIN_MS} to ${RERANKER_TIMEOUT_MAX_MS}`);
288
- }
289
- }
290
- }
291
250
  export function normalizeAutoCleanupRetentionDays(value) {
292
251
  if (!Number.isSafeInteger(value) || value <= 0) {
293
252
  throw new Error(`Invalid autoCleanupRetentionDays config: ${value}`);
@@ -364,6 +323,8 @@ function buildConfig(fileConfig) {
364
323
  rerankerModel: fileConfig.rerankerModel?.trim() || undefined,
365
324
  rerankerApiKey: fileConfig.rerankerApiKey,
366
325
  rerankerTimeoutMs: fileConfig.rerankerTimeoutMs ?? DEFAULTS.rerankerTimeoutMs,
326
+ keywordBoost: checkedKeywordBoost(fileConfig.keywordBoost),
327
+ keywordMode: checkedKeywordMode(fileConfig.keywordMode),
367
328
  maxMemories,
368
329
  retrievalMaxTokens,
369
330
  maxProfileItems: fileConfig.maxProfileItems ?? DEFAULTS.maxProfileItems,
@@ -26,7 +26,7 @@ export async function buildRetrievalSection(prompt, directory, sessionId, budget
26
26
  : search.results;
27
27
  if (results.length === 0)
28
28
  return null;
29
- const memoryContext = await formatContextForPrompt(tags.user.userEmail || null, { results }, budget);
29
+ const memoryContext = await formatContextForPrompt(tags.user.userEmail || null, { results }, budget, search.queryVector);
30
30
  return memoryContext || null;
31
31
  }
32
32
  /**
@@ -0,0 +1,56 @@
1
+ import type { RetrievedSources } from "./chat-retrieve.js";
2
+ import type { ChatRole, ChatSource } from "./chat-types.js";
3
+ /** Each source is cut to this many characters in the prompt. Stored snapshots keep it whole. */
4
+ export declare const MAX_SOURCE_CHARS = 2000;
5
+ export declare const ANSWER_TOOL: {
6
+ readonly type: "function";
7
+ readonly function: {
8
+ readonly name: "answer";
9
+ readonly description: "Answer the question from the numbered sources";
10
+ readonly parameters: {
11
+ readonly type: "object";
12
+ readonly properties: {
13
+ readonly answer: {
14
+ readonly type: "string";
15
+ readonly description: "The answer, with a marker such as [S1] or [P2] after each claim";
16
+ };
17
+ readonly citations: {
18
+ readonly type: "array";
19
+ readonly items: {
20
+ readonly type: "string";
21
+ };
22
+ };
23
+ readonly insufficient: {
24
+ readonly type: "boolean";
25
+ readonly description: "True when the sources do not answer the question";
26
+ };
27
+ };
28
+ readonly required: readonly ["answer", "citations", "insufficient"];
29
+ };
30
+ };
31
+ };
32
+ export declare function numberSources(found: RetrievedSources): ChatSource[];
33
+ export declare function buildAnswerPrompt(question: string, turns: Array<{
34
+ role: ChatRole;
35
+ content: string;
36
+ }>, sources: ChatSource[]): {
37
+ system: string;
38
+ user: string;
39
+ };
40
+ /**
41
+ * Removes `[S9]`-style markers that name a source that was not sent, and lists
42
+ * the valid ones in order of first use. Other bracketed text is left alone.
43
+ */
44
+ export declare function validateCitations(answer: string, sentLabels: string[]): {
45
+ text: string;
46
+ cited: string[];
47
+ };
48
+ export interface FinalAnswer {
49
+ text: string;
50
+ cited: string[];
51
+ insufficient: boolean;
52
+ /** True when a full answer cites no valid source; the page shows a note. */
53
+ uncited: boolean;
54
+ }
55
+ /** The checked answer, or null when the reply holds no answer text. */
56
+ export declare function finaliseAnswer(raw: unknown, sources: ChatSource[]): FinalAnswer | null;
@@ -0,0 +1,88 @@
1
+ import { MAX_PLAN_TURNS, MAX_TURN_CHARS } from "./chat-plan.js";
2
+ /** Each source is cut to this many characters in the prompt. Stored snapshots keep it whole. */
3
+ export const MAX_SOURCE_CHARS = 2000;
4
+ export const ANSWER_TOOL = {
5
+ type: "function",
6
+ function: {
7
+ name: "answer",
8
+ description: "Answer the question from the numbered sources",
9
+ parameters: {
10
+ type: "object",
11
+ properties: {
12
+ answer: {
13
+ type: "string",
14
+ description: "The answer, with a marker such as [S1] or [P2] after each claim",
15
+ },
16
+ citations: { type: "array", items: { type: "string" } },
17
+ insufficient: {
18
+ type: "boolean",
19
+ description: "True when the sources do not answer the question",
20
+ },
21
+ },
22
+ required: ["answer", "citations", "insufficient"],
23
+ },
24
+ },
25
+ };
26
+ const SYSTEM = [
27
+ "You answer questions about one user's saved memories of past coding sessions and their profile.",
28
+ "Use only the numbered sources given. Do not use outside knowledge.",
29
+ "Put the source marker, such as [S1] or [P2], after each claim it supports.",
30
+ "When the sources do not answer the question, say so briefly and set insufficient to true.",
31
+ "Write plain, short British English. Call the answer tool.",
32
+ ].join(" ");
33
+ export function numberSources(found) {
34
+ return [
35
+ ...found.memories.map((source, i) => ({ ...source, label: `S${i + 1}` })),
36
+ ...found.profile.map((source, i) => ({ ...source, label: `P${i + 1}` })),
37
+ ];
38
+ }
39
+ function describeSource(source) {
40
+ const meta = source.kind === "memory"
41
+ ? [source.projectName, source.tags?.length ? `tags: ${source.tags.join(", ")}` : ""]
42
+ : [source.section, source.category];
43
+ const header = meta.filter(Boolean).join("; ");
44
+ return `[${source.label}] ${source.text.slice(0, MAX_SOURCE_CHARS)}${header ? `\n(${header})` : ""}`;
45
+ }
46
+ export function buildAnswerPrompt(question, turns, sources) {
47
+ const history = turns
48
+ .slice(-MAX_PLAN_TURNS)
49
+ .map((turn) => `${turn.role}: ${turn.content.slice(0, MAX_TURN_CHARS)}`);
50
+ const user = [
51
+ history.length ? `Earlier turns:\n${history.join("\n")}` : "",
52
+ `Sources:\n${sources.map(describeSource).join("\n\n")}`,
53
+ `Question: ${question}`,
54
+ ]
55
+ .filter(Boolean)
56
+ .join("\n\n");
57
+ return { system: SYSTEM, user };
58
+ }
59
+ const MARKER = /\s*\[([SP]\d+)\]/g;
60
+ /**
61
+ * Removes `[S9]`-style markers that name a source that was not sent, and lists
62
+ * the valid ones in order of first use. Other bracketed text is left alone.
63
+ */
64
+ export function validateCitations(answer, sentLabels) {
65
+ const sent = new Set(sentLabels);
66
+ const cited = [];
67
+ const text = answer
68
+ .replace(MARKER, (match, label) => {
69
+ if (!sent.has(label))
70
+ return "";
71
+ if (!cited.includes(label))
72
+ cited.push(label);
73
+ return match;
74
+ })
75
+ .trim();
76
+ return { text, cited };
77
+ }
78
+ /** The checked answer, or null when the reply holds no answer text. */
79
+ export function finaliseAnswer(raw, sources) {
80
+ if (!raw || typeof raw !== "object")
81
+ return null;
82
+ const reply = raw;
83
+ if (typeof reply.answer !== "string" || !reply.answer.trim())
84
+ return null;
85
+ const insufficient = reply.insufficient === true;
86
+ const { text, cited } = validateCitations(reply.answer, sources.map((source) => source.label));
87
+ return { text, cited, insufficient, uncited: !insufficient && cited.length === 0 };
88
+ }
@@ -0,0 +1,60 @@
1
+ import type { ChatPlanRecord, ChatRole } from "./chat-types.js";
2
+ export declare const MAX_PLAN_TAGS = 200;
3
+ export declare const MAX_PLAN_TURNS = 6;
4
+ export declare const MAX_TURN_CHARS = 500;
5
+ export declare const MAX_PLAN_QUERIES = 3;
6
+ export interface PlanInput {
7
+ question: string;
8
+ turns: Array<{
9
+ role: ChatRole;
10
+ content: string;
11
+ }>;
12
+ tags: Array<{
13
+ keyword: string;
14
+ count: number;
15
+ }>;
16
+ categories: string[];
17
+ }
18
+ export declare const PLAN_TOOL: {
19
+ readonly type: "function";
20
+ readonly function: {
21
+ readonly name: "plan_search";
22
+ readonly description: "Choose how to search the user's stored memories and profile";
23
+ readonly parameters: {
24
+ readonly type: "object";
25
+ readonly properties: {
26
+ readonly queries: {
27
+ readonly type: "array";
28
+ readonly items: {
29
+ readonly type: "string";
30
+ };
31
+ readonly description: "1 to 3 short search phrases";
32
+ };
33
+ readonly tags: {
34
+ readonly type: "array";
35
+ readonly items: {
36
+ readonly type: "string";
37
+ };
38
+ readonly description: "Tags from the given list that match the question";
39
+ };
40
+ readonly profileCategories: {
41
+ readonly type: "array";
42
+ readonly items: {
43
+ readonly type: "string";
44
+ };
45
+ readonly description: "Profile categories from the given list that match the question";
46
+ };
47
+ };
48
+ readonly required: readonly ["queries", "tags", "profileCategories"];
49
+ };
50
+ };
51
+ };
52
+ export declare function buildPlanPrompt(input: PlanInput): {
53
+ system: string;
54
+ user: string;
55
+ };
56
+ /**
57
+ * The model's plan with unknown tags and categories removed. A missing or
58
+ * broken plan searches with the question alone and is marked skipped.
59
+ */
60
+ export declare function validatePlan(raw: unknown, input: PlanInput): ChatPlanRecord;
@@ -0,0 +1,92 @@
1
+ export const MAX_PLAN_TAGS = 200;
2
+ export const MAX_PLAN_TURNS = 6;
3
+ export const MAX_TURN_CHARS = 500;
4
+ export const MAX_PLAN_QUERIES = 3;
5
+ export const PLAN_TOOL = {
6
+ type: "function",
7
+ function: {
8
+ name: "plan_search",
9
+ description: "Choose how to search the user's stored memories and profile",
10
+ parameters: {
11
+ type: "object",
12
+ properties: {
13
+ queries: {
14
+ type: "array",
15
+ items: { type: "string" },
16
+ description: "1 to 3 short search phrases",
17
+ },
18
+ tags: {
19
+ type: "array",
20
+ items: { type: "string" },
21
+ description: "Tags from the given list that match the question",
22
+ },
23
+ profileCategories: {
24
+ type: "array",
25
+ items: { type: "string" },
26
+ description: "Profile categories from the given list that match the question",
27
+ },
28
+ },
29
+ required: ["queries", "tags", "profileCategories"],
30
+ },
31
+ },
32
+ };
33
+ const SYSTEM = [
34
+ "You plan a search over one user's saved memories of past coding sessions and their profile.",
35
+ "Return 1 to 3 short search phrases that would find memories answering the question.",
36
+ "Resolve follow-up questions with the earlier turns, so each phrase stands alone.",
37
+ "Pick tags and profile categories only from the lists given. Pick none when none fit.",
38
+ "Call plan_search. Do not answer the question.",
39
+ ].join(" ");
40
+ function topTags(tags) {
41
+ return [...tags].sort((a, b) => b.count - a.count).slice(0, MAX_PLAN_TAGS);
42
+ }
43
+ export function buildPlanPrompt(input) {
44
+ const turns = input.turns
45
+ .slice(-MAX_PLAN_TURNS)
46
+ .map((turn) => `${turn.role}: ${turn.content.slice(0, MAX_TURN_CHARS)}`);
47
+ const tags = topTags(input.tags).map((tag) => `${tag.keyword} (${tag.count})`);
48
+ const categories = [...new Set(input.categories)];
49
+ const user = [
50
+ turns.length ? `Earlier turns:\n${turns.join("\n")}` : "Earlier turns: none",
51
+ `Tags (count): ${tags.length ? tags.join(", ") : "none"}`,
52
+ `Profile categories: ${categories.length ? categories.join(", ") : "none"}`,
53
+ `Question: ${input.question}`,
54
+ ].join("\n\n");
55
+ return { system: SYSTEM, user };
56
+ }
57
+ function strings(value) {
58
+ if (!Array.isArray(value))
59
+ return [];
60
+ return value
61
+ .filter((entry) => typeof entry === "string")
62
+ .map((entry) => entry.trim())
63
+ .filter(Boolean);
64
+ }
65
+ /** Keep the picks that exist in `allowed`, ignoring case, in the stored spelling. */
66
+ function known(picks, allowed) {
67
+ const byKey = new Map(allowed.map((value) => [value.toLowerCase(), value]));
68
+ const out = [];
69
+ for (const pick of picks) {
70
+ const match = byKey.get(pick.toLowerCase());
71
+ if (match && !out.includes(match))
72
+ out.push(match);
73
+ }
74
+ return out;
75
+ }
76
+ /**
77
+ * The model's plan with unknown tags and categories removed. A missing or
78
+ * broken plan searches with the question alone and is marked skipped.
79
+ */
80
+ export function validatePlan(raw, input) {
81
+ const plan = raw && typeof raw === "object" ? raw : {};
82
+ const queries = strings(plan.queries).slice(0, MAX_PLAN_QUERIES);
83
+ if (queries.length === 0) {
84
+ return { queries: [input.question], tags: [], profileCategories: [], skipped: true };
85
+ }
86
+ return {
87
+ queries,
88
+ tags: known(strings(plan.tags), topTags(input.tags).map((tag) => tag.keyword)),
89
+ profileCategories: known(strings(plan.profileCategories), input.categories),
90
+ skipped: false,
91
+ };
92
+ }
@@ -0,0 +1,36 @@
1
+ import type { ChatPlanRecord, ChatSource } from "./chat-types.js";
2
+ export declare const MAX_MEMORY_SOURCES = 12;
3
+ export declare const MAX_PROFILE_SOURCES = 6;
4
+ /** A profile item outside the chosen categories joins only when it is at least this close. */
5
+ export declare const PROFILE_MIN_SIMILARITY = 0.35;
6
+ /** One search result, as `memoryClient.searchMemories` returns it. */
7
+ export interface MemoryHit {
8
+ id: string;
9
+ memory: string;
10
+ similarity: number;
11
+ tags?: string[];
12
+ createdAt?: number;
13
+ projectName?: string;
14
+ containerTag?: string;
15
+ }
16
+ export interface ProfileCandidate {
17
+ section: "preferences" | "patterns" | "workflows";
18
+ index: number;
19
+ category?: string;
20
+ description: string;
21
+ evidence?: string[] | string;
22
+ centroid?: number[];
23
+ }
24
+ export interface RetrieveDeps {
25
+ /** `scopeTag` null searches every project. */
26
+ searchMemories: (query: string, scopeTag: string | null) => Promise<MemoryHit[]>;
27
+ embed: (text: string) => Promise<ArrayLike<number>>;
28
+ profileItems: () => Promise<ProfileCandidate[]>;
29
+ }
30
+ /** Sources still need labels; `numberSources` in chat-answer.ts gives them. */
31
+ export interface RetrievedSources {
32
+ memories: ChatSource[];
33
+ profile: ChatSource[];
34
+ }
35
+ /** Memory and profile sources for one question, searched in parallel. */
36
+ export declare function retrieveSources(question: string, plan: ChatPlanRecord, scopeTag: string | null, deps: RetrieveDeps): Promise<RetrievedSources>;
@@ -0,0 +1,82 @@
1
+ export const MAX_MEMORY_SOURCES = 12;
2
+ export const MAX_PROFILE_SOURCES = 6;
3
+ /** A profile item outside the chosen categories joins only when it is at least this close. */
4
+ export const PROFILE_MIN_SIMILARITY = 0.35;
5
+ function cosine(a, b) {
6
+ if (a.length !== b.length || a.length === 0)
7
+ return 0;
8
+ let dot = 0;
9
+ let na = 0;
10
+ let nb = 0;
11
+ for (let i = 0; i < a.length; i++) {
12
+ dot += a[i] * b[i];
13
+ na += a[i] * a[i];
14
+ nb += b[i] * b[i];
15
+ }
16
+ return na && nb ? dot / Math.sqrt(na * nb) : 0;
17
+ }
18
+ async function memorySources(plan, scopeTag, deps) {
19
+ const queries = [...plan.queries];
20
+ if (plan.tags.length)
21
+ queries.push(plan.tags.join(" "));
22
+ const batches = await Promise.all(queries.map((query) => deps.searchMemories(query, scopeTag)));
23
+ const best = new Map();
24
+ for (const hit of batches.flat()) {
25
+ const seen = best.get(hit.id);
26
+ if (!seen || hit.similarity > seen.similarity)
27
+ best.set(hit.id, hit);
28
+ }
29
+ return [...best.values()]
30
+ .sort((a, b) => b.similarity - a.similarity)
31
+ .slice(0, MAX_MEMORY_SOURCES)
32
+ .map((hit) => ({
33
+ label: "",
34
+ kind: "memory",
35
+ id: hit.id,
36
+ text: hit.memory,
37
+ projectName: hit.projectName,
38
+ projectTag: hit.containerTag,
39
+ createdAt: hit.createdAt,
40
+ tags: hit.tags,
41
+ score: hit.similarity,
42
+ }));
43
+ }
44
+ async function profileSources(question, plan, deps) {
45
+ const items = await deps.profileItems();
46
+ if (items.length === 0)
47
+ return [];
48
+ const chosen = new Set(plan.profileCategories.map((c) => c.toLowerCase()));
49
+ const vector = items.some((item) => item.centroid) ? await deps.embed(question) : null;
50
+ return items
51
+ .map((item) => {
52
+ const closeness = vector && item.centroid ? cosine(vector, item.centroid) : 0;
53
+ const inCategory = !!item.category && chosen.has(item.category.toLowerCase());
54
+ // Chosen categories rank above everything found by vector alone.
55
+ return { item, closeness, inCategory, rank: (inCategory ? 1 : 0) + closeness };
56
+ })
57
+ .filter((entry) => entry.inCategory || entry.closeness >= PROFILE_MIN_SIMILARITY)
58
+ .sort((a, b) => b.rank - a.rank)
59
+ .slice(0, MAX_PROFILE_SOURCES)
60
+ .map(({ item, closeness }) => ({
61
+ label: "",
62
+ kind: "profile",
63
+ id: `${item.section}:${item.index}`,
64
+ text: item.description,
65
+ section: item.section,
66
+ category: item.category,
67
+ evidence: Array.isArray(item.evidence)
68
+ ? item.evidence
69
+ : item.evidence
70
+ ? [item.evidence]
71
+ : undefined,
72
+ score: closeness,
73
+ }));
74
+ }
75
+ /** Memory and profile sources for one question, searched in parallel. */
76
+ export async function retrieveSources(question, plan, scopeTag, deps) {
77
+ const [memories, profile] = await Promise.all([
78
+ memorySources(plan, scopeTag, deps),
79
+ profileSources(question, plan, deps),
80
+ ]);
81
+ return { memories, profile };
82
+ }
@@ -0,0 +1,12 @@
1
+ import { type ChatRuntimeConfig } from "./chat-runtime.js";
2
+ export interface RouteResult {
3
+ status: number;
4
+ body: unknown;
5
+ }
6
+ type Body = Record<string, unknown>;
7
+ /**
8
+ * The chat routes under `/api/chat/`. Returns undefined for any other path.
9
+ * Store errors map to 400, 404 or 409; anything else is logged by code only.
10
+ */
11
+ export declare function routeChat(path: string, method: string, readBody: () => Promise<Body>, config: ChatRuntimeConfig): Promise<RouteResult | undefined>;
12
+ export {};