mirror-mind 0.38.0 → 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.
Files changed (42) hide show
  1. package/bin/loader.js +41 -20
  2. package/docs/releases/index.md +2 -0
  3. package/docs/releases/v0.33.3.md +9 -1
  4. package/docs/releases/v0.38.1.md +152 -0
  5. package/docs/releases/v0.39.0.md +110 -0
  6. package/lib/desktop.js +72 -0
  7. package/package.json +6 -1
  8. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  9. package/plugins/mirror-mind/plugin.json +1 -1
  10. package/ts/src/conversation/extraction.ts +2 -2
  11. package/ts/src/conversation/hostCalls.ts +204 -0
  12. package/ts/src/conversation/hostReads.ts +126 -0
  13. package/ts/src/conversation/learnFromConversations.ts +405 -0
  14. package/ts/src/db/database.ts +79 -0
  15. package/ts/src/db/migrateHome.ts +153 -0
  16. package/ts/src/db/migrateOnOpen.ts +7 -3
  17. package/ts/src/db/migrationReport.ts +82 -0
  18. package/ts/src/frontDoor/cli.ts +32 -103
  19. package/ts/src/frontDoor/consultRoute.ts +4 -2
  20. package/ts/src/frontDoor/dbPath.ts +80 -7
  21. package/ts/src/frontDoor/initRoute.ts +190 -0
  22. package/ts/src/frontDoor/mirrorHomes.ts +95 -0
  23. package/ts/src/frontDoor/namedRefusal.ts +29 -1
  24. package/ts/src/frontDoor/runtimeRoute.ts +11 -6
  25. package/ts/src/frontDoor/searchRoute.ts +31 -3
  26. package/ts/src/frontDoor/usage.ts +17 -3
  27. package/ts/src/guards/packContents.ts +3 -0
  28. package/ts/src/init/createHome.ts +136 -0
  29. package/ts/src/init/init.ts +2 -1
  30. package/ts/src/journey/journeyCalls.ts +516 -0
  31. package/ts/src/journey/journeyTree.ts +191 -0
  32. package/ts/src/mirror/deliverContext.ts +239 -0
  33. package/ts/src/providers/config.ts +88 -1
  34. package/ts/src/providers/embedding.ts +26 -3
  35. package/ts/src/providers/familyProviders.ts +43 -0
  36. package/ts/src/runtime/diagnose.ts +28 -1
  37. package/ts/src/runtime/git.ts +153 -42
  38. package/ts/src/runtime/status.ts +27 -17
  39. package/ts/src/runtime/strategies/clone.ts +76 -55
  40. package/ts/src/runtime/strategies/package.ts +26 -7
  41. package/ts/src/runtime/update.ts +2 -1
  42. package/ts/src/search/memorySearch.ts +4 -2
@@ -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
+ }