om-memory-system 4.24.0-next.147 → 4.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/opencode/user-prompt.js +6 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.js +5 -44
- package/dist/core/retrieval.js +1 -1
- package/dist/services/chat/chat-answer.d.ts +56 -0
- package/dist/services/chat/chat-answer.js +88 -0
- package/dist/services/chat/chat-plan.d.ts +60 -0
- package/dist/services/chat/chat-plan.js +92 -0
- package/dist/services/chat/chat-retrieve.d.ts +36 -0
- package/dist/services/chat/chat-retrieve.js +82 -0
- package/dist/services/chat/chat-routes.d.ts +12 -0
- package/dist/services/chat/chat-routes.js +145 -0
- package/dist/services/chat/chat-runtime.d.ts +21 -0
- package/dist/services/chat/chat-runtime.js +104 -0
- package/dist/services/chat/chat-service.d.ts +42 -0
- package/dist/services/chat/chat-service.js +130 -0
- package/dist/services/chat/chat-status.d.ts +16 -0
- package/dist/services/chat/chat-status.js +23 -0
- package/dist/services/chat/chat-store.d.ts +60 -0
- package/dist/services/chat/chat-store.js +352 -0
- package/dist/services/chat/chat-types.d.ts +59 -0
- package/dist/services/chat/chat-types.js +8 -0
- package/dist/services/client.d.ts +1 -0
- package/dist/services/client.js +8 -3
- package/dist/services/context.d.ts +3 -1
- package/dist/services/context.js +4 -2
- package/dist/services/global-config-writer.js +8 -1
- package/dist/services/privacy.js +2 -2
- package/dist/services/settings-snapshot.js +1 -0
- package/dist/services/turso/exact-scan.d.ts +6 -0
- package/dist/services/turso/exact-scan.js +23 -0
- package/dist/services/turso/keyword-search.d.ts +35 -0
- package/dist/services/turso/keyword-search.js +153 -0
- package/dist/services/turso/rank-fusion.d.ts +34 -0
- package/dist/services/turso/rank-fusion.js +45 -0
- package/dist/services/turso/tag-match.d.ts +7 -0
- package/dist/services/turso/tag-match.js +29 -0
- package/dist/services/turso/types.d.ts +2 -0
- package/dist/services/turso/vector-search.d.ts +3 -3
- package/dist/services/turso/vector-search.js +23 -39
- package/dist/services/user-profile/profile-context.d.ts +6 -1
- package/dist/services/user-profile/profile-context.js +43 -7
- package/dist/services/user-profile/profile-relevance.d.ts +37 -0
- package/dist/services/user-profile/profile-relevance.js +75 -0
- package/dist/services/web-route-modules.d.ts +8 -0
- package/dist/services/web-route-modules.js +15 -0
- package/dist/services/web-server.js +5 -4
- package/dist/utils/keyword-boost.d.ts +17 -0
- package/dist/utils/keyword-boost.js +32 -0
- package/dist/utils/reranker-settings.d.ts +13 -0
- package/dist/utils/reranker-settings.js +48 -0
- package/dist/web/assets/index-DWk9C9Sm.css +2 -0
- package/dist/web/assets/index-rHcTKCkC.js +126 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B_2GCxJJ.js +0 -127
- 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
|
-
|
|
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
|
|
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,
|
package/dist/core/retrieval.js
CHANGED
|
@@ -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 {};
|