telecodex 0.1.0 → 0.1.2

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,370 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { DEFAULT_SESSION_PROFILE, isSessionApprovalPolicy, isSessionReasoningEffort, isSessionSandboxMode, isSessionWebSearchMode, } from "../config.js";
4
+ const FILE_STATE_VERSION = 1;
5
+ export class FileStateStorage {
6
+ rootDir;
7
+ appPath;
8
+ projectsPath;
9
+ sessionsPath;
10
+ appState = new Map();
11
+ projects = new Map();
12
+ sessions = new Map();
13
+ constructor(rootDir) {
14
+ this.rootDir = rootDir;
15
+ mkdirSync(rootDir, { recursive: true });
16
+ this.appPath = path.join(rootDir, "app.json");
17
+ this.projectsPath = path.join(rootDir, "projects.json");
18
+ this.sessionsPath = path.join(rootDir, "sessions.json");
19
+ for (const [key, value] of Object.entries(loadAppStateFile(this.appPath).values)) {
20
+ this.appState.set(key, value);
21
+ }
22
+ for (const project of loadProjectsFile(this.projectsPath).projects) {
23
+ this.projects.set(project.chatId, normalizeStoredProjectBinding(project));
24
+ }
25
+ for (const session of loadSessionsFile(this.sessionsPath).sessions) {
26
+ this.sessions.set(session.sessionKey, normalizeStoredSessionRecord(session));
27
+ }
28
+ }
29
+ getAppState(key) {
30
+ return this.appState.get(key) ?? null;
31
+ }
32
+ setAppState(key, value) {
33
+ this.appState.set(key, value);
34
+ this.flushAppState();
35
+ }
36
+ deleteAppState(key) {
37
+ if (!this.appState.delete(key))
38
+ return;
39
+ this.flushAppState();
40
+ }
41
+ mergeImportedAppState(values) {
42
+ let changed = false;
43
+ for (const [key, value] of Object.entries(values)) {
44
+ if (this.appState.has(key))
45
+ continue;
46
+ this.appState.set(key, value);
47
+ changed = true;
48
+ }
49
+ if (changed)
50
+ this.flushAppState();
51
+ }
52
+ getProject(chatId) {
53
+ return cloneProjectBinding(this.projects.get(chatId) ?? null);
54
+ }
55
+ upsertProject(input) {
56
+ const existing = this.projects.get(input.chatId);
57
+ const now = input.now ?? new Date().toISOString();
58
+ const project = {
59
+ chatId: input.chatId,
60
+ cwd: path.resolve(input.cwd),
61
+ name: input.name.trim(),
62
+ createdAt: existing?.createdAt ?? now,
63
+ updatedAt: now,
64
+ };
65
+ this.projects.set(project.chatId, project);
66
+ this.flushProjects();
67
+ return cloneProjectBinding(project);
68
+ }
69
+ removeProject(chatId) {
70
+ if (!this.projects.delete(chatId))
71
+ return;
72
+ this.flushProjects();
73
+ }
74
+ listProjects() {
75
+ return [...this.projects.values()]
76
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
77
+ .map((project) => cloneProjectBinding(project))
78
+ .filter((project) => project != null);
79
+ }
80
+ mergeImportedProjects(projects) {
81
+ let changed = false;
82
+ for (const project of projects) {
83
+ if (this.projects.has(project.chatId))
84
+ continue;
85
+ this.projects.set(project.chatId, normalizeStoredProjectBinding(project));
86
+ changed = true;
87
+ }
88
+ if (changed)
89
+ this.flushProjects();
90
+ }
91
+ getSession(sessionKey) {
92
+ return cloneSessionRecord(this.sessions.get(sessionKey) ?? null);
93
+ }
94
+ listSessions() {
95
+ return [...this.sessions.values()]
96
+ .sort((left, right) => left.sessionKey.localeCompare(right.sessionKey))
97
+ .map((session) => cloneSessionRecord(session))
98
+ .filter((session) => session != null);
99
+ }
100
+ getSessionByThreadId(threadId) {
101
+ for (const session of this.sessions.values()) {
102
+ if (session.codexThreadId === threadId) {
103
+ return cloneSessionRecord(session);
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ putSession(session) {
109
+ const normalized = normalizeStoredSessionRecord(session);
110
+ this.sessions.set(normalized.sessionKey, normalized);
111
+ this.flushSessions();
112
+ return cloneSessionRecord(normalized);
113
+ }
114
+ patchSession(sessionKey, patch) {
115
+ const existing = this.sessions.get(sessionKey);
116
+ if (!existing)
117
+ return null;
118
+ const next = normalizeStoredSessionRecord({
119
+ ...existing,
120
+ ...patch,
121
+ sessionKey,
122
+ createdAt: existing.createdAt,
123
+ updatedAt: new Date().toISOString(),
124
+ });
125
+ this.sessions.set(sessionKey, next);
126
+ this.flushSessions();
127
+ return cloneSessionRecord(next);
128
+ }
129
+ removeSession(sessionKey) {
130
+ if (!this.sessions.delete(sessionKey))
131
+ return;
132
+ this.flushSessions();
133
+ }
134
+ mergeImportedSessions(sessions) {
135
+ let changed = false;
136
+ for (const session of sessions) {
137
+ if (this.sessions.has(session.sessionKey))
138
+ continue;
139
+ this.sessions.set(session.sessionKey, normalizeStoredSessionRecord(session));
140
+ changed = true;
141
+ }
142
+ if (changed)
143
+ this.flushSessions();
144
+ }
145
+ flushAppState() {
146
+ writeJsonFile(this.appPath, {
147
+ version: FILE_STATE_VERSION,
148
+ values: Object.fromEntries([...this.appState.entries()].sort(([left], [right]) => left.localeCompare(right))),
149
+ });
150
+ }
151
+ flushProjects() {
152
+ writeJsonFile(this.projectsPath, {
153
+ version: FILE_STATE_VERSION,
154
+ projects: [...this.projects.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
155
+ });
156
+ }
157
+ flushSessions() {
158
+ writeJsonFile(this.sessionsPath, {
159
+ version: FILE_STATE_VERSION,
160
+ sessions: [...this.sessions.values()].sort((left, right) => left.sessionKey.localeCompare(right.sessionKey)),
161
+ });
162
+ }
163
+ }
164
+ function loadAppStateFile(filePath) {
165
+ try {
166
+ const parsed = readJsonFile(filePath);
167
+ if (!parsed) {
168
+ return {
169
+ version: FILE_STATE_VERSION,
170
+ values: {},
171
+ };
172
+ }
173
+ if (!isRecord(parsed) || !isRecord(parsed.values)) {
174
+ throw new Error(`Invalid app state file: ${filePath}`);
175
+ }
176
+ const values = {};
177
+ for (const [key, value] of Object.entries(parsed.values)) {
178
+ if (typeof value === "string")
179
+ values[key] = value;
180
+ }
181
+ return {
182
+ version: FILE_STATE_VERSION,
183
+ values,
184
+ };
185
+ }
186
+ catch (error) {
187
+ recoverCorruptStateFile(filePath, error);
188
+ return {
189
+ version: FILE_STATE_VERSION,
190
+ values: {},
191
+ };
192
+ }
193
+ }
194
+ function loadProjectsFile(filePath) {
195
+ try {
196
+ const parsed = readJsonFile(filePath);
197
+ if (!parsed) {
198
+ return {
199
+ version: FILE_STATE_VERSION,
200
+ projects: [],
201
+ };
202
+ }
203
+ if (!isRecord(parsed) || !Array.isArray(parsed.projects)) {
204
+ throw new Error(`Invalid projects state file: ${filePath}`);
205
+ }
206
+ return {
207
+ version: FILE_STATE_VERSION,
208
+ projects: parsed.projects.map((project) => normalizeStoredProjectBinding(project)),
209
+ };
210
+ }
211
+ catch (error) {
212
+ recoverCorruptStateFile(filePath, error);
213
+ return {
214
+ version: FILE_STATE_VERSION,
215
+ projects: [],
216
+ };
217
+ }
218
+ }
219
+ function loadSessionsFile(filePath) {
220
+ try {
221
+ const parsed = readJsonFile(filePath);
222
+ if (!parsed) {
223
+ return {
224
+ version: FILE_STATE_VERSION,
225
+ sessions: [],
226
+ };
227
+ }
228
+ if (!isRecord(parsed) || !Array.isArray(parsed.sessions)) {
229
+ throw new Error(`Invalid sessions state file: ${filePath}`);
230
+ }
231
+ return {
232
+ version: FILE_STATE_VERSION,
233
+ sessions: parsed.sessions.map((session) => normalizeStoredSessionRecord(session)),
234
+ };
235
+ }
236
+ catch (error) {
237
+ recoverCorruptStateFile(filePath, error);
238
+ return {
239
+ version: FILE_STATE_VERSION,
240
+ sessions: [],
241
+ };
242
+ }
243
+ }
244
+ function normalizeStoredProjectBinding(value) {
245
+ if (!isRecord(value)) {
246
+ throw new Error("Invalid stored project binding");
247
+ }
248
+ if (typeof value.chatId !== "string" || typeof value.cwd !== "string") {
249
+ throw new Error("Stored project binding is missing required fields");
250
+ }
251
+ const cwd = path.resolve(value.cwd);
252
+ const now = new Date().toISOString();
253
+ return {
254
+ chatId: value.chatId,
255
+ cwd,
256
+ name: typeof value.name === "string" && value.name.trim() ? value.name : path.basename(cwd) || cwd,
257
+ createdAt: typeof value.createdAt === "string" ? value.createdAt : now,
258
+ updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : now,
259
+ };
260
+ }
261
+ function normalizeStoredSessionRecord(value) {
262
+ if (!isRecord(value)) {
263
+ throw new Error("Invalid stored session");
264
+ }
265
+ if (typeof value.sessionKey !== "string" ||
266
+ typeof value.chatId !== "string" ||
267
+ typeof value.cwd !== "string" ||
268
+ typeof value.model !== "string") {
269
+ throw new Error("Stored session is missing required fields");
270
+ }
271
+ const now = new Date().toISOString();
272
+ return {
273
+ sessionKey: value.sessionKey,
274
+ chatId: value.chatId,
275
+ messageThreadId: typeof value.messageThreadId === "string" ? value.messageThreadId : null,
276
+ telegramTopicName: typeof value.telegramTopicName === "string" ? value.telegramTopicName : null,
277
+ codexThreadId: typeof value.codexThreadId === "string" ? value.codexThreadId : null,
278
+ cwd: path.resolve(value.cwd),
279
+ model: value.model.trim() || "gpt-5.4",
280
+ sandboxMode: normalizeSandboxMode(value.sandboxMode),
281
+ approvalPolicy: normalizeApprovalPolicy(value.approvalPolicy),
282
+ reasoningEffort: normalizeReasoningEffort(value.reasoningEffort),
283
+ webSearchMode: normalizeWebSearchMode(value.webSearchMode),
284
+ networkAccessEnabled: normalizeBoolean(value.networkAccessEnabled, true),
285
+ skipGitRepoCheck: normalizeBoolean(value.skipGitRepoCheck, true),
286
+ additionalDirectories: normalizeStringArray(value.additionalDirectories),
287
+ outputSchema: normalizeOutputSchema(value.outputSchema),
288
+ createdAt: typeof value.createdAt === "string" ? value.createdAt : now,
289
+ updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : now,
290
+ };
291
+ }
292
+ function writeJsonFile(filePath, value) {
293
+ mkdirSync(path.dirname(filePath), { recursive: true });
294
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
295
+ writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
296
+ renameSync(tempPath, filePath);
297
+ }
298
+ function readJsonFile(filePath) {
299
+ try {
300
+ return JSON.parse(readFileSync(filePath, "utf8"));
301
+ }
302
+ catch (error) {
303
+ if (isMissingFileError(error))
304
+ return null;
305
+ throw error;
306
+ }
307
+ }
308
+ function isMissingFileError(error) {
309
+ return isRecord(error) && error.code === "ENOENT";
310
+ }
311
+ function recoverCorruptStateFile(filePath, error) {
312
+ if (isMissingFileError(error) || !existsSync(filePath))
313
+ return;
314
+ const recoveredPath = `${filePath}.corrupt-${Date.now()}`;
315
+ try {
316
+ renameSync(filePath, recoveredPath);
317
+ }
318
+ catch {
319
+ // Keep startup resilient even if the corrupt file cannot be moved.
320
+ }
321
+ }
322
+ function cloneProjectBinding(project) {
323
+ return project ? { ...project } : null;
324
+ }
325
+ function cloneSessionRecord(session) {
326
+ return session
327
+ ? {
328
+ ...session,
329
+ additionalDirectories: [...session.additionalDirectories],
330
+ }
331
+ : null;
332
+ }
333
+ function normalizeSandboxMode(value) {
334
+ return typeof value === "string" && isSessionSandboxMode(value) ? value : DEFAULT_SESSION_PROFILE.sandboxMode;
335
+ }
336
+ function normalizeApprovalPolicy(value) {
337
+ return typeof value === "string" && isSessionApprovalPolicy(value) ? value : DEFAULT_SESSION_PROFILE.approvalPolicy;
338
+ }
339
+ function normalizeReasoningEffort(value) {
340
+ return typeof value === "string" && isSessionReasoningEffort(value) ? value : null;
341
+ }
342
+ function normalizeWebSearchMode(value) {
343
+ return typeof value === "string" && isSessionWebSearchMode(value) ? value : null;
344
+ }
345
+ function normalizeBoolean(value, fallback) {
346
+ if (typeof value === "boolean")
347
+ return value;
348
+ if (typeof value === "number" || typeof value === "bigint")
349
+ return Number(value) !== 0;
350
+ return fallback;
351
+ }
352
+ function normalizeStringArray(value) {
353
+ if (!Array.isArray(value))
354
+ return [];
355
+ return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
356
+ }
357
+ function normalizeOutputSchema(value) {
358
+ if (typeof value !== "string" || !value.trim())
359
+ return null;
360
+ try {
361
+ const parsed = JSON.parse(value);
362
+ return isRecord(parsed) ? JSON.stringify(parsed) : null;
363
+ }
364
+ catch {
365
+ return null;
366
+ }
367
+ }
368
+ function isRecord(value) {
369
+ return typeof value === "object" && value !== null && !Array.isArray(value);
370
+ }
@@ -0,0 +1,160 @@
1
+ import { existsSync, rmSync } from "node:fs";
2
+ import { DatabaseSync } from "node:sqlite";
3
+ import { DEFAULT_SESSION_PROFILE, isSessionApprovalPolicy, isSessionReasoningEffort, isSessionSandboxMode, isSessionWebSearchMode, } from "../config.js";
4
+ const LEGACY_IMPORT_MARKER_KEY = "__telecodex_legacy_sqlite_import_completed_at";
5
+ const LEGACY_SQLITE_ARTIFACT_SUFFIXES = ["", "-shm", "-wal", "-journal"];
6
+ export function migrateLegacySqliteState(input) {
7
+ if (input.storage.getAppState(LEGACY_IMPORT_MARKER_KEY) != null) {
8
+ cleanupLegacySqliteArtifacts(input.legacyDbPath);
9
+ return { imported: false };
10
+ }
11
+ if (!existsSync(input.legacyDbPath)) {
12
+ return { imported: false };
13
+ }
14
+ let imported = false;
15
+ const db = new DatabaseSync(input.legacyDbPath);
16
+ try {
17
+ const appState = hasTable(db, "app_state") ? readAppState(db) : {};
18
+ const projects = hasTable(db, "projects") ? readProjects(db) : [];
19
+ const sessions = hasTable(db, "sessions") ? readSessions(db) : [];
20
+ input.storage.mergeImportedAppState(appState);
21
+ input.storage.mergeImportedProjects(projects);
22
+ input.storage.mergeImportedSessions(sessions);
23
+ input.storage.setAppState(LEGACY_IMPORT_MARKER_KEY, new Date().toISOString());
24
+ imported = true;
25
+ }
26
+ finally {
27
+ db.close();
28
+ }
29
+ cleanupLegacySqliteArtifacts(input.legacyDbPath);
30
+ return { imported };
31
+ }
32
+ function readAppState(db) {
33
+ const rows = db.prepare("SELECT key, value FROM app_state").all();
34
+ const values = {};
35
+ for (const row of rows) {
36
+ if (typeof row.key !== "string" || typeof row.value !== "string")
37
+ continue;
38
+ values[row.key] = row.value;
39
+ }
40
+ return values;
41
+ }
42
+ function readProjects(db) {
43
+ const rows = db.prepare("SELECT * FROM projects").all();
44
+ const projects = [];
45
+ for (const row of rows) {
46
+ if (typeof row.chat_id !== "string" || typeof row.cwd !== "string")
47
+ continue;
48
+ const cwd = row.cwd.trim();
49
+ if (!cwd)
50
+ continue;
51
+ const now = new Date().toISOString();
52
+ projects.push({
53
+ chatId: row.chat_id,
54
+ name: typeof row.name === "string" && row.name.trim() ? row.name : cwd,
55
+ cwd,
56
+ createdAt: typeof row.created_at === "string" ? row.created_at : now,
57
+ updatedAt: typeof row.updated_at === "string" ? row.updated_at : now,
58
+ });
59
+ }
60
+ return projects;
61
+ }
62
+ function readSessions(db) {
63
+ const rows = db.prepare("SELECT * FROM sessions").all();
64
+ const sessions = [];
65
+ for (const row of rows) {
66
+ if (typeof row.session_key !== "string" ||
67
+ typeof row.chat_id !== "string" ||
68
+ typeof row.cwd !== "string" ||
69
+ typeof row.model !== "string") {
70
+ continue;
71
+ }
72
+ const now = new Date().toISOString();
73
+ sessions.push({
74
+ sessionKey: row.session_key,
75
+ chatId: row.chat_id,
76
+ messageThreadId: typeof row.message_thread_id === "string" ? row.message_thread_id : null,
77
+ telegramTopicName: typeof row.telegram_topic_name === "string" ? row.telegram_topic_name : null,
78
+ codexThreadId: typeof row.codex_thread_id === "string" ? row.codex_thread_id : null,
79
+ cwd: row.cwd,
80
+ model: row.model,
81
+ sandboxMode: normalizeSandboxMode(row.sandbox_mode, row.mode),
82
+ approvalPolicy: normalizeApprovalPolicy(row.approval_policy),
83
+ reasoningEffort: normalizeReasoningEffort(row.reasoning_effort),
84
+ webSearchMode: normalizeWebSearchMode(row.web_search_mode),
85
+ networkAccessEnabled: normalizeBoolean(row.network_access_enabled, true),
86
+ skipGitRepoCheck: normalizeBoolean(row.skip_git_repo_check, true),
87
+ additionalDirectories: normalizeStringArray(row.additional_directories),
88
+ outputSchema: normalizeOutputSchema(row.output_schema),
89
+ createdAt: typeof row.created_at === "string" ? row.created_at : now,
90
+ updatedAt: typeof row.updated_at === "string" ? row.updated_at : now,
91
+ });
92
+ }
93
+ return sessions;
94
+ }
95
+ function hasTable(db, tableName) {
96
+ const row = db
97
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1")
98
+ .get(tableName);
99
+ return typeof row?.name === "string";
100
+ }
101
+ function normalizeSandboxMode(current, legacyMode) {
102
+ if (typeof current === "string" && isSessionSandboxMode(current))
103
+ return current;
104
+ if (legacyMode === "write")
105
+ return "workspace-write";
106
+ return DEFAULT_SESSION_PROFILE.sandboxMode;
107
+ }
108
+ function normalizeApprovalPolicy(value) {
109
+ return typeof value === "string" && isSessionApprovalPolicy(value) ? value : DEFAULT_SESSION_PROFILE.approvalPolicy;
110
+ }
111
+ function normalizeReasoningEffort(value) {
112
+ return typeof value === "string" && isSessionReasoningEffort(value) ? value : null;
113
+ }
114
+ function normalizeWebSearchMode(value) {
115
+ return typeof value === "string" && isSessionWebSearchMode(value) ? value : null;
116
+ }
117
+ function normalizeBoolean(value, fallback) {
118
+ if (typeof value === "boolean")
119
+ return value;
120
+ if (typeof value === "number" || typeof value === "bigint")
121
+ return Number(value) !== 0;
122
+ return fallback;
123
+ }
124
+ function normalizeStringArray(value) {
125
+ if (typeof value !== "string" || !value.trim())
126
+ return [];
127
+ try {
128
+ const parsed = JSON.parse(value);
129
+ return Array.isArray(parsed)
130
+ ? parsed.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
131
+ : [];
132
+ }
133
+ catch {
134
+ return [];
135
+ }
136
+ }
137
+ function normalizeOutputSchema(value) {
138
+ if (typeof value !== "string" || !value.trim())
139
+ return null;
140
+ try {
141
+ const parsed = JSON.parse(value);
142
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? JSON.stringify(parsed) : null;
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ }
148
+ function cleanupLegacySqliteArtifacts(legacyDbPath) {
149
+ for (const suffix of LEGACY_SQLITE_ARTIFACT_SUFFIXES) {
150
+ const filePath = `${legacyDbPath}${suffix}`;
151
+ if (!existsSync(filePath))
152
+ continue;
153
+ try {
154
+ rmSync(filePath, { force: true });
155
+ }
156
+ catch {
157
+ // Retry on the next startup; legacy files are no longer a source of truth.
158
+ }
159
+ }
160
+ }
@@ -1,47 +1,25 @@
1
1
  import path from "node:path";
2
2
  export class ProjectStore {
3
- db;
4
- constructor(db) {
5
- this.db = db;
3
+ storage;
4
+ constructor(storage) {
5
+ this.storage = storage;
6
6
  }
7
7
  get(chatId) {
8
- const row = this.db.prepare("SELECT * FROM projects WHERE chat_id = ?").get(chatId);
9
- return row ? mapRow(row) : null;
8
+ return this.storage.getProject(chatId);
10
9
  }
11
10
  upsert(input) {
12
- const now = new Date().toISOString();
13
11
  const cwd = path.resolve(input.cwd);
14
12
  const name = input.name?.trim() || path.basename(cwd) || cwd;
15
- this.db
16
- .prepare(`INSERT INTO projects (chat_id, name, cwd, created_at, updated_at)
17
- VALUES (?, ?, ?, ?, ?)
18
- ON CONFLICT(chat_id) DO UPDATE SET
19
- name = excluded.name,
20
- cwd = excluded.cwd,
21
- updated_at = excluded.updated_at`)
22
- .run(input.chatId, name, cwd, now, now);
23
- const project = this.get(input.chatId);
24
- if (!project) {
25
- throw new Error("Project upsert failed");
26
- }
27
- return project;
13
+ return this.storage.upsertProject({
14
+ chatId: input.chatId,
15
+ cwd,
16
+ name,
17
+ });
28
18
  }
29
19
  remove(chatId) {
30
- this.db.prepare("DELETE FROM projects WHERE chat_id = ?").run(chatId);
20
+ this.storage.removeProject(chatId);
31
21
  }
32
22
  list() {
33
- const rows = this.db
34
- .prepare("SELECT * FROM projects ORDER BY updated_at DESC")
35
- .all();
36
- return rows.map(mapRow);
23
+ return this.storage.listProjects();
37
24
  }
38
25
  }
39
- function mapRow(row) {
40
- return {
41
- chatId: row.chat_id,
42
- name: row.name,
43
- cwd: row.cwd,
44
- createdAt: row.created_at,
45
- updatedAt: row.updated_at,
46
- };
47
- }