peon-mem 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +301 -0
  3. package/bin/peon-mem.mjs +273 -0
  4. package/dist/brain.d.ts +72 -0
  5. package/dist/brain.js +224 -0
  6. package/dist/compression.d.ts +9 -0
  7. package/dist/compression.js +37 -0
  8. package/dist/config.d.ts +22 -0
  9. package/dist/config.js +99 -0
  10. package/dist/daemon-cli.d.ts +2 -0
  11. package/dist/daemon-cli.js +54 -0
  12. package/dist/daemon.d.ts +23 -0
  13. package/dist/daemon.js +1078 -0
  14. package/dist/embedding-store.d.ts +43 -0
  15. package/dist/embedding-store.js +169 -0
  16. package/dist/embeddings.d.ts +93 -0
  17. package/dist/embeddings.js +345 -0
  18. package/dist/entities.d.ts +61 -0
  19. package/dist/entities.js +191 -0
  20. package/dist/entity-extraction.d.ts +33 -0
  21. package/dist/entity-extraction.js +75 -0
  22. package/dist/eval-metrics.d.ts +27 -0
  23. package/dist/eval-metrics.js +50 -0
  24. package/dist/evaluation.d.ts +58 -0
  25. package/dist/evaluation.js +244 -0
  26. package/dist/global-extraction.d.ts +15 -0
  27. package/dist/global-extraction.js +61 -0
  28. package/dist/global-memory.d.ts +43 -0
  29. package/dist/global-memory.js +306 -0
  30. package/dist/global-promotion.d.ts +25 -0
  31. package/dist/global-promotion.js +29 -0
  32. package/dist/hyde.d.ts +31 -0
  33. package/dist/hyde.js +46 -0
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.js +246 -0
  36. package/dist/injection.d.ts +38 -0
  37. package/dist/injection.js +133 -0
  38. package/dist/logger.d.ts +17 -0
  39. package/dist/logger.js +63 -0
  40. package/dist/memory-mutations.d.ts +24 -0
  41. package/dist/memory-mutations.js +57 -0
  42. package/dist/memory-store.d.ts +194 -0
  43. package/dist/memory-store.js +1205 -0
  44. package/dist/monitor.d.ts +13 -0
  45. package/dist/monitor.js +977 -0
  46. package/dist/overview.d.ts +73 -0
  47. package/dist/overview.js +104 -0
  48. package/dist/processor.d.ts +90 -0
  49. package/dist/processor.js +450 -0
  50. package/dist/quality.d.ts +86 -0
  51. package/dist/quality.js +338 -0
  52. package/dist/recuration.d.ts +13 -0
  53. package/dist/recuration.js +65 -0
  54. package/dist/reranker.d.ts +34 -0
  55. package/dist/reranker.js +89 -0
  56. package/dist/retrieval.d.ts +106 -0
  57. package/dist/retrieval.js +392 -0
  58. package/dist/session-index.d.ts +34 -0
  59. package/dist/session-index.js +87 -0
  60. package/dist/temporal.d.ts +20 -0
  61. package/dist/temporal.js +62 -0
  62. package/dist/token-ab-monitor.d.ts +1 -0
  63. package/dist/token-ab-monitor.js +7 -0
  64. package/dist/tools.d.ts +232 -0
  65. package/dist/tools.js +546 -0
  66. package/dist/types.d.ts +169 -0
  67. package/dist/types.js +1 -0
  68. package/docs/assets/neural-universe.png +0 -0
  69. package/package.json +57 -0
  70. package/scripts/claude-peon-hook.mjs +522 -0
  71. package/scripts/codex-peon-hook.mjs +4 -0
  72. package/scripts/eval-retrieval-labeled.mjs +135 -0
  73. package/scripts/eval-retrieval.mjs +96 -0
  74. package/scripts/evaluate-peon.mjs +47 -0
  75. package/scripts/install-peon-stl.mjs +82 -0
  76. package/scripts/install-peon.mjs +318 -0
  77. package/scripts/lib/eval-ledger.mjs +104 -0
  78. package/scripts/lib/stl-classify.mjs +44 -0
  79. package/scripts/longmemeval-eval.mjs +144 -0
  80. package/scripts/peon-report.mjs +155 -0
  81. package/scripts/peon-stl.mjs +506 -0
  82. package/scripts/token-ab-monitor.html +235 -0
@@ -0,0 +1,72 @@
1
+ import type { MemoryRecord } from "./types.js";
2
+ /**
3
+ * The autonomous brain — a "sleep cycle" of curation the daemon runs on its own.
4
+ * Pure and testable: every step takes a belief set and returns a new one plus a
5
+ * log of what it did. Nothing is destroyed — losers/merged/compressed beliefs are
6
+ * demoted to the `archived` tier (recoverable, searchable, never injected).
7
+ *
8
+ * The LLM-dependent step (topic compression) takes an injected `summarize`
9
+ * callback so the orchestration stays unit-testable without a model.
10
+ */
11
+ export type BrainActionType = "reinforce" | "resolve_conflict" | "merge_duplicate" | "compress_cluster";
12
+ export interface BrainAction {
13
+ type: BrainActionType;
14
+ detail: string;
15
+ affectedIds: string[];
16
+ }
17
+ export interface SleepCycleResult {
18
+ records: MemoryRecord[];
19
+ actions: BrainAction[];
20
+ }
21
+ /**
22
+ * Reinforcement: beliefs recalled since the last pass get stronger and a fresh
23
+ * lastRecalledAt; everything else relaxes slightly toward its importance anchor.
24
+ * This is the "use it or lose it" signal — except nothing is lost, it only
25
+ * decides what stays detailed in working memory vs. eligible for compression.
26
+ */
27
+ export declare function reinforce(records: readonly MemoryRecord[], recalledIds: readonly string[], now: string, protectGlobalScope?: boolean): SleepCycleResult;
28
+ /**
29
+ * Resolve detected conflicts autonomously: the higher-confidence belief wins,
30
+ * ties break to the newer one; the loser is archived (recoverable), not deleted.
31
+ */
32
+ export declare function resolveConflicts(records: readonly MemoryRecord[], now: string, protectGlobalScope?: boolean): SleepCycleResult;
33
+ /**
34
+ * Auto-merge near-duplicate beliefs: fold the weaker into the stronger (union
35
+ * entities, keep the higher scores), archiving the raw copy via the merge helper's
36
+ * delete — here we instead ARCHIVE the dropped record so nothing is lost.
37
+ */
38
+ export declare function autoMergeDuplicates(records: readonly MemoryRecord[], now: string, threshold?: number, protectGlobalScope?: boolean): SleepCycleResult;
39
+ export interface TopicCluster {
40
+ entity: string;
41
+ members: MemoryRecord[];
42
+ }
43
+ /** Group ACTIVE, non-protected beliefs by their dominant entity. */
44
+ export declare function findTopicClusters(records: readonly MemoryRecord[], minSize: number, protectGlobalScope?: boolean): TopicCluster[];
45
+ export type Summarizer = (cluster: TopicCluster) => Promise<string>;
46
+ /**
47
+ * Compress topic clusters: when many beliefs share an entity, roll them into one
48
+ * summary belief (via the injected LLM `summarize`) and archive the raw detail,
49
+ * linked by summaryOf/summarizedBy. Working memory shrinks; nothing is lost.
50
+ */
51
+ export declare function compressTopicClusters(records: readonly MemoryRecord[], summarize: Summarizer, now: string, options?: {
52
+ minClusterSize?: number;
53
+ maxClusters?: number;
54
+ protectGlobalScope?: boolean;
55
+ makeId: (entity: string) => string;
56
+ }): Promise<SleepCycleResult>;
57
+ export interface SleepCycleOptions {
58
+ recalledIds?: string[];
59
+ now: string;
60
+ summarize?: Summarizer;
61
+ minClusterSize?: number;
62
+ maxClusters?: number;
63
+ /** Set false when curating the GLOBAL brain — there, global beliefs are the working set. */
64
+ protectGlobalScope?: boolean;
65
+ makeSummaryId: (entity: string) => string;
66
+ }
67
+ /**
68
+ * One full autonomous pass: reinforce → resolve conflicts → merge duplicates →
69
+ * compress topic clusters. Returns the curated belief set and an action log.
70
+ * The caller snapshots a backup BEFORE applying this — every step is recoverable.
71
+ */
72
+ export declare function runSleepCycle(records: readonly MemoryRecord[], options: SleepCycleOptions): Promise<SleepCycleResult>;
package/dist/brain.js ADDED
@@ -0,0 +1,224 @@
1
+ import { detectMemoryConflicts } from "./quality.js";
2
+ import { detectDuplicates } from "./overview.js";
3
+ import { applyMerge } from "./memory-mutations.js";
4
+ function isProtected(record, protectGlobalScope = true) {
5
+ // Pinned beliefs are always protected. Global-scoped beliefs are protected when
6
+ // curating a PROJECT brain (don't touch shared memory), but NOT when curating the
7
+ // GLOBAL brain itself — there, global beliefs are the working set.
8
+ if (record.pinned)
9
+ return true;
10
+ return protectGlobalScope && record.scope === "global";
11
+ }
12
+ function clamp01(value) {
13
+ return Math.max(0, Math.min(1, value));
14
+ }
15
+ /** Strength a belief starts at if it has never been scored — anchored to importance. */
16
+ function baseStrength(record) {
17
+ return typeof record.strength === "number" ? record.strength : record.score.importance;
18
+ }
19
+ /**
20
+ * Reinforcement: beliefs recalled since the last pass get stronger and a fresh
21
+ * lastRecalledAt; everything else relaxes slightly toward its importance anchor.
22
+ * This is the "use it or lose it" signal — except nothing is lost, it only
23
+ * decides what stays detailed in working memory vs. eligible for compression.
24
+ */
25
+ export function reinforce(records, recalledIds, now, protectGlobalScope = true) {
26
+ const recalled = new Set(recalledIds);
27
+ const actions = [];
28
+ const out = records.map((record) => {
29
+ const current = baseStrength(record);
30
+ if (recalled.has(record.id)) {
31
+ actions.push({ type: "reinforce", detail: `recalled: ${record.content.slice(0, 60)}`, affectedIds: [record.id] });
32
+ return {
33
+ ...record,
34
+ strength: clamp01(current + 0.15 * (1 - current)),
35
+ recallCount: (record.recallCount ?? 0) + 1,
36
+ lastRecalledAt: now
37
+ };
38
+ }
39
+ // Gentle relaxation toward the importance anchor (never below it for protected beliefs).
40
+ const anchor = record.score.importance;
41
+ const relaxed = isProtected(record, protectGlobalScope) ? Math.max(current, anchor) : current - 0.02 * (current - anchor * 0.5);
42
+ return { ...record, strength: clamp01(relaxed) };
43
+ });
44
+ return { records: out, actions };
45
+ }
46
+ /**
47
+ * Resolve detected conflicts autonomously: the higher-confidence belief wins,
48
+ * ties break to the newer one; the loser is archived (recoverable), not deleted.
49
+ */
50
+ export function resolveConflicts(records, now, protectGlobalScope = true) {
51
+ // Consolidation flags BOTH sides of a conflict as "conflicted" and waits for a
52
+ // human. The brain re-detects among active+conflicted and decides: winner back
53
+ // to active, loser archived (recoverable).
54
+ const candidates = records.filter((r) => r.status === "active" || r.status === "conflicted");
55
+ const conflicts = detectMemoryConflicts(candidates);
56
+ if (conflicts.length === 0)
57
+ return { records: [...records], actions: [] };
58
+ const byId = new Map(records.map((r) => [r.id, r]));
59
+ const archived = new Set();
60
+ const reactivated = new Set();
61
+ const actions = [];
62
+ for (const conflict of conflicts) {
63
+ const left = byId.get(conflict.leftId);
64
+ const right = byId.get(conflict.rightId);
65
+ if (!left || !right)
66
+ continue;
67
+ if (archived.has(left.id) || archived.has(right.id))
68
+ continue;
69
+ // Protected beliefs always win; otherwise confidence, then recency.
70
+ let loser;
71
+ if (isProtected(left, protectGlobalScope) && !isProtected(right, protectGlobalScope))
72
+ loser = right;
73
+ else if (isProtected(right, protectGlobalScope) && !isProtected(left, protectGlobalScope))
74
+ loser = left;
75
+ else if (left.score.confidence !== right.score.confidence)
76
+ loser = left.score.confidence < right.score.confidence ? left : right;
77
+ else
78
+ loser = left.updatedAt <= right.updatedAt ? left : right;
79
+ const winner = loser.id === left.id ? right : left;
80
+ if (isProtected(loser, protectGlobalScope))
81
+ continue; // never archive a protected belief
82
+ archived.add(loser.id);
83
+ reactivated.add(winner.id);
84
+ actions.push({
85
+ type: "resolve_conflict",
86
+ detail: `kept "${winner.content.slice(0, 40)}" over "${loser.content.slice(0, 40)}"`,
87
+ affectedIds: [winner.id, loser.id]
88
+ });
89
+ }
90
+ const out = records.map((r) => {
91
+ if (archived.has(r.id))
92
+ return { ...r, status: "archived", updatedAt: now };
93
+ if (reactivated.has(r.id))
94
+ return { ...r, status: "active", updatedAt: now };
95
+ return r;
96
+ });
97
+ return { records: out, actions };
98
+ }
99
+ /**
100
+ * Auto-merge near-duplicate beliefs: fold the weaker into the stronger (union
101
+ * entities, keep the higher scores), archiving the raw copy via the merge helper's
102
+ * delete — here we instead ARCHIVE the dropped record so nothing is lost.
103
+ */
104
+ export function autoMergeDuplicates(records, now, threshold = 0.6, protectGlobalScope = true) {
105
+ const pairs = detectDuplicates(records, { threshold, limit: 50 });
106
+ if (pairs.length === 0)
107
+ return { records: [...records], actions: [] };
108
+ const byId = new Map(records.map((r) => [r.id, r]));
109
+ let working = [...records];
110
+ const archived = new Set();
111
+ const actions = [];
112
+ for (const pair of pairs) {
113
+ const a = byId.get(pair.aId);
114
+ const b = byId.get(pair.bId);
115
+ if (!a || !b || archived.has(a.id) || archived.has(b.id))
116
+ continue;
117
+ if (isProtected(a, protectGlobalScope) && isProtected(b, protectGlobalScope))
118
+ continue;
119
+ // Keep the stronger (or protected) belief; archive the other.
120
+ const keep = isProtected(a, protectGlobalScope) ? a : isProtected(b, protectGlobalScope) ? b : baseStrength(a) >= baseStrength(b) ? a : b;
121
+ const drop = keep.id === a.id ? b : a;
122
+ // Fold drop's entities/score into keep (applyMerge removes drop), then re-add
123
+ // drop as an archived record so the raw copy is recoverable, not erased.
124
+ working = applyMerge(working, keep.id, drop.id, now);
125
+ working.push({ ...drop, status: "archived", summarizedBy: keep.id, updatedAt: now });
126
+ archived.add(drop.id);
127
+ actions.push({ type: "merge_duplicate", detail: `merged duplicate of "${keep.content.slice(0, 50)}"`, affectedIds: [keep.id, drop.id] });
128
+ }
129
+ return { records: working, actions };
130
+ }
131
+ /** Group ACTIVE, non-protected beliefs by their dominant entity. */
132
+ export function findTopicClusters(records, minSize, protectGlobalScope = true) {
133
+ const byEntity = new Map();
134
+ for (const record of records) {
135
+ if (record.status !== "active" || isProtected(record, protectGlobalScope))
136
+ continue;
137
+ if (record.summaryOf)
138
+ continue; // don't re-compress existing summaries
139
+ const entity = (record.entities[0] ?? "").toLowerCase();
140
+ if (!entity)
141
+ continue;
142
+ const list = byEntity.get(entity) ?? [];
143
+ list.push(record);
144
+ byEntity.set(entity, list);
145
+ }
146
+ return Array.from(byEntity.entries())
147
+ .filter(([, members]) => members.length >= minSize)
148
+ .map(([entity, members]) => ({ entity, members }));
149
+ }
150
+ /**
151
+ * Compress topic clusters: when many beliefs share an entity, roll them into one
152
+ * summary belief (via the injected LLM `summarize`) and archive the raw detail,
153
+ * linked by summaryOf/summarizedBy. Working memory shrinks; nothing is lost.
154
+ */
155
+ export async function compressTopicClusters(records, summarize, now, options = { makeId: (e) => `summary_${e}` }) {
156
+ const minClusterSize = options.minClusterSize ?? 5;
157
+ const maxClusters = options.maxClusters ?? 3; // bound LLM cost per pass
158
+ const clusters = findTopicClusters(records, minClusterSize, options.protectGlobalScope ?? true)
159
+ .sort((a, b) => b.members.length - a.members.length)
160
+ .slice(0, maxClusters);
161
+ if (clusters.length === 0)
162
+ return { records: [...records], actions: [] };
163
+ let working = [...records];
164
+ const actions = [];
165
+ for (const cluster of clusters) {
166
+ const content = (await summarize(cluster)).trim();
167
+ if (!content)
168
+ continue;
169
+ const memberIds = cluster.members.map((m) => m.id);
170
+ const summaryId = options.makeId(cluster.entity);
171
+ const importance = Math.max(...cluster.members.map((m) => m.score.importance));
172
+ const entities = Array.from(new Set(cluster.members.flatMap((m) => m.entities)));
173
+ const summary = {
174
+ id: summaryId,
175
+ type: "summary",
176
+ content,
177
+ normalized: content.toLowerCase(),
178
+ scope: cluster.members[0].scope,
179
+ status: "active",
180
+ score: { importance, confidence: 0.82 },
181
+ source: { kind: "ai_processing", reason: `compressed ${memberIds.length} beliefs about ${cluster.entity}` },
182
+ entities,
183
+ createdAt: now,
184
+ updatedAt: now,
185
+ strength: importance,
186
+ summaryOf: memberIds
187
+ };
188
+ const memberSet = new Set(memberIds);
189
+ working = working.map((r) => (memberSet.has(r.id) ? { ...r, status: "archived", summarizedBy: summaryId, updatedAt: now } : r));
190
+ working.push(summary);
191
+ actions.push({ type: "compress_cluster", detail: `compressed ${memberIds.length} beliefs about "${cluster.entity}" into one summary`, affectedIds: [summaryId, ...memberIds] });
192
+ }
193
+ return { records: working, actions };
194
+ }
195
+ /**
196
+ * One full autonomous pass: reinforce → resolve conflicts → merge duplicates →
197
+ * compress topic clusters. Returns the curated belief set and an action log.
198
+ * The caller snapshots a backup BEFORE applying this — every step is recoverable.
199
+ */
200
+ export async function runSleepCycle(records, options) {
201
+ const actions = [];
202
+ let working = [...records];
203
+ const protectGlobalScope = options.protectGlobalScope ?? true;
204
+ const r = reinforce(working, options.recalledIds ?? [], options.now, protectGlobalScope);
205
+ working = r.records;
206
+ actions.push(...r.actions);
207
+ const c = resolveConflicts(working, options.now, protectGlobalScope);
208
+ working = c.records;
209
+ actions.push(...c.actions);
210
+ const m = autoMergeDuplicates(working, options.now, 0.6, protectGlobalScope);
211
+ working = m.records;
212
+ actions.push(...m.actions);
213
+ if (options.summarize) {
214
+ const z = await compressTopicClusters(working, options.summarize, options.now, {
215
+ minClusterSize: options.minClusterSize,
216
+ maxClusters: options.maxClusters,
217
+ protectGlobalScope,
218
+ makeId: options.makeSummaryId
219
+ });
220
+ working = z.records;
221
+ actions.push(...z.actions);
222
+ }
223
+ return { records: working, actions };
224
+ }
@@ -0,0 +1,9 @@
1
+ import type { PeonConfig } from "./config.js";
2
+ import type { Summarizer } from "./brain.js";
3
+ /**
4
+ * Builds the LLM summarizer the brain uses to compress a topic cluster into one
5
+ * gist belief. Kept separate from brain.ts so the curation logic stays pure and
6
+ * testable; this is the only network-touching piece. Returns null when AI is off
7
+ * or no key is configured (the brain then runs cost-free, skipping compression).
8
+ */
9
+ export declare function createClusterSummarizer(config: PeonConfig): Summarizer | null;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Builds the LLM summarizer the brain uses to compress a topic cluster into one
3
+ * gist belief. Kept separate from brain.ts so the curation logic stays pure and
4
+ * testable; this is the only network-touching piece. Returns null when AI is off
5
+ * or no key is configured (the brain then runs cost-free, skipping compression).
6
+ */
7
+ export function createClusterSummarizer(config) {
8
+ if (config.aiMode === "off" || !config.openRouterApiKey)
9
+ return null;
10
+ return async (cluster) => {
11
+ const beliefs = cluster.members.map((m, i) => `${i + 1}. ${m.content}`).join("\n");
12
+ const system = "You compress several related memory beliefs into ONE durable summary belief. " +
13
+ "PRESERVE every concrete fact verbatim — names, numbers, metrics, file paths, hostnames, decisions. " +
14
+ "Losing a specific fact is a failure; merge wording, never drop information. Drop only redundancy and filler. " +
15
+ "Output ONLY the summary sentence(s) — no preamble, no markdown, no quotes around it. Max 240 characters.";
16
+ const user = `Topic: ${cluster.entity}\n\nBeliefs to compress:\n${beliefs}\n\nOne compact summary:`;
17
+ const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
18
+ method: "POST",
19
+ headers: { Authorization: `Bearer ${config.openRouterApiKey}`, "Content-Type": "application/json" },
20
+ body: JSON.stringify({
21
+ model: config.processingModel,
22
+ messages: [
23
+ { role: "system", content: system },
24
+ { role: "user", content: user }
25
+ ],
26
+ temperature: 0.1
27
+ })
28
+ });
29
+ if (!response.ok)
30
+ throw new Error(`compression failed with ${response.status}`);
31
+ const json = (await response.json());
32
+ const content = json.choices?.[0]?.message?.content?.trim();
33
+ if (!content)
34
+ throw new Error("compression returned no content");
35
+ return content.slice(0, 280);
36
+ };
37
+ }
@@ -0,0 +1,22 @@
1
+ export type PeonProvider = "openrouter" | "openai" | "anthropic" | "ollama";
2
+ export interface PeonConfig {
3
+ /** LLM provider for consolidation (+ embeddings where supported). */
4
+ provider: PeonProvider;
5
+ /** Generic API key (falls back to provider-specific env vars). */
6
+ llmApiKey?: string;
7
+ /** OpenAI-compatible chat/embeddings base URL for the provider. */
8
+ llmBaseUrl: string;
9
+ openRouterApiKey?: string;
10
+ processingModel: string;
11
+ embeddingModel?: string;
12
+ embeddingMode: "off" | "local" | "api" | "ollama";
13
+ /** Ollama server for local semantic embeddings (embeddingMode "ollama"). */
14
+ ollamaBaseUrl?: string;
15
+ memoryDirName: string;
16
+ flushMinChars: number;
17
+ aiMode: "off" | "gated";
18
+ }
19
+ type Env = Record<string, string | undefined>;
20
+ export declare function loadPeonConfig(env?: Env): PeonConfig;
21
+ export declare function readEnvFile(startDir?: string): Env;
22
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,99 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, parse } from "node:path";
3
+ function optional(value) {
4
+ return value && value.trim() ? value.trim() : undefined;
5
+ }
6
+ function numberFromEnv(value, fallback) {
7
+ if (!value)
8
+ return fallback;
9
+ const parsed = Number.parseInt(value, 10);
10
+ return Number.isFinite(parsed) ? parsed : fallback;
11
+ }
12
+ function resolveEmbeddingMode(explicit, caps) {
13
+ const normalized = explicit?.trim().toLowerCase();
14
+ if (normalized === "off" || normalized === "api" || normalized === "local" || normalized === "ollama")
15
+ return normalized;
16
+ // No explicit mode: prefer REAL semantic embeddings when an API key + embedding
17
+ // model are configured; otherwise fall back to the deterministic local lexical
18
+ // (character-trigram) embeddings so retrieval still works fully offline.
19
+ return caps.hasKey && caps.hasModel ? "api" : "local";
20
+ }
21
+ const PROVIDER_BASE_URLS = {
22
+ openrouter: "https://openrouter.ai/api/v1",
23
+ openai: "https://api.openai.com/v1",
24
+ anthropic: "https://api.anthropic.com",
25
+ ollama: "http://127.0.0.1:11434/v1" // Ollama's OpenAI-compatible endpoint
26
+ };
27
+ const PROVIDER_DEFAULT_MODELS = {
28
+ openrouter: "google/gemini-2.5-flash-lite",
29
+ openai: "gpt-4o-mini",
30
+ anthropic: "claude-haiku-4-5-20251001",
31
+ ollama: "llama3.2"
32
+ };
33
+ function resolveProvider(mergedEnv) {
34
+ const explicit = mergedEnv.PEON_PROVIDER?.trim().toLowerCase();
35
+ if (explicit === "openrouter" || explicit === "openai" || explicit === "anthropic" || explicit === "ollama")
36
+ return explicit;
37
+ if (mergedEnv.OPENROUTER_API_KEY)
38
+ return "openrouter";
39
+ if (mergedEnv.OPENAI_API_KEY)
40
+ return "openai";
41
+ if (mergedEnv.ANTHROPIC_API_KEY)
42
+ return "anthropic";
43
+ return "openrouter"; // default; without a key the cost gate simply never opens
44
+ }
45
+ export function loadPeonConfig(env = process.env) {
46
+ const mergedEnv = env === process.env ? { ...readEnvFile(), ...env } : env;
47
+ const provider = resolveProvider(mergedEnv);
48
+ const llmApiKey = optional(mergedEnv.PEON_API_KEY) ??
49
+ optional(mergedEnv.OPENROUTER_API_KEY) ?? optional(mergedEnv.OPENAI_API_KEY) ?? optional(mergedEnv.ANTHROPIC_API_KEY);
50
+ const openRouterApiKey = provider === "openrouter" ? (optional(mergedEnv.OPENROUTER_API_KEY) ?? llmApiKey) : optional(mergedEnv.OPENROUTER_API_KEY);
51
+ const embeddingModel = optional(mergedEnv.PEON_EMBEDDING_MODEL);
52
+ return {
53
+ provider,
54
+ llmApiKey,
55
+ llmBaseUrl: optional(mergedEnv.PEON_LLM_BASE_URL) ?? PROVIDER_BASE_URLS[provider],
56
+ openRouterApiKey,
57
+ processingModel: mergedEnv.PEON_PROCESSING_MODEL ?? mergedEnv.PEON_SUMMARY_MODEL ?? PROVIDER_DEFAULT_MODELS[provider],
58
+ embeddingModel,
59
+ embeddingMode: resolveEmbeddingMode(mergedEnv.PEON_EMBEDDING_MODE, {
60
+ hasKey: Boolean(openRouterApiKey),
61
+ hasModel: Boolean(embeddingModel)
62
+ }),
63
+ ollamaBaseUrl: optional(mergedEnv.PEON_OLLAMA_URL),
64
+ memoryDirName: mergedEnv.PEON_MEMORY_DIR ?? ".peon",
65
+ flushMinChars: numberFromEnv(mergedEnv.PEON_FLUSH_MIN_CHARS, 6000),
66
+ aiMode: mergedEnv.PEON_AI_MODE === "off" ? "off" : "gated"
67
+ };
68
+ }
69
+ export function readEnvFile(startDir = process.cwd()) {
70
+ const envPath = findEnvFile(startDir);
71
+ if (!envPath)
72
+ return {};
73
+ return Object.fromEntries(readFileSync(envPath, "utf8")
74
+ .split(/\r?\n/)
75
+ .map((line) => line.trim())
76
+ .filter((line) => line && !line.startsWith("#"))
77
+ .map((line) => {
78
+ const equalsIndex = line.indexOf("=");
79
+ if (equalsIndex < 0)
80
+ return undefined;
81
+ const key = line.slice(0, equalsIndex).trim();
82
+ const rawValue = line.slice(equalsIndex + 1).trim();
83
+ const value = rawValue.replace(/^['"]|['"]$/g, "");
84
+ return key ? [key, value] : undefined;
85
+ })
86
+ .filter((entry) => Boolean(entry)));
87
+ }
88
+ function findEnvFile(startDir) {
89
+ let current = startDir;
90
+ const root = parse(current).root;
91
+ while (true) {
92
+ const candidate = join(current, ".env");
93
+ if (existsSync(candidate))
94
+ return candidate;
95
+ if (current === root)
96
+ return undefined;
97
+ current = dirname(current);
98
+ }
99
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ import { startPeonDaemon } from "./daemon.js";
3
+ const host = process.env.PEON_DAEMON_HOST ?? "127.0.0.1";
4
+ const parsedPort = Number.parseInt(process.env.PEON_DAEMON_PORT ?? "3737", 10);
5
+ const port = Number.isFinite(parsedPort) ? parsedPort : 3737;
6
+ /** True if a healthy Peon daemon already answers on this host:port. */
7
+ async function daemonAlreadyHealthy() {
8
+ try {
9
+ const controller = new AbortController();
10
+ const timer = setTimeout(() => controller.abort(), 1000);
11
+ const response = await fetch(`http://${host}:${port}/health`, { signal: controller.signal });
12
+ clearTimeout(timer);
13
+ if (!response.ok)
14
+ return false;
15
+ const body = (await response.json());
16
+ return body?.service === "peon-daemon";
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ // Single-owner: if a healthy daemon is already running (e.g. spawned by the MCP
23
+ // client), attach to it instead of fighting over the port and crashing.
24
+ if (await daemonAlreadyHealthy()) {
25
+ process.stderr.write(`Peon daemon already running on http://${host}:${port} — attaching.\n`);
26
+ process.exit(0);
27
+ }
28
+ let daemon;
29
+ try {
30
+ daemon = await startPeonDaemon({ host, port });
31
+ }
32
+ catch (error) {
33
+ // Lost a start race (another daemon bound the port between our health check and
34
+ // listen). If it's now healthy, that's fine — attach. Otherwise surface the error.
35
+ if (isAddrInUse(error) && (await daemonAlreadyHealthy())) {
36
+ process.stderr.write(`Peon daemon already running on http://${host}:${port} — attaching.\n`);
37
+ process.exit(0);
38
+ }
39
+ throw error;
40
+ }
41
+ process.stderr.write(`Peon daemon listening on ${daemon.url}\n`);
42
+ function isAddrInUse(error) {
43
+ return Boolean(error && typeof error === "object" && error.code === "EADDRINUSE");
44
+ }
45
+ async function shutdown() {
46
+ await daemon.close();
47
+ process.exit(0);
48
+ }
49
+ process.on("SIGINT", () => {
50
+ void shutdown();
51
+ });
52
+ process.on("SIGTERM", () => {
53
+ void shutdown();
54
+ });
@@ -0,0 +1,23 @@
1
+ export interface StartPeonDaemonOptions {
2
+ host?: string;
3
+ port?: number;
4
+ logDir?: string;
5
+ globalMemoryDir?: string;
6
+ }
7
+ export interface PeonDaemonHandle {
8
+ host: string;
9
+ port: number;
10
+ url: string;
11
+ close(): Promise<void>;
12
+ }
13
+ /**
14
+ * Resolve any path to its ONE project brain: collapse git-worktree paths to the repo root, then
15
+ * climb ancestors (bounded by home). A `.peon/root` marker declares a brain BOUNDARY — the nearest
16
+ * one wins and the climb stops there, so a big sub-project (e.g. a thesis folder) keeps its OWN
17
+ * brain instead of being swallowed by the parent. With no marker anywhere the behaviour is
18
+ * unchanged: climb to the TOPMOST `.peon` (unify stray subfolders onto the root brain). Applied at
19
+ * the daemon boundary so EVERY caller (Claude hook, direct MCP, Codex) resolves a path identically
20
+ * — not just the hook. Mirrors resolveProjectPath() in scripts/claude-peon-hook.mjs.
21
+ */
22
+ export declare function canonicalProjectPath(projectPath: string, home?: string): string;
23
+ export declare function startPeonDaemon(options?: StartPeonDaemonOptions): Promise<PeonDaemonHandle>;