@hienlh/ppm 0.9.39 → 0.9.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -50
- package/dist/web/assets/browser-tab--V6I70pH.js +1 -0
- package/dist/web/assets/chat-tab-CrkhvVjF.js +10 -0
- package/dist/web/assets/code-editor-BfMyExLp.js +2 -0
- package/dist/web/assets/{database-viewer-TjRo2b8_.js → database-viewer-CeRUrZKj.js} +1 -1
- package/dist/web/assets/{diff-viewer-BMhCz0xk.js → diff-viewer-D2p3WTMS.js} +1 -1
- package/dist/web/assets/{extension-webview-DiVdlE2r.js → extension-webview-DQWAHMlR.js} +1 -1
- package/dist/web/assets/git-graph-BWRMlCdK.js +1 -0
- package/dist/web/assets/index-C7esr4gM.css +2 -0
- package/dist/web/assets/index-DU6UVgQY.js +30 -0
- package/dist/web/assets/keybindings-store-BE2T8jM9.js +1 -0
- package/dist/web/assets/{markdown-renderer-IyEzLrC6.js → markdown-renderer-C7lKs47M.js} +4 -4
- package/dist/web/assets/{postgres-viewer-CSynGGkJ.js → postgres-viewer-Cr9jpBNd.js} +1 -1
- package/dist/web/assets/{settings-tab-BdI4HhRa.js → settings-tab-DKy-YDg2.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-C5mviyU5.js → sqlite-viewer-9AmeF-Zs.js} +1 -1
- package/dist/web/assets/square-oPKIkJiw.js +1 -0
- package/dist/web/assets/{terminal-tab-CDyC1grg.js → terminal-tab-DFhB4Rxh.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-DcVicB_i.js → use-monaco-theme-B7XLw-OX.js} +1 -1
- package/dist/web/index.html +2 -3
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +3 -33
- package/docs/project-changelog.md +0 -47
- package/docs/project-roadmap.md +7 -14
- package/docs/system-architecture.md +2 -65
- package/package.json +1 -1
- package/src/server/index.ts +0 -7
- package/src/server/routes/settings.ts +1 -72
- package/src/services/config.service.ts +1 -1
- package/src/services/db.service.ts +1 -279
- package/src/services/git.service.ts +2 -2
- package/src/types/config.ts +0 -26
- package/src/web/components/browser/browser-tab.tsx +128 -97
- package/src/web/components/chat/chat-history-bar.tsx +3 -8
- package/src/web/components/layout/command-palette.tsx +1 -1
- package/src/web/components/settings/settings-tab.tsx +1 -4
- package/src/web/hooks/use-url-sync.ts +1 -1
- package/dist/web/assets/browser-tab-DnIsHiCc.js +0 -1
- package/dist/web/assets/chat-tab-il6D4jql.js +0 -10
- package/dist/web/assets/code-editor-BUc1jBqm.js +0 -2
- package/dist/web/assets/git-graph-4eGJ8B1A.js +0 -1
- package/dist/web/assets/index-BmcV1di6.js +0 -30
- package/dist/web/assets/index-CcFDEPCo.css +0 -2
- package/dist/web/assets/keybindings-store--5T5hsAj.js +0 -1
- package/dist/web/assets/tab-store-BXMIUvsE.js +0 -1
- package/docs/streaming-input-guide.md +0 -267
- package/snapshot-state.md +0 -1526
- package/src/services/ppmbot/ppmbot-formatter.ts +0 -88
- package/src/services/ppmbot/ppmbot-memory.ts +0 -333
- package/src/services/ppmbot/ppmbot-service.ts +0 -545
- package/src/services/ppmbot/ppmbot-session.ts +0 -199
- package/src/services/ppmbot/ppmbot-streamer.ts +0 -288
- package/src/services/ppmbot/ppmbot-telegram.ts +0 -279
- package/src/types/ppmbot.ts +0 -103
- package/src/web/components/settings/ppmbot-settings-section.tsx +0 -270
- package/test-session-ops.mjs +0 -444
- package/test-tokens.mjs +0 -212
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
const MAX_MESSAGE_LENGTH = 4096;
|
|
2
|
-
|
|
3
|
-
/** Escape HTML special chars for Telegram HTML parse mode */
|
|
4
|
-
export function escapeHtml(str: string): string {
|
|
5
|
-
return str
|
|
6
|
-
.replace(/&/g, "&")
|
|
7
|
-
.replace(/</g, "<")
|
|
8
|
-
.replace(/>/g, ">")
|
|
9
|
-
.replace(/"/g, """);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Convert Markdown to Telegram-compatible HTML.
|
|
14
|
-
* Handles: **bold**, *italic*, `code`, ```pre```, [links](url), ~~strikethrough~~
|
|
15
|
-
* Does NOT handle nested formatting (Telegram limitation).
|
|
16
|
-
*/
|
|
17
|
-
export function markdownToTelegramHtml(md: string): string {
|
|
18
|
-
let html = md;
|
|
19
|
-
|
|
20
|
-
// Code blocks first (prevent inner processing)
|
|
21
|
-
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
|
|
22
|
-
const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
|
23
|
-
return `<pre><code${langAttr}>${escapeHtml(code.trimEnd())}</code></pre>`;
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
// Inline code: `code` → <code>code</code>
|
|
27
|
-
html = html.replace(/`([^`\n]+)`/g, (_match, code) => {
|
|
28
|
-
return `<code>${escapeHtml(code)}</code>`;
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
// Bold: **text** → <b>text</b>
|
|
32
|
-
html = html.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
|
|
33
|
-
|
|
34
|
-
// Italic: *text* → <i>text</i>
|
|
35
|
-
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "<i>$1</i>");
|
|
36
|
-
|
|
37
|
-
// Strikethrough: ~~text~~ → <s>text</s>
|
|
38
|
-
html = html.replace(/~~(.+?)~~/g, "<s>$1</s>");
|
|
39
|
-
|
|
40
|
-
// Links: [text](url) → <a href="url">text</a>
|
|
41
|
-
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
42
|
-
|
|
43
|
-
return html;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Split text into chunks that fit Telegram's 4096 char limit.
|
|
48
|
-
* Tries to break at newlines, falling back to word boundaries.
|
|
49
|
-
*/
|
|
50
|
-
export function chunkMessage(text: string, maxLen = MAX_MESSAGE_LENGTH): string[] {
|
|
51
|
-
if (text.length <= maxLen) return [text];
|
|
52
|
-
|
|
53
|
-
const chunks: string[] = [];
|
|
54
|
-
let remaining = text;
|
|
55
|
-
|
|
56
|
-
while (remaining.length > 0) {
|
|
57
|
-
if (remaining.length <= maxLen) {
|
|
58
|
-
chunks.push(remaining);
|
|
59
|
-
break;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
let breakAt = -1;
|
|
63
|
-
const searchWindow = remaining.slice(0, maxLen);
|
|
64
|
-
|
|
65
|
-
// Try double newline
|
|
66
|
-
breakAt = searchWindow.lastIndexOf("\n\n");
|
|
67
|
-
if (breakAt === -1 || breakAt < maxLen * 0.3) {
|
|
68
|
-
breakAt = searchWindow.lastIndexOf("\n");
|
|
69
|
-
}
|
|
70
|
-
if (breakAt === -1 || breakAt < maxLen * 0.3) {
|
|
71
|
-
breakAt = searchWindow.lastIndexOf(" ");
|
|
72
|
-
}
|
|
73
|
-
if (breakAt === -1 || breakAt < maxLen * 0.3) {
|
|
74
|
-
breakAt = maxLen;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
chunks.push(remaining.slice(0, breakAt));
|
|
78
|
-
remaining = remaining.slice(breakAt).trimStart();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return chunks;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Truncate text for preview (e.g. session titles), adding ellipsis */
|
|
85
|
-
export function truncateForPreview(text: string, maxLen = 200): string {
|
|
86
|
-
if (text.length <= maxLen) return text;
|
|
87
|
-
return text.slice(0, maxLen - 1) + "\u2026";
|
|
88
|
-
}
|
|
@@ -1,333 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
insertPPMBotMemory,
|
|
3
|
-
searchPPMBotMemories,
|
|
4
|
-
getPPMBotMemories,
|
|
5
|
-
supersedePPMBotMemory,
|
|
6
|
-
deletePPMBotMemoriesByTopic,
|
|
7
|
-
decayPPMBotMemories,
|
|
8
|
-
getDb,
|
|
9
|
-
} from "../db.service.ts";
|
|
10
|
-
import { configService } from "../config.service.ts";
|
|
11
|
-
import type {
|
|
12
|
-
PPMBotMemoryCategory,
|
|
13
|
-
MemoryRecallResult,
|
|
14
|
-
} from "../../types/ppmbot.ts";
|
|
15
|
-
import type { ProjectConfig } from "../../types/config.ts";
|
|
16
|
-
|
|
17
|
-
/** Max memories per project before pruning */
|
|
18
|
-
const MAX_MEMORIES_PER_PROJECT = 500;
|
|
19
|
-
|
|
20
|
-
/** Fact extracted from AI response */
|
|
21
|
-
interface ExtractedFact {
|
|
22
|
-
content: string;
|
|
23
|
-
category: PPMBotMemoryCategory;
|
|
24
|
-
importance?: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export class PPMBotMemory {
|
|
28
|
-
/**
|
|
29
|
-
* Recall relevant memories for a project.
|
|
30
|
-
* If query provided, use FTS5 search. Otherwise return top by importance.
|
|
31
|
-
*/
|
|
32
|
-
recall(project: string, query?: string, limit = 20): MemoryRecallResult[] {
|
|
33
|
-
if (query) {
|
|
34
|
-
const sanitized = this.sanitizeFtsQuery(query);
|
|
35
|
-
if (sanitized) {
|
|
36
|
-
try {
|
|
37
|
-
const results = searchPPMBotMemories(project, sanitized, limit);
|
|
38
|
-
return results.map((r) => ({
|
|
39
|
-
id: r.id,
|
|
40
|
-
content: r.content,
|
|
41
|
-
category: r.category,
|
|
42
|
-
importance: r.importance,
|
|
43
|
-
project: r.project,
|
|
44
|
-
rank: r.rank,
|
|
45
|
-
}));
|
|
46
|
-
} catch {
|
|
47
|
-
// FTS query syntax error — fallback to importance-based
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const rows = getPPMBotMemories(project, limit);
|
|
53
|
-
return rows.map((r) => ({
|
|
54
|
-
id: r.id,
|
|
55
|
-
content: r.content,
|
|
56
|
-
category: r.category,
|
|
57
|
-
importance: r.importance,
|
|
58
|
-
project: r.project,
|
|
59
|
-
}));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Enhanced recall: include memories from mentioned projects.
|
|
64
|
-
* Detects project names in message and fetches their memories too.
|
|
65
|
-
*/
|
|
66
|
-
recallWithCrossProject(
|
|
67
|
-
currentProject: string,
|
|
68
|
-
query: string | undefined,
|
|
69
|
-
message: string,
|
|
70
|
-
limit = 20,
|
|
71
|
-
): MemoryRecallResult[] {
|
|
72
|
-
const mainMemories = this.recall(currentProject, query, limit);
|
|
73
|
-
const mentioned = this.detectMentionedProjects(message, currentProject);
|
|
74
|
-
|
|
75
|
-
if (mentioned.length === 0) return mainMemories;
|
|
76
|
-
|
|
77
|
-
const crossMemories: MemoryRecallResult[] = [];
|
|
78
|
-
for (const proj of mentioned.slice(0, 3)) {
|
|
79
|
-
const projMems = this.recall(proj, query, 5);
|
|
80
|
-
crossMemories.push(...projMems.map((m) => ({ ...m, project: proj })));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return [...mainMemories, ...crossMemories].slice(0, limit);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Save multiple extracted facts. Checks for duplicates via FTS
|
|
88
|
-
* and supersedes old facts when new ones are similar.
|
|
89
|
-
*/
|
|
90
|
-
save(project: string, facts: ExtractedFact[], sessionId?: string): number {
|
|
91
|
-
let inserted = 0;
|
|
92
|
-
for (const fact of facts) {
|
|
93
|
-
if (!fact.content?.trim()) continue;
|
|
94
|
-
|
|
95
|
-
const existingId = this.findSimilar(project, fact.content);
|
|
96
|
-
|
|
97
|
-
const newId = insertPPMBotMemory(
|
|
98
|
-
project,
|
|
99
|
-
fact.content.trim(),
|
|
100
|
-
fact.category || "fact",
|
|
101
|
-
fact.importance ?? 1.0,
|
|
102
|
-
sessionId,
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
if (existingId) {
|
|
106
|
-
supersedePPMBotMemory(existingId, newId);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
inserted++;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
this.pruneExcess(project);
|
|
113
|
-
return inserted;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Save a single fact immediately (from /remember command) */
|
|
117
|
-
saveOne(
|
|
118
|
-
project: string,
|
|
119
|
-
content: string,
|
|
120
|
-
category: PPMBotMemoryCategory = "fact",
|
|
121
|
-
sessionId?: string,
|
|
122
|
-
): number {
|
|
123
|
-
const existingId = this.findSimilar(project, content);
|
|
124
|
-
const newId = insertPPMBotMemory(project, content.trim(), category, 1.0, sessionId);
|
|
125
|
-
if (existingId) {
|
|
126
|
-
supersedePPMBotMemory(existingId, newId);
|
|
127
|
-
}
|
|
128
|
-
return newId;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Delete memories matching a topic (from /forget command) */
|
|
132
|
-
forget(project: string, topic: string): number {
|
|
133
|
-
const sanitized = this.sanitizeFtsQuery(topic);
|
|
134
|
-
if (!sanitized) return 0;
|
|
135
|
-
return deletePPMBotMemoriesByTopic(project, sanitized);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Get summary of all active memories for a project */
|
|
139
|
-
getSummary(project: string, limit = 30): MemoryRecallResult[] {
|
|
140
|
-
const rows = getPPMBotMemories(project, limit);
|
|
141
|
-
return rows.map((r) => ({
|
|
142
|
-
id: r.id,
|
|
143
|
-
content: r.content,
|
|
144
|
-
category: r.category,
|
|
145
|
-
importance: r.importance,
|
|
146
|
-
project: r.project,
|
|
147
|
-
}));
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Build system prompt section with recalled memories */
|
|
151
|
-
buildRecallPrompt(memories: MemoryRecallResult[]): string {
|
|
152
|
-
if (memories.length === 0) return "";
|
|
153
|
-
|
|
154
|
-
const grouped = new Map<string, string[]>();
|
|
155
|
-
for (const mem of memories) {
|
|
156
|
-
const cat = mem.category || "fact";
|
|
157
|
-
if (!grouped.has(cat)) grouped.set(cat, []);
|
|
158
|
-
grouped.get(cat)!.push(mem.content);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
let prompt = "\n\n## Cross-Session Memory\n";
|
|
162
|
-
prompt += "The following facts are recalled from previous sessions:\n\n";
|
|
163
|
-
|
|
164
|
-
for (const [category, facts] of grouped) {
|
|
165
|
-
prompt += `### ${category.charAt(0).toUpperCase() + category.slice(1)}s\n`;
|
|
166
|
-
for (const fact of facts) {
|
|
167
|
-
prompt += `- ${fact}\n`;
|
|
168
|
-
}
|
|
169
|
-
prompt += "\n";
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
prompt += "Use these as context. Correct any that seem outdated.\n";
|
|
173
|
-
return prompt;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Build the extraction prompt sent at session end */
|
|
177
|
-
buildExtractionPrompt(): string {
|
|
178
|
-
return `Summarize the key facts, decisions, and preferences from this conversation as a JSON array. Each entry:
|
|
179
|
-
{"content": "the fact", "category": "fact|decision|preference|architecture|issue", "importance": 0.5-2.0}
|
|
180
|
-
|
|
181
|
-
Rules:
|
|
182
|
-
- Only include facts worth remembering across sessions
|
|
183
|
-
- Skip ephemeral details (file line numbers, temp debug info)
|
|
184
|
-
- Prefer concise, self-contained statements
|
|
185
|
-
- Max 10 entries
|
|
186
|
-
- Return ONLY the JSON array, no markdown fencing
|
|
187
|
-
|
|
188
|
-
If nothing worth remembering, return []`;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Parse the AI's extraction response into structured facts.
|
|
193
|
-
* Handles: raw JSON array, markdown-fenced JSON, or graceful failure.
|
|
194
|
-
*/
|
|
195
|
-
parseExtractionResponse(text: string): ExtractedFact[] {
|
|
196
|
-
let cleaned = text.trim();
|
|
197
|
-
if (cleaned.startsWith("```")) {
|
|
198
|
-
cleaned = cleaned.replace(/^```\w*\n?/, "").replace(/\n?```$/, "");
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
try {
|
|
202
|
-
const parsed = JSON.parse(cleaned);
|
|
203
|
-
if (!Array.isArray(parsed)) return [];
|
|
204
|
-
|
|
205
|
-
return parsed
|
|
206
|
-
.filter(
|
|
207
|
-
(item: unknown): item is Record<string, unknown> =>
|
|
208
|
-
typeof item === "object" && item !== null && "content" in item,
|
|
209
|
-
)
|
|
210
|
-
.map((item) => ({
|
|
211
|
-
content: String(item.content ?? ""),
|
|
212
|
-
category: this.validateCategory(String(item.category ?? "fact")),
|
|
213
|
-
importance: Math.max(0, Math.min(2, Number(item.importance ?? 1))),
|
|
214
|
-
}))
|
|
215
|
-
.filter((f) => f.content.length > 0);
|
|
216
|
-
} catch {
|
|
217
|
-
console.warn("[ppmbot-memory] Failed to parse extraction response");
|
|
218
|
-
return [];
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Regex-based fallback for memory extraction.
|
|
224
|
-
* Used when AI extraction returns empty or fails.
|
|
225
|
-
*/
|
|
226
|
-
extractiveMemoryFallback(conversationText: string): ExtractedFact[] {
|
|
227
|
-
const facts: ExtractedFact[] = [];
|
|
228
|
-
const patterns: Array<{ re: RegExp; category: PPMBotMemoryCategory }> = [
|
|
229
|
-
{ re: /(?:decided|chose|went with|picked|selected)\s+(.{10,100})/gi, category: "decision" },
|
|
230
|
-
{ re: /(?:prefer|always use|like to|rather)\s+(.{10,80})/gi, category: "preference" },
|
|
231
|
-
{ re: /(?:uses?|built with|stack is|powered by|database is)\s+(.{5,80})/gi, category: "architecture" },
|
|
232
|
-
{ re: /(?:bug|issue|problem|broken|fails?|error)\s+(?:with|in|when)\s+(.{10,100})/gi, category: "issue" },
|
|
233
|
-
];
|
|
234
|
-
for (const { re, category } of patterns) {
|
|
235
|
-
let match: RegExpExecArray | null;
|
|
236
|
-
while ((match = re.exec(conversationText)) !== null) {
|
|
237
|
-
const content = (match[1] || match[0]).trim();
|
|
238
|
-
if (content.length > 10) {
|
|
239
|
-
facts.push({ content, category, importance: 0.7 });
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
return facts.slice(0, 10);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/** Run importance decay on old memories */
|
|
247
|
-
runDecay(): void {
|
|
248
|
-
try {
|
|
249
|
-
decayPPMBotMemories();
|
|
250
|
-
} catch (err) {
|
|
251
|
-
console.error("[ppmbot-memory] Decay error:", (err as Error).message);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** Remove excess memories beyond the per-project cap */
|
|
256
|
-
pruneExcess(project: string, maxCount = MAX_MEMORIES_PER_PROJECT): void {
|
|
257
|
-
try {
|
|
258
|
-
const count = (getDb().query(
|
|
259
|
-
`SELECT COUNT(*) as cnt FROM clawbot_memories
|
|
260
|
-
WHERE project = ? AND superseded_by IS NULL`,
|
|
261
|
-
).get(project) as { cnt: number })?.cnt ?? 0;
|
|
262
|
-
|
|
263
|
-
if (count <= maxCount) return;
|
|
264
|
-
|
|
265
|
-
const excess = count - maxCount;
|
|
266
|
-
getDb().query(
|
|
267
|
-
`DELETE FROM clawbot_memories WHERE id IN (
|
|
268
|
-
SELECT id FROM clawbot_memories
|
|
269
|
-
WHERE project = ? AND superseded_by IS NULL
|
|
270
|
-
ORDER BY importance ASC, updated_at ASC
|
|
271
|
-
LIMIT ?
|
|
272
|
-
)`,
|
|
273
|
-
).run(project, excess);
|
|
274
|
-
} catch (err) {
|
|
275
|
-
console.error("[ppmbot-memory] Prune error:", (err as Error).message);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// ── Private ─────────────────────────────────────────────────────
|
|
280
|
-
|
|
281
|
-
/** Detect project names mentioned in user message */
|
|
282
|
-
private detectMentionedProjects(message: string, currentProject: string): string[] {
|
|
283
|
-
const allProjects = configService.get("projects") as ProjectConfig[];
|
|
284
|
-
if (!allProjects?.length) return [];
|
|
285
|
-
return allProjects
|
|
286
|
-
.filter((p) => p.name !== currentProject)
|
|
287
|
-
.filter((p) => message.toLowerCase().includes(p.name.toLowerCase()))
|
|
288
|
-
.map((p) => p.name);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** Find an existing memory similar to the given content */
|
|
292
|
-
private findSimilar(project: string, content: string): number | null {
|
|
293
|
-
const words = content
|
|
294
|
-
.split(/\s+/)
|
|
295
|
-
.filter((w) => w.length > 3)
|
|
296
|
-
.slice(0, 5);
|
|
297
|
-
|
|
298
|
-
if (words.length === 0) return null;
|
|
299
|
-
|
|
300
|
-
const query = words.map((w) => w.replace(/[^a-zA-Z0-9]/g, "")).filter(Boolean).join(" OR ");
|
|
301
|
-
if (!query) return null;
|
|
302
|
-
|
|
303
|
-
try {
|
|
304
|
-
const results = searchPPMBotMemories(project, query, 3);
|
|
305
|
-
if (results.length > 0 && results[0]!.rank < -5) {
|
|
306
|
-
return results[0]!.id;
|
|
307
|
-
}
|
|
308
|
-
} catch {
|
|
309
|
-
// FTS error — no match
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/** Sanitize user input for FTS5 MATCH syntax */
|
|
316
|
-
private sanitizeFtsQuery(input: string): string {
|
|
317
|
-
return input
|
|
318
|
-
.replace(/['"():*^~{}[\]\\]/g, " ")
|
|
319
|
-
.replace(/\b(AND|OR|NOT|NEAR)\b/gi, "")
|
|
320
|
-
.replace(/\s+/g, " ")
|
|
321
|
-
.trim();
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/** Validate category string against known values */
|
|
325
|
-
private validateCategory(cat: string): PPMBotMemoryCategory {
|
|
326
|
-
const valid: PPMBotMemoryCategory[] = [
|
|
327
|
-
"fact", "decision", "preference", "architecture", "issue",
|
|
328
|
-
];
|
|
329
|
-
return valid.includes(cat as PPMBotMemoryCategory)
|
|
330
|
-
? (cat as PPMBotMemoryCategory)
|
|
331
|
-
: "fact";
|
|
332
|
-
}
|
|
333
|
-
}
|