@just-every/manager 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.
@@ -0,0 +1,153 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { DB_PATH } from '../constants.js';
4
+ import { ensureDir } from '../utils/fs.js';
5
+
6
+ export interface AgentEventRecord {
7
+ id: string;
8
+ sessionId: string;
9
+ eventType: string;
10
+ sequence: number;
11
+ timestamp: number;
12
+ role?: string | null;
13
+ text?: string | null;
14
+ toolName?: string | null;
15
+ toolInput?: string | null;
16
+ toolOutput?: string | null;
17
+ errorType?: string | null;
18
+ errorMessage?: string | null;
19
+ rawPayload?: string | null;
20
+ sourceFile?: string | null;
21
+ }
22
+
23
+ export interface EventStore {
24
+ insert(events: AgentEventRecord[]): void;
25
+ listBySession(sessionId: string, limit?: number): AgentEventRecord[];
26
+ maxSequence(sessionId: string): number;
27
+ }
28
+
29
+ class SqliteEventStore implements EventStore {
30
+ private db: import('better-sqlite3');
31
+
32
+ constructor(dbPath: string = DB_PATH) {
33
+ const dir = path.dirname(dbPath);
34
+ ensureDir(dir);
35
+ const Database = loadSqlite();
36
+ this.db = new Database(dbPath);
37
+ this.db.pragma('journal_mode = WAL');
38
+ this.migrate();
39
+ }
40
+
41
+ private migrate() {
42
+ this.db.exec(`
43
+ CREATE TABLE IF NOT EXISTS agent_events (
44
+ id TEXT PRIMARY KEY,
45
+ session_id TEXT NOT NULL,
46
+ event_type TEXT NOT NULL,
47
+ sequence INTEGER NOT NULL,
48
+ timestamp INTEGER NOT NULL,
49
+ role TEXT,
50
+ text TEXT,
51
+ tool_name TEXT,
52
+ tool_input TEXT,
53
+ tool_output TEXT,
54
+ error_type TEXT,
55
+ error_message TEXT,
56
+ raw_payload TEXT,
57
+ source_file TEXT
58
+ );
59
+ CREATE INDEX IF NOT EXISTS idx_agent_events_session ON agent_events(session_id, sequence);
60
+ `);
61
+ }
62
+
63
+ insert(events: AgentEventRecord[]): void {
64
+ if (!events.length) return;
65
+ const stmt = this.db.prepare(`
66
+ INSERT OR IGNORE INTO agent_events (
67
+ id, session_id, event_type, sequence, timestamp,
68
+ role, text, tool_name, tool_input, tool_output,
69
+ error_type, error_message, raw_payload, source_file
70
+ ) VALUES (@id, @sessionId, @eventType, @sequence, @timestamp,
71
+ @role, @text, @toolName, @toolInput, @toolOutput,
72
+ @errorType, @errorMessage, @rawPayload, @sourceFile)
73
+ `);
74
+ const tx = this.db.transaction((records: AgentEventRecord[]) => {
75
+ for (const record of records) {
76
+ stmt.run(record);
77
+ }
78
+ });
79
+ tx(events);
80
+ }
81
+
82
+ listBySession(sessionId: string, limit = 20): AgentEventRecord[] {
83
+ const rows = this.db
84
+ .prepare('SELECT * FROM agent_events WHERE session_id = ? ORDER BY sequence DESC LIMIT ?')
85
+ .all(sessionId, limit);
86
+ return rows.map((row) => ({
87
+ id: row.id,
88
+ sessionId: row.session_id,
89
+ eventType: row.event_type,
90
+ sequence: row.sequence,
91
+ timestamp: row.timestamp,
92
+ role: row.role,
93
+ text: row.text,
94
+ toolName: row.tool_name,
95
+ toolInput: row.tool_input,
96
+ toolOutput: row.tool_output,
97
+ errorType: row.error_type,
98
+ errorMessage: row.error_message,
99
+ rawPayload: row.raw_payload,
100
+ sourceFile: row.source_file,
101
+ }));
102
+ }
103
+
104
+ maxSequence(sessionId: string): number {
105
+ const row = this.db
106
+ .prepare('SELECT MAX(sequence) as max FROM agent_events WHERE session_id = ?')
107
+ .get(sessionId);
108
+ return row?.max ?? 0;
109
+ }
110
+ }
111
+
112
+ class MemoryEventStore implements EventStore {
113
+ private events = new Map<string, AgentEventRecord[]>();
114
+
115
+ insert(records: AgentEventRecord[]): void {
116
+ if (!records.length) return;
117
+ for (const record of records) {
118
+ const sessionEvents = this.events.get(record.sessionId) ?? [];
119
+ if (sessionEvents.some((existing) => existing.id === record.id)) continue;
120
+ sessionEvents.push(record);
121
+ this.events.set(record.sessionId, sessionEvents);
122
+ }
123
+ }
124
+
125
+ listBySession(sessionId: string, limit = 20): AgentEventRecord[] {
126
+ const events = this.events.get(sessionId) ?? [];
127
+ return [...events]
128
+ .sort((a, b) => b.sequence - a.sequence)
129
+ .slice(0, limit);
130
+ }
131
+
132
+ maxSequence(sessionId: string): number {
133
+ const events = this.events.get(sessionId) ?? [];
134
+ if (!events.length) return 0;
135
+ return Math.max(...events.map((event) => event.sequence));
136
+ }
137
+ }
138
+
139
+ function loadSqlite(): typeof import('better-sqlite3') {
140
+ try {
141
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
142
+ return require('better-sqlite3');
143
+ } catch (err) {
144
+ throw new Error('better-sqlite3 native bindings are unavailable. Rebuild dependencies or set MANAGERD_STORAGE=memory.');
145
+ }
146
+ }
147
+
148
+ export function createEventStore(): EventStore {
149
+ if (process.env.MANAGERD_STORAGE === 'memory') {
150
+ return new MemoryEventStore();
151
+ }
152
+ return new SqliteEventStore();
153
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { MemorySessionStore } from './session-store.js';
3
+ import { SessionStatus } from '../constants.js';
4
+
5
+ describe('SessionStore', () => {
6
+ it('creates and updates sessions', async () => {
7
+ const store = new MemorySessionStore();
8
+ const created = store.createSession('codex', 'codex', ['--foo']);
9
+
10
+ expect(created.agentType).toBe('codex');
11
+ expect(created.status).toBe(SessionStatus.Launching);
12
+
13
+ store.updateStatus(created.id, SessionStatus.Running);
14
+ const refreshed = store.get(created.id);
15
+ expect(refreshed?.status).toBe(SessionStatus.Running);
16
+
17
+ store.updateStatus(created.id, SessionStatus.Completed, 0);
18
+ const done = store.get(created.id);
19
+ expect(done?.status).toBe(SessionStatus.Completed);
20
+ expect(done?.exitCode).toBe(0);
21
+
22
+ const recent = store.listRecent(5);
23
+ expect(recent.length).toBe(1);
24
+ expect(recent[0].id).toBe(created.id);
25
+
26
+ const external = store.findByToolSession('codex', created.toolSessionId);
27
+ expect(external?.id).toBe(created.id);
28
+
29
+ const override = store.createSession('claude', 'claude', [], { toolSessionId: 'claude-session' });
30
+ expect(override.toolSessionId).toBe('claude-session');
31
+ const found = store.findByToolSession('claude', 'claude-session');
32
+ expect(found?.id).toBe(override.id);
33
+ });
34
+ });
@@ -0,0 +1,231 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { AgentType, DB_PATH, SessionStatus } from '../constants.js';
5
+ import { ensureDir } from '../utils/fs.js';
6
+
7
+ export interface SessionRecord {
8
+ id: string;
9
+ agentType: AgentType;
10
+ toolSessionId: string;
11
+ status: SessionStatus;
12
+ command: string;
13
+ args: string[];
14
+ createdAt: string;
15
+ updatedAt: string;
16
+ exitCode: number | null;
17
+ }
18
+
19
+ export interface CreateSessionOptions {
20
+ toolSessionId?: string;
21
+ }
22
+
23
+ export interface SessionStore {
24
+ createSession(
25
+ agentType: AgentType,
26
+ command: string,
27
+ args: string[],
28
+ options?: CreateSessionOptions,
29
+ ): SessionRecord;
30
+ updateStatus(id: string, status: SessionStatus, exitCode?: number | null): void;
31
+ listRecent(limit?: number): SessionRecord[];
32
+ get(id: string): SessionRecord | null;
33
+ findByToolSession(agentType: AgentType, toolSessionId: string): SessionRecord | null;
34
+ }
35
+
36
+ type DatabaseModule = typeof import('better-sqlite3');
37
+ let cachedDatabase: DatabaseModule | null = null;
38
+
39
+ function loadDatabase(): DatabaseModule {
40
+ if (cachedDatabase) return cachedDatabase;
41
+ try {
42
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
43
+ cachedDatabase = require('better-sqlite3');
44
+ return cachedDatabase;
45
+ } catch (err) {
46
+ throw new Error('better-sqlite3 native bindings are unavailable. Rebuild dependencies or set MANAGERD_STORAGE=memory.');
47
+ }
48
+ }
49
+
50
+ export class SqliteSessionStore implements SessionStore {
51
+ private db: import('better-sqlite3');
52
+
53
+ constructor(dbPath: string = DB_PATH) {
54
+ const dir = path.dirname(dbPath);
55
+ if (!fs.existsSync(dir)) {
56
+ fs.mkdirSync(dir, { recursive: true });
57
+ }
58
+ const Database = loadDatabase();
59
+ this.db = new Database(dbPath);
60
+ this.db.pragma('journal_mode = WAL');
61
+ this.migrate();
62
+ }
63
+
64
+ private migrate() {
65
+ this.db.exec(`
66
+ CREATE TABLE IF NOT EXISTS local_sessions (
67
+ id TEXT PRIMARY KEY,
68
+ agent_type TEXT NOT NULL,
69
+ tool_session_id TEXT NOT NULL,
70
+ status TEXT NOT NULL,
71
+ command TEXT NOT NULL,
72
+ args TEXT NOT NULL,
73
+ exit_code INTEGER,
74
+ created_at TEXT NOT NULL,
75
+ updated_at TEXT NOT NULL
76
+ );
77
+ CREATE INDEX IF NOT EXISTS idx_local_sessions_agent_type ON local_sessions(agent_type);
78
+ `);
79
+ }
80
+
81
+ createSession(
82
+ agentType: AgentType,
83
+ command: string,
84
+ args: string[],
85
+ options: CreateSessionOptions = {},
86
+ ): SessionRecord {
87
+ const id = randomUUID();
88
+ const now = new Date().toISOString();
89
+ const toolSessionId = options.toolSessionId ?? id;
90
+ this.db
91
+ .prepare(`
92
+ INSERT INTO local_sessions (id, agent_type, tool_session_id, status, command, args, exit_code, created_at, updated_at)
93
+ VALUES (@id, @agentType, @toolSessionId, @status, @command, @args, NULL, @createdAt, @updatedAt)
94
+ `)
95
+ .run({
96
+ id,
97
+ agentType,
98
+ toolSessionId,
99
+ status: SessionStatus.Launching,
100
+ command,
101
+ args: JSON.stringify(args),
102
+ createdAt: now,
103
+ updatedAt: now,
104
+ });
105
+ return {
106
+ id,
107
+ agentType,
108
+ toolSessionId,
109
+ status: SessionStatus.Launching,
110
+ command,
111
+ args,
112
+ createdAt: now,
113
+ updatedAt: now,
114
+ exitCode: null,
115
+ };
116
+ }
117
+
118
+ updateStatus(id: string, status: SessionStatus, exitCode: number | null = null) {
119
+ this.db
120
+ .prepare(`
121
+ UPDATE local_sessions
122
+ SET status=@status, exit_code=@exitCode, updated_at=@updatedAt
123
+ WHERE id=@id
124
+ `)
125
+ .run({ id, status, exitCode, updatedAt: new Date().toISOString() });
126
+ }
127
+
128
+ listRecent(limit = 10): SessionRecord[] {
129
+ const rows = this.db
130
+ .prepare(`SELECT * FROM local_sessions ORDER BY datetime(updated_at) DESC LIMIT ?`)
131
+ .all(limit);
132
+ return rows.map((row) => this.denormalize(row));
133
+ }
134
+
135
+ get(id: string): SessionRecord | null {
136
+ const row = this.db.prepare(`SELECT * FROM local_sessions WHERE id=? LIMIT 1`).get(id);
137
+ return row ? this.denormalize(row) : null;
138
+ }
139
+
140
+ private denormalize(row: any): SessionRecord {
141
+ return {
142
+ id: row.id,
143
+ agentType: row.agent_type,
144
+ toolSessionId: row.tool_session_id,
145
+ status: row.status,
146
+ command: row.command,
147
+ args: JSON.parse(row.args ?? '[]'),
148
+ createdAt: row.created_at,
149
+ updatedAt: row.updated_at,
150
+ exitCode: row.exit_code ?? null,
151
+ };
152
+ }
153
+
154
+ findByToolSession(agentType: AgentType, toolSessionId: string): SessionRecord | null {
155
+ const row = this.db
156
+ .prepare(
157
+ `SELECT * FROM local_sessions WHERE agent_type = @agentType AND tool_session_id = @toolSessionId LIMIT 1`,
158
+ )
159
+ .get({ agentType, toolSessionId });
160
+ return row ? this.denormalize(row) : null;
161
+ }
162
+ }
163
+
164
+ export class MemorySessionStore implements SessionStore {
165
+ private records = new Map<string, SessionRecord>();
166
+ private toolIndex = new Map<string, SessionRecord>();
167
+
168
+ createSession(
169
+ agentType: AgentType,
170
+ command: string,
171
+ args: string[],
172
+ options: CreateSessionOptions = {},
173
+ ): SessionRecord {
174
+ const id = randomUUID();
175
+ const now = new Date().toISOString();
176
+ const record: SessionRecord = {
177
+ id,
178
+ agentType,
179
+ toolSessionId: options.toolSessionId ?? id,
180
+ status: SessionStatus.Launching,
181
+ command,
182
+ args,
183
+ createdAt: now,
184
+ updatedAt: now,
185
+ exitCode: null,
186
+ };
187
+ this.records.set(id, record);
188
+ this.toolIndex.set(this.toolKey(agentType, record.toolSessionId), record);
189
+ return record;
190
+ }
191
+
192
+ updateStatus(id: string, status: SessionStatus, exitCode: number | null = null) {
193
+ const record = this.records.get(id);
194
+ if (!record) return;
195
+ record.status = status;
196
+ record.exitCode = exitCode;
197
+ record.updatedAt = new Date().toISOString();
198
+ this.toolIndex.set(this.toolKey(record.agentType, record.toolSessionId), record);
199
+ }
200
+
201
+ listRecent(limit = 10): SessionRecord[] {
202
+ return Array.from(this.records.values())
203
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
204
+ .slice(0, limit);
205
+ }
206
+
207
+ get(id: string): SessionRecord | null {
208
+ return this.records.get(id) ?? null;
209
+ }
210
+
211
+ findByToolSession(agentType: AgentType, toolSessionId: string): SessionRecord | null {
212
+ return this.toolIndex.get(this.toolKey(agentType, toolSessionId)) ?? null;
213
+ }
214
+
215
+ private toolKey(agentType: AgentType, toolSessionId: string): string {
216
+ return `${agentType}:${toolSessionId}`;
217
+ }
218
+ }
219
+
220
+ export function createSessionStore(): SessionStore {
221
+ if (process.env.MANAGERD_STORAGE === 'memory') {
222
+ return new MemorySessionStore();
223
+ }
224
+ return new SqliteSessionStore();
225
+ }
226
+
227
+ export async function ensureBaseDirs(dir: string) {
228
+ await ensureDir(dir);
229
+ await ensureDir(path.join(dir, 'logs'));
230
+ await ensureDir(path.join(dir, 'sessions'));
231
+ }
@@ -0,0 +1,19 @@
1
+ import fs from 'node:fs/promises';
2
+
3
+ export async function ensureDir(dir: string): Promise<void> {
4
+ await fs.mkdir(dir, { recursive: true });
5
+ }
6
+
7
+ export async function writeFileAtomic(filePath: string, contents: string): Promise<void> {
8
+ await fs.mkdir(require('node:path').dirname(filePath), { recursive: true });
9
+ await fs.writeFile(filePath, contents, 'utf8');
10
+ }
11
+
12
+ export async function fileExists(filePath: string): Promise<boolean> {
13
+ try {
14
+ await fs.access(filePath);
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
@@ -0,0 +1,13 @@
1
+ import pino from 'pino';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { DATA_DIRS } from '../constants.js';
5
+
6
+ if (!fs.existsSync(DATA_DIRS.logs)) {
7
+ fs.mkdirSync(DATA_DIRS.logs, { recursive: true });
8
+ }
9
+
10
+ export const logger = pino({
11
+ level: process.env.LOG_LEVEL ?? 'info',
12
+ base: undefined,
13
+ }, pino.destination(path.join(DATA_DIRS.logs, 'managerd.log')));
@@ -0,0 +1,141 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { SessionStore } from '../storage/session-store.js';
4
+ import { GeminiIngestor, GeminiSessionPayload } from '../ingestion/gemini-ingestor.js';
5
+ import { logger } from '../utils/logger.js';
6
+
7
+ export interface GeminiLogWatcherOptions {
8
+ rootDir?: string;
9
+ intervalMs?: number;
10
+ }
11
+
12
+ export class GeminiLogWatcher {
13
+ #store: SessionStore;
14
+ #ingestor: GeminiIngestor;
15
+ #rootDir: string;
16
+ #interval: number;
17
+ #timer?: NodeJS.Timer;
18
+ #seen = new Map<string, number>();
19
+
20
+ constructor(store: SessionStore, ingestor: GeminiIngestor, options: GeminiLogWatcherOptions = {}) {
21
+ this.#store = store;
22
+ this.#ingestor = ingestor;
23
+ this.#rootDir = options.rootDir ?? process.env.MANAGER_GEMINI_LOG_ROOT ?? path.join(process.env.HOME || '', '.gemini', 'tmp');
24
+ this.#interval = options.intervalMs ?? 5000;
25
+ }
26
+
27
+ start() {
28
+ if (this.#timer) return;
29
+ this.#timer = setInterval(() => {
30
+ this.scan().catch((err) => logger.warn({ err }, 'gemini log scan failed'));
31
+ }, this.#interval);
32
+ void this.scan();
33
+ }
34
+
35
+ stop() {
36
+ if (!this.#timer) return;
37
+ clearInterval(this.#timer);
38
+ this.#timer = undefined;
39
+ }
40
+
41
+ private async scan() {
42
+ let dirs: string[] = [];
43
+ try {
44
+ const rootEntries = await fs.readdir(this.#rootDir, { withFileTypes: true });
45
+ for (const entry of rootEntries) {
46
+ if (entry.isDirectory()) {
47
+ const chatsDir = path.join(this.#rootDir, entry.name, 'chats');
48
+ dirs.push(chatsDir);
49
+ }
50
+ }
51
+ } catch (err: any) {
52
+ if (err?.code !== 'ENOENT') {
53
+ logger.debug({ err }, 'gemini root not accessible');
54
+ }
55
+ return;
56
+ }
57
+
58
+ for (const dir of dirs) {
59
+ let files: string[] = [];
60
+ try {
61
+ const entries = await fs.readdir(dir, { withFileTypes: true });
62
+ files = entries.filter((d) => d.isFile() && /session-.*\.(jsonl?|log)$/i.test(d.name)).map((d) => path.join(dir, d.name));
63
+ } catch (err: any) {
64
+ if (err?.code !== 'ENOENT') {
65
+ logger.debug({ err }, 'failed to read gemini chat dir');
66
+ }
67
+ continue;
68
+ }
69
+
70
+ for (const filePath of files) {
71
+ await this.processFile(filePath);
72
+ }
73
+ }
74
+ }
75
+
76
+ private async processFile(filePath: string) {
77
+ let stats;
78
+ try {
79
+ stats = await fs.stat(filePath);
80
+ } catch {
81
+ return;
82
+ }
83
+ const lastProcessed = this.#seen.get(filePath) ?? 0;
84
+ if (stats.mtimeMs <= lastProcessed) {
85
+ return;
86
+ }
87
+
88
+ let text: string;
89
+ try {
90
+ text = await fs.readFile(filePath, 'utf8');
91
+ } catch (err) {
92
+ logger.warn({ err }, 'failed to read gemini session file');
93
+ return;
94
+ }
95
+
96
+ const payload = parseGeminiFile(text);
97
+ if (!payload) {
98
+ logger.warn({ filePath }, 'unable to parse gemini session file');
99
+ this.#seen.set(filePath, stats.mtimeMs);
100
+ return;
101
+ }
102
+
103
+ const sessionId = payload.sessionId ?? inferSessionIdFromPath(filePath);
104
+ const session = this.#store.findByToolSession('gemini', sessionId) ?? this.#store.createSession('gemini', 'gemini-cli', [], { toolSessionId: sessionId });
105
+ try {
106
+ this.#ingestor.ingest(session, payload);
107
+ this.#seen.set(filePath, stats.mtimeMs);
108
+ } catch (err) {
109
+ logger.error({ err }, 'gemini ingestion failed');
110
+ }
111
+ }
112
+ }
113
+
114
+ function parseGeminiFile(contents: string): GeminiSessionPayload | null {
115
+ const trimmed = contents.trim();
116
+ if (!trimmed) return null;
117
+ try {
118
+ const parsed = JSON.parse(trimmed);
119
+ if (Array.isArray(parsed)) {
120
+ return { messages: parsed };
121
+ }
122
+ return parsed as GeminiSessionPayload;
123
+ } catch {
124
+ const lines = trimmed.split(/\r?\n/).filter(Boolean);
125
+ const messages = [];
126
+ for (const line of lines) {
127
+ try {
128
+ messages.push(JSON.parse(line));
129
+ } catch {
130
+ // ignore malformed lines
131
+ }
132
+ }
133
+ if (!messages.length) return null;
134
+ return { messages };
135
+ }
136
+ }
137
+
138
+ function inferSessionIdFromPath(filePath: string): string {
139
+ const base = path.basename(filePath);
140
+ return base.replace(/\.[^.]+$/, '') || `gemini-${Date.now()}`;
141
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "moduleResolution": "node",
6
+ "module": "esnext",
7
+ "target": "es2022",
8
+ "rootDir": "src",
9
+ "resolveJsonModule": true,
10
+ "esModuleInterop": true,
11
+ "types": ["node"]
12
+ },
13
+ "include": ["src/**/*.ts"],
14
+ "exclude": ["dist", "node_modules"]
15
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['src/**/*.test.ts'],
6
+ environment: 'node',
7
+ coverage: {
8
+ reporter: ['text', 'lcov'],
9
+ },
10
+ },
11
+ });