@kevin5251984/guild 0.2.18 → 0.2.20
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/package.json +2 -2
- package/src/catalog/subagents.ts +2 -1
- package/src/chat-parts.ts +6 -1
- package/src/cli.ts +2 -5
- package/src/compact.ts +27 -0
- package/src/db.ts +236 -68
- package/src/generate.ts +59 -28
- package/src/handlers.ts +355 -84
- package/src/harness.ts +39 -10
- package/src/host-browse.ts +149 -4
- package/src/image-gen.ts +2 -1
- package/src/llm.ts +435 -71
- package/src/mcp.ts +32 -5
- package/src/memory.ts +69 -0
- package/src/mention.ts +189 -14
- package/src/oauth.ts +204 -27
- package/src/opencode-free.ts +248 -0
- package/src/public/buddy.js +432 -0
- package/src/public/chat.css +174 -72
- package/src/public/chat.html +354 -69
- package/src/public/i18n.js +44 -6
- package/src/public/index.html +1 -46
- package/src/public/mobile.css +597 -0
- package/src/public/mobile.html +954 -0
- package/src/public/settings.html +239 -35
- package/src/public/skills-add.html +8 -0
- package/src/public/studio.html +171 -117
- package/src/public/style.css +220 -26
- package/src/public/subagents-add.html +8 -0
- package/src/reasoning-catalog.ts +346 -0
- package/src/router.ts +184 -13
- package/src/store.ts +170 -5
- package/src/subagent.ts +239 -7
- package/src/tools.ts +132 -21
- package/src/trajectory.ts +72 -9
- package/src/usage.ts +10 -0
- package/src/version.ts +25 -0
- package/vendor/protocol/src/index.ts +27 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kevin5251984/guild",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A local guild of adventurers. npx @kevin5251984/guild web",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"dev": "tsx src/cli.ts",
|
|
33
33
|
"start": "node ./bin/guildd.mjs",
|
|
34
|
-
"build": "
|
|
34
|
+
"build": "node --check ./bin/guildd.mjs",
|
|
35
35
|
"test": "tsx --test test/*.test.ts",
|
|
36
36
|
"prepack": "node ./scripts/vendor-protocol.mjs prepack",
|
|
37
37
|
"postpack": "node ./scripts/vendor-protocol.mjs postpack"
|
package/src/catalog/subagents.ts
CHANGED
|
@@ -20,7 +20,8 @@ sandbox_mode = "read-only"
|
|
|
20
20
|
developer_instructions = """
|
|
21
21
|
Role: codebase search specialist. Find files and code. Read-only.
|
|
22
22
|
|
|
23
|
-
Answer "where is X / which files do Y" with every relevant absolute path and the actual need behind the request.
|
|
23
|
+
Answer "where is X / which files do Y" with every relevant absolute path and the actual need behind the request.
|
|
24
|
+
Fire 3+ independent tool calls in the first round (they run in parallel). Stop after two waves add nothing new.
|
|
24
25
|
|
|
25
26
|
Never edit, write, or apply patches. Findings are message text only.
|
|
26
27
|
|
package/src/chat-parts.ts
CHANGED
|
@@ -40,11 +40,16 @@ export function assembleParts(input: {
|
|
|
40
40
|
? String(trace.args.prompt ?? "")
|
|
41
41
|
: trace.name === "spawn"
|
|
42
42
|
? String(
|
|
43
|
-
trace.args.
|
|
43
|
+
trace.args.title ||
|
|
44
|
+
trace.args.description ||
|
|
45
|
+
trace.args.profile ||
|
|
44
46
|
trace.args.name ||
|
|
47
|
+
trace.args.task ||
|
|
45
48
|
trace.args.prompt ||
|
|
46
49
|
"",
|
|
47
50
|
)
|
|
51
|
+
: trace.name === "read_spawn"
|
|
52
|
+
? String(trace.args.agent_id || trace.args.id || "")
|
|
48
53
|
: String(trace.args.path ?? ""),
|
|
49
54
|
output: trace.text,
|
|
50
55
|
isError: trace.isError,
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
1
|
import { execFile } from "node:child_process";
|
|
3
2
|
import { startGuildDaemon } from "./start.ts";
|
|
4
3
|
import {
|
|
@@ -6,6 +5,7 @@ import {
|
|
|
6
5
|
parseGuildCli,
|
|
7
6
|
shouldOpenBrowser,
|
|
8
7
|
} from "./cli-args.ts";
|
|
8
|
+
import { guildVersion } from "./version.ts";
|
|
9
9
|
|
|
10
10
|
const opts = parseGuildCli(process.argv);
|
|
11
11
|
if (opts.error) {
|
|
@@ -15,10 +15,7 @@ if (opts.error) {
|
|
|
15
15
|
} else if (opts.help) {
|
|
16
16
|
process.stdout.write(guildCliHelp());
|
|
17
17
|
} else if (opts.version) {
|
|
18
|
-
|
|
19
|
-
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
20
|
-
) as { version: string };
|
|
21
|
-
process.stdout.write(`${pkg.version}\n`);
|
|
18
|
+
process.stdout.write(`${guildVersion()}\n`);
|
|
22
19
|
} else {
|
|
23
20
|
if (opts.port !== undefined) process.env.GUILD_PORT = String(opts.port);
|
|
24
21
|
const started = startGuildDaemon();
|
package/src/compact.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ChatPart, ModelRef } from "@guild/protocol";
|
|
2
2
|
import { llmComplete } from "./llm.ts";
|
|
3
|
+
import type { ToolProgress } from "./tools.ts";
|
|
3
4
|
|
|
4
5
|
/** Cheap char/4 estimate, same ballpark Codex uses before a real tokenizer. */
|
|
5
6
|
export const CHARS_PER_TOKEN = 4;
|
|
@@ -251,6 +252,8 @@ async function summarizeOld(input: {
|
|
|
251
252
|
dataDir: string;
|
|
252
253
|
env?: NodeJS.ProcessEnv;
|
|
253
254
|
prefer?: ModelRef | null;
|
|
255
|
+
onProgress?: (update: ToolProgress) => void;
|
|
256
|
+
signal?: AbortSignal;
|
|
254
257
|
}): Promise<string> {
|
|
255
258
|
const transcript = input.old
|
|
256
259
|
.map((item) => {
|
|
@@ -275,6 +278,14 @@ async function summarizeOld(input: {
|
|
|
275
278
|
prefer: input.prefer,
|
|
276
279
|
tools: false,
|
|
277
280
|
temperature: 0.1,
|
|
281
|
+
toolCtx: {
|
|
282
|
+
dataDir: input.dataDir,
|
|
283
|
+
env: input.env,
|
|
284
|
+
spawnDepth: 0,
|
|
285
|
+
allowWrite: false,
|
|
286
|
+
onProgress: input.onProgress,
|
|
287
|
+
signal: input.signal,
|
|
288
|
+
},
|
|
278
289
|
system:
|
|
279
290
|
"You compact a conversation so work can continue. Output only the summary.",
|
|
280
291
|
messages: [
|
|
@@ -305,6 +316,8 @@ export async function packHistory(input: {
|
|
|
305
316
|
prefer?: ModelRef | null;
|
|
306
317
|
checkpoint?: CompactCheckpoint | null;
|
|
307
318
|
tokenLimit?: number;
|
|
319
|
+
onProgress?: (update: ToolProgress) => void;
|
|
320
|
+
signal?: AbortSignal;
|
|
308
321
|
}): Promise<PackedHistory> {
|
|
309
322
|
const user = { role: "user" as const, content: input.userMessage };
|
|
310
323
|
const history = input.history.map(clipHistoryItem);
|
|
@@ -328,12 +341,26 @@ export async function packHistory(input: {
|
|
|
328
341
|
summary = input.checkpoint!.summary;
|
|
329
342
|
checkpoint = input.checkpoint!;
|
|
330
343
|
} else {
|
|
344
|
+
input.onProgress?.({
|
|
345
|
+
thinking: "整理上文…",
|
|
346
|
+
traces: [
|
|
347
|
+
{
|
|
348
|
+
name: "context",
|
|
349
|
+
args: {},
|
|
350
|
+
text: "",
|
|
351
|
+
isError: false,
|
|
352
|
+
running: true,
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
});
|
|
331
356
|
summary = await summarizeOld({
|
|
332
357
|
old: plan.old,
|
|
333
358
|
previous: input.checkpoint?.summary,
|
|
334
359
|
dataDir: input.dataDir,
|
|
335
360
|
env: input.env,
|
|
336
361
|
prefer: input.prefer,
|
|
362
|
+
onProgress: input.onProgress,
|
|
363
|
+
signal: input.signal,
|
|
337
364
|
});
|
|
338
365
|
checkpoint = {
|
|
339
366
|
throughId: lastId(plan.old),
|
package/src/db.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
closeSync,
|
|
2
4
|
existsSync,
|
|
3
5
|
mkdirSync,
|
|
6
|
+
openSync,
|
|
4
7
|
readdirSync,
|
|
5
8
|
readFileSync,
|
|
9
|
+
readSync,
|
|
6
10
|
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
writeFileSync,
|
|
7
13
|
} from "node:fs";
|
|
8
14
|
import { dirname, join, resolve } from "node:path";
|
|
9
15
|
import { DatabaseSync } from "node:sqlite";
|
|
@@ -17,7 +23,17 @@ import type {
|
|
|
17
23
|
import type { TrajectoryDraft, TrajectoryEvent } from "./trajectory.ts";
|
|
18
24
|
|
|
19
25
|
export const GUILD_DB_FILE = "guild.sqlite";
|
|
20
|
-
|
|
26
|
+
/** Per-room hot window in SQLite. Older rows spill to rooms/<id>/trajectory.jsonl. */
|
|
27
|
+
export const TRAJECTORY_HOT_CAP = 1000;
|
|
28
|
+
/** Bump when this guildd writes a shape an older guildd cannot read. */
|
|
29
|
+
export const SCHEMA_VERSION = "2";
|
|
30
|
+
|
|
31
|
+
/** Numeric view of a `meta.schema` value. Missing/garbage = 0 (migrate). */
|
|
32
|
+
function schemaVersionOf(raw: unknown): number {
|
|
33
|
+
const value = Number(typeof raw === "string" ? raw.trim() : raw);
|
|
34
|
+
return Number.isFinite(value) ? value : 0;
|
|
35
|
+
}
|
|
36
|
+
const WAREHOUSE_TAIL = 1024 * 1024;
|
|
21
37
|
|
|
22
38
|
const SCHEMA = `
|
|
23
39
|
PRAGMA journal_mode = WAL;
|
|
@@ -34,7 +50,9 @@ CREATE TABLE IF NOT EXISTS rooms (
|
|
|
34
50
|
kind TEXT NOT NULL CHECK (kind IN ('channel', 'dm')),
|
|
35
51
|
name TEXT NOT NULL,
|
|
36
52
|
member_ids TEXT NOT NULL DEFAULT '[]',
|
|
37
|
-
created_at TEXT NOT NULL
|
|
53
|
+
created_at TEXT NOT NULL,
|
|
54
|
+
parent_id TEXT,
|
|
55
|
+
branch_from_id TEXT
|
|
38
56
|
);
|
|
39
57
|
|
|
40
58
|
CREATE TABLE IF NOT EXISTS messages (
|
|
@@ -51,6 +69,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|
|
51
69
|
finished_at TEXT,
|
|
52
70
|
steer INTEGER NOT NULL DEFAULT 0,
|
|
53
71
|
steer_bot_id TEXT,
|
|
72
|
+
mentions TEXT,
|
|
54
73
|
UNIQUE (room_id, seq)
|
|
55
74
|
);
|
|
56
75
|
|
|
@@ -138,7 +157,11 @@ function parseJsonlTrajectory(raw: string): TrajectoryEvent[] {
|
|
|
138
157
|
if (!trimmed) continue;
|
|
139
158
|
try {
|
|
140
159
|
const parsed = JSON.parse(trimmed) as TrajectoryEvent;
|
|
141
|
-
if (parsed
|
|
160
|
+
if (!parsed || typeof parsed.kind !== "string") continue;
|
|
161
|
+
if (typeof parsed.seq !== "number" || !Number.isFinite(parsed.seq)) {
|
|
162
|
+
parsed.seq = out.length;
|
|
163
|
+
}
|
|
164
|
+
out.push(parsed);
|
|
142
165
|
} catch {
|
|
143
166
|
/* skip */
|
|
144
167
|
}
|
|
@@ -146,6 +169,51 @@ function parseJsonlTrajectory(raw: string): TrajectoryEvent[] {
|
|
|
146
169
|
return out;
|
|
147
170
|
}
|
|
148
171
|
|
|
172
|
+
function lastJsonlSeq(path: string): number {
|
|
173
|
+
if (!existsSync(path)) return -1;
|
|
174
|
+
const stat = statSync(path);
|
|
175
|
+
if (stat.size === 0) return -1;
|
|
176
|
+
const fd = openSync(path, "r");
|
|
177
|
+
try {
|
|
178
|
+
const size = Math.min(stat.size, WAREHOUSE_TAIL);
|
|
179
|
+
const buf = Buffer.alloc(size);
|
|
180
|
+
readSync(fd, buf, 0, size, stat.size - size);
|
|
181
|
+
const lines = buf.toString("utf8").split("\n");
|
|
182
|
+
const start = stat.size > size ? 1 : 0;
|
|
183
|
+
for (let i = lines.length - 1; i >= start; i--) {
|
|
184
|
+
const trimmed = lines[i]!.trim();
|
|
185
|
+
if (!trimmed) continue;
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(trimmed) as { seq?: unknown };
|
|
188
|
+
if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) {
|
|
189
|
+
return parsed.seq;
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
/* skip */
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return -1;
|
|
196
|
+
} finally {
|
|
197
|
+
closeSync(fd);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function trajectoryValues(roomId: string, event: TrajectoryEvent) {
|
|
202
|
+
return [
|
|
203
|
+
roomId,
|
|
204
|
+
event.seq,
|
|
205
|
+
event.ts,
|
|
206
|
+
event.turnId,
|
|
207
|
+
event.botId ?? null,
|
|
208
|
+
event.kind,
|
|
209
|
+
event.summary,
|
|
210
|
+
event.payload === undefined ? null : JSON.stringify(event.payload),
|
|
211
|
+
event.result ?? null,
|
|
212
|
+
event.durationMs ?? null,
|
|
213
|
+
event.isError ? 1 : 0,
|
|
214
|
+
] as const;
|
|
215
|
+
}
|
|
216
|
+
|
|
149
217
|
function messageFromRow(row: Record<string, unknown>): ChatMessage {
|
|
150
218
|
const parts = parseJson<ChatPart[] | null>(row.parts, null);
|
|
151
219
|
const attachments = parseJson<ChatAttachment[] | null>(row.attachments, null);
|
|
@@ -167,12 +235,18 @@ function messageFromRow(row: Record<string, unknown>): ChatMessage {
|
|
|
167
235
|
if (asNumber(row.steer) === 1) message.steer = true;
|
|
168
236
|
const steerBotId = asString(row.steer_bot_id);
|
|
169
237
|
if (steerBotId) message.steerBotId = steerBotId;
|
|
238
|
+
if (typeof row.mentions === "string" && row.mentions) {
|
|
239
|
+
const mentions = parseJson<string[]>(row.mentions, []);
|
|
240
|
+
if (Array.isArray(mentions)) {
|
|
241
|
+
message.mentions = mentions.filter((id) => typeof id === "string");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
170
244
|
return message;
|
|
171
245
|
}
|
|
172
246
|
|
|
173
247
|
function roomFromRow(row: Record<string, unknown>): Room {
|
|
174
248
|
const memberIds = parseJson<string[]>(row.member_ids, []);
|
|
175
|
-
|
|
249
|
+
const room: Room = {
|
|
176
250
|
id: asString(row.id),
|
|
177
251
|
kind: asString(row.kind) === "dm" ? "dm" : "channel",
|
|
178
252
|
name: asString(row.name),
|
|
@@ -181,6 +255,11 @@ function roomFromRow(row: Record<string, unknown>): Room {
|
|
|
181
255
|
: [],
|
|
182
256
|
createdAt: asString(row.created_at),
|
|
183
257
|
};
|
|
258
|
+
const parentId = asString(row.parent_id);
|
|
259
|
+
if (parentId) room.parentId = parentId;
|
|
260
|
+
const branchFromId = asString(row.branch_from_id);
|
|
261
|
+
if (branchFromId) room.branchFromId = branchFromId;
|
|
262
|
+
return room;
|
|
184
263
|
}
|
|
185
264
|
|
|
186
265
|
function trajectoryFromRow(row: Record<string, unknown>): TrajectoryEvent {
|
|
@@ -209,40 +288,85 @@ export class GuildDb {
|
|
|
209
288
|
constructor(readonly path: string) {
|
|
210
289
|
mkdirSync(dirname(path), { recursive: true });
|
|
211
290
|
this.sqlite = new DatabaseSync(path, { timeout: 5000 });
|
|
212
|
-
|
|
291
|
+
try {
|
|
292
|
+
this.sqlite.exec(SCHEMA);
|
|
293
|
+
// Refuse to open a DB written by a newer guildd: the ALTERs below and the
|
|
294
|
+
// version upsert would silently migrate the file back down.
|
|
295
|
+
const row = this.sqlite
|
|
296
|
+
.prepare("SELECT value FROM meta WHERE key = 'schema'")
|
|
297
|
+
.get() as { value?: unknown } | undefined;
|
|
298
|
+
const stored = schemaVersionOf(row?.value);
|
|
299
|
+
const current = schemaVersionOf(SCHEMA_VERSION);
|
|
300
|
+
if (stored > current) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`guild.sqlite schema ${stored} is newer than this guildd (${current}); upgrade guildd`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
} catch (error) {
|
|
306
|
+
this.close();
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
213
309
|
try {
|
|
214
310
|
this.sqlite.exec("ALTER TABLE messages ADD COLUMN steer_bot_id TEXT");
|
|
215
311
|
} catch {
|
|
216
312
|
/* column already exists on fresh schema */
|
|
217
313
|
}
|
|
218
|
-
|
|
219
|
-
"
|
|
220
|
-
|
|
314
|
+
try {
|
|
315
|
+
this.sqlite.exec("ALTER TABLE messages ADD COLUMN mentions TEXT");
|
|
316
|
+
} catch {
|
|
317
|
+
/* column already exists on fresh schema */
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
this.sqlite.exec("ALTER TABLE rooms ADD COLUMN parent_id TEXT");
|
|
321
|
+
} catch {
|
|
322
|
+
/* column already exists on fresh schema */
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
this.sqlite.exec("ALTER TABLE rooms ADD COLUMN branch_from_id TEXT");
|
|
326
|
+
} catch {
|
|
327
|
+
/* column already exists on fresh schema */
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
this.sqlite.prepare(
|
|
331
|
+
"INSERT INTO meta (key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
332
|
+
).run(SCHEMA_VERSION);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
this.close();
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
221
337
|
}
|
|
222
338
|
|
|
223
339
|
close(): void {
|
|
224
|
-
|
|
340
|
+
try {
|
|
341
|
+
this.sqlite.close();
|
|
342
|
+
} catch {
|
|
343
|
+
/* already closed */
|
|
344
|
+
}
|
|
225
345
|
}
|
|
226
346
|
|
|
227
347
|
importLegacyFiles(dataDir: string): void {
|
|
228
348
|
const root = join(dataDir, "rooms");
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
349
|
+
if (existsSync(root)) {
|
|
350
|
+
const ids = readdirSync(root, { withFileTypes: true })
|
|
351
|
+
.filter((entry) => entry.isDirectory())
|
|
352
|
+
.map((entry) => entry.name);
|
|
353
|
+
for (const id of ids) this.importRoomDir(join(root, id), id);
|
|
354
|
+
}
|
|
355
|
+
for (const room of this.listRooms()) this.spillColdTrajectory(room.id);
|
|
234
356
|
}
|
|
235
357
|
|
|
236
358
|
upsertRoom(room: Room): void {
|
|
237
359
|
this.sqlite
|
|
238
360
|
.prepare(
|
|
239
|
-
`INSERT INTO rooms (id, kind, name, member_ids, created_at)
|
|
240
|
-
VALUES (?, ?, ?, ?, ?)
|
|
361
|
+
`INSERT INTO rooms (id, kind, name, member_ids, created_at, parent_id, branch_from_id)
|
|
362
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
241
363
|
ON CONFLICT(id) DO UPDATE SET
|
|
242
364
|
kind = excluded.kind,
|
|
243
365
|
name = excluded.name,
|
|
244
366
|
member_ids = excluded.member_ids,
|
|
245
|
-
created_at = excluded.created_at
|
|
367
|
+
created_at = excluded.created_at,
|
|
368
|
+
parent_id = excluded.parent_id,
|
|
369
|
+
branch_from_id = excluded.branch_from_id`,
|
|
246
370
|
)
|
|
247
371
|
.run(
|
|
248
372
|
room.id,
|
|
@@ -250,6 +374,8 @@ export class GuildDb {
|
|
|
250
374
|
room.name,
|
|
251
375
|
JSON.stringify(room.memberIds),
|
|
252
376
|
room.createdAt,
|
|
377
|
+
room.parentId ?? null,
|
|
378
|
+
room.branchFromId ?? null,
|
|
253
379
|
);
|
|
254
380
|
}
|
|
255
381
|
|
|
@@ -304,10 +430,22 @@ export class GuildDb {
|
|
|
304
430
|
}
|
|
305
431
|
}
|
|
306
432
|
|
|
307
|
-
updateMessageBody(
|
|
433
|
+
updateMessageBody(
|
|
434
|
+
roomId: string,
|
|
435
|
+
messageId: string,
|
|
436
|
+
body: string,
|
|
437
|
+
mentions?: string[],
|
|
438
|
+
): ChatMessage | null {
|
|
308
439
|
const result = this.sqlite
|
|
309
|
-
.prepare(
|
|
310
|
-
|
|
440
|
+
.prepare(
|
|
441
|
+
"UPDATE messages SET body = ?, mentions = ? WHERE room_id = ? AND id = ?",
|
|
442
|
+
)
|
|
443
|
+
.run(
|
|
444
|
+
body,
|
|
445
|
+
mentions ? JSON.stringify(mentions) : null,
|
|
446
|
+
roomId,
|
|
447
|
+
messageId,
|
|
448
|
+
);
|
|
311
449
|
if (!result.changes) return null;
|
|
312
450
|
return this.getMessage(roomId, messageId);
|
|
313
451
|
}
|
|
@@ -321,12 +459,13 @@ export class GuildDb {
|
|
|
321
459
|
usage?: ChatUsage;
|
|
322
460
|
createdAt: string;
|
|
323
461
|
finishedAt: string;
|
|
462
|
+
mentions?: string[];
|
|
324
463
|
},
|
|
325
464
|
): ChatMessage | null {
|
|
326
465
|
const result = this.sqlite
|
|
327
466
|
.prepare(
|
|
328
467
|
`UPDATE messages
|
|
329
|
-
SET body = ?, parts = ?, usage = ?, created_at = ?, finished_at = ?
|
|
468
|
+
SET body = ?, parts = ?, usage = ?, created_at = ?, finished_at = ?, mentions = ?
|
|
330
469
|
WHERE room_id = ? AND id = ?`,
|
|
331
470
|
)
|
|
332
471
|
.run(
|
|
@@ -335,6 +474,7 @@ export class GuildDb {
|
|
|
335
474
|
patch.usage ? JSON.stringify(patch.usage) : null,
|
|
336
475
|
patch.createdAt,
|
|
337
476
|
patch.finishedAt,
|
|
477
|
+
patch.mentions ? JSON.stringify(patch.mentions) : null,
|
|
338
478
|
roomId,
|
|
339
479
|
messageId,
|
|
340
480
|
);
|
|
@@ -348,6 +488,7 @@ export class GuildDb {
|
|
|
348
488
|
this.sqlite
|
|
349
489
|
.prepare("DELETE FROM messages WHERE room_id = ? AND id = ?")
|
|
350
490
|
.run(roomId, messageId);
|
|
491
|
+
/* Hot window only. Warehouse jsonl stays append-only. */
|
|
351
492
|
this.sqlite
|
|
352
493
|
.prepare("DELETE FROM trajectory WHERE room_id = ? AND turn_id = ?")
|
|
353
494
|
.run(roomId, messageId);
|
|
@@ -385,19 +526,7 @@ export class GuildDb {
|
|
|
385
526
|
for (const draft of drafts) {
|
|
386
527
|
seq += 1;
|
|
387
528
|
const event: TrajectoryEvent = { ...draft, seq };
|
|
388
|
-
insert.run(
|
|
389
|
-
roomId,
|
|
390
|
-
event.seq,
|
|
391
|
-
event.ts,
|
|
392
|
-
event.turnId,
|
|
393
|
-
event.botId ?? null,
|
|
394
|
-
event.kind,
|
|
395
|
-
event.summary,
|
|
396
|
-
event.payload === undefined ? null : JSON.stringify(event.payload),
|
|
397
|
-
event.result ?? null,
|
|
398
|
-
event.durationMs ?? null,
|
|
399
|
-
event.isError ? 1 : 0,
|
|
400
|
-
);
|
|
529
|
+
insert.run(...trajectoryValues(roomId, event));
|
|
401
530
|
written.push(event);
|
|
402
531
|
}
|
|
403
532
|
this.sqlite.exec("COMMIT");
|
|
@@ -462,7 +591,39 @@ export class GuildDb {
|
|
|
462
591
|
const row = this.sqlite
|
|
463
592
|
.prepare("SELECT COALESCE(MAX(seq), -1) AS seq FROM trajectory WHERE room_id = ?")
|
|
464
593
|
.get(roomId) as { seq?: number } | undefined;
|
|
465
|
-
return asNumber(row?.seq, -1);
|
|
594
|
+
return Math.max(asNumber(row?.seq, -1), lastJsonlSeq(this.warehousePath(roomId)));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private warehousePath(roomId: string): string {
|
|
598
|
+
return join(dirname(this.path), "rooms", roomId, "trajectory.jsonl");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
spillColdTrajectory(roomId: string): void {
|
|
602
|
+
const extra = this.trajectoryCount(roomId) - TRAJECTORY_HOT_CAP;
|
|
603
|
+
if (extra <= 0) return;
|
|
604
|
+
const rows = this.sqlite
|
|
605
|
+
.prepare(
|
|
606
|
+
"SELECT * FROM trajectory WHERE room_id = ? ORDER BY seq ASC LIMIT ?",
|
|
607
|
+
)
|
|
608
|
+
.all(roomId, extra) as Record<string, unknown>[];
|
|
609
|
+
if (!rows.length) return;
|
|
610
|
+
const events = rows.map(trajectoryFromRow);
|
|
611
|
+
const path = this.warehousePath(roomId);
|
|
612
|
+
const lastArchived = lastJsonlSeq(path);
|
|
613
|
+
const fresh = events.filter((event) => event.seq > lastArchived);
|
|
614
|
+
if (fresh.length) {
|
|
615
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
616
|
+
appendFileSync(
|
|
617
|
+
path,
|
|
618
|
+
fresh.map((event) => `${JSON.stringify(event)}\n`).join(""),
|
|
619
|
+
"utf8",
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
const lastSeq = events[events.length - 1]?.seq;
|
|
623
|
+
if (typeof lastSeq !== "number") return;
|
|
624
|
+
this.sqlite
|
|
625
|
+
.prepare("DELETE FROM trajectory WHERE room_id = ? AND seq <= ?")
|
|
626
|
+
.run(roomId, lastSeq);
|
|
466
627
|
}
|
|
467
628
|
|
|
468
629
|
private insertMessage(message: ChatMessage, seq: number): void {
|
|
@@ -470,8 +631,8 @@ export class GuildDb {
|
|
|
470
631
|
.prepare(
|
|
471
632
|
`INSERT INTO messages (
|
|
472
633
|
id, room_id, seq, author, body, parts, reply_to, attachments, usage,
|
|
473
|
-
created_at, finished_at, steer, steer_bot_id
|
|
474
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
634
|
+
created_at, finished_at, steer, steer_bot_id, mentions
|
|
635
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
475
636
|
)
|
|
476
637
|
.run(
|
|
477
638
|
message.id,
|
|
@@ -487,6 +648,7 @@ export class GuildDb {
|
|
|
487
648
|
message.finishedAt ?? null,
|
|
488
649
|
message.steer ? 1 : 0,
|
|
489
650
|
message.steerBotId ?? null,
|
|
651
|
+
message.mentions ? JSON.stringify(message.mentions) : null,
|
|
490
652
|
);
|
|
491
653
|
}
|
|
492
654
|
|
|
@@ -507,6 +669,12 @@ export class GuildDb {
|
|
|
507
669
|
typeof parsed.createdAt === "string"
|
|
508
670
|
? parsed.createdAt
|
|
509
671
|
: "2026-01-01T00:00:00.000Z",
|
|
672
|
+
...(typeof parsed.parentId === "string" && parsed.parentId
|
|
673
|
+
? { parentId: parsed.parentId }
|
|
674
|
+
: {}),
|
|
675
|
+
...(typeof parsed.branchFromId === "string" && parsed.branchFromId
|
|
676
|
+
? { branchFromId: parsed.branchFromId }
|
|
677
|
+
: {}),
|
|
510
678
|
});
|
|
511
679
|
}
|
|
512
680
|
} catch {
|
|
@@ -536,34 +704,20 @@ export class GuildDb {
|
|
|
536
704
|
const path = join(dir, "trajectory.jsonl");
|
|
537
705
|
if (existsSync(path)) {
|
|
538
706
|
const events = parseJsonlTrajectory(readFileSync(path, "utf8"));
|
|
539
|
-
if (events.length) {
|
|
540
|
-
this.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
event.botId ?? null,
|
|
554
|
-
event.kind,
|
|
555
|
-
event.summary,
|
|
556
|
-
event.payload === undefined ? null : JSON.stringify(event.payload),
|
|
557
|
-
event.result ?? null,
|
|
558
|
-
event.durationMs ?? null,
|
|
559
|
-
event.isError ? 1 : 0,
|
|
560
|
-
);
|
|
561
|
-
}
|
|
562
|
-
this.sqlite.exec("COMMIT");
|
|
563
|
-
} catch (error) {
|
|
564
|
-
this.sqlite.exec("ROLLBACK");
|
|
565
|
-
throw error;
|
|
566
|
-
}
|
|
707
|
+
if (events.length <= TRAJECTORY_HOT_CAP) {
|
|
708
|
+
if (events.length) this.insertTrajectoryRows(id, events);
|
|
709
|
+
rmIfExists(path);
|
|
710
|
+
} else {
|
|
711
|
+
const cut = events.length - TRAJECTORY_HOT_CAP;
|
|
712
|
+
this.insertTrajectoryRows(id, events.slice(cut));
|
|
713
|
+
writeFileSync(
|
|
714
|
+
path,
|
|
715
|
+
events
|
|
716
|
+
.slice(0, cut)
|
|
717
|
+
.map((event) => `${JSON.stringify(event)}\n`)
|
|
718
|
+
.join(""),
|
|
719
|
+
"utf8",
|
|
720
|
+
);
|
|
567
721
|
}
|
|
568
722
|
}
|
|
569
723
|
}
|
|
@@ -597,12 +751,26 @@ export class GuildDb {
|
|
|
597
751
|
rmIfExists(join(dir, "messages.json"));
|
|
598
752
|
rmIfExists(join(dir, "messages.jsonl"));
|
|
599
753
|
}
|
|
600
|
-
if (this.trajectoryCount(id) > 0) {
|
|
601
|
-
rmIfExists(join(dir, "trajectory.jsonl"));
|
|
602
|
-
}
|
|
603
754
|
if (this.readCompact(id)) rmIfExists(join(dir, "compact.json"));
|
|
604
755
|
}
|
|
605
756
|
|
|
757
|
+
private insertTrajectoryRows(roomId: string, events: TrajectoryEvent[]): void {
|
|
758
|
+
if (!events.length) return;
|
|
759
|
+
this.sqlite.exec("BEGIN");
|
|
760
|
+
try {
|
|
761
|
+
const insert = this.sqlite.prepare(
|
|
762
|
+
`INSERT INTO trajectory (
|
|
763
|
+
room_id, seq, ts, turn_id, bot_id, kind, summary, payload, result, duration_ms, is_error
|
|
764
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
765
|
+
);
|
|
766
|
+
for (const event of events) insert.run(...trajectoryValues(roomId, event));
|
|
767
|
+
this.sqlite.exec("COMMIT");
|
|
768
|
+
} catch (error) {
|
|
769
|
+
this.sqlite.exec("ROLLBACK");
|
|
770
|
+
throw error;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
606
774
|
private messageCount(roomId: string): number {
|
|
607
775
|
const row = this.sqlite
|
|
608
776
|
.prepare("SELECT COUNT(*) AS n FROM messages WHERE room_id = ?")
|