mirror-mind 0.39.5 → 0.40.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/.claude/hooks/log-assistant-reply.sh +3 -2
- package/.claude/hooks/log-session-end.sh +3 -2
- package/.claude/hooks/log-user-prompt.sh +3 -2
- package/.claude/hooks/mirror-inject.sh +3 -2
- package/.claude/hooks/session-start.sh +3 -2
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.40.0-next.1.md +103 -0
- package/docs/releases/v0.40.0.md +158 -0
- package/lib/desktop.js +8 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/agy/pre-invocation.sh +3 -2
- package/plugins/mirror-mind/agy/session-start.sh +3 -2
- package/plugins/mirror-mind/agy/stop.sh +3 -2
- package/plugins/mirror-mind/hooks/log-assistant-reply.sh +3 -2
- package/plugins/mirror-mind/hooks/log-session-end.sh +3 -2
- package/plugins/mirror-mind/hooks/log-user-prompt.sh +3 -2
- package/plugins/mirror-mind/hooks/mirror-inject.sh +3 -2
- package/plugins/mirror-mind/hooks/session-start.sh +3 -2
- package/plugins/mirror-mind/plugin.json +1 -1
- package/ts/src/conversation/logger.ts +30 -30
- package/ts/src/conversation/transcriptBackfill.ts +68 -61
- package/ts/src/guards/claudePlugin.ts +3 -2
- package/ts/src/guards/retiredSurfaces.ts +9 -4
- package/ts/src/journey/journeyCalls.ts +9 -2
- package/ts/src/journey/journeyDeletion.ts +602 -0
|
@@ -1,23 +1,33 @@
|
|
|
1
|
-
// Assistant-turn backfill from a Claude transcript (CV22.DS7.US10 slice E
|
|
1
|
+
// Assistant-turn backfill from a Claude transcript (CV22.DS7.US10 slice E,
|
|
2
|
+
// rescoped by CR151).
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
+
// Started as a port of `backfill_assistant_messages` from
|
|
4
5
|
// `src/memory/cli/conversation_logger.py` together with the two helpers it
|
|
5
6
|
// borrows from `src/memory/cli/transcript_export.py` (`parse_jsonl`,
|
|
6
|
-
// `_assistant_text`). `hook_session_end` calls it after ending the session
|
|
7
|
-
// and, when the payload carries no session id at all, INSTEAD of ending one:
|
|
8
|
-
// that session-less route is the path US5's hook port deliberately left out.
|
|
7
|
+
// `_assistant_text`). `hook_session_end` calls it after ending the session.
|
|
9
8
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
9
|
+
// It now DIVERGES from that Python on purpose (CR151). Python chose the
|
|
10
|
+
// conversations to fill by time window alone, so a session's replies were
|
|
11
|
+
// copied into every open, reply-less conversation overlapping its transcript
|
|
12
|
+
// -- a session killed mid-first-turn, or a concurrent one on another runtime --
|
|
13
|
+
// and stamped them with `now`. Do not restore either behavior:
|
|
14
|
+
//
|
|
15
|
+
// * the caller names the ONE conversation the transcript belongs to (the
|
|
16
|
+
// ending session's bound conversation); nothing else is touched, and a
|
|
17
|
+
// transcript no session owns fills nothing;
|
|
18
|
+
// * an inserted reply carries the transcript's own timestamp, normalized to
|
|
19
|
+
// the logger's microsecond ISO form, so the reply sorts between the prompts
|
|
20
|
+
// around it for every reader that orders by `created_at`.
|
|
21
|
+
//
|
|
22
|
+
// This is not an import: nothing is created or bound. A conversation that
|
|
23
|
+
// already logged an assistant turn of its own (the Stop hook's path) is left
|
|
24
|
+
// alone. Appends commit as they go; a malformed entry reached mid-walk raises
|
|
25
|
+
// with the earlier appends already committed, and the hook swallows it.
|
|
17
26
|
|
|
18
27
|
import { readFileSync } from "node:fs";
|
|
19
28
|
import type { LoggerDeps } from "#conversation/logger.ts";
|
|
20
29
|
import type { WritableDatabase } from "#db/database.ts";
|
|
30
|
+
import { toMicrosecondIso } from "#util/pyGenerators.ts";
|
|
21
31
|
|
|
22
32
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
33
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -76,70 +86,67 @@ export function assistantText(contentBlocks: unknown): string {
|
|
|
76
86
|
return parts.join("\n\n");
|
|
77
87
|
}
|
|
78
88
|
|
|
79
|
-
|
|
89
|
+
const UTC_ISO = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z$/;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A timestamp in the logger's microsecond ISO-`Z` form, or null when it cannot
|
|
93
|
+
* be read as an instant. Claude writes milliseconds (`.123Z`), the logger
|
|
94
|
+
* microseconds (`.123000Z`); compared as strings the two disagree (`.123Z`
|
|
95
|
+
* sorts after `.123456Z`), so every bound and every insert goes through here.
|
|
96
|
+
* A UTC string is re-padded rather than parsed, keeping microseconds intact.
|
|
97
|
+
*/
|
|
98
|
+
export function normalizeTimestamp(value: string): string | null {
|
|
99
|
+
const utc = UTC_ISO.exec(value);
|
|
100
|
+
if (utc) return `${utc[1]}.${(utc[2] ?? "").padEnd(6, "0").slice(0, 6)}Z`;
|
|
101
|
+
const parsed = new Date(value);
|
|
102
|
+
return Number.isNaN(parsed.getTime()) ? null : toMicrosecondIso(parsed);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** An entry's normalized timestamp; absent, empty, non-string, or unreadable is null. */
|
|
80
106
|
function timestampOf(entry: unknown): string | null {
|
|
81
107
|
const value = getOr(entry, "timestamp", "");
|
|
82
|
-
return typeof value === "string" && value ? value : null;
|
|
108
|
+
return typeof value === "string" && value ? normalizeTimestamp(value) : null;
|
|
83
109
|
}
|
|
84
110
|
|
|
85
111
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* conversation
|
|
91
|
-
*
|
|
112
|
+
* Append the transcript's assistant turns to `conversationId` -- the one
|
|
113
|
+
* conversation the transcript's session is bound to, and only it (CR151).
|
|
114
|
+
* Skipped when that conversation does not exist or already logged an
|
|
115
|
+
* assistant turn. An `assistant` entry lands when its timestamp falls inside
|
|
116
|
+
* the conversation's own `[started_at, ended_at]` (open-ended while the
|
|
117
|
+
* conversation is open), and it lands stamped with that timestamp.
|
|
92
118
|
*/
|
|
93
119
|
export function backfillAssistantMessages(
|
|
94
120
|
db: WritableDatabase,
|
|
95
121
|
transcriptPath: string,
|
|
96
|
-
|
|
122
|
+
conversationId: string,
|
|
123
|
+
deps: Pick<LoggerDeps, "newId">,
|
|
97
124
|
): void {
|
|
98
|
-
const
|
|
99
|
-
|
|
125
|
+
const conversation = db
|
|
126
|
+
.prepare("SELECT started_at, ended_at FROM conversations WHERE id = ?")
|
|
127
|
+
.get(conversationId) as { started_at: string; ended_at: string | null } | undefined;
|
|
128
|
+
if (!conversation) return;
|
|
129
|
+
const hasAssistant = db
|
|
130
|
+
.prepare("SELECT 1 FROM messages WHERE conversation_id = ? AND role = 'assistant' LIMIT 1")
|
|
131
|
+
.get(conversationId);
|
|
132
|
+
if (hasAssistant) return;
|
|
100
133
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
let endTime = timestamps[0] as string;
|
|
107
|
-
for (const value of timestamps) {
|
|
108
|
-
if (value < startTime) startTime = value;
|
|
109
|
-
if (value > endTime) endTime = value;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const conversations = db
|
|
113
|
-
.prepare(
|
|
114
|
-
`SELECT id, started_at, ended_at FROM conversations
|
|
115
|
-
WHERE started_at <= ? AND (ended_at >= ? OR ended_at IS NULL)`,
|
|
116
|
-
)
|
|
117
|
-
.all(endTime, startTime) as { id: string; started_at: string; ended_at: string | null }[];
|
|
134
|
+
const start = normalizeTimestamp(conversation.started_at) ?? conversation.started_at;
|
|
135
|
+
const end =
|
|
136
|
+
conversation.ended_at === null
|
|
137
|
+
? null
|
|
138
|
+
: (normalizeTimestamp(conversation.ended_at) ?? conversation.ended_at);
|
|
118
139
|
|
|
119
|
-
const hasAssistant = db.prepare(
|
|
120
|
-
"SELECT 1 FROM messages WHERE conversation_id = ? AND role = 'assistant' LIMIT 1",
|
|
121
|
-
);
|
|
122
140
|
const insert = db.prepare(
|
|
123
141
|
`INSERT INTO messages (id, conversation_id, role, content, created_at, token_count, metadata)
|
|
124
142
|
VALUES (?, ?, 'assistant', ?, ?, NULL, NULL)`,
|
|
125
143
|
);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const timestamp = timestampOf(entry);
|
|
134
|
-
if (!timestamp || !(conversationStart <= timestamp && timestamp <= conversationEnd)) continue;
|
|
135
|
-
if (getFrom(entry, "type") !== "assistant") continue;
|
|
136
|
-
const message = getOr(entry, "message", {});
|
|
137
|
-
const text = assistantText(getOr(message, "content", []));
|
|
138
|
-
if (text) {
|
|
139
|
-
// Python: Message(...) draws its id before its created_at.
|
|
140
|
-
const id = deps.newId();
|
|
141
|
-
insert.run(id, conversation.id, text, deps.nowIso());
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
+
for (const entry of parseJsonl(transcriptPath)) {
|
|
145
|
+
const timestamp = timestampOf(entry);
|
|
146
|
+
if (!timestamp || timestamp < start || (end !== null && timestamp > end)) continue;
|
|
147
|
+
if (getFrom(entry, "type") !== "assistant") continue;
|
|
148
|
+
const message = getOr(entry, "message", {});
|
|
149
|
+
const text = assistantText(getOr(message, "content", []));
|
|
150
|
+
if (text) insert.run(deps.newId(), conversationId, text, timestamp);
|
|
144
151
|
}
|
|
145
152
|
}
|
|
@@ -87,8 +87,9 @@ export const GLOBAL_BIN_DIRS = ['"$HOME/.nvm/current/bin"', "/opt/homebrew/bin",
|
|
|
87
87
|
* `scripts/generate_hook_wrappers.sh` and into the locator below, and held
|
|
88
88
|
* equal by `mirrorNodeRule.test.ts`. It classifies exactly as
|
|
89
89
|
* `path.win32.isAbsolute`, on every platform: under Git Bash the Windows
|
|
90
|
-
* installer writes
|
|
91
|
-
*
|
|
90
|
+
* installer writes a drive-letter path,
|
|
91
|
+
* `C:\Users\…\AppData\Local\Programs\MirrorMind\node\node.exe`, which a
|
|
92
|
+
* POSIX-only pattern would refuse on every hook (PR #47). On POSIX a drive or backslash
|
|
92
93
|
* value names no file, and is refused as missing.
|
|
93
94
|
*/
|
|
94
95
|
export const ABSOLUTE_PATH_CASE = "/*|\\\\*|[A-Za-z]:[\\\\/]*";
|
|
@@ -500,8 +500,10 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
500
500
|
// Windows spells `C:\...` as `/C:/...` -- and read as one it made every
|
|
501
501
|
// install under such a directory `unknown` (PR #42, CR140). The fix is
|
|
502
502
|
// `import.meta.dirname` or `fileURLToPath`; this row keeps the idiom from
|
|
503
|
-
// coming back in code.
|
|
504
|
-
//
|
|
503
|
+
// coming back in code. The tests and smokes are in scope too (issue #44):
|
|
504
|
+
// twenty fixture and CLI paths there read a URL's pathname, so the suite
|
|
505
|
+
// failed when run from a checkout under such a directory. Only the files
|
|
506
|
+
// that spell the bug to pin it are exempt.
|
|
505
507
|
surfaceId: "url-pathname-as-path",
|
|
506
508
|
story: "PR #42 (CR140)",
|
|
507
509
|
absentPaths: [],
|
|
@@ -509,8 +511,11 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
509
511
|
// off a variable (`url.pathname`). Prose writes `URL.pathname`, capital U,
|
|
510
512
|
// which neither pattern matches, so a comment may still name the cause.
|
|
511
513
|
forbiddenPatterns: ["\\)\\.pathname\\b", "\\b[a-z_$][\\w$]*\\.pathname\\b"],
|
|
512
|
-
residueScope: ["ts/src/", "ts/scripts/"],
|
|
513
|
-
exemptions: {
|
|
514
|
+
residueScope: ["ts/src/", "ts/scripts/", "ts/test/", "ts/smoke/"],
|
|
515
|
+
exemptions: {
|
|
516
|
+
"ts/test/runtime/installKind.test.ts":
|
|
517
|
+
"asserts what a URL's pathname spells for a non-ASCII checkout, the bug it pins",
|
|
518
|
+
},
|
|
514
519
|
},
|
|
515
520
|
{
|
|
516
521
|
surfaceId: "gemini-cli",
|
|
@@ -49,12 +49,19 @@ export type JourneyCallRejection =
|
|
|
49
49
|
| "unknown_parent"
|
|
50
50
|
| "unknown_journey"
|
|
51
51
|
| "journey_changed"
|
|
52
|
-
| "journey_not_empty"
|
|
52
|
+
| "journey_not_empty"
|
|
53
|
+
// `deleteJourneyWithRecords` (journeyDeletion.ts):
|
|
54
|
+
| "journey_has_children"
|
|
55
|
+
| "extension_rows_held"
|
|
56
|
+
| "backup_failed";
|
|
53
57
|
|
|
54
58
|
/** A call's refusal, named with the administration's codes where it had one. Nothing was written. */
|
|
55
59
|
export class JourneyCallRejectedError extends Error {
|
|
56
60
|
readonly reason: JourneyCallRejection;
|
|
57
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* For `journey_not_empty`: the association classes that hold records. For
|
|
63
|
+
* `journey_has_children`: the child Journeys. For `extension_rows_held`: the table.
|
|
64
|
+
*/
|
|
58
65
|
readonly populated: string[];
|
|
59
66
|
|
|
60
67
|
constructor(reason: JourneyCallRejection, populated: string[] = []) {
|