@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.
- package/dist/cache/LocalSessionStore.d.ts +129 -0
- package/dist/cache/LocalSessionStore.js +688 -0
- package/dist/cache/capture.d.ts +26 -0
- package/dist/cache/capture.js +461 -0
- package/dist/cache/index.d.ts +7 -0
- package/dist/cache/index.js +23 -0
- package/dist/cache/types.d.ts +147 -0
- package/dist/cache/types.js +40 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +478 -0
- package/dist/commands/run.d.ts +12 -0
- package/dist/commands/run.js +829 -0
- package/dist/commands/setup.d.ts +6 -0
- package/dist/commands/setup.js +658 -0
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +109 -0
- package/dist/commands/test.d.ts +1 -0
- package/dist/commands/test.js +157 -0
- package/dist/deploy/agents.d.ts +15 -0
- package/dist/deploy/agents.js +72 -0
- package/dist/deploy/hooks.d.ts +16 -0
- package/dist/deploy/hooks.js +121 -0
- package/dist/deploy/index.d.ts +7 -0
- package/dist/deploy/index.js +24 -0
- package/dist/deploy/instructions.d.ts +12 -0
- package/dist/deploy/instructions.js +36 -0
- package/dist/deploy/mcp.d.ts +19 -0
- package/dist/deploy/mcp.js +109 -0
- package/dist/deploy/plugins.d.ts +19 -0
- package/dist/deploy/plugins.js +62 -0
- package/dist/deploy/settings.d.ts +8 -0
- package/dist/deploy/settings.js +84 -0
- package/dist/deploy/skills.d.ts +19 -0
- package/dist/deploy/skills.js +60 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +71 -0
- package/dist/restore/RestoreOrchestrator.d.ts +48 -0
- package/dist/restore/RestoreOrchestrator.js +481 -0
- package/dist/restore/index.d.ts +4 -0
- package/dist/restore/index.js +20 -0
- package/dist/utils/platform.d.ts +29 -0
- package/dist/utils/platform.js +65 -0
- package/dist/utils/session-words.json +119 -0
- package/dist/utils/state.d.ts +57 -0
- package/dist/utils/state.js +186 -0
- package/dist/utils/templates.d.ts +24 -0
- package/dist/utils/templates.js +118 -0
- package/package.json +48 -0
- package/templates/CLAUDE.md +287 -0
- package/templates/README.md +378 -0
- package/templates/agents/README.md +182 -0
- package/templates/agents/code-reviewer.md +166 -0
- package/templates/agents/debug-detective.md +169 -0
- package/templates/agents/ekkOS_Vercel.md +99 -0
- package/templates/agents/extension-manager.md +229 -0
- package/templates/agents/git-companion.md +185 -0
- package/templates/agents/github-test-agent.md +321 -0
- package/templates/agents/railway-manager.md +179 -0
- package/templates/claude-plugins/PHASE2_COMPLETION.md +346 -0
- package/templates/claude-plugins/PLUGIN_PROPOSALS.md +1776 -0
- package/templates/claude-plugins/README.md +587 -0
- package/templates/claude-plugins/agents/code-reviewer.json +14 -0
- package/templates/claude-plugins/agents/debug-detective.json +15 -0
- package/templates/claude-plugins/agents/git-companion.json +14 -0
- package/templates/claude-plugins/blog-manager/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins/blog-manager/commands/blog.md +691 -0
- package/templates/claude-plugins/golden-loop-monitor/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins/golden-loop-monitor/commands/loop-status.md +434 -0
- package/templates/claude-plugins/learning-tracker/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins/learning-tracker/commands/my-patterns.md +282 -0
- package/templates/claude-plugins/memory-lens/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins/memory-lens/commands/memory-search.md +181 -0
- package/templates/claude-plugins/pattern-coach/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins/pattern-coach/commands/forge.md +365 -0
- package/templates/claude-plugins/project-schema-validator/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins/project-schema-validator/commands/validate-schema.md +582 -0
- package/templates/claude-plugins-admin/AGENT_TEAM_PROPOSALS.md +819 -0
- package/templates/claude-plugins-admin/README.md +446 -0
- package/templates/claude-plugins-admin/autonomous-admin-agent/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins-admin/autonomous-admin-agent/commands/agent.md +595 -0
- package/templates/claude-plugins-admin/backend-agent/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins-admin/backend-agent/commands/backend.md +798 -0
- package/templates/claude-plugins-admin/deploy-guardian/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins-admin/deploy-guardian/commands/deploy.md +554 -0
- package/templates/claude-plugins-admin/frontend-agent/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins-admin/frontend-agent/commands/frontend.md +881 -0
- package/templates/claude-plugins-admin/mcp-server-manager/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins-admin/mcp-server-manager/commands/mcp.md +85 -0
- package/templates/claude-plugins-admin/memory-system-monitor/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins-admin/memory-system-monitor/commands/memory-health.md +569 -0
- package/templates/claude-plugins-admin/qa-agent/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins-admin/qa-agent/commands/qa.md +863 -0
- package/templates/claude-plugins-admin/tech-lead-agent/.claude-plugin/plugin.json +8 -0
- package/templates/claude-plugins-admin/tech-lead-agent/commands/lead.md +732 -0
- package/templates/commands/continue.md +47 -0
- package/templates/cursor-hooks/after-agent-response.sh +117 -0
- package/templates/cursor-hooks/before-submit-prompt.sh +419 -0
- package/templates/cursor-hooks/hooks.json +20 -0
- package/templates/cursor-hooks/lib/contract.sh +320 -0
- package/templates/cursor-hooks/stop.sh +75 -0
- package/templates/cursor-rules/ekkos-memory.md +187 -0
- package/templates/hooks/assistant-response.sh +96 -0
- package/templates/hooks/hooks.json +28 -0
- package/templates/hooks/lib/contract.sh +320 -0
- package/templates/hooks/lib/state.sh +158 -0
- package/templates/hooks/session-start.ps1 +41 -0
- package/templates/hooks/session-start.sh +318 -0
- package/templates/hooks/stop.ps1 +16 -0
- package/templates/hooks/stop.sh +989 -0
- package/templates/hooks/user-prompt-submit.ps1 +174 -0
- package/templates/hooks/user-prompt-submit.sh +587 -0
- package/templates/hooks-node/lib/state.js +187 -0
- package/templates/hooks-node/stop.js +416 -0
- package/templates/hooks-node/user-prompt-submit.js +337 -0
- package/templates/plan-template.md +306 -0
- package/templates/rules/00-hooks-contract.mdc +89 -0
- package/templates/rules/30-ekkos-core.mdc +188 -0
- package/templates/rules/31-ekkos-messages.mdc +78 -0
- package/templates/skills/continue/SKILL.md +169 -0
- package/templates/skills/ekkOS_Deep_Recall/Skill.md +282 -0
- package/templates/skills/ekkOS_Learn/Skill.md +265 -0
- package/templates/skills/ekkOS_Memory_First/Skill.md +206 -0
- package/templates/skills/ekkOS_Plan_Assist/Skill.md +302 -0
- package/templates/skills/ekkOS_Preferences/Skill.md +247 -0
- package/templates/skills/ekkOS_Reflect/Skill.md +257 -0
- package/templates/skills/ekkOS_Safety/Skill.md +265 -0
- package/templates/skills/ekkOS_Schema/Skill.md +251 -0
- package/templates/skills/ekkOS_Summary/Skill.md +257 -0
- package/templates/skills/ekkOS_Vault/Skill.md +287 -0
- package/templates/skills/permissions/Skill.md +322 -0
- package/templates/spec-template.md +159 -0
- package/templates/windsurf-hooks/before-submit-prompt.sh +238 -0
- package/templates/windsurf-hooks/hooks.json +10 -0
- package/templates/windsurf-hooks/lib/contract.sh +320 -0
- 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();
|