@ekkos/cli 0.2.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 (135) hide show
  1. package/dist/cache/LocalSessionStore.d.ts +129 -0
  2. package/dist/cache/LocalSessionStore.js +688 -0
  3. package/dist/cache/capture.d.ts +26 -0
  4. package/dist/cache/capture.js +461 -0
  5. package/dist/cache/index.d.ts +7 -0
  6. package/dist/cache/index.js +23 -0
  7. package/dist/cache/types.d.ts +147 -0
  8. package/dist/cache/types.js +40 -0
  9. package/dist/commands/init.d.ts +9 -0
  10. package/dist/commands/init.js +478 -0
  11. package/dist/commands/run.d.ts +12 -0
  12. package/dist/commands/run.js +829 -0
  13. package/dist/commands/setup.d.ts +6 -0
  14. package/dist/commands/setup.js +658 -0
  15. package/dist/commands/status.d.ts +1 -0
  16. package/dist/commands/status.js +109 -0
  17. package/dist/commands/test.d.ts +1 -0
  18. package/dist/commands/test.js +157 -0
  19. package/dist/deploy/agents.d.ts +15 -0
  20. package/dist/deploy/agents.js +72 -0
  21. package/dist/deploy/hooks.d.ts +16 -0
  22. package/dist/deploy/hooks.js +121 -0
  23. package/dist/deploy/index.d.ts +7 -0
  24. package/dist/deploy/index.js +24 -0
  25. package/dist/deploy/instructions.d.ts +12 -0
  26. package/dist/deploy/instructions.js +36 -0
  27. package/dist/deploy/mcp.d.ts +19 -0
  28. package/dist/deploy/mcp.js +109 -0
  29. package/dist/deploy/plugins.d.ts +19 -0
  30. package/dist/deploy/plugins.js +62 -0
  31. package/dist/deploy/settings.d.ts +8 -0
  32. package/dist/deploy/settings.js +84 -0
  33. package/dist/deploy/skills.d.ts +19 -0
  34. package/dist/deploy/skills.js +60 -0
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.js +71 -0
  37. package/dist/restore/RestoreOrchestrator.d.ts +48 -0
  38. package/dist/restore/RestoreOrchestrator.js +481 -0
  39. package/dist/restore/index.d.ts +4 -0
  40. package/dist/restore/index.js +20 -0
  41. package/dist/utils/platform.d.ts +29 -0
  42. package/dist/utils/platform.js +65 -0
  43. package/dist/utils/session-words.json +119 -0
  44. package/dist/utils/state.d.ts +57 -0
  45. package/dist/utils/state.js +186 -0
  46. package/dist/utils/templates.d.ts +24 -0
  47. package/dist/utils/templates.js +118 -0
  48. package/package.json +48 -0
  49. package/templates/CLAUDE.md +287 -0
  50. package/templates/README.md +378 -0
  51. package/templates/agents/README.md +182 -0
  52. package/templates/agents/code-reviewer.md +166 -0
  53. package/templates/agents/debug-detective.md +169 -0
  54. package/templates/agents/ekkOS_Vercel.md +99 -0
  55. package/templates/agents/extension-manager.md +229 -0
  56. package/templates/agents/git-companion.md +185 -0
  57. package/templates/agents/github-test-agent.md +321 -0
  58. package/templates/agents/railway-manager.md +179 -0
  59. package/templates/claude-plugins/PHASE2_COMPLETION.md +346 -0
  60. package/templates/claude-plugins/PLUGIN_PROPOSALS.md +1776 -0
  61. package/templates/claude-plugins/README.md +587 -0
  62. package/templates/claude-plugins/agents/code-reviewer.json +14 -0
  63. package/templates/claude-plugins/agents/debug-detective.json +15 -0
  64. package/templates/claude-plugins/agents/git-companion.json +14 -0
  65. package/templates/claude-plugins/blog-manager/.claude-plugin/plugin.json +8 -0
  66. package/templates/claude-plugins/blog-manager/commands/blog.md +691 -0
  67. package/templates/claude-plugins/golden-loop-monitor/.claude-plugin/plugin.json +8 -0
  68. package/templates/claude-plugins/golden-loop-monitor/commands/loop-status.md +434 -0
  69. package/templates/claude-plugins/learning-tracker/.claude-plugin/plugin.json +8 -0
  70. package/templates/claude-plugins/learning-tracker/commands/my-patterns.md +282 -0
  71. package/templates/claude-plugins/memory-lens/.claude-plugin/plugin.json +8 -0
  72. package/templates/claude-plugins/memory-lens/commands/memory-search.md +181 -0
  73. package/templates/claude-plugins/pattern-coach/.claude-plugin/plugin.json +8 -0
  74. package/templates/claude-plugins/pattern-coach/commands/forge.md +365 -0
  75. package/templates/claude-plugins/project-schema-validator/.claude-plugin/plugin.json +8 -0
  76. package/templates/claude-plugins/project-schema-validator/commands/validate-schema.md +582 -0
  77. package/templates/claude-plugins-admin/AGENT_TEAM_PROPOSALS.md +819 -0
  78. package/templates/claude-plugins-admin/README.md +446 -0
  79. package/templates/claude-plugins-admin/autonomous-admin-agent/.claude-plugin/plugin.json +8 -0
  80. package/templates/claude-plugins-admin/autonomous-admin-agent/commands/agent.md +595 -0
  81. package/templates/claude-plugins-admin/backend-agent/.claude-plugin/plugin.json +8 -0
  82. package/templates/claude-plugins-admin/backend-agent/commands/backend.md +798 -0
  83. package/templates/claude-plugins-admin/deploy-guardian/.claude-plugin/plugin.json +8 -0
  84. package/templates/claude-plugins-admin/deploy-guardian/commands/deploy.md +554 -0
  85. package/templates/claude-plugins-admin/frontend-agent/.claude-plugin/plugin.json +8 -0
  86. package/templates/claude-plugins-admin/frontend-agent/commands/frontend.md +881 -0
  87. package/templates/claude-plugins-admin/mcp-server-manager/.claude-plugin/plugin.json +8 -0
  88. package/templates/claude-plugins-admin/mcp-server-manager/commands/mcp.md +85 -0
  89. package/templates/claude-plugins-admin/memory-system-monitor/.claude-plugin/plugin.json +8 -0
  90. package/templates/claude-plugins-admin/memory-system-monitor/commands/memory-health.md +569 -0
  91. package/templates/claude-plugins-admin/qa-agent/.claude-plugin/plugin.json +8 -0
  92. package/templates/claude-plugins-admin/qa-agent/commands/qa.md +863 -0
  93. package/templates/claude-plugins-admin/tech-lead-agent/.claude-plugin/plugin.json +8 -0
  94. package/templates/claude-plugins-admin/tech-lead-agent/commands/lead.md +732 -0
  95. package/templates/commands/continue.md +47 -0
  96. package/templates/cursor-hooks/after-agent-response.sh +117 -0
  97. package/templates/cursor-hooks/before-submit-prompt.sh +419 -0
  98. package/templates/cursor-hooks/hooks.json +20 -0
  99. package/templates/cursor-hooks/lib/contract.sh +320 -0
  100. package/templates/cursor-hooks/stop.sh +75 -0
  101. package/templates/cursor-rules/ekkos-memory.md +187 -0
  102. package/templates/hooks/assistant-response.sh +96 -0
  103. package/templates/hooks/hooks.json +28 -0
  104. package/templates/hooks/lib/contract.sh +320 -0
  105. package/templates/hooks/lib/state.sh +158 -0
  106. package/templates/hooks/session-start.ps1 +41 -0
  107. package/templates/hooks/session-start.sh +318 -0
  108. package/templates/hooks/stop.ps1 +16 -0
  109. package/templates/hooks/stop.sh +989 -0
  110. package/templates/hooks/user-prompt-submit.ps1 +174 -0
  111. package/templates/hooks/user-prompt-submit.sh +587 -0
  112. package/templates/hooks-node/lib/state.js +187 -0
  113. package/templates/hooks-node/stop.js +416 -0
  114. package/templates/hooks-node/user-prompt-submit.js +337 -0
  115. package/templates/plan-template.md +306 -0
  116. package/templates/rules/00-hooks-contract.mdc +89 -0
  117. package/templates/rules/30-ekkos-core.mdc +188 -0
  118. package/templates/rules/31-ekkos-messages.mdc +78 -0
  119. package/templates/skills/continue/SKILL.md +169 -0
  120. package/templates/skills/ekkOS_Deep_Recall/Skill.md +282 -0
  121. package/templates/skills/ekkOS_Learn/Skill.md +265 -0
  122. package/templates/skills/ekkOS_Memory_First/Skill.md +206 -0
  123. package/templates/skills/ekkOS_Plan_Assist/Skill.md +302 -0
  124. package/templates/skills/ekkOS_Preferences/Skill.md +247 -0
  125. package/templates/skills/ekkOS_Reflect/Skill.md +257 -0
  126. package/templates/skills/ekkOS_Safety/Skill.md +265 -0
  127. package/templates/skills/ekkOS_Schema/Skill.md +251 -0
  128. package/templates/skills/ekkOS_Summary/Skill.md +257 -0
  129. package/templates/skills/ekkOS_Vault/Skill.md +287 -0
  130. package/templates/skills/permissions/Skill.md +322 -0
  131. package/templates/spec-template.md +159 -0
  132. package/templates/windsurf-hooks/before-submit-prompt.sh +238 -0
  133. package/templates/windsurf-hooks/hooks.json +10 -0
  134. package/templates/windsurf-hooks/lib/contract.sh +320 -0
  135. package/templates/windsurf-rules/ekkos-memory.md +129 -0
@@ -0,0 +1,688 @@
1
+ "use strict";
2
+ /**
3
+ * ekkOS Fast /continue - Local Session Store
4
+ *
5
+ * Tier 0 of the 3-tier restore chain.
6
+ * Provides ultra-low latency (<20ms) turn storage using append-only JSONL files.
7
+ *
8
+ * Features:
9
+ * - Atomic writes (write to .tmp, rename)
10
+ * - Crash recovery (handle partial JSONL lines)
11
+ * - ACK-based safe pruning
12
+ * - Multi-session support with LRU eviction
13
+ */
14
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ var desc = Object.getOwnPropertyDescriptor(m, k);
17
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
+ desc = { enumerable: true, get: function() { return m[k]; } };
19
+ }
20
+ Object.defineProperty(o, k2, desc);
21
+ }) : (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ o[k2] = m[k];
24
+ }));
25
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
27
+ }) : function(o, v) {
28
+ o["default"] = v;
29
+ });
30
+ var __importStar = (this && this.__importStar) || (function () {
31
+ var ownKeys = function(o) {
32
+ ownKeys = Object.getOwnPropertyNames || function (o) {
33
+ var ar = [];
34
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
35
+ return ar;
36
+ };
37
+ return ownKeys(o);
38
+ };
39
+ return function (mod) {
40
+ if (mod && mod.__esModule) return mod;
41
+ var result = {};
42
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
43
+ __setModuleDefault(result, mod);
44
+ return result;
45
+ };
46
+ })();
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.localCache = exports.LocalSessionStore = void 0;
49
+ const fs = __importStar(require("fs"));
50
+ const path = __importStar(require("path"));
51
+ const os = __importStar(require("os"));
52
+ // Default configuration
53
+ const DEFAULT_CONFIG = {
54
+ cache_dir: path.join(os.homedir(), '.ekkos', 'cache'),
55
+ max_sessions: 20,
56
+ max_turns_per_session: 100,
57
+ safety_margin: 5,
58
+ flush_interval_ms: 5000,
59
+ };
60
+ /**
61
+ * LocalSessionStore - Fast local cache for conversation turns
62
+ */
63
+ class LocalSessionStore {
64
+ constructor(config = {}) {
65
+ this.indexCache = null;
66
+ this.indexCacheTime = 0;
67
+ this.INDEX_CACHE_TTL = 1000; // 1 second
68
+ this.config = { ...DEFAULT_CONFIG, ...config };
69
+ this.sessionsDir = path.join(this.config.cache_dir, 'sessions');
70
+ this.indexPath = path.join(this.sessionsDir, 'index.json');
71
+ this.ensureDirectories();
72
+ }
73
+ /**
74
+ * Ensure cache directories exist
75
+ */
76
+ ensureDirectories() {
77
+ try {
78
+ fs.mkdirSync(this.sessionsDir, { recursive: true });
79
+ }
80
+ catch (err) {
81
+ // Directory might already exist
82
+ }
83
+ }
84
+ /**
85
+ * Get the JSONL file path for a session
86
+ */
87
+ getTurnsPath(sessionId) {
88
+ return path.join(this.sessionsDir, `${sessionId}.jsonl`);
89
+ }
90
+ /**
91
+ * Get the metadata file path for a session
92
+ */
93
+ getMetaPath(sessionId) {
94
+ return path.join(this.sessionsDir, `${sessionId}.meta.json`);
95
+ }
96
+ // ═══════════════════════════════════════════════════════════════════════════
97
+ // INDEX OPERATIONS
98
+ // ═══════════════════════════════════════════════════════════════════════════
99
+ /**
100
+ * Read the session index (with caching)
101
+ */
102
+ readIndex() {
103
+ const now = Date.now();
104
+ if (this.indexCache && now - this.indexCacheTime < this.INDEX_CACHE_TTL) {
105
+ return this.indexCache;
106
+ }
107
+ try {
108
+ if (fs.existsSync(this.indexPath)) {
109
+ const content = fs.readFileSync(this.indexPath, 'utf-8');
110
+ this.indexCache = JSON.parse(content);
111
+ this.indexCacheTime = now;
112
+ return this.indexCache;
113
+ }
114
+ }
115
+ catch (err) {
116
+ // Corrupted index, start fresh
117
+ console.error('[LocalSessionStore] Index read error, resetting:', err);
118
+ }
119
+ this.indexCache = {};
120
+ this.indexCacheTime = now;
121
+ return this.indexCache;
122
+ }
123
+ /**
124
+ * Write the session index atomically
125
+ */
126
+ writeIndex(index) {
127
+ const tmpPath = `${this.indexPath}.tmp`;
128
+ try {
129
+ fs.writeFileSync(tmpPath, JSON.stringify(index, null, 2), 'utf-8');
130
+ fs.renameSync(tmpPath, this.indexPath);
131
+ this.indexCache = index;
132
+ this.indexCacheTime = Date.now();
133
+ }
134
+ catch (err) {
135
+ // Clean up temp file on error
136
+ try {
137
+ fs.unlinkSync(tmpPath);
138
+ }
139
+ catch {
140
+ // Ignore cleanup errors
141
+ }
142
+ throw err;
143
+ }
144
+ }
145
+ /**
146
+ * Update a single session in the index
147
+ */
148
+ updateIndexEntry(sessionName, entry) {
149
+ const index = this.readIndex();
150
+ index[sessionName] = entry;
151
+ this.writeIndex(index);
152
+ }
153
+ // ═══════════════════════════════════════════════════════════════════════════
154
+ // SESSION METADATA
155
+ // ═══════════════════════════════════════════════════════════════════════════
156
+ /**
157
+ * Read session metadata
158
+ */
159
+ getSessionMeta(sessionId) {
160
+ const metaPath = this.getMetaPath(sessionId);
161
+ try {
162
+ if (fs.existsSync(metaPath)) {
163
+ const content = fs.readFileSync(metaPath, 'utf-8');
164
+ return JSON.parse(content);
165
+ }
166
+ }
167
+ catch (err) {
168
+ console.error('[LocalSessionStore] Meta read error:', err);
169
+ }
170
+ return null;
171
+ }
172
+ /**
173
+ * Write session metadata atomically
174
+ */
175
+ writeSessionMeta(meta) {
176
+ const metaPath = this.getMetaPath(meta.session_id);
177
+ const tmpPath = `${metaPath}.tmp`;
178
+ try {
179
+ fs.writeFileSync(tmpPath, JSON.stringify(meta, null, 2), 'utf-8');
180
+ fs.renameSync(tmpPath, metaPath);
181
+ }
182
+ catch (err) {
183
+ try {
184
+ fs.unlinkSync(tmpPath);
185
+ }
186
+ catch {
187
+ // Ignore cleanup errors
188
+ }
189
+ throw err;
190
+ }
191
+ }
192
+ // ═══════════════════════════════════════════════════════════════════════════
193
+ // TURN OPERATIONS
194
+ // ═══════════════════════════════════════════════════════════════════════════
195
+ /**
196
+ * Append a turn to the session's JSONL file
197
+ * This is the hot path - must be fast
198
+ */
199
+ appendTurn(sessionId, sessionName, turn, projectPath) {
200
+ const startTime = Date.now();
201
+ const turnsPath = this.getTurnsPath(sessionId);
202
+ try {
203
+ // Append turn as JSONL line (atomic append on most filesystems)
204
+ const line = JSON.stringify(turn) + '\n';
205
+ fs.appendFileSync(turnsPath, line, 'utf-8');
206
+ // Update metadata
207
+ let meta = this.getSessionMeta(sessionId);
208
+ if (!meta) {
209
+ meta = {
210
+ session_id: sessionId,
211
+ session_name: sessionName,
212
+ acked_turn_id: 0,
213
+ last_flush_ts: new Date().toISOString(),
214
+ turn_count: 0,
215
+ project_path: projectPath,
216
+ created_at: new Date().toISOString(),
217
+ };
218
+ }
219
+ meta.turn_count = turn.turn_id;
220
+ meta.last_flush_ts = new Date().toISOString();
221
+ if (projectPath)
222
+ meta.project_path = projectPath;
223
+ this.writeSessionMeta(meta);
224
+ // Update index
225
+ this.updateIndexEntry(sessionName, {
226
+ session_id: sessionId,
227
+ last_active_ts: new Date().toISOString(),
228
+ last_turn_id: turn.turn_id,
229
+ acked_turn_id: meta.acked_turn_id,
230
+ project_path: projectPath,
231
+ });
232
+ return {
233
+ success: true,
234
+ source: 'local',
235
+ latency_ms: Date.now() - startTime,
236
+ };
237
+ }
238
+ catch (err) {
239
+ return {
240
+ success: false,
241
+ error: err instanceof Error ? err.message : String(err),
242
+ source: 'local',
243
+ latency_ms: Date.now() - startTime,
244
+ };
245
+ }
246
+ }
247
+ /**
248
+ * Get the last N turns from a session
249
+ * Handles crash recovery (partial lines)
250
+ */
251
+ getLastTurns(sessionId, n = 10) {
252
+ const startTime = Date.now();
253
+ const turnsPath = this.getTurnsPath(sessionId);
254
+ try {
255
+ if (!fs.existsSync(turnsPath)) {
256
+ return {
257
+ success: false,
258
+ error: 'Session not found in local cache',
259
+ source: 'local',
260
+ latency_ms: Date.now() - startTime,
261
+ };
262
+ }
263
+ const content = fs.readFileSync(turnsPath, 'utf-8');
264
+ const lines = content.trim().split('\n').filter(Boolean);
265
+ const turns = [];
266
+ // Parse lines, handling potential corruption
267
+ for (const line of lines) {
268
+ try {
269
+ const turn = JSON.parse(line);
270
+ turns.push(turn);
271
+ }
272
+ catch {
273
+ // Skip corrupted lines (crash recovery)
274
+ console.error('[LocalSessionStore] Skipping corrupted line');
275
+ }
276
+ }
277
+ // Return last N turns, oldest first
278
+ const lastN = turns.slice(-n);
279
+ return {
280
+ success: true,
281
+ data: lastN,
282
+ source: 'local',
283
+ latency_ms: Date.now() - startTime,
284
+ };
285
+ }
286
+ catch (err) {
287
+ return {
288
+ success: false,
289
+ error: err instanceof Error ? err.message : String(err),
290
+ source: 'local',
291
+ latency_ms: Date.now() - startTime,
292
+ };
293
+ }
294
+ }
295
+ /**
296
+ * Get a specific turn by ID
297
+ */
298
+ getTurn(sessionId, turnId) {
299
+ const result = this.getLastTurns(sessionId, 1000); // Get all
300
+ if (!result.success || !result.data) {
301
+ return { success: false, error: result.error, source: 'local' };
302
+ }
303
+ const turn = result.data.find((t) => t.turn_id === turnId);
304
+ if (!turn) {
305
+ return { success: false, error: `Turn ${turnId} not found`, source: 'local' };
306
+ }
307
+ return { success: true, data: turn, source: 'local' };
308
+ }
309
+ /**
310
+ * Update the assistant response for a pending turn
311
+ * (Called when Stop hook fires)
312
+ */
313
+ updateTurnResponse(sessionId, turnId, response, toolsUsed, filesReferenced) {
314
+ const startTime = Date.now();
315
+ const turnsPath = this.getTurnsPath(sessionId);
316
+ try {
317
+ if (!fs.existsSync(turnsPath)) {
318
+ return {
319
+ success: false,
320
+ error: 'Session not found',
321
+ source: 'local',
322
+ latency_ms: Date.now() - startTime,
323
+ };
324
+ }
325
+ const content = fs.readFileSync(turnsPath, 'utf-8');
326
+ const lines = content.trim().split('\n').filter(Boolean);
327
+ const turns = [];
328
+ for (const line of lines) {
329
+ try {
330
+ turns.push(JSON.parse(line));
331
+ }
332
+ catch {
333
+ // Skip corrupted lines
334
+ }
335
+ }
336
+ // Find and update the turn
337
+ const turnIndex = turns.findIndex((t) => t.turn_id === turnId);
338
+ if (turnIndex === -1) {
339
+ return {
340
+ success: false,
341
+ error: `Turn ${turnId} not found`,
342
+ source: 'local',
343
+ latency_ms: Date.now() - startTime,
344
+ };
345
+ }
346
+ turns[turnIndex].assistant_response = response;
347
+ if (toolsUsed)
348
+ turns[turnIndex].tools_used = toolsUsed;
349
+ if (filesReferenced)
350
+ turns[turnIndex].files_referenced = filesReferenced;
351
+ // Rewrite the file atomically
352
+ const newContent = turns.map((t) => JSON.stringify(t)).join('\n') + '\n';
353
+ const tmpPath = `${turnsPath}.tmp`;
354
+ fs.writeFileSync(tmpPath, newContent, 'utf-8');
355
+ fs.renameSync(tmpPath, turnsPath);
356
+ return {
357
+ success: true,
358
+ source: 'local',
359
+ latency_ms: Date.now() - startTime,
360
+ };
361
+ }
362
+ catch (err) {
363
+ return {
364
+ success: false,
365
+ error: err instanceof Error ? err.message : String(err),
366
+ source: 'local',
367
+ latency_ms: Date.now() - startTime,
368
+ };
369
+ }
370
+ }
371
+ // ═══════════════════════════════════════════════════════════════════════════
372
+ // ACK & PRUNING
373
+ // ═══════════════════════════════════════════════════════════════════════════
374
+ /**
375
+ * Acknowledge that turns up to acked_turn_id have been flushed to Redis
376
+ */
377
+ ack(sessionId, ackedTurnId) {
378
+ const startTime = Date.now();
379
+ try {
380
+ const meta = this.getSessionMeta(sessionId);
381
+ if (!meta) {
382
+ return {
383
+ success: false,
384
+ error: 'Session metadata not found',
385
+ source: 'local',
386
+ latency_ms: Date.now() - startTime,
387
+ };
388
+ }
389
+ // Only update if new ACK is higher (prevent regression)
390
+ if (ackedTurnId > meta.acked_turn_id) {
391
+ meta.acked_turn_id = ackedTurnId;
392
+ meta.last_flush_ts = new Date().toISOString();
393
+ this.writeSessionMeta(meta);
394
+ // Update index too
395
+ const index = this.readIndex();
396
+ const sessionName = Object.keys(index).find((name) => index[name].session_id === sessionId);
397
+ if (sessionName) {
398
+ index[sessionName].acked_turn_id = ackedTurnId;
399
+ this.writeIndex(index);
400
+ }
401
+ }
402
+ return {
403
+ success: true,
404
+ source: 'local',
405
+ latency_ms: Date.now() - startTime,
406
+ };
407
+ }
408
+ catch (err) {
409
+ return {
410
+ success: false,
411
+ error: err instanceof Error ? err.message : String(err),
412
+ source: 'local',
413
+ latency_ms: Date.now() - startTime,
414
+ };
415
+ }
416
+ }
417
+ /**
418
+ * Acknowledge that turns up to ackedTurnId have been flushed to Supabase
419
+ * This is separate from Redis ACK - both must succeed before pruning
420
+ */
421
+ ackSupabase(sessionId, ackedTurnId) {
422
+ const startTime = Date.now();
423
+ try {
424
+ const meta = this.getSessionMeta(sessionId);
425
+ if (!meta) {
426
+ return {
427
+ success: false,
428
+ error: 'Session metadata not found',
429
+ source: 'local',
430
+ latency_ms: Date.now() - startTime,
431
+ };
432
+ }
433
+ // Only update if new ACK is higher (prevent regression)
434
+ const currentSupabaseAck = meta.supabase_acked_turn_id || 0;
435
+ if (ackedTurnId > currentSupabaseAck) {
436
+ meta.supabase_acked_turn_id = ackedTurnId;
437
+ meta.last_flush_ts = new Date().toISOString();
438
+ this.writeSessionMeta(meta);
439
+ }
440
+ return {
441
+ success: true,
442
+ source: 'local',
443
+ latency_ms: Date.now() - startTime,
444
+ };
445
+ }
446
+ catch (err) {
447
+ return {
448
+ success: false,
449
+ error: err instanceof Error ? err.message : String(err),
450
+ source: 'local',
451
+ latency_ms: Date.now() - startTime,
452
+ };
453
+ }
454
+ }
455
+ /**
456
+ * Prune turns that are safely ACKed to BOTH Redis AND Supabase
457
+ * Only removes turns where turn_id <= min(redis_ack, supabase_ack) - safety_margin
458
+ * This ensures data is persisted in both tiers before local pruning
459
+ */
460
+ prune(sessionId) {
461
+ const startTime = Date.now();
462
+ const turnsPath = this.getTurnsPath(sessionId);
463
+ try {
464
+ const meta = this.getSessionMeta(sessionId);
465
+ if (!meta) {
466
+ return {
467
+ success: false,
468
+ error: 'Session metadata not found',
469
+ source: 'local',
470
+ latency_ms: Date.now() - startTime,
471
+ };
472
+ }
473
+ // Require BOTH Redis AND Supabase ACKs before pruning
474
+ // This prevents data loss when Supabase capture fails
475
+ const redisAck = meta.acked_turn_id || 0;
476
+ const supabaseAck = meta.supabase_acked_turn_id || 0;
477
+ const minAck = Math.min(redisAck, supabaseAck);
478
+ const pruneThreshold = minAck - this.config.safety_margin;
479
+ if (pruneThreshold <= 0) {
480
+ return {
481
+ success: true,
482
+ data: 0,
483
+ source: 'local',
484
+ latency_ms: Date.now() - startTime,
485
+ };
486
+ }
487
+ if (!fs.existsSync(turnsPath)) {
488
+ return { success: true, data: 0, source: 'local' };
489
+ }
490
+ const content = fs.readFileSync(turnsPath, 'utf-8');
491
+ const lines = content.trim().split('\n').filter(Boolean);
492
+ const turns = [];
493
+ let prunedCount = 0;
494
+ for (const line of lines) {
495
+ try {
496
+ const turn = JSON.parse(line);
497
+ if (turn.turn_id > pruneThreshold) {
498
+ turns.push(turn);
499
+ }
500
+ else {
501
+ prunedCount++;
502
+ }
503
+ }
504
+ catch {
505
+ // Skip corrupted lines
506
+ }
507
+ }
508
+ // Rewrite with remaining turns
509
+ if (prunedCount > 0) {
510
+ const newContent = turns.map((t) => JSON.stringify(t)).join('\n') + '\n';
511
+ const tmpPath = `${turnsPath}.tmp`;
512
+ fs.writeFileSync(tmpPath, newContent, 'utf-8');
513
+ fs.renameSync(tmpPath, turnsPath);
514
+ }
515
+ return {
516
+ success: true,
517
+ data: prunedCount,
518
+ source: 'local',
519
+ latency_ms: Date.now() - startTime,
520
+ };
521
+ }
522
+ catch (err) {
523
+ return {
524
+ success: false,
525
+ error: err instanceof Error ? err.message : String(err),
526
+ source: 'local',
527
+ latency_ms: Date.now() - startTime,
528
+ };
529
+ }
530
+ }
531
+ // ═══════════════════════════════════════════════════════════════════════════
532
+ // SESSION MANAGEMENT
533
+ // ═══════════════════════════════════════════════════════════════════════════
534
+ /**
535
+ * List all sessions, sorted by last active (newest first)
536
+ */
537
+ listSessions() {
538
+ const index = this.readIndex();
539
+ const entries = [];
540
+ for (const [sessionName, entry] of Object.entries(index)) {
541
+ const meta = this.getSessionMeta(entry.session_id);
542
+ entries.push({
543
+ session_name: sessionName,
544
+ session_id: entry.session_id,
545
+ last_active_ts: entry.last_active_ts,
546
+ turn_count: meta?.turn_count || entry.last_turn_id,
547
+ project_path: entry.project_path,
548
+ is_current: false, // Caller determines this
549
+ });
550
+ }
551
+ // Sort by last active (newest first)
552
+ entries.sort((a, b) => new Date(b.last_active_ts).getTime() - new Date(a.last_active_ts).getTime());
553
+ return entries;
554
+ }
555
+ /**
556
+ * Get session ID from session name
557
+ */
558
+ getSessionId(sessionName) {
559
+ const index = this.readIndex();
560
+ return index[sessionName]?.session_id || null;
561
+ }
562
+ /**
563
+ * Get session name from session ID
564
+ */
565
+ getSessionName(sessionId) {
566
+ const index = this.readIndex();
567
+ for (const [name, entry] of Object.entries(index)) {
568
+ if (entry.session_id === sessionId) {
569
+ return name;
570
+ }
571
+ }
572
+ return null;
573
+ }
574
+ /**
575
+ * Check if a session exists in local cache
576
+ */
577
+ hasSession(sessionIdOrName) {
578
+ const index = this.readIndex();
579
+ // Check by name
580
+ if (index[sessionIdOrName])
581
+ return true;
582
+ // Check by ID
583
+ for (const entry of Object.values(index)) {
584
+ if (entry.session_id === sessionIdOrName)
585
+ return true;
586
+ }
587
+ return false;
588
+ }
589
+ /**
590
+ * Delete a session from local cache
591
+ */
592
+ deleteSession(sessionId) {
593
+ const startTime = Date.now();
594
+ try {
595
+ // Remove files
596
+ const turnsPath = this.getTurnsPath(sessionId);
597
+ const metaPath = this.getMetaPath(sessionId);
598
+ if (fs.existsSync(turnsPath))
599
+ fs.unlinkSync(turnsPath);
600
+ if (fs.existsSync(metaPath))
601
+ fs.unlinkSync(metaPath);
602
+ // Remove from index
603
+ const index = this.readIndex();
604
+ const sessionName = Object.keys(index).find((name) => index[name].session_id === sessionId);
605
+ if (sessionName) {
606
+ delete index[sessionName];
607
+ this.writeIndex(index);
608
+ }
609
+ return {
610
+ success: true,
611
+ source: 'local',
612
+ latency_ms: Date.now() - startTime,
613
+ };
614
+ }
615
+ catch (err) {
616
+ return {
617
+ success: false,
618
+ error: err instanceof Error ? err.message : String(err),
619
+ source: 'local',
620
+ latency_ms: Date.now() - startTime,
621
+ };
622
+ }
623
+ }
624
+ /**
625
+ * Evict oldest sessions to stay under max_sessions limit
626
+ * Only evicts sessions that are fully ACKed to Redis
627
+ */
628
+ evictOldSessions() {
629
+ const sessions = this.listSessions();
630
+ if (sessions.length <= this.config.max_sessions) {
631
+ return 0;
632
+ }
633
+ let evictedCount = 0;
634
+ const toEvict = sessions.length - this.config.max_sessions;
635
+ // Start from oldest (end of sorted array)
636
+ for (let i = sessions.length - 1; i >= 0 && evictedCount < toEvict; i--) {
637
+ const session = sessions[i];
638
+ const meta = this.getSessionMeta(session.session_id);
639
+ // Only evict if fully ACKed
640
+ if (meta && meta.acked_turn_id >= meta.turn_count) {
641
+ this.deleteSession(session.session_id);
642
+ evictedCount++;
643
+ }
644
+ }
645
+ return evictedCount;
646
+ }
647
+ // ═══════════════════════════════════════════════════════════════════════════
648
+ // UTILITIES
649
+ // ═══════════════════════════════════════════════════════════════════════════
650
+ /**
651
+ * Get cache statistics
652
+ */
653
+ getStats() {
654
+ const sessions = this.listSessions();
655
+ let totalTurns = 0;
656
+ let cacheSize = 0;
657
+ for (const session of sessions) {
658
+ totalTurns += session.turn_count;
659
+ const turnsPath = this.getTurnsPath(session.session_id);
660
+ try {
661
+ const stats = fs.statSync(turnsPath);
662
+ cacheSize += stats.size;
663
+ }
664
+ catch {
665
+ // File might not exist
666
+ }
667
+ }
668
+ return {
669
+ session_count: sessions.length,
670
+ total_turns: totalTurns,
671
+ cache_size_bytes: cacheSize,
672
+ };
673
+ }
674
+ /**
675
+ * Clear all local cache (use with caution)
676
+ */
677
+ clearAll() {
678
+ const sessions = this.listSessions();
679
+ for (const session of sessions) {
680
+ this.deleteSession(session.session_id);
681
+ }
682
+ this.indexCache = {};
683
+ this.writeIndex({});
684
+ }
685
+ }
686
+ exports.LocalSessionStore = LocalSessionStore;
687
+ // Export singleton instance
688
+ exports.localCache = new LocalSessionStore();