mirror-mind 0.32.1 → 0.33.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/.claude/hooks/log-assistant-reply.sh +85 -0
- package/.pi/extensions/mirror-logger.ts +107 -22
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.33.0.md +164 -0
- package/docs/releases/v0.33.1.md +174 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +6 -2
- package/plugins/mirror-mind/hooks/hooks.json +18 -6
- package/plugins/mirror-mind/hooks/log-assistant-reply.sh +98 -0
- package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +4 -2
- package/ts/src/conversation/hookPayload.ts +51 -0
- package/ts/src/conversation/logger.ts +300 -25
- package/ts/src/conversation/loggerCli.ts +41 -2
- package/ts/src/frontDoor/cli.ts +8 -1
- package/ts/src/frontDoor/conversationLoggerRoute.ts +20 -10
- package/ts/src/frontDoor/routing.ts +2 -0
- package/ts/src/frontDoor/runtimeRoute.ts +142 -24
- package/ts/src/frontDoor/sessionStartWelcomeRoute.ts +166 -0
- package/ts/src/frontDoor/stdinSource.ts +37 -0
- package/ts/src/guards/claudePlugin.ts +56 -10
- package/ts/src/guards/packContents.ts +1 -1
- package/ts/src/hooks/claude.ts +52 -14
- package/ts/src/hooks/payload.ts +18 -13
- package/ts/src/hooks/runtime.ts +24 -4
- package/ts/src/hooks/runtimeDetection.ts +34 -0
- package/ts/src/mirror/mirrorForm.ts +43 -0
- package/ts/src/runtime/git.ts +8 -2
- package/ts/src/runtime/releaseNotes.ts +230 -52
- package/ts/src/runtime/strategies/clone.ts +10 -3
- package/ts/src/runtime/strategies/package.ts +112 -23
- package/ts/src/util/tarEntries.ts +128 -0
- package/ts/src/welcome/card.ts +182 -60
- package/ts/src/welcome/statusLine.ts +12 -2
- package/ts/src/welcome/updateCache.ts +45 -27
- package/plugins/mirror-mind/mcp/launch.sh +0 -52
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mirror-mind",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.1",
|
|
4
4
|
"description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mirror Mind"
|
|
7
7
|
},
|
|
8
8
|
"mcpServers": {
|
|
9
9
|
"mirror-mind": {
|
|
10
|
-
"command": "
|
|
10
|
+
"command": "/bin/sh",
|
|
11
|
+
"args": [
|
|
12
|
+
"-c",
|
|
13
|
+
"mmbin=\"\"; [ -n \"$MIRROR_BIN\" ] && [ -x \"$MIRROR_BIN/mirror\" ] && mmbin=\"$MIRROR_BIN/mirror\"; [ -n \"$mmbin\" ] || mmbin=\"$(command -v mirror 2>/dev/null)\"; for mmdir in \"$HOME/.nvm/current/bin\" /opt/homebrew/bin /usr/local/bin; do [ -n \"$mmbin\" ] && break; [ -x \"$mmdir/mirror\" ] && mmbin=\"$mmdir/mirror\"; done; [ -n \"$mmbin\" ] || { echo \"mirror-mind: mirror not found on PATH; the MCP server cannot start. Install mirror-mind (npm install -g mirror-mind) or set MIRROR_BIN to its bin directory.\" >&2; exit 1; }; mmnode=\"$MIRROR_NODE\"; [ -n \"$mmnode\" ] && [ -x \"$mmnode\" ] || mmnode=\"$(command -v node 2>/dev/null)\"; for mmdir in \"$(dirname \"$mmbin\")\" \"$HOME/.nvm/current/bin\" /opt/homebrew/bin /usr/local/bin; do [ -n \"$mmnode\" ] && break; [ -x \"$mmdir/node\" ] && mmnode=\"$mmdir/node\"; done; [ -n \"$mmnode\" ] || { echo \"mirror-mind: node not found on PATH; the MCP server cannot start. Set MIRROR_NODE.\" >&2; exit 1; }; exec \"$mmnode\" \"$mmbin\" mcp"
|
|
14
|
+
]
|
|
11
15
|
}
|
|
12
16
|
}
|
|
13
17
|
}
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh",
|
|
10
|
-
"timeout":
|
|
9
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh\"",
|
|
10
|
+
"timeout": 300
|
|
11
11
|
}
|
|
12
12
|
]
|
|
13
13
|
}
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"hooks": [
|
|
19
19
|
{
|
|
20
20
|
"type": "command",
|
|
21
|
-
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/mirror-inject.sh",
|
|
22
|
-
"timeout":
|
|
21
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/mirror-inject.sh\"",
|
|
22
|
+
"timeout": 30
|
|
23
23
|
}
|
|
24
24
|
]
|
|
25
25
|
},
|
|
@@ -28,7 +28,19 @@
|
|
|
28
28
|
"hooks": [
|
|
29
29
|
{
|
|
30
30
|
"type": "command",
|
|
31
|
-
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/log-user-prompt.sh",
|
|
31
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/log-user-prompt.sh\"",
|
|
32
|
+
"async": true
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"Stop": [
|
|
38
|
+
{
|
|
39
|
+
"matcher": "",
|
|
40
|
+
"hooks": [
|
|
41
|
+
{
|
|
42
|
+
"type": "command",
|
|
43
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/log-assistant-reply.sh\"",
|
|
32
44
|
"async": true
|
|
33
45
|
}
|
|
34
46
|
]
|
|
@@ -40,7 +52,7 @@
|
|
|
40
52
|
"hooks": [
|
|
41
53
|
{
|
|
42
54
|
"type": "command",
|
|
43
|
-
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/log-session-end.sh",
|
|
55
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/log-session-end.sh\"",
|
|
44
56
|
"async": true
|
|
45
57
|
}
|
|
46
58
|
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Mirror Mind hook wrapper — GENERATED by scripts/generate_hook_wrappers.sh.
|
|
3
|
+
# Edit the generator, never this file: every wrapper shares these bytes and
|
|
4
|
+
# differs only in the hook name and in how it finds the Mirror entry
|
|
5
|
+
# (CV22.DS10.TS5 decision D4; CV22.DS10.US3 decision D5).
|
|
6
|
+
set -uo pipefail
|
|
7
|
+
HOOK="claude:stop"
|
|
8
|
+
|
|
9
|
+
# This wrapper is the Claude plugin's, and a plugin is copied into Claude
|
|
10
|
+
# Code's own cache: no relative path from here reaches the Mirror tree. It
|
|
11
|
+
# runs the installed `mirror-hook` bin instead, and reads no tree .env.
|
|
12
|
+
REPO_ROOT=""
|
|
13
|
+
|
|
14
|
+
# A hook never fails the user's turn, and never fails in silence: when it
|
|
15
|
+
# cannot do its job it appends ONE line to <mirror home>/hooks.log and exits 0.
|
|
16
|
+
# The home is found the way the core finds it -- MIRROR_HOME, else
|
|
17
|
+
# MIRROR_USER -- from the environment when set there, else from the tree's
|
|
18
|
+
# .env when the wrapper is in a tree, else from the user's config file
|
|
19
|
+
# (~/.config/mirror/env), in the core's order (US3, D3). A GUI launch carries
|
|
20
|
+
# neither variable, and the first wrappers then wrote to a home nobody reads.
|
|
21
|
+
env_file_value() {
|
|
22
|
+
[ -f "$2" ] || return 0
|
|
23
|
+
sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?$1[[:space:]]*=[[:space:]]*//p" "$2" \
|
|
24
|
+
| tail -n 1 | tr -d "\"'\r"
|
|
25
|
+
}
|
|
26
|
+
env_value() {
|
|
27
|
+
local value=""
|
|
28
|
+
if [ -n "${!1+set}" ]; then printf '%s' "${!1}"; return; fi
|
|
29
|
+
[ -z "$REPO_ROOT" ] || value="$(env_file_value "$1" "$REPO_ROOT/.env")"
|
|
30
|
+
[ -n "$value" ] || value="$(env_file_value "$1" "${XDG_CONFIG_HOME:-$HOME/.config}/mirror/env")"
|
|
31
|
+
printf '%s' "$value"
|
|
32
|
+
}
|
|
33
|
+
note() {
|
|
34
|
+
local home user
|
|
35
|
+
home="$(env_value MIRROR_HOME)"
|
|
36
|
+
user="$(env_value MIRROR_USER)"
|
|
37
|
+
if [ -n "$home" ]; then
|
|
38
|
+
home="${home/#\~/$HOME}"
|
|
39
|
+
elif [ -n "$user" ]; then
|
|
40
|
+
home="$HOME/.mirror-minds/$user"
|
|
41
|
+
[ -d "$home" ] || [ ! -d "$HOME/.mirror/$user" ] || home="$HOME/.mirror/$user"
|
|
42
|
+
else
|
|
43
|
+
return 0
|
|
44
|
+
fi
|
|
45
|
+
mkdir -p "$home" 2>/dev/null \
|
|
46
|
+
&& printf '%s %s: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$HOOK" "$1" >> "$home/hooks.log" 2>/dev/null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Find the bin. MIRROR_BIN names the directory holding `mirror` and
|
|
50
|
+
# `mirror-hook` (`$(npm prefix -g)/bin`), the same trust class as MIRROR_NODE;
|
|
51
|
+
# then the PATH; then the global bin directories a GUI launch leaves off it.
|
|
52
|
+
ENTRY=""
|
|
53
|
+
[ -n "${MIRROR_BIN:-}" ] && [ -x "$MIRROR_BIN/mirror-hook" ] && ENTRY="$MIRROR_BIN/mirror-hook"
|
|
54
|
+
[ -n "$ENTRY" ] || ENTRY="$(command -v mirror-hook 2>/dev/null || true)"
|
|
55
|
+
for candidate in "$HOME/.nvm/current/bin" /opt/homebrew/bin /usr/local/bin; do
|
|
56
|
+
[ -n "$ENTRY" ] && break
|
|
57
|
+
[ -x "$candidate/mirror-hook" ] && ENTRY="$candidate/mirror-hook"
|
|
58
|
+
done
|
|
59
|
+
|
|
60
|
+
if [ -z "$ENTRY" ]; then
|
|
61
|
+
note "mirror-hook not found on PATH; hook skipped. Install mirror-mind (npm install -g mirror-mind) or set MIRROR_BIN to its bin directory."
|
|
62
|
+
exit 0
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# Find Node. /usr/bin/python3 exists on every macOS; node usually lives under
|
|
66
|
+
# nvm or Homebrew and is NOT on the PATH of a GUI-launched runtime. The Python
|
|
67
|
+
# hooks could end in `|| true` because they always found their interpreter;
|
|
68
|
+
# the same silence here would hide a new failure — no logging, no inject, no
|
|
69
|
+
# close tail — that a user would meet weeks later as "Mirror stopped
|
|
70
|
+
# remembering". So an unresolvable Node is RECORDED, never swallowed. The
|
|
71
|
+
# directory the entry was found in is searched first: an npm global bin holds
|
|
72
|
+
# the `node` that installed the package beside `mirror-hook`, while a system
|
|
73
|
+
# `/usr/local/bin/node` can be years older (CI's Ubuntu runner has one).
|
|
74
|
+
NODE="${MIRROR_NODE:-}"
|
|
75
|
+
if [ -z "$NODE" ] || [ ! -x "$NODE" ]; then
|
|
76
|
+
NODE="$(command -v node 2>/dev/null || true)"
|
|
77
|
+
fi
|
|
78
|
+
for candidate in "$(dirname "$ENTRY")/node" "$HOME/.nvm/current/bin/node" /opt/homebrew/bin/node /usr/local/bin/node; do
|
|
79
|
+
[ -n "$NODE" ] && break
|
|
80
|
+
[ -x "$candidate" ] && NODE="$candidate"
|
|
81
|
+
done
|
|
82
|
+
|
|
83
|
+
if [ -z "$NODE" ]; then
|
|
84
|
+
note "node not found on PATH; hook skipped. Set MIRROR_NODE."
|
|
85
|
+
exit 0
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# Not `exec`: the entry exits 0 whatever happens inside it, so a non-zero exit
|
|
89
|
+
# means Node itself could not run the hook -- too old for the loader shim, or
|
|
90
|
+
# it crashed. That used to vanish into a stderr no runtime shows (TS5 handoff
|
|
91
|
+
# review, finding N1). No flags: the entry reads configuration and silences
|
|
92
|
+
# its own warnings (US3, D3).
|
|
93
|
+
"$NODE" "$ENTRY" "$HOOK" "$@"
|
|
94
|
+
status=$?
|
|
95
|
+
if [ "$status" -ne 0 ]; then
|
|
96
|
+
note "node exited $status before the hook finished ($NODE, $("$NODE" --version 2>/dev/null || echo 'version unknown')); Mirror needs Node 24 or later. Set MIRROR_NODE."
|
|
97
|
+
fi
|
|
98
|
+
exit 0
|
|
@@ -29,7 +29,7 @@ Use this procedure to answer in Mirror Mode. Automatic routing in `CLAUDE.md` de
|
|
|
29
29
|
|
|
30
30
|
This is always the first action, before any visible output to the user.
|
|
31
31
|
|
|
32
|
-
> **Automatic hook:** When the user invokes
|
|
32
|
+
> **Automatic hook:** When the user invokes this skill explicitly, `mirror-inject.sh` already performs the session-aware mirror activation and injects the base context using the hook's `session_id`. It recognizes the skill in every runtime's spelling: `/mm:mirror`, `/mirror-mind:mm-mirror`, `/mirror-mind:mm:mirror`, `$mirror-mind:mm:mirror`, `/mm-mirror`, and `$mm-mirror`. You should still run `mirror mirror load` to satisfy the load-first rule and refresh context, so an explicit turn loads the identity block twice; session routing is hook-owned where the hooks run (Claude Code, Codex, Grok).
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
35
|
mirror mirror load \
|
|
@@ -62,4 +62,6 @@ Use the `load` output as the complete response context.
|
|
|
62
62
|
|
|
63
63
|
## 3. Record Response
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
Where Mirror's hooks run (Claude Code, Codex, Grok), record **nothing** yourself: do **not** run `mirror mirror log`, and do not invent a session id. The prompt hook logs the question (without the `/mm:mirror` form) and the Stop hook logs your reply beside it, under the runtime's own name; a summary logged by hand would sit beside the reply as a second answer.
|
|
66
|
+
|
|
67
|
+
Elsewhere, if the runtime has an explicit session-aware logging path, record a concise 2-3 sentence summary there.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The fields a Claude-schema hook payload carries, in both spellings
|
|
2
|
+
// (CV21.DS2.US8).
|
|
3
|
+
//
|
|
4
|
+
// Three runtimes hand the shared hooks a payload: Claude Code and Codex in
|
|
5
|
+
// snake_case, Grok in camelCase with a snake_case alias on some fields but
|
|
6
|
+
// not all (DS12.TS4 L8). The turn is correlated under three names: Claude
|
|
7
|
+
// Code's `prompt_id`, Codex's `turn_id`, Grok's `promptId`. One reader, so
|
|
8
|
+
// the logger and the hook entries cannot disagree about where a field lives.
|
|
9
|
+
//
|
|
10
|
+
// Every reader returns "" for a missing or non-string field, and the parser
|
|
11
|
+
// returns null rather than throwing: a hook that threw on a payload it did
|
|
12
|
+
// not recognize would break the user's turn (the `extract_prompt` posture).
|
|
13
|
+
|
|
14
|
+
export type HookPayloadObject = Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
/** Parse a hook payload. Null for anything but a JSON object. */
|
|
17
|
+
export function parseHookPayloadObject(raw: string): HookPayloadObject | null {
|
|
18
|
+
try {
|
|
19
|
+
const value: unknown = JSON.parse(raw);
|
|
20
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
21
|
+
return value as HookPayloadObject;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The first of the named fields that holds a string, else "". */
|
|
28
|
+
export function payloadText(payload: HookPayloadObject, ...names: string[]): string {
|
|
29
|
+
for (const name of names) {
|
|
30
|
+
const value = payload[name];
|
|
31
|
+
if (typeof value === "string") return value;
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const sessionIdOf = (payload: HookPayloadObject): string =>
|
|
37
|
+
payloadText(payload, "session_id", "sessionId");
|
|
38
|
+
|
|
39
|
+
export const promptOf = (payload: HookPayloadObject): string => payloadText(payload, "prompt");
|
|
40
|
+
|
|
41
|
+
/** The runtime's correlation id for the turn, whatever it calls it. */
|
|
42
|
+
export const turnIdOf = (payload: HookPayloadObject): string =>
|
|
43
|
+
payloadText(payload, "prompt_id", "turn_id", "promptId");
|
|
44
|
+
|
|
45
|
+
/** The turn's final answer, as the Stop hook receives it. */
|
|
46
|
+
export const lastAssistantMessageOf = (payload: HookPayloadObject): string =>
|
|
47
|
+
payloadText(payload, "last_assistant_message", "lastAssistantMessage");
|
|
48
|
+
|
|
49
|
+
/** Set only inside a subagent (Grok names it; Claude Code and Codex fire SubagentStop instead). */
|
|
50
|
+
export const subagentTypeOf = (payload: HookPayloadObject): string =>
|
|
51
|
+
payloadText(payload, "subagentType", "subagent_type");
|
|
@@ -12,8 +12,17 @@
|
|
|
12
12
|
|
|
13
13
|
import { closeSync, existsSync, mkdirSync, openSync, rmSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
lastAssistantMessageOf,
|
|
17
|
+
parseHookPayloadObject,
|
|
18
|
+
promptOf,
|
|
19
|
+
sessionIdOf,
|
|
20
|
+
subagentTypeOf,
|
|
21
|
+
turnIdOf,
|
|
22
|
+
} from "#conversation/hookPayload.ts";
|
|
15
23
|
import { backfillAssistantMessages } from "#conversation/transcriptBackfill.ts";
|
|
16
24
|
import { type WritableDatabase, withTransaction } from "#db/database.ts";
|
|
25
|
+
import { mirrorForm } from "#mirror/mirrorForm.ts";
|
|
17
26
|
import {
|
|
18
27
|
decodeMetadata,
|
|
19
28
|
getRuntimeSession,
|
|
@@ -187,17 +196,39 @@ function discardMarkerActive(session: RuntimeSessionRow | null): boolean {
|
|
|
187
196
|
return Boolean(decodeMetadata(session?.metadata ?? null).discard_current_conversation);
|
|
188
197
|
}
|
|
189
198
|
|
|
199
|
+
/**
|
|
200
|
+
* What a message row may carry beside its text. The runtime's turn id (US8)
|
|
201
|
+
* is the one field today: written by the prompt and Stop hooks, read by the
|
|
202
|
+
* reply rule, and NULL for every message logged another way, as before.
|
|
203
|
+
*/
|
|
204
|
+
export interface MessageMetadata {
|
|
205
|
+
readonly turn_id?: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface LogMessageOptions {
|
|
209
|
+
interface: string;
|
|
210
|
+
metadata?: MessageMetadata | null;
|
|
211
|
+
}
|
|
212
|
+
|
|
190
213
|
function addMessage(
|
|
191
214
|
db: WritableDatabase,
|
|
192
215
|
conversationId: string,
|
|
193
216
|
role: "user" | "assistant",
|
|
194
217
|
content: string,
|
|
195
218
|
deps: LoggerDeps,
|
|
219
|
+
metadata: MessageMetadata | null = null,
|
|
196
220
|
): void {
|
|
197
221
|
db.prepare(
|
|
198
222
|
`INSERT INTO messages (id, conversation_id, role, content, created_at, token_count, metadata)
|
|
199
|
-
VALUES (?, ?, ?, ?, ?, NULL,
|
|
200
|
-
).run(
|
|
223
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
|
224
|
+
).run(
|
|
225
|
+
deps.newId(),
|
|
226
|
+
conversationId,
|
|
227
|
+
role,
|
|
228
|
+
content,
|
|
229
|
+
deps.nowIso(),
|
|
230
|
+
metadata ? pythonJsonDumps(metadata) : null,
|
|
231
|
+
);
|
|
201
232
|
}
|
|
202
233
|
|
|
203
234
|
/** Python `log_user_message`: discard-marker reset, bind, title-on-first, append. */
|
|
@@ -205,7 +236,7 @@ export function logUserMessage(
|
|
|
205
236
|
db: WritableDatabase,
|
|
206
237
|
sessionId: string,
|
|
207
238
|
content: string,
|
|
208
|
-
options:
|
|
239
|
+
options: LogMessageOptions,
|
|
209
240
|
deps: LoggerDeps,
|
|
210
241
|
): void {
|
|
211
242
|
let existing = getRuntimeSession(db, sessionId);
|
|
@@ -234,7 +265,7 @@ export function logUserMessage(
|
|
|
234
265
|
if (isNew) {
|
|
235
266
|
setProvisionalTitle(db, conversationId, generateTitle(content));
|
|
236
267
|
}
|
|
237
|
-
addMessage(db, conversationId, "user", content, deps);
|
|
268
|
+
addMessage(db, conversationId, "user", content, deps, options.metadata ?? null);
|
|
238
269
|
}
|
|
239
270
|
|
|
240
271
|
/** Python `log_assistant_message`: silent no-op while the discard marker holds. */
|
|
@@ -242,7 +273,7 @@ export function logAssistantMessage(
|
|
|
242
273
|
db: WritableDatabase,
|
|
243
274
|
sessionId: string,
|
|
244
275
|
content: string,
|
|
245
|
-
options:
|
|
276
|
+
options: LogMessageOptions,
|
|
246
277
|
deps: LoggerDeps,
|
|
247
278
|
): void {
|
|
248
279
|
const existing = getRuntimeSession(db, sessionId);
|
|
@@ -253,7 +284,7 @@ export function logAssistantMessage(
|
|
|
253
284
|
{ interface: options.interface },
|
|
254
285
|
deps,
|
|
255
286
|
);
|
|
256
|
-
addMessage(db, conversationId, "assistant", content, deps);
|
|
287
|
+
addMessage(db, conversationId, "assistant", content, deps, options.metadata ?? null);
|
|
257
288
|
}
|
|
258
289
|
|
|
259
290
|
/** Python `ConversationService.start_conversation`: always a fresh row. */
|
|
@@ -444,53 +475,139 @@ export type UserPromptHookOutcome =
|
|
|
444
475
|
| { action: "muted" }
|
|
445
476
|
| {
|
|
446
477
|
action: "skipped";
|
|
447
|
-
reason:
|
|
478
|
+
reason:
|
|
479
|
+
| "malformed_payload"
|
|
480
|
+
| "missing_session"
|
|
481
|
+
| "missing_prompt"
|
|
482
|
+
| "slash_command"
|
|
483
|
+
| "bare_mirror_form";
|
|
448
484
|
}
|
|
449
485
|
| { action: "logged"; sessionId: string }
|
|
450
486
|
| { action: "failed"; error: string };
|
|
451
487
|
|
|
488
|
+
export type StopHookOutcome =
|
|
489
|
+
| { action: "muted" }
|
|
490
|
+
| {
|
|
491
|
+
action: "skipped";
|
|
492
|
+
reason:
|
|
493
|
+
| "malformed_payload"
|
|
494
|
+
| "missing_session"
|
|
495
|
+
| "subagent"
|
|
496
|
+
| "empty_message"
|
|
497
|
+
| "discarded"
|
|
498
|
+
| "already_answered"
|
|
499
|
+
// Expected: the turn's prompt was a command, which history skips.
|
|
500
|
+
| "command_turn"
|
|
501
|
+
// Expected: the Stop names no turn (Grok's at session end).
|
|
502
|
+
| "session_event"
|
|
503
|
+
// Unexplained: the turn's prompt was neither logged nor skipped as a
|
|
504
|
+
// command -- the reply is lost. The only skip the caller reports.
|
|
505
|
+
| "nothing_waiting";
|
|
506
|
+
}
|
|
507
|
+
| { action: "logged"; sessionId: string; pairedBy: "turn_id" | "last_message" }
|
|
508
|
+
| { action: "failed"; error: string };
|
|
509
|
+
|
|
510
|
+
export interface HookLoggingOptions {
|
|
511
|
+
mirrorHome: string;
|
|
512
|
+
/** The runtime the hook detected (`claude_code`, `codex`, `grok_build`). */
|
|
513
|
+
interface?: string;
|
|
514
|
+
}
|
|
515
|
+
|
|
452
516
|
export type SessionEndHookOutcome =
|
|
453
517
|
| { action: "skipped"; reason: "malformed_payload" | "nothing_to_do" }
|
|
454
518
|
| { action: "ended"; sessionId: string; transcriptPath: string | null; backfilled: boolean }
|
|
455
519
|
| { action: "backfilled"; transcriptPath: string }
|
|
456
520
|
| { action: "failed"; error: string };
|
|
457
521
|
|
|
458
|
-
|
|
459
|
-
try {
|
|
460
|
-
const value: unknown = JSON.parse(raw);
|
|
461
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
462
|
-
return value as Record<string, unknown>;
|
|
463
|
-
} catch {
|
|
464
|
-
return null;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
522
|
+
const parsePayload = parseHookPayloadObject;
|
|
467
523
|
|
|
468
524
|
function payloadString(payload: Record<string, unknown>, key: string): string {
|
|
469
525
|
const value = payload[key];
|
|
470
526
|
return typeof value === "string" ? value : "";
|
|
471
527
|
}
|
|
472
528
|
|
|
473
|
-
|
|
529
|
+
function turnMetadata(turnId: string): MessageMetadata | null {
|
|
530
|
+
return turnId ? { turn_id: turnId } : null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The session-metadata key that names the last turn the prompt hook skipped as a
|
|
535
|
+
* command, so the Stop hook can tell that turn's reply -- expected to go
|
|
536
|
+
* unrecorded -- from a reply lost because its prompt never reached Mirror.
|
|
537
|
+
*/
|
|
538
|
+
const SKIPPED_TURN_KEY = "skipped_turn_id";
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Record the turn a command prompt occupied. `runtime_sessions.metadata` is a
|
|
542
|
+
* shared JSON object (the discard marker, the operating mode, Soul, Explorer,
|
|
543
|
+
* and Builder keep keys there), so the key is merged in, never written over.
|
|
544
|
+
*/
|
|
545
|
+
function recordSkippedTurn(
|
|
546
|
+
db: WritableDatabase,
|
|
547
|
+
sessionId: string,
|
|
548
|
+
turnId: string,
|
|
549
|
+
deps: LoggerDeps,
|
|
550
|
+
): void {
|
|
551
|
+
if (!turnId) return;
|
|
552
|
+
const metadata = decodeMetadata(getRuntimeSession(db, sessionId)?.metadata ?? null);
|
|
553
|
+
metadata[SKIPPED_TURN_KEY] = turnId;
|
|
554
|
+
upsertRuntimeSession(db, sessionId, { metadata: pythonJsonDumps(metadata) }, deps.nowIso());
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Python `hook_user_prompt`: mute gate, slash-command skip, never throw.
|
|
559
|
+
*
|
|
560
|
+
* Two additions since CV21.DS2.US8. The session's label is stamped on every
|
|
561
|
+
* prompt, skipped or not, before anything else can create the conversation:
|
|
562
|
+
* the Mirror Mode path creates one with `session?.interface ?? "claude_code"`,
|
|
563
|
+
* and `guessRuntimeSessionId` ignores a session row with no interface, so a
|
|
564
|
+
* Grok session whose first turn was a Mirror turn used to be labelled
|
|
565
|
+
* `claude_code` and bound to someone else's session (DS12.TS4 L7, TS2 L9).
|
|
566
|
+
* And a Mirror turn is a conversation turn: its question is logged without
|
|
567
|
+
* the form, where every other slash command stays a command.
|
|
568
|
+
*/
|
|
474
569
|
export function handleUserPromptHook(
|
|
475
570
|
db: WritableDatabase,
|
|
476
571
|
rawPayload: string,
|
|
477
|
-
options:
|
|
572
|
+
options: HookLoggingOptions,
|
|
478
573
|
deps: LoggerDeps,
|
|
479
574
|
): UserPromptHookOutcome {
|
|
480
575
|
try {
|
|
481
|
-
if (isMuted(options.mirrorHome)) return { action: "muted" };
|
|
482
|
-
|
|
483
576
|
const payload = parsePayload(rawPayload);
|
|
484
577
|
if (!payload) return { action: "skipped", reason: "malformed_payload" };
|
|
485
578
|
|
|
486
|
-
const sessionId =
|
|
487
|
-
const prompt =
|
|
579
|
+
const sessionId = sessionIdOf(payload);
|
|
580
|
+
const prompt = promptOf(payload);
|
|
488
581
|
if (!sessionId) return { action: "skipped", reason: "missing_session" };
|
|
582
|
+
|
|
583
|
+
const interfaceName = options.interface ?? "claude_code";
|
|
584
|
+
upsertRuntimeSession(db, sessionId, { interface: interfaceName }, deps.nowIso());
|
|
585
|
+
|
|
586
|
+
if (isMuted(options.mirrorHome)) return { action: "muted" };
|
|
489
587
|
if (!prompt) return { action: "skipped", reason: "missing_prompt" };
|
|
490
|
-
// Slash commands never enter conversation history.
|
|
491
|
-
if (prompt.startsWith("/")) return { action: "skipped", reason: "slash_command" };
|
|
492
588
|
|
|
493
|
-
|
|
589
|
+
const mirror = mirrorForm(prompt);
|
|
590
|
+
let content = prompt;
|
|
591
|
+
if (mirror) {
|
|
592
|
+
if (!mirror.query) {
|
|
593
|
+
recordSkippedTurn(db, sessionId, turnIdOf(payload), deps);
|
|
594
|
+
return { action: "skipped", reason: "bare_mirror_form" };
|
|
595
|
+
}
|
|
596
|
+
content = mirror.query;
|
|
597
|
+
} else if (prompt.startsWith("/")) {
|
|
598
|
+
// Slash commands never enter conversation history, and neither does
|
|
599
|
+
// their reply: the turn is recorded so the Stop hook knows why.
|
|
600
|
+
recordSkippedTurn(db, sessionId, turnIdOf(payload), deps);
|
|
601
|
+
return { action: "skipped", reason: "slash_command" };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
logUserMessage(
|
|
605
|
+
db,
|
|
606
|
+
sessionId,
|
|
607
|
+
content,
|
|
608
|
+
{ interface: interfaceName, metadata: turnMetadata(turnIdOf(payload)) },
|
|
609
|
+
deps,
|
|
610
|
+
);
|
|
494
611
|
return { action: "logged", sessionId };
|
|
495
612
|
} catch (error) {
|
|
496
613
|
// Python swallows everything here; the runtime must survive a logging fault.
|
|
@@ -498,6 +615,164 @@ export function handleUserPromptHook(
|
|
|
498
615
|
}
|
|
499
616
|
}
|
|
500
617
|
|
|
618
|
+
interface MessageRow {
|
|
619
|
+
rowid: number;
|
|
620
|
+
role: string;
|
|
621
|
+
created_at: string;
|
|
622
|
+
metadata: string | null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** The conversation's last message, in the order the rows were written. */
|
|
626
|
+
function lastMessage(db: WritableDatabase, conversationId: string): MessageRow | null {
|
|
627
|
+
const row = db
|
|
628
|
+
.prepare(
|
|
629
|
+
`SELECT rowid, role, created_at, metadata FROM messages
|
|
630
|
+
WHERE conversation_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`,
|
|
631
|
+
)
|
|
632
|
+
.get(conversationId);
|
|
633
|
+
return (row as MessageRow | undefined) ?? null;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** The latest logged prompt of the turn, if the turn's prompt was logged. */
|
|
637
|
+
function promptOfTurn(
|
|
638
|
+
db: WritableDatabase,
|
|
639
|
+
conversationId: string,
|
|
640
|
+
turnId: string,
|
|
641
|
+
): MessageRow | null {
|
|
642
|
+
const row = db
|
|
643
|
+
.prepare(
|
|
644
|
+
`SELECT rowid, role, created_at, metadata FROM messages
|
|
645
|
+
WHERE conversation_id = ? AND role = 'user' AND json_extract(metadata, '$.turn_id') = ?
|
|
646
|
+
ORDER BY created_at DESC, rowid DESC LIMIT 1`,
|
|
647
|
+
)
|
|
648
|
+
.get(conversationId, turnId);
|
|
649
|
+
return (row as MessageRow | undefined) ?? null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Whether the turn already has its reply: an assistant row carrying the same
|
|
654
|
+
* turn id, after the prompt. Only a Stop reply carries one, so an assistant row
|
|
655
|
+
* written another way -- a summary `mirror mirror log` appended by hand, a
|
|
656
|
+
* transcript backfill -- never stands in for the turn's answer.
|
|
657
|
+
*/
|
|
658
|
+
function answeredAfter(
|
|
659
|
+
db: WritableDatabase,
|
|
660
|
+
conversationId: string,
|
|
661
|
+
prompt: MessageRow,
|
|
662
|
+
turnId: string,
|
|
663
|
+
): boolean {
|
|
664
|
+
const row = db
|
|
665
|
+
.prepare(
|
|
666
|
+
`SELECT 1 FROM messages
|
|
667
|
+
WHERE conversation_id = ? AND role = 'assistant'
|
|
668
|
+
AND json_extract(metadata, '$.turn_id') = ?
|
|
669
|
+
AND (created_at > ? OR (created_at = ? AND rowid > ?)) LIMIT 1`,
|
|
670
|
+
)
|
|
671
|
+
.get(conversationId, turnId, prompt.created_at, prompt.created_at, prompt.rowid);
|
|
672
|
+
return row !== undefined;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function turnIdOfRow(row: MessageRow): string {
|
|
676
|
+
const value = decodeMetadata(row.metadata).turn_id;
|
|
677
|
+
return typeof value === "string" ? value : "";
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* The Stop hook (CV21.DS2.US8): log the turn's final answer beside its prompt.
|
|
682
|
+
*
|
|
683
|
+
* A reply is logged when it answers a logged prompt, and never otherwise:
|
|
684
|
+
*
|
|
685
|
+
* * by turn id, when the payload carries one and a logged prompt carries the
|
|
686
|
+
* same -- unless a reply with that turn id already follows it, which is a
|
|
687
|
+
* repeat Stop (a Stop re-fired after another hook blocked). An assistant
|
|
688
|
+
* row without the turn id (a hand-logged summary) does not count;
|
|
689
|
+
* * else by position, when the conversation's last message is a logged
|
|
690
|
+
* prompt -- unless both sides carry a turn id and they differ, which means
|
|
691
|
+
* this turn's prompt was a command and an older prompt is waiting because
|
|
692
|
+
* its own Stop was lost; attaching the new answer to the old question
|
|
693
|
+
* would record a conversation that never happened.
|
|
694
|
+
*
|
|
695
|
+
* The handler never creates a conversation. When nothing is waiting, it says
|
|
696
|
+
* why: `command_turn` when the prompt hook skipped this turn as a command,
|
|
697
|
+
* `session_event` when the Stop names no turn (Grok's at session end), and
|
|
698
|
+
* `nothing_waiting` only when nothing explains it -- the turn's prompt never
|
|
699
|
+
* reached Mirror, the async race the plan names, a reply lost. The caller
|
|
700
|
+
* writes a `hooks.log` line for that one alone: `runtime diagnose` reads every
|
|
701
|
+
* line there as a failure, and an expected skip is not one (Debt Review,
|
|
702
|
+
* 2026-10-13).
|
|
703
|
+
*/
|
|
704
|
+
export function handleStopHook(
|
|
705
|
+
db: WritableDatabase,
|
|
706
|
+
rawPayload: string,
|
|
707
|
+
options: HookLoggingOptions,
|
|
708
|
+
deps: LoggerDeps,
|
|
709
|
+
): StopHookOutcome {
|
|
710
|
+
try {
|
|
711
|
+
const payload = parsePayload(rawPayload);
|
|
712
|
+
if (!payload) return { action: "skipped", reason: "malformed_payload" };
|
|
713
|
+
|
|
714
|
+
const sessionId = sessionIdOf(payload);
|
|
715
|
+
if (!sessionId) return { action: "skipped", reason: "missing_session" };
|
|
716
|
+
if (isMuted(options.mirrorHome)) return { action: "muted" };
|
|
717
|
+
if (subagentTypeOf(payload)) return { action: "skipped", reason: "subagent" };
|
|
718
|
+
|
|
719
|
+
const message = lastAssistantMessageOf(payload);
|
|
720
|
+
if (!message.trim()) return { action: "skipped", reason: "empty_message" };
|
|
721
|
+
|
|
722
|
+
const session = getRuntimeSession(db, sessionId);
|
|
723
|
+
if (discardMarkerActive(session)) return { action: "skipped", reason: "discarded" };
|
|
724
|
+
const turnId = turnIdOf(payload);
|
|
725
|
+
const nothingWaiting = (): StopHookOutcome => {
|
|
726
|
+
if (!turnId) return { action: "skipped", reason: "session_event" };
|
|
727
|
+
if (decodeMetadata(session?.metadata ?? null)[SKIPPED_TURN_KEY] === turnId) {
|
|
728
|
+
return { action: "skipped", reason: "command_turn" };
|
|
729
|
+
}
|
|
730
|
+
return { action: "skipped", reason: "nothing_waiting" };
|
|
731
|
+
};
|
|
732
|
+
const conversationId = session?.conversationId ?? null;
|
|
733
|
+
if (!conversationId) return nothingWaiting();
|
|
734
|
+
|
|
735
|
+
const interfaceName = options.interface ?? session?.interface ?? "claude_code";
|
|
736
|
+
const last = lastMessage(db, conversationId);
|
|
737
|
+
|
|
738
|
+
if (turnId) {
|
|
739
|
+
const prompt = promptOfTurn(db, conversationId, turnId);
|
|
740
|
+
if (prompt) {
|
|
741
|
+
if (answeredAfter(db, conversationId, prompt, turnId)) {
|
|
742
|
+
return { action: "skipped", reason: "already_answered" };
|
|
743
|
+
}
|
|
744
|
+
logAssistantMessage(
|
|
745
|
+
db,
|
|
746
|
+
sessionId,
|
|
747
|
+
message,
|
|
748
|
+
{ interface: interfaceName, metadata: turnMetadata(turnId) },
|
|
749
|
+
deps,
|
|
750
|
+
);
|
|
751
|
+
return { action: "logged", sessionId, pairedBy: "turn_id" };
|
|
752
|
+
}
|
|
753
|
+
if (last && last.role === "user" && !turnIdOfRow(last)) {
|
|
754
|
+
logAssistantMessage(
|
|
755
|
+
db,
|
|
756
|
+
sessionId,
|
|
757
|
+
message,
|
|
758
|
+
{ interface: interfaceName, metadata: turnMetadata(turnId) },
|
|
759
|
+
deps,
|
|
760
|
+
);
|
|
761
|
+
return { action: "logged", sessionId, pairedBy: "last_message" };
|
|
762
|
+
}
|
|
763
|
+
return nothingWaiting();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (last && last.role === "user") {
|
|
767
|
+
logAssistantMessage(db, sessionId, message, { interface: interfaceName }, deps);
|
|
768
|
+
return { action: "logged", sessionId, pairedBy: "last_message" };
|
|
769
|
+
}
|
|
770
|
+
return nothingWaiting();
|
|
771
|
+
} catch (error) {
|
|
772
|
+
return { action: "failed", error: error instanceof Error ? error.message : String(error) };
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
501
776
|
/**
|
|
502
777
|
* Python `hook_session_end`: end the session when the payload names one, then
|
|
503
778
|
* backfill assistant turns from the transcript when it exists -- in that
|