mirror-mind 0.38.1 → 0.39.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/bin/loader.js CHANGED
@@ -24,34 +24,52 @@
24
24
  // stands on; `scripts/smoke_npm_package.sh` runs both bins from a real global
25
25
  // install on CI's Node 24.
26
26
  //
27
- // The warning filter below is the shim's own copy, because the hook emits its
27
+ // The warning filter is the shim's own copy, because the hook emits its
28
28
  // ExperimentalWarning before the core's filter can be imported through it.
29
+ // `boot` installs it; `registerTypeStripping` alone does not (CV24.DS1).
29
30
 
30
31
  import { readFileSync } from "node:fs";
31
32
  import { registerHooks, stripTypeScriptTypes } from "node:module";
32
33
  import { fileURLToPath } from "node:url";
33
34
 
34
- process.removeAllListeners("warning");
35
- process.on("warning", (warning) => {
36
- if (warning.name === "ExperimentalWarning") return;
37
- process.stderr.write(`${warning.name}: ${warning.message}\n`);
38
- });
39
-
40
35
  const packageRoot = new URL("../", import.meta.url).href;
36
+ const REGISTERED = Symbol.for("mirror-mind.typeStripping");
41
37
 
42
- registerHooks({
43
- load(url, context, nextLoad) {
44
- if (url.startsWith(packageRoot) && url.endsWith(".ts")) {
45
- const source = readFileSync(fileURLToPath(url), "utf8");
46
- return {
47
- format: "module",
48
- source: stripTypeScriptTypes(source, { mode: "strip" }),
49
- shortCircuit: true,
50
- };
51
- }
52
- return nextLoad(url, context);
53
- },
54
- });
38
+ /**
39
+ * Register the load hook that strips types for `.ts` files under this package
40
+ * root. Idempotent per package root, so the bins and the library entry
41
+ * (`lib/desktop.js`, CV24.DS1) can both call it in one process.
42
+ *
43
+ * It does nothing to the process's warnings: a library entry runs inside a
44
+ * host that owns them. Only `boot`, which owns its process, filters them.
45
+ */
46
+ export function registerTypeStripping() {
47
+ if (!globalThis[REGISTERED]) globalThis[REGISTERED] = new Set();
48
+ const roots = globalThis[REGISTERED];
49
+ if (roots.has(packageRoot)) return;
50
+ roots.add(packageRoot);
51
+ registerHooks({
52
+ load(url, context, nextLoad) {
53
+ if (url.startsWith(packageRoot) && url.endsWith(".ts")) {
54
+ const source = readFileSync(fileURLToPath(url), "utf8");
55
+ return {
56
+ format: "module",
57
+ source: stripTypeScriptTypes(source, { mode: "strip" }),
58
+ shortCircuit: true,
59
+ };
60
+ }
61
+ return nextLoad(url, context);
62
+ },
63
+ });
64
+ }
65
+
66
+ function filterExperimentalWarnings() {
67
+ process.removeAllListeners("warning");
68
+ process.on("warning", (warning) => {
69
+ if (warning.name === "ExperimentalWarning") return;
70
+ process.stderr.write(`${warning.name}: ${warning.message}\n`);
71
+ });
72
+ }
55
73
 
56
74
  /**
57
75
  * Import the entry at `entryPath` (relative to the package root) and run its
@@ -62,6 +80,9 @@ registerHooks({
62
80
  * @returns {Promise<number>}
63
81
  */
64
82
  export async function boot(entryPath) {
83
+ // The filter first: registering the hook emits its own ExperimentalWarning.
84
+ filterExperimentalWarnings();
85
+ registerTypeStripping();
65
86
  const { runAsEntry } = await import(new URL(entryPath, packageRoot).href);
66
87
  return runAsEntry();
67
88
  }
@@ -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.39.0 — The Core as a Library](v0.39.0.md) — minor release of CV24's first eight Delivery Stories: `mirror-mind/desktop`, a library entry through which a host such as Mirror Desktop 0.3 embeds this core; calls for a home's migration state, Conversations, sidebar reads, home creation, migration on request, Mirror's context, learning from open Conversations, and Journey administration, each with an explicit path and no environment. The new `exports` field closes every other deep import. No migration.
15
16
  - [v0.38.1 — The Refusal Names the Way Out](v0.38.1.md) — patch release of three Change Requests, CR185, CR186, and CR187: a package with no home configured names the homes it finds and the `mirror init <name>` that selects one, and `init` adopts a home that already exists; a missing or refused OpenRouter key is one line that names the file, before anything is touched; a `stable` clone ahead only by `main`'s commits is told that `stable` is behind it, not to push or reset. No migration.
16
17
  - [v0.38.0 — The Model Behind the Memories, Chosen by Measurement](v0.38.0.md) — minor release closing CV9.DS9: the model behind memories, tasks, titles, summaries, tags, reception, journal, shadow, and consolidation is Claude Haiku 5.5 at reasoning off, chosen on the eval suite with reasoning on and off and on 24 of the owner's own conversations ranked blind, where it came first on 16 of 26 surfaces. Every extraction-pin call can carry a reasoning effort, and a pin carries its own default. With CR180, summaries open with their subject, every lifecycle prompt sees the owner's name, and a framed opening is written once more. Release gate 70 of 70 probes. Rollback is one env line. No migration.
17
18
  - [v0.37.0 — The Mirror Stays in the Conversation](v0.37.0.md) — minor release closing CV9.DS8 and, with CV21.DS11, CV21: on Pi, Claude Code, Codex, and Antigravity one doorman in the core puts Mirror's context before the model on every turn of Mirror Mode, all of it on the turn Mirror Mode starts and then the persona and memories each question calls for; an explicit turn loads once; one signature, `◇ persona-name`; Pi reaches L4, measured live. One parity matrix in the runtime interface contract, sourced and held by a test. Codex, Antigravity, and Grok Build need their plugin copy brought level. Migration 018, applied on its own.
@@ -0,0 +1,110 @@
1
+ ---
2
+ digest: >
3
+ v0.39.0 is a minor release that ships CV24's first eight Delivery Stories: a library
4
+ entry, `mirror-mind/desktop`, through which a host such as Mirror Desktop 0.3 embeds
5
+ this core in its own Node process instead of spawning a CLI. Twenty-five calls and refusal
6
+ classes, beside `DESKTOP_API_VERSION`, cover a
7
+ home's migration state, Conversations, sidebar reads, home creation, migration on
8
+ request, Mirror's context for a turn, learning from open Conversations, and Journey
9
+ administration, each taking an explicit path and reading no environment. The package
10
+ gains an `exports` field, which closes every deep import other than the new entry. The
11
+ terminal, the bins and the hooks behave as before. No migration, and no model or prompt
12
+ change.
13
+ ---
14
+
15
+ # v0.39.0 — The Core as a Library
16
+
17
+ **Date:** 2026-10-31
18
+
19
+ ## Highlights
20
+
21
+ - **`mirror-mind/desktop`, a library entry for a host**
22
+ ([CV24](../project/roadmap/cv24-embeddable-core-for-mirror-desktop/index.md)). Imported by
23
+ package name from a host's `node_modules`, with no flags: the entry registers the bins'
24
+ type-stripping hook itself. Everything a host may call is re-exported there by name, with
25
+ `DESKTOP_API_VERSION = 1`; a name not in that file is not part of the contract.
26
+ - **Eight stories, one rule.** Every call takes an explicit database or home path, never reads
27
+ `MIRROR_HOME`, `MIRROR_USER`, `DB_PATH` or a key from the environment, never migrates as a side
28
+ effect, and refuses a database that is not `current` before opening it for writing.
29
+ - **Custody chosen per write, with a decision for each.** Conversation writes go through a
30
+ narrowed handle instead of a per-write snapshot; a home is created beside its target and renamed
31
+ into place; a home migrates only behind a uniquely named, verified backup the core never deletes.
32
+ - **Breaking for deep imports only.** The package now declares `exports` with `./desktop` and
33
+ `./package.json`. Any other `import "mirror-mind/…"` path that resolved before no longer does.
34
+ The `mirror` and `mirror-hook` bins are unaffected.
35
+ - No migration, and no model or prompt change, so the model-behavior release gate does not apply.
36
+
37
+ ## Where We Started
38
+
39
+ Decision CV22.DS10.TS1 left Mirror Desktop outside the TypeScript migration: it kept calling the
40
+ Python core through the retired Journey administration and a catalog script. On 2026-10-28 Mirror
41
+ Desktop decided to follow this core instead. It froze its 0.2 line on Python and opened a 0.3 line
42
+ that hosts the core in its own Node process, on its own home.
43
+
44
+ That host could not use the CLI's way in. It runs from `node_modules`, where Node refuses to strip
45
+ TypeScript types. A shell that starts it may export another home's `MIRROR_HOME`. It writes on every
46
+ turn of a Conversation that never ends. And it has to ask the person before a home is migrated,
47
+ while every open through the front door migrates first.
48
+
49
+ ## What Changed
50
+
51
+ The contract grew one story at a time on `desktop/library-seam-1a`, each with its spec first, and
52
+ merged as PR #52.
53
+
54
+ - **DS1, inspect a home without migrating.** `inspectMigrationState` reports `missing`,
55
+ `not_mirror`, `current`, `pending` or `ahead`, with the pending list. It shares its three
56
+ ledger readers with `ensureMigratedOnOpen`, so what it calls pending is exactly what an open
57
+ would apply. The loader became `boot` plus an idempotent `registerTypeStripping`; only `boot`,
58
+ which owns its process, filters warnings.
59
+ - **DS2, Conversations as calls.** `ensureConversation`, `appendConversationMessages` (the
60
+ `conversations append` contract, byte for byte), `renameConversation` and
61
+ `rebindConversationJourney`, written through `openDatabaseForConversationWrites`. That handle
62
+ prepares only a single SELECT, INSERT … VALUES into three tables, and an UPDATE of four
63
+ `conversations` columns by id. The per-write `VACUUM INTO` measured about 0.9 s on a 108 MB
64
+ home, too much for every turn.
65
+ - **DS3, Journey reads.** `readJourneyTree` (a new contract in the stored sibling order, not a
66
+ revival of the retired registry dump), `listJourneyConversations` and `inspectConversation`,
67
+ read-only.
68
+ - **DS4, create a home.** `createHome` copies the templates with the person's name, bootstraps,
69
+ and seeds in a staging directory, renaming it into place only when the home reads `current`.
70
+ It never writes `~/.config/mirror/env`.
71
+ - **DS5, migrate on request.** `migrateHome` applies exactly the pending list the person was
72
+ shown, under the bootstrap lock, behind a backup that is quick-checked, hashed, and read back as
73
+ the same pre-image.
74
+ - **DS6, Mirror's context as a call.** `deliverMirrorContext` is the doorman for a host's turn,
75
+ with the key as an argument and Mirror Mode from the first turn; each provider call is tried
76
+ once within its own bound. Its write is `mirror doorman`'s, behind the front door's snapshot.
77
+ - **DS7, learning from an open Conversation.** `learnFromConversations` extracts the stretch an
78
+ idle, unended Conversation gained since its `learned_through` watermark, in bounded pieces,
79
+ with the terminal's prompts, and never ends it.
80
+ - **DS8, Journey administration as calls.** `addJourney`, `setJourneyProjectPath`, `moveJourney`
81
+ and `deleteJourney` port Python 0.31.14's administration, so the requests Desktop 0.2 sent keep
82
+ their meaning. `updateJourney` is new: it edits a Journey's name, description and status, and
83
+ refuses `journey_changed` when the row changed since the host read it.
84
+
85
+ Existing code changed only where the entry needed it: the loader, three exported readers, two
86
+ `export` keywords in `extraction.ts`, the tarball guard (`lib/` allowed, `lib/desktop.js`
87
+ required), and `package.json`. No existing test changed. The npm smoke installs the package and
88
+ imports the entry from a host project for DS1 to DS5.
89
+
90
+ ## Conscious Exclusions
91
+
92
+ - **Renaming a Journey's slug.** The slug keys Conversations, memories and tasks; renaming it is a
93
+ migration, not a call.
94
+ - **A Journey's icon and color, and a receipt ledger for Journey calls.**
95
+ - **The front door's own migration custody.** `ensureMigratedOnOpen` still overwrites one fixed
96
+ backup; giving it DS5's custody is a separate decision.
97
+ - **Installed-package smoke for DS6 to DS8.** DS6 and DS7 need a provider key. DS8 is exercised
98
+ end to end by Mirror Desktop's real-host test; a keyless smoke probe is a cheap follow-up.
99
+
100
+ ## What We Learned
101
+
102
+ A library entry is a stricter consumer than a CLI. Every assumption the bins could make about
103
+ their own process — environment, warnings, migrate-on-open, a snapshot per write — had to become
104
+ an explicit argument or a decision. The retired-surfaces sweep also caught the port twice naming a
105
+ retired verb in comments, which says the sweep is doing its job on new files.
106
+
107
+ ## Next Horizon
108
+
109
+ Mirror Desktop moves its pin from a branch tarball to this release, then its first 0.3 alpha. CV24
110
+ stays open for the pieces that alpha's use asks for.
package/lib/desktop.js ADDED
@@ -0,0 +1,72 @@
1
+ // `mirror-mind/desktop` — the core's library entry for Mirror Desktop. CV24.DS1.
2
+ //
3
+ // Mirror Desktop hosts this core in its own Node process and imports it from
4
+ // `node_modules`, where Node refuses to strip TypeScript types. So this entry
5
+ // registers the same type-stripping hook the bins use, and only then imports
6
+ // the TypeScript modules -- dynamically, because a static `export ... from
7
+ // "./x.ts"` would be loaded before the hook exists.
8
+ //
9
+ // Everything Desktop may call is re-exported here BY NAME. A name missing from
10
+ // this file is not part of the contract, whatever the TypeScript tree holds.
11
+ // `DESKTOP_API_VERSION` changes when a name is removed or changes meaning.
12
+
13
+ import { registerTypeStripping } from "../bin/loader.js";
14
+
15
+ registerTypeStripping();
16
+
17
+ const migrationReport = await import("../ts/src/db/migrationReport.ts");
18
+ const conversations = await import("../ts/src/conversation/hostCalls.ts");
19
+ const reads = await import("../ts/src/conversation/hostReads.ts");
20
+ const homes = await import("../ts/src/init/createHome.ts");
21
+ const migration = await import("../ts/src/db/migrateHome.ts");
22
+ const context = await import("../ts/src/mirror/deliverContext.ts");
23
+ const learning = await import("../ts/src/conversation/learnFromConversations.ts");
24
+ const journeys = await import("../ts/src/journey/journeyCalls.ts");
25
+
26
+ export const DESKTOP_API_VERSION = 1;
27
+ export const inspectMigrationState = migrationReport.inspectMigrationState;
28
+
29
+ // CV24.DS2: Conversations as calls. Each opens only a `current` database,
30
+ // never migrates, and writes through the narrowed Conversation handle.
31
+ export const ensureConversation = conversations.ensureConversation;
32
+ export const appendConversationMessages = conversations.appendConversationMessages;
33
+ export const renameConversation = conversations.renameConversation;
34
+ export const rebindConversationJourney = conversations.rebindConversationJourney;
35
+ export const DatabaseNotCurrentError = conversations.DatabaseNotCurrentError;
36
+ export const ConversationCallRejectedError = conversations.ConversationCallRejectedError;
37
+
38
+ // CV24.DS3: the reads a sidebar is built from. Read-only, `current` homes only.
39
+ export const readJourneyTree = reads.readJourneyTree;
40
+ export const listJourneyConversations = reads.listJourneyConversations;
41
+ export const inspectConversation = reads.inspectConversation;
42
+
43
+ // CV24.DS4: create a complete, seeded home at an explicit path, all or nothing.
44
+ // Writes nothing outside it, never the machine-wide ~/.config/mirror/env.
45
+ export const createHome = homes.createHome;
46
+ export const CreateHomeRejectedError = homes.CreateHomeRejectedError;
47
+
48
+ // CV24.DS5: migrate a home on request, applying exactly the pending list the
49
+ // person was shown, behind a uniquely named, verified, retained backup.
50
+ export const migrateHome = migration.migrateHome;
51
+ export const MigrateHomeRejectedError = migration.MigrateHomeRejectedError;
52
+
53
+ // CV24.DS6: Mirror's context for a host's turn -- the doorman as a call. The
54
+ // key is an argument; nothing is read from the environment. A Conversation is
55
+ // in Mirror Mode from its first turn.
56
+ export const deliverMirrorContext = context.deliverMirrorContext;
57
+ export const forgetMirrorContext = context.forgetMirrorContext;
58
+ export const MirrorContextRejectedError = context.MirrorContextRejectedError;
59
+
60
+ // CV24.DS7: learn from a host's idle Conversations without ending them -- the
61
+ // stretch each gained since it was last learned from. The key is an argument.
62
+ export const learnFromConversations = learning.learnFromConversations;
63
+ export const LearningRejectedError = learning.LearningRejectedError;
64
+
65
+ // CV24.DS8: Journeys as calls -- create, edit, move, set a project path,
66
+ // delete -- each answering with the tree `readJourneyTree` reads.
67
+ export const addJourney = journeys.addJourney;
68
+ export const updateJourney = journeys.updateJourney;
69
+ export const setJourneyProjectPath = journeys.setJourneyProjectPath;
70
+ export const moveJourney = journeys.moveJourney;
71
+ export const deleteJourney = journeys.deleteJourney;
72
+ export const JourneyCallRejectedError = journeys.JourneyCallRejectedError;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.38.1",
3
+ "version": "0.39.0",
4
4
  "description": "Mirror Mind: a local-first memory and identity framework for agentic AI runtimes (Pi, Claude Code, Codex, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,8 +27,13 @@
27
27
  "mirror": "bin/mirror.js",
28
28
  "mirror-hook": "bin/mirror-hook.js"
29
29
  },
30
+ "exports": {
31
+ "./desktop": "./lib/desktop.js",
32
+ "./package.json": "./package.json"
33
+ },
30
34
  "files": [
31
35
  "bin",
36
+ "lib",
32
37
  "ts/src",
33
38
  "templates",
34
39
  "docs/releases",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.38.1",
3
+ "version": "0.39.0",
4
4
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
5
5
  "author": {
6
6
  "name": "Mirror Mind"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
3
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
4
- "version": "0.38.1",
4
+ "version": "0.39.0",
5
5
  "mcpServers": {
6
6
  "mirror-mind": {
7
7
  "command": "/bin/sh",
@@ -179,7 +179,7 @@ export async function runConversationExtraction(
179
179
  return { memoryIds, taskIds, extracted: memoryIds.length };
180
180
  }
181
181
 
182
- async function persistExtractedTasks(
182
+ export async function persistExtractedTasks(
183
183
  db: WritableDatabase,
184
184
  llm: LlmProvider,
185
185
  messages: readonly ExtractionMessage[],
@@ -295,7 +295,7 @@ function logEmbeddingAttempt(
295
295
  return embeddingLedgerHook(db, { conversationId, now });
296
296
  }
297
297
 
298
- function insertMemory(
298
+ export function insertMemory(
299
299
  db: WritableDatabase,
300
300
  id: string,
301
301
  conversationId: string,
@@ -0,0 +1,204 @@
1
+ // Conversations as calls — CV24.DS2.
2
+ //
3
+ // A host that embeds this core (Mirror Desktop 0.3) records Conversations
4
+ // through four calls instead of the CLI and Python's `MemoryClient`:
5
+ //
6
+ // - `ensureConversation`: the Conversation bound to a runtime session,
7
+ // created and bound when none is live. A port of Mirror Desktop's
8
+ // `provision_mirror_conversation.py`, step for step:
9
+ // `get_or_create_conversation`, `upsert_runtime_session`, then
10
+ // `update_conversation(title, journey, interface)`.
11
+ // - `appendConversationMessages`: the `conversations append` contract, the
12
+ // same request and the same receipts, byte for byte.
13
+ // - `renameConversation`: a manual title, locked against the close tail.
14
+ // - `rebindConversationJourney`: move a Conversation to another Journey.
15
+ //
16
+ // Every call:
17
+ //
18
+ // - takes an explicit database path and never reads the environment;
19
+ // - asks the migration report first (CV24.DS1), and refuses a database that
20
+ // is not `current` without opening it for writing, so it never migrates;
21
+ // - writes through `openDatabaseForConversationWrites`, the narrowed handle,
22
+ // instead of the CLI's per-write snapshot (see the decision "A host's
23
+ // Conversation writes are narrowed, not snapshotted").
24
+ //
25
+ // The caller names the `interface`; this module does not know which host it
26
+ // serves.
27
+
28
+ import {
29
+ type AppendReceipt,
30
+ AppendRejectedError,
31
+ appendConversationMessages as appendBatch,
32
+ parseAppendRequest,
33
+ } from "#conversation/append.ts";
34
+ import { cleanTitle, updateConversation, writeTitle } from "#conversation/lifecycleWrites.ts";
35
+ import { getOrCreateSessionConversation } from "#conversation/logger.ts";
36
+ import { openDatabaseForConversationWrites, type WritableDatabase } from "#db/database.ts";
37
+ import {
38
+ inspectMigrationState,
39
+ type MigrationReport,
40
+ type MigrationState,
41
+ } from "#db/migrationReport.ts";
42
+ import { upsertRuntimeSession } from "#mirror/runtimeSession.ts";
43
+ import { newId, nowIso } from "#util/pyGenerators.ts";
44
+
45
+ /** The database is not one these calls may write: anything but `current`. */
46
+ export class DatabaseNotCurrentError extends Error {
47
+ readonly state: MigrationState;
48
+ readonly report: MigrationReport;
49
+
50
+ constructor(report: MigrationReport) {
51
+ super(`database is ${report.state}, not current; no Conversation was written`);
52
+ this.state = report.state;
53
+ this.report = report;
54
+ }
55
+ }
56
+
57
+ export type ConversationCallRejection =
58
+ | "invalid_request"
59
+ | "conversation_unavailable"
60
+ | "invalid_title";
61
+
62
+ /** A call's own refusal, named as Mirror Desktop's catalog script named it. */
63
+ export class ConversationCallRejectedError extends Error {
64
+ readonly reason: ConversationCallRejection;
65
+
66
+ constructor(reason: ConversationCallRejection) {
67
+ super(reason);
68
+ this.reason = reason;
69
+ }
70
+ }
71
+
72
+ function withConversationWrites<T>(dbPath: string, write: (db: WritableDatabase) => T): T {
73
+ const report = inspectMigrationState(dbPath);
74
+ if (report.state !== "current") throw new DatabaseNotCurrentError(report);
75
+ const db = openDatabaseForConversationWrites(dbPath);
76
+ try {
77
+ return write(db);
78
+ } finally {
79
+ db.close();
80
+ }
81
+ }
82
+
83
+ function text(value: unknown): string {
84
+ if (typeof value !== "string" || value.length === 0) {
85
+ throw new ConversationCallRejectedError("invalid_request");
86
+ }
87
+ return value;
88
+ }
89
+
90
+ /** The Conversation row by EXACT id: a host names one, never a prefix. */
91
+ function exactConversation(
92
+ db: WritableDatabase,
93
+ conversationId: unknown,
94
+ ): { id: string; journey: unknown } {
95
+ const id = text(conversationId);
96
+ const found = db.prepare("SELECT id, journey FROM conversations WHERE id = ?").get(id);
97
+ if (!found) throw new ConversationCallRejectedError("conversation_unavailable");
98
+ return { id: String(found.id), journey: found.journey };
99
+ }
100
+
101
+ export interface EnsureConversationRequest {
102
+ sessionId: string;
103
+ journeyId: string;
104
+ title: string;
105
+ interface: string;
106
+ }
107
+
108
+ /** The live Conversation bound to `sessionId`, created and bound if there is none. */
109
+ export function ensureConversation(
110
+ dbPath: string,
111
+ request: EnsureConversationRequest,
112
+ ): { conversationId: string; journeyId: string } {
113
+ const sessionId = text(request?.sessionId);
114
+ const journeyId = text(request?.journeyId);
115
+ const title = text(request?.title);
116
+ const surface = text(request?.interface);
117
+ return withConversationWrites(dbPath, (db) => {
118
+ const conversationId = getOrCreateSessionConversation(
119
+ db,
120
+ sessionId,
121
+ { interface: surface, journey: journeyId, title },
122
+ { newId, nowIso },
123
+ );
124
+ upsertRuntimeSession(
125
+ db,
126
+ sessionId,
127
+ { conversationId, interface: surface, journey: journeyId, active: true, closedAt: null },
128
+ nowIso(),
129
+ );
130
+ updateConversation(db, conversationId, { title, journey: journeyId, interface: surface });
131
+ return { conversationId, journeyId };
132
+ });
133
+ }
134
+
135
+ /** What `conversations append` would print as its rejection. */
136
+ type AppendRejectionReceipt = ReturnType<AppendRejectedError["receipt"]>;
137
+
138
+ /**
139
+ * Append one batch under the `conversations append` contract. Returns the
140
+ * receipt the CLI would print, accepted or rejected; a database that is not
141
+ * `current` throws `DatabaseNotCurrentError` instead, because the CLI would
142
+ * have migrated it and these calls never do.
143
+ */
144
+ export function appendConversationMessages(
145
+ dbPath: string,
146
+ payload: unknown,
147
+ ): AppendReceipt | AppendRejectionReceipt {
148
+ let request: ReturnType<typeof parseAppendRequest>;
149
+ try {
150
+ request = parseAppendRequest(payload);
151
+ } catch (error) {
152
+ if (error instanceof AppendRejectedError) return error.receipt();
153
+ return new AppendRejectedError("malformed_request").receipt();
154
+ }
155
+ try {
156
+ return withConversationWrites(dbPath, (db) => appendBatch(db, request));
157
+ } catch (error) {
158
+ if (error instanceof DatabaseNotCurrentError) throw error;
159
+ if (error instanceof AppendRejectedError) return error.receipt();
160
+ return new AppendRejectedError("persistence_failure").receipt();
161
+ }
162
+ }
163
+
164
+ /** Give a Conversation of `journeyId` a manual title, which the close tail will not replace. */
165
+ export function renameConversation(
166
+ dbPath: string,
167
+ request: { conversationId: string; journeyId: string; title: string },
168
+ ): { conversationId: string; journeyId: string; title: string } {
169
+ const journeyId = text(request?.journeyId);
170
+ let title: string;
171
+ try {
172
+ title = cleanTitle(typeof request?.title === "string" ? request.title : "");
173
+ } catch {
174
+ throw new ConversationCallRejectedError("invalid_title");
175
+ }
176
+ return withConversationWrites(dbPath, (db) => {
177
+ const conversation = exactConversation(db, request?.conversationId);
178
+ if (conversation.journey !== journeyId)
179
+ throw new ConversationCallRejectedError("conversation_unavailable");
180
+ writeTitle(db, conversation.id, title, "manual");
181
+ return { conversationId: conversation.id, journeyId, title };
182
+ });
183
+ }
184
+
185
+ /** Move a Conversation to `journeyId`; a no-op when it is already there. */
186
+ export function rebindConversationJourney(
187
+ dbPath: string,
188
+ request: { conversationId: string; journeyId: string },
189
+ ): {
190
+ conversationId: string;
191
+ journeyId: string;
192
+ rebound: boolean;
193
+ previousJourneyId: string | null;
194
+ } {
195
+ const journeyId = text(request?.journeyId);
196
+ return withConversationWrites(dbPath, (db) => {
197
+ const conversation = exactConversation(db, request?.conversationId);
198
+ const previousJourneyId =
199
+ typeof conversation.journey === "string" ? conversation.journey : null;
200
+ const rebound = previousJourneyId !== journeyId;
201
+ if (rebound) updateConversation(db, conversation.id, { journey: journeyId });
202
+ return { conversationId: conversation.id, journeyId, rebound, previousJourneyId };
203
+ });
204
+ }
@@ -0,0 +1,126 @@
1
+ // The reads a host's sidebar is built from — CV24.DS3.
2
+ //
3
+ // Three calls for a host that embeds this core (Mirror Desktop 0.3), each
4
+ // replacing a route it took through Python:
5
+ //
6
+ // - `readJourneyTree`: every Journey as a tree (was the Journey registry dump
7
+ // CV22.DS10.TS4 retired as `journey-admin-verbs`; this is a new contract,
8
+ // see `journey/journeyTree.ts`);
9
+ // - `listJourneyConversations`: one Journey's Conversations, newest first
10
+ // (was the catalog script's `catalog`);
11
+ // - `inspectConversation`: one Conversation by EXACT id (was its `inspect`).
12
+ //
13
+ // Like the Conversation calls (`hostCalls.ts`), each asks the migration report
14
+ // first and refuses a database that is not `current`, never migrates, and reads
15
+ // only the path it is given. Unlike them, each opens read-only: on a WAL
16
+ // database the only trace is the `-wal` and `-shm` every reader leaves.
17
+
18
+ import { listRecentConversationSummaries } from "#conversation/listing.ts";
19
+ import { type Database, openDatabaseReadOnly } from "#db/database.ts";
20
+ import { inspectMigrationState } from "#db/migrationReport.ts";
21
+ import { buildJourneyTree, type JourneyTree, type JourneyTreeRow } from "#journey/journeyTree.ts";
22
+ import { ConversationCallRejectedError, DatabaseNotCurrentError } from "./hostCalls.ts";
23
+
24
+ /** The catalog's bound, as Mirror Desktop's catalog script set it. */
25
+ const MAX_LIST_LIMIT = 100;
26
+
27
+ function withRead<T>(dbPath: string, read: (db: Database) => T): T {
28
+ const report = inspectMigrationState(dbPath);
29
+ if (report.state !== "current") throw new DatabaseNotCurrentError(report);
30
+ const db = openDatabaseReadOnly(dbPath);
31
+ try {
32
+ return read(db);
33
+ } finally {
34
+ db.close();
35
+ }
36
+ }
37
+
38
+ function text(value: unknown): string {
39
+ if (typeof value !== "string" || value.length === 0) {
40
+ throw new ConversationCallRejectedError("invalid_request");
41
+ }
42
+ return value;
43
+ }
44
+
45
+ /** Every Journey in the home, as a tree in its stored sibling order. */
46
+ export function readJourneyTree(dbPath: string): JourneyTree {
47
+ return withRead(dbPath, (db) =>
48
+ buildJourneyTree(
49
+ db
50
+ .prepare(
51
+ "SELECT key, content, updated_at, metadata FROM identity WHERE layer = 'journey' ORDER BY key",
52
+ )
53
+ .all() as unknown as JourneyTreeRow[],
54
+ ),
55
+ );
56
+ }
57
+
58
+ export interface JourneyConversationEntry {
59
+ conversationId: string;
60
+ title: string | null;
61
+ startedAt: string | null;
62
+ messageCount: number;
63
+ persona: string | null;
64
+ }
65
+
66
+ /** A Journey's Conversations, newest first, at most `limit` (1 to 100). */
67
+ export function listJourneyConversations(
68
+ dbPath: string,
69
+ request: { journeyId: string; limit: number },
70
+ ): JourneyConversationEntry[] {
71
+ const journeyId = text(request?.journeyId);
72
+ const limit = request?.limit;
73
+ if (
74
+ typeof limit !== "number" ||
75
+ !Number.isInteger(limit) ||
76
+ limit < 1 ||
77
+ limit > MAX_LIST_LIMIT
78
+ ) {
79
+ throw new ConversationCallRejectedError("invalid_request");
80
+ }
81
+ return withRead(dbPath, (db) =>
82
+ listRecentConversationSummaries(db, { journey: journeyId, limit }).map((summary) => ({
83
+ conversationId: summary.id,
84
+ title: summary.title,
85
+ startedAt: summary.started_at,
86
+ messageCount: summary.message_count,
87
+ persona: summary.persona,
88
+ })),
89
+ );
90
+ }
91
+
92
+ export interface ConversationInspection {
93
+ conversationId: string;
94
+ journeyId: string | null;
95
+ title: string | null;
96
+ startedAt: string | null;
97
+ endedAt: string | null;
98
+ messageCount: number;
99
+ }
100
+
101
+ /** One Conversation by exact id; a prefix is never resolved. */
102
+ export function inspectConversation(
103
+ dbPath: string,
104
+ request: { conversationId: string },
105
+ ): ConversationInspection {
106
+ const conversationId = text(request?.conversationId);
107
+ return withRead(dbPath, (db) => {
108
+ const found = db
109
+ .prepare(
110
+ "SELECT id, journey, title, started_at, ended_at, " +
111
+ "(SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) AS message_count " +
112
+ "FROM conversations c WHERE id = ?",
113
+ )
114
+ .get(conversationId);
115
+ if (!found) throw new ConversationCallRejectedError("conversation_unavailable");
116
+ const nullable = (value: unknown): string | null => (typeof value === "string" ? value : null);
117
+ return {
118
+ conversationId: String(found.id),
119
+ journeyId: nullable(found.journey),
120
+ title: nullable(found.title),
121
+ startedAt: nullable(found.started_at),
122
+ endedAt: nullable(found.ended_at),
123
+ messageCount: Number(found.message_count),
124
+ };
125
+ });
126
+ }