@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
package/src/config.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { NoodleConfig, NoodleConfigPartial, NoodleExtractorMode } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Paths — user-level, not project-level (memories travel with the user)
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const NOODLE_DIR = join(homedir(), ".pi", "noodle");
|
|
11
|
+
|
|
12
|
+
export function resolveConfigPath(): string {
|
|
13
|
+
return process.env["NOODLE_CONFIG_PATH"] ?? join(NOODLE_DIR, "config.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Defaults
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_EXTRACTOR_MODE: NoodleExtractorMode = "balanced";
|
|
21
|
+
export const DISABLED_EXTRACTOR_MODE: NoodleExtractorMode = "off";
|
|
22
|
+
|
|
23
|
+
export function defaultExtractorTriggerEvery(mode: NoodleExtractorMode = DEFAULT_EXTRACTOR_MODE): number {
|
|
24
|
+
switch (mode) {
|
|
25
|
+
case "conservative":
|
|
26
|
+
return 20;
|
|
27
|
+
case "proactive":
|
|
28
|
+
return 5;
|
|
29
|
+
case "off":
|
|
30
|
+
case "balanced":
|
|
31
|
+
default:
|
|
32
|
+
return 10;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULTS: NoodleConfig = {
|
|
37
|
+
db: {
|
|
38
|
+
mode: "local",
|
|
39
|
+
path: join(NOODLE_DIR, "memories.db"),
|
|
40
|
+
},
|
|
41
|
+
embedding: {
|
|
42
|
+
provider: "openai",
|
|
43
|
+
apiKey: "",
|
|
44
|
+
baseUrl: "https://api.openai.com/v1",
|
|
45
|
+
model: "text-embedding-3-small",
|
|
46
|
+
},
|
|
47
|
+
extractor: {
|
|
48
|
+
mode: DISABLED_EXTRACTOR_MODE,
|
|
49
|
+
triggerEvery: defaultExtractorTriggerEvery(DEFAULT_EXTRACTOR_MODE),
|
|
50
|
+
debug: false,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Read
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export function resolveConfig(): NoodleConfig {
|
|
59
|
+
const config = structuredClone(DEFAULTS);
|
|
60
|
+
|
|
61
|
+
// File overlays defaults
|
|
62
|
+
const filePath = resolveConfigPath();
|
|
63
|
+
if (existsSync(filePath)) {
|
|
64
|
+
try {
|
|
65
|
+
mergeInto(config, JSON.parse(readFileSync(filePath, "utf-8")));
|
|
66
|
+
} catch {
|
|
67
|
+
// corrupt file — skip to env
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Environment variables take highest priority
|
|
72
|
+
const env = process.env;
|
|
73
|
+
if (env["NOODLE_DB_PATH"]) config.db.path = env["NOODLE_DB_PATH"];
|
|
74
|
+
if (env["NOODLE_DB_URL"]) {
|
|
75
|
+
config.db.url = env["NOODLE_DB_URL"];
|
|
76
|
+
config.db.mode = "cloud";
|
|
77
|
+
}
|
|
78
|
+
if (env["NOODLE_DB_TOKEN"]) config.db.authToken = env["NOODLE_DB_TOKEN"];
|
|
79
|
+
if (env["OPENAI_API_KEY"]) config.embedding.apiKey = env["OPENAI_API_KEY"];
|
|
80
|
+
if (env["EMBEDDING_BASE_URL"]) config.embedding.baseUrl = env["EMBEDDING_BASE_URL"];
|
|
81
|
+
if (env["EMBEDDING_MODEL"]) config.embedding.model = env["EMBEDDING_MODEL"];
|
|
82
|
+
if (env["EMBEDDING_DIMENSIONS"]) {
|
|
83
|
+
const n = parseInt(env["EMBEDDING_DIMENSIONS"], 10);
|
|
84
|
+
if (!isNaN(n) && n > 0) config.embedding.dimensions = n;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Extractor env overrides
|
|
88
|
+
if (env["NOODLE_EXTRACTOR_MODE"]) {
|
|
89
|
+
const mode = env["NOODLE_EXTRACTOR_MODE"];
|
|
90
|
+
if (mode === "off" || mode === "conservative" || mode === "balanced" || mode === "proactive") {
|
|
91
|
+
if (!config.extractor) config.extractor = {};
|
|
92
|
+
config.extractor.mode = mode;
|
|
93
|
+
if (!config.extractor.triggerEvery || config.extractor.triggerEvery < 1) {
|
|
94
|
+
config.extractor.triggerEvery = defaultExtractorTriggerEvery(mode === "off" ? DEFAULT_EXTRACTOR_MODE : mode);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (env["NOODLE_EXTRACTOR_MODEL"]) {
|
|
99
|
+
if (!config.extractor) config.extractor = {};
|
|
100
|
+
config.extractor.model = env["NOODLE_EXTRACTOR_MODEL"];
|
|
101
|
+
config.extractor.mode ??= DEFAULT_EXTRACTOR_MODE;
|
|
102
|
+
}
|
|
103
|
+
if (env["NOODLE_EXTRACTOR_TRIGGER_EVERY"]) {
|
|
104
|
+
const n = parseInt(env["NOODLE_EXTRACTOR_TRIGGER_EVERY"], 10);
|
|
105
|
+
if (!isNaN(n) && n > 0) {
|
|
106
|
+
if (!config.extractor) config.extractor = {};
|
|
107
|
+
config.extractor.triggerEvery = n;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (env["NOODLE_EXTRACTOR_DEBUG"]) {
|
|
111
|
+
if (!config.extractor) config.extractor = {};
|
|
112
|
+
config.extractor.debug = ["1", "true", "yes", "on"].includes(env["NOODLE_EXTRACTOR_DEBUG"].toLowerCase());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (config.extractor) {
|
|
116
|
+
config.extractor.mode ??= DISABLED_EXTRACTOR_MODE;
|
|
117
|
+
config.extractor.triggerEvery = config.extractor.triggerEvery && config.extractor.triggerEvery > 0
|
|
118
|
+
? config.extractor.triggerEvery
|
|
119
|
+
: defaultExtractorTriggerEvery(config.extractor.mode === "off" ? DEFAULT_EXTRACTOR_MODE : config.extractor.mode);
|
|
120
|
+
config.extractor.debug ??= false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return config;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Write
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export function writeConfig(partial: NoodleConfigPartial): void {
|
|
131
|
+
const config = resolveConfig();
|
|
132
|
+
mergeInto(config, partial);
|
|
133
|
+
|
|
134
|
+
const filePath = resolveConfigPath();
|
|
135
|
+
mkdirSync(NOODLE_DIR, { recursive: true });
|
|
136
|
+
writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Internal helpers
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
type PlainObject = Record<string, unknown>;
|
|
144
|
+
|
|
145
|
+
function isPlainObject(value: unknown): value is PlainObject {
|
|
146
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function mergeInto(target: PlainObject, source: PlainObject): void {
|
|
150
|
+
for (const key of Object.keys(source)) {
|
|
151
|
+
const src = source[key];
|
|
152
|
+
const dst = target[key];
|
|
153
|
+
if (isPlainObject(src) && isPlainObject(dst)) {
|
|
154
|
+
mergeInto(dst, src);
|
|
155
|
+
} else {
|
|
156
|
+
target[key] = src;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_AGENT_ID = "pi";
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
type DebugRunStatus = "queued" | "running" | "success" | "error" | "skipped";
|
|
4
|
+
|
|
5
|
+
type DebugRunSummary = {
|
|
6
|
+
startedAt: number;
|
|
7
|
+
finishedAt?: number;
|
|
8
|
+
status: DebugRunStatus;
|
|
9
|
+
reason: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
candidateTexts: string[];
|
|
12
|
+
savedCount: number;
|
|
13
|
+
error?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type DebugState = {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
mode: string;
|
|
19
|
+
triggerEvery: number;
|
|
20
|
+
sessionTurnCount: number;
|
|
21
|
+
turnsUntilNextRun: number;
|
|
22
|
+
isRunning: boolean;
|
|
23
|
+
lastRun?: DebugRunSummary;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const state: DebugState = {
|
|
27
|
+
enabled: false,
|
|
28
|
+
mode: "off",
|
|
29
|
+
triggerEvery: 10,
|
|
30
|
+
sessionTurnCount: 0,
|
|
31
|
+
turnsUntilNextRun: 10,
|
|
32
|
+
isRunning: false,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const sessions = new Set<ExtensionContext>();
|
|
36
|
+
const WIDGET_KEY = "noodle-extractor-debug-widget";
|
|
37
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
38
|
+
const MIN_RUNNING_MS = 900;
|
|
39
|
+
|
|
40
|
+
let spinnerIndex = 0;
|
|
41
|
+
let spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
42
|
+
|
|
43
|
+
function emit(): void {
|
|
44
|
+
for (const ctx of sessions) {
|
|
45
|
+
ctx.ui.setWidget(WIDGET_KEY, renderWidget(ctx));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatAge(timestamp?: number): string {
|
|
50
|
+
if (!timestamp) return "—";
|
|
51
|
+
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
|
|
52
|
+
if (seconds < 60) return `${seconds}s`;
|
|
53
|
+
const minutes = Math.floor(seconds / 60);
|
|
54
|
+
return `${minutes}m${seconds % 60}s`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function startSpinner(): void {
|
|
58
|
+
if (spinnerTimer) return;
|
|
59
|
+
spinnerTimer = setInterval(() => {
|
|
60
|
+
spinnerIndex = (spinnerIndex + 1) % SPINNER_FRAMES.length;
|
|
61
|
+
emit();
|
|
62
|
+
}, 90);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stopSpinner(): void {
|
|
66
|
+
if (!spinnerTimer) return;
|
|
67
|
+
clearInterval(spinnerTimer);
|
|
68
|
+
spinnerTimer = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderStateLabel(ctx: ExtensionContext): string {
|
|
72
|
+
const theme = ctx.ui.theme;
|
|
73
|
+
if (state.isRunning) {
|
|
74
|
+
return `${theme.fg("accent", SPINNER_FRAMES[spinnerIndex] ?? "⠋")} ${theme.fg("accent", "running")}`;
|
|
75
|
+
}
|
|
76
|
+
switch (state.lastRun?.status) {
|
|
77
|
+
case "queued":
|
|
78
|
+
return `${theme.fg("warning", "…")} ${theme.fg("warning", "queued")}`;
|
|
79
|
+
case "error":
|
|
80
|
+
return theme.fg("error", "error");
|
|
81
|
+
case "skipped":
|
|
82
|
+
return theme.fg("warning", "skipped");
|
|
83
|
+
case "success":
|
|
84
|
+
return theme.fg("success", "ready");
|
|
85
|
+
default:
|
|
86
|
+
return theme.fg("dim", "idle");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function summarizeReason(reason: string): string {
|
|
91
|
+
if (reason.startsWith("shutdown:")) return `shutdown (${reason.slice("shutdown:".length)})`;
|
|
92
|
+
return reason === "scheduled" ? "scheduled cadence" : reason;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderWidget(ctx: ExtensionContext): string[] | undefined {
|
|
96
|
+
if (!state.enabled) return undefined;
|
|
97
|
+
|
|
98
|
+
const theme = ctx.ui.theme;
|
|
99
|
+
const title = theme.bold?.("Noodle extractor") ?? "Noodle extractor";
|
|
100
|
+
const nextRun = state.turnsUntilNextRun === 0
|
|
101
|
+
? "this turn"
|
|
102
|
+
: `in ${state.turnsUntilNextRun} turn${state.turnsUntilNextRun === 1 ? "" : "s"}`;
|
|
103
|
+
|
|
104
|
+
const lines = [
|
|
105
|
+
title,
|
|
106
|
+
theme.fg("dim", `mode ${state.mode} • every ${state.triggerEvery} turns • next ${nextRun}`),
|
|
107
|
+
renderStateLabel(ctx),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
if (!state.lastRun) return lines;
|
|
111
|
+
|
|
112
|
+
lines.push(theme.fg("dim", `last run ${formatAge(state.lastRun.finishedAt ?? state.lastRun.startedAt)} ago • ${summarizeReason(state.lastRun.reason)}`));
|
|
113
|
+
if (state.lastRun.model) lines.push(theme.fg("dim", `model ${state.lastRun.model}`));
|
|
114
|
+
|
|
115
|
+
if (state.lastRun.status === "success") {
|
|
116
|
+
lines.push(theme.fg("dim", `pulled ${state.lastRun.candidateTexts.length} candidate${state.lastRun.candidateTexts.length === 1 ? "" : "s"} • saved ${state.lastRun.savedCount}`));
|
|
117
|
+
if (state.lastRun.candidateTexts.length > 0) {
|
|
118
|
+
lines.push(...state.lastRun.candidateTexts.slice(0, 2).map((text) => theme.fg("dim", `• ${text}`)));
|
|
119
|
+
} else {
|
|
120
|
+
lines.push(theme.fg("dim", "• nothing durable found"));
|
|
121
|
+
}
|
|
122
|
+
} else if (state.lastRun.status === "skipped") {
|
|
123
|
+
lines.push(theme.fg("dim", `• ${state.lastRun.reason}`));
|
|
124
|
+
} else if (state.lastRun.status === "error" && state.lastRun.error) {
|
|
125
|
+
lines.push(theme.fg("dim", `• ${state.lastRun.error}`));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function finalizeRun(
|
|
132
|
+
status: Extract<DebugRunStatus, "success" | "error">,
|
|
133
|
+
options: { candidateTexts?: string[]; savedCount?: number; error?: string },
|
|
134
|
+
): void {
|
|
135
|
+
const apply = () => {
|
|
136
|
+
state.isRunning = false;
|
|
137
|
+
stopSpinner();
|
|
138
|
+
state.lastRun = {
|
|
139
|
+
startedAt: state.lastRun?.startedAt ?? Date.now(),
|
|
140
|
+
finishedAt: Date.now(),
|
|
141
|
+
status,
|
|
142
|
+
reason: state.lastRun?.reason ?? "scheduled",
|
|
143
|
+
...(state.lastRun?.model ? { model: state.lastRun.model } : {}),
|
|
144
|
+
candidateTexts: options.candidateTexts?.slice(0, 3) ?? state.lastRun?.candidateTexts ?? [],
|
|
145
|
+
savedCount: options.savedCount ?? state.lastRun?.savedCount ?? 0,
|
|
146
|
+
...(options.error ? { error: options.error } : {}),
|
|
147
|
+
};
|
|
148
|
+
emit();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const startedAt = state.lastRun?.startedAt ?? Date.now();
|
|
152
|
+
const remaining = Math.max(0, MIN_RUNNING_MS - (Date.now() - startedAt));
|
|
153
|
+
if (remaining > 0) {
|
|
154
|
+
setTimeout(apply, remaining);
|
|
155
|
+
} else {
|
|
156
|
+
apply();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function configureExtractorDebug(enabled: boolean, mode: string, triggerEvery: number): void {
|
|
161
|
+
state.enabled = enabled && mode !== "off";
|
|
162
|
+
state.mode = mode;
|
|
163
|
+
state.triggerEvery = Math.max(1, triggerEvery);
|
|
164
|
+
state.turnsUntilNextRun = state.enabled
|
|
165
|
+
? state.triggerEvery - (state.sessionTurnCount % state.triggerEvery || 0)
|
|
166
|
+
: 0;
|
|
167
|
+
emit();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function noteUserTurnForExtractorDebug(): void {
|
|
171
|
+
state.sessionTurnCount += 1;
|
|
172
|
+
if (!state.enabled) {
|
|
173
|
+
emit();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const remainder = state.sessionTurnCount % state.triggerEvery;
|
|
178
|
+
state.turnsUntilNextRun = remainder === 0 ? 0 : state.triggerEvery - remainder;
|
|
179
|
+
emit();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function noteExtractorSkipped(reason: string): void {
|
|
183
|
+
state.isRunning = false;
|
|
184
|
+
state.lastRun = {
|
|
185
|
+
startedAt: Date.now(),
|
|
186
|
+
finishedAt: Date.now(),
|
|
187
|
+
status: "skipped",
|
|
188
|
+
reason,
|
|
189
|
+
candidateTexts: [],
|
|
190
|
+
savedCount: 0,
|
|
191
|
+
};
|
|
192
|
+
emit();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function noteExtractorQueued(reason: string, model?: string): void {
|
|
196
|
+
state.isRunning = false;
|
|
197
|
+
state.lastRun = {
|
|
198
|
+
startedAt: Date.now(),
|
|
199
|
+
status: "queued",
|
|
200
|
+
reason,
|
|
201
|
+
...(model ? { model } : {}),
|
|
202
|
+
candidateTexts: [],
|
|
203
|
+
savedCount: 0,
|
|
204
|
+
};
|
|
205
|
+
emit();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function noteExtractorRunStarted(): void {
|
|
209
|
+
state.isRunning = true;
|
|
210
|
+
startSpinner();
|
|
211
|
+
if (state.lastRun) {
|
|
212
|
+
state.lastRun.status = "running";
|
|
213
|
+
state.lastRun.startedAt = Date.now();
|
|
214
|
+
}
|
|
215
|
+
emit();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function noteExtractorRunFinished(candidateTexts: string[], savedCount: number): void {
|
|
219
|
+
finalizeRun("success", { candidateTexts, savedCount });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function noteExtractorRunFailed(error: string): void {
|
|
223
|
+
finalizeRun("error", { error });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function maybeStartExtractorDebugOverlay(ctx: ExtensionContext): void {
|
|
227
|
+
if (!ctx.hasUI) return;
|
|
228
|
+
sessions.add(ctx);
|
|
229
|
+
ctx.ui.setWidget(WIDGET_KEY, renderWidget(ctx));
|
|
230
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import { registerCommands as registerCommandsRuntime } from "./commands.ts";
|
|
5
|
+
import {
|
|
6
|
+
configureExtractorDebug,
|
|
7
|
+
maybeStartExtractorDebugOverlay,
|
|
8
|
+
noteUserTurnForExtractorDebug,
|
|
9
|
+
} from "./debug-overlay.ts";
|
|
10
|
+
import {
|
|
11
|
+
extractorDebug as runtimeExtractorDebug,
|
|
12
|
+
extractorMode as runtimeExtractorMode,
|
|
13
|
+
extractorModelId as runtimeExtractorModelId,
|
|
14
|
+
extractorTriggerEvery as runtimeExtractorTriggerEvery,
|
|
15
|
+
memoryService as runtimeMemoryService,
|
|
16
|
+
} from "./memory/runtime.ts";
|
|
17
|
+
import type { MemoryCaptureEvent, MemoryCaptureResult, MemoryExtractorResolution, MemoryRecord } from "./memory/types.ts";
|
|
18
|
+
import { flushPendingWrites as flushPendingWritesRuntime } from "./session.ts";
|
|
19
|
+
import { memoryTools as runtimeMemoryTools } from "./tools.ts";
|
|
20
|
+
import type { NoodleExtractorMode } from "./types.ts";
|
|
21
|
+
|
|
22
|
+
type RegisteredTool = Parameters<ExtensionAPI["registerTool"]>[0];
|
|
23
|
+
|
|
24
|
+
type MemoryServiceLike = {
|
|
25
|
+
capture: (event: MemoryCaptureEvent) => Promise<MemoryCaptureResult>;
|
|
26
|
+
findRelevantMemories: (prompt: string, limit?: number) => Promise<MemoryRecord[]>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type MemoryExtensionDeps = {
|
|
30
|
+
memoryService: MemoryServiceLike;
|
|
31
|
+
extractorMode: NoodleExtractorMode;
|
|
32
|
+
extractorModelId?: string;
|
|
33
|
+
extractorTriggerEvery: number;
|
|
34
|
+
extractorDebug?: boolean;
|
|
35
|
+
flushPendingWrites: () => Promise<void>;
|
|
36
|
+
memoryTools: readonly RegisteredTool[];
|
|
37
|
+
registerCommands: (pi: ExtensionAPI) => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type ExtractorModelRegistry = {
|
|
41
|
+
getAll(): Model<Api>[];
|
|
42
|
+
getApiKeyAndHeaders(model: Model<Api>): Promise<{ ok: boolean; apiKey?: string; headers?: Record<string, string>; error?: string }>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type ExtractorContext = {
|
|
46
|
+
modelRegistry: ExtractorModelRegistry;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const runtimeDeps: MemoryExtensionDeps = {
|
|
50
|
+
memoryService: runtimeMemoryService,
|
|
51
|
+
extractorMode: runtimeExtractorMode,
|
|
52
|
+
...(runtimeExtractorModelId ? { extractorModelId: runtimeExtractorModelId } : {}),
|
|
53
|
+
extractorTriggerEvery: runtimeExtractorTriggerEvery,
|
|
54
|
+
extractorDebug: runtimeExtractorDebug,
|
|
55
|
+
flushPendingWrites: flushPendingWritesRuntime,
|
|
56
|
+
memoryTools: runtimeMemoryTools,
|
|
57
|
+
registerCommands: registerCommandsRuntime,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function createMemoryExtension(deps: MemoryExtensionDeps = runtimeDeps) {
|
|
61
|
+
return function memoryExtension(pi: ExtensionAPI) {
|
|
62
|
+
configureExtractorDebug(deps.extractorDebug ?? false, deps.extractorMode, deps.extractorTriggerEvery);
|
|
63
|
+
|
|
64
|
+
const buildExtractor = (ctx: ExtractorContext): MemoryCaptureEvent["extractor"] | undefined => {
|
|
65
|
+
if (deps.extractorMode === "off") return undefined;
|
|
66
|
+
return {
|
|
67
|
+
resolve: () => resolveExtractorModel(deps.extractorModelId, ctx.modelRegistry),
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
72
|
+
maybeStartExtractorDebugOverlay(ctx);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
pi.on("input", async (event, ctx) => {
|
|
76
|
+
if (event.source === "extension") return { action: "continue" };
|
|
77
|
+
|
|
78
|
+
noteUserTurnForExtractorDebug();
|
|
79
|
+
const extractor = buildExtractor(ctx);
|
|
80
|
+
await deps.memoryService.capture({
|
|
81
|
+
type: "user_input",
|
|
82
|
+
text: event.text,
|
|
83
|
+
sessionManager: ctx.sessionManager,
|
|
84
|
+
target: ctx,
|
|
85
|
+
...(extractor ? { extractor } : {}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return { action: "continue" };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
pi.on("before_agent_start", async (event) => {
|
|
92
|
+
try {
|
|
93
|
+
const memories = await deps.memoryService.findRelevantMemories(event.prompt, 3);
|
|
94
|
+
if (memories.length === 0) return;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
systemPrompt: `${event.systemPrompt}\n\nRelevant user memory:\n${memories.map((memory) => `- ${memory.text}`).join("\n")}`,
|
|
98
|
+
};
|
|
99
|
+
} catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
pi.on("session_before_compact", async (_event, ctx) => {
|
|
105
|
+
const extractor = buildExtractor(ctx);
|
|
106
|
+
await deps.memoryService.capture({
|
|
107
|
+
type: "session_before_compact",
|
|
108
|
+
sessionManager: ctx.sessionManager,
|
|
109
|
+
target: ctx,
|
|
110
|
+
...(extractor ? { extractor } : {}),
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
pi.on("session_before_switch", async (event, ctx) => {
|
|
115
|
+
const extractor = buildExtractor(ctx);
|
|
116
|
+
await deps.memoryService.capture({
|
|
117
|
+
type: "session_before_switch",
|
|
118
|
+
reason: event.reason,
|
|
119
|
+
sessionManager: ctx.sessionManager,
|
|
120
|
+
target: ctx,
|
|
121
|
+
...(extractor ? { extractor } : {}),
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
126
|
+
const extractor = buildExtractor(ctx);
|
|
127
|
+
await deps.memoryService.capture({
|
|
128
|
+
type: "session_shutdown",
|
|
129
|
+
reason: event.reason,
|
|
130
|
+
sessionManager: ctx.sessionManager,
|
|
131
|
+
...(extractor ? { extractor } : {}),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await deps.flushPendingWrites();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
for (const tool of deps.memoryTools) {
|
|
138
|
+
pi.registerTool(tool);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
deps.registerCommands(pi);
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function resolveExtractorModel(
|
|
146
|
+
extractorModelId: string | undefined,
|
|
147
|
+
modelRegistry: ExtractorModelRegistry,
|
|
148
|
+
): Promise<MemoryExtractorResolution | null> {
|
|
149
|
+
if (!extractorModelId) return null;
|
|
150
|
+
|
|
151
|
+
const model = modelRegistry.getAll().find((candidate) => candidate.id === extractorModelId);
|
|
152
|
+
if (!model) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const auth = await modelRegistry.getApiKeyAndHeaders(model);
|
|
157
|
+
if (!auth?.ok || !auth.apiKey) return null;
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
model,
|
|
161
|
+
apiKey: auth.apiKey,
|
|
162
|
+
...(auth.headers ? { headers: auth.headers } : {}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default createMemoryExtension();
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./extension.ts";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AddMemoryInput,
|
|
3
|
+
ConsolidationReport,
|
|
4
|
+
ConversationCaptureInput,
|
|
5
|
+
MemoryListInput,
|
|
6
|
+
MemoryRecord,
|
|
7
|
+
MemorySearchInput,
|
|
8
|
+
UpdateMemoryInput,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
|
|
11
|
+
export interface MemoryBackend {
|
|
12
|
+
add(input: AddMemoryInput): Promise<void>;
|
|
13
|
+
search(input: MemorySearchInput): Promise<MemoryRecord[]>;
|
|
14
|
+
list(input?: MemoryListInput): Promise<MemoryRecord[]>;
|
|
15
|
+
get(id: string): Promise<MemoryRecord | null>;
|
|
16
|
+
update(id: string, input: UpdateMemoryInput): Promise<void>;
|
|
17
|
+
delete(id: string): Promise<void>;
|
|
18
|
+
/** Bump retrieval stats when memories are injected into a prompt. */
|
|
19
|
+
recordRetrievals?(ids: string[]): Promise<void>;
|
|
20
|
+
captureConversation?(input: ConversationCaptureInput): Promise<void>;
|
|
21
|
+
consolidate?(): Promise<ConsolidationReport>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Injectable embedding function. */
|
|
2
|
+
export type Embedder = {
|
|
3
|
+
/** Produce a floating-point embedding vector for `text`. */
|
|
4
|
+
embed(text: string): Promise<Float32Array>;
|
|
5
|
+
/** Optional expected dimensionality of the vectors returned by `embed`. */
|
|
6
|
+
dimensions?: number;
|
|
7
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Embedder } from "../embedder.ts";
|
|
2
|
+
import { createOpenAIEmbedder } from "./openai.ts";
|
|
3
|
+
import type { OpenAIEmbedderOptions } from "./openai.ts";
|
|
4
|
+
|
|
5
|
+
export type LMStudioEmbedderOptions = {
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
model?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* LM Studio exposes an OpenAI-compatible `/v1/embeddings` endpoint — this is a
|
|
12
|
+
* thin convenience wrapper.
|
|
13
|
+
*/
|
|
14
|
+
export function createLMStudioEmbedder(
|
|
15
|
+
options: LMStudioEmbedderOptions,
|
|
16
|
+
): Embedder {
|
|
17
|
+
const opts: Record<string, unknown> = {
|
|
18
|
+
apiKey: "lm-studio",
|
|
19
|
+
baseUrl: options.baseUrl,
|
|
20
|
+
};
|
|
21
|
+
if (options.model) opts.model = options.model;
|
|
22
|
+
return createOpenAIEmbedder(
|
|
23
|
+
opts as unknown as OpenAIEmbedderOptions,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Embedder } from "../embedder.ts";
|
|
2
|
+
|
|
3
|
+
export type OpenAIEmbedderOptions = {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
model?: string;
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
dimensions?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates an embedder that calls an OpenAI-compatible `/v1/embeddings` endpoint.
|
|
12
|
+
* Works with OpenAI, LM Studio, Ollama, vLLM, etc. — anything that exposes the
|
|
13
|
+
* OpenAI embeddings API shape.
|
|
14
|
+
*/
|
|
15
|
+
export function createOpenAIEmbedder(options: OpenAIEmbedderOptions): Embedder {
|
|
16
|
+
const model = options.model ?? "text-embedding-3-small";
|
|
17
|
+
const baseUrl = (options.baseUrl ?? "https://api.openai.com/v1").replace(
|
|
18
|
+
/\/$/,
|
|
19
|
+
"",
|
|
20
|
+
);
|
|
21
|
+
const knownDimensions: Record<string, number> = {
|
|
22
|
+
"text-embedding-3-small": 1536,
|
|
23
|
+
"text-embedding-3-large": 3072,
|
|
24
|
+
"text-embedding-ada-002": 1536,
|
|
25
|
+
};
|
|
26
|
+
const expectedDimensions = options.dimensions ?? knownDimensions[model];
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
...(expectedDimensions ? { dimensions: expectedDimensions } : {}),
|
|
30
|
+
embed: async (text: string): Promise<Float32Array> => {
|
|
31
|
+
const response = await fetch(`${baseUrl}/embeddings`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: {
|
|
34
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
},
|
|
37
|
+
body: JSON.stringify({ model, input: text }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const body = await response.text().catch(() => "(no body)");
|
|
42
|
+
throw new Error(
|
|
43
|
+
`OpenAI embeddings request failed: ${response.status} ${body}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const json = (await response.json()) as {
|
|
48
|
+
data: Array<{ embedding: number[] }>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const embedding = json.data[0];
|
|
52
|
+
if (!embedding?.embedding) {
|
|
53
|
+
throw new Error("OpenAI returned no embeddings");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const vector = new Float32Array(embedding.embedding);
|
|
57
|
+
if (expectedDimensions && vector.length !== expectedDimensions) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Embedding dimension mismatch for model ${model}: expected ${expectedDimensions}, got ${vector.length}. Update noodle embedding.dimensions or switch to a matching model/provider.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return vector;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|