appback-remoteagent 0.13.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.
- package/.env.example +39 -0
- package/LICENSE +21 -0
- package/README.md +371 -0
- package/bin/remoteagent.js +2 -0
- package/dist/adapters/claude-adapter.js +78 -0
- package/dist/adapters/codex-adapter.js +241 -0
- package/dist/adapters/provider-adapter.js +1 -0
- package/dist/adapters/shell-adapter.js +44 -0
- package/dist/adapters/windows-shell.js +111 -0
- package/dist/bot.js +2135 -0
- package/dist/config.js +170 -0
- package/dist/index.js +534 -0
- package/dist/secret-helper.js +24 -0
- package/dist/services/agent-memory-service.js +737 -0
- package/dist/services/bot-management-service.js +626 -0
- package/dist/services/bridge-service.js +807 -0
- package/dist/services/local-ui-service.js +533 -0
- package/dist/services/provider-setup-service.js +284 -0
- package/dist/services/remote-shell-service.js +97 -0
- package/dist/store/file-store.js +690 -0
- package/dist/telegram-fetch.js +85 -0
- package/dist/types.js +1 -0
- package/docs/ARCHITECTURE.md +170 -0
- package/docs/COKACDIR_NOTES.md +79 -0
- package/docs/ERROR_NORMALIZATION.md +46 -0
- package/docs/MINI_APP.md +112 -0
- package/docs/MVP.md +108 -0
- package/docs/OPERATIONS.md +181 -0
- package/docs/RELEASING.md +87 -0
- package/docs/SESSION_DIRECTORY_PLAN.md +506 -0
- package/package.json +47 -0
- package/scripts/bump-version.sh +23 -0
- package/scripts/finish-claude-login.sh +48 -0
- package/scripts/install-claude.sh +6 -0
- package/scripts/install-codex.sh +8 -0
- package/scripts/install.ps1 +51 -0
- package/scripts/install.sh +101 -0
- package/scripts/mock-adapter.sh +7 -0
- package/scripts/restart-after-bot-op.sh +118 -0
- package/scripts/selftest-telegram-update.mjs +359 -0
- package/scripts/start-claude-login.sh +4 -0
- package/scripts/start.ps1 +39 -0
- package/scripts/start.sh +54 -0
- package/scripts/stop.ps1 +40 -0
- package/scripts/stop.sh +39 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import fsSync from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
const MAX_CONTEXT_CHARS = 2500;
|
|
6
|
+
export class AgentMemoryService {
|
|
7
|
+
dataDir;
|
|
8
|
+
rootDir;
|
|
9
|
+
artifactsPath;
|
|
10
|
+
secretsPath;
|
|
11
|
+
docsPath;
|
|
12
|
+
telegramUploadsDir;
|
|
13
|
+
constructor(dataDir) {
|
|
14
|
+
this.dataDir = dataDir;
|
|
15
|
+
this.rootDir = path.join(dataDir, "managed");
|
|
16
|
+
this.artifactsPath = path.join(this.rootDir, "artifacts.json");
|
|
17
|
+
this.secretsPath = path.join(this.rootDir, "secrets.json");
|
|
18
|
+
this.docsPath = path.join(this.rootDir, "docs-index.json");
|
|
19
|
+
this.telegramUploadsDir = path.join(dataDir, "uploads", "telegram");
|
|
20
|
+
}
|
|
21
|
+
async recordInstruction(session, instruction) {
|
|
22
|
+
const directive = this.extractInstructionDirective(instruction);
|
|
23
|
+
const mode = directive?.kind ?? "message";
|
|
24
|
+
const normalizedInstruction = (directive?.instruction ?? instruction).trim();
|
|
25
|
+
if (!normalizedInstruction) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const dir = this.sessionDir(session);
|
|
29
|
+
await fs.mkdir(dir, { recursive: true });
|
|
30
|
+
const currentPath = path.join(dir, "current.md");
|
|
31
|
+
const now = new Date().toISOString();
|
|
32
|
+
const current = await fs.readFile(currentPath, "utf8").catch(() => "");
|
|
33
|
+
if (current.trim() && mode === "new") {
|
|
34
|
+
await this.appendHistory(session, {
|
|
35
|
+
type: "archived",
|
|
36
|
+
at: now,
|
|
37
|
+
reason: "new-instruction",
|
|
38
|
+
text: this.truncate(current, 4000),
|
|
39
|
+
});
|
|
40
|
+
await this.resetAttempts(session);
|
|
41
|
+
}
|
|
42
|
+
const next = [
|
|
43
|
+
`# Session State`,
|
|
44
|
+
``,
|
|
45
|
+
`session: ${session.publicId}`,
|
|
46
|
+
`updatedAt: ${now}`,
|
|
47
|
+
``,
|
|
48
|
+
`## Latest User Instruction`,
|
|
49
|
+
normalizedInstruction.trim(),
|
|
50
|
+
``,
|
|
51
|
+
`## Harness Rule`,
|
|
52
|
+
`RemoteAgent records this as session state. It must not block provider execution because a TODO is missing.`,
|
|
53
|
+
`Use this state to avoid repeating completed work, reverting prior changes, or losing the current workspace after context compaction.`,
|
|
54
|
+
``,
|
|
55
|
+
].join("\n");
|
|
56
|
+
await fs.writeFile(currentPath, next, "utf8");
|
|
57
|
+
await this.appendHistory(session, { type: "instruction", at: now, mode, text: normalizedInstruction.trim() });
|
|
58
|
+
}
|
|
59
|
+
async formatSessionState(session) {
|
|
60
|
+
const current = await this.currentTaskText(session);
|
|
61
|
+
const todo = await this.readTodo(session);
|
|
62
|
+
const active = this.activeTodoItems(todo);
|
|
63
|
+
const history = await this.readRecentHistory(session, 6);
|
|
64
|
+
return [
|
|
65
|
+
`Session state for ${session.publicId}`,
|
|
66
|
+
"",
|
|
67
|
+
current.trim() ? `Current note:\n${this.summarizeCurrentTask(current)}` : "Current note: none",
|
|
68
|
+
"",
|
|
69
|
+
todo.items.length > 0 ? `Legacy TODO notes:\n${this.formatTodoSummary(todo, true)}` : undefined,
|
|
70
|
+
active.length > 0 ? `legacyActiveTodo: yes` : undefined,
|
|
71
|
+
history.length > 0 ? ["Recent history:", ...history.map((entry) => `- ${entry}`)].join("\n") : undefined,
|
|
72
|
+
].filter(Boolean).join("\n");
|
|
73
|
+
}
|
|
74
|
+
async clearSessionState(session, summary) {
|
|
75
|
+
const dir = this.sessionDir(session);
|
|
76
|
+
const currentPath = path.join(dir, "current.md");
|
|
77
|
+
const current = await fs.readFile(currentPath, "utf8").catch(() => "");
|
|
78
|
+
const todo = await this.readTodo(session);
|
|
79
|
+
const now = new Date().toISOString();
|
|
80
|
+
if (todo.items.length > 0) {
|
|
81
|
+
await this.writeTodo(session, {
|
|
82
|
+
...todo,
|
|
83
|
+
updatedAt: now,
|
|
84
|
+
items: todo.items.map((item) => this.isActiveTodo(item)
|
|
85
|
+
? { ...item, status: "done", updatedAt: now, note: this.truncate(summary, 500) }
|
|
86
|
+
: item),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
await this.appendHistory(session, {
|
|
90
|
+
type: "completed",
|
|
91
|
+
at: now,
|
|
92
|
+
summary: this.truncate(summary, 2000),
|
|
93
|
+
text: this.truncate(current, 4000),
|
|
94
|
+
});
|
|
95
|
+
await fs.rm(currentPath, { force: true }).catch(() => undefined);
|
|
96
|
+
await this.resetAttempts(session);
|
|
97
|
+
}
|
|
98
|
+
async completeTask(session, summary) {
|
|
99
|
+
await this.clearSessionState(session, summary);
|
|
100
|
+
}
|
|
101
|
+
async addSessionNote(session, note) {
|
|
102
|
+
const normalized = note.trim();
|
|
103
|
+
if (!normalized) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const dir = this.sessionDir(session);
|
|
107
|
+
await fs.mkdir(dir, { recursive: true });
|
|
108
|
+
const now = new Date().toISOString();
|
|
109
|
+
await this.appendHistory(session, { type: "note", at: now, text: this.truncate(normalized, 2000) });
|
|
110
|
+
const currentPath = path.join(dir, "current.md");
|
|
111
|
+
const current = await fs.readFile(currentPath, "utf8").catch(() => "");
|
|
112
|
+
const next = [
|
|
113
|
+
current.trim() || [
|
|
114
|
+
"# Session State",
|
|
115
|
+
"",
|
|
116
|
+
`session: ${session.publicId}`,
|
|
117
|
+
`updatedAt: ${now}`,
|
|
118
|
+
].join("\n"),
|
|
119
|
+
"",
|
|
120
|
+
"## Operator Note",
|
|
121
|
+
normalized,
|
|
122
|
+
"",
|
|
123
|
+
].join("\n");
|
|
124
|
+
await fs.writeFile(currentPath, next, "utf8");
|
|
125
|
+
}
|
|
126
|
+
async recordProgress(session, text) {
|
|
127
|
+
const attempts = await this.readAttempts(session);
|
|
128
|
+
const signature = this.progressSignature(text);
|
|
129
|
+
const now = new Date().toISOString();
|
|
130
|
+
const previous = attempts.progress[signature];
|
|
131
|
+
const count = (previous?.count ?? 0) + 1;
|
|
132
|
+
attempts.progress[signature] = {
|
|
133
|
+
count,
|
|
134
|
+
lastAt: now,
|
|
135
|
+
sample: this.truncate(text, 500),
|
|
136
|
+
};
|
|
137
|
+
await this.writeAttempts(session, attempts);
|
|
138
|
+
await this.appendHistory(session, { type: "progress", at: now, count, signature, text: this.truncate(text, 1000) });
|
|
139
|
+
if (count >= 3) {
|
|
140
|
+
await this.markActiveTodoBlocked(session, `Repeated similar progress report ${count} times.`);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
await this.touchActiveTodo(session);
|
|
144
|
+
}
|
|
145
|
+
return { count, repeated: count >= 3 };
|
|
146
|
+
}
|
|
147
|
+
async recordArtifact(input) {
|
|
148
|
+
const artifacts = await this.readJson(this.artifactsPath, []);
|
|
149
|
+
const stat = await fs.stat(input.filePath).catch(() => undefined);
|
|
150
|
+
const existing = artifacts.find((artifact) => artifact.path === input.filePath);
|
|
151
|
+
if (existing) {
|
|
152
|
+
return existing;
|
|
153
|
+
}
|
|
154
|
+
const artifact = {
|
|
155
|
+
id: randomUUID().slice(0, 8),
|
|
156
|
+
sessionId: input.session?.sessionId,
|
|
157
|
+
sessionPublicId: input.session?.publicId,
|
|
158
|
+
botId: input.botId,
|
|
159
|
+
chatId: input.chatId,
|
|
160
|
+
kind: input.kind,
|
|
161
|
+
path: input.filePath,
|
|
162
|
+
fileName: input.fileName,
|
|
163
|
+
mimeType: input.mimeType,
|
|
164
|
+
size: stat?.size,
|
|
165
|
+
createdAt: new Date().toISOString(),
|
|
166
|
+
};
|
|
167
|
+
artifacts.push(artifact);
|
|
168
|
+
await this.writeJson(this.artifactsPath, artifacts);
|
|
169
|
+
return artifact;
|
|
170
|
+
}
|
|
171
|
+
async listArtifacts(session, limit = 20) {
|
|
172
|
+
const artifacts = await this.readJson(this.artifactsPath, []);
|
|
173
|
+
const filtered = session
|
|
174
|
+
? artifacts.filter((artifact) => artifact.sessionId === session.sessionId || artifact.sessionPublicId === session.publicId)
|
|
175
|
+
: artifacts;
|
|
176
|
+
const recent = filtered.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit);
|
|
177
|
+
if (recent.length === 0) {
|
|
178
|
+
return "No artifacts are indexed yet.";
|
|
179
|
+
}
|
|
180
|
+
return [
|
|
181
|
+
`Artifacts (${recent.length}/${filtered.length})`,
|
|
182
|
+
...recent.map((artifact, index) => [
|
|
183
|
+
`${index + 1}. ${artifact.id} ${artifact.kind}`,
|
|
184
|
+
` file: ${artifact.fileName ?? path.basename(artifact.path)}`,
|
|
185
|
+
` session: ${artifact.sessionPublicId ?? "unknown"}`,
|
|
186
|
+
` size: ${artifact.size ?? "unknown"} bytes`,
|
|
187
|
+
` path: ${artifact.path}`,
|
|
188
|
+
].join("\n")),
|
|
189
|
+
].join("\n");
|
|
190
|
+
}
|
|
191
|
+
async cleanupArtifacts(days) {
|
|
192
|
+
const artifacts = await this.readJson(this.artifactsPath, []);
|
|
193
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
194
|
+
const kept = [];
|
|
195
|
+
const keptPaths = new Set();
|
|
196
|
+
let removedFiles = 0;
|
|
197
|
+
let removedRecords = 0;
|
|
198
|
+
for (const artifact of artifacts) {
|
|
199
|
+
const created = Date.parse(artifact.createdAt);
|
|
200
|
+
if (artifact.keep || !Number.isFinite(created) || created >= cutoff) {
|
|
201
|
+
kept.push(artifact);
|
|
202
|
+
keptPaths.add(path.resolve(artifact.path));
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
await fs.rm(artifact.path, { force: true }).then(() => {
|
|
206
|
+
removedFiles += 1;
|
|
207
|
+
}).catch(() => undefined);
|
|
208
|
+
removedRecords += 1;
|
|
209
|
+
}
|
|
210
|
+
await this.writeJson(this.artifactsPath, kept);
|
|
211
|
+
const orphanResult = await this.cleanupOrphanTelegramUploads(cutoff, keptPaths);
|
|
212
|
+
return [
|
|
213
|
+
"Artifact cleanup finished.",
|
|
214
|
+
`removedRecords=${removedRecords}`,
|
|
215
|
+
`removedIndexedFiles=${removedFiles}`,
|
|
216
|
+
`removedOrphanFiles=${orphanResult.removedFiles}`,
|
|
217
|
+
`scannedUploadFiles=${orphanResult.scannedFiles}`,
|
|
218
|
+
`kept=${kept.length}`,
|
|
219
|
+
].join(" ");
|
|
220
|
+
}
|
|
221
|
+
async setSecret(key, value) {
|
|
222
|
+
this.assertSecretKey(key);
|
|
223
|
+
const secrets = await this.readSecrets();
|
|
224
|
+
const now = new Date().toISOString();
|
|
225
|
+
secrets[key] = {
|
|
226
|
+
key,
|
|
227
|
+
value,
|
|
228
|
+
createdAt: secrets[key]?.createdAt ?? now,
|
|
229
|
+
updatedAt: now,
|
|
230
|
+
};
|
|
231
|
+
await this.writeSecrets(secrets);
|
|
232
|
+
}
|
|
233
|
+
async removeSecret(key) {
|
|
234
|
+
this.assertSecretKey(key);
|
|
235
|
+
const secrets = await this.readSecrets();
|
|
236
|
+
const existed = Boolean(secrets[key]);
|
|
237
|
+
delete secrets[key];
|
|
238
|
+
await this.writeSecrets(secrets);
|
|
239
|
+
return existed;
|
|
240
|
+
}
|
|
241
|
+
async getSecret(key) {
|
|
242
|
+
this.assertSecretKey(key);
|
|
243
|
+
const secrets = await this.readSecrets();
|
|
244
|
+
return secrets[key]?.value;
|
|
245
|
+
}
|
|
246
|
+
async listSecrets() {
|
|
247
|
+
const secrets = await this.readSecrets();
|
|
248
|
+
const records = Object.values(secrets).sort((a, b) => a.key.localeCompare(b.key));
|
|
249
|
+
if (records.length === 0) {
|
|
250
|
+
return "No secrets are stored.";
|
|
251
|
+
}
|
|
252
|
+
return [
|
|
253
|
+
`Secrets (${records.length})`,
|
|
254
|
+
...records.map((secret) => `- ${secret.key} updatedAt=${secret.updatedAt}`),
|
|
255
|
+
"",
|
|
256
|
+
"Values are not shown. Providers may read them through REMOTEAGENT_SECRET_BIN.",
|
|
257
|
+
].join("\n");
|
|
258
|
+
}
|
|
259
|
+
async pinDocument(keyword, targetPath, note) {
|
|
260
|
+
const normalized = this.normalizeKeyword(keyword);
|
|
261
|
+
const docs = await this.readDocs();
|
|
262
|
+
const now = new Date().toISOString();
|
|
263
|
+
docs[normalized] = {
|
|
264
|
+
keyword: normalized,
|
|
265
|
+
targetPath,
|
|
266
|
+
note,
|
|
267
|
+
createdAt: docs[normalized]?.createdAt ?? now,
|
|
268
|
+
updatedAt: now,
|
|
269
|
+
};
|
|
270
|
+
await this.writeJson(this.docsPath, docs);
|
|
271
|
+
}
|
|
272
|
+
async removeDocumentPin(keyword) {
|
|
273
|
+
const normalized = this.normalizeKeyword(keyword);
|
|
274
|
+
const docs = await this.readDocs();
|
|
275
|
+
const existed = Boolean(docs[normalized]);
|
|
276
|
+
delete docs[normalized];
|
|
277
|
+
await this.writeJson(this.docsPath, docs);
|
|
278
|
+
return existed;
|
|
279
|
+
}
|
|
280
|
+
async findDocuments(keyword) {
|
|
281
|
+
const normalized = this.normalizeKeyword(keyword);
|
|
282
|
+
const docs = await this.readDocs();
|
|
283
|
+
const matches = Object.values(docs)
|
|
284
|
+
.filter((doc) => doc.keyword.includes(normalized) || normalized.includes(doc.keyword))
|
|
285
|
+
.sort((a, b) => a.keyword.localeCompare(b.keyword));
|
|
286
|
+
if (matches.length === 0) {
|
|
287
|
+
return `No document pins matched: ${keyword}`;
|
|
288
|
+
}
|
|
289
|
+
return [
|
|
290
|
+
`Document pins for "${keyword}" (${matches.length})`,
|
|
291
|
+
...matches.map((doc) => `- ${doc.keyword}: ${doc.targetPath}${doc.note ? ` (${doc.note})` : ""}`),
|
|
292
|
+
].join("\n");
|
|
293
|
+
}
|
|
294
|
+
async listDocuments() {
|
|
295
|
+
const docs = await this.readDocs();
|
|
296
|
+
const records = Object.values(docs).sort((a, b) => a.keyword.localeCompare(b.keyword));
|
|
297
|
+
if (records.length === 0) {
|
|
298
|
+
return "No document pins are configured.";
|
|
299
|
+
}
|
|
300
|
+
return [
|
|
301
|
+
`Document pins (${records.length})`,
|
|
302
|
+
...records.map((doc) => `- ${doc.keyword}: ${doc.targetPath}${doc.note ? ` (${doc.note})` : ""}`),
|
|
303
|
+
].join("\n");
|
|
304
|
+
}
|
|
305
|
+
async formatProviderContext(session) {
|
|
306
|
+
const current = await fs.readFile(path.join(this.sessionDir(session), "current.md"), "utf8").catch(() => "");
|
|
307
|
+
const todo = await this.readTodo(session);
|
|
308
|
+
const history = await this.readRecentHistory(session, 5);
|
|
309
|
+
const docs = Object.values(await this.readDocs()).slice(0, 30);
|
|
310
|
+
const secrets = Object.keys(await this.readSecrets()).sort();
|
|
311
|
+
const artifacts = (await this.readJson(this.artifactsPath, []))
|
|
312
|
+
.filter((artifact) => artifact.sessionId === session.sessionId || artifact.sessionPublicId === session.publicId)
|
|
313
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
314
|
+
.slice(0, 8);
|
|
315
|
+
const lines = [
|
|
316
|
+
"RemoteAgent managed context:",
|
|
317
|
+
[
|
|
318
|
+
"Session work locations:",
|
|
319
|
+
`- workspace: ${session.workspace}`,
|
|
320
|
+
`- memory: ${this.sessionDir(session)}`,
|
|
321
|
+
"- docs: use managed document pins first, then inspect docs/ under the workspace when relevant.",
|
|
322
|
+
].join("\n"),
|
|
323
|
+
[
|
|
324
|
+
"Harness execution rules:",
|
|
325
|
+
"- Treat the user message as the active instruction unless the user explicitly says otherwise.",
|
|
326
|
+
"- RemoteAgent state is guidance only; never refuse work because a TODO is missing.",
|
|
327
|
+
"- Use the workspace/memory/docs pointers before searching broadly.",
|
|
328
|
+
"- Do not claim external delivery, dashboard access, deployment, or file transfer without RemoteAgent-confirmed evidence.",
|
|
329
|
+
"- If the same action or progress repeats 3 times, stop, mark the blocker, and report the exact blocker.",
|
|
330
|
+
"- A final report must include concrete evidence: changed files, relevant line references, git diff/status, command output, or log path.",
|
|
331
|
+
].join("\n"),
|
|
332
|
+
todo.items.length > 0
|
|
333
|
+
? ["Legacy task notes:", this.formatTodoSummary(todo, true, true)].join("\n")
|
|
334
|
+
: undefined,
|
|
335
|
+
current.trim() ? ["Current task note:", this.truncate(current.trim(), 700)].join("\n") : undefined,
|
|
336
|
+
history.length > 0 ? ["Recent session history:", ...history.map((entry) => `- ${entry}`)].join("\n") : undefined,
|
|
337
|
+
docs.length > 0
|
|
338
|
+
? ["Document index:", ...docs.map((doc) => `- ${doc.keyword}: ${doc.targetPath}${doc.note ? ` (${doc.note})` : ""}`)].join("\n")
|
|
339
|
+
: undefined,
|
|
340
|
+
secrets.length > 0
|
|
341
|
+
? ["Secret keys available through `node \"$REMOTEAGENT_SECRET_BIN\" get <KEY>`:", ...secrets.map((key) => `- ${key}`)].join("\n")
|
|
342
|
+
: undefined,
|
|
343
|
+
artifacts.length > 0
|
|
344
|
+
? ["Recent session artifacts:", ...artifacts.map((artifact) => `- ${artifact.id} ${artifact.kind}: ${artifact.path}`)].join("\n")
|
|
345
|
+
: undefined,
|
|
346
|
+
].filter(Boolean).join("\n\n");
|
|
347
|
+
return this.truncate(lines, MAX_CONTEXT_CHARS);
|
|
348
|
+
}
|
|
349
|
+
async currentTaskText(session) {
|
|
350
|
+
return fs.readFile(path.join(this.sessionDir(session), "current.md"), "utf8").catch(() => "");
|
|
351
|
+
}
|
|
352
|
+
async readRecentHistory(session, limit) {
|
|
353
|
+
const historyPath = path.join(this.sessionDir(session), "history.ndjson");
|
|
354
|
+
const raw = await fs.readFile(historyPath, "utf8").catch(() => "");
|
|
355
|
+
return raw
|
|
356
|
+
.trim()
|
|
357
|
+
.split("\n")
|
|
358
|
+
.filter(Boolean)
|
|
359
|
+
.slice(-limit)
|
|
360
|
+
.map((line) => {
|
|
361
|
+
try {
|
|
362
|
+
const entry = JSON.parse(line);
|
|
363
|
+
const kind = entry.mode ? `${entry.type ?? "event"}:${entry.mode}` : entry.type ?? "event";
|
|
364
|
+
const body = entry.summary ?? entry.text ?? entry.reason ?? "";
|
|
365
|
+
return `${entry.at ?? "unknown"} ${kind}: ${this.truncate(body.replace(/\s+/g, " ").trim(), 220)}`;
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return this.truncate(line.replace(/\s+/g, " ").trim(), 220);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
todoPath(session) {
|
|
373
|
+
return path.join(this.sessionDir(session), "todo.json");
|
|
374
|
+
}
|
|
375
|
+
async readTodo(session) {
|
|
376
|
+
const todo = await this.readJson(this.todoPath(session), { createdAt: "", updatedAt: "", items: [] });
|
|
377
|
+
const normalized = this.normalizeTodoState(todo);
|
|
378
|
+
if (JSON.stringify(normalized) !== JSON.stringify(todo)) {
|
|
379
|
+
await this.writeJson(this.todoPath(session), normalized);
|
|
380
|
+
}
|
|
381
|
+
return normalized;
|
|
382
|
+
}
|
|
383
|
+
async writeTodo(session, todo) {
|
|
384
|
+
await this.writeJson(this.todoPath(session), {
|
|
385
|
+
...todo,
|
|
386
|
+
items: this.ensureActiveTodo(todo.items),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
extractTodoItems(session, text, now) {
|
|
390
|
+
if (!this.looksActionableInstruction(text)) {
|
|
391
|
+
return [];
|
|
392
|
+
}
|
|
393
|
+
const listed = this.extractExplicitTodoLines(text);
|
|
394
|
+
const source = listed.length > 1 ? listed : [text.trim()];
|
|
395
|
+
return source.slice(0, 20).map((item, index) => this.createTodoItem(session, item, index, now));
|
|
396
|
+
}
|
|
397
|
+
extractExplicitTodoLines(text) {
|
|
398
|
+
const shouldSplit = /쪼개|나눠서|단계별|순서대로|체크리스트|todo/i.test(text);
|
|
399
|
+
const lines = text
|
|
400
|
+
.split(/\r?\n/)
|
|
401
|
+
.map((line) => line.trim())
|
|
402
|
+
.filter(Boolean);
|
|
403
|
+
const listed = lines
|
|
404
|
+
.map((line) => {
|
|
405
|
+
const match = /^([-*]|\d+[.)])\s+(.+)$/.exec(line);
|
|
406
|
+
return match?.[2]?.trim();
|
|
407
|
+
})
|
|
408
|
+
.filter((line) => Boolean(line && line.length >= 4 && this.looksActionableInstruction(line)));
|
|
409
|
+
if (listed.length > 1) {
|
|
410
|
+
return listed;
|
|
411
|
+
}
|
|
412
|
+
if (!shouldSplit) {
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
return lines
|
|
416
|
+
.map((line) => line.replace(/^[-*]\s+/, "").replace(/^\d+[.)]\s+/, "").trim())
|
|
417
|
+
.filter((line) => line.length >= 4 && this.looksActionableInstruction(line));
|
|
418
|
+
}
|
|
419
|
+
createTodoItem(session, text, index, now) {
|
|
420
|
+
const purpose = this.truncate(this.normalizeTodoPurpose(text), 500);
|
|
421
|
+
const sessionMemory = this.sessionDir(session);
|
|
422
|
+
return {
|
|
423
|
+
id: `T${String(index + 1).padStart(3, "0")}`,
|
|
424
|
+
text: purpose,
|
|
425
|
+
status: index === 0 ? "in_progress" : "pending",
|
|
426
|
+
createdAt: now,
|
|
427
|
+
updatedAt: now,
|
|
428
|
+
attempts: 0,
|
|
429
|
+
action: this.truncate(text.trim(), 700),
|
|
430
|
+
workspace: index === 0 ? session.workspace : undefined,
|
|
431
|
+
memoryPath: index === 0 ? sessionMemory : undefined,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
normalizeTodoPurpose(text) {
|
|
435
|
+
const compact = text.replace(/\s+/g, " ").trim();
|
|
436
|
+
const sentence = compact.split(/(?<=[.!?。!?])\s+/u)[0] || compact;
|
|
437
|
+
return sentence;
|
|
438
|
+
}
|
|
439
|
+
looksActionableInstruction(text) {
|
|
440
|
+
const normalized = text.trim();
|
|
441
|
+
if (!normalized) {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
if (/너는\s+.+담당|담당이야|역할은|프로젝트는/i.test(normalized) && !/개발|진행|등록|처리|시작|작업|수정|고쳐|구현|추가|저장|기록|남겨|배포|테스트|검증|실패|에러|버그|문제|postback|로그|히스토리|마이그레이션|근거|참조|산출물|확인/i.test(normalized)) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
return /개발|진행|등록|처리|시작|작업|구축|연동|작성|수정|고쳐|구현|추가|저장|기록|남겨|배포|테스트|검증|확인|찾아|분석|비교|참조|근거|이유|왜|어떤|어디|어떻게|산출물|자료|데이터|출처|실패|에러|버그|문제|DB|database|api|postback|로그|히스토리|마이그레이션|schema|table|endpoint/i.test(normalized);
|
|
448
|
+
}
|
|
449
|
+
activeTodoItems(todo) {
|
|
450
|
+
return todo.items.filter((item) => this.isActiveTodo(item));
|
|
451
|
+
}
|
|
452
|
+
isActiveTodo(item) {
|
|
453
|
+
return item.status === "pending" || item.status === "in_progress";
|
|
454
|
+
}
|
|
455
|
+
ensureActiveTodo(items) {
|
|
456
|
+
const activeIndex = items.findIndex((item) => item.status === "in_progress");
|
|
457
|
+
if (activeIndex >= 0) {
|
|
458
|
+
return items;
|
|
459
|
+
}
|
|
460
|
+
const pendingIndex = items.findIndex((item) => item.status === "pending");
|
|
461
|
+
if (pendingIndex < 0) {
|
|
462
|
+
return items;
|
|
463
|
+
}
|
|
464
|
+
return items.map((item, index) => index === pendingIndex ? { ...item, status: "in_progress" } : item);
|
|
465
|
+
}
|
|
466
|
+
normalizeTodoState(todo) {
|
|
467
|
+
const now = new Date().toISOString();
|
|
468
|
+
const items = (todo.items ?? []).map((item) => {
|
|
469
|
+
return item;
|
|
470
|
+
});
|
|
471
|
+
return {
|
|
472
|
+
createdAt: todo.createdAt || now,
|
|
473
|
+
updatedAt: todo.updatedAt || now,
|
|
474
|
+
items: this.ensureActiveTodo(items),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
matchesAnyTodo(text, items) {
|
|
478
|
+
return items.some((item) => this.todoTextSimilarity(text, item.text) >= 0.86);
|
|
479
|
+
}
|
|
480
|
+
todoTextSimilarity(left, right) {
|
|
481
|
+
const a = this.normalizeComparableText(left);
|
|
482
|
+
const b = this.normalizeComparableText(right);
|
|
483
|
+
if (!a || !b) {
|
|
484
|
+
return 0;
|
|
485
|
+
}
|
|
486
|
+
if (a === b) {
|
|
487
|
+
return 1;
|
|
488
|
+
}
|
|
489
|
+
const shorter = a.length <= b.length ? a : b;
|
|
490
|
+
const longer = a.length > b.length ? a : b;
|
|
491
|
+
if (shorter.length >= 20 && longer.includes(shorter)) {
|
|
492
|
+
return shorter.length / longer.length;
|
|
493
|
+
}
|
|
494
|
+
const aGrams = this.charBigrams(a);
|
|
495
|
+
const bGrams = this.charBigrams(b);
|
|
496
|
+
if (aGrams.size === 0 || bGrams.size === 0) {
|
|
497
|
+
return 0;
|
|
498
|
+
}
|
|
499
|
+
let intersection = 0;
|
|
500
|
+
for (const gram of aGrams) {
|
|
501
|
+
if (bGrams.has(gram)) {
|
|
502
|
+
intersection += 1;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return intersection / (aGrams.size + bGrams.size - intersection);
|
|
506
|
+
}
|
|
507
|
+
normalizeComparableText(text) {
|
|
508
|
+
return text
|
|
509
|
+
.toLowerCase()
|
|
510
|
+
.replace(/\s+/g, "")
|
|
511
|
+
.replace(/[^\p{L}\p{N}가-힣]/gu, "")
|
|
512
|
+
.trim();
|
|
513
|
+
}
|
|
514
|
+
charBigrams(text) {
|
|
515
|
+
if (text.length <= 1) {
|
|
516
|
+
return new Set(text ? [text] : []);
|
|
517
|
+
}
|
|
518
|
+
const grams = new Set();
|
|
519
|
+
for (let index = 0; index < text.length - 1; index += 1) {
|
|
520
|
+
grams.add(text.slice(index, index + 2));
|
|
521
|
+
}
|
|
522
|
+
return grams;
|
|
523
|
+
}
|
|
524
|
+
async touchActiveTodo(session) {
|
|
525
|
+
const todo = await this.readTodo(session);
|
|
526
|
+
const index = todo.items.findIndex((item) => item.status === "in_progress");
|
|
527
|
+
if (index < 0) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const now = new Date().toISOString();
|
|
531
|
+
const items = todo.items.slice();
|
|
532
|
+
items[index] = { ...items[index], attempts: items[index].attempts + 1, updatedAt: now };
|
|
533
|
+
await this.writeTodo(session, { ...todo, updatedAt: now, items });
|
|
534
|
+
}
|
|
535
|
+
async markActiveTodoBlocked(session, note) {
|
|
536
|
+
const todo = await this.readTodo(session);
|
|
537
|
+
const now = new Date().toISOString();
|
|
538
|
+
await this.writeTodo(session, {
|
|
539
|
+
...todo,
|
|
540
|
+
updatedAt: now,
|
|
541
|
+
items: todo.items.map((item) => item.status === "in_progress"
|
|
542
|
+
? { ...item, status: "blocked", updatedAt: now, note }
|
|
543
|
+
: item),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
extractInstructionDirective(text) {
|
|
547
|
+
const match = /^(new|새작업|새\s*작업|continue|계속|이어|이어서)\s*[::]\s*([\s\S]+)$/i.exec(text.trim());
|
|
548
|
+
if (!match) {
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
const raw = match[1]?.toLowerCase().replace(/\s+/g, "") ?? "";
|
|
552
|
+
const instruction = match[2]?.trim() ?? "";
|
|
553
|
+
if (!instruction) {
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
kind: raw === "new" || raw === "새작업" ? "new" : "continue",
|
|
558
|
+
instruction,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
looksLikeContinuation(text) {
|
|
562
|
+
const normalized = text.trim();
|
|
563
|
+
return /^(계속|이어|이어서|진행|수정|고쳐|해|다시|확인|검증|배포|빌드|테스트|적용|마무리|커밋|푸시|중단|멈춰|결과|상태|됐어|했어|끝났|완료|continue|go on|resume)/i.test(normalized);
|
|
564
|
+
}
|
|
565
|
+
looksLikeNewTask(text) {
|
|
566
|
+
return /^(새로|새\s*작업|다른\s*작업|이제\s*부터|다음\s*작업|전환|바꿔서|new task)/i.test(text.trim());
|
|
567
|
+
}
|
|
568
|
+
summarizeCurrentTask(current) {
|
|
569
|
+
const instructionIndex = current.indexOf("## Instruction");
|
|
570
|
+
const body = instructionIndex >= 0 ? current.slice(instructionIndex + "## Instruction".length) : current;
|
|
571
|
+
const immediateRuleIndex = body.indexOf("## Immediate Rule");
|
|
572
|
+
const instruction = (immediateRuleIndex >= 0 ? body.slice(0, immediateRuleIndex) : body).trim();
|
|
573
|
+
return this.truncate(instruction || current.trim(), 700);
|
|
574
|
+
}
|
|
575
|
+
formatTodoSummary(todo, includeDone = false, detailed = false) {
|
|
576
|
+
const items = includeDone ? todo.items : todo.items.filter((item) => item.status !== "done");
|
|
577
|
+
if (items.length === 0) {
|
|
578
|
+
return "TODO: none";
|
|
579
|
+
}
|
|
580
|
+
return items.map((item, index) => {
|
|
581
|
+
const marker = item.status === "done" ? "완료" : item.status === "in_progress" ? "진행중" : item.status === "blocked" ? "차단" : "대기";
|
|
582
|
+
const note = item.note ? ` (${item.note})` : "";
|
|
583
|
+
if (!detailed) {
|
|
584
|
+
const attempts = item.attempts > 0 ? ` attempts=${item.attempts}` : "";
|
|
585
|
+
return `${index + 1}. [${item.id}] ${marker}: ${item.text}${note}${attempts}`;
|
|
586
|
+
}
|
|
587
|
+
const details = [
|
|
588
|
+
`${index + 1}. [${item.id}] ${marker}: ${item.text}${note}`,
|
|
589
|
+
item.workspace ? ` - 작업 폴더: ${item.workspace}` : undefined,
|
|
590
|
+
item.memoryPath ? ` - 개인 memory: ${item.memoryPath}` : undefined,
|
|
591
|
+
item.officialDocs ? ` - 공식 문서 위치: ${item.officialDocs}` : undefined,
|
|
592
|
+
item.relatedFiles ? ` - 관련 파일: ${item.relatedFiles}` : undefined,
|
|
593
|
+
item.action ? ` - 해야 할 일: ${item.action}` : undefined,
|
|
594
|
+
item.caution ? ` - 하지 말 것: ${item.caution}` : undefined,
|
|
595
|
+
item.doneEvidence ? ` - 완료 조건: ${item.doneEvidence}` : undefined,
|
|
596
|
+
item.stopCondition ? ` - 중단 조건: ${item.stopCondition}` : undefined,
|
|
597
|
+
item.reportFormat ? ` - 보고 형식: ${item.reportFormat}` : undefined,
|
|
598
|
+
` - attempts: ${item.attempts}`,
|
|
599
|
+
].filter(Boolean);
|
|
600
|
+
return details.join("\n");
|
|
601
|
+
}).join("\n");
|
|
602
|
+
}
|
|
603
|
+
progressSignature(text) {
|
|
604
|
+
const normalized = text
|
|
605
|
+
.toLowerCase()
|
|
606
|
+
.replace(/\d{4}-\d{2}-\d{2}[^\s]*/g, "")
|
|
607
|
+
.replace(/\b\d+\b/g, "")
|
|
608
|
+
.replace(/\s+/g, " ")
|
|
609
|
+
.trim()
|
|
610
|
+
.slice(0, 240);
|
|
611
|
+
return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
612
|
+
}
|
|
613
|
+
sessionDir(session) {
|
|
614
|
+
return path.join(this.rootDir, "sessions", session.publicId);
|
|
615
|
+
}
|
|
616
|
+
async appendHistory(session, entry) {
|
|
617
|
+
const dir = this.sessionDir(session);
|
|
618
|
+
await fs.mkdir(dir, { recursive: true });
|
|
619
|
+
await fs.appendFile(path.join(dir, "history.ndjson"), `${JSON.stringify(entry)}\n`, "utf8");
|
|
620
|
+
}
|
|
621
|
+
async readAttempts(session) {
|
|
622
|
+
return this.readJson(path.join(this.sessionDir(session), "attempts.json"), { progress: {} });
|
|
623
|
+
}
|
|
624
|
+
async writeAttempts(session, attempts) {
|
|
625
|
+
await this.writeJson(path.join(this.sessionDir(session), "attempts.json"), attempts);
|
|
626
|
+
}
|
|
627
|
+
async resetAttempts(session) {
|
|
628
|
+
await this.writeAttempts(session, { progress: {} });
|
|
629
|
+
}
|
|
630
|
+
async readSecrets() {
|
|
631
|
+
return this.readJson(this.secretsPath, {});
|
|
632
|
+
}
|
|
633
|
+
async writeSecrets(secrets) {
|
|
634
|
+
await fs.mkdir(path.dirname(this.secretsPath), { recursive: true });
|
|
635
|
+
await fs.writeFile(this.secretsPath, JSON.stringify(secrets, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
636
|
+
await fs.chmod(this.secretsPath, 0o600).catch(() => undefined);
|
|
637
|
+
}
|
|
638
|
+
async readDocs() {
|
|
639
|
+
return this.readJson(this.docsPath, {});
|
|
640
|
+
}
|
|
641
|
+
normalizeKeyword(keyword) {
|
|
642
|
+
const normalized = keyword.trim().toLowerCase();
|
|
643
|
+
if (!/^[a-z0-9가-힣._-]{1,80}$/i.test(normalized)) {
|
|
644
|
+
throw new Error("Keyword must be 1-80 chars and may contain letters, numbers, Korean, dot, underscore, or dash.");
|
|
645
|
+
}
|
|
646
|
+
return normalized;
|
|
647
|
+
}
|
|
648
|
+
assertSecretKey(key) {
|
|
649
|
+
if (!/^[A-Z0-9_.-]{1,80}$/.test(key)) {
|
|
650
|
+
throw new Error("Secret key must be 1-80 chars using A-Z, 0-9, dot, underscore, or dash.");
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async readJson(filePath, fallback) {
|
|
654
|
+
try {
|
|
655
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
656
|
+
return JSON.parse(raw);
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
return fallback;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
async cleanupOrphanTelegramUploads(cutoff, keptPaths) {
|
|
663
|
+
let scannedFiles = 0;
|
|
664
|
+
let removedFiles = 0;
|
|
665
|
+
const files = await this.listFilesRecursive(this.telegramUploadsDir);
|
|
666
|
+
for (const filePath of files) {
|
|
667
|
+
scannedFiles += 1;
|
|
668
|
+
const resolved = path.resolve(filePath);
|
|
669
|
+
if (keptPaths.has(resolved)) {
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const stat = await fs.stat(filePath).catch(() => undefined);
|
|
673
|
+
if (!stat?.isFile() || stat.mtimeMs >= cutoff) {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
await fs.rm(filePath, { force: true }).then(() => {
|
|
677
|
+
removedFiles += 1;
|
|
678
|
+
}).catch(() => undefined);
|
|
679
|
+
}
|
|
680
|
+
await this.removeEmptyDirectories(this.telegramUploadsDir);
|
|
681
|
+
return { scannedFiles, removedFiles };
|
|
682
|
+
}
|
|
683
|
+
async listFilesRecursive(root) {
|
|
684
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
685
|
+
const files = [];
|
|
686
|
+
for (const entry of entries) {
|
|
687
|
+
const entryPath = path.join(root, entry.name);
|
|
688
|
+
if (entry.isDirectory()) {
|
|
689
|
+
files.push(...await this.listFilesRecursive(entryPath));
|
|
690
|
+
}
|
|
691
|
+
else if (entry.isFile()) {
|
|
692
|
+
files.push(entryPath);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return files;
|
|
696
|
+
}
|
|
697
|
+
async removeEmptyDirectories(root) {
|
|
698
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
699
|
+
let empty = entries.length === 0;
|
|
700
|
+
for (const entry of entries) {
|
|
701
|
+
if (!entry.isDirectory()) {
|
|
702
|
+
empty = false;
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
const childPath = path.join(root, entry.name);
|
|
706
|
+
const childEmpty = await this.removeEmptyDirectories(childPath);
|
|
707
|
+
if (!childEmpty) {
|
|
708
|
+
empty = false;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (empty && root !== this.telegramUploadsDir) {
|
|
712
|
+
await fs.rmdir(root).catch(() => undefined);
|
|
713
|
+
}
|
|
714
|
+
return empty;
|
|
715
|
+
}
|
|
716
|
+
async writeJson(filePath, value) {
|
|
717
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
718
|
+
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
719
|
+
}
|
|
720
|
+
truncate(text, max) {
|
|
721
|
+
return text.length > max ? `${text.slice(0, max)}\n[truncated ${text.length - max} chars]` : text;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
export function readSecretValue(dataDir, key) {
|
|
725
|
+
if (!/^[A-Z0-9_.-]{1,80}$/.test(key)) {
|
|
726
|
+
return undefined;
|
|
727
|
+
}
|
|
728
|
+
const filePath = path.join(dataDir, "managed", "secrets.json");
|
|
729
|
+
try {
|
|
730
|
+
const raw = fsSync.readFileSync(filePath, "utf8");
|
|
731
|
+
const secrets = JSON.parse(raw);
|
|
732
|
+
return secrets[key]?.value;
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
return undefined;
|
|
736
|
+
}
|
|
737
|
+
}
|