pi-crew 0.5.24 → 0.6.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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Hash-based task ID generation with adaptive length and hierarchical decomposition.
3
+ *
4
+ * Pattern origin: beads/internal/idgen/hash.go — SHA-256 → base36 encoding
5
+ * with birthday-paradox collision probability adaptation.
6
+ *
7
+ * IDs look like: `pc-a1b2` (prefix + base36 hash)
8
+ * Hierarchical: `pc-a1b2.1` (parent.child)
9
+ */
10
+
11
+ import { createHash } from "node:crypto";
12
+
13
+ // ── Configuration ────────────────────────────────────────────────────────
14
+
15
+ const DEFAULT_PREFIX = "pc";
16
+ const BASE36_CHARS = "0123456789abcdefghijklmnopqrstuvwxyz";
17
+
18
+ interface AdaptiveIDConfig {
19
+ maxCollisionProbability: number;
20
+ minLength: number;
21
+ maxLength: number;
22
+ }
23
+
24
+ const DEFAULT_CONFIG: AdaptiveIDConfig = {
25
+ maxCollisionProbability: 0.25,
26
+ minLength: 3,
27
+ maxLength: 8,
28
+ };
29
+
30
+ // ── Core Functions ───────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Generate a hash-based ID using SHA-256 → base36 encoding.
34
+ *
35
+ * @param content - String content to hash
36
+ * @param length - Desired hash length (3–8 chars)
37
+ * @returns Base36 hash string
38
+ */
39
+ export function hashToBase36(content: string, length: number): string {
40
+ const hash = createHash("sha256").update(content).digest();
41
+ let result = "";
42
+ for (let i = 0; i < hash.length && result.length < length; i++) {
43
+ const byte = hash[i]!;
44
+ // Use modulo to map byte to base36
45
+ result += BASE36_CHARS[byte % 36]!;
46
+ }
47
+ return result.padEnd(length, "0").slice(0, length);
48
+ }
49
+
50
+ /**
51
+ * Calculate adaptive hash length based on existing ID count.
52
+ *
53
+ * Uses birthday-paradox formula: P(collision) ≈ 1 - e^(-n² / (2 * 36^L))
54
+ *
55
+ * @param existingCount - Number of existing IDs with the same prefix
56
+ * @param config - Adaptive configuration
57
+ * @returns Recommended hash length
58
+ */
59
+ export function calculateAdaptiveLength(
60
+ existingCount: number,
61
+ config: AdaptiveIDConfig = DEFAULT_CONFIG,
62
+ ): number {
63
+ for (let length = config.minLength; length <= config.maxLength; length++) {
64
+ const totalPossibilities = Math.pow(36, length);
65
+ const probability = 1 - Math.exp(-(existingCount * existingCount) / (2 * totalPossibilities));
66
+ if (probability <= config.maxCollisionProbability) {
67
+ return length;
68
+ }
69
+ }
70
+ return config.maxLength;
71
+ }
72
+
73
+ /**
74
+ * Generate a deterministic hash-based task ID.
75
+ *
76
+ * @param parts - Content parts to hash (title, description, etc.)
77
+ * @param prefix - ID prefix (default: "pc")
78
+ * @param existingCount - Number of existing IDs (for adaptive length)
79
+ * @returns Hash-based ID like "pc-a1b2"
80
+ */
81
+ export function generateTaskHashId(
82
+ parts: string[],
83
+ prefix = DEFAULT_PREFIX,
84
+ existingCount = 0,
85
+ ): string {
86
+ const content = parts.join("|");
87
+ const length = calculateAdaptiveLength(existingCount);
88
+ const hash = hashToBase36(content, length);
89
+ return `${prefix}-${hash}`;
90
+ }
91
+
92
+ // ── Hierarchical IDs ────────────────────────────────────────────────────
93
+
94
+ export interface ParsedHierarchicalId {
95
+ parentId: string;
96
+ childNum: number;
97
+ isHierarchical: boolean;
98
+ }
99
+
100
+ /**
101
+ * Parse a hierarchical ID into parent and child number.
102
+ *
103
+ * Example: "pc-a1b2.3" → { parentId: "pc-a1b2", childNum: 3, isHierarchical: true }
104
+ */
105
+ export function parseHierarchicalId(id: string): ParsedHierarchicalId {
106
+ const dotIndex = id.lastIndexOf(".");
107
+ if (dotIndex === -1 || dotIndex < 3) {
108
+ return { parentId: id, childNum: 0, isHierarchical: false };
109
+ }
110
+
111
+ const parentId = id.slice(0, dotIndex);
112
+ const childStr = id.slice(dotIndex + 1);
113
+ const childNum = Number.parseInt(childStr, 10);
114
+
115
+ if (!Number.isFinite(childNum) || childNum < 1) {
116
+ return { parentId: id, childNum: 0, isHierarchical: false };
117
+ }
118
+
119
+ return { parentId, childNum, isHierarchical: true };
120
+ }
121
+
122
+ /**
123
+ * Generate a child ID from a parent ID and child number.
124
+ *
125
+ * Example: childId("pc-a1b2", 3) → "pc-a1b2.3"
126
+ */
127
+ export function childId(parentId: string, childNum: number): string {
128
+ return `${parentId}.${childNum}`;
129
+ }
130
+
131
+ // ── Dependency Types ─────────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Rich dependency types for task relationships.
135
+ *
136
+ * Pattern origin: beads/internal/types/types.go — 19 DependencyType constants
137
+ * Only "blocks" and "parent-child" affect execution ordering.
138
+ */
139
+ export type DependencyType =
140
+ | "blocks" // A must complete before B starts
141
+ | "parent-child" // Hierarchical relationship
142
+ | "conditional-blocks" // B runs only if A fails
143
+ | "waits-for" // Fanout gate: wait for dynamic children
144
+ | "related" // Association only (no ordering)
145
+ | "supersedes" // A replaces B
146
+ | "duplicates" // A duplicates B
147
+ | "delegated-from" // A was delegated from B
148
+ | "validates"; // A validates B's output
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Iterative retrieval loop — workers progressively discover needed context.
3
+ *
4
+ * Pattern origin: ECC/skills/iterative-retrieval/SKILL.md — 4-phase loop:
5
+ * Dispatch → Evaluate → Refine → Loop. Max 3 cycles. Convergence when
6
+ * ≥3 high-relevance files found AND no critical gaps.
7
+ *
8
+ * This module provides the scoring and convergence logic.
9
+ * The actual file discovery is delegated to the caller (prompt-builder or task-runner).
10
+ */
11
+
12
+ // ── Types ────────────────────────────────────────────────────────────────
13
+
14
+ export interface RetrievalQuery {
15
+ patterns: string[];
16
+ keywords: string[];
17
+ excludes: string[];
18
+ focusAreas?: string[];
19
+ }
20
+
21
+ export interface RelevanceEvaluation {
22
+ path: string;
23
+ relevance: number; // 0.0–1.0
24
+ reason: string;
25
+ missingContext: string[];
26
+ }
27
+
28
+ export interface RetrievalResult {
29
+ query: RetrievalQuery;
30
+ evaluations: RelevanceEvaluation[];
31
+ cycle: number;
32
+ converged: boolean;
33
+ }
34
+
35
+ // ── Scoring ──────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Score relevance of a file to a task description.
39
+ *
40
+ * Uses keyword matching as a heuristic. In production, this would be
41
+ * replaced by embedding-based similarity or BM25 scoring.
42
+ *
43
+ * @param filePath - Path to the file
44
+ * @param fileContent - Content of the file (or excerpt)
45
+ * @param keywords - Task-relevant keywords
46
+ * @returns Relevance score 0.0–1.0
47
+ */
48
+ export function scoreRelevance(
49
+ filePath: string,
50
+ fileContent: string,
51
+ keywords: string[],
52
+ ): number {
53
+ if (keywords.length === 0) return 0;
54
+
55
+ const pathLower = filePath.toLowerCase();
56
+ const contentLower = fileContent.toLowerCase();
57
+ let matchCount = 0;
58
+ let weightedScore = 0;
59
+
60
+ for (const keyword of keywords) {
61
+ const kw = keyword.toLowerCase();
62
+ // Path match is worth more (file naming is intentional)
63
+ if (pathLower.includes(kw)) {
64
+ matchCount++;
65
+ weightedScore += 0.3;
66
+ }
67
+ // Content match
68
+ const contentMatches = contentLower.split(kw).length - 1;
69
+ if (contentMatches > 0) {
70
+ matchCount++;
71
+ // Diminishing returns for repeated matches
72
+ weightedScore += Math.min(0.2, 0.05 * Math.log2(contentMatches + 1));
73
+ }
74
+ }
75
+
76
+ // Normalize: if all keywords matched, score is high
77
+ const keywordCoverage = matchCount / keywords.length;
78
+ const rawScore = keywordCoverage * 0.6 + Math.min(weightedScore, 0.4);
79
+
80
+ return Math.min(1.0, Math.max(0.0, rawScore));
81
+ }
82
+
83
+ // ── Convergence ──────────────────────────────────────────────────────────
84
+
85
+ const CONVERGENCE_MIN_HIGH_RELEVANCE = 3;
86
+ const HIGH_RELEVANCE_THRESHOLD = 0.7;
87
+
88
+ /**
89
+ * Check if retrieval has converged — enough high-relevance files found.
90
+ *
91
+ * @param evaluations - Current relevance evaluations
92
+ * @returns true if converged
93
+ */
94
+ export function hasConverged(evaluations: RelevanceEvaluation[]): boolean {
95
+ const highRelevance = evaluations.filter((e) => e.relevance >= HIGH_RELEVANCE_THRESHOLD);
96
+ if (highRelevance.length < CONVERGENCE_MIN_HIGH_RELEVANCE) return false;
97
+
98
+ // Check for critical gaps — any evaluation with empty missingContext
99
+ const criticalGaps = evaluations.some(
100
+ (e) => e.relevance < 0.3 && e.missingContext.length > 0,
101
+ );
102
+
103
+ return !criticalGaps;
104
+ }
105
+
106
+ // ── Refinement ───────────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * Refine a retrieval query based on evaluation results.
110
+ *
111
+ * Extracts new keywords from high-relevance files, adds discovered
112
+ * terminology, and excludes confirmed-irrelevant paths.
113
+ *
114
+ * @param query - Original query
115
+ * @param evaluations - Results from the current cycle
116
+ * @returns Refined query for the next cycle
117
+ */
118
+ export function refineQuery(
119
+ query: RetrievalQuery,
120
+ evaluations: RelevanceEvaluation[],
121
+ ): RetrievalQuery {
122
+ const newKeywords = new Set(query.keywords);
123
+ const newExcludes = new Set(query.excludes);
124
+ const newFocusAreas = new Set(query.focusAreas ?? []);
125
+
126
+ for (const eval_ of evaluations) {
127
+ if (eval_.relevance >= HIGH_RELEVANCE_THRESHOLD) {
128
+ // Extract potential keywords from the file path
129
+ const parts = eval_.path.replace(/[\\/]/g, "/").split("/");
130
+ for (const part of parts) {
131
+ // Skip common non-informative segments
132
+ if (part.length > 2 && !["src", "lib", "test", "dist", "node_modules"].includes(part)) {
133
+ // Use the filename stem as a keyword hint
134
+ const stem = part.replace(/\.[^.]+$/, "").replace(/[.-]/g, " ");
135
+ for (const word of stem.split(/\s+/)) {
136
+ if (word.length > 3) newKeywords.add(word);
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ if (eval_.relevance < 0.2) {
143
+ // Exclude confirmed-irrelevant paths
144
+ newExcludes.add(eval_.path);
145
+ }
146
+
147
+ // Track missing context as focus areas
148
+ for (const gap of eval_.missingContext) {
149
+ newFocusAreas.add(gap);
150
+ }
151
+ }
152
+
153
+ return {
154
+ patterns: query.patterns, // patterns don't change
155
+ keywords: [...newKeywords],
156
+ excludes: [...newExcludes],
157
+ focusAreas: newFocusAreas.size > 0 ? [...newFocusAreas] : undefined,
158
+ };
159
+ }
160
+
161
+ // ── Loop Control ─────────────────────────────────────────────────────────
162
+
163
+ const MAX_CYCLES = 3;
164
+
165
+ /**
166
+ * Determine if another retrieval cycle should run.
167
+ */
168
+ export function shouldContinue(evaluations: RelevanceEvaluation[], cycle: number): boolean {
169
+ if (cycle >= MAX_CYCLES) return false;
170
+ if (hasConverged(evaluations)) return false;
171
+ return true;
172
+ }
@@ -267,6 +267,25 @@ export async function runTeamTask(
267
267
  const skillNames = input.skillNames ?? renderedSkills?.names;
268
268
  const skillPaths = input.skillPaths ?? renderedSkills?.paths;
269
269
 
270
+ // Deterministic pre-step: run script, inject stdout into worker prompt
271
+ let preStepOutput: string | undefined;
272
+ if (input.step.preStepScript) {
273
+ const scriptTimeout = input.step.preStepTimeout ?? 30_000;
274
+ const scriptArgs = input.step.preStepArgs ?? [];
275
+ try {
276
+ const { execFileSync } = await import("node:child_process");
277
+ preStepOutput = execFileSync(input.step.preStepScript, scriptArgs, {
278
+ timeout: scriptTimeout,
279
+ encoding: "utf-8",
280
+ cwd: manifest.cwd,
281
+ maxBuffer: 1024 * 1024, // 1MB cap
282
+ });
283
+ } catch (err) {
284
+ const msg = err instanceof Error ? err.message : String(err);
285
+ throw new Error(`preStepScript failed: ${input.step.preStepScript}: ${msg}`);
286
+ }
287
+ }
288
+
270
289
  const promptResult = await renderTaskPrompt(
271
290
  manifest,
272
291
  input.step,
@@ -274,7 +293,12 @@ export async function runTeamTask(
274
293
  input.agent,
275
294
  skillBlock,
276
295
  );
277
- const prompt = promptResult.full;
296
+ let prompt = promptResult.full;
297
+
298
+ // Inject deterministic pre-step output into prompt
299
+ if (preStepOutput) {
300
+ prompt += "\n\n---\n## Pre-Step Script Output\n\nThe following data was produced by a pre-step script. Use it as context for your task:\n\n<output>\n" + preStepOutput + "\n</output>\n";
301
+ }
278
302
  const promptArtifact = writeArtifact(manifest.artifactsRoot, {
279
303
  kind: "prompt",
280
304
  relativePath: `prompts/${task.id}.md`,
@@ -1211,6 +1235,11 @@ export async function runTeamTask(
1211
1235
  taskId: task.id,
1212
1236
  message: error,
1213
1237
  });
1238
+
1239
+ // Execute after_task_complete lifecycle hook (non-blocking)
1240
+ const afterTaskReport = await executeHook("after_task_complete", { runId: manifest.runId, taskId: task.id, cwd: manifest.cwd, status: error ? "failed" : noYield ? "needs_attention" : "completed" });
1241
+ appendHookEvent(manifest, afterTaskReport);
1242
+
1214
1243
  return { manifest, tasks };
1215
1244
  } finally {
1216
1245
  streamBridge?.dispose();
@@ -324,6 +324,13 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
324
324
  // Emit run completion hook (100% reliable, fire-and-forget)
325
325
  crewHooks.emit({ type: "run_completed", timestamp: new Date().toISOString(), runId: manifest.runId, data: { status: result.manifest.status, taskCount: result.tasks.length } });
326
326
 
327
+ // Execute after_run_complete lifecycle hook (non-blocking)
328
+ const afterRunReport = await executeHook("after_run_complete", { runId: manifest.runId, cwd: manifest.cwd, status: result.manifest.status });
329
+ appendHookEvent(manifest, afterRunReport);
330
+ if (afterRunReport.outcome === "block") {
331
+ logInternalError("team-runner.after_run_complete.blocked", new Error(afterRunReport.reason ?? "after_run_complete hook blocked"), `runId=${manifest.runId}`);
332
+ }
333
+
327
334
  return result;
328
335
  } catch (error) {
329
336
  // P1: Catch unhandled errors — ensure manifest/tasks/agents are terminal so they don't stay "running" forever.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * 4-Tier Memory Consolidation System.
3
+ *
4
+ * Pattern origin: agentmemory — Working → Episodic → Semantic → Procedural.
5
+ * Ebbinghaus decay curve: S(t) = e^(-t/s) where s = strength.
6
+ * Frequently accessed memories strengthen. Tier promotion on access count.
7
+ * Token-budgeted injection for context window management.
8
+ *
9
+ * Tiers:
10
+ * - Working: Current run observations (capacity: 50)
11
+ * - Episodic: Recent run summaries (capacity: 200)
12
+ * - Semantic: Extracted patterns/knowledge (capacity: 1000)
13
+ * - Procedural: Learned skills/methods (capacity: 100)
14
+ */
15
+
16
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
17
+ import { logInternalError } from "../utils/internal-error.ts";
18
+
19
+ // ── Types ────────────────────────────────────────────────────────────────
20
+
21
+ export type MemoryTier = "working" | "episodic" | "semantic" | "procedural";
22
+
23
+ export interface Memory {
24
+ id: string;
25
+ tier: MemoryTier;
26
+ content: string;
27
+ strength: number; // 0.0–1.0
28
+ accessCount: number;
29
+ lastAccessed: number; // epoch ms
30
+ createdAt: number; // epoch ms
31
+ tags: string[];
32
+ sourceRunId?: string;
33
+ }
34
+
35
+ export interface MemoryConfig {
36
+ workingCapacity: number;
37
+ episodicCapacity: number;
38
+ semanticCapacity: number;
39
+ proceduralCapacity: number;
40
+ decayRate: number; // Ebbinghaus parameter (higher = faster decay)
41
+ tokenBudget: number; // max tokens to inject
42
+ }
43
+
44
+ const DEFAULT_CONFIG: MemoryConfig = {
45
+ workingCapacity: 50,
46
+ episodicCapacity: 200,
47
+ semanticCapacity: 1000,
48
+ proceduralCapacity: 100,
49
+ decayRate: 0.001, // slow decay
50
+ tokenBudget: 2000,
51
+ };
52
+
53
+ const TIER_CAPACITIES: Record<MemoryTier, keyof MemoryConfig> = {
54
+ working: "workingCapacity",
55
+ episodic: "episodicCapacity",
56
+ semantic: "semanticCapacity",
57
+ procedural: "proceduralCapacity",
58
+ };
59
+
60
+ // ── Memory Operations ────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Compute current strength using Ebbinghaus decay.
64
+ * S(t) = strength * e^(-elapsed_ms * decayRate)
65
+ */
66
+ export function computeCurrentStrength(memory: Memory, config = DEFAULT_CONFIG): number {
67
+ const elapsedMs = Date.now() - memory.lastAccessed;
68
+ return memory.strength * Math.exp(-elapsedMs * config.decayRate);
69
+ }
70
+
71
+ /**
72
+ * Access a memory — strengthens it and updates access count.
73
+ * After N accesses, memory may be promoted to a higher tier.
74
+ */
75
+ export function accessMemory(memory: Memory): Memory {
76
+ const newCount = memory.accessCount + 1;
77
+
78
+ // Strengthen: capped at 1.0
79
+ const newStrength = Math.min(1.0, memory.strength + 0.1);
80
+
81
+ // Tier promotion thresholds
82
+ let newTier = memory.tier;
83
+ if (newCount >= 10 && memory.tier === "working") newTier = "episodic";
84
+ if (newCount >= 20 && memory.tier === "episodic") newTier = "semantic";
85
+ if (newCount >= 30 && memory.tier === "semantic") newTier = "procedural";
86
+
87
+ return {
88
+ ...memory,
89
+ strength: newStrength,
90
+ accessCount: newCount,
91
+ lastAccessed: Date.now(),
92
+ tier: newTier,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Create a new working memory.
98
+ */
99
+ export function createMemory(content: string, tags: string[] = [], sourceRunId?: string): Memory {
100
+ return {
101
+ id: `mem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
102
+ tier: "working",
103
+ content,
104
+ strength: 0.5,
105
+ accessCount: 0,
106
+ lastAccessed: Date.now(),
107
+ createdAt: Date.now(),
108
+ tags,
109
+ sourceRunId,
110
+ };
111
+ }
112
+
113
+ // ── Memory Store ─────────────────────────────────────────────────────────
114
+
115
+ export class MemoryStore {
116
+ private memories = new Map<string, Memory>();
117
+ private config: MemoryConfig;
118
+ private storePath?: string;
119
+
120
+ constructor(config: Partial<MemoryConfig> = {}, storePath?: string) {
121
+ this.config = { ...DEFAULT_CONFIG, ...config };
122
+ this.storePath = storePath;
123
+ if (storePath && existsSync(storePath)) {
124
+ this.load();
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Add a memory to the store, enforcing capacity limits.
130
+ */
131
+ add(memory: Memory): void {
132
+ // Evict weakest if at capacity
133
+ const capacity = this.config[TIER_CAPACITIES[memory.tier]];
134
+ const tierMemories = [...this.memories.values()].filter((m) => m.tier === memory.tier);
135
+
136
+ if (tierMemories.length >= capacity) {
137
+ // Evict weakest memory in this tier
138
+ const weakest = tierMemories
139
+ .map((m) => ({ id: m.id, strength: computeCurrentStrength(m, this.config) }))
140
+ .sort((a, b) => a.strength - b.strength)[0];
141
+ if (weakest) this.memories.delete(weakest.id);
142
+ }
143
+
144
+ this.memories.set(memory.id, memory);
145
+ }
146
+
147
+ /**
148
+ * Search memories by tags, returning strongest matches first.
149
+ */
150
+ search(query: string, tags: string[] = [], limit = 10): Memory[] {
151
+ const queryLower = query.toLowerCase();
152
+
153
+ let results = [...this.memories.values()]
154
+ // Apply decay
155
+ .map((m) => ({ ...m, strength: computeCurrentStrength(m, this.config) }))
156
+ // Filter by minimum strength
157
+ .filter((m) => m.strength > 0.1)
158
+ // Score relevance
159
+ .map((m) => {
160
+ let score = m.strength;
161
+ if (m.content.toLowerCase().includes(queryLower)) score += 0.3;
162
+ if (tags.some((t) => m.tags.includes(t))) score += 0.2;
163
+ return { ...m, strength: score };
164
+ })
165
+ .sort((a, b) => b.strength - a.strength)
166
+ .slice(0, limit);
167
+
168
+ // Access side effect: strengthen returned memories
169
+ results = results.map((m) => accessMemory(m));
170
+ for (const m of results) {
171
+ this.memories.set(m.id, m);
172
+ }
173
+
174
+ return results;
175
+ }
176
+
177
+ /**
178
+ * Inject memories into a prompt within a token budget.
179
+ * Returns formatted text block.
180
+ */
181
+ inject(query: string, tags: string[] = []): string {
182
+ const results = this.search(query, tags, 20);
183
+
184
+ if (results.length === 0) return "";
185
+
186
+ // Estimate tokens (4 chars ≈ 1 token)
187
+ const budget = this.config.tokenBudget;
188
+ let usedTokens = 0;
189
+ const selected: Memory[] = [];
190
+
191
+ for (const memory of results) {
192
+ const tokens = Math.ceil(memory.content.length / 4);
193
+ if (usedTokens + tokens > budget) break;
194
+ selected.push(memory);
195
+ usedTokens += tokens;
196
+ }
197
+
198
+ if (selected.length === 0) return "";
199
+
200
+ return "## Relevant Context from Previous Runs\n\n" +
201
+ selected.map((m) => `- [${m.tier}] ${m.content}`).join("\n") +
202
+ "\n";
203
+ }
204
+
205
+ /**
206
+ * Get count of memories per tier.
207
+ */
208
+ get stats(): Record<MemoryTier, number> {
209
+ const counts: Record<MemoryTier, number> = { working: 0, episodic: 0, semantic: 0, procedural: 0 };
210
+ for (const m of this.memories.values()) {
211
+ counts[m.tier]++;
212
+ }
213
+ return counts;
214
+ }
215
+
216
+ /**
217
+ * Persist memories to disk.
218
+ */
219
+ save(): void {
220
+ if (!this.storePath) return;
221
+ const entries = [...this.memories.values()];
222
+ try {
223
+ mkdirSync(path.dirname(this.storePath), { recursive: true });
224
+ writeFileSync(this.storePath, JSON.stringify(entries, null, 2), "utf-8");
225
+ } catch (error) {
226
+ logInternalError("memory-store.save", error, `path=${this.storePath}`);
227
+ }
228
+ }
229
+
230
+ private load(): void {
231
+ if (!this.storePath) return;
232
+ try {
233
+ const data = JSON.parse(readFileSync(this.storePath, "utf-8")) as Memory[];
234
+ for (const m of data) {
235
+ this.memories.set(m.id, m);
236
+ }
237
+ } catch (error) {
238
+ logInternalError("memory-store.load", error, `path=${this.storePath}`);
239
+ }
240
+ }
241
+ }
242
+
243
+ // Need path for mkdirSync in save()
244
+ import path from "node:path";