@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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +231 -0
  3. package/index.ts +1 -0
  4. package/package.json +70 -0
  5. package/src/AGENTS.md +33 -0
  6. package/src/commands/index.ts +51 -0
  7. package/src/commands/memory-crud.ts +136 -0
  8. package/src/commands/review.ts +291 -0
  9. package/src/commands/setup.ts +189 -0
  10. package/src/commands/status.ts +32 -0
  11. package/src/commands/ui.ts +14 -0
  12. package/src/commands/web.ts +40 -0
  13. package/src/commands.ts +1 -0
  14. package/src/config/schema.ts +234 -0
  15. package/src/config-screen.ts +439 -0
  16. package/src/config.ts +159 -0
  17. package/src/constants.ts +1 -0
  18. package/src/debug-overlay.ts +230 -0
  19. package/src/extension.ts +166 -0
  20. package/src/index.ts +1 -0
  21. package/src/memory/backend.ts +22 -0
  22. package/src/memory/embedder.ts +7 -0
  23. package/src/memory/embedders/lm-studio.ts +25 -0
  24. package/src/memory/embedders/openai.ts +66 -0
  25. package/src/memory/extractor.ts +189 -0
  26. package/src/memory/policy.ts +325 -0
  27. package/src/memory/project-identity.ts +51 -0
  28. package/src/memory/runtime.ts +70 -0
  29. package/src/memory/service.ts +761 -0
  30. package/src/memory/turso-backend.ts +716 -0
  31. package/src/memory/types.ts +192 -0
  32. package/src/notifications.ts +11 -0
  33. package/src/queue.ts +42 -0
  34. package/src/session.ts +72 -0
  35. package/src/tools.ts +172 -0
  36. package/src/types.ts +81 -0
  37. package/src/utils.ts +68 -0
  38. package/src/web/dev.ts +7 -0
  39. package/src/web/index.html +1963 -0
  40. package/src/web/manager.ts +92 -0
  41. package/src/web/run.ts +33 -0
  42. package/src/web/server.ts +212 -0
  43. package/tsconfig.json +17 -0
@@ -0,0 +1,189 @@
1
+ import { complete } from "@earendil-works/pi-ai";
2
+ import type { Api, Model, Message } from "@earendil-works/pi-ai";
3
+
4
+ import type {
5
+ ExtractionCandidate,
6
+ ExtractionSensitivity,
7
+ ExtractionStability,
8
+ MemoryApplicability,
9
+ MemoryCategory,
10
+ MemoryDurability,
11
+ MemoryMessage,
12
+ } from "./types.ts";
13
+
14
+ const SYSTEM_PROMPT = `You extract durable, memorable facts from AI assistant conversations.
15
+
16
+ Return a JSON array of memory objects. Each object must have:
17
+ - "text": string — concise third-person statement, max 120 chars (e.g. "User prefers TypeScript for new projects")
18
+ - "category": one of "identity" | "response_style" | "coding_pref" | "workflow" | "project"
19
+ - "durability": one of "durable" | "semi_durable"
20
+ - "confidence": number 0.0–1.0
21
+ - "reason": string — short machine-readable reason such as "explicit_statement" | "repeated_pattern" | "inferred_from_behavior"
22
+ - "stability": one of "stable" | "likely_stable" | "uncertain"
23
+ - "sensitivity": one of "safe" | "sensitive"
24
+ - "suggestedAction": one of "save" | "pending" | "discard"
25
+ - "applicability": one of "user" | "project" | "unknown"
26
+ - "applicabilityConfidence": number 0.0–1.0
27
+ - "applicabilityReason": string — short explanation for why this applies broadly vs to the current project
28
+
29
+ Rules:
30
+ - Only extract facts stable across sessions — not task-specific details
31
+ - Prioritize user defaults, repeated habits, negative preferences, and project conventions likely to matter later
32
+ - Prefer facts stated by the user over assistant speculation
33
+ - "project" means the fact seems tied to the current codebase, product, feature, or initiative rather than the user's broad cross-project preference
34
+ - Prefer "user" only when the user clearly states a general habit or default likely to apply across unrelated projects
35
+ - Use "unknown" when the distinction is ambiguous
36
+ - Skip: file contents, code snippets, transient decisions, error messages, tool results
37
+ - Skip: credentials, API keys, tokens, passwords, private secrets, financial details, and medical details
38
+ - Skip: conversational mechanics ("the user asked", "I replied")
39
+ - Skip: anything that only matters for the current task, file, or response
40
+ - Identity facts (name, role, background) → durable. Preferences and conventions → semi_durable.
41
+ - Use "pending" for plausible but not yet certain durable preferences.
42
+ - Use "discard" when something looks transient, risky, or not worth long-term memory.
43
+ - Be conservative: return [] if nothing clearly warrants long-term memory
44
+ - Return ONLY the JSON array, no other text`;
45
+
46
+ type RawCandidate = {
47
+ text?: unknown;
48
+ category?: unknown;
49
+ durability?: unknown;
50
+ confidence?: unknown;
51
+ reason?: unknown;
52
+ stability?: unknown;
53
+ sensitivity?: unknown;
54
+ suggestedAction?: unknown;
55
+ applicability?: unknown;
56
+ applicabilityConfidence?: unknown;
57
+ applicabilityReason?: unknown;
58
+ };
59
+
60
+ const VALID_CATEGORIES = new Set<string>(["identity", "response_style", "coding_pref", "workflow", "project"]);
61
+ const VALID_DURABILITIES = new Set<string>(["durable", "semi_durable"]);
62
+ const VALID_STABILITIES = new Set<string>(["stable", "likely_stable", "uncertain"]);
63
+ const VALID_SENSITIVITIES = new Set<string>(["safe", "sensitive"]);
64
+ const VALID_ACTIONS = new Set<string>(["save", "pending", "discard"]);
65
+ const VALID_APPLICABILITY = new Set<string>(["user", "project", "unknown"]);
66
+
67
+ function normalizeStability(raw: unknown): ExtractionStability {
68
+ return typeof raw === "string" && VALID_STABILITIES.has(raw) ? raw as ExtractionStability : "uncertain";
69
+ }
70
+
71
+ function normalizeSensitivity(raw: unknown): ExtractionSensitivity {
72
+ return typeof raw === "string" && VALID_SENSITIVITIES.has(raw) ? raw as ExtractionSensitivity : "safe";
73
+ }
74
+
75
+ function normalizeApplicability(raw: unknown): MemoryApplicability {
76
+ return typeof raw === "string" && VALID_APPLICABILITY.has(raw) ? raw as MemoryApplicability : "unknown";
77
+ }
78
+
79
+ function isValidCandidate(raw: unknown): raw is ExtractionCandidate {
80
+ if (!raw || typeof raw !== "object") return false;
81
+ const r = raw as RawCandidate;
82
+ if (typeof r.text !== "string" || r.text.trim().length === 0) return false;
83
+ if (typeof r.category !== "string" || !VALID_CATEGORIES.has(r.category)) return false;
84
+ if (typeof r.durability !== "string" || !VALID_DURABILITIES.has(r.durability)) return false;
85
+ if (typeof r.confidence !== "number" || r.confidence < 0 || r.confidence > 1) return false;
86
+ return true;
87
+ }
88
+
89
+ function parseJsonArray(content: string): unknown[] {
90
+ const trimmed = content.trim();
91
+ try {
92
+ const parsed = JSON.parse(trimmed);
93
+ if (Array.isArray(parsed)) return parsed;
94
+ if (parsed && typeof parsed === "object") {
95
+ for (const val of Object.values(parsed)) {
96
+ if (Array.isArray(val)) return val;
97
+ }
98
+ }
99
+ } catch {
100
+ const match = trimmed.match(/\[[\s\S]*\]/);
101
+ if (match) {
102
+ try {
103
+ const arr = JSON.parse(match[0]);
104
+ if (Array.isArray(arr)) return arr;
105
+ } catch {
106
+ // unparseable
107
+ }
108
+ }
109
+ }
110
+ return [];
111
+ }
112
+
113
+ export function parseExtractedCandidates(content: string): ExtractionCandidate[] {
114
+ if (!content) return [];
115
+
116
+ const raw = parseJsonArray(content);
117
+
118
+ return raw
119
+ .filter(isValidCandidate)
120
+ .map((c) => {
121
+ const applicabilityConfidence = typeof (c as RawCandidate).applicabilityConfidence === "number"
122
+ ? ((c as RawCandidate).applicabilityConfidence as number)
123
+ : undefined;
124
+ const applicabilityReason = typeof (c as RawCandidate).applicabilityReason === "string"
125
+ ? ((c as RawCandidate).applicabilityReason as string)
126
+ : undefined;
127
+
128
+ return {
129
+ text: (c as ExtractionCandidate).text.trim(),
130
+ category: (c as ExtractionCandidate).category as MemoryCategory,
131
+ durability: (c as ExtractionCandidate).durability as MemoryDurability,
132
+ confidence: (c as ExtractionCandidate).confidence,
133
+ reason: typeof (c as RawCandidate).reason === "string"
134
+ ? ((c as RawCandidate).reason as string)
135
+ : "llm_extracted",
136
+ stability: normalizeStability((c as RawCandidate).stability),
137
+ sensitivity: normalizeSensitivity((c as RawCandidate).sensitivity),
138
+ suggestedAction: typeof (c as RawCandidate).suggestedAction === "string" && VALID_ACTIONS.has((c as RawCandidate).suggestedAction as string)
139
+ ? ((c as RawCandidate).suggestedAction as "save" | "pending" | "discard")
140
+ : "pending",
141
+ applicability: normalizeApplicability((c as RawCandidate).applicability),
142
+ ...(applicabilityConfidence !== undefined ? { applicabilityConfidence } : {}),
143
+ ...(applicabilityReason ? { applicabilityReason } : {}),
144
+ };
145
+ });
146
+ }
147
+
148
+ export async function extractMemoriesFromMessages(
149
+ messages: MemoryMessage[],
150
+ model: Model<Api>,
151
+ options?: { apiKey?: string; headers?: Record<string, string>; signal?: AbortSignal },
152
+ ): Promise<ExtractionCandidate[]> {
153
+ if (messages.length === 0) return [];
154
+
155
+ const conversationText = messages
156
+ .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
157
+ .join("\n\n");
158
+
159
+ const userMessage: Message = {
160
+ role: "user",
161
+ content: [{
162
+ type: "text",
163
+ text: `Extract memorable facts from this conversation:\n\n${conversationText}`,
164
+ }],
165
+ timestamp: Date.now(),
166
+ };
167
+
168
+ // Use complete() rather than completeSimple() so we can pass the API key
169
+ // and headers resolved from ctx.modelRegistry.getApiKeyAndHeaders().
170
+ // completeSimple() accepts options but doesn't get auth from the model
171
+ // registry — it only checks env vars, which misses SSO/OAuth setups.
172
+ const result = await complete(model, {
173
+ systemPrompt: SYSTEM_PROMPT,
174
+ messages: [userMessage],
175
+ }, {
176
+ temperature: 0,
177
+ maxTokens: 1000,
178
+ ...(options?.apiKey ? { apiKey: options.apiKey } : {}),
179
+ ...(options?.headers ? { headers: options.headers } : {}),
180
+ ...(options?.signal ? { signal: options.signal } : {}),
181
+ });
182
+
183
+ const content = result.content
184
+ .filter((c) => c.type === "text")
185
+ .map((c) => (c as { type: "text"; text: string }).text)
186
+ .join("");
187
+
188
+ return parseExtractedCandidates(content);
189
+ }
@@ -0,0 +1,325 @@
1
+ import type {
2
+ LocalSignal,
3
+ MemoryApplicability,
4
+ MemoryCandidate,
5
+ MemoryCategory,
6
+ MemoryDurability,
7
+ MemoryPolicyDecision,
8
+ PrefilterResult,
9
+ } from "./types.ts";
10
+ import type { NoodleExtractorMode } from "../types.ts";
11
+
12
+ const EXPLICIT_PATTERNS: Array<{
13
+ pattern: RegExp;
14
+ category: MemoryCategory;
15
+ durability: MemoryDurability;
16
+ reason: string;
17
+ confidence?: number;
18
+ explicit?: boolean;
19
+ }> = [
20
+ {
21
+ pattern: /\bremember(?:\s+that)?\s+([^.!?\n]+)/i,
22
+ category: "project",
23
+ durability: "semi_durable",
24
+ reason: "explicit_memory_request",
25
+ confidence: 0.99,
26
+ explicit: true,
27
+ },
28
+ ];
29
+
30
+ const RETRIEVAL_PATTERNS: RegExp[] = [
31
+ /\b(call me|what should you call me|my name|nickname)\b/i,
32
+ /\b(prefer|by default|always|never|concise|verbose|brief|detailed|usually|normally|avoid|should i use|should i avoid)\b/i,
33
+ /\b(code|implement|refactor|fix|review|format|summari[sz]e|plan|debug|test|language|runtime|stack|daemon|backend|script)\b/i,
34
+ ];
35
+
36
+ const TEMPORARY_PATTERNS: RegExp[] = [
37
+ /\b(for this|this time|for now|today only|in this response|for this task|for this file|for this repo)\b/i,
38
+ /\b(current task|temporary|right now)\b/i,
39
+ ];
40
+
41
+ const SENSITIVE_PATTERNS: RegExp[] = [
42
+ /\b(api[_ -]?key|token|secret|password|passwd|private key|ssh key|oauth)\b/i,
43
+ /\bm0sk_[a-z0-9]+\b/i,
44
+ /\bsk-[a-z0-9]+\b/i,
45
+ /authorization:\s*bearer/i,
46
+ ];
47
+
48
+ const STYLE_HINTS = /\b(concise|brief|short|verbose|detailed|bullet points?|markdown|plain text)\b/i;
49
+ const CODING_CONTEXT_HINTS = /\b(code|coding|implementation|implement|function|script|library|framework|stack|tool|tooling|test|testing|formatter|lint|cli|backend|frontend|language|daemon)\b/i;
50
+ const GENERAL_APPLICABILITY_HINTS = /\b(in general|generally|usually|normally|by default|always|never|across projects?)\b/i;
51
+ const PROJECT_APPLICABILITY_HINTS = /\b(for this project|in this project|for this repo|in this repo|for this codebase|in this codebase|for this app|for this feature|for this refactor|for this component|for the web viewer|for this viewer|current project|current codebase)\b/i;
52
+
53
+ function normalizeMemoryText(text: string): string {
54
+ return text
55
+ .trim()
56
+ .replace(/^that\s+/i, "")
57
+ .replace(/^to\s+/i, "")
58
+ .replace(/\s+/g, " ")
59
+ .replace(/[.]+$/, "");
60
+ }
61
+
62
+ function canonicalizeCandidateText(_category: MemoryCategory, _sourceText: string, extracted: string, _reason: string): string {
63
+ return extracted;
64
+ }
65
+
66
+ function inferCategory(text: string, fallback: MemoryCategory, _reason: string): MemoryCategory {
67
+ if (/\b(call me|my name|nickname)\b/i.test(text)) return "identity";
68
+ if (STYLE_HINTS.test(text)) return "response_style";
69
+ if (CODING_CONTEXT_HINTS.test(text)) return "coding_pref";
70
+ return fallback;
71
+ }
72
+
73
+ function inferDurability(text: string, fallback: MemoryDurability): MemoryDurability {
74
+ if (TEMPORARY_PATTERNS.some((pattern) => pattern.test(text))) return "ephemeral";
75
+ return fallback;
76
+ }
77
+
78
+ function confidenceFor(entry: { confidence?: number }, category: MemoryCategory): number {
79
+ if (typeof entry.confidence === "number") return entry.confidence;
80
+ return category === "identity" ? 0.96 : 0.8;
81
+ }
82
+
83
+ function inferApplicability(text: string, category: MemoryCategory): MemoryApplicability {
84
+ if (PROJECT_APPLICABILITY_HINTS.test(text)) return "project";
85
+ if (GENERAL_APPLICABILITY_HINTS.test(text)) return "user";
86
+ if (category === "identity" || category === "response_style") return "user";
87
+ if (category === "project") return "project";
88
+ return "unknown";
89
+ }
90
+
91
+ export function buildSignalKey(candidate: MemoryCandidate): string {
92
+ return `${candidate.category}:${candidate.normalized}`;
93
+ }
94
+
95
+ export function shouldBlockSensitiveMemory(text: string): boolean {
96
+ return SENSITIVE_PATTERNS.some((pattern) => pattern.test(text));
97
+ }
98
+
99
+ export function shouldRetrieveMemories(prompt: string): boolean {
100
+ return RETRIEVAL_PATTERNS.some((pattern) => pattern.test(prompt));
101
+ }
102
+
103
+ export function categoriesForPrompt(prompt: string): MemoryCategory[] {
104
+ const categories: MemoryCategory[] = ["identity", "response_style"];
105
+
106
+ if (/\b(code|implement|implementation|refactor|fix|test|review|debug|scripts?|function|library|framework|tool|tooling|stack|daemon|backend|services?|language|runtime)\b/i.test(prompt)) {
107
+ categories.push("coding_pref", "workflow");
108
+ }
109
+
110
+ if (/\b(repo|project|convention|branch|workflow|team|codebase|stack)\b/i.test(prompt)) {
111
+ categories.push("project", "workflow");
112
+ }
113
+
114
+ return Array.from(new Set(categories));
115
+ }
116
+
117
+ export function tokenizePrompt(text: string): string[] {
118
+ return text
119
+ .toLowerCase()
120
+ .split(/[^a-z0-9]+/i)
121
+ .filter((token) => token.length >= 3);
122
+ }
123
+
124
+ export function scoreMemoryText(memoryText: string, queryTokens: string[], categories: string[], memoryCategories: string[], durability?: unknown): number {
125
+ let score = 0;
126
+ const normalizedText = memoryText.toLowerCase();
127
+
128
+ for (const token of queryTokens) {
129
+ if (normalizedText.includes(token)) score += 2;
130
+ }
131
+
132
+ for (const category of categories) {
133
+ if (memoryCategories.includes(category)) score += 3;
134
+ }
135
+
136
+ if (durability === "durable") score += 1;
137
+ return score;
138
+ }
139
+
140
+ // The extractor can suggest an action, but local policy makes the final decision.
141
+ // This keeps persistence deterministic, testable, and easy to tune by mode.
142
+ export function evaluateCandidateDecision(
143
+ candidate: MemoryCandidate,
144
+ signal: LocalSignal,
145
+ mode: NoodleExtractorMode = "balanced",
146
+ ): MemoryPolicyDecision {
147
+ let score = 0;
148
+ const reasons: string[] = [];
149
+
150
+ // 1. Explicit asks should be saved immediately.
151
+ if (candidate.explicit) {
152
+ score += 10;
153
+ reasons.push("explicit_request");
154
+ }
155
+
156
+ // 2. Identity facts are durable and usually useful later.
157
+ if (candidate.category === "identity") {
158
+ score += 6;
159
+ reasons.push("identity_fact");
160
+ }
161
+
162
+ // 3. Strong wording (negative preferences/defaults/standards) is high-value.
163
+ if (candidate.reasons.includes("negative_preference")) {
164
+ score += 5;
165
+ reasons.push("negative_preference");
166
+ } else if (candidate.reasons.includes("strong_preference")) {
167
+ score += 4;
168
+ reasons.push("strong_preference");
169
+ } else if (candidate.reasons.some((reason) => ["default_preference", "workflow_default", "project_standard", "project_default", "project_stack", "tech_decision"].includes(reason))) {
170
+ score += 3;
171
+ reasons.push("project_or_default_convention");
172
+ }
173
+
174
+ // 4. Repetition is the safest non-explicit promotion signal.
175
+ if (signal.count >= 4) {
176
+ score += 5;
177
+ reasons.push("repeated_signal");
178
+ } else if (signal.count >= 3) {
179
+ score += 4;
180
+ reasons.push("repeated_signal");
181
+ } else if (signal.count >= 2) {
182
+ score += 3;
183
+ reasons.push("repeated_signal");
184
+ }
185
+
186
+ // 5. Confidence should help, but not overrule durability/repetition by itself.
187
+ if (signal.strongestConfidence >= 0.9) {
188
+ score += 2;
189
+ reasons.push("very_high_confidence");
190
+ } else if (signal.strongestConfidence >= 0.75) {
191
+ score += 1;
192
+ reasons.push("high_confidence");
193
+ }
194
+
195
+ // 6. Retrieval telemetry is weak but useful evidence that the fact mattered.
196
+ if ((signal.retrievalCount ?? 0) >= 3) {
197
+ score += 2;
198
+ reasons.push("retrieved_repeatedly");
199
+ } else if ((signal.retrievalCount ?? 0) >= 1) {
200
+ score += 1;
201
+ reasons.push("retrieved_once");
202
+ }
203
+
204
+ // 7. Durable preference categories deserve some weight even before they repeat a lot.
205
+ if (candidate.category === "coding_pref" || candidate.category === "response_style") {
206
+ score += 1;
207
+ reasons.push("preference_category");
208
+ }
209
+
210
+ // 8. Project standards / stack declarations are valuable even on first mention.
211
+ if (candidate.category === "project" && candidate.reasons.some((reason) => ["project_standard", "project_stack", "tech_decision"].includes(reason))) {
212
+ score += 2;
213
+ reasons.push("project_convention");
214
+ }
215
+
216
+ // 9. Durable memories get a slight nudge over semi-durable habits.
217
+ if (candidate.durability === "durable") {
218
+ score += 1;
219
+ reasons.push("durable");
220
+ }
221
+
222
+ const sensitivity = candidate.metadata["sensitivity"];
223
+ if (sensitivity === "sensitive") {
224
+ reasons.push("sensitive_blocked");
225
+ return {
226
+ action: "discard",
227
+ score,
228
+ shouldPromote: false,
229
+ reasons,
230
+ };
231
+ }
232
+
233
+ if (candidate.explicit) {
234
+ return { action: "save", score, shouldPromote: true, reasons };
235
+ }
236
+
237
+ if (candidate.category === "identity" && signal.strongestConfidence >= 0.9) {
238
+ return { action: "save", score, shouldPromote: true, reasons };
239
+ }
240
+
241
+ if (mode === "conservative") {
242
+ if (score >= 7) return { action: "save", score, shouldPromote: true, reasons };
243
+ return { action: "discard", score, shouldPromote: false, reasons };
244
+ }
245
+
246
+ if (score >= (mode === "proactive" ? 6 : 5)) {
247
+ return { action: "save", score, shouldPromote: true, reasons };
248
+ }
249
+
250
+ const isPreferenceLike = candidate.category === "coding_pref" || candidate.category === "response_style" || candidate.category === "workflow" || candidate.category === "project";
251
+ const confidenceFloor = mode === "proactive" ? 0.65 : 0.72;
252
+ if (isPreferenceLike && signal.strongestConfidence >= confidenceFloor) {
253
+ return { action: "pending", score, shouldPromote: false, reasons };
254
+ }
255
+
256
+ return { action: "discard", score, shouldPromote: false, reasons };
257
+ }
258
+
259
+ export function evaluateCandidatePromotion(candidate: MemoryCandidate, signal: LocalSignal): {
260
+ score: number;
261
+ shouldPromote: boolean;
262
+ reasons: string[];
263
+ } {
264
+ const decision = evaluateCandidateDecision(candidate, signal, "balanced");
265
+ return {
266
+ score: decision.score,
267
+ shouldPromote: decision.shouldPromote,
268
+ reasons: decision.reasons,
269
+ };
270
+ }
271
+
272
+ export function prefilterUserMessage(text: string): PrefilterResult {
273
+ const candidates: MemoryCandidate[] = [];
274
+ const candidateReasons = new Set<string>();
275
+
276
+ if (shouldBlockSensitiveMemory(text)) {
277
+ return {
278
+ hasCandidate: false,
279
+ shouldRetrieve: shouldRetrieveMemories(text),
280
+ candidateReasons: ["sensitive_content_blocked"],
281
+ candidates,
282
+ };
283
+ }
284
+
285
+ for (const entry of EXPLICIT_PATTERNS) {
286
+ const match = text.match(entry.pattern);
287
+ if (!match) continue;
288
+
289
+ const rawExtracted = normalizeMemoryText(match[1] || match[2] || match[0]);
290
+ if (!rawExtracted) continue;
291
+
292
+ const category = inferCategory(text, entry.category, entry.reason);
293
+ const durability = inferDurability(text, entry.durability);
294
+ if (durability === "ephemeral") continue;
295
+
296
+ const extracted = rawExtracted.replace(/^use\s+/i, "");
297
+ const canonicalText = canonicalizeCandidateText(category, text, extracted, entry.reason);
298
+ candidateReasons.add(entry.reason);
299
+ const applicability = inferApplicability(text, category);
300
+ candidates.push({
301
+ text: canonicalText,
302
+ normalized: canonicalText.toLowerCase(),
303
+ category,
304
+ durability,
305
+ applicability,
306
+ source: entry.reason === "explicit_memory_request" ? "explicit" : "heuristic",
307
+ confidence: confidenceFor(entry, category),
308
+ explicit: entry.explicit === true,
309
+ reasons: [entry.reason],
310
+ metadata: {
311
+ trigger: entry.reason,
312
+ applicability,
313
+ },
314
+ });
315
+ }
316
+
317
+ const deduped = Array.from(new Map(candidates.map((candidate) => [buildSignalKey(candidate), candidate])).values());
318
+
319
+ return {
320
+ hasCandidate: deduped.length > 0,
321
+ shouldRetrieve: shouldRetrieveMemories(text),
322
+ candidateReasons: Array.from(candidateReasons),
323
+ candidates: deduped,
324
+ };
325
+ }
@@ -0,0 +1,51 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ function runGit(args: string[], cwd: string): string | null {
6
+ try {
7
+ const output = execFileSync("git", args, {
8
+ cwd,
9
+ encoding: "utf8",
10
+ stdio: ["ignore", "pipe", "ignore"],
11
+ }).trim();
12
+ return output || null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ export function normalizeGitRemote(remote: string): string | null {
19
+ const trimmed = remote.trim();
20
+ if (!trimmed) return null;
21
+
22
+ const withoutGitSuffix = trimmed.replace(/\.git$/i, "");
23
+ const sshLike = withoutGitSuffix.match(/^(?:ssh:\/\/)?git@([^/:]+)[:/]([^\s]+)$/i);
24
+ if (sshLike) {
25
+ return `${sshLike[1]}/${sshLike[2]}`.replace(/^\/*/, "");
26
+ }
27
+
28
+ try {
29
+ const url = new URL(withoutGitSuffix);
30
+ if (!url.hostname || !url.pathname) return null;
31
+ return `${url.hostname}${url.pathname}`.replace(/^\/*/, "");
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export function deriveProjectKey(cwd = process.cwd()): string | null {
38
+ const absoluteCwd = resolve(cwd);
39
+ const repoRoot = runGit(["rev-parse", "--show-toplevel"], absoluteCwd);
40
+ const gitCwd = repoRoot ?? absoluteCwd;
41
+
42
+ const remote = runGit(["config", "--get", "remote.origin.url"], gitCwd);
43
+ const normalizedRemote = remote ? normalizeGitRemote(remote) : null;
44
+ if (normalizedRemote) return normalizedRemote;
45
+
46
+ try {
47
+ return `cwd:${realpathSync(gitCwd)}`;
48
+ } catch {
49
+ return `cwd:${gitCwd}`;
50
+ }
51
+ }
@@ -0,0 +1,70 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ import { createClient } from "@libsql/client";
5
+ import { DEFAULT_EXTRACTOR_MODE, defaultExtractorTriggerEvery, resolveConfig } from "../config.ts";
6
+ import type { NoodleConfig } from "../types.ts";
7
+ import type { MemoryBackend } from "./backend.ts";
8
+ import { createOpenAIEmbedder } from "./embedders/openai.ts";
9
+ import { MemoryService } from "./service.ts";
10
+ import { TursoBackend } from "./turso-backend.ts";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Runtime wiring
14
+ //
15
+ // Config is resolved from:
16
+ // 1. Defaults — local DB at ~/.pi/noodle/memories.db, OpenAI embedder
17
+ // 2. ~/.pi/noodle/config.json — persisted by /noodle settings
18
+ // 3. Environment variables — NOODLE_DB_PATH, OPENAI_API_KEY, etc.
19
+ //
20
+ // Use /noodle in Pi to view the current config.
21
+ // Use /noodle settings to configure interactively.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function createBackend(config: NoodleConfig): MemoryBackend {
25
+ if (config.db.mode === "local") {
26
+ mkdirSync(dirname(config.db.path), { recursive: true });
27
+ }
28
+
29
+ let dbUrl: string;
30
+ if (config.db.mode === "cloud") {
31
+ dbUrl = config.db.url ?? "libsql://";
32
+ } else {
33
+ dbUrl = `file:${config.db.path}`;
34
+ }
35
+
36
+ const embedder = createOpenAIEmbedder({
37
+ apiKey: config.embedding.apiKey,
38
+ baseUrl: config.embedding.baseUrl,
39
+ ...(config.embedding.model ? { model: config.embedding.model } : {}),
40
+ ...(config.embedding.dimensions ? { dimensions: config.embedding.dimensions } : {}),
41
+ });
42
+
43
+ const dbOptions: Record<string, unknown> = { url: dbUrl };
44
+ if (config.db.mode === "cloud" && config.db.authToken) {
45
+ dbOptions.authToken = config.db.authToken;
46
+ }
47
+
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ const db = createClient(dbOptions as any);
50
+ return new TursoBackend(db, embedder, {
51
+ provider: config.embedding.provider,
52
+ model: config.embedding.model,
53
+ baseUrl: config.embedding.baseUrl,
54
+ });
55
+ }
56
+
57
+ const config = resolveConfig();
58
+ export const memoryService = new MemoryService(createBackend(config), {
59
+ extractorMode: config.extractor?.mode ?? DEFAULT_EXTRACTOR_MODE,
60
+ extractorTriggerEvery: config.extractor?.triggerEvery ?? defaultExtractorTriggerEvery((config.extractor?.mode ?? DEFAULT_EXTRACTOR_MODE) === "off" ? DEFAULT_EXTRACTOR_MODE : (config.extractor?.mode ?? DEFAULT_EXTRACTOR_MODE)),
61
+ });
62
+
63
+ /** Behavior profile for proactive extraction. */
64
+ export const extractorMode = config.extractor?.mode ?? DEFAULT_EXTRACTOR_MODE;
65
+ /** Model ID to use for extraction. Extraction is skipped when unset. */
66
+ export const extractorModelId = config.extractor?.model ?? undefined;
67
+ /** How many user turns trigger an extraction pass. */
68
+ export const extractorTriggerEvery = config.extractor?.triggerEvery ?? defaultExtractorTriggerEvery(extractorMode === "off" ? DEFAULT_EXTRACTOR_MODE : extractorMode);
69
+ /** Whether to show the extractor debug widget in Pi. */
70
+ export const extractorDebug = config.extractor?.debug ?? false;