peon-mem 1.0.6 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/daemon.d.ts +3 -0
- package/dist/daemon.js +134 -3
- package/dist/embedding-store.d.ts +2 -0
- package/dist/embedding-store.js +52 -5
- package/dist/embeddings.d.ts +30 -0
- package/dist/embeddings.js +68 -5
- package/dist/monitor.js +224 -14
- package/dist/roundtable.d.ts +104 -0
- package/dist/roundtable.js +787 -0
- package/package.json +1 -1
package/dist/daemon.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { type RoundtableAgentRunner } from "./roundtable.js";
|
|
1
2
|
export interface StartPeonDaemonOptions {
|
|
2
3
|
host?: string;
|
|
3
4
|
port?: number;
|
|
4
5
|
logDir?: string;
|
|
5
6
|
globalMemoryDir?: string;
|
|
7
|
+
/** Override the local Codex/Claude bridge. Intended for tests and custom launchers. */
|
|
8
|
+
roundtableRunner?: RoundtableAgentRunner;
|
|
6
9
|
}
|
|
7
10
|
export interface PeonDaemonHandle {
|
|
8
11
|
host: string;
|
package/dist/daemon.js
CHANGED
|
@@ -7,6 +7,7 @@ import { URL } from "node:url";
|
|
|
7
7
|
import { PeonLogger } from "./logger.js";
|
|
8
8
|
import { renderMonitorHtml } from "./monitor.js";
|
|
9
9
|
import { renderTokenAbMonitorHtml } from "./token-ab-monitor.js";
|
|
10
|
+
import { RoundtableManager } from "./roundtable.js";
|
|
10
11
|
import { SessionIndex } from "./session-index.js";
|
|
11
12
|
import { createPeonTools } from "./tools.js";
|
|
12
13
|
import { summarizeBeliefs, detectDuplicates, computeTokenSavings, enrichInjection, filterStrayProjects } from "./overview.js";
|
|
@@ -158,6 +159,53 @@ export async function startPeonDaemon(options = {}) {
|
|
|
158
159
|
const tools = createPeonTools({ globalMemoryDir: options.globalMemoryDir, sessionIndexPath });
|
|
159
160
|
const logger = new PeonLogger({ logDir: options.logDir });
|
|
160
161
|
const projectRegistry = new ProjectRegistry(stateDir);
|
|
162
|
+
const roundtable = new RoundtableManager({
|
|
163
|
+
stateDir: join(stateDir, "roundtables"),
|
|
164
|
+
runner: options.roundtableRunner,
|
|
165
|
+
memory: {
|
|
166
|
+
getContext: (input) => tools.getContext(input),
|
|
167
|
+
saveProposal: async (projectPath, question, proposal) => {
|
|
168
|
+
const session = await tools.startSession({ projectPath, client: "peon-roundtable", cwd: projectPath });
|
|
169
|
+
try {
|
|
170
|
+
await tools.recordEvent({
|
|
171
|
+
sessionId: session.sessionId,
|
|
172
|
+
type: "roundtable_proposal",
|
|
173
|
+
content: `Roundtable proposal for “${question.slice(0, 220)}”: ${proposal.slice(0, 6_000)}`
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
await tools.endSession({ sessionId: session.sessionId }).catch(() => undefined);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
saveDecision: async (projectPath, question, decision) => {
|
|
181
|
+
const session = await tools.startSession({ projectPath, client: "peon-roundtable", cwd: projectPath });
|
|
182
|
+
try {
|
|
183
|
+
await tools.recordEvent({
|
|
184
|
+
sessionId: session.sessionId,
|
|
185
|
+
type: "decision",
|
|
186
|
+
content: `Roundtable decision for “${question.slice(0, 220)}”: ${decision.slice(0, 6_000)}`
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
await tools.endSession({ sessionId: session.sessionId }).catch(() => undefined);
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
saveResult: async (projectPath, question, result) => {
|
|
194
|
+
const session = await tools.startSession({ projectPath, client: "peon-roundtable", cwd: projectPath });
|
|
195
|
+
try {
|
|
196
|
+
await tools.recordEvent({
|
|
197
|
+
sessionId: session.sessionId,
|
|
198
|
+
type: "roundtable_result",
|
|
199
|
+
content: `Roundtable implementation result for “${question.slice(0, 220)}”: ${result.slice(0, 6_000)}`
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
await tools.endSession({ sessionId: session.sessionId }).catch(() => undefined);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
await roundtable.initialize();
|
|
161
209
|
const sessionIndex = new SessionIndex(sessionIndexPath);
|
|
162
210
|
const activeSessions = new Map();
|
|
163
211
|
// Clear zombie sessions left behind by a crashed run before rehydrating, so the
|
|
@@ -203,7 +251,8 @@ export async function startPeonDaemon(options = {}) {
|
|
|
203
251
|
sendJson(response, 403, { error: "forbidden: non-local request origin" });
|
|
204
252
|
return;
|
|
205
253
|
}
|
|
206
|
-
const noisy = NOISY_LOG_PATHS.has(requestUrl.pathname)
|
|
254
|
+
const noisy = NOISY_LOG_PATHS.has(requestUrl.pathname) ||
|
|
255
|
+
(requestMethod === "GET" && requestUrl.pathname.startsWith("/roundtable/"));
|
|
207
256
|
if (!noisy) {
|
|
208
257
|
await logger.log("request_in", {
|
|
209
258
|
requestId,
|
|
@@ -213,7 +262,36 @@ export async function startPeonDaemon(options = {}) {
|
|
|
213
262
|
});
|
|
214
263
|
}
|
|
215
264
|
try {
|
|
216
|
-
|
|
265
|
+
if (requestMethod === "GET" && requestUrl.pathname === "/roundtable/events") {
|
|
266
|
+
const rawProjectPath = requestUrl.searchParams.get("projectPath");
|
|
267
|
+
if (!rawProjectPath)
|
|
268
|
+
throw new BadRequestError("projectPath is required");
|
|
269
|
+
const projectPath = canonicalProjectPath(rawProjectPath);
|
|
270
|
+
response.writeHead(200, {
|
|
271
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
272
|
+
"cache-control": "no-cache, no-transform",
|
|
273
|
+
connection: "keep-alive",
|
|
274
|
+
"x-accel-buffering": "no"
|
|
275
|
+
});
|
|
276
|
+
response.write(": thesis room connected\n\n");
|
|
277
|
+
const unsubscribe = roundtable.subscribe((run) => {
|
|
278
|
+
if (run.projectPath !== projectPath || response.writableEnded)
|
|
279
|
+
return;
|
|
280
|
+
response.write(`data: ${JSON.stringify(run)}\n\n`);
|
|
281
|
+
});
|
|
282
|
+
const heartbeat = setInterval(() => {
|
|
283
|
+
if (!response.writableEnded)
|
|
284
|
+
response.write(": keep-alive\n\n");
|
|
285
|
+
}, 20_000);
|
|
286
|
+
if (typeof heartbeat.unref === "function")
|
|
287
|
+
heartbeat.unref();
|
|
288
|
+
request.on("close", () => {
|
|
289
|
+
clearInterval(heartbeat);
|
|
290
|
+
unsubscribe();
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const result = await routeRequest(request, tools, activeSessions, monitorState, logger, projectRegistry, roundtable);
|
|
217
295
|
if ("html" in result) {
|
|
218
296
|
sendHtml(response, result.status ?? 200, result.html);
|
|
219
297
|
}
|
|
@@ -344,12 +422,15 @@ async function maybeRecurate(tools, monitorState, logger) {
|
|
|
344
422
|
await logger.log("recurate_fail", { projectPath, error: error instanceof Error ? error.message : "unknown" });
|
|
345
423
|
}
|
|
346
424
|
}
|
|
347
|
-
async function routeRequest(request, tools, activeSessions, monitorState, logger, projectRegistry) {
|
|
425
|
+
async function routeRequest(request, tools, activeSessions, monitorState, logger, projectRegistry, roundtable) {
|
|
348
426
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
349
427
|
const method = request.method ?? "GET";
|
|
350
428
|
if (method === "GET" && url.pathname === "/health") {
|
|
351
429
|
return { body: { ok: true, service: "peon-daemon" } };
|
|
352
430
|
}
|
|
431
|
+
if (method === "GET" && url.pathname === "/favicon.ico") {
|
|
432
|
+
return { status: 204, body: "" };
|
|
433
|
+
}
|
|
353
434
|
if ((method === "GET" || method === "HEAD") && url.pathname === "/monitor") {
|
|
354
435
|
return { html: renderMonitorHtml() };
|
|
355
436
|
}
|
|
@@ -359,6 +440,56 @@ async function routeRequest(request, tools, activeSessions, monitorState, logger
|
|
|
359
440
|
if (method === "GET" && url.pathname === "/monitor/state") {
|
|
360
441
|
return { body: await buildMonitorState(tools, activeSessions, monitorState, logger) };
|
|
361
442
|
}
|
|
443
|
+
if (method === "GET" && url.pathname === "/roundtable/status") {
|
|
444
|
+
return { body: { agents: await roundtable.availability() } };
|
|
445
|
+
}
|
|
446
|
+
if (method === "GET" && url.pathname === "/roundtable/runs") {
|
|
447
|
+
const rawProjectPath = url.searchParams.get("projectPath");
|
|
448
|
+
const projectPath = rawProjectPath ? canonicalProjectPath(rawProjectPath) : undefined;
|
|
449
|
+
return { body: { runs: roundtable.list(projectPath).slice(0, 30) } };
|
|
450
|
+
}
|
|
451
|
+
const roundtableRunMatch = url.pathname.match(/^\/roundtable\/runs\/([^/]+)$/);
|
|
452
|
+
if (method === "GET" && roundtableRunMatch) {
|
|
453
|
+
const run = roundtable.get(decodeURIComponent(roundtableRunMatch[1]));
|
|
454
|
+
if (!run)
|
|
455
|
+
return { status: 404, body: { error: "Roundtable discussion not found" } };
|
|
456
|
+
return { body: run };
|
|
457
|
+
}
|
|
458
|
+
if (method === "POST" && url.pathname === "/roundtable/runs") {
|
|
459
|
+
const input = await readJson(request);
|
|
460
|
+
if (!input.projectPath)
|
|
461
|
+
throw new BadRequestError("projectPath is required");
|
|
462
|
+
const projectPath = canonicalProjectPath(input.projectPath);
|
|
463
|
+
if (!monitorState.knownProjects.has(projectPath) && !isRealProjectPath(projectPath)) {
|
|
464
|
+
throw new BadRequestError("Select a project already known to Peon");
|
|
465
|
+
}
|
|
466
|
+
const availability = await roundtable.availability();
|
|
467
|
+
const missing = ["codex", "claude"].filter((agent) => !availability[agent].available);
|
|
468
|
+
if (missing.length)
|
|
469
|
+
throw new BadRequestError(`Local client unavailable: ${missing.join(" and ")}`);
|
|
470
|
+
await rememberProject(monitorState, projectRegistry, projectPath);
|
|
471
|
+
return { status: 202, body: await roundtable.start(projectPath, input.question) };
|
|
472
|
+
}
|
|
473
|
+
const roundtableApprovalMatch = url.pathname.match(/^\/roundtable\/runs\/([^/]+)\/(approve|reject)$/);
|
|
474
|
+
if (method === "POST" && roundtableApprovalMatch) {
|
|
475
|
+
const id = decodeURIComponent(roundtableApprovalMatch[1]);
|
|
476
|
+
const action = roundtableApprovalMatch[2];
|
|
477
|
+
return { body: action === "approve" ? await roundtable.approve(id) : await roundtable.reject(id) };
|
|
478
|
+
}
|
|
479
|
+
const roundtableProposalMatch = url.pathname.match(/^\/roundtable\/runs\/([^/]+)\/propose$/);
|
|
480
|
+
if (method === "POST" && roundtableProposalMatch) {
|
|
481
|
+
return { body: await roundtable.propose(decodeURIComponent(roundtableProposalMatch[1])) };
|
|
482
|
+
}
|
|
483
|
+
const roundtableContinueMatch = url.pathname.match(/^\/roundtable\/runs\/([^/]+)\/continue$/);
|
|
484
|
+
if (method === "POST" && roundtableContinueMatch) {
|
|
485
|
+
return { body: await roundtable.continueDiscussion(decodeURIComponent(roundtableContinueMatch[1])) };
|
|
486
|
+
}
|
|
487
|
+
const roundtableMessageMatch = url.pathname.match(/^\/roundtable\/runs\/([^/]+)\/message$/);
|
|
488
|
+
if (method === "POST" && roundtableMessageMatch) {
|
|
489
|
+
const id = decodeURIComponent(roundtableMessageMatch[1]);
|
|
490
|
+
const input = await readJson(request);
|
|
491
|
+
return { body: await roundtable.message(id, input.message) };
|
|
492
|
+
}
|
|
362
493
|
if (method === "GET" && url.pathname === "/logs") {
|
|
363
494
|
const limit = Number.parseInt(url.searchParams.get("limit") ?? "100", 10);
|
|
364
495
|
return { body: { entries: await logger.recent(Number.isFinite(limit) ? limit : 100) } };
|
|
@@ -20,6 +20,8 @@ export interface SyncResult {
|
|
|
20
20
|
reused: number;
|
|
21
21
|
pruned: number;
|
|
22
22
|
}
|
|
23
|
+
/** Test helper: forget learned widths, simulating a fresh daemon process. */
|
|
24
|
+
export declare function resetEmbeddingDimensionCache(): void;
|
|
23
25
|
export declare class EmbeddingStore {
|
|
24
26
|
private readonly filePath;
|
|
25
27
|
private cache?;
|
package/dist/embedding-store.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { contentHash } from "./embeddings.js";
|
|
4
|
+
/** Real output width per embedding model, learned once per process. */
|
|
5
|
+
const modelDimensions = new Map();
|
|
6
|
+
/** Test helper: forget learned widths, simulating a fresh daemon process. */
|
|
7
|
+
export function resetEmbeddingDimensionCache() {
|
|
8
|
+
modelDimensions.clear();
|
|
9
|
+
}
|
|
4
10
|
export class EmbeddingStore {
|
|
5
11
|
filePath;
|
|
6
12
|
// mtime-keyed cache so the (multi-MB) sidecar isn't re-read+parsed on every prompt's
|
|
@@ -56,11 +62,36 @@ export class EmbeddingStore {
|
|
|
56
62
|
const existing = await this.load();
|
|
57
63
|
const liveIds = new Set(records.map((record) => record.id));
|
|
58
64
|
const pruned = [...existing.keys()].filter((id) => !liveIds.has(id)).length;
|
|
65
|
+
// A stored vector can carry the right model name and hash yet the wrong width —
|
|
66
|
+
// that is what a degraded fallback wrote — and cosineSimilarity returns 0 on a
|
|
67
|
+
// length mismatch, so those records vanish from semantic recall without erroring.
|
|
68
|
+
// Width is part of validity. Learning it must not cost a round trip per sync, so
|
|
69
|
+
// it is cached per model and only probed when nothing else needs recomputing:
|
|
70
|
+
// precisely the case where a fully-poisoned sidecar looks entirely reusable.
|
|
71
|
+
const matchesStored = (record) => {
|
|
72
|
+
const prior = existing.get(record.id);
|
|
73
|
+
return prior && prior.model === client.model && prior.hash === contentHash(embeddingText(record))
|
|
74
|
+
? prior
|
|
75
|
+
: undefined;
|
|
76
|
+
};
|
|
77
|
+
let expectedDim = modelDimensions.get(client.model) ?? 0;
|
|
78
|
+
if (expectedDim === 0 && records.length > 0 && records.every((record) => matchesStored(record))) {
|
|
79
|
+
try {
|
|
80
|
+
const probe = await client.embed([embeddingText(records[0])]);
|
|
81
|
+
if (!client.degraded && probe[0]?.length) {
|
|
82
|
+
expectedDim = probe[0].length;
|
|
83
|
+
modelDimensions.set(client.model, expectedDim);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
expectedDim = 0; // cannot probe — fall back to model+hash validity only
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const valid = (prior) => Boolean(prior) && (expectedDim === 0 || prior.vector.length === expectedDim);
|
|
59
91
|
const toCompute = [];
|
|
60
92
|
let reused = 0;
|
|
61
93
|
for (const record of records) {
|
|
62
|
-
|
|
63
|
-
if (prior && prior.model === client.model && prior.hash === contentHash(embeddingText(record))) {
|
|
94
|
+
if (valid(matchesStored(record))) {
|
|
64
95
|
reused += 1;
|
|
65
96
|
}
|
|
66
97
|
else {
|
|
@@ -69,15 +100,28 @@ export class EmbeddingStore {
|
|
|
69
100
|
}
|
|
70
101
|
const result = new Map();
|
|
71
102
|
for (const record of records) {
|
|
72
|
-
const prior =
|
|
73
|
-
if (prior
|
|
103
|
+
const prior = matchesStored(record);
|
|
104
|
+
if (valid(prior))
|
|
74
105
|
result.set(record.id, prior);
|
|
75
|
-
}
|
|
76
106
|
}
|
|
77
107
|
let computed = 0;
|
|
78
108
|
if (toCompute.length > 0) {
|
|
79
109
|
try {
|
|
80
110
|
const vectors = await client.embed(toCompute.map((record) => embeddingText(record)));
|
|
111
|
+
// A degraded run returns local trigram vectors. Serving them for THIS call is
|
|
112
|
+
// graceful degradation; writing them under the primary model's name is not —
|
|
113
|
+
// they would be reused forever as if they were real embeddings.
|
|
114
|
+
if (client.degraded) {
|
|
115
|
+
const degradedById = new Map();
|
|
116
|
+
for (const [id, stored] of result)
|
|
117
|
+
degradedById.set(id, stored.vector);
|
|
118
|
+
toCompute.forEach((record, i) => {
|
|
119
|
+
const vector = vectors[i];
|
|
120
|
+
if (vector)
|
|
121
|
+
degradedById.set(record.id, vector);
|
|
122
|
+
});
|
|
123
|
+
return { vectorById: degradedById, computed: 0, reused, pruned };
|
|
124
|
+
}
|
|
81
125
|
toCompute.forEach((record, i) => {
|
|
82
126
|
result.set(record.id, {
|
|
83
127
|
id: record.id,
|
|
@@ -87,6 +131,9 @@ export class EmbeddingStore {
|
|
|
87
131
|
});
|
|
88
132
|
});
|
|
89
133
|
computed = toCompute.length;
|
|
134
|
+
const width = vectors[0]?.length ?? 0;
|
|
135
|
+
if (width > 0)
|
|
136
|
+
modelDimensions.set(client.model, width);
|
|
90
137
|
}
|
|
91
138
|
catch {
|
|
92
139
|
// On a hard failure, keep whatever we already had and continue lexical-only.
|
package/dist/embeddings.d.ts
CHANGED
|
@@ -81,6 +81,15 @@ export declare class FallbackEmbeddingClient implements EmbeddingClient {
|
|
|
81
81
|
private readonly fallback;
|
|
82
82
|
private readonly onFallback?;
|
|
83
83
|
readonly model: string;
|
|
84
|
+
/**
|
|
85
|
+
* True when the most recent embed() degraded to the fallback. The vectors it
|
|
86
|
+
* returns are a different model AND a different width, so persisting them under
|
|
87
|
+
* the primary's name makes them indistinguishable from real ones — every later
|
|
88
|
+
* sync then "reuses" trigram vectors as if they were embeddings, and retrieval
|
|
89
|
+
* silently scores them 0 (cosineSimilarity returns 0 on a length mismatch).
|
|
90
|
+
* Callers that persist vectors must check this and skip writing.
|
|
91
|
+
*/
|
|
92
|
+
degraded: boolean;
|
|
84
93
|
constructor(primary: EmbeddingClient, fallback?: EmbeddingClient, onFallback?: ((error: unknown) => void) | undefined);
|
|
85
94
|
embed(texts: string[]): Promise<EmbeddingVector[]>;
|
|
86
95
|
}
|
|
@@ -90,4 +99,25 @@ export interface CreateEmbeddingClientOptions {
|
|
|
90
99
|
onFallback?: (error: unknown) => void;
|
|
91
100
|
}
|
|
92
101
|
/** Build the embedding client implied by config, or null when embeddings are off. */
|
|
102
|
+
/**
|
|
103
|
+
* What was asked for versus what will actually run.
|
|
104
|
+
*
|
|
105
|
+
* Peon degrades to deterministic local trigram embeddings whenever the configured
|
|
106
|
+
* embedder is unavailable. That is deliberate — retrieval keeps working — but it was
|
|
107
|
+
* silent, and a silent downgrade is indistinguishable from working correctly while
|
|
108
|
+
* semantic recall quietly collapses. Two real incidents: an Ollama blip embedding 30k+
|
|
109
|
+
* records with trigram vectors, and a script whose .env was not found resolving to
|
|
110
|
+
* "local" with no warning at all.
|
|
111
|
+
*/
|
|
112
|
+
export interface EmbeddingPlan {
|
|
113
|
+
intended: PeonConfig["embeddingMode"];
|
|
114
|
+
effective: "off" | "local" | "api" | "ollama";
|
|
115
|
+
downgraded: boolean;
|
|
116
|
+
reason?: string;
|
|
117
|
+
}
|
|
118
|
+
/** Only the fields the decision actually depends on, matching the client factory. */
|
|
119
|
+
export type EmbeddingPlanInput = Pick<PeonConfig, "embeddingMode" | "embeddingModel" | "openRouterApiKey" | "provider" | "llmApiKey">;
|
|
120
|
+
export declare function resolveEmbeddingPlan(config: EmbeddingPlanInput): EmbeddingPlan;
|
|
121
|
+
/** Test helper: forget which downgrade warnings have already been emitted. */
|
|
122
|
+
export declare function resetEmbeddingWarnings(): void;
|
|
93
123
|
export declare function createEmbeddingClient(options: CreateEmbeddingClientOptions): EmbeddingClient | null;
|
package/dist/embeddings.js
CHANGED
|
@@ -290,6 +290,15 @@ export class FallbackEmbeddingClient {
|
|
|
290
290
|
fallback;
|
|
291
291
|
onFallback;
|
|
292
292
|
model;
|
|
293
|
+
/**
|
|
294
|
+
* True when the most recent embed() degraded to the fallback. The vectors it
|
|
295
|
+
* returns are a different model AND a different width, so persisting them under
|
|
296
|
+
* the primary's name makes them indistinguishable from real ones — every later
|
|
297
|
+
* sync then "reuses" trigram vectors as if they were embeddings, and retrieval
|
|
298
|
+
* silently scores them 0 (cosineSimilarity returns 0 on a length mismatch).
|
|
299
|
+
* Callers that persist vectors must check this and skip writing.
|
|
300
|
+
*/
|
|
301
|
+
degraded = false;
|
|
293
302
|
constructor(primary, fallback = new LocalEmbeddingClient(), onFallback) {
|
|
294
303
|
this.primary = primary;
|
|
295
304
|
this.fallback = fallback;
|
|
@@ -298,19 +307,73 @@ export class FallbackEmbeddingClient {
|
|
|
298
307
|
}
|
|
299
308
|
async embed(texts) {
|
|
300
309
|
try {
|
|
301
|
-
|
|
310
|
+
const vectors = await this.primary.embed(texts);
|
|
311
|
+
this.degraded = false;
|
|
312
|
+
return vectors;
|
|
302
313
|
}
|
|
303
314
|
catch (error) {
|
|
304
315
|
this.onFallback?.(error);
|
|
316
|
+
this.degraded = true;
|
|
305
317
|
return this.fallback.embed(texts);
|
|
306
318
|
}
|
|
307
319
|
}
|
|
308
320
|
}
|
|
309
|
-
|
|
321
|
+
export function resolveEmbeddingPlan(config) {
|
|
322
|
+
const intended = config.embeddingMode;
|
|
323
|
+
if (intended === "off")
|
|
324
|
+
return { intended, effective: "off", downgraded: false };
|
|
325
|
+
if (intended === "local")
|
|
326
|
+
return { intended, effective: "local", downgraded: false };
|
|
327
|
+
if (intended === "ollama") {
|
|
328
|
+
// Reachability cannot be known at construction time; a dead server surfaces at
|
|
329
|
+
// the first embed() as a degraded run, which the store refuses to persist.
|
|
330
|
+
return { intended, effective: "ollama", downgraded: false };
|
|
331
|
+
}
|
|
332
|
+
const apiKey = config.llmApiKey ?? config.openRouterApiKey;
|
|
333
|
+
if (!apiKey) {
|
|
334
|
+
return { intended, effective: "local", downgraded: true, reason: "no API key/credentials configured" };
|
|
335
|
+
}
|
|
336
|
+
if (!config.embeddingModel) {
|
|
337
|
+
return { intended, effective: "local", downgraded: true, reason: "PEON_EMBEDDING_MODEL is not set" };
|
|
338
|
+
}
|
|
339
|
+
if (config.provider === "anthropic") {
|
|
340
|
+
return { intended, effective: "local", downgraded: true, reason: "Anthropic has no embeddings API" };
|
|
341
|
+
}
|
|
342
|
+
return { intended, effective: "api", downgraded: false };
|
|
343
|
+
}
|
|
344
|
+
/** Warn once per distinct reason, so a long-lived daemon does not spam its log. */
|
|
345
|
+
const warnedDowngrades = new Set();
|
|
346
|
+
function warnOnce(plan) {
|
|
347
|
+
if (!plan.downgraded || !plan.reason)
|
|
348
|
+
return;
|
|
349
|
+
if (warnedDowngrades.has(plan.reason))
|
|
350
|
+
return;
|
|
351
|
+
warnedDowngrades.add(plan.reason);
|
|
352
|
+
console.warn(`[peon] embedding mode "${plan.intended}" is not available (${plan.reason}); ` +
|
|
353
|
+
`falling back to local trigram embeddings. Semantic recall will be much weaker — ` +
|
|
354
|
+
`set PEON_EMBEDDING_MODE=local to silence this, or fix the configuration.`);
|
|
355
|
+
}
|
|
356
|
+
/** Test helper: forget which downgrade warnings have already been emitted. */
|
|
357
|
+
export function resetEmbeddingWarnings() {
|
|
358
|
+
warnedDowngrades.clear();
|
|
359
|
+
}
|
|
310
360
|
export function createEmbeddingClient(options) {
|
|
311
361
|
const { config } = options;
|
|
362
|
+
warnOnce(resolveEmbeddingPlan(config));
|
|
312
363
|
if (config.embeddingMode === "off")
|
|
313
364
|
return null;
|
|
365
|
+
// No caller ever supplied onFallback, so a runtime degrade (embedding server down)
|
|
366
|
+
// was completely silent. Default to warning once per process: the vectors from that
|
|
367
|
+
// run are trigram, not semantic, and the operator needs to know retrieval got worse.
|
|
368
|
+
const onFallback = options.onFallback ??
|
|
369
|
+
((error) => {
|
|
370
|
+
if (warnedDowngrades.has("runtime-fallback"))
|
|
371
|
+
return;
|
|
372
|
+
warnedDowngrades.add("runtime-fallback");
|
|
373
|
+
console.warn(`[peon] embedding request failed (${error instanceof Error ? error.message : String(error)}); ` +
|
|
374
|
+
`falling back to local trigram embeddings for this run. Semantic recall is degraded ` +
|
|
375
|
+
`until the embedding server is reachable again.`);
|
|
376
|
+
});
|
|
314
377
|
if (config.embeddingMode === "ollama") {
|
|
315
378
|
// Local semantic embeddings. Fall back to the API client (if configured) then trigram-local,
|
|
316
379
|
// so a stopped Ollama service degrades instead of breaking retrieval.
|
|
@@ -319,9 +382,9 @@ export function createEmbeddingClient(options) {
|
|
|
319
382
|
baseUrl: config.ollamaBaseUrl
|
|
320
383
|
});
|
|
321
384
|
const fallback = config.openRouterApiKey && config.embeddingModel && config.embeddingModel.includes("/")
|
|
322
|
-
? new FallbackEmbeddingClient(new OpenRouterEmbeddingClient({ apiKey: config.openRouterApiKey, model: config.embeddingModel }), new LocalEmbeddingClient(),
|
|
385
|
+
? new FallbackEmbeddingClient(new OpenRouterEmbeddingClient({ apiKey: config.openRouterApiKey, model: config.embeddingModel }), new LocalEmbeddingClient(), onFallback)
|
|
323
386
|
: new LocalEmbeddingClient();
|
|
324
|
-
return new FallbackEmbeddingClient(ollama, fallback,
|
|
387
|
+
return new FallbackEmbeddingClient(ollama, fallback, onFallback);
|
|
325
388
|
}
|
|
326
389
|
const apiKey = config.llmApiKey ?? config.openRouterApiKey;
|
|
327
390
|
const embeddable = config.provider !== "anthropic"; // Anthropic has no embeddings API — local fallback
|
|
@@ -331,7 +394,7 @@ export function createEmbeddingClient(options) {
|
|
|
331
394
|
model: config.embeddingModel,
|
|
332
395
|
baseUrl: config.llmBaseUrl
|
|
333
396
|
});
|
|
334
|
-
return new FallbackEmbeddingClient(primary, new LocalEmbeddingClient(),
|
|
397
|
+
return new FallbackEmbeddingClient(primary, new LocalEmbeddingClient(), onFallback);
|
|
335
398
|
}
|
|
336
399
|
// Default and "api"-without-credentials both resolve to deterministic local embeddings.
|
|
337
400
|
return new LocalEmbeddingClient();
|