peon-mem 1.0.5 → 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 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
- const result = await routeRequest(request, tools, activeSessions, monitorState, logger, projectRegistry);
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?;
@@ -40,4 +42,4 @@ export declare class EmbeddingStore {
40
42
  /** Serialize a vector as base64 of its float32 bytes — ~4x smaller + faster to parse than JSON float64. */
41
43
  export declare function encodeVector(vector: EmbeddingVector): string;
42
44
  /** Decode a base64 float32 vector back to number[]; null on malformed/misaligned input. */
43
- export declare function decodeVector(b64: string): number[] | null;
45
+ export declare function decodeVector(b64: string): Float32Array | null;
@@ -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
- const prior = existing.get(record.id);
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 = existing.get(record.id);
73
- if (prior && prior.model === client.model && prior.hash === contentHash(embeddingText(record))) {
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.
@@ -140,7 +187,10 @@ export function decodeVector(b64) {
140
187
  const buf = Buffer.from(b64, "base64");
141
188
  if (buf.byteLength === 0 || buf.byteLength % 4 !== 0)
142
189
  return null;
143
- return Array.from(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
190
+ // Return the Float32Array itself rather than Array.from(...): a number[] stores every
191
+ // dimension as a double, doubling memory and copying 28k vectors on every cold load.
192
+ // slice() so the vector owns its bytes instead of pinning Node's shared Buffer pool.
193
+ return new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
144
194
  }
145
195
  catch {
146
196
  return null;
@@ -15,7 +15,7 @@ import type { PeonConfig } from "./config.js";
15
15
  *
16
16
  * - "off" mode: no embeddings; retrieval stays purely lexical.
17
17
  */
18
- export type EmbeddingVector = number[];
18
+ export type EmbeddingVector = number[] | Float32Array;
19
19
  export declare const LOCAL_EMBEDDING_DIM = 256;
20
20
  export declare const LOCAL_EMBEDDING_MODEL = "peon-local-trigram-v1";
21
21
  export interface EmbeddingClient {
@@ -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;
@@ -28,7 +28,10 @@ export function l2normalize(vector) {
28
28
  norm = Math.sqrt(norm);
29
29
  if (norm === 0)
30
30
  return vector.slice();
31
- return vector.map((value) => value / norm);
31
+ const out = new Float32Array(vector.length);
32
+ for (let i = 0; i < vector.length; i += 1)
33
+ out[i] = vector[i] / norm;
34
+ return out;
32
35
  }
33
36
  /**
34
37
  * Deterministic local embedding: hashed character trigrams folded into a fixed
@@ -96,7 +99,8 @@ function b64decode(b64) {
96
99
  const buf = Buffer.from(b64, "base64");
97
100
  if (buf.byteLength === 0 || buf.byteLength % 4 !== 0)
98
101
  return null;
99
- return Array.from(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
102
+ // slice() to own the bytes: a view onto buf.buffer would pin Node's shared Buffer pool.
103
+ return new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
100
104
  }
101
105
  catch {
102
106
  return null;
@@ -286,6 +290,15 @@ export class FallbackEmbeddingClient {
286
290
  fallback;
287
291
  onFallback;
288
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;
289
302
  constructor(primary, fallback = new LocalEmbeddingClient(), onFallback) {
290
303
  this.primary = primary;
291
304
  this.fallback = fallback;
@@ -294,19 +307,73 @@ export class FallbackEmbeddingClient {
294
307
  }
295
308
  async embed(texts) {
296
309
  try {
297
- return await this.primary.embed(texts);
310
+ const vectors = await this.primary.embed(texts);
311
+ this.degraded = false;
312
+ return vectors;
298
313
  }
299
314
  catch (error) {
300
315
  this.onFallback?.(error);
316
+ this.degraded = true;
301
317
  return this.fallback.embed(texts);
302
318
  }
303
319
  }
304
320
  }
305
- /** Build the embedding client implied by config, or null when embeddings are off. */
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
+ }
306
360
  export function createEmbeddingClient(options) {
307
361
  const { config } = options;
362
+ warnOnce(resolveEmbeddingPlan(config));
308
363
  if (config.embeddingMode === "off")
309
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
+ });
310
377
  if (config.embeddingMode === "ollama") {
311
378
  // Local semantic embeddings. Fall back to the API client (if configured) then trigram-local,
312
379
  // so a stopped Ollama service degrades instead of breaking retrieval.
@@ -315,9 +382,9 @@ export function createEmbeddingClient(options) {
315
382
  baseUrl: config.ollamaBaseUrl
316
383
  });
317
384
  const fallback = config.openRouterApiKey && config.embeddingModel && config.embeddingModel.includes("/")
318
- ? new FallbackEmbeddingClient(new OpenRouterEmbeddingClient({ apiKey: config.openRouterApiKey, model: config.embeddingModel }), new LocalEmbeddingClient(), options.onFallback)
385
+ ? new FallbackEmbeddingClient(new OpenRouterEmbeddingClient({ apiKey: config.openRouterApiKey, model: config.embeddingModel }), new LocalEmbeddingClient(), onFallback)
319
386
  : new LocalEmbeddingClient();
320
- return new FallbackEmbeddingClient(ollama, fallback, options.onFallback);
387
+ return new FallbackEmbeddingClient(ollama, fallback, onFallback);
321
388
  }
322
389
  const apiKey = config.llmApiKey ?? config.openRouterApiKey;
323
390
  const embeddable = config.provider !== "anthropic"; // Anthropic has no embeddings API — local fallback
@@ -327,7 +394,7 @@ export function createEmbeddingClient(options) {
327
394
  model: config.embeddingModel,
328
395
  baseUrl: config.llmBaseUrl
329
396
  });
330
- return new FallbackEmbeddingClient(primary, new LocalEmbeddingClient(), options.onFallback);
397
+ return new FallbackEmbeddingClient(primary, new LocalEmbeddingClient(), onFallback);
331
398
  }
332
399
  // Default and "api"-without-credentials both resolve to deterministic local embeddings.
333
400
  return new LocalEmbeddingClient();
@@ -1,5 +1,5 @@
1
1
  import { type PeonConfig } from "./config.js";
2
- import { type EmbeddingClient } from "./embeddings.js";
2
+ import { type EmbeddingClient, type EmbeddingVector } from "./embeddings.js";
3
3
  import type { MemoryQualityReport } from "./quality.js";
4
4
  import { type MemoryPatch } from "./memory-mutations.js";
5
5
  import { type BrainAction, type Summarizer } from "./brain.js";
@@ -134,7 +134,7 @@ export declare class PeonMemoryStore {
134
134
  */
135
135
  rankRecordsReadonly(query: string | undefined, options?: {
136
136
  limit?: number;
137
- queryVector?: number[];
137
+ queryVector?: EmbeddingVector;
138
138
  }): Promise<RankedMemoryRecord[]>;
139
139
  private buildSemanticInput;
140
140
  writeQualityReport(report: MemoryQualityReport): Promise<void>;