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 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?;
@@ -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.
@@ -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;
@@ -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
- return await this.primary.embed(texts);
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
- /** 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
+ }
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(), options.onFallback)
385
+ ? new FallbackEmbeddingClient(new OpenRouterEmbeddingClient({ apiKey: config.openRouterApiKey, model: config.embeddingModel }), new LocalEmbeddingClient(), onFallback)
323
386
  : new LocalEmbeddingClient();
324
- return new FallbackEmbeddingClient(ollama, fallback, options.onFallback);
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(), options.onFallback);
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();