@brianmichel/pi-noodle 0.1.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/LICENSE +21 -0
- package/README.md +231 -0
- package/index.ts +1 -0
- package/package.json +70 -0
- package/src/AGENTS.md +33 -0
- package/src/commands/index.ts +51 -0
- package/src/commands/memory-crud.ts +136 -0
- package/src/commands/review.ts +291 -0
- package/src/commands/setup.ts +189 -0
- package/src/commands/status.ts +32 -0
- package/src/commands/ui.ts +14 -0
- package/src/commands/web.ts +40 -0
- package/src/commands.ts +1 -0
- package/src/config/schema.ts +234 -0
- package/src/config-screen.ts +439 -0
- package/src/config.ts +159 -0
- package/src/constants.ts +1 -0
- package/src/debug-overlay.ts +230 -0
- package/src/extension.ts +166 -0
- package/src/index.ts +1 -0
- package/src/memory/backend.ts +22 -0
- package/src/memory/embedder.ts +7 -0
- package/src/memory/embedders/lm-studio.ts +25 -0
- package/src/memory/embedders/openai.ts +66 -0
- package/src/memory/extractor.ts +189 -0
- package/src/memory/policy.ts +325 -0
- package/src/memory/project-identity.ts +51 -0
- package/src/memory/runtime.ts +70 -0
- package/src/memory/service.ts +761 -0
- package/src/memory/turso-backend.ts +716 -0
- package/src/memory/types.ts +192 -0
- package/src/notifications.ts +11 -0
- package/src/queue.ts +42 -0
- package/src/session.ts +72 -0
- package/src/tools.ts +172 -0
- package/src/types.ts +81 -0
- package/src/utils.ts +68 -0
- package/src/web/dev.ts +7 -0
- package/src/web/index.html +1963 -0
- package/src/web/manager.ts +92 -0
- package/src/web/run.ts +33 -0
- package/src/web/server.ts +212 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import { DEFAULT_AGENT_ID } from "../constants.ts";
|
|
2
|
+
import {
|
|
3
|
+
noteExtractorQueued,
|
|
4
|
+
noteExtractorRunFailed,
|
|
5
|
+
noteExtractorRunFinished,
|
|
6
|
+
noteExtractorRunStarted,
|
|
7
|
+
noteExtractorSkipped,
|
|
8
|
+
} from "../debug-overlay.ts";
|
|
9
|
+
import { enqueueWriteTask } from "../queue.ts";
|
|
10
|
+
import {
|
|
11
|
+
buildSessionSignature,
|
|
12
|
+
collectSessionMessages,
|
|
13
|
+
ensureMessages,
|
|
14
|
+
selectExtractorMessages,
|
|
15
|
+
selectMemoryWorthMessages,
|
|
16
|
+
} from "../session.ts";
|
|
17
|
+
import type { JsonObject } from "../types.ts";
|
|
18
|
+
import type { NoodleExtractorMode, NotificationTarget } from "../types.ts";
|
|
19
|
+
import type { MemoryBackend } from "./backend.ts";
|
|
20
|
+
import { extractMemoriesFromMessages } from "./extractor.ts";
|
|
21
|
+
import { deriveProjectKey } from "./project-identity.ts";
|
|
22
|
+
import {
|
|
23
|
+
buildSignalKey,
|
|
24
|
+
categoriesForPrompt,
|
|
25
|
+
evaluateCandidateDecision,
|
|
26
|
+
prefilterUserMessage,
|
|
27
|
+
shouldRetrieveMemories,
|
|
28
|
+
} from "./policy.ts";
|
|
29
|
+
import type {
|
|
30
|
+
AddMemoryInput,
|
|
31
|
+
ExtractionCandidate,
|
|
32
|
+
LocalSignal,
|
|
33
|
+
MemoryCandidate,
|
|
34
|
+
MemoryCaptureEvent,
|
|
35
|
+
MemoryCapturePlan,
|
|
36
|
+
MemoryCaptureResult,
|
|
37
|
+
MemoryCategory,
|
|
38
|
+
MemoryExtractorResolution,
|
|
39
|
+
MemoryRecord,
|
|
40
|
+
MemorySearchInput,
|
|
41
|
+
MemoryScope,
|
|
42
|
+
UpdateMemoryInput,
|
|
43
|
+
} from "./types.ts";
|
|
44
|
+
|
|
45
|
+
const DEFAULT_EXTRACTOR_TRIGGER_EVERY = 10;
|
|
46
|
+
|
|
47
|
+
type ExtractMemoriesFn = typeof extractMemoriesFromMessages;
|
|
48
|
+
|
|
49
|
+
type MemoryServiceOptions = {
|
|
50
|
+
extractorMode?: NoodleExtractorMode;
|
|
51
|
+
extractorTriggerEvery?: number;
|
|
52
|
+
projectKeyResolver?: () => string | null;
|
|
53
|
+
extractMemoriesFromMessages?: ExtractMemoriesFn;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type QueueCandidateOptions = {
|
|
57
|
+
countIncrement?: number;
|
|
58
|
+
target?: NotificationTarget;
|
|
59
|
+
label?: string;
|
|
60
|
+
extraMetadata?: JsonObject;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type ExtractorRunOptions = {
|
|
64
|
+
sessionManager: MemoryCaptureEvent["sessionManager"];
|
|
65
|
+
reason: string;
|
|
66
|
+
target?: NotificationTarget;
|
|
67
|
+
resolve: () => Promise<MemoryExtractorResolution | null>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type ConversationCaptureOptions = {
|
|
71
|
+
sessionManager: MemoryCaptureEvent["sessionManager"];
|
|
72
|
+
reason: string;
|
|
73
|
+
target?: NotificationTarget;
|
|
74
|
+
successMessage?: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function planMemoryCaptureEvent(
|
|
78
|
+
event: MemoryCaptureEvent,
|
|
79
|
+
options: {
|
|
80
|
+
extractorMode: NoodleExtractorMode;
|
|
81
|
+
extractorTriggerEvery: number;
|
|
82
|
+
sessionTurnCount: number;
|
|
83
|
+
hasHeuristicCandidates: boolean;
|
|
84
|
+
},
|
|
85
|
+
): MemoryCapturePlan {
|
|
86
|
+
const canExtract = options.extractorMode !== "off" && !!event.extractor;
|
|
87
|
+
|
|
88
|
+
switch (event.type) {
|
|
89
|
+
case "user_input": {
|
|
90
|
+
const runLlmExtraction = canExtract && (
|
|
91
|
+
options.hasHeuristicCandidates
|
|
92
|
+
|| options.sessionTurnCount % Math.max(1, options.extractorTriggerEvery) === 0
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
runHeuristics: true,
|
|
97
|
+
runLlmExtraction,
|
|
98
|
+
captureConversation: false,
|
|
99
|
+
consolidate: false,
|
|
100
|
+
extractionReason: options.hasHeuristicCandidates ? "automatic_capture" : "scheduled",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
case "session_before_compact":
|
|
104
|
+
return {
|
|
105
|
+
runHeuristics: false,
|
|
106
|
+
runLlmExtraction: canExtract,
|
|
107
|
+
captureConversation: true,
|
|
108
|
+
consolidate: false,
|
|
109
|
+
extractionReason: "before_compact",
|
|
110
|
+
conversationReason: "before_compact",
|
|
111
|
+
};
|
|
112
|
+
case "session_before_switch":
|
|
113
|
+
return {
|
|
114
|
+
runHeuristics: false,
|
|
115
|
+
runLlmExtraction: canExtract,
|
|
116
|
+
captureConversation: true,
|
|
117
|
+
consolidate: false,
|
|
118
|
+
extractionReason: `before_switch:${event.reason}`,
|
|
119
|
+
conversationReason: `before_switch:${event.reason}`,
|
|
120
|
+
};
|
|
121
|
+
case "session_shutdown":
|
|
122
|
+
if (event.reason === "reload") {
|
|
123
|
+
return {
|
|
124
|
+
runHeuristics: false,
|
|
125
|
+
runLlmExtraction: false,
|
|
126
|
+
captureConversation: false,
|
|
127
|
+
consolidate: false,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
runHeuristics: false,
|
|
132
|
+
runLlmExtraction: canExtract,
|
|
133
|
+
captureConversation: true,
|
|
134
|
+
consolidate: true,
|
|
135
|
+
extractionReason: `shutdown:${event.reason}`,
|
|
136
|
+
conversationReason: `shutdown:${event.reason}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class MemoryService {
|
|
142
|
+
private readonly localSignals = new Map<string, LocalSignal>();
|
|
143
|
+
private readonly recentlySaved = new Set<string>();
|
|
144
|
+
private readonly savedSessionSignatures = new Set<string>();
|
|
145
|
+
private readonly backend: MemoryBackend;
|
|
146
|
+
private readonly extractorMode: NoodleExtractorMode;
|
|
147
|
+
private readonly extractorTriggerEvery: number;
|
|
148
|
+
private readonly projectKeyResolver: () => string | null;
|
|
149
|
+
private readonly extractMemories: ExtractMemoriesFn;
|
|
150
|
+
private cachedProjectKey?: string | null;
|
|
151
|
+
private sessionTurnCount = 0;
|
|
152
|
+
|
|
153
|
+
constructor(backend: MemoryBackend, options?: MemoryServiceOptions) {
|
|
154
|
+
this.backend = backend;
|
|
155
|
+
this.extractorMode = options?.extractorMode ?? "balanced";
|
|
156
|
+
this.extractorTriggerEvery = Math.max(1, options?.extractorTriggerEvery ?? DEFAULT_EXTRACTOR_TRIGGER_EVERY);
|
|
157
|
+
this.projectKeyResolver = options?.projectKeyResolver ?? (() => deriveProjectKey());
|
|
158
|
+
this.extractMemories = options?.extractMemoriesFromMessages ?? extractMemoriesFromMessages;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
add(input: AddMemoryInput): Promise<void> {
|
|
162
|
+
return this.backend.add({
|
|
163
|
+
...input,
|
|
164
|
+
messages: ensureMessages(input.text, input.messages),
|
|
165
|
+
scope: this.withDefaultScope(input.scope),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
search(input: MemorySearchInput): Promise<MemoryRecord[]> {
|
|
170
|
+
return this.backend.search({
|
|
171
|
+
...input,
|
|
172
|
+
scope: this.withDefaultScope(input.scope),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
list(scope?: MemoryScope): Promise<MemoryRecord[]> {
|
|
177
|
+
return this.backend.list({
|
|
178
|
+
scope: this.withDefaultScope(scope),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
get(id: string): Promise<MemoryRecord | null> {
|
|
183
|
+
return this.backend.get(id);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
update(id: string, input: UpdateMemoryInput): Promise<void> {
|
|
187
|
+
return this.backend.update(id, input);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
delete(id: string): Promise<void> {
|
|
191
|
+
return this.backend.delete(id);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
findRelevantMemories(prompt: string, limit = 3): Promise<MemoryRecord[]> {
|
|
195
|
+
if (!shouldRetrieveMemories(prompt)) return Promise.resolve([]);
|
|
196
|
+
|
|
197
|
+
return this.backend.search({
|
|
198
|
+
query: prompt,
|
|
199
|
+
limit,
|
|
200
|
+
threshold: 0.22,
|
|
201
|
+
categories: categoriesForPrompt(prompt),
|
|
202
|
+
scope: this.withDefaultScope(),
|
|
203
|
+
}).then((results) => {
|
|
204
|
+
const filtered = this.filterMemoriesForCurrentProject(results);
|
|
205
|
+
this.noteRetrievedMemories(filtered);
|
|
206
|
+
return filtered;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async capture(event: MemoryCaptureEvent): Promise<MemoryCaptureResult> {
|
|
211
|
+
if (event.type === "user_input") {
|
|
212
|
+
this.sessionTurnCount += 1;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const prefilter = event.type === "user_input"
|
|
216
|
+
? prefilterUserMessage(event.text)
|
|
217
|
+
: { hasCandidate: false, candidates: [] };
|
|
218
|
+
|
|
219
|
+
const plan = planMemoryCaptureEvent(event, {
|
|
220
|
+
extractorMode: this.extractorMode,
|
|
221
|
+
extractorTriggerEvery: this.extractorTriggerEvery,
|
|
222
|
+
sessionTurnCount: this.sessionTurnCount,
|
|
223
|
+
hasHeuristicCandidates: prefilter.hasCandidate,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
let automaticCaptureQueued = false;
|
|
227
|
+
if (plan.runHeuristics && prefilter.hasCandidate) {
|
|
228
|
+
for (const candidate of prefilter.candidates) {
|
|
229
|
+
if (this.handleCandidate(candidate, {
|
|
230
|
+
label: "Memory automatic capture",
|
|
231
|
+
...(event.target ? { target: event.target } : {}),
|
|
232
|
+
})) {
|
|
233
|
+
automaticCaptureQueued = true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let conversationCaptureQueued = false;
|
|
239
|
+
if (plan.captureConversation && plan.conversationReason) {
|
|
240
|
+
conversationCaptureQueued = this.queueConversationCapture({
|
|
241
|
+
sessionManager: event.sessionManager,
|
|
242
|
+
reason: plan.conversationReason,
|
|
243
|
+
...(event.target ? { target: event.target } : {}),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let llmExtractionQueued = false;
|
|
248
|
+
if (plan.runLlmExtraction && event.extractor && plan.extractionReason) {
|
|
249
|
+
llmExtractionQueued = await this.queueExtractorRun({
|
|
250
|
+
sessionManager: event.sessionManager,
|
|
251
|
+
reason: plan.extractionReason,
|
|
252
|
+
...(event.target ? { target: event.target } : {}),
|
|
253
|
+
resolve: event.extractor.resolve,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let consolidationQueued = false;
|
|
258
|
+
if (plan.consolidate) {
|
|
259
|
+
consolidationQueued = this.queueConsolidationInternal(event.target);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
plan,
|
|
264
|
+
automaticCaptureQueued,
|
|
265
|
+
llmExtractionQueued,
|
|
266
|
+
conversationCaptureQueued,
|
|
267
|
+
consolidationQueued,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Pending candidates stay review-only in v1 so retrieval quality is based on
|
|
272
|
+
// committed memories, not speculative first-pass inferences.
|
|
273
|
+
listPendingCandidates(): Array<LocalSignal & { score: number; promotionReasons: string[] }> {
|
|
274
|
+
return Array.from(this.localSignals.values())
|
|
275
|
+
.filter((signal) => !signal.promotedAt && signal.lastDecisionAction === "pending")
|
|
276
|
+
.map((signal) => {
|
|
277
|
+
const candidate = signalToCandidate(signal);
|
|
278
|
+
const decision = evaluateCandidateDecision(candidate, signal, this.extractorMode);
|
|
279
|
+
return {
|
|
280
|
+
...signal,
|
|
281
|
+
score: decision.score,
|
|
282
|
+
promotionReasons: decision.reasons,
|
|
283
|
+
};
|
|
284
|
+
})
|
|
285
|
+
.sort((a, b) => b.score - a.score || b.count - a.count || b.lastSeenAt - a.lastSeenAt)
|
|
286
|
+
.slice(0, 10);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
dismissPendingCandidate(key: string): boolean {
|
|
290
|
+
const signal = this.localSignals.get(key);
|
|
291
|
+
if (!signal || signal.promotedAt || signal.lastDecisionAction !== "pending") return false;
|
|
292
|
+
this.localSignals.delete(key);
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async promotePendingCandidate(key: string): Promise<boolean> {
|
|
297
|
+
const signal = this.localSignals.get(key);
|
|
298
|
+
if (!signal || signal.promotedAt || signal.lastDecisionAction !== "pending") return false;
|
|
299
|
+
|
|
300
|
+
const candidate = signalToCandidate(signal);
|
|
301
|
+
const score = typeof signal.lastPromotionScore === "number"
|
|
302
|
+
? signal.lastPromotionScore
|
|
303
|
+
: evaluateCandidateDecision(candidate, signal, this.extractorMode).score;
|
|
304
|
+
const promotionReasons = ["manual_review_approved"];
|
|
305
|
+
const result = await this.promoteSignal(
|
|
306
|
+
signal,
|
|
307
|
+
score,
|
|
308
|
+
promotionReasons,
|
|
309
|
+
buildPromotionMetadata(candidate, signal, score, promotionReasons, this.extractorMode, {
|
|
310
|
+
reviewed_action: "saved_from_review",
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
if (result === "saved" || result === "merged") {
|
|
315
|
+
signal.promotedAt = Date.now();
|
|
316
|
+
signal.lastPromotionScore = score;
|
|
317
|
+
signal.lastDecisionAction = "save";
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async addCandidateIfNovel(text: string, normalized: string, metadata: JsonObject): Promise<"saved" | "merged" | "skipped"> {
|
|
325
|
+
const existing = await this.list();
|
|
326
|
+
const normalizedValue = normalizeText(normalized);
|
|
327
|
+
const duplicate = existing.find((memory) => overlapsNormalizedText(memory.text, normalizedValue));
|
|
328
|
+
|
|
329
|
+
if (duplicate?.id) {
|
|
330
|
+
await this.update(duplicate.id, {
|
|
331
|
+
metadata: mergeMemoryMetadata(duplicate.metadata, {
|
|
332
|
+
...metadata,
|
|
333
|
+
retrieval_count: duplicate.retrievalCount ?? 0,
|
|
334
|
+
last_retrieved_at: duplicate.lastRetrieved ?? null,
|
|
335
|
+
}),
|
|
336
|
+
});
|
|
337
|
+
return "merged";
|
|
338
|
+
}
|
|
339
|
+
if (duplicate) {
|
|
340
|
+
return "skipped";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const category = typeof metadata.category === "string" ? metadata.category as MemoryCategory : undefined;
|
|
344
|
+
const categories = Array.isArray(metadata.categories)
|
|
345
|
+
? metadata.categories.filter((value): value is string => typeof value === "string")
|
|
346
|
+
: undefined;
|
|
347
|
+
|
|
348
|
+
await this.add({
|
|
349
|
+
text,
|
|
350
|
+
metadata,
|
|
351
|
+
...(category ? { category } : {}),
|
|
352
|
+
...(categories ? { categories } : {}),
|
|
353
|
+
});
|
|
354
|
+
return "saved";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private queueConversationCapture(options: ConversationCaptureOptions): boolean {
|
|
358
|
+
const signature = buildSessionSignature(options.sessionManager);
|
|
359
|
+
if (this.savedSessionSignatures.has(signature)) return false;
|
|
360
|
+
|
|
361
|
+
const messages = selectMemoryWorthMessages(collectSessionMessages(options.sessionManager));
|
|
362
|
+
if (messages.length < 2) return false;
|
|
363
|
+
|
|
364
|
+
this.savedSessionSignatures.add(signature);
|
|
365
|
+
enqueueWriteTask({
|
|
366
|
+
label: "Memory session capture",
|
|
367
|
+
...(options.target ? { target: options.target } : {}),
|
|
368
|
+
...(options.successMessage ? { successMessage: options.successMessage } : {}),
|
|
369
|
+
onFailure: () => {
|
|
370
|
+
this.savedSessionSignatures.delete(signature);
|
|
371
|
+
},
|
|
372
|
+
task: async () => {
|
|
373
|
+
if (this.backend.captureConversation) {
|
|
374
|
+
await this.backend.captureConversation({
|
|
375
|
+
messages,
|
|
376
|
+
metadata: {
|
|
377
|
+
source: "pi-session-wrapup",
|
|
378
|
+
reason: options.reason,
|
|
379
|
+
session_file: options.sessionManager.getSessionFile?.() || null,
|
|
380
|
+
},
|
|
381
|
+
scope: this.withDefaultScope(),
|
|
382
|
+
});
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await this.add({
|
|
387
|
+
messages,
|
|
388
|
+
metadata: {
|
|
389
|
+
source: "pi-session-wrapup",
|
|
390
|
+
reason: options.reason,
|
|
391
|
+
session_file: options.sessionManager.getSessionFile?.() || null,
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private async queueExtractorRun(options: ExtractorRunOptions): Promise<boolean> {
|
|
401
|
+
const resolved = await options.resolve();
|
|
402
|
+
if (!resolved?.model || !resolved.apiKey) {
|
|
403
|
+
noteExtractorSkipped("extractor model not configured");
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const messages = selectExtractorMessages(collectSessionMessages(options.sessionManager));
|
|
408
|
+
if (messages.length < 4) {
|
|
409
|
+
noteExtractorSkipped(
|
|
410
|
+
options.reason.startsWith("shutdown:")
|
|
411
|
+
? "shutdown run skipped: not enough memory-worthy context yet"
|
|
412
|
+
: "not enough memory-worthy context yet",
|
|
413
|
+
);
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
noteExtractorQueued(options.reason, resolved.model.id);
|
|
418
|
+
enqueueWriteTask({
|
|
419
|
+
label: "Memory LLM extraction",
|
|
420
|
+
...(options.target ? { target: options.target } : {}),
|
|
421
|
+
onFailure: () => {
|
|
422
|
+
noteExtractorRunFailed("LLM extraction failed");
|
|
423
|
+
},
|
|
424
|
+
task: async () => {
|
|
425
|
+
noteExtractorRunStarted();
|
|
426
|
+
const candidates = await this.extractMemories(messages, resolved.model, {
|
|
427
|
+
apiKey: resolved.apiKey,
|
|
428
|
+
...(resolved.headers ? { headers: resolved.headers } : {}),
|
|
429
|
+
});
|
|
430
|
+
let savedCount = 0;
|
|
431
|
+
const extractedTexts: string[] = [];
|
|
432
|
+
|
|
433
|
+
for (const extracted of candidates) {
|
|
434
|
+
if (extracted.confidence < 0.58) continue;
|
|
435
|
+
extractedTexts.push(extracted.text);
|
|
436
|
+
|
|
437
|
+
const candidate = buildCandidateFromExtraction(extracted);
|
|
438
|
+
|
|
439
|
+
if (this.handleCandidate(candidate, {
|
|
440
|
+
countIncrement: extracted.confidence >= 0.85 ? 2 : 1,
|
|
441
|
+
label: "Memory LLM extraction",
|
|
442
|
+
...(options.target ? { target: options.target } : {}),
|
|
443
|
+
extraMetadata: {
|
|
444
|
+
extractor_reinforced: true,
|
|
445
|
+
extractor_stability: extracted.stability,
|
|
446
|
+
extractor_sensitivity: extracted.sensitivity,
|
|
447
|
+
extractor_suggested_action: extracted.suggestedAction,
|
|
448
|
+
},
|
|
449
|
+
})) {
|
|
450
|
+
savedCount += 1;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
noteExtractorRunFinished(extractedTexts, savedCount);
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private queueConsolidationInternal(target?: NotificationTarget): boolean {
|
|
462
|
+
if (!this.backend.consolidate) return false;
|
|
463
|
+
|
|
464
|
+
const consolidate = this.backend.consolidate.bind(this.backend);
|
|
465
|
+
|
|
466
|
+
enqueueWriteTask({
|
|
467
|
+
label: "Memory consolidation",
|
|
468
|
+
...(target ? { target } : {}),
|
|
469
|
+
task: async () => {
|
|
470
|
+
await consolidate();
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Heuristic and LLM candidates share one promotion pipeline so settings,
|
|
478
|
+
// dedupe, metadata, and policy decisions stay consistent across sources.
|
|
479
|
+
private handleCandidate(candidate: MemoryCandidate, options?: QueueCandidateOptions): boolean {
|
|
480
|
+
const candidateWithContext = this.bindCandidateProjectContext(candidate);
|
|
481
|
+
const signal = this.recordCandidateEvidence(
|
|
482
|
+
candidateWithContext,
|
|
483
|
+
options?.countIncrement !== undefined ? { countIncrement: options.countIncrement } : undefined,
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
if (candidateWithContext.applicability === "project" && typeof signal.metadata["project_key"] !== "string") {
|
|
487
|
+
signal.lastDecisionAction = "pending";
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const decision = evaluateCandidateDecision(candidateWithContext, signal, this.extractorMode);
|
|
492
|
+
signal.lastDecisionAction = decision.action;
|
|
493
|
+
|
|
494
|
+
if (decision.action !== "save") {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
if (this.recentlySaved.has(signal.key)) {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
this.recentlySaved.add(signal.key);
|
|
502
|
+
enqueueWriteTask({
|
|
503
|
+
label: options?.label ?? "Memory candidate promotion",
|
|
504
|
+
...(options?.target ? { target: options.target } : {}),
|
|
505
|
+
task: async () => {
|
|
506
|
+
const result = await this.promoteSignal(
|
|
507
|
+
signal,
|
|
508
|
+
decision.score,
|
|
509
|
+
decision.reasons,
|
|
510
|
+
buildPromotionMetadata(candidateWithContext, signal, decision.score, decision.reasons, this.extractorMode, options?.extraMetadata),
|
|
511
|
+
);
|
|
512
|
+
if (result === "saved" || result === "merged") {
|
|
513
|
+
signal.promotedAt = Date.now();
|
|
514
|
+
signal.lastPromotionScore = decision.score;
|
|
515
|
+
signal.lastDecisionAction = "save";
|
|
516
|
+
} else {
|
|
517
|
+
this.recentlySaved.delete(signal.key);
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
onFailure: () => {
|
|
521
|
+
this.recentlySaved.delete(signal.key);
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private recordCandidateEvidence(candidate: MemoryCandidate, options?: { countIncrement?: number }): LocalSignal {
|
|
529
|
+
const key = this.findMatchingSignalKey(candidate) ?? buildSignalKey(candidate);
|
|
530
|
+
const signal = this.localSignals.get(key) ?? {
|
|
531
|
+
key,
|
|
532
|
+
text: candidate.text,
|
|
533
|
+
normalized: candidate.normalized,
|
|
534
|
+
category: candidate.category,
|
|
535
|
+
durability: candidate.durability,
|
|
536
|
+
...(candidate.applicability ? { applicability: candidate.applicability } : {}),
|
|
537
|
+
source: candidate.source,
|
|
538
|
+
explicit: candidate.explicit,
|
|
539
|
+
count: 0,
|
|
540
|
+
lastSeenAt: 0,
|
|
541
|
+
strongestConfidence: 0,
|
|
542
|
+
reasons: [],
|
|
543
|
+
metadata: {},
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
signal.text = candidate.text;
|
|
547
|
+
signal.normalized = candidate.normalized;
|
|
548
|
+
signal.category = candidate.category;
|
|
549
|
+
signal.durability = candidate.durability;
|
|
550
|
+
if (candidate.applicability) signal.applicability = candidate.applicability;
|
|
551
|
+
signal.source = candidate.source;
|
|
552
|
+
signal.explicit = signal.explicit || candidate.explicit;
|
|
553
|
+
signal.count += options?.countIncrement ?? 1;
|
|
554
|
+
signal.lastSeenAt = Date.now();
|
|
555
|
+
signal.strongestConfidence = Math.max(signal.strongestConfidence, candidate.confidence);
|
|
556
|
+
signal.reasons = Array.from(new Set([...signal.reasons, ...candidate.reasons]));
|
|
557
|
+
signal.metadata = {
|
|
558
|
+
...signal.metadata,
|
|
559
|
+
...candidate.metadata,
|
|
560
|
+
};
|
|
561
|
+
this.localSignals.set(key, signal);
|
|
562
|
+
return signal;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private async promoteSignal(signal: LocalSignal, score: number, promotionReasons: string[], metadata: JsonObject): Promise<"saved" | "merged" | "skipped"> {
|
|
566
|
+
return this.addCandidateIfNovel(signal.text, signal.normalized, {
|
|
567
|
+
...metadata,
|
|
568
|
+
confidence: signal.strongestConfidence,
|
|
569
|
+
signal_count: signal.count,
|
|
570
|
+
trigger_reasons: signal.reasons,
|
|
571
|
+
promotion_score: score,
|
|
572
|
+
promotion_reasons: promotionReasons,
|
|
573
|
+
last_seen_at: signal.lastSeenAt,
|
|
574
|
+
retrieval_signal_count: signal.retrievalCount ?? 0,
|
|
575
|
+
last_retrieved_at: signal.lastRetrievedAt ?? null,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private findMatchingSignalKey(candidate: MemoryCandidate): string | null {
|
|
580
|
+
for (const [key, signal] of this.localSignals.entries()) {
|
|
581
|
+
if (signal.category !== candidate.category) continue;
|
|
582
|
+
if (overlapsNormalizedText(signal.normalized, candidate.normalized)) {
|
|
583
|
+
return key;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private noteRetrievedMemories(records: MemoryRecord[]): void {
|
|
590
|
+
const now = Date.now();
|
|
591
|
+
for (const record of records) {
|
|
592
|
+
const normalized = record.text.trim().toLowerCase();
|
|
593
|
+
for (const signal of this.localSignals.values()) {
|
|
594
|
+
if (overlapsNormalizedText(signal.normalized, normalized)) {
|
|
595
|
+
signal.retrievalCount = (signal.retrievalCount ?? 0) + 1;
|
|
596
|
+
signal.lastRetrievedAt = now;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private withDefaultScope(scope?: MemoryScope): MemoryScope {
|
|
603
|
+
return {
|
|
604
|
+
assistantId: scope?.assistantId || DEFAULT_AGENT_ID,
|
|
605
|
+
...(scope?.userId ? { userId: scope.userId } : {}),
|
|
606
|
+
...(scope?.sessionId ? { sessionId: scope.sessionId } : {}),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private currentProjectKey(): string | null {
|
|
611
|
+
if (this.cachedProjectKey !== undefined) return this.cachedProjectKey;
|
|
612
|
+
this.cachedProjectKey = this.projectKeyResolver();
|
|
613
|
+
return this.cachedProjectKey;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private bindCandidateProjectContext(candidate: MemoryCandidate): MemoryCandidate {
|
|
617
|
+
if (candidate.applicability !== "project") return candidate;
|
|
618
|
+
const projectKey = this.currentProjectKey();
|
|
619
|
+
if (!projectKey) return candidate;
|
|
620
|
+
return {
|
|
621
|
+
...candidate,
|
|
622
|
+
metadata: {
|
|
623
|
+
...candidate.metadata,
|
|
624
|
+
project_key: projectKey,
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private filterMemoriesForCurrentProject(records: MemoryRecord[]): MemoryRecord[] {
|
|
630
|
+
const currentProjectKey = this.currentProjectKey();
|
|
631
|
+
return records.filter((record) => {
|
|
632
|
+
const applicability = typeof record.metadata["applicability"] === "string"
|
|
633
|
+
? record.metadata["applicability"]
|
|
634
|
+
: "unknown";
|
|
635
|
+
if (applicability !== "project") return true;
|
|
636
|
+
const projectKey = typeof record.metadata["project_key"] === "string"
|
|
637
|
+
? record.metadata["project_key"]
|
|
638
|
+
: null;
|
|
639
|
+
return !!currentProjectKey && projectKey === currentProjectKey;
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function buildCandidateFromExtraction(extracted: ExtractionCandidate): MemoryCandidate {
|
|
645
|
+
return {
|
|
646
|
+
text: extracted.text,
|
|
647
|
+
normalized: extracted.text.toLowerCase(),
|
|
648
|
+
category: extracted.category,
|
|
649
|
+
durability: extracted.durability,
|
|
650
|
+
applicability: extracted.applicability,
|
|
651
|
+
source: "llm_extracted",
|
|
652
|
+
confidence: extracted.confidence,
|
|
653
|
+
explicit: false,
|
|
654
|
+
reasons: [extracted.reason],
|
|
655
|
+
metadata: {
|
|
656
|
+
trigger: extracted.reason,
|
|
657
|
+
stability: extracted.stability,
|
|
658
|
+
sensitivity: extracted.sensitivity,
|
|
659
|
+
suggested_action: extracted.suggestedAction,
|
|
660
|
+
applicability: extracted.applicability,
|
|
661
|
+
...(extracted.applicabilityConfidence !== undefined
|
|
662
|
+
? { applicability_confidence: extracted.applicabilityConfidence }
|
|
663
|
+
: {}),
|
|
664
|
+
...(extracted.applicabilityReason
|
|
665
|
+
? { applicability_reason: extracted.applicabilityReason }
|
|
666
|
+
: {}),
|
|
667
|
+
},
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function buildPromotionMetadata(
|
|
672
|
+
candidate: MemoryCandidate,
|
|
673
|
+
signal: LocalSignal,
|
|
674
|
+
score: number,
|
|
675
|
+
promotionReasons: string[],
|
|
676
|
+
extractorMode: NoodleExtractorMode,
|
|
677
|
+
extraMetadata?: JsonObject,
|
|
678
|
+
): JsonObject {
|
|
679
|
+
return {
|
|
680
|
+
category: candidate.category,
|
|
681
|
+
categories: [candidate.category],
|
|
682
|
+
durability: candidate.durability,
|
|
683
|
+
confidence: signal.strongestConfidence,
|
|
684
|
+
source: signal.count >= 2 && !candidate.explicit ? "repetition" : candidate.source,
|
|
685
|
+
signal_count: signal.count,
|
|
686
|
+
trigger_reasons: signal.reasons,
|
|
687
|
+
promotion_score: score,
|
|
688
|
+
promotion_reasons: promotionReasons,
|
|
689
|
+
assistant_id: DEFAULT_AGENT_ID,
|
|
690
|
+
auto_saved: true,
|
|
691
|
+
extractor_mode: extractorMode,
|
|
692
|
+
...signal.metadata,
|
|
693
|
+
...extraMetadata,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function normalizeText(text: string): string {
|
|
698
|
+
return text.trim().toLowerCase();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function overlapsNormalizedText(left: string, right: string): boolean {
|
|
702
|
+
const normalizedLeft = normalizeText(left);
|
|
703
|
+
const normalizedRight = normalizeText(right);
|
|
704
|
+
return normalizedLeft === normalizedRight
|
|
705
|
+
|| normalizedLeft.includes(normalizedRight)
|
|
706
|
+
|| normalizedRight.includes(normalizedLeft);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function mergeMemoryMetadata(existing: JsonObject, incoming: JsonObject): JsonObject {
|
|
710
|
+
const merged: JsonObject = {
|
|
711
|
+
...existing,
|
|
712
|
+
...incoming,
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
const triggerReasons = new Set<string>();
|
|
716
|
+
const addReasons = (value: unknown) => {
|
|
717
|
+
if (!Array.isArray(value)) return;
|
|
718
|
+
for (const item of value) {
|
|
719
|
+
if (typeof item === "string" && item.trim()) triggerReasons.add(item);
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
addReasons(existing["trigger_reasons"]);
|
|
723
|
+
addReasons(incoming["trigger_reasons"]);
|
|
724
|
+
if (triggerReasons.size > 0) merged["trigger_reasons"] = Array.from(triggerReasons);
|
|
725
|
+
|
|
726
|
+
const existingSignal = typeof existing["signal_count"] === "number" ? existing["signal_count"] : 0;
|
|
727
|
+
const incomingSignal = typeof incoming["signal_count"] === "number" ? incoming["signal_count"] : 0;
|
|
728
|
+
if (existingSignal || incomingSignal) {
|
|
729
|
+
merged["signal_count"] = Math.max(existingSignal, incomingSignal);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const existingConfidence = typeof existing["confidence"] === "number" ? existing["confidence"] : undefined;
|
|
733
|
+
const incomingConfidence = typeof incoming["confidence"] === "number" ? incoming["confidence"] : undefined;
|
|
734
|
+
if (existingConfidence !== undefined || incomingConfidence !== undefined) {
|
|
735
|
+
merged["confidence"] = Math.max(existingConfidence ?? 0, incomingConfidence ?? 0);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const existingRetrievalSignal = typeof existing["retrieval_signal_count"] === "number" ? existing["retrieval_signal_count"] : 0;
|
|
739
|
+
const incomingRetrievalSignal = typeof incoming["retrieval_signal_count"] === "number" ? incoming["retrieval_signal_count"] : 0;
|
|
740
|
+
if (existingRetrievalSignal || incomingRetrievalSignal) {
|
|
741
|
+
merged["retrieval_signal_count"] = Math.max(existingRetrievalSignal, incomingRetrievalSignal);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
merged["last_seen_at"] = Date.now();
|
|
745
|
+
return merged;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function signalToCandidate(signal: LocalSignal): MemoryCandidate {
|
|
749
|
+
return {
|
|
750
|
+
text: signal.text,
|
|
751
|
+
normalized: signal.normalized,
|
|
752
|
+
category: signal.category,
|
|
753
|
+
durability: signal.durability,
|
|
754
|
+
...(signal.applicability ? { applicability: signal.applicability } : {}),
|
|
755
|
+
source: signal.source,
|
|
756
|
+
confidence: signal.strongestConfidence,
|
|
757
|
+
explicit: signal.explicit,
|
|
758
|
+
reasons: signal.reasons,
|
|
759
|
+
metadata: signal.metadata,
|
|
760
|
+
};
|
|
761
|
+
}
|