mirror-mind 0.37.0 → 0.38.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 +2 -0
- package/docs/releases/v0.33.3.md +9 -1
- package/docs/releases/v0.38.0.md +215 -0
- package/docs/releases/v0.38.1.md +152 -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/closeTail.ts +7 -3
- package/ts/src/conversation/extraction.ts +1 -11
- package/ts/src/conversation/userName.ts +44 -0
- package/ts/src/extraction/conversationMetadata.ts +53 -8
- package/ts/src/extraction/prompts.ts +8 -3
- package/ts/src/frontDoor/cli.ts +32 -103
- package/ts/src/frontDoor/consultRoute.ts +4 -2
- package/ts/src/frontDoor/dbPath.ts +80 -7
- package/ts/src/frontDoor/initRoute.ts +190 -0
- package/ts/src/frontDoor/mirrorHomes.ts +95 -0
- package/ts/src/frontDoor/namedRefusal.ts +29 -1
- package/ts/src/frontDoor/runtimeRoute.ts +13 -5
- package/ts/src/frontDoor/searchRoute.ts +31 -3
- package/ts/src/frontDoor/usage.ts +17 -3
- package/ts/src/init/init.ts +2 -1
- package/ts/src/providers/config.ts +193 -2
- package/ts/src/providers/cost.ts +5 -1
- package/ts/src/providers/embedding.ts +26 -3
- package/ts/src/providers/familyProviders.ts +43 -0
- package/ts/src/providers/llm.ts +40 -0
- package/ts/src/runtime/diagnose.ts +59 -0
- package/ts/src/runtime/git.ts +153 -42
- package/ts/src/runtime/status.ts +47 -18
- package/ts/src/runtime/strategies/clone.ts +76 -55
- package/ts/src/runtime/update.ts +2 -1
- package/ts/src/search/memorySearch.ts +4 -2
package/ts/src/frontDoor/cli.ts
CHANGED
|
@@ -44,12 +44,11 @@ import { type Database, openDatabaseReadOnly, type WritableDatabase } from "#db/
|
|
|
44
44
|
import { ensureDatabaseReady } from "#db/readyOnOpen.ts";
|
|
45
45
|
import { assertSchemaState, SchemaStateError } from "#db/schemaState.ts";
|
|
46
46
|
import { allDescriptors, descriptorsByLayer } from "#descriptor/descriptorRead.ts";
|
|
47
|
-
import { newIdentityKeyProblem
|
|
47
|
+
import { newIdentityKeyProblem } from "#identity/identityKey.ts";
|
|
48
48
|
import { identityRowExists, listIdentityByLayer } from "#identity/identityRead.ts";
|
|
49
49
|
import { listJourneysForListCommand } from "#identity/journeyListing.ts";
|
|
50
50
|
import { listPersonas } from "#identity/personaListing.ts";
|
|
51
51
|
import { setIdentity } from "#identity/setIdentity.ts";
|
|
52
|
-
import { IdentityRootExistsError, initUserHome, TemplatesNotFoundError } from "#init/init.ts";
|
|
53
52
|
import { JOURNEY_PATH_LAYER } from "#journey/journeyStatus.ts";
|
|
54
53
|
import { JourneyNotFoundError } from "#journey/journeyWrite.ts";
|
|
55
54
|
import { environmentSessionId } from "#mirror/runtimeSession.ts";
|
|
@@ -61,9 +60,14 @@ import {
|
|
|
61
60
|
import { chatLedgerHook } from "#observability/ledgerHooks.ts";
|
|
62
61
|
import { runWeekSave } from "#planning/weekSave.ts";
|
|
63
62
|
import type { EmbeddingProvider } from "#providers/embedding.ts";
|
|
64
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
requireFamilyProviders,
|
|
65
|
+
requireKeyForFamily,
|
|
66
|
+
resolveFamilyProviders,
|
|
67
|
+
} from "#providers/familyProviders.ts";
|
|
65
68
|
import type { LlmProvider } from "#providers/llm.ts";
|
|
66
69
|
import {
|
|
70
|
+
CONSULT_ASK_TRANSPORT,
|
|
67
71
|
CULTIVATION_APPLY_TRANSPORT,
|
|
68
72
|
CULTIVATION_SCAN_TRANSPORT,
|
|
69
73
|
DESCRIPTOR_TRANSPORT,
|
|
@@ -72,18 +76,10 @@ import {
|
|
|
72
76
|
} from "#providers/transport.ts";
|
|
73
77
|
import { verifyBackupArchive } from "#runtime/backup.ts";
|
|
74
78
|
import { exitQuietlyOnBrokenPipe } from "#runtime/brokenPipe.ts";
|
|
75
|
-
import {
|
|
76
|
-
configFilePath,
|
|
77
|
-
loadConfiguration,
|
|
78
|
-
silenceExperimentalWarnings,
|
|
79
|
-
writeConfigValue,
|
|
80
|
-
} from "#runtime/config.ts";
|
|
81
|
-
import { runningTreeRoot } from "#runtime/treeRoot.ts";
|
|
82
|
-
import { detectRuntimes, runtimeWiringLines } from "#runtime/wiring.ts";
|
|
79
|
+
import { configFilePath, loadConfiguration, silenceExperimentalWarnings } from "#runtime/config.ts";
|
|
83
80
|
import { runSeed } from "#seed/seed.ts";
|
|
84
81
|
import { getTasksForWeek, listTasks } from "#tasks/taskStore.ts";
|
|
85
82
|
import { computeWeekRange } from "#tasks/weekView.ts";
|
|
86
|
-
import { isExecutable } from "#util/paths.ts";
|
|
87
83
|
import { PROGRAM } from "#util/program.ts";
|
|
88
84
|
import { newId, nowIso } from "#util/pyGenerators.ts";
|
|
89
85
|
import { hasOption, optionValue, stripOptionWithValue } from "./args.ts";
|
|
@@ -105,7 +101,7 @@ import {
|
|
|
105
101
|
runReject,
|
|
106
102
|
runShadowApply as runShadowApplyRoute,
|
|
107
103
|
} from "./cultivationRoute.ts";
|
|
108
|
-
import { MirrorHomeNotConfiguredError, resolveDbPath } from "./dbPath.ts";
|
|
104
|
+
import { initCommandFor, MirrorHomeNotConfiguredError, resolveDbPath } from "./dbPath.ts";
|
|
109
105
|
import { runBackupRoute, runRepairEncodingRoute } from "./dbSafetyToolsRoute.ts";
|
|
110
106
|
import { defaultExploreRouteDeps, runExploreRoute } from "./exploreRoute.ts";
|
|
111
107
|
import { frontDoorLogPath, logFrontDoor } from "./frontDoorLog.ts";
|
|
@@ -114,8 +110,10 @@ import {
|
|
|
114
110
|
isImportedConversationsCommand,
|
|
115
111
|
runImportedConversationsRoute,
|
|
116
112
|
} from "./importedConversationsRoute.ts";
|
|
113
|
+
import { runInit } from "./initRoute.ts";
|
|
117
114
|
import { applyJourneySetPath } from "./journeyWriteRoute.ts";
|
|
118
115
|
import { openLiveWriteDatabase } from "./liveBackup.ts";
|
|
116
|
+
import { findMirrorHomes, homesRoot } from "./mirrorHomes.ts";
|
|
119
117
|
import {
|
|
120
118
|
isMirrorDoorman,
|
|
121
119
|
isMirrorWrite,
|
|
@@ -454,7 +452,10 @@ function runWeekSaveWrite(argv: readonly string[]): number {
|
|
|
454
452
|
* in depth.
|
|
455
453
|
*/
|
|
456
454
|
async function runJournalContentRoute(argv: readonly string[]): Promise<number> {
|
|
457
|
-
|
|
455
|
+
// CR187: refused before the database opens, saying what was not kept.
|
|
456
|
+
const family = await requireFamilyProviders(process.env, JOURNAL_TRANSPORT, {
|
|
457
|
+
consequence: "the journal entry was not saved",
|
|
458
|
+
});
|
|
458
459
|
if (!family?.llm || !family.embedding) {
|
|
459
460
|
throw new Error("journal requires an LLM and an embedding provider");
|
|
460
461
|
}
|
|
@@ -467,14 +468,15 @@ async function runJournalContentRoute(argv: readonly string[]): Promise<number>
|
|
|
467
468
|
}
|
|
468
469
|
|
|
469
470
|
async function runWeekPlanContentRoute(argv: readonly string[]): Promise<number> {
|
|
470
|
-
const family = await
|
|
471
|
+
const family = await requireFamilyProviders(process.env, WEEK_PLAN_TRANSPORT);
|
|
471
472
|
if (!family?.llm) throw new Error("week plan requires an LLM provider");
|
|
472
473
|
const llm = family.llm;
|
|
473
474
|
return withLiveWriteDbAsync(argv, (db) => runWeekPlanRoute(db, argv.slice(2), llm));
|
|
474
475
|
}
|
|
475
476
|
|
|
476
477
|
async function runDescriptorContentRoute(argv: readonly string[]): Promise<number> {
|
|
477
|
-
|
|
478
|
+
// CR187: without the key every persona would read "skipped (empty response)".
|
|
479
|
+
const family = await requireFamilyProviders(process.env, DESCRIPTOR_TRANSPORT);
|
|
478
480
|
if (!family?.llm) throw new Error("descriptor generate requires an LLM provider");
|
|
479
481
|
const llm = family.llm;
|
|
480
482
|
const reporter = outcomeReporter(argv, "descriptor");
|
|
@@ -884,90 +886,6 @@ function isInit(argv: readonly string[]): boolean {
|
|
|
884
886
|
return argv[0] === "init";
|
|
885
887
|
}
|
|
886
888
|
|
|
887
|
-
/**
|
|
888
|
-
* Serve `init <user>` (DS7.US1 Slice B). Filesystem-only — no database is
|
|
889
|
-
* opened. Python's argparse defines only the `user` positional (no
|
|
890
|
-
* --mirror-home/--db-path), so none are stripped here.
|
|
891
|
-
*
|
|
892
|
-
* Deliberate divergence: an already-populated identity root or a missing
|
|
893
|
-
* templates root are uncaught exceptions in Python (a raw traceback, exit 1) --
|
|
894
|
-
* not a designed message. This preserves the contract (exit 1, a stderr
|
|
895
|
-
* message naming the failure) without fabricating a fake traceback.
|
|
896
|
-
*
|
|
897
|
-
* CV22.DS10.US3 (D3): when no source configured a user, `init` writes
|
|
898
|
-
* `MIRROR_USER=<user>` into `~/.config/mirror/env` (0600 in 0700) and says
|
|
899
|
-
* where to add `OPENROUTER_API_KEY` by hand. The key is never read from argv.
|
|
900
|
-
* When a user IS configured -- the checkout's `.env`, or a shell running a
|
|
901
|
-
* second mirror -- the file is left alone and the person is told how to
|
|
902
|
-
* select the new home.
|
|
903
|
-
*
|
|
904
|
-
* Plateau 3: it ends with the wiring step for each runtime found on the PATH
|
|
905
|
-
* (`#runtime/wiring.ts`), with this tree's path in it, so the person is handed
|
|
906
|
-
* the next line rather than a page. Printing is not automating.
|
|
907
|
-
*/
|
|
908
|
-
function runInit(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): number {
|
|
909
|
-
const user = argv[1];
|
|
910
|
-
// A word that starts with `-` is a flag this command does not have, not a
|
|
911
|
-
// user: `init --help` once created a home named `--help`.
|
|
912
|
-
if (!user || user.startsWith("-")) {
|
|
913
|
-
console.error("Usage: init <user>");
|
|
914
|
-
return 2;
|
|
915
|
-
}
|
|
916
|
-
// And any other word is a key: the home's directory name and the value of
|
|
917
|
-
// MIRROR_USER in the wiring commands printed below. CR104's rule for slugs
|
|
918
|
-
// and persona ids, asked before anything is created (CR141).
|
|
919
|
-
const problem = newUserNameProblem(user);
|
|
920
|
-
if (problem !== null) {
|
|
921
|
-
console.error(`Error: ${problem}`);
|
|
922
|
-
return 2;
|
|
923
|
-
}
|
|
924
|
-
try {
|
|
925
|
-
const identityRoot = initUserHome(user);
|
|
926
|
-
const mirrorHome = dirname(identityRoot);
|
|
927
|
-
const configured = Boolean(env.MIRROR_USER || env.MIRROR_HOME);
|
|
928
|
-
const configuration = configured
|
|
929
|
-
? [
|
|
930
|
-
` 1. This machine already selects a user; to use this one, run with MIRROR_USER=${user}`,
|
|
931
|
-
` or set it in ${configFilePath(env)}`,
|
|
932
|
-
]
|
|
933
|
-
: [
|
|
934
|
-
`Configuration: ${writeConfigValue("MIRROR_USER", user, { env })} (MIRROR_USER=${user})`,
|
|
935
|
-
"",
|
|
936
|
-
"Next steps:",
|
|
937
|
-
" 1. Add OPENROUTER_API_KEY=<your key> to that file (it is created readable by you only)",
|
|
938
|
-
];
|
|
939
|
-
const prints = [
|
|
940
|
-
`Created user home: ${mirrorHome}`,
|
|
941
|
-
`Identity ready at: ${identityRoot}`,
|
|
942
|
-
...(configured ? ["", "Next steps:"] : []),
|
|
943
|
-
...configuration,
|
|
944
|
-
` 2. Run: ${PROGRAM} seed`,
|
|
945
|
-
"\nYour identity is ready to use. Deepen it over time with:",
|
|
946
|
-
` ${PROGRAM} identity edit user identity`,
|
|
947
|
-
...runtimeWiringSection(env),
|
|
948
|
-
];
|
|
949
|
-
process.stdout.write(prints.map((line) => `${line}\n`).join(""));
|
|
950
|
-
return 0;
|
|
951
|
-
} catch (error) {
|
|
952
|
-
if (error instanceof IdentityRootExistsError || error instanceof TemplatesNotFoundError) {
|
|
953
|
-
console.error(error.message);
|
|
954
|
-
return 1;
|
|
955
|
-
}
|
|
956
|
-
throw error;
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
/** The "Wire your runtime" tail of `init`, empty when no runtime is on the PATH. */
|
|
961
|
-
function runtimeWiringSection(env: NodeJS.ProcessEnv): string[] {
|
|
962
|
-
const detected = detectRuntimes(env, isExecutable);
|
|
963
|
-
if (detected.length === 0) return [];
|
|
964
|
-
const root = runningTreeRoot();
|
|
965
|
-
return [
|
|
966
|
-
"\nWire your runtime to this install (each step is one you run and can read first):",
|
|
967
|
-
...detected.flatMap((runtime) => ["", ...runtimeWiringLines(runtime, root)]),
|
|
968
|
-
];
|
|
969
|
-
}
|
|
970
|
-
|
|
971
889
|
function isSeed(argv: readonly string[]): boolean {
|
|
972
890
|
return argv[0] === "seed";
|
|
973
891
|
}
|
|
@@ -1408,7 +1326,7 @@ async function runConsolidateScanWrite(argv: readonly string[]): Promise<number>
|
|
|
1408
1326
|
const limit = limitRaw !== null ? Number(limitRaw) : DEFAULT_CONSOLIDATE_SCAN_LIMIT;
|
|
1409
1327
|
const thresholdRaw = optionValue(args, "--threshold");
|
|
1410
1328
|
const threshold = thresholdRaw !== null ? Number(thresholdRaw) : DEFAULT_CLUSTER_THRESHOLD;
|
|
1411
|
-
const family = await
|
|
1329
|
+
const family = await requireFamilyProviders(process.env, CULTIVATION_SCAN_TRANSPORT);
|
|
1412
1330
|
if (!family?.llm) throw new Error("consolidate scan requires an LLM provider");
|
|
1413
1331
|
const provider = family.llm;
|
|
1414
1332
|
const reporter = outcomeReporter(argv, "consolidation");
|
|
@@ -1435,7 +1353,7 @@ async function runShadowScanWrite(argv: readonly string[]): Promise<number> {
|
|
|
1435
1353
|
const args = argv.slice(2);
|
|
1436
1354
|
const limitRaw = optionValue(args, "--limit");
|
|
1437
1355
|
const limit = limitRaw !== null ? Number(limitRaw) : DEFAULT_SHADOW_SCAN_LIMIT;
|
|
1438
|
-
const family = await
|
|
1356
|
+
const family = await requireFamilyProviders(process.env, CULTIVATION_SCAN_TRANSPORT);
|
|
1439
1357
|
if (!family?.llm) throw new Error("shadow scan requires an LLM provider");
|
|
1440
1358
|
const provider = family.llm;
|
|
1441
1359
|
const reporter = outcomeReporter(argv, "shadow_scan");
|
|
@@ -1663,6 +1581,9 @@ function resolveLogPath(argv: readonly string[]): string | null {
|
|
|
1663
1581
|
* itself, extended to db resolution.
|
|
1664
1582
|
*/
|
|
1665
1583
|
async function runConsultAskWithDb(argv: readonly string[]): Promise<number> {
|
|
1584
|
+
// CR187: before the ledger database opens, because that open takes the
|
|
1585
|
+
// pre-write snapshot; the route checks again, for `consult credits`.
|
|
1586
|
+
requireKeyForFamily(process.env, CONSULT_ASK_TRANSPORT);
|
|
1666
1587
|
const db = tryOpenDbForConsultLogging(argv);
|
|
1667
1588
|
try {
|
|
1668
1589
|
process.stdout.write(await runConsultRoute(db, argv.slice(1)));
|
|
@@ -1904,9 +1825,17 @@ export async function main(rawArgv = process.argv.slice(2)): Promise<number> {
|
|
|
1904
1825
|
// name is whatever the caller typed, and it stays out of the log.
|
|
1905
1826
|
if (decision.engine === "usage") {
|
|
1906
1827
|
const env = process.env;
|
|
1828
|
+
const userConfigured = Boolean(
|
|
1829
|
+
env.MIRROR_USER || env.MIRROR_HOME || env.MEMORY_DIR || env.DB_PATH,
|
|
1830
|
+
);
|
|
1907
1831
|
const answer = answerUsage(decision.request, {
|
|
1908
|
-
userConfigured
|
|
1832
|
+
userConfigured,
|
|
1909
1833
|
configFile: configFilePath(env),
|
|
1834
|
+
// CR185: a home on disk that nothing selects is named, read only when no
|
|
1835
|
+
// user is configured -- the one case the orientation is rendered.
|
|
1836
|
+
useHome: userConfigured
|
|
1837
|
+
? null
|
|
1838
|
+
: (initCommandFor(findMirrorHomes(), homesRoot())?.sentence ?? null),
|
|
1910
1839
|
});
|
|
1911
1840
|
(answer.stream === "stdout" ? process.stdout : process.stderr).write(answer.text);
|
|
1912
1841
|
logFrontDoor(logPath, {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseConsultArgs } from "#consult/args.ts";
|
|
2
2
|
import { runConsult } from "#consult/core.ts";
|
|
3
3
|
import type { WritableDatabase } from "#db/database.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { requireFamilyProviders } from "#providers/familyProviders.ts";
|
|
5
5
|
import { CONSULT_ASK_TRANSPORT, CONSULT_CREDITS_TRANSPORT } from "#providers/transport.ts";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -17,7 +17,9 @@ export async function runConsultRoute(
|
|
|
17
17
|
// Per leaf, from the same specs `routing.ts` decided with: `credits` needs
|
|
18
18
|
// only a credit provider, `ask` needs a chat provider too (CV22.DS8.US3).
|
|
19
19
|
const spec = command.command === "credits" ? CONSULT_CREDITS_TRANSPORT : CONSULT_ASK_TRANSPORT;
|
|
20
|
-
|
|
20
|
+
// CR187: no provider call without the key; `ask` checked before its ledger
|
|
21
|
+
// database opened, `credits` has none to open.
|
|
22
|
+
const family = await requireFamilyProviders(process.env, spec);
|
|
21
23
|
if (!family?.credits) {
|
|
22
24
|
// `routing.ts` sends a reverted or half-configured family to Python before
|
|
23
25
|
// the route is reached, so this is defense in depth.
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
// modern `~/.mirror-minds/<user>` does not exist (one-line stderr warning,
|
|
15
15
|
// permanent support — see docs/project/decisions.md).
|
|
16
16
|
// - Nothing configured fails loudly (CV9.E2.S6: unconfigured resolution must
|
|
17
|
-
// not write to the homes root), unlike the pre-CR024 silent fallback.
|
|
17
|
+
// not write to the homes root), unlike the pre-CR024 silent fallback. The
|
|
18
|
+
// sentence says what to run and which file that writes, naming the homes it
|
|
19
|
+
// finds (CR185): a move from a clone to the package leaves a home that no
|
|
20
|
+
// configuration points at, and the person needs the command, not the cause.
|
|
18
21
|
//
|
|
19
22
|
// Front-door-only extension: the CLI flags `--db-path` and `--mirror-home`
|
|
20
23
|
// are explicit per-invocation intent and take precedence over all env vars.
|
|
@@ -22,8 +25,13 @@
|
|
|
22
25
|
import { existsSync } from "node:fs";
|
|
23
26
|
import { homedir } from "node:os";
|
|
24
27
|
import { basename, join } from "node:path";
|
|
28
|
+
import { configFilePath } from "#runtime/config.ts";
|
|
25
29
|
import { expandHome } from "#util/paths.ts";
|
|
30
|
+
import { PROGRAM } from "#util/program.ts";
|
|
31
|
+
import { pyRepr } from "#util/pythonText.ts";
|
|
32
|
+
import { shellWord } from "#util/shellWord.ts";
|
|
26
33
|
import { optionValue } from "./args.ts";
|
|
34
|
+
import { type FoundHome, findMirrorHomes, homesRoot, userHomePath } from "./mirrorHomes.ts";
|
|
27
35
|
|
|
28
36
|
/** Raised when no database location can be resolved — maps to CLI exit 2. */
|
|
29
37
|
export class MirrorHomeNotConfiguredError extends Error {}
|
|
@@ -34,7 +42,6 @@ const DB_NAMES: Record<string, string> = {
|
|
|
34
42
|
test: "memory_test.db",
|
|
35
43
|
};
|
|
36
44
|
|
|
37
|
-
const DEFAULT_USER_HOMES_DIR = ".mirror-minds";
|
|
38
45
|
const LEGACY_USER_HOMES_DIR = ".mirror";
|
|
39
46
|
|
|
40
47
|
/** Port of Python `db_name_for_env`: one (environment) → one database name. */
|
|
@@ -50,6 +57,8 @@ export interface DbPathEnv {
|
|
|
50
57
|
MEMORY_DIR?: string;
|
|
51
58
|
MIRROR_HOME?: string;
|
|
52
59
|
MIRROR_USER?: string;
|
|
60
|
+
/** Read only to name the config file in the not-configured sentence. */
|
|
61
|
+
XDG_CONFIG_HOME?: string;
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
/** Injection points so tests can run without touching the real filesystem. */
|
|
@@ -57,6 +66,8 @@ export interface DbPathIo {
|
|
|
57
66
|
home?: string;
|
|
58
67
|
exists?: (path: string) => boolean;
|
|
59
68
|
warn?: (message: string) => void;
|
|
69
|
+
/** The homes under `<home>/.mirror-minds`, for the not-configured sentence. */
|
|
70
|
+
homes?: (home: string) => readonly FoundHome[];
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
/**
|
|
@@ -72,7 +83,7 @@ export function resolveMirrorHome(env: DbPathEnv, io: DbPathIo = {}): string {
|
|
|
72
83
|
const explicitHome = env.MIRROR_HOME ?? "";
|
|
73
84
|
const explicitUser = env.MIRROR_USER ?? "";
|
|
74
85
|
const resolvedHome = explicitHome ? expandHome(explicitHome) : null;
|
|
75
|
-
const derivedHome = explicitUser ?
|
|
86
|
+
const derivedHome = explicitUser ? userHomePath(explicitUser, home) : null;
|
|
76
87
|
|
|
77
88
|
if (resolvedHome && explicitUser && basename(resolvedHome) !== explicitUser) {
|
|
78
89
|
throw new MirrorHomeNotConfiguredError(
|
|
@@ -94,10 +105,72 @@ export function resolveMirrorHome(env: DbPathEnv, io: DbPathIo = {}): string {
|
|
|
94
105
|
}
|
|
95
106
|
return derivedHome;
|
|
96
107
|
}
|
|
97
|
-
throw new MirrorHomeNotConfiguredError(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
108
|
+
throw new MirrorHomeNotConfiguredError(homeNotConfiguredMessage(env, io));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The not-configured sentence (CR185), one line: what to run, and which file
|
|
113
|
+
* that command writes.
|
|
114
|
+
*
|
|
115
|
+
* Every surface prints it: `runtime update`, `migrate`, and `backup` bare, the
|
|
116
|
+
* other commands after `Mirror TS front door:`, and `runtime status` as its
|
|
117
|
+
* `Mirror home note:`. Built only when nothing resolves, so a hook on an
|
|
118
|
+
* unconfigured machine pays one directory read for a message it discards.
|
|
119
|
+
*
|
|
120
|
+
* A home name reaches the line bare only when `init` accepts it, so the line
|
|
121
|
+
* cannot be broken, or carry an escape sequence, by a directory name. Any other
|
|
122
|
+
* home is offered `MIRROR_HOME`, its path printed as a repr.
|
|
123
|
+
*/
|
|
124
|
+
export function homeNotConfiguredMessage(env: DbPathEnv, io: DbPathIo = {}): string {
|
|
125
|
+
const home = io.home ?? homedir();
|
|
126
|
+
const config = configFilePath({ XDG_CONFIG_HOME: env.XDG_CONFIG_HOME }, home);
|
|
127
|
+
const homes = (io.homes ?? findMirrorHomes)(home);
|
|
128
|
+
const sentences: string[] = [];
|
|
129
|
+
const command = initCommandFor(homes, homesRoot(home));
|
|
130
|
+
if (command !== null) {
|
|
131
|
+
sentences.push(`${command.sentence} (it writes MIRROR_USER=${command.name} to ${config}).`);
|
|
132
|
+
}
|
|
133
|
+
for (const other of homes.filter((found) => !found.nameable)) {
|
|
134
|
+
const path = pyRepr(other.path);
|
|
135
|
+
sentences.push(`To use the home at ${path}, add MIRROR_HOME=${path} to ${config}.`);
|
|
136
|
+
}
|
|
137
|
+
if (sentences.length === 0) {
|
|
138
|
+
const create = `${PROGRAM} init <your-name>`;
|
|
139
|
+
sentences.push(
|
|
140
|
+
`To create one, run: ${create} (it writes MIRROR_USER=<your-name> to ${config}).`,
|
|
141
|
+
`If yours lives elsewhere, add MIRROR_HOME=<path> to ${config}.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return ["Mirror home is not configured.", ...sentences].join(" ");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* "To use the home at <path>, run: mirror init <name>", or the form for
|
|
149
|
+
* several, for the homes `init` can name; null when there are none. `name` is
|
|
150
|
+
* what the command takes: the one home's own name, or `<name>`. Bare `mirror`
|
|
151
|
+
* prints the same sentence.
|
|
152
|
+
*
|
|
153
|
+
* The command is built on a line of its own, its value through `shellWord`
|
|
154
|
+
* (CR104): an agent runs it as printed. A name `init` accepts is a plain token,
|
|
155
|
+
* so it prints unchanged. The path and the names around the command are prose.
|
|
156
|
+
*/
|
|
157
|
+
export function initCommandFor(
|
|
158
|
+
homes: readonly FoundHome[],
|
|
159
|
+
root: string,
|
|
160
|
+
): { sentence: string; name: string } | null {
|
|
161
|
+
const nameable = homes.filter((found) => found.nameable);
|
|
162
|
+
const [only] = nameable;
|
|
163
|
+
if (only === undefined) return null;
|
|
164
|
+
if (nameable.length === 1) {
|
|
165
|
+
const command = `${PROGRAM} init ${shellWord(only.name)}`;
|
|
166
|
+
return { sentence: `To use the home at ${only.path}, run: ${command}`, name: only.name };
|
|
167
|
+
}
|
|
168
|
+
const names = nameable.map((found) => found.name).join(", ");
|
|
169
|
+
const command = `${PROGRAM} init <name>`;
|
|
170
|
+
return {
|
|
171
|
+
sentence: `To use one of the homes in ${root} (${names}), run: ${command}`,
|
|
172
|
+
name: "<name>",
|
|
173
|
+
};
|
|
101
174
|
}
|
|
102
175
|
|
|
103
176
|
/**
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// `init <user>`: the front door route that creates a user home (DS7.US1 Slice B).
|
|
2
|
+
// It moved out of cli.ts when it learned to adopt a home that already exists (CR185).
|
|
3
|
+
|
|
4
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { newUserNameProblem } from "#identity/identityKey.ts";
|
|
7
|
+
import {
|
|
8
|
+
defaultUserHome,
|
|
9
|
+
IdentityRootExistsError,
|
|
10
|
+
initUserHome,
|
|
11
|
+
TemplatesNotFoundError,
|
|
12
|
+
} from "#init/init.ts";
|
|
13
|
+
import { configFilePath, writeConfigValue } from "#runtime/config.ts";
|
|
14
|
+
import { runningTreeRoot } from "#runtime/treeRoot.ts";
|
|
15
|
+
import { detectRuntimes, runtimeWiringLines } from "#runtime/wiring.ts";
|
|
16
|
+
import { isExecutable } from "#util/paths.ts";
|
|
17
|
+
import { PROGRAM } from "#util/program.ts";
|
|
18
|
+
import { dbNameForEnv, resolveMirrorHome } from "./dbPath.ts";
|
|
19
|
+
import { isMirrorHome } from "./mirrorHomes.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Serve `init <user>` (DS7.US1 Slice B). Filesystem-only — no database is
|
|
23
|
+
* opened. Python's argparse defines only the `user` positional (no
|
|
24
|
+
* --mirror-home/--db-path), so none are stripped here.
|
|
25
|
+
*
|
|
26
|
+
* Deliberate divergence: a missing templates root is an uncaught exception in
|
|
27
|
+
* Python (a raw traceback, exit 1) -- not a designed message. This preserves
|
|
28
|
+
* the contract (exit 1, a stderr message naming the failure) without
|
|
29
|
+
* fabricating a fake traceback.
|
|
30
|
+
*
|
|
31
|
+
* CV22.DS10.US3 (D3): when no source configured a user, `init` writes
|
|
32
|
+
* `MIRROR_USER=<user>` into `~/.config/mirror/env` (0600 in 0700) and says
|
|
33
|
+
* where to add `OPENROUTER_API_KEY` by hand. The key is never read from argv.
|
|
34
|
+
* When a user IS configured -- the checkout's `.env`, or a shell running a
|
|
35
|
+
* second mirror -- the file is left alone and the person is told how to
|
|
36
|
+
* select the new home, or that it is already selected.
|
|
37
|
+
*
|
|
38
|
+
* CR185: a home that already exists is used as it is, never refused. It is
|
|
39
|
+
* the route the not-configured sentence names, and Getting Started's for
|
|
40
|
+
* moving from a clone to the package and for printing the wiring again. Nothing
|
|
41
|
+
* in the home is copied or changed; the configuration is written as for a new
|
|
42
|
+
* home, and only the steps still left are printed.
|
|
43
|
+
*
|
|
44
|
+
* Plateau 3: it ends with the wiring step for each runtime found on the PATH
|
|
45
|
+
* (`#runtime/wiring.ts`), with this tree's path in it, so the person is handed
|
|
46
|
+
* the next line rather than a page. Printing is not automating.
|
|
47
|
+
*/
|
|
48
|
+
export function runInit(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): number {
|
|
49
|
+
const user = argv[1];
|
|
50
|
+
// A word that starts with `-` is a flag this command does not have, not a
|
|
51
|
+
// user: `init --help` once created a home named `--help`.
|
|
52
|
+
if (!user || user.startsWith("-")) {
|
|
53
|
+
console.error("Usage: init <user>");
|
|
54
|
+
return 2;
|
|
55
|
+
}
|
|
56
|
+
// And any other word is a key: the home's directory name and the value of
|
|
57
|
+
// MIRROR_USER in the wiring commands printed below. CR104's rule for slugs
|
|
58
|
+
// and persona ids, asked before anything is created or looked at (CR141).
|
|
59
|
+
const problem = newUserNameProblem(user);
|
|
60
|
+
if (problem !== null) {
|
|
61
|
+
console.error(`Error: ${problem}`);
|
|
62
|
+
return 2;
|
|
63
|
+
}
|
|
64
|
+
// Decided before `initUserHome` runs: its refusal of a populated identity
|
|
65
|
+
// stays the guard under this branch, never the way into it.
|
|
66
|
+
const existing = defaultUserHome(user);
|
|
67
|
+
if (isMirrorHome(existing)) return adoptUserHome(user, existing, env);
|
|
68
|
+
try {
|
|
69
|
+
const identityRoot = initUserHome(user);
|
|
70
|
+
const mirrorHome = dirname(identityRoot);
|
|
71
|
+
const selection = selectionFor(mirrorHome, env);
|
|
72
|
+
const steps: Step[] =
|
|
73
|
+
selection === "none"
|
|
74
|
+
? [["Add OPENROUTER_API_KEY=<your key> to that file (it is created readable by you only)"]]
|
|
75
|
+
: selection === "other"
|
|
76
|
+
? [selectThisOne(user, env)]
|
|
77
|
+
: [];
|
|
78
|
+
const prints = [
|
|
79
|
+
`Created user home: ${mirrorHome}`,
|
|
80
|
+
`Identity ready at: ${identityRoot}`,
|
|
81
|
+
...configurationLine(user, selection, env),
|
|
82
|
+
"",
|
|
83
|
+
"Next steps:",
|
|
84
|
+
...numbered([...steps, [`Run: ${PROGRAM} seed`]]),
|
|
85
|
+
"\nYour identity is ready to use. Deepen it over time with:",
|
|
86
|
+
` ${PROGRAM} identity edit user identity`,
|
|
87
|
+
...runtimeWiringSection(env),
|
|
88
|
+
];
|
|
89
|
+
process.stdout.write(prints.map((line) => `${line}\n`).join(""));
|
|
90
|
+
return 0;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error instanceof IdentityRootExistsError || error instanceof TemplatesNotFoundError) {
|
|
93
|
+
console.error(error.message);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `init <user>` on a home that exists (CR185). The first line is a one-line
|
|
102
|
+
* summary, as for a new home: the Windows installer (PR #45) reads it. Then
|
|
103
|
+
* only what is left: the configuration, the key when none is set, `seed` when
|
|
104
|
+
* the home has no database, and the wiring.
|
|
105
|
+
*/
|
|
106
|
+
function adoptUserHome(user: string, home: string, env: NodeJS.ProcessEnv): number {
|
|
107
|
+
const selection = selectionFor(home, env);
|
|
108
|
+
const config = configFilePath(env);
|
|
109
|
+
const steps: Step[] = [];
|
|
110
|
+
if (selection === "other") steps.push(selectThisOne(user, env));
|
|
111
|
+
else if (!env.OPENROUTER_API_KEY) {
|
|
112
|
+
steps.push([
|
|
113
|
+
`Add OPENROUTER_API_KEY=<your key> to ${config} (moving from a clone? copy the line from its .env)`,
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
if (!existsSync(join(home, dbNameForEnv(env.MEMORY_ENV || "production")))) {
|
|
117
|
+
steps.push([`Run: ${PROGRAM} seed`]);
|
|
118
|
+
}
|
|
119
|
+
const prints = [
|
|
120
|
+
`Found user home: ${home} (nothing in it was changed)`,
|
|
121
|
+
...configurationLine(user, selection, env),
|
|
122
|
+
...(steps.length > 0 ? ["", "Next steps:", ...numbered(steps)] : []),
|
|
123
|
+
...runtimeWiringSection(env),
|
|
124
|
+
];
|
|
125
|
+
process.stdout.write(prints.map((line) => `${line}\n`).join(""));
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Which home this machine already selects: none, this one, or another. */
|
|
130
|
+
type Selection = "none" | "this" | "other";
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read once, for a new home and an existing one alike. Compared as real paths,
|
|
134
|
+
* so a home reached through a symlink, or named by a `MIRROR_HOME` spelled
|
|
135
|
+
* another way, is still this one. A selection that does not resolve (a
|
|
136
|
+
* `MIRROR_HOME` that conflicts with `MIRROR_USER`) is not this home, and not
|
|
137
|
+
* `init`'s to repair.
|
|
138
|
+
*/
|
|
139
|
+
function selectionFor(home: string, env: NodeJS.ProcessEnv): Selection {
|
|
140
|
+
if (!env.MIRROR_USER && !env.MIRROR_HOME) return "none";
|
|
141
|
+
try {
|
|
142
|
+
const selected = resolveMirrorHome(env, { warn: () => {} });
|
|
143
|
+
return realPath(selected) === realPath(home) ? "this" : "other";
|
|
144
|
+
} catch {
|
|
145
|
+
return "other";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function realPath(path: string): string {
|
|
150
|
+
try {
|
|
151
|
+
return realpathSync(path);
|
|
152
|
+
} catch {
|
|
153
|
+
return resolve(path);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The `Configuration:` line: written now, already this home, or nothing when another is selected. */
|
|
158
|
+
function configurationLine(user: string, selection: Selection, env: NodeJS.ProcessEnv): string[] {
|
|
159
|
+
if (selection === "none") {
|
|
160
|
+
return [
|
|
161
|
+
`Configuration: ${writeConfigValue("MIRROR_USER", user, { env })} (MIRROR_USER=${user})`,
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
return selection === "this" ? ["Configuration: already selects this home"] : [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** One numbered step: its first line takes the number, the rest are indented under it. */
|
|
168
|
+
type Step = readonly string[];
|
|
169
|
+
|
|
170
|
+
function selectThisOne(user: string, env: NodeJS.ProcessEnv): Step {
|
|
171
|
+
return [
|
|
172
|
+
`This machine already selects a user; to use this one, run with MIRROR_USER=${user}`,
|
|
173
|
+
` or set it in ${configFilePath(env)}`,
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function numbered(steps: readonly Step[]): string[] {
|
|
178
|
+
return steps.flatMap(([first, ...rest], index) => [` ${index + 1}. ${first}`, ...rest]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The "Wire your runtime" tail of `init`, empty when no runtime is on the PATH. */
|
|
182
|
+
function runtimeWiringSection(env: NodeJS.ProcessEnv): string[] {
|
|
183
|
+
const detected = detectRuntimes(env, isExecutable);
|
|
184
|
+
if (detected.length === 0) return [];
|
|
185
|
+
const root = runningTreeRoot();
|
|
186
|
+
return [
|
|
187
|
+
"\nWire your runtime to this install (each step is one you run and can read first):",
|
|
188
|
+
...detected.flatMap((runtime) => ["", ...runtimeWiringLines(runtime, root)]),
|
|
189
|
+
];
|
|
190
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The homes root, and what counts as a home in it (CR185).
|
|
2
|
+
//
|
|
3
|
+
// `<home>/.mirror-minds` holds one directory per user home: the place
|
|
4
|
+
// `MIRROR_USER=<user>` resolves to and `init <user>` creates. The root used to
|
|
5
|
+
// be spelled in three places (`dbPath.ts`, `init.ts`, `runtimeRoute.ts`); it is
|
|
6
|
+
// spelled here. This module also owns the rule for what a home is, because two
|
|
7
|
+
// readers have to agree on it: the not-configured error, which names the homes
|
|
8
|
+
// it finds, and `init <name>`, which adopts an existing home instead of
|
|
9
|
+
// refusing it. If they disagreed, the error could print a command that `init`
|
|
10
|
+
// then answers differently.
|
|
11
|
+
//
|
|
12
|
+
// Filesystem reads only, and every one of them is allowed to fail: a root that
|
|
13
|
+
// is missing, a file, or unreadable has no homes. The callers are error paths
|
|
14
|
+
// and a bootstrap command, and neither may fail for a second reason.
|
|
15
|
+
|
|
16
|
+
import { readdirSync, statSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { newUserNameProblem } from "#identity/identityKey.ts";
|
|
20
|
+
import { sortByCodePoint } from "#util/pythonText.ts";
|
|
21
|
+
|
|
22
|
+
/** The homes root's name under the OS user's home directory. */
|
|
23
|
+
export const HOMES_DIR = ".mirror-minds";
|
|
24
|
+
|
|
25
|
+
/** `<home>/.mirror-minds`. */
|
|
26
|
+
export function homesRoot(home: string = homedir()): string {
|
|
27
|
+
return join(home, HOMES_DIR);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `<home>/.mirror-minds/<user>`: where `MIRROR_USER=<user>` resolves, and where `init <user>` creates. */
|
|
31
|
+
export function userHomePath(user: string, home: string = homedir()): string {
|
|
32
|
+
return join(homesRoot(home), user);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every database name `dbNameForEnv` gives: `memory.db`, `memory_dev.db`, `memory_<env>.db`. */
|
|
36
|
+
const DATABASE_NAME = /^memory(?:_.+)?\.db$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether `path` is a Mirror home: a directory, through a symlink if need be,
|
|
40
|
+
* that holds a non-empty `identity/` or a database file.
|
|
41
|
+
*
|
|
42
|
+
* A directory holding only a log is not a home: an unresolved Pi session leaves
|
|
43
|
+
* one under the root. An empty `identity/` is not a home either, because `init`
|
|
44
|
+
* may still fill it. That is also the boundary `initUserHome` refuses at, so
|
|
45
|
+
* `init` adopts exactly what it would otherwise have to refuse, and also a home
|
|
46
|
+
* whose identity lives only in its database.
|
|
47
|
+
*/
|
|
48
|
+
export function isMirrorHome(path: string): boolean {
|
|
49
|
+
if (!isDirectory(path)) return false;
|
|
50
|
+
const identity = join(path, "identity");
|
|
51
|
+
if (isDirectory(identity) && entries(identity).length > 0) return true;
|
|
52
|
+
return entries(path).some((name) => DATABASE_NAME.test(name) && isFile(join(path, name)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A home found under the root. */
|
|
56
|
+
export interface FoundHome {
|
|
57
|
+
/** The directory's name. */
|
|
58
|
+
readonly name: string;
|
|
59
|
+
/** `<root>/<name>`: the entry itself, never a symlink's target. */
|
|
60
|
+
readonly path: string;
|
|
61
|
+
/** Whether `init <name>` accepts the name (CR141): only then can a message print it bare. */
|
|
62
|
+
readonly nameable: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The homes in `<home>/.mirror-minds`, in code-point order. Hidden entries are skipped. */
|
|
66
|
+
export function findMirrorHomes(home: string = homedir()): FoundHome[] {
|
|
67
|
+
const root = homesRoot(home);
|
|
68
|
+
return sortByCodePoint(entries(root).filter((name) => !name.startsWith(".")))
|
|
69
|
+
.map((name) => ({ name, path: join(root, name), nameable: newUserNameProblem(name) === null }))
|
|
70
|
+
.filter((found) => isMirrorHome(found.path));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function entries(path: string): string[] {
|
|
74
|
+
try {
|
|
75
|
+
return readdirSync(path);
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isDirectory(path: string): boolean {
|
|
82
|
+
try {
|
|
83
|
+
return statSync(path).isDirectory();
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isFile(path: string): boolean {
|
|
90
|
+
try {
|
|
91
|
+
return statSync(path).isFile();
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|