mirror-mind 0.40.0-next.1 → 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/docs/releases/index.md +1 -0
- package/docs/releases/v0.40.0.md +158 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- 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/docs/releases/index.md
CHANGED
|
@@ -12,6 +12,7 @@ Future release notes should use the structure defined in [Release Notes](../proc
|
|
|
12
12
|
|
|
13
13
|
## Releases
|
|
14
14
|
|
|
15
|
+
- [v0.40.0 — A Reply Stays in Its Own Conversation, and a Journey Can Go](v0.40.0.md) — minor release closing the first release train: CR151, a session's transcript fills only the conversation that session is bound to, each reply stamped with its transcript time, where it used to fill every open reply-less conversation in the transcript's window, another session's included; a transcript no session owns backfills nothing; and CR200, previewed as v0.40.0-next.1, `mirror-mind/desktop` deletes a Journey with every record that belongs to it, by digest, behind a verified backup. Update every install that has a home. No migration.
|
|
15
16
|
- [v0.40.0-next.1 — A Journey Can Go, Showing What Goes With It](v0.40.0-next.1.md) — preview under npm's `next`, read by no updater, for Nautilus for Mirror (the release train): CR200, `mirror-mind/desktop` gains `previewJourneyDeletion` and `deleteJourneyWithRecords`, deleting a Journey with every record that belongs to it after a preview that counts it, by digest, behind a verified backup; child Journeys refuse. Terminal users receive it with v0.40.0. No migration.
|
|
16
17
|
- [v0.39.5 — The Node You Named, or Why Not](v0.39.5.md) — patch release of CR192, from PR #47's thread (@rodrigoimmaginario): the hook wrappers, the plugin's MCP entry, and `runtime diagnose` use `MIRROR_NODE` only as an absolute path to an executable file, as the Pi extension does; a relative value is never run, and a refused one is named with its reason, once a day in `hooks.log` and on the MCP entry's stderr; the Windows installer's drive-letter path counts as absolute, in CI under Git Bash and on the installer. No migration.
|
|
17
18
|
- [v0.39.4 — The Reply Finds Its Prompt](v0.39.4.md) — patch release of PR #51 (@alannascimento1), CR166 and CR196: on Claude Code, a prompt typed while a turn runs and a journey entered in plain words no longer lose the reply; `nothing_waiting` in `hooks.log` means a lost reply again. No schema change, no migration.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
---
|
|
2
|
+
digest: >
|
|
3
|
+
v0.40.0 closes the first release train. It carries CR151: a session's transcript is
|
|
4
|
+
written only into the conversation that session is bound to, each reply stamped with its
|
|
5
|
+
own transcript time, where before it was copied into every open, reply-less conversation
|
|
6
|
+
in the transcript's window, another session's included. It also carries CR200, previewed
|
|
7
|
+
as v0.40.0-next.1 for Nautilus for Mirror: a host can delete a Journey with every record
|
|
8
|
+
that belongs to it, after a preview that counts what will go, by digest, behind a
|
|
9
|
+
verified backup. A transcript no session owns, or a session bound to no conversation,
|
|
10
|
+
now backfills nothing. No schema change, no migration, and no model or prompt change.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# v0.40.0 — A Reply Stays in Its Own Conversation, and a Journey Can Go
|
|
14
|
+
|
|
15
|
+
**Date:** 2026-11-06
|
|
16
|
+
|
|
17
|
+
The first stable release under [the release train](../process/versioning.md#the-release-train):
|
|
18
|
+
everything merged into `main` since v0.39.5, in one batch. One of its two changes users
|
|
19
|
+
have never had; the other reached embedding hosts as a preview on the same day.
|
|
20
|
+
|
|
21
|
+
## Highlights
|
|
22
|
+
|
|
23
|
+
- **A session's replies go into its own conversation, and only there**
|
|
24
|
+
([CR151](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr151-the-transcript-backfill-writes-a-session-s-replies-into-other-sessions-conversations.md)).
|
|
25
|
+
At `SessionEnd`, on Claude Code and the runtimes that share its hook, the transcript
|
|
26
|
+
filled every conversation in its time window that had no assistant message yet. An open
|
|
27
|
+
conversation that started earlier was always in the window: a session killed in its first
|
|
28
|
+
turn, or a concurrent session still in its own. Now the transcript fills the conversation
|
|
29
|
+
`runtime_sessions` binds to the ending session, inside that conversation's own window.
|
|
30
|
+
- **A reply carries its own time.** The copy used to be stamped with the hook's clock, so
|
|
31
|
+
every reply sorted after every prompt of its conversation, and an extraction read all the
|
|
32
|
+
questions and then all the answers. A reply now carries its transcript timestamp,
|
|
33
|
+
normalized to the logger's microsecond form, so recall, extraction, and the MCP read
|
|
34
|
+
model read the turns in order.
|
|
35
|
+
- **A transcript nobody owns writes nothing.** A payload with no `session_id`, and a session
|
|
36
|
+
bound to no conversation, used to backfill by window alone. Both are retired: with no
|
|
37
|
+
key there is no attribution. A "same runtime only" fallback was rejected because it would
|
|
38
|
+
not have fixed the recorded case, in which the orphan and the session that wrote into it
|
|
39
|
+
were both `claude_code`.
|
|
40
|
+
- **A Journey and its records, deleted in one act**
|
|
41
|
+
([CR200](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr200-delete-a-journey-with-its-records-after-showing-what-will-go.md),
|
|
42
|
+
previewed as [v0.40.0-next.1](v0.40.0-next.1.md)). `mirror-mind/desktop` gains
|
|
43
|
+
`previewJourneyDeletion` and `deleteJourneyWithRecords`: the preview counts the Journey's
|
|
44
|
+
records, and the deletion acts only on the preview the person saw, in one transaction,
|
|
45
|
+
behind a named backup verified before any write. Child Journeys refuse.
|
|
46
|
+
- No schema change, no migration, and no model or prompt change.
|
|
47
|
+
|
|
48
|
+
## Where We Started
|
|
49
|
+
|
|
50
|
+
CV21.DS5.US3's live route on 2026-10-20 found three product defects and stopped at its own
|
|
51
|
+
rule: capture, don't fix inside the story. One of them was CR151. A hand-run diagnostic had
|
|
52
|
+
left an open `claude_code` conversation with one prompt and no Stop; the route's Claude
|
|
53
|
+
session ended at 20:41, and its reply `ready` landed in its own conversation and in the
|
|
54
|
+
orphan's, at the same instant. The port had inherited the behavior from the Python fallback
|
|
55
|
+
it replaced, written when one session at a time was the only case, and no test pinned the
|
|
56
|
+
scoping.
|
|
57
|
+
|
|
58
|
+
The Navigator accepted the plan after a review through three lenses. The database
|
|
59
|
+
architect's finding set the shape: the key already existed (`runtime_sessions.conversation_id`)
|
|
60
|
+
and the backfill ignored it, so the fix is to use it, and no fallback survives that the
|
|
61
|
+
evidence case would pass through.
|
|
62
|
+
|
|
63
|
+
## What Changed
|
|
64
|
+
|
|
65
|
+
**CR151.** `backfillAssistantMessages(db, transcriptPath, conversationId, deps)` takes the
|
|
66
|
+
conversation as a required argument: one row, its own `[started_at, ended_at]` window, and
|
|
67
|
+
skipped when it already logged a reply, so a Stop-logged one is not recorded twice.
|
|
68
|
+
`handleSessionEndHook` reads the bound conversation before `endSession` closes the
|
|
69
|
+
session, rather than relying on the close to preserve it. `normalizeTimestamp` pads
|
|
70
|
+
Claude's millisecond `.123Z` to the logger's `.123000Z`, because as strings `.123Z` sorts
|
|
71
|
+
after `.123456Z`. Both headers say the code departs from the Python port on purpose, so
|
|
72
|
+
nobody fixes it back.
|
|
73
|
+
|
|
74
|
+
The seven-conversation fixture that sat on every window boundary is kept, graded now by
|
|
75
|
+
what it was always meant to show: naming one conversation changes it and leaves the other
|
|
76
|
+
six exactly as they were. The golden that recorded Python filling all seven at once stays as
|
|
77
|
+
a frozen record of the defect, since its generator and its Python source are gone.
|
|
78
|
+
|
|
79
|
+
**Validated on a real session.** The Navigator ran the route against Claude Code 2.1.318:
|
|
80
|
+
the orphan kept its prompt alone, and the session's reply carried the transcript's own
|
|
81
|
+
timestamp. Two earlier runs failed, and told us something worth keeping: the Navigator's
|
|
82
|
+
machine has Claude settings that pin `MIRROR_BIN` to the installed package over whatever the shell exports,
|
|
83
|
+
and a second copy of the plugin loads from `~/.claude/skills`, so both runs executed the
|
|
84
|
+
published 0.39.5. They reproduced the defect on a real session (`ready` in the orphan, after
|
|
85
|
+
the conversation's close, with no `turn_id`), which is the "before" the CR now records.
|
|
86
|
+
The route pins the checkout with `--settings`.
|
|
87
|
+
|
|
88
|
+
**CR200** is as [v0.40.0-next.1](v0.40.0-next.1.md) describes it: four rules for what
|
|
89
|
+
belongs to a Journey, a closed inventory a test holds against the schema, and refusals that
|
|
90
|
+
write nothing.
|
|
91
|
+
|
|
92
|
+
**Also in this release.** The Windows installer's real `MIRROR_NODE` path is named where
|
|
93
|
+
docs and comments had named another. The tests and smokes read their paths through
|
|
94
|
+
`fileURLToPath`, so a checkout under `Código` or `My Projects` no longer fails 113 tests
|
|
95
|
+
(issue #44), and the guard that keeps the product from reading a URL's pathname as a path
|
|
96
|
+
now covers them. The lifecycle smoke asserts the new session-less contract. Captured for
|
|
97
|
+
later: CR198 (`runtime diagnose` does not name a refused `MIRROR_NODE`), CR199
|
|
98
|
+
(`deliverMirrorContext` takes three to seven seconds before a Desktop turn), and CR201.
|
|
99
|
+
|
|
100
|
+
## Conscious Exclusions
|
|
101
|
+
|
|
102
|
+
- **Replies already copied stay where they are.** A home that ran an earlier version may
|
|
103
|
+
hold a reply under the wrong conversation. Nothing in the data tells it from a real one,
|
|
104
|
+
and removing one is a manual edit; the selector under Upgrading finds the candidates.
|
|
105
|
+
- **A late `SessionEnd` still adds replies after the extraction.** When maintenance closed
|
|
106
|
+
and extracted a conversation first, the replies the backfill then adds are read by no
|
|
107
|
+
extraction. CR151 pins that they land in the right conversation, not what then reads them.
|
|
108
|
+
[CR201](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr201-a-reply-backfilled-after-the-close-misses-the-extraction-that-closed-the-conversation.md)
|
|
109
|
+
holds the decision.
|
|
110
|
+
- **The session-less route is gone, not narrowed.** A caller that fed a transcript alone to
|
|
111
|
+
`conversation-logger session-end` now gets a no-op.
|
|
112
|
+
- No CLI command deletes a Journey with its records; a host is the only caller. No cascade
|
|
113
|
+
through child Journeys; files on disk are the host's.
|
|
114
|
+
|
|
115
|
+
## What We Learned
|
|
116
|
+
|
|
117
|
+
A fallback written for one case does not know about the second. The Python backfill was
|
|
118
|
+
right for a machine with one session at a time, and ordinary use of Mirror, a Ctrl-C in a
|
|
119
|
+
first turn, was enough to make it wrong.
|
|
120
|
+
|
|
121
|
+
The test that proves a fix is the one that runs the code you changed. Two of the Navigator's three live
|
|
122
|
+
runs exercised the previous release, because a setting the validator did not know about decided
|
|
123
|
+
which Mirror the hooks ran. The checkout's own `mirror-hook` was never in the path. Pinning
|
|
124
|
+
the code under test is part of the route now, and the failed runs stay in the record as
|
|
125
|
+
the defect, measured on a real session, in the published release.
|
|
126
|
+
|
|
127
|
+
## Upgrading
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
mirror runtime update
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Update every install that has a home: the fix lives in the core that the hooks run, and a
|
|
134
|
+
machine whose Claude Code runs a global install keeps the old backfill until that install
|
|
135
|
+
is updated. Plugin copies need no change.
|
|
136
|
+
|
|
137
|
+
To find replies an earlier version copied into the wrong conversation, read-only:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
sqlite3 ~/.mirror-minds/<user>/memory.db \
|
|
141
|
+
"SELECT substr(a.conversation_id,1,8) AS conv, a.created_at, substr(a.content,1,40)
|
|
142
|
+
FROM messages a JOIN messages b
|
|
143
|
+
ON a.content = b.content
|
|
144
|
+
AND substr(a.created_at,1,19) = substr(b.created_at,1,19)
|
|
145
|
+
AND a.conversation_id < b.conversation_id
|
|
146
|
+
WHERE a.role = 'assistant' AND b.role = 'assistant'
|
|
147
|
+
ORDER BY a.created_at"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
A match is one reply written into two conversations within the same second, which is the
|
|
151
|
+
trace of one backfill; read each row, because two identical short replies a second apart in
|
|
152
|
+
different conversations are a different thing. The home's dated backups hold the rows as they
|
|
153
|
+
were.
|
|
154
|
+
|
|
155
|
+
## Next Horizon
|
|
156
|
+
|
|
157
|
+
CR201's decision, how often the late path is taken on a real home; CR198 and CR199; CR195
|
|
158
|
+
in RS010's backlog. The next train leaves when the Navigator calls it.
|
package/package.json
CHANGED
|
@@ -584,7 +584,6 @@ export interface HookLoggingOptions {
|
|
|
584
584
|
export type SessionEndHookOutcome =
|
|
585
585
|
| { action: "skipped"; reason: "malformed_payload" | "nothing_to_do" }
|
|
586
586
|
| { action: "ended"; sessionId: string; transcriptPath: string | null; backfilled: boolean }
|
|
587
|
-
| { action: "backfilled"; transcriptPath: string }
|
|
588
587
|
| { action: "failed"; error: string };
|
|
589
588
|
|
|
590
589
|
const parsePayload = parseHookPayloadObject;
|
|
@@ -917,12 +916,15 @@ export function handleStopHook(
|
|
|
917
916
|
}
|
|
918
917
|
|
|
919
918
|
/**
|
|
920
|
-
* Python `hook_session_end`: end the session
|
|
921
|
-
*
|
|
922
|
-
*
|
|
919
|
+
* Python `hook_session_end`: end the session the payload names, then backfill
|
|
920
|
+
* assistant turns from its transcript when it exists -- in that order, so the
|
|
921
|
+
* just-ended conversation's `ended_at` bounds the backfill.
|
|
923
922
|
*
|
|
924
|
-
* The
|
|
925
|
-
*
|
|
923
|
+
* The backfill fills only the conversation the session is bound to (CR151).
|
|
924
|
+
* Python's session-less route -- no `session_id`, a transcript alone, filling
|
|
925
|
+
* every conversation in the transcript's time window -- is gone: a transcript
|
|
926
|
+
* no session owns cannot be attributed, so it writes nothing. A session with no
|
|
927
|
+
* bound conversation likewise backfills nothing; a session without a
|
|
926
928
|
* transcript still ends. Everything is swallowed; the hook always exits 0.
|
|
927
929
|
*/
|
|
928
930
|
export async function handleSessionEndHook(
|
|
@@ -943,42 +945,40 @@ export async function handleSessionEndHook(
|
|
|
943
945
|
if (!payload) return { action: "skipped", reason: "malformed_payload" };
|
|
944
946
|
|
|
945
947
|
const sessionId = payloadString(payload, "session_id");
|
|
948
|
+
if (!sessionId) return { action: "skipped", reason: "nothing_to_do" };
|
|
946
949
|
const transcriptPath = resolveTranscriptPath(
|
|
947
950
|
payloadString(payload, "transcript_path"),
|
|
948
951
|
sessionId,
|
|
949
952
|
options.claudeProjectDir,
|
|
950
953
|
options.homeDir,
|
|
951
954
|
);
|
|
955
|
+
// Read before the close, so the attribution does not lean on the close
|
|
956
|
+
// preserving `conversation_id` when it deactivates the session.
|
|
957
|
+
const runtimeSession = getRuntimeSession(db, sessionId);
|
|
958
|
+
const conversationId = runtimeSession?.conversationId ?? null;
|
|
952
959
|
let backfilled = false;
|
|
953
960
|
// After the close, which bounds the backfill window, and before the tail,
|
|
954
|
-
// which should see what the backfill adds (CV21.DS4.TS2).
|
|
961
|
+
// which should see what the backfill adds (CV21.DS4.TS2). A session with no
|
|
962
|
+
// bound conversation closes nothing and backfills nothing (CR151).
|
|
955
963
|
const backfill = () => {
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
964
|
+
if (conversationId === null || transcriptPath === null || !existsSync(transcriptPath)) return;
|
|
965
|
+
backfilled = true;
|
|
966
|
+
backfillAssistantMessages(db, transcriptPath, conversationId, deps);
|
|
959
967
|
};
|
|
960
968
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
);
|
|
973
|
-
// A session with no bound conversation closes nothing; the backfill still runs.
|
|
974
|
-
if (!closed) backfill();
|
|
975
|
-
} else {
|
|
976
|
-
backfill();
|
|
977
|
-
}
|
|
969
|
+
// The label US8 stamps on every prompt; the hook's own detection only when
|
|
970
|
+
// the row carries none; today's behavior when neither names a runtime.
|
|
971
|
+
const label = runtimeSession?.interface || options.interface || null;
|
|
972
|
+
const finalize = sessionEndRunsLlmTail(label);
|
|
973
|
+
await endSession(
|
|
974
|
+
db,
|
|
975
|
+
sessionId,
|
|
976
|
+
{ extract: finalize, finalize, beforeTail: backfill },
|
|
977
|
+
deps,
|
|
978
|
+
hooks,
|
|
979
|
+
);
|
|
978
980
|
|
|
979
|
-
|
|
980
|
-
if (backfilled && transcriptPath !== null) return { action: "backfilled", transcriptPath };
|
|
981
|
-
return { action: "skipped", reason: "nothing_to_do" };
|
|
981
|
+
return { action: "ended", sessionId, transcriptPath, backfilled };
|
|
982
982
|
} catch (error) {
|
|
983
983
|
return { action: "failed", error: error instanceof Error ? error.message : String(error) };
|
|
984
984
|
}
|
|
@@ -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
|
}
|