miki-moni 0.3.1
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/LICENSE +21 -0
- package/README.md +283 -0
- package/README.zh-CN.md +275 -0
- package/README.zh-TW.md +275 -0
- package/bin/miki.mjs +49 -0
- package/dist/web/assets/favicon-DFpLtP36.svg +13 -0
- package/dist/web/assets/index--89DkyV1.css +1 -0
- package/dist/web/assets/index-CyPlxvOn.js +64 -0
- package/dist/web/index.html +20 -0
- package/dist/web/pair-info.html +138 -0
- package/dist/web-phone/assets/app-CyQWCdKZ.js +64 -0
- package/dist/web-phone/assets/index-D5BUh7Uf.js +1 -0
- package/dist/web-phone/assets/index-D8vY_9ld.css +1 -0
- package/dist/web-phone/index.html +20 -0
- package/hooks/miki-emit.ps1 +56 -0
- package/package.json +89 -0
- package/shared/i18n.ts +915 -0
- package/src/cli/i18n-cli.ts +149 -0
- package/src/cli/miki.ts +168 -0
- package/src/cli/pair.ts +534 -0
- package/src/cli/prompt.ts +6 -0
- package/src/cli/pushable-iter.ts +45 -0
- package/src/cli/setup-self-host.ts +292 -0
- package/src/cli/setup-wizard.ts +130 -0
- package/src/cli/wrap.ts +742 -0
- package/src/config.ts +121 -0
- package/src/crypto.ts +66 -0
- package/src/data-dir.ts +31 -0
- package/src/ext-registry.ts +47 -0
- package/src/hook-handler.ts +86 -0
- package/src/index.ts +279 -0
- package/src/install-hooks.ts +107 -0
- package/src/notifier.ts +21 -0
- package/src/pairing.ts +100 -0
- package/src/protocol-ext.ts +46 -0
- package/src/relay-client.ts +468 -0
- package/src/relay-protocol.ts +57 -0
- package/src/server.ts +1134 -0
- package/src/session-resolver.ts +437 -0
- package/src/session-store.ts +131 -0
- package/src/types.ts +33 -0
- package/src/vscode-bridge.ts +407 -0
- package/src/wrap-process.ts +183 -0
- package/tools/tray.ps1 +286 -0
- package/worker/package.json +24 -0
- package/worker/src/daemon-relay.ts +348 -0
- package/worker/src/env.ts +11 -0
- package/worker/src/handshake.ts +63 -0
- package/worker/src/index.ts +81 -0
- package/worker/src/pairing-code.ts +39 -0
- package/worker/src/pairing-coordinator.ts +145 -0
- package/worker/wrangler-selfhost.toml +36 -0
- package/worker/wrangler.toml +29 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function encodeCwd(cwd: string): string {
|
|
5
|
+
return cwd.replace(/[\/\\:]/g, "-");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class SessionResolver {
|
|
9
|
+
constructor(private projectsRoot: string) {}
|
|
10
|
+
|
|
11
|
+
async resolveLatest(cwd: string): Promise<string | null> {
|
|
12
|
+
const dir = path.join(this.projectsRoot, encodeCwd(cwd));
|
|
13
|
+
let entries: string[];
|
|
14
|
+
try {
|
|
15
|
+
entries = await fs.readdir(dir);
|
|
16
|
+
} catch (err: unknown) {
|
|
17
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl"));
|
|
21
|
+
if (jsonlFiles.length === 0) return null;
|
|
22
|
+
|
|
23
|
+
const withMtime = await Promise.all(
|
|
24
|
+
jsonlFiles.map(async (f) => ({
|
|
25
|
+
file: f,
|
|
26
|
+
mtime: (await fs.stat(path.join(dir, f))).mtimeMs,
|
|
27
|
+
}))
|
|
28
|
+
);
|
|
29
|
+
withMtime.sort((a, b) => b.mtime - a.mtime);
|
|
30
|
+
const latest = withMtime[0]!.file;
|
|
31
|
+
return latest.replace(/\.jsonl$/, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Scan all project dirs to find which one holds <sessionUuid>.jsonl. */
|
|
35
|
+
async findTranscriptPath(sessionUuid: string): Promise<string | null> {
|
|
36
|
+
let dirs: string[];
|
|
37
|
+
try {
|
|
38
|
+
dirs = await fs.readdir(this.projectsRoot);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
for (const d of dirs) {
|
|
43
|
+
const candidate = path.join(this.projectsRoot, d, `${sessionUuid}.jsonl`);
|
|
44
|
+
try {
|
|
45
|
+
await fs.access(candidate);
|
|
46
|
+
return candidate;
|
|
47
|
+
} catch { /* keep looking */ }
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Transcript reading ────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export interface ToolUseInfo {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string; // "Bash" / "Edit" / "Read" / etc.
|
|
58
|
+
description?: string; // when tool_input.description exists (e.g. Bash)
|
|
59
|
+
input: unknown; // raw input JSON
|
|
60
|
+
input_summary: string; // 1-line preview for collapsed view
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ToolResultInfo {
|
|
64
|
+
tool_use_id?: string;
|
|
65
|
+
content: string; // truncated to a reasonable size (8 KB)
|
|
66
|
+
truncated: boolean;
|
|
67
|
+
is_error?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TranscriptTurn {
|
|
71
|
+
ts: string;
|
|
72
|
+
/**
|
|
73
|
+
* "user" / "assistant" = real conversation turns.
|
|
74
|
+
* "system" = harness-injected meta entries (skill content, task-notification,
|
|
75
|
+
* tool-originated user turns). Stored in the JSONL as role:"user" but should
|
|
76
|
+
* NOT be displayed as if the human typed them.
|
|
77
|
+
*/
|
|
78
|
+
role: "user" | "assistant" | "system";
|
|
79
|
+
text: string; // free-form markdown content
|
|
80
|
+
tool_use?: ToolUseInfo;
|
|
81
|
+
tool_result?: ToolResultInfo;
|
|
82
|
+
raw_type?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* True if a JSONL entry with role:"user" is harness-injected (skill content,
|
|
87
|
+
* task notifications, tool-originated user turns) rather than something the
|
|
88
|
+
* human typed.
|
|
89
|
+
*/
|
|
90
|
+
function isInjectedUserEntry(e: any): boolean {
|
|
91
|
+
if (!e) return false;
|
|
92
|
+
if (e.isMeta === true) return true;
|
|
93
|
+
if (typeof e.sourceToolUseID === "string") return true;
|
|
94
|
+
if (e.origin && typeof e.origin.kind === "string") return true;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const MAX_TOOL_RESULT_BYTES = 8 * 1024;
|
|
99
|
+
|
|
100
|
+
function summarizeToolInput(name: string, input: unknown): string {
|
|
101
|
+
if (input == null || typeof input !== "object") return String(input ?? "");
|
|
102
|
+
const o = input as Record<string, unknown>;
|
|
103
|
+
// Pick a sensible one-line summary by tool
|
|
104
|
+
if (name === "Bash") return String(o.command ?? "").split("\n")[0]?.slice(0, 200) ?? "";
|
|
105
|
+
if (name === "Read") return String(o.file_path ?? "");
|
|
106
|
+
if (name === "Write") return `${o.file_path ?? ""} (${(o.content as string)?.length ?? 0} chars)`;
|
|
107
|
+
if (name === "Edit") return `${o.file_path ?? ""}`;
|
|
108
|
+
if (name === "Glob") return String(o.pattern ?? "");
|
|
109
|
+
if (name === "Grep") return String(o.pattern ?? "");
|
|
110
|
+
if (name === "WebFetch" || name === "WebSearch") return String(o.url ?? o.query ?? "");
|
|
111
|
+
if (name === "TodoWrite") return `${Array.isArray(o.todos) ? o.todos.length : "?"} todos`;
|
|
112
|
+
// Default: short JSON
|
|
113
|
+
return JSON.stringify(o).slice(0, 200);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function stringifyResult(content: unknown): string {
|
|
117
|
+
if (typeof content === "string") return content;
|
|
118
|
+
if (Array.isArray(content)) {
|
|
119
|
+
return content.map((c: any) => (c?.type === "text" ? c.text : JSON.stringify(c))).join("");
|
|
120
|
+
}
|
|
121
|
+
return JSON.stringify(content ?? "");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface SessionPreview {
|
|
125
|
+
session_uuid: string;
|
|
126
|
+
ai_title: string | null;
|
|
127
|
+
last_assistant_text: string | null;
|
|
128
|
+
last_assistant_ts: string | null;
|
|
129
|
+
last_user_text: string | null;
|
|
130
|
+
last_user_ts: string | null;
|
|
131
|
+
last_tool_use: { name: string; description?: string } | null;
|
|
132
|
+
last_tool_use_ts: string | null;
|
|
133
|
+
last_modified_ms: number;
|
|
134
|
+
transcript_path: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Cheap-ish scan: read tail of transcript, extract ai_title (special entry),
|
|
139
|
+
* last assistant text block, and last user message (skipping tool_results).
|
|
140
|
+
*/
|
|
141
|
+
export async function readSessionPreview(
|
|
142
|
+
sessionUuid: string,
|
|
143
|
+
transcriptPath: string,
|
|
144
|
+
): Promise<SessionPreview> {
|
|
145
|
+
const stat = await fs.stat(transcriptPath);
|
|
146
|
+
const raw = await fs.readFile(transcriptPath, "utf8");
|
|
147
|
+
// Last ~500 lines is plenty to find recent assistant/user text + ai-title
|
|
148
|
+
const allLines = raw.split(/\r?\n/);
|
|
149
|
+
const lines = allLines.slice(-500);
|
|
150
|
+
|
|
151
|
+
let ai_title: string | null = null;
|
|
152
|
+
let last_assistant_text: string | null = null;
|
|
153
|
+
let last_assistant_ts: string | null = null;
|
|
154
|
+
let last_user_text: string | null = null;
|
|
155
|
+
let last_user_ts: string | null = null;
|
|
156
|
+
let last_tool_use: { name: string; description?: string } | null = null;
|
|
157
|
+
let last_tool_use_ts: string | null = null;
|
|
158
|
+
|
|
159
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
160
|
+
const line = lines[i];
|
|
161
|
+
if (!line) continue;
|
|
162
|
+
let e: any;
|
|
163
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
164
|
+
|
|
165
|
+
const entryTs = typeof e.timestamp === "string" ? e.timestamp : null;
|
|
166
|
+
|
|
167
|
+
// ai-title is a special non-message entry
|
|
168
|
+
if (!ai_title && e.type === "ai-title" && typeof e.aiTitle === "string") {
|
|
169
|
+
ai_title = e.aiTitle;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Slash commands (type:"system", subtype:"local_command") are NOT message
|
|
173
|
+
// entries but they ARE conversation in the user's eyes. Treat them as
|
|
174
|
+
// last_user_text / last_assistant_text for the card preview.
|
|
175
|
+
if (e.type === "system" && e.subtype === "local_command" && typeof e.content === "string") {
|
|
176
|
+
const raw = e.content;
|
|
177
|
+
const out = raw.match(/^<local-command-stdout>([\s\S]*)<\/local-command-stdout>$/);
|
|
178
|
+
if (out) {
|
|
179
|
+
const text = (out[1] ?? "").trim();
|
|
180
|
+
if (text && !last_assistant_text) { last_assistant_text = `↪ ${text}`; last_assistant_ts = entryTs; }
|
|
181
|
+
} else {
|
|
182
|
+
const text = raw.trim();
|
|
183
|
+
if (text && !last_user_text) { last_user_text = `⚡ ${text}`; last_user_ts = entryTs; }
|
|
184
|
+
}
|
|
185
|
+
if (ai_title && last_assistant_text && last_user_text && last_tool_use) break;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const msg = e.message;
|
|
190
|
+
if (!msg) continue;
|
|
191
|
+
|
|
192
|
+
// Skip synthetic placeholder messages Claude Code writes when no model
|
|
193
|
+
// response was needed (e.g. {model:"<synthetic>", text:"No response requested."}).
|
|
194
|
+
if (msg.model === "<synthetic>") continue;
|
|
195
|
+
|
|
196
|
+
// Extract text content + scan for most recent tool_use (assistant turns).
|
|
197
|
+
let textPart = "";
|
|
198
|
+
if (typeof msg.content === "string") {
|
|
199
|
+
textPart = msg.content;
|
|
200
|
+
} else if (Array.isArray(msg.content)) {
|
|
201
|
+
for (const block of msg.content) {
|
|
202
|
+
if (block?.type === "text" && typeof block.text === "string") {
|
|
203
|
+
textPart += block.text;
|
|
204
|
+
} else if (!last_tool_use && block?.type === "tool_use" && typeof block.name === "string") {
|
|
205
|
+
const desc = typeof block.input?.description === "string" ? block.input.description : undefined;
|
|
206
|
+
last_tool_use = { name: block.name, description: desc };
|
|
207
|
+
last_tool_use_ts = entryTs;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
textPart = textPart.trim();
|
|
212
|
+
|
|
213
|
+
if (textPart) {
|
|
214
|
+
// Also skip known placeholder strings just in case
|
|
215
|
+
if (textPart === "No response requested." || textPart === "(no content)") continue;
|
|
216
|
+
|
|
217
|
+
if (msg.role === "assistant" && !last_assistant_text) {
|
|
218
|
+
last_assistant_text = textPart;
|
|
219
|
+
last_assistant_ts = entryTs;
|
|
220
|
+
}
|
|
221
|
+
if (msg.role === "user" && !last_user_text) {
|
|
222
|
+
// Skip harness-injected user turns (skill content, Skill tool output,
|
|
223
|
+
// task notifications, system reminders). They contain real text blocks
|
|
224
|
+
// so the textPart filter above won't catch them — they'd otherwise
|
|
225
|
+
// shadow the actual most-recent user message in the reverse scan.
|
|
226
|
+
if (!isInjectedUserEntry(e)) {
|
|
227
|
+
last_user_text = textPart;
|
|
228
|
+
last_user_ts = entryTs;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (ai_title && last_assistant_text && last_user_text && last_tool_use) break;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
session_uuid: sessionUuid,
|
|
238
|
+
ai_title,
|
|
239
|
+
last_assistant_text,
|
|
240
|
+
last_assistant_ts,
|
|
241
|
+
last_user_text,
|
|
242
|
+
last_user_ts,
|
|
243
|
+
last_tool_use,
|
|
244
|
+
last_tool_use_ts,
|
|
245
|
+
last_modified_ms: stat.mtimeMs,
|
|
246
|
+
transcript_path: transcriptPath,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Read the original cwd that the session was started with — what's stored on
|
|
252
|
+
* the very first JSONL entry that carries a `cwd` field. The SDK encodes the
|
|
253
|
+
* projects directory from THIS cwd, so `query({ resume: uuid, cwd })` only
|
|
254
|
+
* works if you pass exactly this value. DB.cwd can drift if hook events fire
|
|
255
|
+
* from subdirectories — never trust DB.cwd for wrap resume.
|
|
256
|
+
*
|
|
257
|
+
* Returns null when the file is missing or no cwd field is found in the
|
|
258
|
+
* first ~50 lines (defensive cap; in practice cwd shows up by line 2-3).
|
|
259
|
+
*/
|
|
260
|
+
export async function readOriginalCwd(transcriptPath: string): Promise<string | null> {
|
|
261
|
+
let raw: string;
|
|
262
|
+
try { raw = await fs.readFile(transcriptPath, "utf8"); }
|
|
263
|
+
catch (e: unknown) {
|
|
264
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
265
|
+
throw e;
|
|
266
|
+
}
|
|
267
|
+
const lines = raw.split(/\r?\n/).slice(0, 50);
|
|
268
|
+
for (const line of lines) {
|
|
269
|
+
if (!line || !line.includes('"cwd"')) continue;
|
|
270
|
+
try {
|
|
271
|
+
const e: any = JSON.parse(line);
|
|
272
|
+
if (typeof e.cwd === "string" && e.cwd) return e.cwd;
|
|
273
|
+
} catch { /* skip unparseable */ }
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Cheap check: does this session's transcript contain ANY real user/assistant
|
|
280
|
+
* turn? Used by daemon to decide if a wrap-spawned session that closed without
|
|
281
|
+
* activity should be auto-deleted (vs. left as stale).
|
|
282
|
+
*
|
|
283
|
+
* "Real" means: text content from role=user (typed by human) OR role=assistant
|
|
284
|
+
* with non-synthetic content. Tool_result blocks (which appear as role=user)
|
|
285
|
+
* and synthetic placeholders ("No response requested.") are ignored.
|
|
286
|
+
*
|
|
287
|
+
* Returns false if the file doesn't exist (treated as "no turns yet").
|
|
288
|
+
*/
|
|
289
|
+
export async function sessionHasAnyTurns(transcriptPath: string): Promise<boolean> {
|
|
290
|
+
let raw: string;
|
|
291
|
+
try { raw = await fs.readFile(transcriptPath, "utf8"); }
|
|
292
|
+
catch (e: unknown) {
|
|
293
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
294
|
+
if (code === "ENOENT") return false;
|
|
295
|
+
throw e;
|
|
296
|
+
}
|
|
297
|
+
const lines = raw.split(/\r?\n/);
|
|
298
|
+
for (const line of lines) {
|
|
299
|
+
if (!line) continue;
|
|
300
|
+
let e: any; try { e = JSON.parse(line); } catch { continue; }
|
|
301
|
+
// Real user typing or any non-synthetic assistant output counts.
|
|
302
|
+
const msg = e.message;
|
|
303
|
+
if (!msg) continue;
|
|
304
|
+
if (msg.model === "<synthetic>") continue;
|
|
305
|
+
if (msg.role !== "user" && msg.role !== "assistant") continue;
|
|
306
|
+
if (typeof msg.content === "string") {
|
|
307
|
+
const t = msg.content.trim();
|
|
308
|
+
if (t && t !== "No response requested." && t !== "(no content)") return true;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (!Array.isArray(msg.content)) continue;
|
|
312
|
+
for (const block of msg.content) {
|
|
313
|
+
if (!block || typeof block !== "object") continue;
|
|
314
|
+
// Text from either role = real turn. tool_use only on assistant side =
|
|
315
|
+
// a real turn (model decided to act). tool_result we deliberately skip
|
|
316
|
+
// (it's just the wrapper's first SDK-init artifact in many cases).
|
|
317
|
+
if (block.type === "text" && typeof block.text === "string" && block.text.trim()) return true;
|
|
318
|
+
if (block.type === "tool_use" && msg.role === "assistant") return true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Read last `limit` "interesting" turns from a Claude Code JSONL transcript. */
|
|
325
|
+
export async function readTranscriptTail(
|
|
326
|
+
filePath: string,
|
|
327
|
+
limit = 20,
|
|
328
|
+
): Promise<TranscriptTurn[]> {
|
|
329
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
330
|
+
const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
|
|
331
|
+
|
|
332
|
+
const turns: TranscriptTurn[] = [];
|
|
333
|
+
for (let i = lines.length - 1; i >= 0 && turns.length < limit; i--) {
|
|
334
|
+
const line = lines[i];
|
|
335
|
+
if (!line) continue;
|
|
336
|
+
let entry: any;
|
|
337
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
338
|
+
|
|
339
|
+
const ts = entry.timestamp ?? "";
|
|
340
|
+
|
|
341
|
+
// Slash commands (e.g. /model, /clear) are stored as type:"system" entries:
|
|
342
|
+
// - subtype:"local_command", content:"/model sonnet4.5" ← user typed
|
|
343
|
+
// - subtype:"local_command", content:"<local-command-stdout>...</local-command-stdout>" ← SDK response
|
|
344
|
+
// They never appear as message.role=user/assistant, so the dashboard
|
|
345
|
+
// dropped them silently. Surface them in the conversation column.
|
|
346
|
+
if (entry.type === "system" && entry.subtype === "local_command" && typeof entry.content === "string") {
|
|
347
|
+
const raw = entry.content;
|
|
348
|
+
const out = raw.match(/^<local-command-stdout>([\s\S]*)<\/local-command-stdout>$/);
|
|
349
|
+
if (out) {
|
|
350
|
+
const text = (out[1] ?? "").trim();
|
|
351
|
+
if (text) turns.push({ ts, role: "assistant", text: `↪ ${text}`, raw_type: "local_command_stdout" });
|
|
352
|
+
} else {
|
|
353
|
+
const text = raw.trim();
|
|
354
|
+
if (text) turns.push({ ts, role: "user", text: `⚡ ${text}`, raw_type: "local_command_input" });
|
|
355
|
+
}
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const msg = entry.message;
|
|
360
|
+
if (!msg) continue;
|
|
361
|
+
const rawRole = msg.role;
|
|
362
|
+
if (rawRole !== "user" && rawRole !== "assistant") continue;
|
|
363
|
+
|
|
364
|
+
// Skip synthetic placeholder messages
|
|
365
|
+
if (msg.model === "<synthetic>") continue;
|
|
366
|
+
|
|
367
|
+
// Harness-injected user-role entries (skill content, task-notification,
|
|
368
|
+
// tool-originated turns) should be surfaced as "system" so the modal
|
|
369
|
+
// doesn't mislabel them as something the human typed. tool_result blocks
|
|
370
|
+
// keep role "user" (they're conceptually the tool's reply lane).
|
|
371
|
+
const injected = rawRole === "user" && isInjectedUserEntry(entry);
|
|
372
|
+
|
|
373
|
+
const content = msg.content;
|
|
374
|
+
if (typeof content === "string") {
|
|
375
|
+
const trimmed = content.trim();
|
|
376
|
+
if (!trimmed) continue;
|
|
377
|
+
if (trimmed === "No response requested." || trimmed === "(no content)") continue;
|
|
378
|
+
const role: TranscriptTurn["role"] = injected ? "system" : rawRole;
|
|
379
|
+
turns.push({ ts, role, text: content, raw_type: entry.type });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (!Array.isArray(content)) continue;
|
|
383
|
+
|
|
384
|
+
// For multi-block messages: emit ONE turn per "interesting block" so the
|
|
385
|
+
// dashboard can render text vs tool_use vs tool_result distinctly.
|
|
386
|
+
//
|
|
387
|
+
// BUG NOTE: iterating blocks in FORWARD order while iterating entries
|
|
388
|
+
// BACKWARD then calling `turns.reverse()` at the end inverts the
|
|
389
|
+
// block-within-entry order. e.g. an assistant message with [B1, B2, B3]
|
|
390
|
+
// ended up rendered as [B3, B2, B1] — early text vanished behind a
|
|
391
|
+
// later tool, looking like "only one round shown". Fix: iterate blocks
|
|
392
|
+
// BACKWARD too so the outer reverse() restores correct order.
|
|
393
|
+
for (let bi = content.length - 1; bi >= 0; bi--) {
|
|
394
|
+
const block = content[bi];
|
|
395
|
+
if (!block || typeof block !== "object") continue;
|
|
396
|
+
if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
|
|
397
|
+
const role: TranscriptTurn["role"] = injected ? "system" : rawRole;
|
|
398
|
+
turns.push({ ts, role, text: block.text, raw_type: entry.type });
|
|
399
|
+
} else if (block.type === "tool_use") {
|
|
400
|
+
const name = block.name ?? "?";
|
|
401
|
+
const input = block.input ?? {};
|
|
402
|
+
const desc = typeof (input as any)?.description === "string"
|
|
403
|
+
? (input as any).description
|
|
404
|
+
: undefined;
|
|
405
|
+
turns.push({
|
|
406
|
+
ts, role: rawRole, text: "", raw_type: entry.type,
|
|
407
|
+
tool_use: {
|
|
408
|
+
id: block.id ?? "",
|
|
409
|
+
name,
|
|
410
|
+
description: desc,
|
|
411
|
+
input,
|
|
412
|
+
input_summary: summarizeToolInput(name, input),
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
} else if (block.type === "tool_result") {
|
|
416
|
+
const full = stringifyResult(block.content);
|
|
417
|
+
const truncated = full.length > MAX_TOOL_RESULT_BYTES;
|
|
418
|
+
turns.push({
|
|
419
|
+
ts, role: rawRole, text: "", raw_type: entry.type,
|
|
420
|
+
tool_result: {
|
|
421
|
+
tool_use_id: block.tool_use_id,
|
|
422
|
+
content: truncated ? full.slice(0, MAX_TOOL_RESULT_BYTES) + "\n…[truncated]" : full,
|
|
423
|
+
truncated,
|
|
424
|
+
is_error: block.is_error === true,
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// We pushed entries newest-first AND blocks-within-entry latest-first.
|
|
432
|
+
// Trim to the requested limit (drops oldest blocks of the oldest visible
|
|
433
|
+
// entry first — desired behavior: keep latest content visible), then
|
|
434
|
+
// reverse for chronological order. The previous in-loop limit check
|
|
435
|
+
// could chop off MID-entry, causing single-message data loss.
|
|
436
|
+
return turns.slice(0, limit).reverse();
|
|
437
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import type { Session, StoreEvents } from "./types.js";
|
|
4
|
+
|
|
5
|
+
// v2 schema: PK is session_uuid (one row per Claude session, not per workspace).
|
|
6
|
+
// Multiple sessions per cwd are supported (e.g. 3 Claude tabs in the same VSCode workspace).
|
|
7
|
+
const SCHEMA_VERSION = 2;
|
|
8
|
+
|
|
9
|
+
const CREATE_SQL = `
|
|
10
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
11
|
+
session_uuid TEXT PRIMARY KEY,
|
|
12
|
+
cwd TEXT NOT NULL,
|
|
13
|
+
project_name TEXT NOT NULL,
|
|
14
|
+
status TEXT NOT NULL,
|
|
15
|
+
last_event_at INTEGER NOT NULL,
|
|
16
|
+
last_message_preview TEXT NOT NULL DEFAULT '',
|
|
17
|
+
tokens_in INTEGER NOT NULL DEFAULT 0,
|
|
18
|
+
tokens_out INTEGER NOT NULL DEFAULT 0,
|
|
19
|
+
vscode_pid INTEGER
|
|
20
|
+
);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_last_event ON sessions(last_event_at);
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
const UPSERT_SQL = `
|
|
26
|
+
INSERT INTO sessions (session_uuid, cwd, project_name, status, last_event_at,
|
|
27
|
+
last_message_preview, tokens_in, tokens_out, vscode_pid)
|
|
28
|
+
VALUES (@session_uuid, @cwd, @project_name, @status, @last_event_at,
|
|
29
|
+
@last_message_preview, @tokens_in, @tokens_out, @vscode_pid)
|
|
30
|
+
ON CONFLICT(session_uuid) DO UPDATE SET
|
|
31
|
+
cwd = excluded.cwd,
|
|
32
|
+
project_name = excluded.project_name,
|
|
33
|
+
status = excluded.status,
|
|
34
|
+
last_event_at = excluded.last_event_at,
|
|
35
|
+
last_message_preview = excluded.last_message_preview,
|
|
36
|
+
tokens_in = excluded.tokens_in,
|
|
37
|
+
tokens_out = excluded.tokens_out,
|
|
38
|
+
vscode_pid = excluded.vscode_pid;
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
export interface SessionStore extends EventEmitter {
|
|
42
|
+
on<K extends keyof StoreEvents>(event: K, listener: StoreEvents[K]): this;
|
|
43
|
+
emit<K extends keyof StoreEvents>(event: K, ...args: Parameters<StoreEvents[K]>): boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class SessionStore extends EventEmitter {
|
|
47
|
+
private db: Database.Database;
|
|
48
|
+
|
|
49
|
+
constructor(path: string) {
|
|
50
|
+
super();
|
|
51
|
+
this.db = new Database(path);
|
|
52
|
+
this.db.pragma("journal_mode = WAL");
|
|
53
|
+
this.migrateIfNeeded();
|
|
54
|
+
this.db.exec(CREATE_SQL);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* If the existing DB has an old schema (v1, where PK was cwd), drop the table.
|
|
59
|
+
* Data loss is acceptable here — session state rebuilds from incoming hooks within seconds.
|
|
60
|
+
*/
|
|
61
|
+
private migrateIfNeeded(): void {
|
|
62
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (version INTEGER NOT NULL);`);
|
|
63
|
+
const row = this.db.prepare("SELECT version FROM schema_meta LIMIT 1").get() as { version?: number } | undefined;
|
|
64
|
+
const currentVersion = row?.version ?? 1;
|
|
65
|
+
if (currentVersion !== SCHEMA_VERSION) {
|
|
66
|
+
this.db.exec(`DROP TABLE IF EXISTS sessions;`);
|
|
67
|
+
this.db.exec(`DELETE FROM schema_meta;`);
|
|
68
|
+
this.db.prepare("INSERT INTO schema_meta (version) VALUES (?)").run(SCHEMA_VERSION);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Upsert a session. `session.session_uuid` MUST be non-null (the PK).
|
|
74
|
+
* Throws if null — callers should drop events without session_uuid.
|
|
75
|
+
*/
|
|
76
|
+
upsert(session: Session): void {
|
|
77
|
+
if (!session.session_uuid) {
|
|
78
|
+
throw new Error("SessionStore.upsert: session_uuid is required (cannot be null)");
|
|
79
|
+
}
|
|
80
|
+
this.db.prepare(UPSERT_SQL).run(session);
|
|
81
|
+
this.emit("session_changed", session);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Get by session_uuid (primary key). */
|
|
85
|
+
get(sessionUuid: string): Session | undefined {
|
|
86
|
+
return this.db.prepare("SELECT * FROM sessions WHERE session_uuid = ?").get(sessionUuid) as Session | undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Get all sessions for a given cwd (may be multiple — e.g. 3 Claude tabs in the same workspace). */
|
|
90
|
+
getByCwd(cwd: string): Session[] {
|
|
91
|
+
return this.db.prepare("SELECT * FROM sessions WHERE cwd = ? ORDER BY last_event_at DESC").all(cwd) as Session[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
list(): Session[] {
|
|
95
|
+
return this.db.prepare("SELECT * FROM sessions ORDER BY last_event_at DESC").all() as Session[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Remove a session by UUID. Emits `session_removed`. No-op if not found. */
|
|
99
|
+
remove(sessionUuid: string): boolean {
|
|
100
|
+
const r = this.db.prepare("DELETE FROM sessions WHERE session_uuid = ?").run(sessionUuid);
|
|
101
|
+
if (r.changes > 0) {
|
|
102
|
+
this.emit("session_removed", sessionUuid);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Wipe all sessions. Hard delete — last resort, prefer markAllStale().
|
|
110
|
+
*/
|
|
111
|
+
truncate(): number {
|
|
112
|
+
const before = (this.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
|
113
|
+
this.db.exec("DELETE FROM sessions;");
|
|
114
|
+
return before;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Mark every session as "stale". Called on daemon startup — when the daemon
|
|
119
|
+
* was off we don't know what state anything is in; the next incoming hook
|
|
120
|
+
* will upgrade the row back to "active". Dashboard can use a filter to
|
|
121
|
+
* hide stale rows. Returns the count that was touched.
|
|
122
|
+
*/
|
|
123
|
+
markAllStale(): number {
|
|
124
|
+
const r = this.db.prepare("UPDATE sessions SET status = 'stale'").run();
|
|
125
|
+
return r.changes;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
close(): void {
|
|
129
|
+
this.db.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type SessionStatus = "active" | "waiting" | "idle" | "stale";
|
|
2
|
+
|
|
3
|
+
export interface Session {
|
|
4
|
+
cwd: string; // primary key, e.g. "d:\\code\\dragonfly"
|
|
5
|
+
session_uuid: string | null;
|
|
6
|
+
project_name: string;
|
|
7
|
+
status: SessionStatus;
|
|
8
|
+
last_event_at: number; // unix ms
|
|
9
|
+
last_message_preview: string;
|
|
10
|
+
tokens_in: number;
|
|
11
|
+
tokens_out: number;
|
|
12
|
+
vscode_pid: number | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type HookEventType =
|
|
16
|
+
| "session_start"
|
|
17
|
+
| "stop"
|
|
18
|
+
| "user_prompt"
|
|
19
|
+
| "pre_tool_use"
|
|
20
|
+
| "post_tool_use";
|
|
21
|
+
|
|
22
|
+
export interface HookEvent {
|
|
23
|
+
event_type: HookEventType;
|
|
24
|
+
cwd: string;
|
|
25
|
+
session_uuid: string | null;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
extra?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StoreEvents {
|
|
31
|
+
session_changed: (session: Session) => void;
|
|
32
|
+
session_removed: (sessionUuid: string) => void;
|
|
33
|
+
}
|