mirror-mind 0.36.0 → 0.36.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/docs/releases/index.md +1 -0
- package/docs/releases/v0.36.1.md +137 -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/loggerRuntime.ts +5 -18
- package/ts/src/conversation/sessionComposites.ts +5 -7
- package/ts/src/conversation/transcriptBackfill.ts +5 -1
- package/ts/src/db/migrations.ts +4 -7
- package/ts/src/guards/retiredSurfaces.ts +42 -5
- package/ts/src/conversation/backfill.ts +0 -271
- package/ts/src/conversation/sessionImport.ts +0 -90
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.36.1 — A Home Keeps Only Its Own Pi Sessions](v0.36.1.md) — patch release of one Change Request, CR153: session maintenance imports no Pi session, so the Mirror homes on one machine no longer copy each other's Pi conversations; a home's Pi conversations are the ones the Pi extension logged into it. `PI_SESSIONS_DIR` is inert. Conversations an earlier version imported stay, and the note shows how to list them. Update every install that has a home. No migration.
|
|
15
16
|
- [v0.36.0 — Five Runtimes, and a Home's Extensions on Every One](v0.36.0.md) — minor release closing CV21.DS10: Grok Build is the fifth runtime, at its honest L3 through the canonical plugin in two printed steps, measured live on grok 1.0.62. With it, CV21.DS5.US2: a home's installed extensions reach Claude Code, Codex, Antigravity, and Grok Build as one generated plugin per home, chosen by measurement on all four. Three Change Requests found by use: an extension's settings file (`extensions/<id>.env`, merged under the environment for the one spawned process), a quiet exit on a closed pipe, and a `bind` that refuses what it cannot bind. No migration.
|
|
16
17
|
- [v0.35.0 — Four Runtimes, One Successor](v0.35.0.md) — minor release closing CV21.DS8: the Gemini CLI integration no longer ships and Antigravity stands in its place, the gate met by DS7's live L4 on agy 1.4.2. Four runtimes — Pi, Claude Code, Codex, Antigravity — named everywhere; the cutoff recorded in full, with the four hook entries a Gemini CLI user deletes and v0.34.0 as the last release that carries the integration; a retired-surface guard keeps it removed. No migration.
|
|
17
18
|
- [v0.34.0 — Codex Through the Canonical Plugin](v0.34.0.md) — minor release closing CV21.DS4: Codex runs Mirror through the canonical plugin, installed in two commands from a marketplace the package ships, measured live at L4 on Codex 0.163.0. The v0.32.1 wrapper wiring no longer ships; `runtime diagnose` and `runtime update` say when Codex's cached plugin copy is behind and name the re-add that brings it level. No migration.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
---
|
|
2
|
+
digest: >
|
|
3
|
+
v0.36.1 is a patch release of one Change Request, CR153. Session
|
|
4
|
+
maintenance used to read Pi's session directory and import every session a
|
|
5
|
+
home did not already track, so on a machine with more than one Mirror home
|
|
6
|
+
each home copied the other's Pi conversations and titled them again through
|
|
7
|
+
the provider. Maintenance now imports no Pi session: a home's Pi
|
|
8
|
+
conversations are the ones the Pi extension logged into it, as they
|
|
9
|
+
happened, and `PI_SESSIONS_DIR` is no longer read. Conversations an earlier
|
|
10
|
+
version imported stay where they are, and this note shows how to list them.
|
|
11
|
+
Update every install that has a home. No migration.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# v0.36.1 — A Home Keeps Only Its Own Pi Sessions
|
|
15
|
+
|
|
16
|
+
**Date:** 2026-10-21
|
|
17
|
+
|
|
18
|
+
## Highlights
|
|
19
|
+
|
|
20
|
+
- **Session maintenance imports no Pi session**
|
|
21
|
+
([CR153](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr153-a-home-s-pi-backfill-imports-another-home-s-sessions.md)).
|
|
22
|
+
It used to read every file in Pi's session directory and import each one the home
|
|
23
|
+
did not track, every session another Mirror home on the same machine had logged
|
|
24
|
+
among them. A home's Pi conversations are now exactly the ones the Pi extension
|
|
25
|
+
logged into it, as they happened.
|
|
26
|
+
- **Nothing in Mirror reads Pi's session files.** Not your history from before Mirror,
|
|
27
|
+
not sessions you run without it, and not another home's. `PI_SESSIONS_DIR` is inert:
|
|
28
|
+
a `.env` that sets it keeps working, and it changes nothing.
|
|
29
|
+
- **What was already imported stays.** This release removes the cause, not the copies;
|
|
30
|
+
[Upgrading](#upgrading) shows how to list them.
|
|
31
|
+
- **Update every install that has a home.** A home is safe from a neighbour's
|
|
32
|
+
maintenance only once the install that neighbour runs carries this release.
|
|
33
|
+
|
|
34
|
+
## Where We Started
|
|
35
|
+
|
|
36
|
+
The import is older than the idea of two homes on one machine. It existed to fill a
|
|
37
|
+
gap: a Pi session the extension never logged reached the home anyway, at the next
|
|
38
|
+
maintenance. v0.32.0 narrowed it to sessions newer than the home, so a new home would
|
|
39
|
+
not swallow a lifetime of Pi history. Nothing scoped it to the home.
|
|
40
|
+
|
|
41
|
+
CV21.DS5.US3's live route exposed it. A Claude Code cell ran on the real `HOME` against
|
|
42
|
+
a scratch home born that evening, and two seconds into its first session, maintenance
|
|
43
|
+
imported four of the person's Pi sessions from that day, from both of their real homes,
|
|
44
|
+
and titled them through the provider. The route closed its own hole the same evening;
|
|
45
|
+
the product's was CR153.
|
|
46
|
+
|
|
47
|
+
Measured before planning, read-only, on the maintainer's two homes, one a copy of the
|
|
48
|
+
other since July: each had taken the other's Pi conversations all along, 543 between
|
|
49
|
+
them, and both were still doing it. Titling them took 411 provider calls. No memory
|
|
50
|
+
came from any of them, because extraction needs a journey and an import carries none,
|
|
51
|
+
but every one listed as the newest conversation, since an import's start is the moment
|
|
52
|
+
it was imported. The gap the import existed to fill came up once in three months.
|
|
53
|
+
|
|
54
|
+
## What Changed
|
|
55
|
+
|
|
56
|
+
**The decision.** Three ways out were weighed: a claim recorded at session start, so a
|
|
57
|
+
home imports only sessions it saw begin; a mark written into each Pi session file, so a
|
|
58
|
+
home imports only files carrying its name; or no import at all. The claim would have
|
|
59
|
+
kept the one recovery in three months for a table, a migration, and a claim lifecycle.
|
|
60
|
+
The mark would have left every home reading every other home's files at every start.
|
|
61
|
+
Retiring the import is the only option in which no home reads Pi's files at all, and
|
|
62
|
+
it gives up almost nothing
|
|
63
|
+
([decision](../project/decisions.md#session-maintenance-imports-no-pi-session-a-home-holds-the-pi-conversations-it-logged-live)).
|
|
64
|
+
|
|
65
|
+
**The change.** The Pi backfill, the import seam only it still used, and v0.32.0's
|
|
66
|
+
birth floor are deleted, and a retired-surface guard keeps them deleted. The tests came
|
|
67
|
+
first: three cases — a session in Pi's default directory, one under `PI_SESSIONS_DIR`,
|
|
68
|
+
and the full `session-start` the SessionStart hooks run — failed on the old code, each
|
|
69
|
+
importing a session another home had logged, and pass now. The Navigator then ran the
|
|
70
|
+
published 0.36.0 beside the change on the same session: the old version took it and
|
|
71
|
+
the new one did not. On the real Pi history the new one took nothing, and a live Pi
|
|
72
|
+
session against a scratch home was logged as before.
|
|
73
|
+
|
|
74
|
+
### What you will notice
|
|
75
|
+
|
|
76
|
+
- The maintenance report has four lines: `Closed stale conversations`, `Retitled pending
|
|
77
|
+
conversations`, `Extracted pending conversations`, and `Finished deferred close tails`.
|
|
78
|
+
- If you have run more than one home on this machine, each may hold Pi conversations
|
|
79
|
+
the other logged. They are not removed.
|
|
80
|
+
- A Pi session you run without Mirror's extension never appears in Mirror. Run Pi with
|
|
81
|
+
the Mirror package, and its sessions are logged as they happen.
|
|
82
|
+
|
|
83
|
+
## Upgrading
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
mirror runtime update # or: npm install -g mirror-mind
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Update every install that has a home: the package, and each clone. There is no
|
|
90
|
+
database migration, and no runtime wiring changes.
|
|
91
|
+
|
|
92
|
+
To list the Pi conversations an earlier version imported into a home, read-only:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
sqlite3 ~/.mirror-minds/<user>/memory.db \
|
|
96
|
+
"SELECT id, title, started_at FROM conversations
|
|
97
|
+
WHERE interface = 'pi' AND julianday(started_at) >= julianday(ended_at)
|
|
98
|
+
ORDER BY started_at"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
An import gives itself away: it started when it was imported, after its last message.
|
|
102
|
+
The home's dated backups and its pre-write backup hold the same rows.
|
|
103
|
+
|
|
104
|
+
## Conscious Exclusions
|
|
105
|
+
|
|
106
|
+
- **Removing what was imported.** A command that lists and removes those conversations
|
|
107
|
+
is [CR154](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr154-a-home-cannot-list-or-remove-the-pi-conversations-it-imported.md).
|
|
108
|
+
- **A session whose logging never reached the core** is now simply missing from the
|
|
109
|
+
home, with an error line in `mirror-logger.log` and nowhere else. `runtime diagnose`
|
|
110
|
+
reading those lines is
|
|
111
|
+
[CR155](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr155-runtime-diagnose-does-not-read-the-pi-extension-s-error-lines.md).
|
|
112
|
+
- **A home made by copying another's database** is the same home twice — the same ids,
|
|
113
|
+
the same history. The docs saying so is
|
|
114
|
+
[CR156](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr156-a-home-made-by-copying-another-s-database-is-the-same-home-twice.md).
|
|
115
|
+
- **Importing a Pi session on request.** If it is wanted, it is a feature with its own
|
|
116
|
+
story, not something maintenance does in the background.
|
|
117
|
+
- **The transcript backfill** matches a session's replies by time window — the same
|
|
118
|
+
shape, inside one home. That is
|
|
119
|
+
[CR151](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr151-the-transcript-backfill-writes-a-session-s-replies-into-other-sessions-conversations.md),
|
|
120
|
+
its own change.
|
|
121
|
+
|
|
122
|
+
## What We Learned
|
|
123
|
+
|
|
124
|
+
The import decided ownership from an absence: a session the home did not track was the
|
|
125
|
+
home's to take. That was true while a machine had one home, and it stopped being true
|
|
126
|
+
on the day a second home appeared, with nothing to mark the day.
|
|
127
|
+
|
|
128
|
+
The leak was also invisible to the check that should have caught it. The first look at
|
|
129
|
+
the real homes asked by journey, and an import has no journey. Measured by construction
|
|
130
|
+
instead — an import starts after its own last message — the same homes held 543. A
|
|
131
|
+
measurement taken before planning turned "consider pulling it first" into a fact about
|
|
132
|
+
the maintainer's own data.
|
|
133
|
+
|
|
134
|
+
## Next Horizon
|
|
135
|
+
|
|
136
|
+
CR154: a home lists, and on request removes, the Pi conversations an earlier version
|
|
137
|
+
imported. Then CV21.DS5.TS4 and DS11, and CV21's own release.
|
package/package.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Everything the `conversation-logger` subcommands need beyond the database
|
|
4
4
|
// is assembled here, once per invocation, from an EXPLICIT environment: the
|
|
5
|
-
// clock and id generator, the transcript/session resolution inputs, the
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// clock and id generator, the transcript/session resolution inputs, the
|
|
6
|
+
// pre-write backup, and -- only for the subcommands that cross the LLM close
|
|
7
|
+
// tail -- the replay providers and the maintenance dependencies built on them.
|
|
8
|
+
// (No Pi sessions directory: maintenance reads no Pi session file, CR153.)
|
|
9
9
|
//
|
|
10
10
|
// The providers are loaded lazily. `diagnose-journeys`
|
|
11
11
|
// or `session-start --fast` must never fail because a replay fixture is not
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
// Nothing here reads `process.env` or `os.homedir()`; the front door passes
|
|
18
18
|
// them in, which is what keeps every test of this module hermetic.
|
|
19
19
|
|
|
20
|
-
import { backfillPiSessions, homeBornAt, resolvePiSessionsDir } from "#conversation/backfill.ts";
|
|
21
20
|
import { createCloseHooks, maybeGenerateTitle } from "#conversation/closeTail.ts";
|
|
22
21
|
import { runConversationExtraction } from "#conversation/extraction.ts";
|
|
23
22
|
import { runExtractionWithAccounting } from "#conversation/extractionRun.ts";
|
|
@@ -47,7 +46,6 @@ import {
|
|
|
47
46
|
export type LoggerRuntimeEnv = {
|
|
48
47
|
MIRROR_SESSION_ID?: string;
|
|
49
48
|
CLAUDE_PROJECT_DIR?: string;
|
|
50
|
-
PI_SESSIONS_DIR?: string;
|
|
51
49
|
MIRROR_TS_CONVERSATION_LLM_REPLAY?: string;
|
|
52
50
|
MIRROR_TS_CONVERSATION_EMBEDDING_REPLAY?: string;
|
|
53
51
|
/** Read by the live providers when the close tail runs against a real model. */
|
|
@@ -100,12 +98,11 @@ export interface LoggerRuntime {
|
|
|
100
98
|
readonly homeDir: string;
|
|
101
99
|
readonly claudeProjectDir: string | null;
|
|
102
100
|
readonly environmentSessionId: string | null;
|
|
103
|
-
readonly piSessionsDir: string;
|
|
104
101
|
/** Which transport answers the close tail here: replay, incomplete replay, or live. */
|
|
105
102
|
readonly transportMode: ProviderTransportMode;
|
|
106
103
|
/** The close hooks (extraction + finalization) behind the replay transport. */
|
|
107
104
|
closeHooks(): Promise<CloseHooks>;
|
|
108
|
-
/** The full maintenance wiring
|
|
105
|
+
/** The full maintenance wiring. */
|
|
109
106
|
maintenanceDeps(): Promise<MaintenanceDeps>;
|
|
110
107
|
/** The dated zip backup for the mutating journey repair; see `LoggerRuntimeOptions.backup`. */
|
|
111
108
|
readonly backup: ((stdout: (line: string) => void) => string | null) | null;
|
|
@@ -135,7 +132,6 @@ function liveTailOverrides(
|
|
|
135
132
|
|
|
136
133
|
export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntime {
|
|
137
134
|
const { db, env, deps } = options;
|
|
138
|
-
const piSessionsDir = resolvePiSessionsDir(null, env, options.homeDir);
|
|
139
135
|
|
|
140
136
|
// One precedence, shared with the router so the two cannot disagree about
|
|
141
137
|
// which transport is live: replay -> incomplete -> live. The pair rule that
|
|
@@ -225,7 +221,6 @@ export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntim
|
|
|
225
221
|
homeDir: options.homeDir,
|
|
226
222
|
claudeProjectDir: env.CLAUDE_PROJECT_DIR || null,
|
|
227
223
|
environmentSessionId: env.MIRROR_SESSION_ID?.trim() || null,
|
|
228
|
-
piSessionsDir,
|
|
229
224
|
transportMode: transport.mode,
|
|
230
225
|
backup: options.backup ?? null,
|
|
231
226
|
closeHooks,
|
|
@@ -246,14 +241,6 @@ export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntim
|
|
|
246
241
|
// CV21.DS4.TS2: the finalization a bounded runtime's SessionEnd deferred.
|
|
247
242
|
finalizeCloseTail: (database, conversationId) =>
|
|
248
243
|
hooks.finalizeMetadata(database, conversationId),
|
|
249
|
-
// CR121: only the sessions newer than this home; its birth is read from
|
|
250
|
-
// the ledger once per maintenance run, and an unknown birth is an error.
|
|
251
|
-
backfillPiSessions: (database) =>
|
|
252
|
-
backfillPiSessions(
|
|
253
|
-
database,
|
|
254
|
-
{ sessionsDir: piSessionsDir, notBefore: homeBornAt(database) },
|
|
255
|
-
deps,
|
|
256
|
-
),
|
|
257
244
|
extractionLimit: resolveMaintenanceMaxExtractions({ env }),
|
|
258
245
|
staleOrphanMinutes: resolveStaleOrphanMinutes({ env }),
|
|
259
246
|
monotonic: options.monotonic,
|
|
@@ -48,12 +48,6 @@ export interface MaintenanceDeps {
|
|
|
48
48
|
* only for callers written before the step existed; the front door wires it.
|
|
49
49
|
*/
|
|
50
50
|
finalizeCloseTail?: (db: WritableDatabase, conversationId: string) => Promise<void> | void;
|
|
51
|
-
/**
|
|
52
|
-
* `backfillPiSessions` (slice E) bound to its resolved sessions directory.
|
|
53
|
-
* Injected rather than resolved here so the composite never reads the
|
|
54
|
-
* environment itself; the front door owns that resolution.
|
|
55
|
-
*/
|
|
56
|
-
backfillPiSessions: (db: WritableDatabase) => Promise<number> | number;
|
|
57
51
|
/** Python's `MEMORY_MAINTENANCE_MAX_EXTRACTIONS` (AI-05); defaults to the released budget. */
|
|
58
52
|
extractionLimit?: number;
|
|
59
53
|
/** `MIRROR_STALE_ORPHAN_MINUTES` (CV21.DS4.US4); defaults to the 30-minute threshold. */
|
|
@@ -231,6 +225,11 @@ function formatElapsed(elapsed: number): string {
|
|
|
231
225
|
* warning tails. The step order is behavior, not presentation — closing stale
|
|
232
226
|
* orphans first is what makes their conversations eligible for the extraction
|
|
233
227
|
* step in the same run.
|
|
228
|
+
*
|
|
229
|
+
* CR153: there is no Pi backfill step. Python's walked Pi's session directory
|
|
230
|
+
* and imported every file this home did not track, which on a machine with two
|
|
231
|
+
* homes meant each other's sessions; a home's Pi conversations are the ones the
|
|
232
|
+
* Pi extension logged into it, and maintenance reads no Pi session file.
|
|
234
233
|
*/
|
|
235
234
|
export async function sessionMaintenance(
|
|
236
235
|
db: WritableDatabase,
|
|
@@ -246,7 +245,6 @@ export async function sessionMaintenance(
|
|
|
246
245
|
thresholdMinutes: deps.staleOrphanMinutes ?? STALE_ORPHAN_THRESHOLD_MINUTES,
|
|
247
246
|
}),
|
|
248
247
|
),
|
|
249
|
-
await timedStep("Backfilled Pi sessions", monotonic, () => deps.backfillPiSessions(db)),
|
|
250
248
|
await timedStep("Retitled pending conversations", monotonic, () =>
|
|
251
249
|
retitlePendingConversations(db, { retitleConversation: deps.retitleConversation }),
|
|
252
250
|
),
|
|
@@ -23,7 +23,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
23
23
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Python `dict.get(key, default)` on a value that MUST be a dict: anything else
|
|
28
|
+
* raises, as Python's AttributeError does. The default applies only when the
|
|
29
|
+
* key is ABSENT -- a present `null` comes back as null, as Python returns None.
|
|
30
|
+
*/
|
|
27
31
|
function getOr(container: unknown, key: string, fallback: unknown): unknown {
|
|
28
32
|
if (!isRecord(container)) throw new TypeError(`expected an object with '${key}'`);
|
|
29
33
|
return key in container ? container[key] : fallback;
|
package/ts/src/db/migrations.ts
CHANGED
|
@@ -548,13 +548,10 @@ export const MIGRATIONS: readonly (readonly [string, MigrationApply])[] = [
|
|
|
548
548
|
* contract.
|
|
549
549
|
*/
|
|
550
550
|
export function runMigrations(db: WritableDatabase): void {
|
|
551
|
-
// The ledger
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
// re-inserts them with a later date. Retiring a migration id moves the
|
|
556
|
-
// minimum forward at worst, which imports less; re-stamping could move it
|
|
557
|
-
// and would be a lie about the home's age.
|
|
551
|
+
// The ledger records which migrations ran, and when -- nothing more. Do not
|
|
552
|
+
// infer a home's birth from its first `applied_at`: a database copied to
|
|
553
|
+
// make a second home carries its source's ledger, so two homes would share
|
|
554
|
+
// one birth (CR153 retired the reader that did, CR156 records the copy).
|
|
558
555
|
db.exec(`
|
|
559
556
|
CREATE TABLE IF NOT EXISTS _migrations (
|
|
560
557
|
id TEXT PRIMARY KEY,
|
|
@@ -280,9 +280,9 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
280
280
|
absentPaths: [],
|
|
281
281
|
forbiddenPatterns: [
|
|
282
282
|
// The flags and the two service methods. NOT the bare word `backfill`:
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
283
|
+
// the transcript backfill (`ts/src/conversation/transcriptBackfill.ts`)
|
|
284
|
+
// is a different feature, ported and staying. (The Pi session backfill
|
|
285
|
+
// that stood beside it is retired by its own row, `pi-session-backfill`.)
|
|
286
286
|
"--metadata-backfill-",
|
|
287
287
|
"metadata_backfill",
|
|
288
288
|
"preview_metadata_backfill",
|
|
@@ -492,8 +492,6 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
492
492
|
exemptions: {
|
|
493
493
|
"docs/getting-started.md":
|
|
494
494
|
"the un-wiring guidance: a user who started codex through the retired wrapper needs its name to undo that wiring",
|
|
495
|
-
"ts/src/conversation/backfill.ts":
|
|
496
|
-
"the header names the retired importer to say where it went, beside the Pi importer that stays",
|
|
497
495
|
},
|
|
498
496
|
},
|
|
499
497
|
{
|
|
@@ -545,6 +543,45 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
545
543
|
],
|
|
546
544
|
exemptions: {},
|
|
547
545
|
},
|
|
546
|
+
{
|
|
547
|
+
surfaceId: "pi-session-backfill",
|
|
548
|
+
story: "CR153",
|
|
549
|
+
// Session maintenance's Pi backfill: a walk of Pi's session directory that
|
|
550
|
+
// imported every file this home's `runtime_sessions` did not track. That
|
|
551
|
+
// held while a machine had one home. With two, each imported the other's
|
|
552
|
+
// sessions -- 543 conversations on the maintainer's machine, both ways,
|
|
553
|
+
// each titled again through the provider -- and both imported the sessions
|
|
554
|
+
// of scratch homes and of Pi run without Mirror, while the gap it existed
|
|
555
|
+
// to fill, a session the home's own extension never logged, came up once
|
|
556
|
+
// in three months. A home's Pi conversations are the ones the Pi extension
|
|
557
|
+
// logged into it, and nothing reads a Pi session file. `importClosedConversation`
|
|
558
|
+
// went with it: the Codex importer that also wrote through it retired with
|
|
559
|
+
// DS4.TS5. `PI_SESSIONS_DIR` is inert, and only the records that say so,
|
|
560
|
+
// and the test that proves it, may name it.
|
|
561
|
+
absentPaths: [
|
|
562
|
+
"ts/src/conversation/backfill.ts",
|
|
563
|
+
"ts/src/conversation/sessionImport.ts",
|
|
564
|
+
"ts/test/conversation/backfill.test.ts",
|
|
565
|
+
"ts/test/db/homeBornAt.test.ts",
|
|
566
|
+
"ts/test/fixtures/backfill/pi-sessions/",
|
|
567
|
+
],
|
|
568
|
+
forbiddenPatterns: [
|
|
569
|
+
"backfillPiSessions",
|
|
570
|
+
"resolvePiSessionsDir",
|
|
571
|
+
"homeBornAt",
|
|
572
|
+
"importClosedConversation",
|
|
573
|
+
"PI_SESSIONS_DIR",
|
|
574
|
+
"Backfilled Pi sessions",
|
|
575
|
+
],
|
|
576
|
+
exemptions: {
|
|
577
|
+
"docs/reference/configuration.md":
|
|
578
|
+
"the inert-values record: a `.env` may still carry PI_SESSIONS_DIR, and the reference says nothing reads it",
|
|
579
|
+
".env.example.advanced":
|
|
580
|
+
"marks PI_SESSIONS_DIR inert, so a leftover line is recognized rather than trusted",
|
|
581
|
+
"ts/test/conversation/loggerCli.test.ts":
|
|
582
|
+
"sets PI_SESSIONS_DIR to a directory holding a fresh session and proves maintenance imports nothing from it",
|
|
583
|
+
},
|
|
584
|
+
},
|
|
548
585
|
];
|
|
549
586
|
|
|
550
587
|
export interface SurfaceProblem {
|
|
@@ -1,271 +0,0 @@
|
|
|
1
|
-
// Session backfills from runtime-written JSONL (CV22.DS7.US10 slice E).
|
|
2
|
-
//
|
|
3
|
-
// Ports `backfill_pi_sessions` from `src/memory/cli/conversation_logger.py`:
|
|
4
|
-
// it imports closed sessions the live hooks never saw, through the atomic
|
|
5
|
-
// seam in `sessionImport.ts`, and is graded against
|
|
6
|
-
// `ts/test/goldens/backfill.golden.json`. (The Codex importer,
|
|
7
|
-
// `backfill_codex_session`, was ported beside it and retired with the
|
|
8
|
-
// v0.32.1 Codex wrapper by CV21.DS4.TS5: its only caller was the wrapper's
|
|
9
|
-
// session-end hook, and the plugin logs Codex turns live.)
|
|
10
|
-
//
|
|
11
|
-
// Pi titles through `set_provisional_title` (whitespace collapse plus
|
|
12
|
-
// `title_source`/`title_status` provenance in metadata), tolerates a corrupt
|
|
13
|
-
// or structurally invalid file by skipping THAT file and continuing the
|
|
14
|
-
// walk, and parses every timestamp form (millisecond epoch, string, absent).
|
|
15
|
-
//
|
|
16
|
-
// Nothing here touches a provider or the clock directly: `now` is injected
|
|
17
|
-
// through `LoggerDeps`, which is what makes "no timestamp -> now" pinnable.
|
|
18
|
-
//
|
|
19
|
-
// CR121 (2026-10-11): a home imports only the Pi sessions newer than itself.
|
|
20
|
-
// Python imported every untracked session on the machine, which for a new
|
|
21
|
-
// home meant the person's whole Pi history, copied and spent on before the
|
|
22
|
-
// first prompt. The floor is explicit (`notBefore`), never defaulted, and
|
|
23
|
-
// the rule fails closed: there is no value that means "everything" except
|
|
24
|
-
// the epoch, which says so.
|
|
25
|
-
|
|
26
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
27
|
-
import { join, relative, sep } from "node:path";
|
|
28
|
-
import { type LoggerDeps, setProvisionalTitle } from "#conversation/logger.ts";
|
|
29
|
-
import { type ImportedMessage, importClosedConversation } from "#conversation/sessionImport.ts";
|
|
30
|
-
import type { WritableDatabase } from "#db/database.ts";
|
|
31
|
-
import { expandHome } from "#util/paths.ts";
|
|
32
|
-
import { comparePathComponents, sliceCodePoints } from "#util/pythonText.ts";
|
|
33
|
-
|
|
34
|
-
/** Python `_DEFAULT_PI_SESSIONS_DIR`: `~/.pi/agent/sessions`. */
|
|
35
|
-
export function defaultPiSessionsDir(homeDir: string): string {
|
|
36
|
-
return join(homeDir, ".pi", "agent", "sessions");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Python's source-directory resolution for the Pi backfill, in order:
|
|
41
|
-
* explicit argument -> `PI_SESSIONS_DIR` -> `~/.pi/agent/sessions`.
|
|
42
|
-
* (Python's module-level `_PI_SESSIONS_DIR` override exists only for its own
|
|
43
|
-
* test monkey-patching and has no runtime input; it is not a contract.)
|
|
44
|
-
*/
|
|
45
|
-
export function resolvePiSessionsDir(
|
|
46
|
-
explicit: string | null | undefined,
|
|
47
|
-
env: { PI_SESSIONS_DIR?: string },
|
|
48
|
-
homeDir: string,
|
|
49
|
-
): string {
|
|
50
|
-
if (explicit) return expandHome(explicit);
|
|
51
|
-
if (env.PI_SESSIONS_DIR) return expandHome(env.PI_SESSIONS_DIR);
|
|
52
|
-
return defaultPiSessionsDir(homeDir);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Python `dict.get(key, default)` on a value that MUST be a dict: anything
|
|
61
|
-
* else raises AttributeError, which each parser turns into its own failure
|
|
62
|
-
* mode. The default applies only when the key is ABSENT -- a present `null`
|
|
63
|
-
* is returned as null, exactly as Python returns None, so a later `.get` on
|
|
64
|
-
* it raises rather than silently reading from the default.
|
|
65
|
-
*/
|
|
66
|
-
function getOr(container: unknown, key: string, fallback: unknown): unknown {
|
|
67
|
-
if (!isRecord(container)) throw new TypeError(`expected an object with '${key}'`);
|
|
68
|
-
return key in container ? container[key] : fallback;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function getFrom(container: unknown, key: string): unknown {
|
|
72
|
-
return getOr(container, key, undefined);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Python `_parse_pi_content`: a string as-is, text blocks joined by `\n`, else `""`. */
|
|
76
|
-
export function parsePiContent(content: unknown): string {
|
|
77
|
-
if (typeof content === "string") return content;
|
|
78
|
-
if (Array.isArray(content)) {
|
|
79
|
-
const parts: string[] = [];
|
|
80
|
-
for (const block of content) {
|
|
81
|
-
if (!isRecord(block) || block.type !== "text") continue;
|
|
82
|
-
const text = block.text ?? "";
|
|
83
|
-
// `"\n".join(...)` raises on a non-str item; the file is skipped.
|
|
84
|
-
if (typeof text !== "string") throw new TypeError("text block is not a string");
|
|
85
|
-
parts.push(text);
|
|
86
|
-
}
|
|
87
|
-
return parts.join("\n");
|
|
88
|
-
}
|
|
89
|
-
return "";
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Python `datetime.fromtimestamp(ms / 1000, tz=utc).isoformat()`:
|
|
94
|
-
* `+00:00` suffix (not `Z`), microseconds only when non-zero, rounded from
|
|
95
|
-
* the fractional second the way CPython does (`round(frac * 1e6)`).
|
|
96
|
-
*/
|
|
97
|
-
function pythonUtcIsoFromEpochMs(epochMs: number): string {
|
|
98
|
-
const seconds = epochMs / 1000;
|
|
99
|
-
let whole = Math.floor(seconds);
|
|
100
|
-
let micros = Math.round((seconds - whole) * 1e6);
|
|
101
|
-
if (micros >= 1_000_000) {
|
|
102
|
-
whole += 1;
|
|
103
|
-
micros -= 1_000_000;
|
|
104
|
-
}
|
|
105
|
-
const base = new Date(whole * 1000).toISOString().slice(0, 19);
|
|
106
|
-
const fraction = micros === 0 ? "" : `.${String(micros).padStart(6, "0")}`;
|
|
107
|
-
return `${base}${fraction}+00:00`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Python `_parse_pi_timestamp`: a number above 1e10 is a millisecond epoch;
|
|
112
|
-
* a string is stored verbatim; anything else (absent, null, a seconds epoch,
|
|
113
|
-
* a boolean -- which is an int in Python but never exceeds 1e10) is `now`.
|
|
114
|
-
*/
|
|
115
|
-
export function parsePiTimestamp(value: unknown, now: () => string): string {
|
|
116
|
-
if (typeof value === "number" && value > 1e10) return pythonUtcIsoFromEpochMs(value);
|
|
117
|
-
if (typeof value === "string") return value;
|
|
118
|
-
return now();
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Parse one Pi session file into importable messages. Throws on any line
|
|
123
|
-
* that is not valid JSON or not the expected shape -- Python wraps the whole
|
|
124
|
-
* file loop in one `try` and skips the file on the first failure, even after
|
|
125
|
-
* earlier lines parsed cleanly.
|
|
126
|
-
*/
|
|
127
|
-
function parsePiSessionFile(path: string, now: () => string): ImportedMessage[] {
|
|
128
|
-
const messages: ImportedMessage[] = [];
|
|
129
|
-
for (const line of readFileSync(path, "utf8").split(/\r?\n|\r/)) {
|
|
130
|
-
if (!line.trim()) continue;
|
|
131
|
-
const entry: unknown = JSON.parse(line);
|
|
132
|
-
if (getFrom(entry, "type") !== "message") continue;
|
|
133
|
-
const message = getOr(entry, "message", {});
|
|
134
|
-
const role = getOr(message, "role", "");
|
|
135
|
-
if (role !== "user" && role !== "assistant") continue;
|
|
136
|
-
const content = parsePiContent(getOr(message, "content", ""));
|
|
137
|
-
if (!content.trim()) continue;
|
|
138
|
-
const createdAt = parsePiTimestamp(getFrom(message, "timestamp"), now);
|
|
139
|
-
messages.push({ role, content, createdAt });
|
|
140
|
-
}
|
|
141
|
-
return messages;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Python `sorted(pi_sessions_dir.rglob("*.jsonl"))`: every `.jsonl` file
|
|
146
|
-
* under the directory, ordered by path components (see
|
|
147
|
-
* `comparePathComponents` for why that is not a string sort).
|
|
148
|
-
*/
|
|
149
|
-
function listPiSessionFiles(sessionsDir: string): string[] {
|
|
150
|
-
const entries = readdirSync(sessionsDir, { recursive: true, withFileTypes: true });
|
|
151
|
-
const found: string[][] = [];
|
|
152
|
-
for (const entry of entries) {
|
|
153
|
-
if (!entry.name.endsWith(".jsonl")) continue;
|
|
154
|
-
const parents = relative(sessionsDir, entry.parentPath).split(sep).filter(Boolean);
|
|
155
|
-
found.push([...parents, entry.name]);
|
|
156
|
-
}
|
|
157
|
-
found.sort(comparePathComponents);
|
|
158
|
-
return found.map((components) => join(sessionsDir, ...components));
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export interface PiBackfillOptions {
|
|
162
|
-
/** Already resolved through `resolvePiSessionsDir`. */
|
|
163
|
-
sessionsDir: string;
|
|
164
|
-
/**
|
|
165
|
-
* An ISO instant: a session that ended at or before it is not imported
|
|
166
|
-
* (CR121). Production passes the home's birth (`homeBornAt`); a caller
|
|
167
|
-
* that wants no floor passes the epoch and says so.
|
|
168
|
-
*/
|
|
169
|
-
notBefore: string;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* When this home was born: the first `applied_at` in the `_migrations`
|
|
174
|
-
* ledger. A fresh database stamps its whole ledger at its first open, and a
|
|
175
|
-
* database from any earlier era carries the ledger from its own first open,
|
|
176
|
-
* so the minimum is the birth of every home there is -- with no new table,
|
|
177
|
-
* column, or marker. Read once per maintenance run.
|
|
178
|
-
*
|
|
179
|
-
* The ledger means "which migrations ran, when"; reading its minimum as a
|
|
180
|
-
* birth is an inference, accepted because every plausible edit to the ledger
|
|
181
|
-
* moves the minimum forward and imports less, never more. `migrations.ts`
|
|
182
|
-
* names this consumer at the ledger's definition. A second consumer of
|
|
183
|
-
* "birth" is the trigger to model it as a row of its own.
|
|
184
|
-
*
|
|
185
|
-
* Fails closed: an empty ledger is an error, not a home born at the epoch.
|
|
186
|
-
*/
|
|
187
|
-
export function homeBornAt(db: WritableDatabase): string {
|
|
188
|
-
const row = db.prepare("SELECT MIN(applied_at) AS born FROM _migrations").get() as
|
|
189
|
-
| { born: string | null }
|
|
190
|
-
| undefined;
|
|
191
|
-
if (typeof row?.born !== "string" || !row.born) {
|
|
192
|
-
throw new Error("the _migrations ledger is empty: this home's birth is unknown");
|
|
193
|
-
}
|
|
194
|
-
return row.born;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** The instant of an ISO timestamp, or NaN for anything `Date.parse` rejects. */
|
|
198
|
-
function instant(iso: string): number {
|
|
199
|
-
return Date.parse(iso);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Python `backfill_pi_sessions`: import every untracked Pi session with at
|
|
204
|
-
* least two messages as a closed `pi` conversation, titled provisionally from
|
|
205
|
-
* the first user line. Returns the number imported.
|
|
206
|
-
*
|
|
207
|
-
* The session id is the session file's path, exactly as Python's
|
|
208
|
-
* `str(session_file)` -- which is also what the live Pi extension binds, so a
|
|
209
|
-
* session the hooks already track is skipped before its file is even read.
|
|
210
|
-
*
|
|
211
|
-
* CR121: a session is Mirror's only if it ended after `notBefore`. Two
|
|
212
|
-
* checks, in cost order. A file whose mtime is at or before the floor is
|
|
213
|
-
* skipped unread: its last message was written at or before its last write,
|
|
214
|
-
* so it cannot be newer. (This is what keeps a skipped session, which is
|
|
215
|
-
* never tracked, from being parsed again at every start for the rest of the
|
|
216
|
-
* home's life.) A file written after the floor is parsed and judged by its
|
|
217
|
-
* last message, the same instant it stores as `endedAt` -- a copy or a sync
|
|
218
|
-
* can touch a file long after its conversation ended. A session whose
|
|
219
|
-
* messages carry no parseable instant dates to `now` under the existing rule
|
|
220
|
-
* and is imported: undated is newer, not older.
|
|
221
|
-
*/
|
|
222
|
-
export function backfillPiSessions(
|
|
223
|
-
db: WritableDatabase,
|
|
224
|
-
options: PiBackfillOptions,
|
|
225
|
-
deps: LoggerDeps,
|
|
226
|
-
): number {
|
|
227
|
-
const floor = instant(options.notBefore);
|
|
228
|
-
if (Number.isNaN(floor)) {
|
|
229
|
-
throw new Error(`notBefore is not an instant: ${JSON.stringify(options.notBefore)}`);
|
|
230
|
-
}
|
|
231
|
-
if (!existsSync(options.sessionsDir)) return 0;
|
|
232
|
-
|
|
233
|
-
let count = 0;
|
|
234
|
-
for (const sessionFile of listPiSessionFiles(options.sessionsDir)) {
|
|
235
|
-
const sessionId = sessionFile;
|
|
236
|
-
if (db.prepare("SELECT 1 FROM runtime_sessions WHERE session_id = ?").get(sessionId)) {
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
239
|
-
if (statSync(sessionFile).mtimeMs <= floor) continue;
|
|
240
|
-
let messages: ImportedMessage[];
|
|
241
|
-
try {
|
|
242
|
-
messages = parsePiSessionFile(sessionFile, deps.nowIso);
|
|
243
|
-
} catch {
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
if (messages.length < 2) continue;
|
|
247
|
-
const endedAt = instant((messages.at(-1) as ImportedMessage).createdAt);
|
|
248
|
-
if (endedAt <= floor) continue;
|
|
249
|
-
|
|
250
|
-
const conversationId = importClosedConversation(
|
|
251
|
-
db,
|
|
252
|
-
sessionId,
|
|
253
|
-
{ interface: "pi", messages, endedAt: (messages.at(-1) as ImportedMessage).createdAt },
|
|
254
|
-
deps,
|
|
255
|
-
);
|
|
256
|
-
if (conversationId === null) {
|
|
257
|
-
// A concurrent writer (a live hook) bound this session while the import
|
|
258
|
-
// waited for the write lock -- their binding wins.
|
|
259
|
-
continue;
|
|
260
|
-
}
|
|
261
|
-
const firstUser = messages.find((message) => message.role === "user");
|
|
262
|
-
if (firstUser) {
|
|
263
|
-
// Python: `content.strip().split("\n")[0][:60]` -- cut BEFORE the
|
|
264
|
-
// provisional-title whitespace collapse, by code point.
|
|
265
|
-
const title = sliceCodePoints(firstUser.content.trim().split("\n")[0] ?? "", 60);
|
|
266
|
-
setProvisionalTitle(db, conversationId, title);
|
|
267
|
-
}
|
|
268
|
-
count += 1;
|
|
269
|
-
}
|
|
270
|
-
return count;
|
|
271
|
-
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
// Atomic closed-session import (CV22.DS7.US10 slice E).
|
|
2
|
-
//
|
|
3
|
-
// Ports `RuntimeSessionService.import_closed_conversation` from
|
|
4
|
-
// `src/memory/services/runtime_session.py`, the seam both backfills write
|
|
5
|
-
// through. It is the sibling of `getOrCreateSessionConversation` in
|
|
6
|
-
// `logger.ts` and shares its transaction discipline for the same reason:
|
|
7
|
-
// a backfill imports against the SAME `runtime_sessions` key a live hook
|
|
8
|
-
// binds (Pi's live session id is the session file path), and the two can
|
|
9
|
-
// interleave.
|
|
10
|
-
//
|
|
11
|
-
// Resolved decision 4B (plan.md): the binding check runs INSIDE the
|
|
12
|
-
// `BEGIN IMMEDIATE` transaction, so a binding a concurrent hook committed
|
|
13
|
-
// while this import waited for the write lock is seen and honored -- the
|
|
14
|
-
// import returns null instead of clobbering a live session with
|
|
15
|
-
// `active=0`/`closed_at`. The same atomicity removes the crash-window
|
|
16
|
-
// duplicate-import path: either the conversation, every message, and the
|
|
17
|
-
// binding all land, or nothing does.
|
|
18
|
-
//
|
|
19
|
-
// Titles are deliberately NOT set here. The two callers title differently
|
|
20
|
-
// (Pi through `setProvisionalTitle`, Codex through a bare column write) and
|
|
21
|
-
// Python applies both after commit; reproducing that asymmetry is the
|
|
22
|
-
// callers' job.
|
|
23
|
-
|
|
24
|
-
import type { LoggerDeps } from "#conversation/logger.ts";
|
|
25
|
-
import { type WritableDatabase, withTransaction } from "#db/database.ts";
|
|
26
|
-
|
|
27
|
-
export interface ImportedMessage {
|
|
28
|
-
role: string;
|
|
29
|
-
content: string;
|
|
30
|
-
createdAt: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface ClosedSessionImport {
|
|
34
|
-
interface: string;
|
|
35
|
-
messages: readonly ImportedMessage[];
|
|
36
|
-
endedAt: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Import a closed session transcript as one conversation bound to
|
|
41
|
-
* `sessionId`. Returns the new conversation id, or null when the session is
|
|
42
|
-
* already tracked -- including by a writer that won the binding while this
|
|
43
|
-
* call waited for the lock.
|
|
44
|
-
*/
|
|
45
|
-
export function importClosedConversation(
|
|
46
|
-
db: WritableDatabase,
|
|
47
|
-
sessionId: string,
|
|
48
|
-
session: ClosedSessionImport,
|
|
49
|
-
deps: LoggerDeps,
|
|
50
|
-
): string | null {
|
|
51
|
-
return withTransaction(db, () => {
|
|
52
|
-
const tracked = db
|
|
53
|
-
.prepare("SELECT 1 FROM runtime_sessions WHERE session_id = ?")
|
|
54
|
-
.get(sessionId);
|
|
55
|
-
if (tracked) return null;
|
|
56
|
-
|
|
57
|
-
// Python's Conversation(...) draws its id before its started_at; keep the
|
|
58
|
-
// same generator order so injected sequences line up with the oracle.
|
|
59
|
-
const conversationId = deps.newId();
|
|
60
|
-
const startedAt = deps.nowIso();
|
|
61
|
-
db.prepare(
|
|
62
|
-
`INSERT INTO conversations
|
|
63
|
-
(id, title, started_at, ended_at, interface, persona, journey, summary, tags, metadata)
|
|
64
|
-
VALUES (?, NULL, ?, ?, ?, NULL, NULL, NULL, NULL, NULL)`,
|
|
65
|
-
).run(conversationId, startedAt, session.endedAt, session.interface);
|
|
66
|
-
|
|
67
|
-
const insertMessage = db.prepare(
|
|
68
|
-
`INSERT INTO messages (id, conversation_id, role, content, created_at, token_count, metadata)
|
|
69
|
-
VALUES (?, ?, ?, ?, ?, NULL, NULL)`,
|
|
70
|
-
);
|
|
71
|
-
for (const message of session.messages) {
|
|
72
|
-
insertMessage.run(
|
|
73
|
-
deps.newId(),
|
|
74
|
-
conversationId,
|
|
75
|
-
message.role,
|
|
76
|
-
message.content,
|
|
77
|
-
message.createdAt,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
db.prepare(
|
|
82
|
-
`INSERT INTO runtime_sessions
|
|
83
|
-
(session_id, conversation_id, interface, mirror_active, persona, journey,
|
|
84
|
-
hook_injected, active, started_at, updated_at, closed_at, metadata)
|
|
85
|
-
VALUES (?, ?, ?, 0, NULL, NULL, 0, 0, ?, ?, ?, NULL)`,
|
|
86
|
-
).run(sessionId, conversationId, session.interface, startedAt, startedAt, session.endedAt);
|
|
87
|
-
|
|
88
|
-
return conversationId;
|
|
89
|
-
});
|
|
90
|
-
}
|