instar 0.1.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 (115) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.claude/skills/setup-wizard/skill.md +343 -0
  3. package/.github/workflows/ci.yml +78 -0
  4. package/CLAUDE.md +82 -0
  5. package/README.md +194 -0
  6. package/dist/cli.d.ts +18 -0
  7. package/dist/cli.js +141 -0
  8. package/dist/commands/init.d.ts +40 -0
  9. package/dist/commands/init.js +568 -0
  10. package/dist/commands/job.d.ts +20 -0
  11. package/dist/commands/job.js +84 -0
  12. package/dist/commands/server.d.ts +19 -0
  13. package/dist/commands/server.js +273 -0
  14. package/dist/commands/setup.d.ts +24 -0
  15. package/dist/commands/setup.js +865 -0
  16. package/dist/commands/status.d.ts +11 -0
  17. package/dist/commands/status.js +114 -0
  18. package/dist/commands/user.d.ts +17 -0
  19. package/dist/commands/user.js +53 -0
  20. package/dist/core/Config.d.ts +16 -0
  21. package/dist/core/Config.js +144 -0
  22. package/dist/core/Prerequisites.d.ts +28 -0
  23. package/dist/core/Prerequisites.js +159 -0
  24. package/dist/core/RelationshipManager.d.ts +73 -0
  25. package/dist/core/RelationshipManager.js +318 -0
  26. package/dist/core/SessionManager.d.ts +89 -0
  27. package/dist/core/SessionManager.js +326 -0
  28. package/dist/core/StateManager.d.ts +28 -0
  29. package/dist/core/StateManager.js +96 -0
  30. package/dist/core/types.d.ts +279 -0
  31. package/dist/core/types.js +8 -0
  32. package/dist/index.d.ts +18 -0
  33. package/dist/index.js +23 -0
  34. package/dist/messaging/TelegramAdapter.d.ts +73 -0
  35. package/dist/messaging/TelegramAdapter.js +288 -0
  36. package/dist/monitoring/HealthChecker.d.ts +38 -0
  37. package/dist/monitoring/HealthChecker.js +148 -0
  38. package/dist/scaffold/bootstrap.d.ts +21 -0
  39. package/dist/scaffold/bootstrap.js +110 -0
  40. package/dist/scaffold/templates.d.ts +34 -0
  41. package/dist/scaffold/templates.js +187 -0
  42. package/dist/scheduler/JobLoader.d.ts +18 -0
  43. package/dist/scheduler/JobLoader.js +70 -0
  44. package/dist/scheduler/JobScheduler.d.ts +111 -0
  45. package/dist/scheduler/JobScheduler.js +402 -0
  46. package/dist/server/AgentServer.d.ts +40 -0
  47. package/dist/server/AgentServer.js +73 -0
  48. package/dist/server/middleware.d.ts +12 -0
  49. package/dist/server/middleware.js +50 -0
  50. package/dist/server/routes.d.ts +25 -0
  51. package/dist/server/routes.js +224 -0
  52. package/dist/users/UserManager.d.ts +45 -0
  53. package/dist/users/UserManager.js +113 -0
  54. package/docs/dawn-audit-report.md +412 -0
  55. package/docs/positioning-vs-openclaw.md +246 -0
  56. package/package.json +52 -0
  57. package/src/cli.ts +169 -0
  58. package/src/commands/init.ts +654 -0
  59. package/src/commands/job.ts +110 -0
  60. package/src/commands/server.ts +325 -0
  61. package/src/commands/setup.ts +958 -0
  62. package/src/commands/status.ts +125 -0
  63. package/src/commands/user.ts +71 -0
  64. package/src/core/Config.ts +161 -0
  65. package/src/core/Prerequisites.ts +187 -0
  66. package/src/core/RelationshipManager.ts +366 -0
  67. package/src/core/SessionManager.ts +385 -0
  68. package/src/core/StateManager.ts +121 -0
  69. package/src/core/types.ts +320 -0
  70. package/src/index.ts +58 -0
  71. package/src/messaging/TelegramAdapter.ts +365 -0
  72. package/src/monitoring/HealthChecker.ts +172 -0
  73. package/src/scaffold/bootstrap.ts +122 -0
  74. package/src/scaffold/templates.ts +204 -0
  75. package/src/scheduler/JobLoader.ts +85 -0
  76. package/src/scheduler/JobScheduler.ts +476 -0
  77. package/src/server/AgentServer.ts +93 -0
  78. package/src/server/middleware.ts +58 -0
  79. package/src/server/routes.ts +278 -0
  80. package/src/templates/default-jobs.json +47 -0
  81. package/src/templates/hooks/compaction-recovery.sh +23 -0
  82. package/src/templates/hooks/dangerous-command-guard.sh +35 -0
  83. package/src/templates/hooks/grounding-before-messaging.sh +22 -0
  84. package/src/templates/hooks/session-start.sh +37 -0
  85. package/src/templates/hooks/settings-template.json +45 -0
  86. package/src/templates/scripts/health-watchdog.sh +63 -0
  87. package/src/templates/scripts/telegram-reply.sh +54 -0
  88. package/src/users/UserManager.ts +129 -0
  89. package/tests/e2e/lifecycle.test.ts +376 -0
  90. package/tests/fixtures/test-repo/CLAUDE.md +3 -0
  91. package/tests/fixtures/test-repo/README.md +1 -0
  92. package/tests/helpers/setup.ts +209 -0
  93. package/tests/integration/fresh-install.test.ts +218 -0
  94. package/tests/integration/scheduler-basic.test.ts +109 -0
  95. package/tests/integration/server-full.test.ts +284 -0
  96. package/tests/integration/session-lifecycle.test.ts +181 -0
  97. package/tests/unit/Config.test.ts +22 -0
  98. package/tests/unit/HealthChecker.test.ts +168 -0
  99. package/tests/unit/JobLoader.test.ts +151 -0
  100. package/tests/unit/JobScheduler.test.ts +267 -0
  101. package/tests/unit/Prerequisites.test.ts +59 -0
  102. package/tests/unit/RelationshipManager.test.ts +345 -0
  103. package/tests/unit/StateManager.test.ts +143 -0
  104. package/tests/unit/TelegramAdapter.test.ts +165 -0
  105. package/tests/unit/UserManager.test.ts +131 -0
  106. package/tests/unit/bootstrap.test.ts +28 -0
  107. package/tests/unit/commands.test.ts +138 -0
  108. package/tests/unit/middleware.test.ts +92 -0
  109. package/tests/unit/relationship-routes.test.ts +131 -0
  110. package/tests/unit/scaffold-templates.test.ts +132 -0
  111. package/tests/unit/server.test.ts +163 -0
  112. package/tsconfig.json +20 -0
  113. package/vitest.config.ts +9 -0
  114. package/vitest.e2e.config.ts +9 -0
  115. package/vitest.integration.config.ts +9 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Session Manager — spawn and monitor Claude Code sessions via tmux.
3
+ *
4
+ * This is the core capability that transforms Claude Code from a CLI tool
5
+ * into a persistent agent. Sessions run in tmux, survive terminal disconnects,
6
+ * and can be monitored/reaped by the server.
7
+ */
8
+ import { execSync } from 'node:child_process';
9
+ import { EventEmitter } from 'node:events';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ export class SessionManager extends EventEmitter {
13
+ config;
14
+ state;
15
+ monitorInterval = null;
16
+ constructor(config, state) {
17
+ super();
18
+ this.config = config;
19
+ this.state = state;
20
+ }
21
+ /**
22
+ * Start polling for completed sessions. Emits 'sessionComplete' when
23
+ * a running session's tmux process disappears.
24
+ */
25
+ startMonitoring(intervalMs = 5000) {
26
+ if (this.monitorInterval)
27
+ return;
28
+ this.monitorInterval = setInterval(() => {
29
+ const running = this.state.listSessions({ status: 'running' });
30
+ for (const session of running) {
31
+ if (!this.isSessionAlive(session.tmuxSession)) {
32
+ session.status = 'completed';
33
+ session.endedAt = new Date().toISOString();
34
+ this.state.saveSession(session);
35
+ this.emit('sessionComplete', session);
36
+ }
37
+ }
38
+ }, intervalMs);
39
+ }
40
+ /**
41
+ * Stop the monitoring poll.
42
+ */
43
+ stopMonitoring() {
44
+ if (this.monitorInterval) {
45
+ clearInterval(this.monitorInterval);
46
+ this.monitorInterval = null;
47
+ }
48
+ }
49
+ /**
50
+ * Spawn a new Claude Code session in tmux.
51
+ */
52
+ async spawnSession(options) {
53
+ const runningSessions = this.listRunningSessions();
54
+ if (runningSessions.length >= this.config.maxSessions) {
55
+ throw new Error(`Max sessions (${this.config.maxSessions}) reached. ` +
56
+ `Running: ${runningSessions.map(s => s.name).join(', ')}`);
57
+ }
58
+ const sessionId = this.generateId();
59
+ const tmuxSession = `${path.basename(this.config.projectDir)}-${options.name}`;
60
+ // Check if tmux session already exists
61
+ if (this.tmuxSessionExists(tmuxSession)) {
62
+ throw new Error(`tmux session "${tmuxSession}" already exists`);
63
+ }
64
+ // Build the claude command
65
+ const claudeArgs = ['--dangerously-skip-permissions'];
66
+ if (options.model) {
67
+ claudeArgs.push('--model', options.model);
68
+ }
69
+ claudeArgs.push('-p', options.prompt);
70
+ // Create tmux session and run claude
71
+ // Unset ANTHROPIC_* env vars so Claude uses OAuth (subscription) not API key
72
+ const cleanEnv = 'unset ANTHROPIC_API_KEY ANTHROPIC_ADMIN_KEY CLAUDECODE;';
73
+ const claudeCmd = `${cleanEnv} ${this.config.claudePath} ${claudeArgs.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`;
74
+ const tmuxCmd = [
75
+ this.config.tmuxPath,
76
+ 'new-session',
77
+ '-d',
78
+ '-s', tmuxSession,
79
+ '-c', this.config.projectDir,
80
+ `bash -c "${claudeCmd.replace(/"/g, '\\"')}"`,
81
+ ];
82
+ try {
83
+ execSync(tmuxCmd.join(' '), { encoding: 'utf-8' });
84
+ }
85
+ catch (err) {
86
+ throw new Error(`Failed to create tmux session: ${err}`);
87
+ }
88
+ const session = {
89
+ id: sessionId,
90
+ name: options.name,
91
+ status: 'running',
92
+ jobSlug: options.jobSlug,
93
+ tmuxSession,
94
+ startedAt: new Date().toISOString(),
95
+ triggeredBy: options.triggeredBy,
96
+ model: options.model,
97
+ prompt: options.prompt,
98
+ };
99
+ this.state.saveSession(session);
100
+ return session;
101
+ }
102
+ /**
103
+ * Check if a session is still running by checking tmux.
104
+ */
105
+ isSessionAlive(tmuxSession) {
106
+ return this.tmuxSessionExists(tmuxSession);
107
+ }
108
+ /**
109
+ * Kill a session by terminating its tmux session.
110
+ */
111
+ killSession(sessionId) {
112
+ const session = this.state.getSession(sessionId);
113
+ if (!session)
114
+ return false;
115
+ // Don't kill protected sessions
116
+ if (this.config.protectedSessions.includes(session.tmuxSession)) {
117
+ throw new Error(`Cannot kill protected session: ${session.tmuxSession}`);
118
+ }
119
+ try {
120
+ execSync(`${this.config.tmuxPath} kill-session -t '=${session.tmuxSession}'`, {
121
+ encoding: 'utf-8',
122
+ });
123
+ }
124
+ catch {
125
+ // Session might already be dead
126
+ }
127
+ session.status = 'killed';
128
+ session.endedAt = new Date().toISOString();
129
+ this.state.saveSession(session);
130
+ return true;
131
+ }
132
+ /**
133
+ * Capture the current output of a tmux session.
134
+ */
135
+ captureOutput(tmuxSession, lines = 100) {
136
+ try {
137
+ // Note: use `=session:` (trailing colon) for pane-level tmux commands
138
+ return execSync(`${this.config.tmuxPath} capture-pane -t '=${tmuxSession}:' -p -S -${lines}`, { encoding: 'utf-8' });
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ /**
145
+ * Send input to a running tmux session.
146
+ */
147
+ sendInput(tmuxSession, input) {
148
+ try {
149
+ // Note: use `=session:` (trailing colon) for pane-level tmux commands
150
+ execSync(`${this.config.tmuxPath} send-keys -t '=${tmuxSession}:' ${JSON.stringify(input)} Enter`, { encoding: 'utf-8' });
151
+ return true;
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ /**
158
+ * List all sessions that are currently running.
159
+ */
160
+ listRunningSessions() {
161
+ const sessions = this.state.listSessions({ status: 'running' });
162
+ // Verify each is actually still alive in tmux
163
+ return sessions.filter(s => {
164
+ const alive = this.isSessionAlive(s.tmuxSession);
165
+ if (!alive) {
166
+ // Mark as completed if tmux session is gone
167
+ s.status = 'completed';
168
+ s.endedAt = new Date().toISOString();
169
+ this.state.saveSession(s);
170
+ }
171
+ return alive;
172
+ });
173
+ }
174
+ /**
175
+ * Detect if a session has completed by checking output patterns.
176
+ */
177
+ detectCompletion(tmuxSession) {
178
+ const output = this.captureOutput(tmuxSession, 30);
179
+ if (!output)
180
+ return false;
181
+ return this.config.completionPatterns.some(pattern => output.includes(pattern));
182
+ }
183
+ /**
184
+ * Reap completed/zombie sessions.
185
+ */
186
+ reapCompletedSessions() {
187
+ const running = this.state.listSessions({ status: 'running' });
188
+ const reaped = [];
189
+ for (const session of running) {
190
+ if (this.config.protectedSessions.includes(session.tmuxSession))
191
+ continue;
192
+ if (!this.isSessionAlive(session.tmuxSession) || this.detectCompletion(session.tmuxSession)) {
193
+ session.status = 'completed';
194
+ session.endedAt = new Date().toISOString();
195
+ this.state.saveSession(session);
196
+ reaped.push(session.id);
197
+ // Kill the tmux session if it's still hanging around
198
+ if (this.isSessionAlive(session.tmuxSession)) {
199
+ try {
200
+ execSync(`${this.config.tmuxPath} kill-session -t '=${session.tmuxSession}'`);
201
+ }
202
+ catch { /* ignore */ }
203
+ }
204
+ }
205
+ }
206
+ return reaped;
207
+ }
208
+ /**
209
+ * Spawn an interactive Claude Code session (no -p prompt — opens at the REPL).
210
+ * Used for Telegram-driven conversational sessions.
211
+ * Optionally sends an initial message after Claude is ready.
212
+ */
213
+ async spawnInteractiveSession(initialMessage, name) {
214
+ const sanitized = name
215
+ ? name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 40)
216
+ : null;
217
+ const projectBase = path.basename(this.config.projectDir);
218
+ const tmuxSession = sanitized ? `${projectBase}-${sanitized}` : `${projectBase}-interactive-${Date.now()}`;
219
+ if (this.tmuxSessionExists(tmuxSession)) {
220
+ // Session already exists — just reuse it
221
+ if (initialMessage) {
222
+ this.injectMessage(tmuxSession, initialMessage);
223
+ }
224
+ return tmuxSession;
225
+ }
226
+ // Unset ANTHROPIC_* env vars so Claude uses OAuth (subscription) not API key
227
+ const claudeCmd = `${this.config.claudePath} --dangerously-skip-permissions`;
228
+ const shellCmd = `cd '${this.config.projectDir}' && unset ANTHROPIC_API_KEY ANTHROPIC_ADMIN_KEY CLAUDECODE && ${claudeCmd}`;
229
+ const tmuxCmd = `${this.config.tmuxPath} new-session -d -s '${tmuxSession}' -x 200 -y 50 'bash -c "${shellCmd.replace(/"/g, '\\"')}"'`;
230
+ try {
231
+ execSync(tmuxCmd, { encoding: 'utf-8' });
232
+ }
233
+ catch (err) {
234
+ throw new Error(`Failed to create interactive tmux session: ${err}`);
235
+ }
236
+ // Track it in state
237
+ const session = {
238
+ id: this.generateId(),
239
+ name: name || tmuxSession,
240
+ status: 'running',
241
+ tmuxSession,
242
+ startedAt: new Date().toISOString(),
243
+ prompt: initialMessage,
244
+ };
245
+ this.state.saveSession(session);
246
+ // Wait for Claude to be ready, then send the initial message
247
+ if (initialMessage) {
248
+ this.waitForClaudeReady(tmuxSession).then((ready) => {
249
+ if (ready) {
250
+ this.injectMessage(tmuxSession, initialMessage);
251
+ }
252
+ else {
253
+ console.error(`[SessionManager] Claude not ready in session "${tmuxSession}" after timeout`);
254
+ }
255
+ });
256
+ }
257
+ return tmuxSession;
258
+ }
259
+ /**
260
+ * Inject a Telegram message into a tmux session.
261
+ * Short messages go via send-keys; long messages are written to a temp file.
262
+ */
263
+ injectTelegramMessage(tmuxSession, topicId, text) {
264
+ const FILE_THRESHOLD = 500;
265
+ const taggedText = `[telegram:${topicId}] ${text}`;
266
+ if (taggedText.length <= FILE_THRESHOLD) {
267
+ this.injectMessage(tmuxSession, taggedText);
268
+ return;
269
+ }
270
+ // Write full message to temp file
271
+ const tmpDir = path.join('/tmp', 'instar-telegram');
272
+ fs.mkdirSync(tmpDir, { recursive: true });
273
+ const filename = `msg-${topicId}-${Date.now()}.txt`;
274
+ const filepath = path.join(tmpDir, filename);
275
+ fs.writeFileSync(filepath, taggedText);
276
+ const ref = `[telegram:${topicId}] [Long message saved to ${filepath} — read it to see the full message]`;
277
+ this.injectMessage(tmuxSession, ref);
278
+ }
279
+ /**
280
+ * Send text to a tmux session via send-keys.
281
+ * Uses -l (literal) flag for text, then sends Enter separately.
282
+ */
283
+ injectMessage(tmuxSession, text) {
284
+ const exactTarget = `=${tmuxSession}:`;
285
+ try {
286
+ // Send the text literally
287
+ execSync(`${this.config.tmuxPath} send-keys -t '${exactTarget}' -l ${JSON.stringify(text)}`, { encoding: 'utf-8' });
288
+ // Send Enter separately
289
+ execSync(`${this.config.tmuxPath} send-keys -t '${exactTarget}' Enter`, { encoding: 'utf-8' });
290
+ }
291
+ catch (err) {
292
+ console.error(`[SessionManager] Failed to inject message into ${tmuxSession}: ${err}`);
293
+ }
294
+ }
295
+ /**
296
+ * Wait for Claude to be ready in a tmux session by polling output.
297
+ */
298
+ async waitForClaudeReady(tmuxSession, timeoutMs = 15000) {
299
+ const start = Date.now();
300
+ while (Date.now() - start < timeoutMs) {
301
+ const output = this.captureOutput(tmuxSession, 10);
302
+ if (output && (output.includes('❯') || output.includes('>') || output.includes('$'))) {
303
+ return true;
304
+ }
305
+ await new Promise(r => setTimeout(r, 500));
306
+ }
307
+ return false;
308
+ }
309
+ tmuxSessionExists(name) {
310
+ try {
311
+ execSync(`${this.config.tmuxPath} has-session -t '=${name}' 2>/dev/null`, {
312
+ encoding: 'utf-8',
313
+ });
314
+ return true;
315
+ }
316
+ catch {
317
+ return false;
318
+ }
319
+ }
320
+ generateId() {
321
+ const timestamp = Date.now().toString(36);
322
+ const random = Math.random().toString(36).substring(2, 6);
323
+ return `${timestamp}-${random}`;
324
+ }
325
+ }
326
+ //# sourceMappingURL=SessionManager.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * File-based state management.
3
+ *
4
+ * All state is stored as JSON files — no database dependency.
5
+ * This is intentional: agent infrastructure should be portable
6
+ * and not require running a DB server.
7
+ */
8
+ import type { Session, JobState, ActivityEvent } from './types.js';
9
+ export declare class StateManager {
10
+ private stateDir;
11
+ constructor(stateDir: string);
12
+ getSession(sessionId: string): Session | null;
13
+ saveSession(session: Session): void;
14
+ listSessions(filter?: {
15
+ status?: Session['status'];
16
+ }): Session[];
17
+ getJobState(slug: string): JobState | null;
18
+ saveJobState(state: JobState): void;
19
+ appendEvent(event: ActivityEvent): void;
20
+ queryEvents(options: {
21
+ since?: Date;
22
+ type?: string;
23
+ limit?: number;
24
+ }): ActivityEvent[];
25
+ get<T>(key: string): T | null;
26
+ set<T>(key: string, value: T): void;
27
+ }
28
+ //# sourceMappingURL=StateManager.d.ts.map
@@ -0,0 +1,96 @@
1
+ /**
2
+ * File-based state management.
3
+ *
4
+ * All state is stored as JSON files — no database dependency.
5
+ * This is intentional: agent infrastructure should be portable
6
+ * and not require running a DB server.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ export class StateManager {
11
+ stateDir;
12
+ constructor(stateDir) {
13
+ this.stateDir = stateDir;
14
+ }
15
+ // ── Session State ───────────────────────────────────────────────
16
+ getSession(sessionId) {
17
+ const filePath = path.join(this.stateDir, 'state', 'sessions', `${sessionId}.json`);
18
+ if (!fs.existsSync(filePath))
19
+ return null;
20
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
21
+ }
22
+ saveSession(session) {
23
+ const filePath = path.join(this.stateDir, 'state', 'sessions', `${session.id}.json`);
24
+ fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
25
+ }
26
+ listSessions(filter) {
27
+ const dir = path.join(this.stateDir, 'state', 'sessions');
28
+ if (!fs.existsSync(dir))
29
+ return [];
30
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
31
+ const sessions = files.map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8')));
32
+ if (filter?.status) {
33
+ return sessions.filter(s => s.status === filter.status);
34
+ }
35
+ return sessions;
36
+ }
37
+ // ── Job State ─────────────────────────────────────────────────
38
+ getJobState(slug) {
39
+ const filePath = path.join(this.stateDir, 'state', 'jobs', `${slug}.json`);
40
+ if (!fs.existsSync(filePath))
41
+ return null;
42
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
43
+ }
44
+ saveJobState(state) {
45
+ const filePath = path.join(this.stateDir, 'state', 'jobs', `${state.slug}.json`);
46
+ fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
47
+ }
48
+ // ── Activity Events ───────────────────────────────────────────
49
+ appendEvent(event) {
50
+ const date = new Date().toISOString().slice(0, 10);
51
+ const filePath = path.join(this.stateDir, 'logs', `activity-${date}.jsonl`);
52
+ fs.appendFileSync(filePath, JSON.stringify(event) + '\n');
53
+ }
54
+ queryEvents(options) {
55
+ const logDir = path.join(this.stateDir, 'logs');
56
+ if (!fs.existsSync(logDir))
57
+ return [];
58
+ const files = fs.readdirSync(logDir)
59
+ .filter(f => f.startsWith('activity-') && f.endsWith('.jsonl'))
60
+ .sort()
61
+ .reverse();
62
+ const events = [];
63
+ const limit = options.limit || 100;
64
+ for (const file of files) {
65
+ const lines = fs.readFileSync(path.join(logDir, file), 'utf-8')
66
+ .split('\n')
67
+ .filter(Boolean);
68
+ for (const line of lines.reverse()) {
69
+ const event = JSON.parse(line);
70
+ if (options.since && new Date(event.timestamp) < options.since) {
71
+ return events; // Past the time window
72
+ }
73
+ if (options.type && event.type !== options.type)
74
+ continue;
75
+ events.push(event);
76
+ if (events.length >= limit)
77
+ return events;
78
+ }
79
+ }
80
+ return events;
81
+ }
82
+ // ── Generic Key-Value Store ───────────────────────────────────
83
+ get(key) {
84
+ const filePath = path.join(this.stateDir, 'state', `${key}.json`);
85
+ if (!fs.existsSync(filePath))
86
+ return null;
87
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
88
+ }
89
+ set(key, value) {
90
+ const filePath = path.join(this.stateDir, 'state', `${key}.json`);
91
+ const dir = path.dirname(filePath);
92
+ fs.mkdirSync(dir, { recursive: true });
93
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
94
+ }
95
+ }
96
+ //# sourceMappingURL=StateManager.js.map