telecodex 0.1.1 → 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.
- package/README.md +10 -3
- package/dist/bot/commandSupport.js +3 -1
- package/dist/bot/createBot.js +16 -2
- package/dist/bot/handlers/projectHandlers.js +67 -8
- package/dist/bot/topicCleanup.js +80 -0
- package/dist/codex/sessionCatalog.js +215 -0
- package/dist/config.js +0 -1
- package/dist/runtime/appPaths.js +4 -1
- package/dist/runtime/bootstrap.js +11 -7
- package/dist/runtime/startTelecodex.js +5 -0
- package/dist/store/fileState.js +370 -0
- package/dist/store/legacyMigration.js +160 -0
- package/dist/store/projects.js +11 -33
- package/dist/store/sessions.js +138 -210
- package/package.json +2 -2
- package/dist/store/db.js +0 -267
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ The bot talks to local Codex through `@openai/codex-sdk`, which wraps the local
|
|
|
28
28
|
- One topic maps to one Codex SDK thread.
|
|
29
29
|
- Each topic has at most one active SDK run.
|
|
30
30
|
- Follow-up messages during an active run are queued and processed in order.
|
|
31
|
+
- The pending queue is in-memory only and is cleared on restart.
|
|
31
32
|
- Text and image messages are mapped to Codex SDK input.
|
|
32
33
|
- A run immediately creates a normal Telegram status message; progress edits that message.
|
|
33
34
|
- telecodex does not use pinned messages for live state.
|
|
@@ -122,13 +123,18 @@ Optional security override:
|
|
|
122
123
|
- The project root is bound once with `/project bind <absolute-path>`.
|
|
123
124
|
- Each topic in that supergroup is one Codex thread.
|
|
124
125
|
- Work happens by sending normal messages inside the topic.
|
|
126
|
+
- `/thread list` shows the saved Codex threads already recorded for the bound project root or its subdirectories.
|
|
125
127
|
- `/thread new <topic-name>` automatically creates a new topic; the first normal message inside it starts a fresh Codex thread.
|
|
126
128
|
- `/thread resume <threadId>` automatically creates a new topic and binds it to an existing thread id.
|
|
129
|
+
- On startup, telecodex probes stored topic bindings once and removes bindings whose Telegram topics no longer exist.
|
|
130
|
+
- On first launch after upgrading from the old SQLite state format, telecodex imports the legacy state once and then deletes the old SQLite files (`state.sqlite` plus sidecars such as `-wal`/`-shm`).
|
|
127
131
|
|
|
128
132
|
## Stored state
|
|
129
133
|
|
|
130
134
|
- Telegram bot token: stored in the system keychain when available. Plaintext local fallback is disabled by default and must be opted into explicitly.
|
|
131
|
-
-
|
|
135
|
+
- Codex thread history: read directly from Codex session files under `$CODEX_HOME/sessions` (or `~/.codex/sessions` by default).
|
|
136
|
+
- Admin binding, project bindings, and durable Telegram topic configuration: stored as local JSON files under `~/.telecodex/state/`.
|
|
137
|
+
- Legacy upgrade path: if `~/.telecodex/state.sqlite` exists from an older telecodex version, it is imported once into the JSON state files and then cleaned up together with any SQLite sidecar files that remain.
|
|
132
138
|
- Runtime logs: written by `pino` to `~/.telecodex/logs/telecodex.log`.
|
|
133
139
|
- Working directory: defaults to the directory where you ran `telecodex`.
|
|
134
140
|
|
|
@@ -149,8 +155,9 @@ Optional security override:
|
|
|
149
155
|
- `/project bind <absolute-path>` - bind the current supergroup to a project root.
|
|
150
156
|
- `/project unbind` - remove the current supergroup's project binding.
|
|
151
157
|
- `/thread` - in a topic, show the current attached thread id.
|
|
158
|
+
- `/thread list` - list saved Codex threads whose working directory is inside the current project root.
|
|
152
159
|
- `/thread new <topic-name>` - create a new topic for a fresh Codex thread.
|
|
153
|
-
- `/thread resume <threadId>` - create a new topic and bind it to an existing Codex thread id.
|
|
160
|
+
- `/thread resume <threadId>` - create a new topic and bind it to an existing saved Codex thread id from the current project.
|
|
154
161
|
- Normal text in a topic - send that message to the current Codex thread.
|
|
155
162
|
- `/stop` - interrupt the active SDK run.
|
|
156
163
|
- `/cwd <absolute-path>` - switch the topic working directory inside the current project root.
|
|
@@ -174,6 +181,6 @@ Optional security override:
|
|
|
174
181
|
- Streaming updates are throttled before editing Telegram messages.
|
|
175
182
|
- Final answers are rendered from Markdown to Telegram-safe HTML.
|
|
176
183
|
- Project-scoped path checks resolve symlinks before enforcing the root boundary, so topic cwd changes cannot escape the bound project through symlink paths.
|
|
177
|
-
- Because the SDK run
|
|
184
|
+
- Because the SDK run and pending queue are in-process, a telecodex restart cannot resume a partially streamed Telegram turn and clears queued follow-up messages.
|
|
178
185
|
- Authentication and provider switching remain owned by the local Codex installation; telecodex does not manage API keys or login state.
|
|
179
186
|
- Interactive terminal stdin bridging and native Codex approval UI are intentionally not part of the Telegram contract. For unattended remote work, use the topic's sandbox/approval preset deliberately.
|
|
@@ -47,6 +47,7 @@ export function formatHelpText(ctx, projects) {
|
|
|
47
47
|
"/project bind <absolute-path>",
|
|
48
48
|
"",
|
|
49
49
|
"Then manage threads in the group:",
|
|
50
|
+
"/thread list",
|
|
50
51
|
"/thread new <topic-name>",
|
|
51
52
|
"/thread resume <threadId>",
|
|
52
53
|
"",
|
|
@@ -80,6 +81,7 @@ export function formatHelpText(ctx, projects) {
|
|
|
80
81
|
"",
|
|
81
82
|
"/project show the project binding",
|
|
82
83
|
"/project bind <absolute-path> update the project root",
|
|
84
|
+
"/thread list show saved Codex threads already recorded for this project",
|
|
83
85
|
"/thread new <topic-name> create a new topic; the first message starts a new thread",
|
|
84
86
|
"/thread resume <threadId> create a topic bound to an existing thread",
|
|
85
87
|
"send a normal message inside a topic to the current thread",
|
|
@@ -135,7 +137,7 @@ export function formatProjectStatus(project) {
|
|
|
135
137
|
"Project status",
|
|
136
138
|
`project: ${project.name}`,
|
|
137
139
|
`root: ${project.cwd}`,
|
|
138
|
-
"This supergroup represents one project. Use /thread new or /thread resume to
|
|
140
|
+
"This supergroup represents one project. Use /thread list, /thread new, or /thread resume to manage topics.",
|
|
139
141
|
].join("\n");
|
|
140
142
|
}
|
|
141
143
|
export function ensureTopicSession(input) {
|
package/dist/bot/createBot.js
CHANGED
|
@@ -3,9 +3,10 @@ import { MessageBuffer } from "../telegram/messageBuffer.js";
|
|
|
3
3
|
import { authMiddleware } from "./auth.js";
|
|
4
4
|
import { recoverActiveTopicSessions } from "./inputService.js";
|
|
5
5
|
import { registerHandlers } from "./registerHandlers.js";
|
|
6
|
+
import { cleanupMissingTopicBindings } from "./topicCleanup.js";
|
|
6
7
|
export { handleUserText, refreshSessionIfActiveTurnIsStale } from "./inputService.js";
|
|
7
8
|
export function wireBot(input) {
|
|
8
|
-
const { bot, config, store, projects, codex, bootstrapCode, logger, onAdminBound } = input;
|
|
9
|
+
const { bot, config, store, projects, codex, threadCatalog, bootstrapCode, logger, onAdminBound } = input;
|
|
9
10
|
const buffers = new MessageBuffer(bot, config.updateIntervalMs, logger?.child("message-buffer"));
|
|
10
11
|
bot.use(authMiddleware({
|
|
11
12
|
store,
|
|
@@ -30,10 +31,23 @@ export function wireBot(input) {
|
|
|
30
31
|
store,
|
|
31
32
|
projects,
|
|
32
33
|
codex,
|
|
34
|
+
threadCatalog,
|
|
33
35
|
buffers,
|
|
34
36
|
...(logger ? { logger } : {}),
|
|
35
37
|
});
|
|
36
|
-
void
|
|
38
|
+
void (async () => {
|
|
39
|
+
try {
|
|
40
|
+
await cleanupMissingTopicBindings({
|
|
41
|
+
bot,
|
|
42
|
+
store,
|
|
43
|
+
...(logger ? { logger: logger.child("topic-cleanup") } : {}),
|
|
44
|
+
});
|
|
45
|
+
await recoverActiveTopicSessions(store, codex, buffers, bot, logger);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
logger?.error("startup topic reconciliation failed", error);
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
37
51
|
return {
|
|
38
52
|
bot,
|
|
39
53
|
buffers,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { contextLogFields, ensureTopicSession, formatPrivateProjectList, formatProjectStatus, formatTopicName, getProjectForContext, getScopedSession, hasTopicContext, isPrivateChat, isSupergroupChat, parseSubcommand, postTopicReadyMessage, resolveExistingDirectory, } from "../commandSupport.js";
|
|
2
3
|
import { formatSessionRuntimeStatus } from "../../runtime/sessionRuntime.js";
|
|
3
4
|
const PROJECT_REQUIRED_MESSAGE = "This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.";
|
|
@@ -82,12 +83,17 @@ export function registerProjectHandlers(deps) {
|
|
|
82
83
|
`queue: ${store.getQueuedInputCount(session.sessionKey)}`,
|
|
83
84
|
`cwd: ${session.cwd}`,
|
|
84
85
|
"Manage threads in this project:",
|
|
86
|
+
"/thread list",
|
|
85
87
|
"/thread resume <threadId>",
|
|
86
88
|
"/thread new <topic-name>",
|
|
87
89
|
].join("\n"));
|
|
88
90
|
return;
|
|
89
91
|
}
|
|
90
|
-
await ctx.reply("Usage:\n/thread resume <threadId>\n/thread new <topic-name>");
|
|
92
|
+
await ctx.reply("Usage:\n/thread list\n/thread resume <threadId>\n/thread new <topic-name>");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (command === "list") {
|
|
96
|
+
await listProjectThreads(ctx, deps);
|
|
91
97
|
return;
|
|
92
98
|
}
|
|
93
99
|
if (command === "resume") {
|
|
@@ -102,7 +108,7 @@ export function registerProjectHandlers(deps) {
|
|
|
102
108
|
await createFreshThreadTopic(ctx, deps, args);
|
|
103
109
|
return;
|
|
104
110
|
}
|
|
105
|
-
await ctx.reply("Usage:\n/thread resume <threadId>\n/thread new <topic-name>");
|
|
111
|
+
await ctx.reply("Usage:\n/thread list\n/thread resume <threadId>\n/thread new <topic-name>");
|
|
106
112
|
});
|
|
107
113
|
bot.on(["message:forum_topic_created", "message:forum_topic_edited"], async (ctx) => {
|
|
108
114
|
const threadId = ctx.message.message_thread_id;
|
|
@@ -119,13 +125,26 @@ export function registerProjectHandlers(deps) {
|
|
|
119
125
|
});
|
|
120
126
|
}
|
|
121
127
|
async function resumeThreadIntoTopic(ctx, deps, threadId) {
|
|
122
|
-
const { bot, config, store, projects, logger } = deps;
|
|
128
|
+
const { bot, config, store, projects, logger, threadCatalog } = deps;
|
|
123
129
|
const project = getProjectForContext(ctx, projects);
|
|
124
130
|
if (!project) {
|
|
125
131
|
await ctx.reply(PROJECT_REQUIRED_MESSAGE);
|
|
126
132
|
return;
|
|
127
133
|
}
|
|
128
|
-
const
|
|
134
|
+
const thread = await threadCatalog.findProjectThreadById({
|
|
135
|
+
projectRoot: project.cwd,
|
|
136
|
+
threadId,
|
|
137
|
+
});
|
|
138
|
+
if (!thread) {
|
|
139
|
+
await ctx.reply([
|
|
140
|
+
"Could not find a saved Codex thread with that id under this project.",
|
|
141
|
+
`project root: ${project.cwd}`,
|
|
142
|
+
`thread: ${threadId}`,
|
|
143
|
+
"Run /thread list to inspect the saved project threads first.",
|
|
144
|
+
].join("\n"));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const topicName = formatTopicName(thread.preview, `Resumed ${thread.id.slice(0, 8)}`);
|
|
129
148
|
const forumTopic = await bot.api.createForumTopic(ctx.chat.id, topicName);
|
|
130
149
|
const session = ensureTopicSession({
|
|
131
150
|
store,
|
|
@@ -134,24 +153,25 @@ async function resumeThreadIntoTopic(ctx, deps, threadId) {
|
|
|
134
153
|
chatId: ctx.chat.id,
|
|
135
154
|
messageThreadId: forumTopic.message_thread_id,
|
|
136
155
|
topicName: forumTopic.name,
|
|
137
|
-
threadId,
|
|
156
|
+
threadId: thread.id,
|
|
138
157
|
});
|
|
139
158
|
logger?.info("thread id bound into topic", {
|
|
140
159
|
...contextLogFields(ctx),
|
|
141
160
|
sessionKey: session.sessionKey,
|
|
142
|
-
threadId,
|
|
161
|
+
threadId: thread.id,
|
|
143
162
|
topicName: forumTopic.name,
|
|
144
163
|
});
|
|
145
164
|
await ctx.reply([
|
|
146
165
|
"Created a topic and bound it to the existing thread id.",
|
|
147
166
|
`topic: ${forumTopic.name}`,
|
|
148
167
|
`topic id: ${forumTopic.message_thread_id}`,
|
|
149
|
-
`thread: ${
|
|
168
|
+
`thread: ${thread.id}`,
|
|
169
|
+
`cwd: ${thread.cwd}`,
|
|
150
170
|
"Future messages in this topic will continue on that thread through the Codex SDK.",
|
|
151
171
|
].join("\n"));
|
|
152
172
|
await postTopicReadyMessage(bot, session, [
|
|
153
173
|
"This topic is now bound to an existing Codex thread id.",
|
|
154
|
-
`thread: ${
|
|
174
|
+
`thread: ${thread.id}`,
|
|
155
175
|
"Send a message to continue.",
|
|
156
176
|
].join("\n"));
|
|
157
177
|
}
|
|
@@ -190,3 +210,42 @@ async function createFreshThreadTopic(ctx, deps, requestedName) {
|
|
|
190
210
|
`model: ${session.model}`,
|
|
191
211
|
].join("\n"));
|
|
192
212
|
}
|
|
213
|
+
async function listProjectThreads(ctx, deps) {
|
|
214
|
+
const { projects, store, threadCatalog } = deps;
|
|
215
|
+
const project = getProjectForContext(ctx, projects);
|
|
216
|
+
if (!project) {
|
|
217
|
+
await ctx.reply(PROJECT_REQUIRED_MESSAGE);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const threads = await threadCatalog.listProjectThreads({
|
|
221
|
+
projectRoot: project.cwd,
|
|
222
|
+
limit: 8,
|
|
223
|
+
});
|
|
224
|
+
if (threads.length === 0) {
|
|
225
|
+
await ctx.reply([
|
|
226
|
+
"No saved Codex threads were found for this project yet.",
|
|
227
|
+
`project root: ${project.cwd}`,
|
|
228
|
+
].join("\n"));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const lines = [
|
|
232
|
+
`Saved Codex threads for ${project.name}:`,
|
|
233
|
+
...threads.flatMap((thread, index) => {
|
|
234
|
+
const relativeCwd = path.relative(project.cwd, thread.cwd) || ".";
|
|
235
|
+
const bound = store.getByThreadId(thread.id);
|
|
236
|
+
return [
|
|
237
|
+
`${index + 1}. ${thread.preview}`,
|
|
238
|
+
` id: ${thread.id}`,
|
|
239
|
+
` cwd: ${relativeCwd}`,
|
|
240
|
+
` updated: ${thread.updatedAt}`,
|
|
241
|
+
` source: ${thread.source ?? "unknown"}`,
|
|
242
|
+
...(bound
|
|
243
|
+
? [` bound: ${bound.telegramTopicName ?? bound.messageThreadId ?? bound.sessionKey}`]
|
|
244
|
+
: []),
|
|
245
|
+
];
|
|
246
|
+
}),
|
|
247
|
+
"",
|
|
248
|
+
"Resume one with /thread resume <threadId>",
|
|
249
|
+
];
|
|
250
|
+
await ctx.reply(lines.join("\n"));
|
|
251
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { GrammyError } from "grammy";
|
|
2
|
+
import { sendTypingAction } from "../telegram/delivery.js";
|
|
3
|
+
export async function cleanupMissingTopicBindings(input) {
|
|
4
|
+
const sessions = input.store.listTopicSessions();
|
|
5
|
+
const summary = {
|
|
6
|
+
total: sessions.length,
|
|
7
|
+
checked: 0,
|
|
8
|
+
kept: 0,
|
|
9
|
+
removed: 0,
|
|
10
|
+
skipped: 0,
|
|
11
|
+
failed: 0,
|
|
12
|
+
};
|
|
13
|
+
for (const session of sessions) {
|
|
14
|
+
const chatId = Number(session.chatId);
|
|
15
|
+
const messageThreadId = Number(session.messageThreadId);
|
|
16
|
+
if (!Number.isSafeInteger(chatId) || !Number.isSafeInteger(messageThreadId)) {
|
|
17
|
+
summary.skipped += 1;
|
|
18
|
+
input.logger?.warn("skipped topic binding cleanup for non-numeric telegram identifiers", {
|
|
19
|
+
sessionKey: session.sessionKey,
|
|
20
|
+
chatId: session.chatId,
|
|
21
|
+
messageThreadId: session.messageThreadId,
|
|
22
|
+
});
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
summary.checked += 1;
|
|
26
|
+
try {
|
|
27
|
+
await sendTypingAction(input.bot, {
|
|
28
|
+
chatId,
|
|
29
|
+
messageThreadId,
|
|
30
|
+
}, input.logger);
|
|
31
|
+
summary.kept += 1;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (!isMissingTopicBindingError(error)) {
|
|
35
|
+
summary.failed += 1;
|
|
36
|
+
input.logger?.warn("topic binding cleanup probe failed", {
|
|
37
|
+
sessionKey: session.sessionKey,
|
|
38
|
+
chatId,
|
|
39
|
+
messageThreadId,
|
|
40
|
+
error,
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
input.store.remove(session.sessionKey);
|
|
45
|
+
summary.removed += 1;
|
|
46
|
+
input.logger?.info("removed stale telegram topic binding", {
|
|
47
|
+
sessionKey: session.sessionKey,
|
|
48
|
+
chatId,
|
|
49
|
+
messageThreadId,
|
|
50
|
+
codexThreadId: session.codexThreadId,
|
|
51
|
+
topicName: session.telegramTopicName,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
input.logger?.info("topic binding cleanup finished", summary);
|
|
56
|
+
return summary;
|
|
57
|
+
}
|
|
58
|
+
export function isMissingTopicBindingError(error) {
|
|
59
|
+
const description = describeError(error);
|
|
60
|
+
if (!description)
|
|
61
|
+
return false;
|
|
62
|
+
return [
|
|
63
|
+
"message thread not found",
|
|
64
|
+
"message thread was not found",
|
|
65
|
+
"forum topic not found",
|
|
66
|
+
"topic not found",
|
|
67
|
+
"thread not found",
|
|
68
|
+
"topic deleted",
|
|
69
|
+
"topic_deleted",
|
|
70
|
+
].some((fragment) => description.includes(fragment));
|
|
71
|
+
}
|
|
72
|
+
function describeError(error) {
|
|
73
|
+
if (error instanceof GrammyError) {
|
|
74
|
+
return typeof error.description === "string" ? error.description.toLowerCase() : null;
|
|
75
|
+
}
|
|
76
|
+
if (error instanceof Error) {
|
|
77
|
+
return error.message.toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
return typeof error === "string" ? error.toLowerCase() : null;
|
|
80
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { createReadStream, existsSync, opendirSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
export class CodexSessionCatalog {
|
|
6
|
+
sessionsRoot;
|
|
7
|
+
constructor(input) {
|
|
8
|
+
this.sessionsRoot = input?.sessionsRoot ?? defaultSessionsRoot();
|
|
9
|
+
this.logger = input?.logger;
|
|
10
|
+
}
|
|
11
|
+
logger;
|
|
12
|
+
async listProjectThreads(input) {
|
|
13
|
+
const projectRoot = canonicalizePath(input.projectRoot);
|
|
14
|
+
const limit = Math.max(1, input.limit ?? 8);
|
|
15
|
+
const files = listSessionFiles(this.sessionsRoot)
|
|
16
|
+
.filter((entry) => entry.mtimeMs > 0)
|
|
17
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
18
|
+
const matches = [];
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
const summary = await readSessionSummary(file.path, file.updatedAt);
|
|
21
|
+
if (!summary)
|
|
22
|
+
continue;
|
|
23
|
+
if (!isPathWithinRoot(summary.cwd, projectRoot))
|
|
24
|
+
continue;
|
|
25
|
+
matches.push(summary);
|
|
26
|
+
if (matches.length >= limit)
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
return matches;
|
|
30
|
+
}
|
|
31
|
+
async findProjectThreadById(input) {
|
|
32
|
+
const projectRoot = canonicalizePath(input.projectRoot);
|
|
33
|
+
const threadId = input.threadId.trim();
|
|
34
|
+
if (!threadId)
|
|
35
|
+
return null;
|
|
36
|
+
const files = listSessionFiles(this.sessionsRoot).sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
const summary = await readSessionSummary(file.path, file.updatedAt);
|
|
39
|
+
if (!summary)
|
|
40
|
+
continue;
|
|
41
|
+
if (summary.id !== threadId)
|
|
42
|
+
continue;
|
|
43
|
+
if (!isPathWithinRoot(summary.cwd, projectRoot))
|
|
44
|
+
return null;
|
|
45
|
+
return summary;
|
|
46
|
+
}
|
|
47
|
+
this.logger?.debug("codex thread not found in saved sessions", {
|
|
48
|
+
projectRoot,
|
|
49
|
+
threadId,
|
|
50
|
+
sessionsRoot: this.sessionsRoot,
|
|
51
|
+
});
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function defaultSessionsRoot() {
|
|
56
|
+
const codexHome = process.env.CODEX_HOME?.trim();
|
|
57
|
+
const root = codexHome ? path.resolve(codexHome) : path.join(homedir(), ".codex");
|
|
58
|
+
return path.join(root, "sessions");
|
|
59
|
+
}
|
|
60
|
+
function listSessionFiles(root) {
|
|
61
|
+
if (!existsSync(root))
|
|
62
|
+
return [];
|
|
63
|
+
const files = [];
|
|
64
|
+
const stack = [root];
|
|
65
|
+
while (stack.length > 0) {
|
|
66
|
+
const current = stack.pop();
|
|
67
|
+
if (!current)
|
|
68
|
+
continue;
|
|
69
|
+
let directory;
|
|
70
|
+
try {
|
|
71
|
+
directory = opendirSync(current);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
for (let entry = directory.readSync(); entry != null; entry = directory.readSync()) {
|
|
77
|
+
const resolved = path.join(current, entry.name);
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
stack.push(resolved);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!entry.isFile() || !resolved.endsWith(".jsonl"))
|
|
83
|
+
continue;
|
|
84
|
+
try {
|
|
85
|
+
const stat = statSync(resolved);
|
|
86
|
+
files.push({
|
|
87
|
+
path: resolved,
|
|
88
|
+
updatedAt: stat.mtime.toISOString(),
|
|
89
|
+
mtimeMs: stat.mtimeMs,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
directory.closeSync();
|
|
97
|
+
}
|
|
98
|
+
return files;
|
|
99
|
+
}
|
|
100
|
+
async function readSessionSummary(filePath, updatedAt) {
|
|
101
|
+
const input = createReadStream(filePath, { encoding: "utf8" });
|
|
102
|
+
const reader = createInterface({
|
|
103
|
+
input,
|
|
104
|
+
crlfDelay: Infinity,
|
|
105
|
+
});
|
|
106
|
+
let meta = null;
|
|
107
|
+
let preview = "";
|
|
108
|
+
try {
|
|
109
|
+
for await (const line of reader) {
|
|
110
|
+
const parsed = parseJsonLine(line);
|
|
111
|
+
if (!parsed)
|
|
112
|
+
continue;
|
|
113
|
+
if (!meta) {
|
|
114
|
+
meta = parseSessionMeta(parsed);
|
|
115
|
+
}
|
|
116
|
+
if (!preview) {
|
|
117
|
+
preview = parseUserPreview(parsed);
|
|
118
|
+
}
|
|
119
|
+
if (meta && preview)
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
reader.close();
|
|
125
|
+
input.destroy();
|
|
126
|
+
}
|
|
127
|
+
if (!meta?.id || !meta.cwd)
|
|
128
|
+
return null;
|
|
129
|
+
return {
|
|
130
|
+
id: meta.id,
|
|
131
|
+
cwd: canonicalizePath(meta.cwd),
|
|
132
|
+
createdAt: typeof meta.timestamp === "string" ? meta.timestamp : null,
|
|
133
|
+
updatedAt,
|
|
134
|
+
preview: preview || "(no user message preview)",
|
|
135
|
+
source: typeof meta.source === "string" ? meta.source : null,
|
|
136
|
+
modelProvider: typeof meta.model_provider === "string" ? meta.model_provider : null,
|
|
137
|
+
sessionPath: filePath,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function parseJsonLine(line) {
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(line);
|
|
143
|
+
return isRecord(parsed) ? parsed : null;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function parseSessionMeta(parsed) {
|
|
150
|
+
if (parsed.type !== "session_meta")
|
|
151
|
+
return null;
|
|
152
|
+
const payload = isRecord(parsed.payload) ? parsed.payload : null;
|
|
153
|
+
if (!payload)
|
|
154
|
+
return null;
|
|
155
|
+
if (typeof payload.id !== "string" || typeof payload.cwd !== "string")
|
|
156
|
+
return null;
|
|
157
|
+
return {
|
|
158
|
+
id: payload.id,
|
|
159
|
+
cwd: payload.cwd,
|
|
160
|
+
...(typeof payload.timestamp === "string" ? { timestamp: payload.timestamp } : {}),
|
|
161
|
+
...(typeof payload.source === "string" ? { source: payload.source } : {}),
|
|
162
|
+
...(typeof payload.model_provider === "string" ? { model_provider: payload.model_provider } : {}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function parseUserPreview(parsed) {
|
|
166
|
+
if (parsed.type === "event_msg") {
|
|
167
|
+
const payload = isRecord(parsed.payload) ? parsed.payload : null;
|
|
168
|
+
if (payload?.type === "user_message" && typeof payload.message === "string") {
|
|
169
|
+
return normalizePreview(payload.message);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (parsed.type !== "response_item")
|
|
173
|
+
return "";
|
|
174
|
+
const payload = isRecord(parsed.payload) ? parsed.payload : null;
|
|
175
|
+
if (!payload || payload.type !== "message" || payload.role !== "user")
|
|
176
|
+
return "";
|
|
177
|
+
const content = Array.isArray(payload.content) ? payload.content : [];
|
|
178
|
+
for (const item of content) {
|
|
179
|
+
if (!isRecord(item) || item.type !== "input_text" || typeof item.text !== "string")
|
|
180
|
+
continue;
|
|
181
|
+
const preview = normalizePreview(item.text);
|
|
182
|
+
if (preview)
|
|
183
|
+
return preview;
|
|
184
|
+
}
|
|
185
|
+
return "";
|
|
186
|
+
}
|
|
187
|
+
function normalizePreview(value) {
|
|
188
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
189
|
+
if (!normalized)
|
|
190
|
+
return "";
|
|
191
|
+
if (normalized.startsWith("# AGENTS.md instructions") ||
|
|
192
|
+
normalized.startsWith("<environment_context>") ||
|
|
193
|
+
normalized.startsWith("<app-context>") ||
|
|
194
|
+
normalized.startsWith("<permissions instructions>")) {
|
|
195
|
+
return "";
|
|
196
|
+
}
|
|
197
|
+
if (normalized.length <= 96)
|
|
198
|
+
return normalized;
|
|
199
|
+
return `${normalized.slice(0, 93)}...`;
|
|
200
|
+
}
|
|
201
|
+
function isPathWithinRoot(candidate, root) {
|
|
202
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
203
|
+
}
|
|
204
|
+
function canonicalizePath(value) {
|
|
205
|
+
const resolved = path.resolve(value);
|
|
206
|
+
try {
|
|
207
|
+
return realpathSync.native(resolved);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return resolved;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function isRecord(value) {
|
|
214
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
215
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -14,7 +14,6 @@ export function buildConfig(input) {
|
|
|
14
14
|
telegramBotToken: input.telegramBotToken,
|
|
15
15
|
defaultCwd,
|
|
16
16
|
defaultModel: input.defaultModel?.trim() || "gpt-5.4",
|
|
17
|
-
dbPath: path.resolve(input.dbPath),
|
|
18
17
|
codexBin: input.codexBin,
|
|
19
18
|
updateIntervalMs: input.updateIntervalMs ?? 700,
|
|
20
19
|
};
|
package/dist/runtime/appPaths.js
CHANGED
|
@@ -3,7 +3,10 @@ import path from "node:path";
|
|
|
3
3
|
export function getAppHome() {
|
|
4
4
|
return path.join(homedir(), ".telecodex");
|
|
5
5
|
}
|
|
6
|
-
export function
|
|
6
|
+
export function getStateDir() {
|
|
7
|
+
return path.join(getAppHome(), "state");
|
|
8
|
+
}
|
|
9
|
+
export function getLegacyStateDbPath() {
|
|
7
10
|
return path.join(getAppHome(), "state.sqlite");
|
|
8
11
|
}
|
|
9
12
|
export function getLogsDir() {
|
|
@@ -5,19 +5,24 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { Bot, GrammyError, HttpError } from "grammy";
|
|
7
7
|
import { buildConfig } from "../config.js";
|
|
8
|
-
import {
|
|
8
|
+
import { FileStateStorage } from "../store/fileState.js";
|
|
9
|
+
import { migrateLegacySqliteState } from "../store/legacyMigration.js";
|
|
9
10
|
import { ProjectStore } from "../store/projects.js";
|
|
10
11
|
import { BINDING_CODE_MAX_ATTEMPTS, SessionStore } from "../store/sessions.js";
|
|
11
|
-
import {
|
|
12
|
+
import { getLegacyStateDbPath, getStateDir } from "./appPaths.js";
|
|
12
13
|
import { generateBindingCode } from "./bindingCodes.js";
|
|
13
14
|
import { PLAINTEXT_TOKEN_FALLBACK_ENV, SecretStore, } from "./secrets.js";
|
|
14
15
|
const MAC_CODEX_BIN = "/Applications/Codex.app/Contents/Resources/codex";
|
|
15
16
|
export async function bootstrapRuntime() {
|
|
16
17
|
intro("telecodex");
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
const stateDir = getStateDir();
|
|
19
|
+
const storage = new FileStateStorage(stateDir);
|
|
20
|
+
migrateLegacySqliteState({
|
|
21
|
+
storage,
|
|
22
|
+
legacyDbPath: getLegacyStateDbPath(),
|
|
23
|
+
});
|
|
24
|
+
const store = new SessionStore(storage);
|
|
25
|
+
const projects = new ProjectStore(storage);
|
|
21
26
|
const secrets = new SecretStore(store, {
|
|
22
27
|
allowPlaintextFallback: process.env[PLAINTEXT_TOKEN_FALLBACK_ENV] === "1",
|
|
23
28
|
});
|
|
@@ -30,7 +35,6 @@ export async function bootstrapRuntime() {
|
|
|
30
35
|
const config = buildConfig({
|
|
31
36
|
telegramBotToken: token,
|
|
32
37
|
defaultCwd: process.cwd(),
|
|
33
|
-
dbPath,
|
|
34
38
|
codexBin,
|
|
35
39
|
});
|
|
36
40
|
let bootstrapCode = null;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { run } from "@grammyjs/runner";
|
|
2
2
|
import { createBot } from "../bot/createBot.js";
|
|
3
|
+
import { CodexSessionCatalog } from "../codex/sessionCatalog.js";
|
|
3
4
|
import { CodexSdkRuntime } from "../codex/sdkRuntime.js";
|
|
4
5
|
import { bootstrapRuntime } from "./bootstrap.js";
|
|
5
6
|
import { acquireInstanceLock } from "./instanceLock.js";
|
|
@@ -29,11 +30,15 @@ export async function startTelecodex() {
|
|
|
29
30
|
logger: logger.child("codex-sdk"),
|
|
30
31
|
...(configOverrides ? { configOverrides } : {}),
|
|
31
32
|
});
|
|
33
|
+
const threadCatalog = new CodexSessionCatalog({
|
|
34
|
+
logger: logger.child("codex-sessions"),
|
|
35
|
+
});
|
|
32
36
|
const bot = createBot({
|
|
33
37
|
config,
|
|
34
38
|
store,
|
|
35
39
|
projects,
|
|
36
40
|
codex,
|
|
41
|
+
threadCatalog,
|
|
37
42
|
bootstrapCode,
|
|
38
43
|
logger: logger.child("bot"),
|
|
39
44
|
onAdminBound: () => {
|