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.
- package/README.md +41 -4
- package/dist/bot/auth.js +62 -13
- package/dist/bot/commandSupport.js +23 -8
- package/dist/bot/createBot.js +16 -3
- package/dist/bot/handlers/operationalHandlers.js +52 -0
- package/dist/bot/handlers/projectHandlers.js +67 -8
- package/dist/bot/handlers/sessionConfigHandlers.js +27 -6
- package/dist/bot/topicCleanup.js +80 -0
- package/dist/cli.js +0 -0
- package/dist/codex/sessionCatalog.js +215 -0
- package/dist/config.js +0 -1
- package/dist/runtime/appPaths.js +4 -1
- package/dist/runtime/bindingCodes.js +5 -0
- package/dist/runtime/bootstrap.js +26 -17
- 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 +240 -207
- package/dist/telegram/renderer.js +40 -3
- package/package.json +2 -2
- package/dist/store/db.js +0 -267
|
@@ -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
|
+
}
|
package/dist/cli.js
CHANGED
|
File without changes
|
|
@@ -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() {
|
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
import { cancel, confirm, intro, isCancel, note, password, spinner, text, } from "@clack/prompts";
|
|
2
2
|
import clipboard from "clipboardy";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { randomBytes } from "node:crypto";
|
|
5
4
|
import { existsSync } from "node:fs";
|
|
6
5
|
import path from "node:path";
|
|
7
6
|
import { Bot, GrammyError, HttpError } from "grammy";
|
|
8
7
|
import { buildConfig } from "../config.js";
|
|
9
|
-
import {
|
|
8
|
+
import { FileStateStorage } from "../store/fileState.js";
|
|
9
|
+
import { migrateLegacySqliteState } from "../store/legacyMigration.js";
|
|
10
10
|
import { ProjectStore } from "../store/projects.js";
|
|
11
|
-
import { SessionStore } from "../store/sessions.js";
|
|
12
|
-
import {
|
|
11
|
+
import { BINDING_CODE_MAX_ATTEMPTS, SessionStore } from "../store/sessions.js";
|
|
12
|
+
import { getLegacyStateDbPath, getStateDir } from "./appPaths.js";
|
|
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,24 +35,31 @@ 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;
|
|
37
41
|
if (store.getAuthorizedUserId() == null) {
|
|
38
|
-
|
|
39
|
-
if (!
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
let binding = store.getBindingCodeState();
|
|
43
|
+
if (!binding || binding.mode !== "bootstrap") {
|
|
44
|
+
binding = store.issueBindingCode({
|
|
45
|
+
code: generateBindingCode("bootstrap"),
|
|
46
|
+
mode: "bootstrap",
|
|
47
|
+
});
|
|
42
48
|
}
|
|
49
|
+
bootstrapCode = binding.code;
|
|
50
|
+
}
|
|
51
|
+
else if (store.getBindingCodeState()?.mode === "bootstrap") {
|
|
52
|
+
store.clearBindingCode();
|
|
43
53
|
}
|
|
44
54
|
if (bootstrapCode) {
|
|
55
|
+
const binding = store.getBindingCodeState();
|
|
45
56
|
const copied = await copyBootstrapCode(bootstrapCode);
|
|
46
57
|
note([
|
|
47
58
|
`Bot: ${botUsername ? `@${botUsername}` : "unknown"}`,
|
|
48
59
|
`Workspace: ${config.defaultCwd}`,
|
|
49
60
|
copied ? "Binding code copied to the clipboard." : "Failed to copy the binding code. Copy it manually.",
|
|
50
|
-
|
|
61
|
+
`Binding code expires at: ${binding?.expiresAt ?? "unknown"}`,
|
|
62
|
+
`Max failed attempts: ${binding?.maxAttempts ?? BINDING_CODE_MAX_ATTEMPTS}`,
|
|
51
63
|
"",
|
|
52
64
|
bootstrapCode,
|
|
53
65
|
].join("\n"), "Admin Binding");
|
|
@@ -208,6 +220,3 @@ function exitCancelled() {
|
|
|
208
220
|
cancel("Cancelled");
|
|
209
221
|
process.exit(0);
|
|
210
222
|
}
|
|
211
|
-
function generateBootstrapCode() {
|
|
212
|
-
return `bind-${randomBytes(6).toString("base64url")}`;
|
|
213
|
-
}
|
|
@@ -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: () => {
|