@199-bio/engram 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -0
- package/boba-prompt.md +107 -0
- package/dist/consolidation/consolidator.d.ts.map +1 -1
- package/dist/consolidation/plan.d.ts.map +1 -0
- package/dist/index.js +170 -9
- package/dist/retrieval/hybrid.d.ts.map +1 -1
- package/dist/storage/database.d.ts.map +1 -1
- package/dist/transport/http.d.ts.map +1 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/web/chat-handler.d.ts.map +1 -1
- package/nixpacks.toml +11 -0
- package/package.json +2 -1
- package/railway.json +13 -0
- package/src/consolidation/consolidator.ts +381 -29
- package/src/consolidation/plan.ts +444 -0
- package/src/index.ts +181 -10
- package/src/retrieval/hybrid.ts +69 -5
- package/src/storage/database.ts +358 -38
- package/src/transport/http.ts +111 -0
- package/src/transport/index.ts +24 -0
- package/src/web/chat-handler.ts +116 -70
- package/src/web/static/app.js +612 -360
- package/src/web/static/index.html +377 -130
- package/src/web/static/style.css +1249 -672
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consolidation Plan
|
|
3
|
+
*
|
|
4
|
+
* Implements a Standard Operating Procedure for safe consolidation of large backlogs.
|
|
5
|
+
* Prevents damage through:
|
|
6
|
+
* - Assessment and recovery mode detection
|
|
7
|
+
* - Prioritization (recent + high importance first)
|
|
8
|
+
* - Rate limiting with delays between API calls
|
|
9
|
+
* - Budget tracking and cost caps
|
|
10
|
+
* - Checkpointing for resume capability
|
|
11
|
+
* - Validation with soft rollback triggers
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomUUID } from "crypto";
|
|
15
|
+
import { EngramDatabase, Memory, Episode, ConsolidationCheckpoint } from "../storage/database.js";
|
|
16
|
+
|
|
17
|
+
// Token pricing (Opus 4.5 with extended thinking)
|
|
18
|
+
const PRICING = {
|
|
19
|
+
opus: {
|
|
20
|
+
input: 15 / 1_000_000, // $15 per 1M input tokens
|
|
21
|
+
output: 75 / 1_000_000, // $75 per 1M output tokens
|
|
22
|
+
thinking: 15 / 1_000_000, // $15 per 1M thinking tokens (same as input)
|
|
23
|
+
},
|
|
24
|
+
haiku: {
|
|
25
|
+
input: 0.80 / 1_000_000, // $0.80 per 1M input tokens
|
|
26
|
+
output: 4.00 / 1_000_000, // $4.00 per 1M output tokens
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Estimated tokens per operation (conservative estimates)
|
|
31
|
+
const TOKEN_ESTIMATES = {
|
|
32
|
+
episodeBatch: {
|
|
33
|
+
input: 2000, // Conversation text + system prompt
|
|
34
|
+
output: 1000, // Extracted memories JSON
|
|
35
|
+
},
|
|
36
|
+
memoryBatch: {
|
|
37
|
+
input: 3000, // Memories + system prompt
|
|
38
|
+
output: 2000, // Digest + contradictions
|
|
39
|
+
thinking: 10000, // Extended thinking budget
|
|
40
|
+
},
|
|
41
|
+
entityProfile: {
|
|
42
|
+
input: 4000,
|
|
43
|
+
output: 3000,
|
|
44
|
+
thinking: 16000,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export interface BacklogAssessment {
|
|
49
|
+
unconsolidatedMemories: number;
|
|
50
|
+
unconsolidatedEpisodes: number;
|
|
51
|
+
isRecoveryMode: boolean;
|
|
52
|
+
estimatedBatches: number;
|
|
53
|
+
estimatedCost: number;
|
|
54
|
+
dailyBudget: number;
|
|
55
|
+
dailySpent: number;
|
|
56
|
+
budgetRemaining: number;
|
|
57
|
+
canProceed: boolean;
|
|
58
|
+
recommendedBatches: number;
|
|
59
|
+
phases: PhasePlan[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PhasePlan {
|
|
63
|
+
phase: "episodes" | "memories" | "decay" | "cleanup";
|
|
64
|
+
itemCount: number;
|
|
65
|
+
batchCount: number;
|
|
66
|
+
estimatedCost: number;
|
|
67
|
+
estimatedTimeMs: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ConsolidationProgress {
|
|
71
|
+
runId: string;
|
|
72
|
+
phase: ConsolidationCheckpoint["phase"];
|
|
73
|
+
batchesCompleted: number;
|
|
74
|
+
batchesTotal: number;
|
|
75
|
+
memoriesProcessed: number;
|
|
76
|
+
episodesProcessed: number;
|
|
77
|
+
digestsCreated: number;
|
|
78
|
+
contradictionsFound: number;
|
|
79
|
+
tokensUsed: number;
|
|
80
|
+
estimatedCost: number;
|
|
81
|
+
errors: string[];
|
|
82
|
+
startedAt: Date;
|
|
83
|
+
elapsedMs: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface RollbackTrigger {
|
|
87
|
+
type: "error_rate" | "empty_digests" | "contradiction_rate" | "budget_exceeded";
|
|
88
|
+
threshold: number;
|
|
89
|
+
current: number;
|
|
90
|
+
triggered: boolean;
|
|
91
|
+
message: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class ConsolidationPlan {
|
|
95
|
+
private db: EngramDatabase;
|
|
96
|
+
private runId: string;
|
|
97
|
+
private errors: string[] = [];
|
|
98
|
+
private emptyDigests: number = 0;
|
|
99
|
+
private totalDigests: number = 0;
|
|
100
|
+
private apiCalls: number = 0;
|
|
101
|
+
private apiErrors: number = 0;
|
|
102
|
+
|
|
103
|
+
constructor(db: EngramDatabase) {
|
|
104
|
+
this.db = db;
|
|
105
|
+
this.runId = randomUUID();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Assess the current backlog and create a consolidation plan
|
|
110
|
+
*/
|
|
111
|
+
assessBacklog(): BacklogAssessment {
|
|
112
|
+
const unconsolidatedMem = this.db.getUnconsolidatedMemories(undefined, 10000);
|
|
113
|
+
const unconsolidatedEp = this.db.getUnconsolidatedEpisodes(10000);
|
|
114
|
+
|
|
115
|
+
const recoveryThreshold = this.db.getConfigNumber("recovery_mode_threshold", 100);
|
|
116
|
+
const isRecoveryMode = unconsolidatedMem.length > recoveryThreshold ||
|
|
117
|
+
unconsolidatedEp.length > recoveryThreshold;
|
|
118
|
+
|
|
119
|
+
const dailyBudget = this.db.getConfigNumber("daily_budget_usd", 5.0);
|
|
120
|
+
const dailySpent = this.db.getDailySpending();
|
|
121
|
+
const budgetRemaining = Math.max(0, dailyBudget - dailySpent);
|
|
122
|
+
|
|
123
|
+
const maxBatchesPerRun = this.db.getConfigNumber("max_batches_per_run", 5);
|
|
124
|
+
|
|
125
|
+
// Calculate phase plans
|
|
126
|
+
const episodeBatches = Math.ceil(unconsolidatedEp.length / 20);
|
|
127
|
+
const memoryBatches = Math.ceil(unconsolidatedMem.length / 15);
|
|
128
|
+
const totalBatches = episodeBatches + memoryBatches;
|
|
129
|
+
|
|
130
|
+
const phases: PhasePlan[] = [];
|
|
131
|
+
const delayMs = this.db.getConfigNumber("delay_between_calls_ms", 2000);
|
|
132
|
+
|
|
133
|
+
// Episode phase (Haiku - cheap)
|
|
134
|
+
if (unconsolidatedEp.length >= 4) {
|
|
135
|
+
const batchCount = Math.min(episodeBatches, maxBatchesPerRun);
|
|
136
|
+
const cost = batchCount * this.estimateEpisodeBatchCost();
|
|
137
|
+
phases.push({
|
|
138
|
+
phase: "episodes",
|
|
139
|
+
itemCount: Math.min(unconsolidatedEp.length, batchCount * 20),
|
|
140
|
+
batchCount,
|
|
141
|
+
estimatedCost: cost,
|
|
142
|
+
estimatedTimeMs: batchCount * (2000 + delayMs), // ~2s per Haiku call + delay
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Memory phase (Opus with thinking - expensive)
|
|
147
|
+
if (unconsolidatedMem.length >= 5) {
|
|
148
|
+
const batchCount = Math.min(memoryBatches, maxBatchesPerRun);
|
|
149
|
+
const cost = batchCount * this.estimateMemoryBatchCost();
|
|
150
|
+
phases.push({
|
|
151
|
+
phase: "memories",
|
|
152
|
+
itemCount: Math.min(unconsolidatedMem.length, batchCount * 15),
|
|
153
|
+
batchCount,
|
|
154
|
+
estimatedCost: cost,
|
|
155
|
+
estimatedTimeMs: batchCount * (15000 + delayMs), // ~15s per Opus call + delay
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Decay and cleanup phases (no API calls)
|
|
160
|
+
phases.push({ phase: "decay", itemCount: 0, batchCount: 0, estimatedCost: 0, estimatedTimeMs: 100 });
|
|
161
|
+
phases.push({ phase: "cleanup", itemCount: 0, batchCount: 0, estimatedCost: 0, estimatedTimeMs: 100 });
|
|
162
|
+
|
|
163
|
+
const estimatedCost = phases.reduce((sum, p) => sum + p.estimatedCost, 0);
|
|
164
|
+
const canProceed = estimatedCost <= budgetRemaining;
|
|
165
|
+
|
|
166
|
+
// In recovery mode, be more conservative
|
|
167
|
+
const recommendedBatches = isRecoveryMode
|
|
168
|
+
? Math.min(3, maxBatchesPerRun)
|
|
169
|
+
: maxBatchesPerRun;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
unconsolidatedMemories: unconsolidatedMem.length,
|
|
173
|
+
unconsolidatedEpisodes: unconsolidatedEp.length,
|
|
174
|
+
isRecoveryMode,
|
|
175
|
+
estimatedBatches: totalBatches,
|
|
176
|
+
estimatedCost,
|
|
177
|
+
dailyBudget,
|
|
178
|
+
dailySpent,
|
|
179
|
+
budgetRemaining,
|
|
180
|
+
canProceed,
|
|
181
|
+
recommendedBatches,
|
|
182
|
+
phases,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get prioritized memories for consolidation
|
|
188
|
+
* Priority: recent + high importance first, then older chronologically
|
|
189
|
+
*/
|
|
190
|
+
getPrioritizedMemories(limit: number): Memory[] {
|
|
191
|
+
const allMemories = this.db.getUnconsolidatedMemories(undefined, 10000);
|
|
192
|
+
|
|
193
|
+
// Score each memory
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
196
|
+
|
|
197
|
+
const scored = allMemories.map(m => {
|
|
198
|
+
const ageHours = (now - m.timestamp.getTime()) / (60 * 60 * 1000);
|
|
199
|
+
const ageDays = ageHours / 24;
|
|
200
|
+
|
|
201
|
+
// Recency score: 1.0 for today, decays over 7 days
|
|
202
|
+
const recencyScore = Math.max(0, 1 - (ageDays / 7));
|
|
203
|
+
|
|
204
|
+
// Importance score: 0-1
|
|
205
|
+
const importanceScore = m.importance;
|
|
206
|
+
|
|
207
|
+
// Emotional weight: 0-1
|
|
208
|
+
const emotionalScore = m.emotional_weight;
|
|
209
|
+
|
|
210
|
+
// Access frequency bonus
|
|
211
|
+
const accessBonus = Math.min(0.2, m.access_count * 0.05);
|
|
212
|
+
|
|
213
|
+
// Combined priority (weights: recency 40%, importance 30%, emotional 20%, access 10%)
|
|
214
|
+
const priority = (recencyScore * 0.4) +
|
|
215
|
+
(importanceScore * 0.3) +
|
|
216
|
+
(emotionalScore * 0.2) +
|
|
217
|
+
(accessBonus * 0.1);
|
|
218
|
+
|
|
219
|
+
return { memory: m, priority };
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Sort by priority (highest first)
|
|
223
|
+
scored.sort((a, b) => b.priority - a.priority);
|
|
224
|
+
|
|
225
|
+
return scored.slice(0, limit).map(s => s.memory);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Get prioritized episodes for consolidation
|
|
230
|
+
*/
|
|
231
|
+
getPrioritizedEpisodes(limit: number): Episode[] {
|
|
232
|
+
const episodes = this.db.getUnconsolidatedEpisodes(limit);
|
|
233
|
+
|
|
234
|
+
// Sort by timestamp (oldest first for episodes - process in order)
|
|
235
|
+
episodes.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
236
|
+
|
|
237
|
+
return episodes;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Create checkpoint for this run
|
|
242
|
+
*/
|
|
243
|
+
createCheckpoint(phase: ConsolidationCheckpoint["phase"], batchesTotal: number): ConsolidationCheckpoint {
|
|
244
|
+
return this.db.createCheckpoint(this.runId, phase, batchesTotal);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Update checkpoint progress
|
|
249
|
+
*/
|
|
250
|
+
updateProgress(updates: Partial<{
|
|
251
|
+
phase: ConsolidationCheckpoint["phase"];
|
|
252
|
+
batchesCompleted: number;
|
|
253
|
+
batchesTotal: number;
|
|
254
|
+
memoriesProcessed: number;
|
|
255
|
+
episodesProcessed: number;
|
|
256
|
+
digestsCreated: number;
|
|
257
|
+
contradictionsFound: number;
|
|
258
|
+
tokensUsed: number;
|
|
259
|
+
estimatedCost: number;
|
|
260
|
+
}>): void {
|
|
261
|
+
this.db.updateCheckpoint(this.runId, {
|
|
262
|
+
phase: updates.phase,
|
|
263
|
+
batches_completed: updates.batchesCompleted,
|
|
264
|
+
batches_total: updates.batchesTotal,
|
|
265
|
+
memories_processed: updates.memoriesProcessed,
|
|
266
|
+
episodes_processed: updates.episodesProcessed,
|
|
267
|
+
digests_created: updates.digestsCreated,
|
|
268
|
+
contradictions_found: updates.contradictionsFound,
|
|
269
|
+
tokens_used: updates.tokensUsed,
|
|
270
|
+
estimated_cost_usd: updates.estimatedCost,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Mark run as complete
|
|
276
|
+
*/
|
|
277
|
+
complete(): void {
|
|
278
|
+
this.db.completeCheckpoint(this.runId);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Mark run as failed
|
|
283
|
+
*/
|
|
284
|
+
fail(error: string): void {
|
|
285
|
+
this.db.updateCheckpoint(this.runId, { error });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Get current progress
|
|
290
|
+
*/
|
|
291
|
+
getProgress(): ConsolidationProgress {
|
|
292
|
+
const checkpoint = this.db.getCheckpoint(this.runId);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
runId: this.runId,
|
|
296
|
+
phase: checkpoint?.phase || "episodes",
|
|
297
|
+
batchesCompleted: checkpoint?.batches_completed || 0,
|
|
298
|
+
batchesTotal: checkpoint?.batches_total || 0,
|
|
299
|
+
memoriesProcessed: checkpoint?.memories_processed || 0,
|
|
300
|
+
episodesProcessed: checkpoint?.episodes_processed || 0,
|
|
301
|
+
digestsCreated: checkpoint?.digests_created || 0,
|
|
302
|
+
contradictionsFound: checkpoint?.contradictions_found || 0,
|
|
303
|
+
tokensUsed: checkpoint?.tokens_used || 0,
|
|
304
|
+
estimatedCost: checkpoint?.estimated_cost_usd || 0,
|
|
305
|
+
errors: this.errors,
|
|
306
|
+
startedAt: checkpoint?.started_at || new Date(),
|
|
307
|
+
elapsedMs: checkpoint ? Date.now() - checkpoint.started_at.getTime() : 0,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Check if we should resume a previous incomplete run
|
|
313
|
+
*/
|
|
314
|
+
checkForResume(): ConsolidationCheckpoint | null {
|
|
315
|
+
return this.db.getIncompleteCheckpoint();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Resume from a previous checkpoint
|
|
320
|
+
*/
|
|
321
|
+
resumeFrom(checkpoint: ConsolidationCheckpoint): void {
|
|
322
|
+
this.runId = checkpoint.run_id;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Record an API call result for tracking
|
|
327
|
+
*/
|
|
328
|
+
recordApiCall(success: boolean, tokensUsed?: number): void {
|
|
329
|
+
this.apiCalls++;
|
|
330
|
+
if (!success) {
|
|
331
|
+
this.apiErrors++;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Record a digest creation result
|
|
337
|
+
*/
|
|
338
|
+
recordDigest(isEmpty: boolean): void {
|
|
339
|
+
this.totalDigests++;
|
|
340
|
+
if (isEmpty) {
|
|
341
|
+
this.emptyDigests++;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Record an error
|
|
347
|
+
*/
|
|
348
|
+
recordError(error: string): void {
|
|
349
|
+
this.errors.push(error);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Check rollback triggers and return any that fired
|
|
354
|
+
*/
|
|
355
|
+
checkRollbackTriggers(): RollbackTrigger[] {
|
|
356
|
+
const triggers: RollbackTrigger[] = [];
|
|
357
|
+
|
|
358
|
+
// Error rate threshold
|
|
359
|
+
const errorRateThreshold = this.db.getConfigNumber("error_rate_threshold", 0.3);
|
|
360
|
+
if (this.apiCalls >= 3) {
|
|
361
|
+
const errorRate = this.apiErrors / this.apiCalls;
|
|
362
|
+
triggers.push({
|
|
363
|
+
type: "error_rate",
|
|
364
|
+
threshold: errorRateThreshold,
|
|
365
|
+
current: errorRate,
|
|
366
|
+
triggered: errorRate > errorRateThreshold,
|
|
367
|
+
message: `API error rate ${(errorRate * 100).toFixed(1)}% exceeds ${(errorRateThreshold * 100).toFixed(0)}%`,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Empty digest threshold
|
|
372
|
+
const emptyDigestThreshold = this.db.getConfigNumber("empty_digest_threshold", 0.2);
|
|
373
|
+
if (this.totalDigests >= 3) {
|
|
374
|
+
const emptyRate = this.emptyDigests / this.totalDigests;
|
|
375
|
+
triggers.push({
|
|
376
|
+
type: "empty_digests",
|
|
377
|
+
threshold: emptyDigestThreshold,
|
|
378
|
+
current: emptyRate,
|
|
379
|
+
triggered: emptyRate > emptyDigestThreshold,
|
|
380
|
+
message: `Empty digest rate ${(emptyRate * 100).toFixed(1)}% exceeds ${(emptyDigestThreshold * 100).toFixed(0)}%`,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Budget exceeded
|
|
385
|
+
const dailyBudget = this.db.getConfigNumber("daily_budget_usd", 5.0);
|
|
386
|
+
const dailySpent = this.db.getDailySpending();
|
|
387
|
+
triggers.push({
|
|
388
|
+
type: "budget_exceeded",
|
|
389
|
+
threshold: dailyBudget,
|
|
390
|
+
current: dailySpent,
|
|
391
|
+
triggered: dailySpent > dailyBudget,
|
|
392
|
+
message: `Daily spending $${dailySpent.toFixed(2)} exceeds budget $${dailyBudget.toFixed(2)}`,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
return triggers;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Delay between API calls (rate limiting)
|
|
400
|
+
*/
|
|
401
|
+
async delay(): Promise<void> {
|
|
402
|
+
const delayMs = this.db.getConfigNumber("delay_between_calls_ms", 2000);
|
|
403
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Estimate cost for an episode batch (Haiku)
|
|
408
|
+
*/
|
|
409
|
+
private estimateEpisodeBatchCost(): number {
|
|
410
|
+
const { input, output } = TOKEN_ESTIMATES.episodeBatch;
|
|
411
|
+
return (input * PRICING.haiku.input) + (output * PRICING.haiku.output);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Estimate cost for a memory batch (Opus with thinking)
|
|
416
|
+
*/
|
|
417
|
+
private estimateMemoryBatchCost(): number {
|
|
418
|
+
const { input, output, thinking } = TOKEN_ESTIMATES.memoryBatch;
|
|
419
|
+
return (input * PRICING.opus.input) +
|
|
420
|
+
(output * PRICING.opus.output) +
|
|
421
|
+
(thinking * PRICING.opus.thinking);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Calculate actual cost from token usage
|
|
426
|
+
*/
|
|
427
|
+
calculateCost(model: "opus" | "haiku", inputTokens: number, outputTokens: number, thinkingTokens?: number): number {
|
|
428
|
+
const pricing = PRICING[model];
|
|
429
|
+
let cost = (inputTokens * pricing.input) + (outputTokens * pricing.output);
|
|
430
|
+
|
|
431
|
+
if (model === "opus" && thinkingTokens) {
|
|
432
|
+
cost += thinkingTokens * PRICING.opus.thinking;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return cost;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Get the run ID
|
|
440
|
+
*/
|
|
441
|
+
getRunId(): string {
|
|
442
|
+
return this.runId;
|
|
443
|
+
}
|
|
444
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
15
15
|
import path from "path";
|
|
16
16
|
import os from "os";
|
|
17
|
+
import fs from "fs";
|
|
18
|
+
|
|
19
|
+
import { getTransportMode, getHttpPort } from "./transport/index.js";
|
|
20
|
+
import { startHttpServer } from "./transport/http.js";
|
|
17
21
|
|
|
18
22
|
import { EngramDatabase } from "./storage/database.js";
|
|
19
23
|
import { KnowledgeGraph } from "./graph/knowledge-graph.js";
|
|
@@ -29,6 +33,102 @@ const DB_PATH = process.env.ENGRAM_DB_PATH
|
|
|
29
33
|
: path.join(os.homedir(), ".engram");
|
|
30
34
|
|
|
31
35
|
const DB_FILE = path.join(DB_PATH, "engram.db");
|
|
36
|
+
const PID_FILE = path.join(DB_PATH, "engram.pid");
|
|
37
|
+
|
|
38
|
+
// ============ Zombie Prevention ============
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Kill any existing engram process and clean up stale PID file
|
|
42
|
+
*/
|
|
43
|
+
function cleanupZombies(): void {
|
|
44
|
+
try {
|
|
45
|
+
if (fs.existsSync(PID_FILE)) {
|
|
46
|
+
const oldPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
47
|
+
if (oldPid && oldPid !== process.pid) {
|
|
48
|
+
try {
|
|
49
|
+
// Check if process exists
|
|
50
|
+
process.kill(oldPid, 0);
|
|
51
|
+
// It exists, kill it
|
|
52
|
+
console.error(`[Engram] Killing old instance (PID ${oldPid})`);
|
|
53
|
+
process.kill(oldPid, "SIGTERM");
|
|
54
|
+
} catch {
|
|
55
|
+
// Process doesn't exist, that's fine
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
fs.unlinkSync(PID_FILE);
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error("[Engram] Error cleaning up zombies:", error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Write our PID file
|
|
67
|
+
*/
|
|
68
|
+
function writePidFile(): void {
|
|
69
|
+
try {
|
|
70
|
+
// Ensure directory exists
|
|
71
|
+
if (!fs.existsSync(DB_PATH)) {
|
|
72
|
+
fs.mkdirSync(DB_PATH, { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
fs.writeFileSync(PID_FILE, String(process.pid));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error("[Engram] Error writing PID file:", error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Clean up on exit
|
|
82
|
+
*/
|
|
83
|
+
function cleanup(): void {
|
|
84
|
+
try {
|
|
85
|
+
if (fs.existsSync(PID_FILE)) {
|
|
86
|
+
const storedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
87
|
+
if (storedPid === process.pid) {
|
|
88
|
+
fs.unlinkSync(PID_FILE);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (webServer) {
|
|
92
|
+
webServer.stop();
|
|
93
|
+
}
|
|
94
|
+
if (db) {
|
|
95
|
+
db.close();
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// Ignore cleanup errors
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Register signal handlers early
|
|
103
|
+
process.on("SIGTERM", () => {
|
|
104
|
+
console.error("[Engram] Received SIGTERM, shutting down...");
|
|
105
|
+
cleanup();
|
|
106
|
+
process.exit(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
process.on("SIGINT", () => {
|
|
110
|
+
console.error("[Engram] Received SIGINT, shutting down...");
|
|
111
|
+
cleanup();
|
|
112
|
+
process.exit(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
process.on("exit", cleanup);
|
|
116
|
+
|
|
117
|
+
// Detect when parent process (Claude) dies by monitoring stdin
|
|
118
|
+
// Only needed in stdio mode
|
|
119
|
+
if (getTransportMode() === "stdio") {
|
|
120
|
+
process.stdin.on("end", () => {
|
|
121
|
+
console.error("[Engram] stdin closed, parent process likely died. Shutting down...");
|
|
122
|
+
cleanup();
|
|
123
|
+
process.exit(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
process.stdin.on("close", () => {
|
|
127
|
+
console.error("[Engram] stdin closed, shutting down...");
|
|
128
|
+
cleanup();
|
|
129
|
+
process.exit(0);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
32
132
|
|
|
33
133
|
// ============ Initialize Components ============
|
|
34
134
|
|
|
@@ -402,7 +502,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
402
502
|
includeGraph: include_graph,
|
|
403
503
|
});
|
|
404
504
|
|
|
505
|
+
// Format digests (synthesized context - these provide broad understanding)
|
|
506
|
+
const digestsFormatted = response.digests.map((d) => ({
|
|
507
|
+
type: "digest" as const,
|
|
508
|
+
id: d.digest.id,
|
|
509
|
+
level: d.digest.level, // 1=session, 2=topic, 3=entity
|
|
510
|
+
topic: d.digest.topic,
|
|
511
|
+
content: d.digest.content,
|
|
512
|
+
source_count: d.digest.source_count,
|
|
513
|
+
period: {
|
|
514
|
+
start: d.digest.period_start.toISOString(),
|
|
515
|
+
end: d.digest.period_end.toISOString(),
|
|
516
|
+
},
|
|
517
|
+
relevance_score: d.score.toFixed(4),
|
|
518
|
+
// Key evidence - specific memories supporting this synthesis
|
|
519
|
+
key_memories: d.key_memories.map((m) => ({
|
|
520
|
+
id: m.id,
|
|
521
|
+
content: m.content,
|
|
522
|
+
timestamp: m.timestamp.toISOString(),
|
|
523
|
+
})),
|
|
524
|
+
}));
|
|
525
|
+
|
|
405
526
|
const formatted = response.results.map((r) => ({
|
|
527
|
+
type: "memory" as const,
|
|
406
528
|
id: r.memory.id,
|
|
407
529
|
content: r.memory.content,
|
|
408
530
|
source: r.memory.source,
|
|
@@ -417,6 +539,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
417
539
|
|
|
418
540
|
// Format connected memories (Hebbian associations)
|
|
419
541
|
const connectedFormatted = response.connected_memories.map((c) => ({
|
|
542
|
+
type: "connected" as const,
|
|
420
543
|
id: c.memory.id,
|
|
421
544
|
content: c.memory.content,
|
|
422
545
|
connected_to: c.connected_to,
|
|
@@ -430,10 +553,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
430
553
|
text: JSON.stringify({
|
|
431
554
|
recall_id: response.recall_id, // For memory_feedback
|
|
432
555
|
query,
|
|
556
|
+
// Digests first - they provide synthesized context
|
|
557
|
+
digests: digestsFormatted,
|
|
558
|
+
digests_count: digestsFormatted.length,
|
|
559
|
+
// Then individual memories for specific evidence
|
|
433
560
|
results: formatted,
|
|
434
561
|
count: formatted.length,
|
|
435
562
|
connected_memories: connectedFormatted,
|
|
436
|
-
hint: formatted.length > 0
|
|
563
|
+
hint: formatted.length > 0 || digestsFormatted.length > 0
|
|
564
|
+
? "Call memory_feedback with useful_memory_ids after answering"
|
|
565
|
+
: undefined,
|
|
437
566
|
}, null, 2),
|
|
438
567
|
},
|
|
439
568
|
],
|
|
@@ -624,17 +753,44 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
624
753
|
need_more?: boolean;
|
|
625
754
|
};
|
|
626
755
|
|
|
627
|
-
//
|
|
628
|
-
const
|
|
756
|
+
// First, get the original recall to validate useful_memory_ids
|
|
757
|
+
const retrievalLog = db.getRetrievalLog(recall_id);
|
|
758
|
+
if (!retrievalLog) {
|
|
759
|
+
return {
|
|
760
|
+
content: [
|
|
761
|
+
{
|
|
762
|
+
type: "text" as const,
|
|
763
|
+
text: JSON.stringify({
|
|
764
|
+
success: false,
|
|
765
|
+
error: `Recall ID not found: ${recall_id}`,
|
|
766
|
+
}),
|
|
767
|
+
},
|
|
768
|
+
],
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Validate: only accept IDs that were in the original recall
|
|
773
|
+
const originalIdSet = new Set(retrievalLog.memory_ids);
|
|
774
|
+
const validUsefulIds = useful_memory_ids.filter(id => originalIdSet.has(id));
|
|
775
|
+
const invalidIds = useful_memory_ids.filter(id => !originalIdSet.has(id));
|
|
776
|
+
|
|
777
|
+
if (invalidIds.length > 0) {
|
|
778
|
+
console.error(`[Engram] memory_feedback: ${invalidIds.length} IDs not in original recall, ignored: ${invalidIds.join(", ")}`);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Update the retrieval log with validated feedback
|
|
782
|
+
const updated = db.updateRetrievalFeedback(recall_id, validUsefulIds, need_more);
|
|
629
783
|
|
|
630
784
|
if (!updated) {
|
|
785
|
+
// Should not happen since we already checked above, but handle gracefully
|
|
786
|
+
console.error(`[Engram] memory_feedback: failed to update retrieval log ${recall_id}`);
|
|
631
787
|
return {
|
|
632
788
|
content: [
|
|
633
789
|
{
|
|
634
790
|
type: "text" as const,
|
|
635
791
|
text: JSON.stringify({
|
|
636
792
|
success: false,
|
|
637
|
-
error: `
|
|
793
|
+
error: `Failed to update feedback for: ${recall_id}`,
|
|
638
794
|
}),
|
|
639
795
|
},
|
|
640
796
|
],
|
|
@@ -664,7 +820,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
664
820
|
text: JSON.stringify({
|
|
665
821
|
success: true,
|
|
666
822
|
feedback_recorded: true,
|
|
667
|
-
useful_count:
|
|
823
|
+
useful_count: validUsefulIds.length,
|
|
668
824
|
expanded_search: true,
|
|
669
825
|
additional_results: formatted,
|
|
670
826
|
additional_count: formatted.length,
|
|
@@ -697,7 +853,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
697
853
|
text: JSON.stringify({
|
|
698
854
|
success: true,
|
|
699
855
|
feedback_recorded: true,
|
|
700
|
-
useful_count:
|
|
856
|
+
useful_count: validUsefulIds.length,
|
|
701
857
|
learning_applied: learningApplied > 0,
|
|
702
858
|
connections_strengthened: learningApplied,
|
|
703
859
|
}, null, 2),
|
|
@@ -726,12 +882,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
726
882
|
// ============ Main ============
|
|
727
883
|
|
|
728
884
|
async function main() {
|
|
729
|
-
|
|
885
|
+
const transportMode = getTransportMode();
|
|
730
886
|
|
|
731
|
-
|
|
732
|
-
|
|
887
|
+
// Zombie cleanup only needed in stdio mode (local usage)
|
|
888
|
+
if (transportMode === "stdio") {
|
|
889
|
+
cleanupZombies();
|
|
890
|
+
writePidFile();
|
|
891
|
+
}
|
|
733
892
|
|
|
734
|
-
|
|
893
|
+
await initialize();
|
|
894
|
+
|
|
895
|
+
if (transportMode === "http") {
|
|
896
|
+
// HTTP mode - for Railway/remote deployment
|
|
897
|
+
const port = getHttpPort();
|
|
898
|
+
await startHttpServer({ port, server });
|
|
899
|
+
console.error(`[Engram] MCP server running in HTTP mode (PID ${process.pid})`);
|
|
900
|
+
} else {
|
|
901
|
+
// Stdio mode (default) - for local Claude Desktop/Cursor
|
|
902
|
+
const transport = new StdioServerTransport();
|
|
903
|
+
await server.connect(transport);
|
|
904
|
+
console.error(`[Engram] MCP server running on stdio (PID ${process.pid})`);
|
|
905
|
+
}
|
|
735
906
|
}
|
|
736
907
|
|
|
737
908
|
main().catch((error) => {
|