Files
Hy-Memory/memhub/apps/cli/src/cli.ts
T
alvinfeiandClaude Opus 5.5 a218a92d4e fix(memhub): memory writes retry themselves; a broken model provider pauses the queue instead of losing jobs
The SDK keeps one outcome per request id and replays it: a write that failed
under an id fails under it forever, without calling the model. Re-running a
job under its idempotency key (including the old manual retry) therefore never
reached the model again. Every re-run now gets an SDK id of its own:
`idempotency_key` on attempt 0 (unchanged for existing jobs), then
`idempotency_key#rN`, N stored on the job (`sdk_attempt`, migration 61). Before
a re-run the outcome of the previous id is checked; a write that did succeed is
completed from its receipt and not made twice.

- Provider failure (401/403, 404, 408, 429, 5xx, timeout, connection error,
  breaker refusal) during the attempt: the job goes back to `pending` as it
  was: same place in the queue, no attempt or retry spent, no cap. The breaker
  paces it. Attribution: `ModelBackoff.attempt()` charges every provider
  failure to every write in flight, because a request cannot be traced to its
  job (the SDK batches embeddings on one shared worker).
- Any other failure: retried after 30 s, 60 s, 120 s, then `failed` with its
  code; the queue moves on.
- Crash or interruption mid-write (`unknown`): re-run automatically under a
  new id after the outcome check, counted as a retry. Existing `unknown` jobs
  are handled the same way after upgrade, once. A write still running on its
  thread in this process is left until it ends.
- The manual retry (`POST /api/v1/memory-processing/retry-unknown`, the App's
  重新整理 button and its strings) is removed.
- App banner while the provider is paused, in 11 locales; `error_code` and
  `next_retry_at` on `GET /api/v2/memory-writes/{id}` and
  `GET /api/v2/memories/additions/{id}`; `memhub status` shows the pause.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 16:58:23 +08:00

7263 lines
243 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { access } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
type HealthResponse,
type ImportJob,
isHostId,
} from "@one-memory/contracts";
import {
type AcquiredRuntimeDistribution,
acquireRuntimeDistribution,
applyLocalInstall,
applyLocalUninstall,
type BackgroundHealth,
canonicalManagedServiceRoot,
defaultManagedInstallRoot,
diagnosticZipFilename,
discoverRuntimeAttachment,
ensureRuntimeProcess,
exportDiagnosticZip,
hourlyIsolationFromEnvironment,
inspectBackgroundHealth,
inspectRuntimeStatus,
installCliLink,
planCliLink,
planLocalInstall,
planLocalUninstall,
planUserService,
processIsAlive,
type ResolvedRuntimeRelease,
RuntimeDiscoveryError,
readInstalledRuntimeSource,
readSdkMigrationBlock,
removeRuntimeServiceOwner,
resolveRuntimeCliConfig,
resolveRuntimeRelease,
restartUserService,
rollbackCliLink,
rollbackLocalInstall,
runRuntimeCli,
runSdkMigration,
runtimeInstallLayout,
type SdkMigrationResult,
type SdkMigrationSource,
sdkMigrationLogPath,
stopRuntimeProcess,
} from "@one-memory/runtime-control";
import axios from "axios";
declare const __ONE_MEMORY_BUILD_NUMBER__: string;
declare const __ONE_MEMORY_COMMIT__: string;
declare const __MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: string;
import {
callRestore,
type RestoreResult,
verifyMaintenanceCoreIdentity,
} from "./backup-restore.js";
import {
type BatchScanDocument,
batchScanExitCode,
detectedSourceIds,
executeBatchImport,
planBatchImport,
renderBatchScanText,
} from "./batch-import-flow.js";
import { readBoundedFile, readBoundedJson } from "./bounded-file.js";
import {
type CommandDescriptor,
findCommand,
findCommandOrAlias,
} from "./command-catalog.js";
import {
CONTEXT_QUERY_MAX_CHARACTERS,
type ContextSearchDocument,
contextExitCode,
parseContextText,
readBoundedStdin,
renderContextText,
searchContext,
} from "./context-search.js";
import {
addScanRoot,
type CoreContextQuery,
type CoreReader,
createCoreContextQuery,
createCoreReader,
createUiSessionRequest,
deleteModelProfile,
type MemoryWriteReceipt,
parseOpenAIMessages,
probeAdapter,
readAdapters,
readMemoryWrite,
readScanRoots,
removeScanRoot,
resetOnboarding,
submitBackup,
submitMemoryWrite,
submitScan,
} from "./core-client.js";
import {
type CoreDeleteOperations,
type CoreOperations,
type CorePatchOperations,
type CorePutOperations,
createCoreOperations,
} from "./core-operations.js";
import {
MEMORY_STATUSES,
type SourceConnectionCandidate,
validateImportJob,
validateSourceConnectionCandidates,
} from "./core-responses.js";
import { applyDataDeletion, planDataDeletion } from "./data-deletion.js";
import {
composeDoctorReport,
type DoctorDocument,
doctorExitCode,
renderDoctorText,
} from "./doctor-report.js";
import {
activateWithEmbeddingSwitch,
cleanupSpace,
listSpaces,
type SwitchIo,
switchCancel,
switchStatus,
} from "./embedding-switch-flow.js";
import {
EXIT_FAILED,
EXIT_OK,
EXIT_PLAN_BLOCKED,
EXIT_RUNTIME_STOPPED,
EXIT_USAGE,
} from "./exit-codes.js";
import {
decideGraphWidth,
GRAPH_WIDTH_DECISIONS,
type GraphWidthDecision,
graphWidthDecisionIsDestructive,
graphWidthExitCode,
renderGraphWidthText,
} from "./graph-width-flow.js";
import {
connectSourcesWithImport,
createModelProfile,
type FlowDocument,
flowExitCode,
parseApiKey,
readModelReceipt,
readSourceReceipt,
renderFlowText,
} from "./headless-flows.js";
import {
type HostFlowDocument,
type HostOperation,
hostExitCode,
listHosts,
readHost,
renderHostText,
} from "./host-flow.js";
import {
createTerminalPrompter,
runInitWizard,
type WizardPrompter,
} from "./init-wizard.js";
import {
defaultInstallReceiptPath,
removeInstallReceipt,
resolveInstalledDataDir,
writeInstallReceipt,
} from "./install-receipt.js";
import { runInteractiveModelSetup } from "./interactive-model-setup.js";
import {
executeJobAction,
executeJobStart,
type JobFlowDocument,
jobExitCode,
planJobAction,
planJobStart,
} from "./job-flow.js";
import {
followLogs,
parseLogQuery,
readLogReport,
redactDiagnosticEvent,
renderLogText,
} from "./log-report.js";
import {
installManagedCliLink,
planManagedCliLink,
removeManagedCliLink,
rollbackManagedCliLink,
} from "./managed-cli-link.js";
import {
type FeaturesDocument,
featuresExitCode,
parseFeaturesCommand,
renderFeaturesText,
runFeaturesCommand,
} from "./memory-features-flow.js";
import {
type LanguageDocument,
languageExitCode,
parseLanguageCommand,
renderLanguageText,
runLanguageCommand,
} from "./memory-language-flow.js";
import {
type EvolveDocument,
evolveExitCode,
type ModeDocument,
modeExitCode,
parseEvolveCommand,
parseSettingsCommand,
renderEvolveText,
renderModeText,
runEvolveCommand,
runModeCommand,
} from "./memory-mode-flow.js";
import {
addMemory,
DEFAULT_USER_ID,
isMemoryId,
isRequestId,
isUserId,
MEMORY_KINDS,
MEMORY_SCENES,
MEMORY_VARIANTS,
type MemoryKind,
type MemoryNamespace,
type MemoryQuery,
type MemoryScene,
type MemoryVariant,
memoryAddExitCode,
namespaceParameters,
parseMemoryContent,
readMemoryAddStatus,
readMemoryDetail,
readMemoryPage,
renderMemoryAddText,
renderMemoryDetailText,
renderMemoryPageText,
userIdParameters,
validMemoryText,
} from "./memory-report.js";
import {
type ModelProbeFlowDocument,
modelProbeExitCode,
readModelProbeReceipt,
renderModelProbeText,
startModelProbe,
} from "./model-probe-flow.js";
import {
type ModelReportDocument,
modelReportExitCode,
readModelProfile,
readModelProfiles,
renderModelReportText,
} from "./model-report.js";
import {
editModelProfile,
type ModelWriteDocument,
modelWriteExitCode,
renderModelWriteText,
} from "./model-write-flows.js";
import {
parseBackupCommand,
parseScanCommand,
parseScanRootsCommand,
parseWriteCommand,
restoreConfirmationMatches,
} from "./product-flows.js";
import { QUERY_MAX_CHARACTERS, readQueryFile } from "./query-file.js";
import { inspectRuntimeServiceLock } from "./runtime-lock-guard.js";
import {
restoreManagedServiceEnabled,
setManagedServiceEnabled,
} from "./service-enable-state.js";
import {
applyPreparedServiceInstall,
prepareServiceInstall,
rollbackPreparedServiceInstall,
uninstallServicePreservingData,
} from "./service-lifecycle.js";
import {
claimCliServiceOwnership,
restoreServiceOwnership,
} from "./service-owner-state.js";
import {
createTolerantLaunchctlRunner,
inspectLaunchdService,
restartManagedService,
runLaunchctlCaptured,
startManagedService,
stopManagedService,
} from "./service-session.js";
import {
applyShellPath,
planShellPath,
removeShellPath,
} from "./shell-path.js";
import {
type ProgressDocument,
readProgress,
readSources,
readSync,
renderProgressText,
renderSourcesText,
renderSyncText,
type SourcesDocument,
type SyncDocument,
sourceProgressExitCode,
unreadProgress,
unreadSources,
unreadSync,
} from "./source-progress-report.js";
import {
renderSourceSyncText,
type SourceSyncFlowDocument,
setSourceSync,
sourceSyncExitCode,
} from "./source-sync-flow.js";
import {
composeStatusReport,
renderStatusText,
type StatusDocument,
statusExitCode,
} from "./status-report.js";
import {
openBrowser,
parseUiSessionExchange,
type UiResult,
uiExitCode,
uiSessionExchangePath,
} from "./ui-session.js";
import {
renderCatalogDocument,
renderCommandHelp,
renderGeneralHelp,
renderUsageError,
renderUsageErrorDocument,
renderVersion,
renderVersionDocument,
} from "./usage.js";
import { CLI_VERSION, PRODUCT, RUNTIME_RELEASE_VERSION } from "./version.js";
import { runWatchLoop, type WatchEvent, type WatchRead } from "./watch-loop.js";
export interface RunControls {
/** Platform used by read-only platform probes. Production uses
* `process.platform`; tests inject it instead of mutating the global. */
platform?: NodeJS.Platform;
watchMaxEvents?: number;
watchSignal?: AbortSignal;
watchWait?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
watchNow?: () => string;
/** `memhub evolve --wait` polls on this; tests inject an instant one. */
sleep?: (milliseconds: number) => Promise<void>;
readStdin?: () => Promise<Buffer | null>;
stdinIsTTY?: boolean;
/** Injectable onboarding prompter so the wizard can be driven in tests
* without a real terminal. Production uses `createTerminalPrompter`. */
wizardPrompter?: WizardPrompter;
/** Wait for a restarted Runtime to become healthy after `upgrade`.
* Injectable so the upgrade tests do not spend real seconds polling. */
waitForRuntimeReady?: (dataDir: string) => Promise<boolean>;
/** Run the SDK data migration before install/upgrade starts the new
* Runtime. Injectable so the tests do not spawn a real Core. */
migrateSdkData?: typeof migrateSdkDataBeforeStart;
restartManagedRuntime?: typeof restartManagedService;
stopRuntime?: typeof stopRuntimeProcess;
ensureRuntime?: typeof ensureRuntimeProcess;
/** The launchd probe `status` uses to catch a service-managed Runtime whose
* receipts vanished. Defaults to the real `launchctl` inspector; tests that
* inject a stopped runtime replace it so they never read the host's global
* LaunchAgent label. */
inspectLaunchd?: (dataDir: string) => Promise<{
state: "running" | "loaded" | "foreign" | "absent" | "unknown";
pid?: number;
}>;
/** Inject the browser opener so `ui` can be tested without launching one. */
openBrowser?: (url: string) => Promise<boolean>;
resolveRuntimeRelease?: typeof resolveRuntimeRelease;
acquireRuntimeDistribution?: typeof acquireRuntimeDistribution;
/** Drive `backup restore`: start a non-owning maintenance Core, call the
* restore endpoint, stop it. Injectable so tests never spawn a process. */
runMaintenanceRestore?: (options: {
coreCommand: string;
dataDir: string;
source: string;
}) => Promise<
| { state: "restored"; result: RestoreResult }
| { state: "rejected"; code: string }
>;
}
type InstallSourceSelection =
| { kind: "local"; source: string }
| {
kind: "remote";
manifestUrl: string;
channel: "stable" | "prerelease";
allowedHosts?: readonly string[];
};
const embeddedReleaseManifestTemplate =
typeof __MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__ === "string"
? __MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__.trim()
: "";
async function selectInstallSource(
parsed: ReturnType<typeof parseInstallOptions> & object,
environment: NodeJS.ProcessEnv,
): Promise<InstallSourceSelection> {
if (
parsed.sourceExplicit ||
environment.MEMHUB_TEST_FORCE_LOCAL_INSTALL_SOURCE === "1"
) {
return { kind: "local", source: parsed.source };
}
if (await regularFile(path.join(parsed.source, "manifest.json"))) {
return { kind: "local", source: parsed.source };
}
if (CLI_VERSION === "development") {
throw new Error("release_version_unavailable");
}
const explicitManifestUrl =
parsed.releaseManifestUrl ??
environment.MEMHUB_RELEASE_MANIFEST_URL?.trim();
const template =
environment.MEMHUB_RELEASE_MANIFEST_URL_TEMPLATE?.trim() ||
embeddedReleaseManifestTemplate;
if (!explicitManifestUrl && !template) {
throw new Error("release_manifest_url_unavailable");
}
const manifestUrl =
explicitManifestUrl ??
template.replaceAll("{version}", RUNTIME_RELEASE_VERSION);
const allowedHosts = parseAllowedReleaseHosts(
environment.MEMHUB_RELEASE_ALLOWED_HOSTS,
);
return {
kind: "remote",
manifestUrl,
channel: parsed.releaseChannel,
...(allowedHosts ? { allowedHosts } : {}),
};
}
async function regularFile(file: string): Promise<boolean> {
try {
await access(file);
return true;
} catch {
return false;
}
}
function parseAllowedReleaseHosts(
value: string | undefined,
): string[] | undefined {
if (value === undefined || value.trim() === "") return undefined;
const hosts = value
.split(",")
.map((host) => host.trim().toLowerCase())
.filter(Boolean);
if (
hosts.length === 0 ||
hosts.length > 16 ||
hosts.some((host) => !/^[a-z0-9.-]+$/.test(host))
) {
throw new Error("release_allowed_hosts_invalid");
}
return [...new Set(hosts)];
}
async function resolveRemoteInstallPreview(
selection: Extract<InstallSourceSelection, { kind: "remote" }>,
controls: RunControls,
): Promise<ResolvedRuntimeRelease> {
return (controls.resolveRuntimeRelease ?? resolveRuntimeRelease)({
manifestUrl: selection.manifestUrl,
expectedVersion: RUNTIME_RELEASE_VERSION,
channel: selection.channel,
platform: process.platform,
arch: process.arch,
allowedHosts: selection.allowedHosts,
});
}
async function acquireRemoteInstallSource(
selection: Extract<InstallSourceSelection, { kind: "remote" }>,
controls: RunControls,
): Promise<AcquiredRuntimeDistribution> {
return (controls.acquireRuntimeDistribution ?? acquireRuntimeDistribution)({
manifestUrl: selection.manifestUrl,
expectedVersion: RUNTIME_RELEASE_VERSION,
channel: selection.channel,
platform: process.platform,
arch: process.arch,
allowedHosts: selection.allowedHosts,
});
}
function remoteReleasePlan(resolved: ResolvedRuntimeRelease) {
return {
schema_version: 1,
source: "release",
manifest_url: resolved.manifest_url,
channel: resolved.release.channel,
version: resolved.release.version,
build_number: resolved.release.build_number,
git_commit: resolved.release.git_commit,
cli_protocol: resolved.release.cli_protocol,
runtime_protocol: resolved.release.runtime_protocol,
platform: resolved.artifact.platform,
arch: resolved.artifact.arch,
artifact: resolved.artifact.filename,
download_bytes: resolved.artifact.bytes,
install_bytes: resolved.artifact.unpacked_bytes,
sha256: resolved.artifact.sha256,
} as const;
}
export async function run(
arguments_: readonly string[],
environment: NodeJS.ProcessEnv = process.env,
controls: RunControls = {},
): Promise<number> {
return runCommand(arguments_, environment, controls, false);
}
/** @internal Retains legacy install paths for focused safety tests only. */
export async function runWithManagedInstallForTest(
arguments_: readonly string[],
environment: NodeJS.ProcessEnv = process.env,
controls: RunControls = {},
): Promise<number> {
return runCommand(
arguments_,
{ ...environment, MEMHUB_TEST_FORCE_LOCAL_INSTALL_SOURCE: "1" },
controls,
false,
);
}
async function runCommand(
arguments_: readonly string[],
environment: NodeJS.ProcessEnv,
controls: RunControls,
localExperienceMode: boolean,
): Promise<number> {
// `--json` is documented as a global option, so a caller may put it before
// the command (`memhub --json status`). Normalise it to the end so every
// existing `arguments_.slice(1)` parser keeps seeing the command first.
if (arguments_[0] === "--json") {
arguments_ = [...arguments_.slice(1), "--json"];
}
arguments_ = rewriteLegacyCommand(arguments_);
const asJson = arguments_.includes("--json");
// `--data-dir <path>` is a global option: every Runtime-attached command
// reads its data directory from ONE_MEMORY_DATA_DIR, so the flag is lifted
// into the environment before dispatch. The install family, service-* and
// delete-data parse it themselves (it has command-specific meaning there).
if (!OWN_DATA_DIR_COMMANDS.has(arguments_[0] ?? "")) {
const lifted = liftDataDirOption(arguments_);
if (lifted === null) {
return reject(findCommand(arguments_[0] ?? ""), asJson, "参数无效。");
}
arguments_ = lifted.arguments_;
if (lifted.dataDir !== undefined) {
environment = { ...environment, ONE_MEMORY_DATA_DIR: lifted.dataDir };
}
}
// `--user-id <id>` is global to the memory commands: it names whose
// memories are read or written. It is lifted before dispatch so each
// command's own parser keeps seeing exactly the arguments it always did.
// `sources connect` gives the flag its own meaning and parses it itself.
let userId: string | undefined;
if (USER_ID_COMMANDS.has(arguments_[0] ?? "")) {
const lifted = liftUserIdOption(arguments_);
if (lifted === null) {
return reject(
findCommand(arguments_[0] ?? ""),
asJson,
"--user-id 无效:只能是 1–64 位字母、数字、点、下划线或连字符。",
);
}
arguments_ = lifted.arguments_;
userId = lifted.userId;
}
environment = await environmentWithManagedDataDir(environment);
const command = arguments_[0];
if (command === undefined) return reject(undefined, asJson, "缺少命令。");
if (command === "--help" || command === "-h" || command === "help") {
return explain(arguments_.slice(1), asJson);
}
const named =
command === "--version" || command === "-V" ? "version" : command;
const descriptor = findCommand(named);
if (!descriptor) return reject(undefined, asJson, "未知命令。");
const usage = () => reject(descriptor, asJson, "参数无效。");
const help = helpRequest(arguments_.slice(1));
if (help === "invalid") return reject(descriptor, asJson, "参数无效。");
if (help === "help") {
return emit(
asJson
? renderCatalogDocument(descriptor)
: renderCommandHelp(descriptor),
);
}
if (named === "version") {
return arguments_.length === (asJson ? 2 : 1)
? emit(asJson ? renderVersionDocument() : renderVersion())
: reject(descriptor, asJson, "参数无效。");
}
if (named === "models" && arguments_[1] === "setup") {
if (
arguments_.length !== 2 ||
asJson ||
!(controls.stdinIsTTY ?? process.stdin.isTTY)
) {
return usage();
}
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
const runtime = await inspectRuntimeStatus({
dataDir: config.dataDir,
probeIdentity,
requireServiceReceipt: true,
});
if (runtime.state !== "running") {
emit(
`无法配置模型:${runtime.code}\n下一步:先运行 ${PRODUCT} start 启动 Runtime。\n`,
);
return runtime.state === "stopped" ? EXIT_RUNTIME_STOPPED : EXIT_FAILED;
}
const clients = await attachClients(config.dataDir);
if (!clients.reader || !clients.operations) return EXIT_FAILED;
const prompter =
controls.wizardPrompter ?? (await createTerminalPrompter());
try {
const result = await runInteractiveModelSetup(
prompter,
{ reader: clients.reader, operations: clients.operations },
{
dataDir: config.dataDir,
hostHome: config.hostHome ?? os.homedir(),
installHosts: false,
},
);
return result.state === "failed" ? EXIT_FAILED : EXIT_OK;
} finally {
(prompter as { close?: () => void }).close?.();
}
}
// Commands that attach to the Runtime directly. They answer a stopped
// Runtime with exit 3, a Core refusal with exit 1 and a JSON document on
// stdout (never a usage error: the arguments were fine).
if (named === "adapter") {
const tokens = arguments_.slice(1).filter((item) => item !== "--json");
if (arguments_.filter((item) => item === "--json").length > 1) {
return usage();
}
const probe =
tokens.length === 3 &&
tokens[0] === "probe" &&
tokens[1] === "--source" &&
CONTEXT_SOURCE_ID.test(tokens[2]);
if (!probe && !(tokens.length === 1 && tokens[0] === "list")) {
return usage();
}
const attachment = await attachToRuntimeOrNull(environment);
if (attachment === null) return runtimeUnavailable(descriptor, asJson);
if (probe) {
const result = await probeAdapter(attachment, tokens[2]);
if (!result.ok) {
return emitFailure(asJson, "adapter probe", result.code);
}
emit(
asJson
? `${JSON.stringify({ schema_version: 1, product: PRODUCT, command: "adapter probe", state: "ok", probe: result.data })}\n`
: renderAdapterProbeText(tokens[2], result.data),
);
return EXIT_OK;
}
const document = await readAdapters(attachment);
if (document === null) {
return emitFailure(asJson, "adapter list", "adapters_unavailable");
}
if (asJson) {
process.stdout.write(`${JSON.stringify(document)}\n`);
return EXIT_OK;
}
const adapters = document.adapters as Array<{
source_id: string;
available: boolean;
authorized: boolean;
capabilities: Record<string, string>;
}>;
const lines = adapters.map(
(adapter) =>
` ${adapter.source_id} available=${adapter.available} authorized=${adapter.authorized} ` +
`live_sync=${adapter.capabilities.live_sync ?? "?"} recall=${adapter.capabilities.recall ?? "?"}`,
);
process.stdout.write(`Harness 适配器:\n${lines.join("\n")}\n`);
return EXIT_OK;
}
// Back to the cold-start screens on purpose. Deleting the app cannot do this
// (the completion flag lives with the memories, by design), and deleting the
// data to get there would cost the user everything they have stored.
if (named === "onboarding") {
if (
arguments_.filter((item) => item === "--json").length > 1 ||
arguments_.filter((item) => item !== "--json").join(" ") !==
"onboarding reset"
) {
return usage();
}
const attachment = await attachToRuntimeOrNull(environment);
if (attachment === null) return runtimeUnavailable(descriptor, asJson);
const state = await resetOnboarding(attachment);
if (state === null) {
return emitFailure(asJson, "onboarding reset", "onboarding_unavailable");
}
if (asJson) {
process.stdout.write(`${JSON.stringify(state)}\n`);
return EXIT_OK;
}
process.stdout.write(
`引导流程已回到首次运行(status=${state.status}),记忆数据未改动。\n`,
);
return EXIT_OK;
}
if (named === "scan-roots") {
const isList =
arguments_.filter((item) => item !== "--json").join(" ") ===
"scan-roots list" &&
arguments_.filter((item) => item === "--json").length <= 1;
const parsed = isList ? null : parseScanRootsCommand(arguments_.slice(1));
if (!isList && parsed === null) return usage();
const attachment = await attachToRuntimeOrNull(environment);
if (attachment === null) return runtimeUnavailable(descriptor, asJson);
if (parsed === null) {
const document = await readScanRoots(attachment);
if (document === null) {
return emitFailure(asJson, "scan-roots list", "scan_roots_unavailable");
}
if (asJson) {
process.stdout.write(`${JSON.stringify(document)}\n`);
return EXIT_OK;
}
const roots = document.roots as Array<{
path: string;
source_id: string | null;
recursive: boolean;
enabled: boolean;
}>;
if (roots.length === 0) {
process.stdout.write("尚未登记任何扫描目录。\n");
return EXIT_OK;
}
const lines = roots.map(
(root) =>
` ${root.path} source=${root.source_id ?? "auto"} recursive=${root.recursive} enabled=${root.enabled}`,
);
process.stdout.write(
`已登记的扫描目录(revision ${document.revision}):\n${lines.join("\n")}\n`,
);
return EXIT_OK;
}
const result =
parsed.kind === "add"
? await addScanRoot(attachment, {
root: parsed.root,
...(parsed.source === undefined ? {} : { source: parsed.source }),
recursive: parsed.recursive,
})
: await removeScanRoot(attachment, { root: parsed.root });
const verb = `scan-roots ${parsed.kind}`;
if (!result.ok) return emitFailure(asJson, verb, result.code);
emit(
asJson
? `${JSON.stringify({ schema_version: 1, product: PRODUCT, command: verb, state: "committed", root: parsed.root, result: result.data })}\n`
: parsed.kind === "add"
? `已登记扫描目录:${parsed.root}\n`
: `已移除扫描目录:${parsed.root}\n`,
);
return EXIT_OK;
}
if (named === "scan") {
const parsed = parseScanCommand(arguments_.slice(1));
if (!parsed) return usage();
if (parsed.kind === "all") {
return await connectSources(
{ kind: "all", importHistory: true, confirmOverrides: false },
asJson,
environment,
);
}
return await applyScan(asJson, descriptor, environment, parsed.sourceId);
}
if (named === "backup") {
const parsed = parseBackupCommand(arguments_.slice(1));
if (!parsed) return usage();
if (parsed.verb === "create") {
return await applyBackupCreate(asJson, descriptor, environment, parsed);
}
return await applyBackupRestore(asJson, environment, controls, parsed);
}
if (named === "write") {
const parsed = parseWriteCommand(arguments_.slice(1));
if (!parsed) return usage();
if (parsed.kind === "submit" && parsed.request_id !== undefined) {
if (!isWriteRequestId(parsed.request_id)) return usage();
}
const messages =
parsed.kind === "submit"
? parseOpenAIMessages(await readBoundedJson(parsed.messages_file))
: null;
if (parsed.kind === "submit" && messages === null) {
return reject(
descriptor,
asJson,
"messages 文件必须是含 user/assistant/tool 文本消息的 OpenAI messages 数组。",
);
}
const attachment = await attachToRuntimeOrNull(environment);
if (attachment === null) return runtimeUnavailable(descriptor, asJson);
if (parsed.kind === "status") {
const receipt = await readMemoryWrite(
attachment,
parsed.id,
userId,
).catch(() => undefined);
if (receipt === undefined) {
return emitFailure(asJson, "write status", "memory_write_read_failed");
}
if (receipt === null) {
// Unknown id: "never accepted", not "accepted and failed". It is still
// not an answer the caller asked for, so it exits 1 with a state.
emit(
asJson
? `${JSON.stringify({ schema_version: 1, product: PRODUCT, command: "write status", state: "not_found", request_id: parsed.id, write: null })}\n`
: `未找到该 request id:${parsed.id}\n`,
);
return EXIT_FAILED;
}
emit(
asJson
? `${JSON.stringify({ schema_version: 1, product: PRODUCT, command: "write status", state: "ok", write: receipt })}\n`
: renderWriteReceiptText(receipt),
);
return EXIT_OK;
}
// The request id is Core's idempotency key. Generate one when the caller
// gave none, and always print it, so a retry can reuse it instead of
// writing the same conversation twice.
const requestId = parsed.request_id ?? randomUUID();
const outcome = await submitMemoryWrite(attachment, {
session: parsed.session,
source: parsed.source,
request_id: requestId,
messages: messages ?? [],
...(parsed.memory_at === undefined
? {}
: { memory_at: parsed.memory_at }),
...(parsed.priority === undefined ? {} : { priority: parsed.priority }),
...(userId === undefined ? {} : { user_id: userId }),
});
if (outcome.state !== "accepted") {
emit(
asJson
? `${JSON.stringify({ schema_version: 1, product: PRODUCT, command: "write submit", state: outcome.state, request_id: requestId, code: outcome.code })}\n`
: `写入未完成:${outcome.state}(${outcome.code})\n${
outcome.state === "unknown"
? `结果未知;用同一个 --request-id ${requestId} 重试不会重复写入。\n`
: ""
}`,
);
return EXIT_FAILED;
}
emit(
asJson
? `${JSON.stringify({ schema_version: 1, product: PRODUCT, command: "write submit", state: "accepted", write: outcome.receipt })}\n`
: `${renderWriteReceiptText(outcome.receipt)}重试请带上同一个 --request-id ${outcome.receipt.request_id},不会重复写入。\n`,
);
return EXIT_OK;
}
if (named === "jobs") {
const parsed = parseJobsCommand(arguments_.slice(1));
if (!parsed) return usage();
const connected = await connectRuntime(environment);
if (!connected.ok) {
emitJobUnavailable(connected.code, asJson);
return connected.exitCode;
}
const { reader, operations } = connected;
if (parsed.kind === "list") {
const document = await readJobs(reader);
emitJobDocument(document, asJson);
return jobExitCode(document);
}
if (parsed.kind === "show") {
const document = await readJob(reader, parsed.jobId);
emitJobDocument(document, asJson);
return jobExitCode(document);
}
const planned =
parsed.kind === "start"
? planJobStart(parsed.sourceId, parsed.operation)
: planJobAction(parsed.jobId, parsed.action);
if (planned.state !== "preview") {
emitJobDocument(planned, asJson);
return jobExitCode(planned);
}
const document =
planned.plan.kind === "import_job_start"
? await executeJobStart(planned.plan, operations)
: await executeJobAction(planned.plan, operations);
emitJobDocument(document, asJson);
return jobExitCode(document);
}
if (named === "logs") {
if (arguments_[1] === "export") {
if (arguments_.slice(2).some((value) => value !== "--json"))
return usage();
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
const downloads = path.resolve(
environment.ONE_MEMORY_EXPORT_DIR ??
path.join(os.homedir(), "Downloads"),
);
const build =
environment.ONE_MEMORY_BUILD_NUMBER?.trim() ||
(typeof __ONE_MEMORY_BUILD_NUMBER__ === "string"
? __ONE_MEMORY_BUILD_NUMBER__
: "unknown");
const summary = await diagnosticSummary(config).catch(() => ({
entry: "cli",
}));
const target = path.join(downloads, diagnosticZipFilename(build));
const environmentCommit = environment.ONE_MEMORY_COMMIT?.trim();
const embeddedCommit =
typeof __ONE_MEMORY_COMMIT__ === "string"
? __ONE_MEMORY_COMMIT__.trim()
: "";
const commit =
environmentCommit ||
(embeddedCommit && embeddedCommit !== "unknown"
? embeddedCommit
: undefined);
const receipt = await exportDiagnosticZip({
dataDir: config.dataDir,
target,
productVersion: build,
...(commit === undefined ? {} : { commit }),
coreSha256: await sha256File(config.coreCommand).catch(() => undefined),
home: os.homedir(),
crashReportsDir: path.join(
os.homedir(),
"Library",
"Logs",
"DiagnosticReports",
),
summary,
});
process.stdout.write(
asJson
? `${JSON.stringify({ schema_version: 1, command: "logs export", state: "exported", path: target, file_count: receipt.file_count })}\n`
: `日志已导出:${target}\n`,
);
return EXIT_OK;
}
const parsed = parseLogQuery(arguments_.slice(1));
if (!parsed) return usage();
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
if (!parsed.follow) {
const report = await readLogReport(config.dataDir, parsed);
if (report.failure !== undefined) {
// A `logs` path that is not a usable directory is a real fault, not an
// empty result: report it instead of emitting zero bytes at exit 0.
if (asJson) {
process.stderr.write(
`${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command: "logs",
status: "error",
code: report.failure,
exit_code: EXIT_FAILED,
next_step:
"检查数据目录下的 logs 是否为可读目录;必要时运行 memhub doctor。",
})}\n`,
);
} else {
process.stderr.write(
`${PRODUCT}: 日志目录不可读(${report.failure})。\n`,
);
}
return EXIT_FAILED;
}
emit(
asJson
? report.events.length
? `${report.events
.map((event) => JSON.stringify(redactDiagnosticEvent(event)))
.join("\n")}\n`
: `${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command: "logs",
state: "empty",
events: 0,
invalid_lines: report.invalid_lines,
unrecognized_lines: report.unrecognized_lines,
})}\n`
: renderLogText(report.events, report.invalid_lines),
);
// Only lines that are not JSON are damage. Records an older build wrote
// are left out without a warning: after an upgrade they are expected.
if (report.invalid_lines) {
process.stderr.write(
`${PRODUCT}: 跳过 ${report.invalid_lines} 行损坏的诊断记录。\n`,
);
}
return EXIT_OK;
}
const owned = controls.watchSignal ? null : new AbortController();
const signal = controls.watchSignal ?? owned?.signal;
if (!signal) return EXIT_FAILED;
const cancel = () => owned?.abort();
if (owned) {
process.once("SIGINT", cancel);
process.once("SIGTERM", cancel);
}
try {
await followLogs({
dataDir: config.dataDir,
query: parsed,
signal,
maxEvents: controls.watchMaxEvents,
wait: controls.watchWait,
emit: (event) =>
emit(
asJson
? `${JSON.stringify(redactDiagnosticEvent(event))}\n`
: renderLogText([event], 0),
),
});
return EXIT_OK;
} finally {
if (owned) {
process.off("SIGINT", cancel);
process.off("SIGTERM", cancel);
}
}
}
if (named === "serve") {
if (arguments_.length !== 1) return usage();
return runRuntimeCli(["serve"], environment);
}
if (command === "start" || command === "restart") {
// Accept the global `--json`; the objective requires stable JSON on every
// declared command, and lifecycle is exactly where scripts need it.
if (arguments_.length !== (asJson ? 2 : 1)) return usage();
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
// A failed upgrade migration froze this home: its service exits without
// migrating. `start`/`restart` is an explicit retry, run here in the
// foreground; success lifts the freeze and turns the service back on.
const frozen = await readSdkMigrationBlock(config.dataDir).catch(
() => null,
);
if (frozen?.frozen !== undefined) {
const migration = await (
controls.migrateSdkData ?? migrateSdkDataBeforeStart
)({
coreCommand: config.coreCommand,
dataDir: config.dataDir,
runtimeBuild:
config.buildIdentity?.buildNumber ??
config.buildIdentity?.version ??
"unknown",
asJson,
caller: command === "restart" ? "cli-restart" : "cli-start",
});
if (migration.state === "deferred") {
const failure = sdkMigrationFailure(
command,
migration.code ?? "sdk_migration_incomplete",
config.dataDir,
"blocked",
migration.dataRestored === true,
);
process.stdout.write(
asJson
? `${JSON.stringify({ ...failure, state: "blocked" })}\n`
: `${failure.message}\n`,
);
return EXIT_FAILED;
}
}
const enableSnapshot = await setManagedServiceEnabled(config.dataDir, true);
const restoreEnableState = () =>
restoreManagedServiceEnabled(config.dataDir, enableSnapshot);
const lifecycle = (
state: "started" | "restarted" | "already_running" | "not-ready",
code: string,
) =>
asJson
? `${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command,
state,
code,
})}\n`
: state === "started"
? command === "restart"
? "MemHub Runtime 已重启(用户服务)。\n"
: "MemHub Runtime 已启动(用户服务)。\n"
: state === "already_running"
? "MemHub Runtime 已在运行。\n"
: "Runtime 服务已注册但未就绪;请运行 memhub logs 查看原因。\n";
// A Runtime that did not come up because its service refuses to serve an
// unmigrated home reports that cause, the migration log and where to send
// it, instead of a generic budget timeout.
const migrationBlocked = async (): Promise<string | null> => {
const blocked = await runtimeMigrationBlock(config.dataDir, command);
if (blocked === null) return null;
return asJson
? `${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command,
state: "blocked",
code: blocked.code,
message: blocked.message,
log_file: blocked.log_file,
issues_url: blocked.issues_url,
})}\n`
: `${blocked.message}\n`;
};
const notReady = async () =>
(await migrationBlocked()) ??
lifecycle("not-ready", "runtime_not_ready_within_budget");
// A launchd-managed Runtime must be driven through launchctl. Spawning a
// second process while the service holds the data directory is exactly what
// produced "Runtime exited before becoming ready".
const uid = process.getuid?.();
const serviceId =
process.platform === "darwin" && uid !== undefined
? managedRuntimeServiceId(config, environment, uid)
: null;
const service =
serviceId !== null && uid !== undefined
? {
serviceId,
uid,
home: config.hostHome ?? os.homedir(),
runCaptured: runLaunchctlCaptured,
// Only a label whose plist binds this data dir is ours.
dataDir: config.dataDir,
}
: null;
let stoppedForRestart = false;
if (command === "restart") {
if (service) {
// Restart keeps the launchd registration: kickstart -k, not bootout.
let restarted: Awaited<ReturnType<typeof restartManagedService>>;
try {
restarted = await (
controls.restartManagedRuntime ?? restartManagedService
)(service);
} catch (error) {
await restoreEnableState();
throw error;
}
if (restarted === "restarted") {
// launchd `state = running` only means a process was spawned, not
// that Core came up: the Runtime supervisor still has to publish its
// receipts and pass the identity probe. Reporting success here would
// reproduce the "started but nothing answers" split.
const ready = await (
controls.waitForRuntimeReady ?? waitForRuntimeReady
)(config.dataDir);
if (ready) {
process.stdout.write(lifecycle("restarted", "runtime_verified"));
return EXIT_OK;
}
process.stdout.write(await notReady());
await restoreEnableState();
return EXIT_FAILED;
}
if (restarted === "not-ready") {
process.stdout.write(await notReady());
await restoreEnableState();
return EXIT_FAILED;
}
if (enableSnapshot.managed) {
await restoreEnableState();
process.stdout.write(
lifecycle("not-ready", "runtime_service_declaration_missing"),
);
return EXIT_FAILED;
}
// "absent": no registered service; fall through to the receipt path.
}
let stopped: Awaited<ReturnType<typeof stopRuntimeProcess>>;
try {
stopped = await (controls.stopRuntime ?? stopRuntimeProcess)({
dataDir: config.dataDir,
probeIdentity,
});
} catch (error) {
await restoreEnableState();
throw error;
}
stoppedForRestart = stopped === "stopped";
if (stopped === "not-running") {
// A note on stderr in JSON mode, so stdout stays a single document.
(asJson ? process.stderr : process.stdout).write(
asJson
? `${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command: "restart",
state: "not-running",
code: "runtime_not_running",
})}\n`
: "MemHub Runtime 原先未运行,将直接启动。\n",
);
}
}
if (service) {
let startedManaged: Awaited<ReturnType<typeof startManagedService>>;
try {
startedManaged = await startManagedService(service);
} catch (error) {
await restoreEnableState();
throw error;
}
if (startedManaged === "started") {
// Same as restart: a launchd pid is not proof Core is ready.
const ready = await (
controls.waitForRuntimeReady ?? waitForRuntimeReady
)(config.dataDir);
if (ready) {
process.stdout.write(lifecycle("started", "runtime_verified"));
return EXIT_OK;
}
process.stdout.write(await notReady());
await restoreEnableState();
return EXIT_FAILED;
}
if (startedManaged === "not-ready") {
process.stdout.write(await notReady());
await restoreEnableState();
return EXIT_FAILED;
}
if (enableSnapshot.managed) {
await restoreEnableState();
process.stdout.write(
lifecycle("not-ready", "runtime_service_declaration_missing"),
);
return EXIT_FAILED;
}
// "absent": no service registered -> fall through to the receipt path.
}
let result: "attached" | "started";
try {
result = await (controls.ensureRuntime ?? ensureRuntimeProcess)({
dataDir: config.dataDir,
launch: {
command: config.nodeCommand,
args: [config.runtimeEntry, "serve"],
env: runtimeEnvironment(config),
},
probeIdentity,
});
} catch (error) {
await restoreEnableState();
// A spawned Runtime that exits before ready is a failure of this command,
// not a crash: report it as a structured document (JSON) or a plain
// message, with a non-zero exit, instead of letting it escape.
const blocked = await migrationBlocked();
if (blocked !== null) {
process.stdout.write(blocked);
return EXIT_FAILED;
}
const message = error instanceof Error ? error.message : String(error);
process.stdout.write(
asJson
? `${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command,
state: "not-ready",
code: "runtime_exited_before_ready",
message,
})}\n`
: `${message}\n`,
);
return EXIT_FAILED;
}
process.stdout.write(
lifecycle(
result === "attached"
? "already_running"
: stoppedForRestart
? "restarted"
: "started",
result === "attached"
? "runtime_attached"
: stoppedForRestart
? "runtime_restarted"
: "runtime_started",
),
);
return 0;
}
if (command === "stop") {
if (arguments_.length !== (asJson ? 2 : 1)) return usage();
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
const enableSnapshot = await setManagedServiceEnabled(
config.dataDir,
false,
);
const restoreEnableState = () =>
restoreManagedServiceEnabled(config.dataDir, enableSnapshot);
// A managed service is kept alive by an enable marker; stopping it must
// clear that marker or launchd/systemd would restart it at once. The
// marker is also what starts it at login, so say plainly that login
// auto-start is now off and that `start` turns it back on.
const autoStartNote = enableSnapshot.managed
? "登录时自动启动已关闭;运行 memhub start 会重新启动并恢复自动启动。\n"
: "";
const stoppedDoc = (
state: "stopped" | "not-running" | "still-running",
code: string,
) =>
asJson
? `${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command: "stop",
state,
code,
...(enableSnapshot.managed && state !== "still-running"
? { login_auto_start: false }
: {}),
})}\n`
: state === "stopped"
? `MemHub Runtime 已停止。\n${autoStartNote}`
: state === "still-running"
? "Runtime 由用户服务托管,停用被拒绝或被立即拉起;请运行 memhub service-uninstall。\n"
: `MemHub Runtime 未运行。\n${autoStartNote}`;
// When the Runtime is a launchd-managed user service, a receipt-based stop
// is wrong: SIGTERM to the receipt pid is immediately undone by KeepAlive,
// and the receipts can be deleted while the daemon lives on. Stop through
// launchctl instead, and never report success unless the label is gone.
const uid = process.getuid?.();
if (process.platform === "darwin" && uid !== undefined) {
const serviceId = managedRuntimeServiceId(config, environment, uid);
let managed: Awaited<ReturnType<typeof stopManagedService>>;
try {
managed = await stopManagedService({
serviceId,
uid,
runCaptured: runLaunchctlCaptured,
// Only a label whose plist binds *this* data dir is ours to stop.
dataDir: config.dataDir,
});
} catch (error) {
await restoreEnableState();
throw error;
}
if (managed === "still-running") {
await restoreEnableState();
process.stdout.write(
stoppedDoc("still-running", "runtime_service_still_running"),
);
return EXIT_FAILED;
}
if (managed === "stopped") {
process.stdout.write(stoppedDoc("stopped", "runtime_stopped"));
return EXIT_OK;
}
// "not-running": no managed service; fall through to the receipt path.
}
let result: Awaited<ReturnType<typeof stopRuntimeProcess>>;
try {
result = await stopRuntimeProcess({
dataDir: config.dataDir,
probeIdentity,
});
} catch (error) {
await restoreEnableState();
throw error;
}
process.stdout.write(
result === "stopped"
? stoppedDoc("stopped", "runtime_stopped")
: stoppedDoc("not-running", "runtime_not_running"),
);
return 0;
}
if (named === "status" || named === "doctor") {
const parsed = parseStatusCommand(named, arguments_.slice(1));
if (!parsed) return usage();
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
const serviceId =
(controls.platform ?? process.platform) === "darwin" &&
process.getuid?.() !== undefined
? managedRuntimeServiceId(
config,
environment,
process.getuid?.() as number,
)
: null;
if (parsed.watch) {
return runCliWatch({
kind: "status",
intervalMs: parsed.intervalMs,
asJson,
controls,
read: (signal) =>
readStatusSnapshot(
config.dataDir,
signal,
controls.inspectLaunchd,
controls.platform,
serviceId ?? undefined,
),
render: (report) => renderStatusText(report, "Runtime 状态"),
});
}
if (named === "doctor") {
const { report, runtimeStopped } = await readDoctorSnapshot(
config.dataDir,
);
emit(asJson ? `${JSON.stringify(report)}\n` : renderDoctorText(report));
// Same exit 3 as `status` when the Runtime is simply not running.
return runtimeStopped ? EXIT_RUNTIME_STOPPED : doctorExitCode(report);
}
const snapshot = await readStatusSnapshot(
config.dataDir,
undefined,
controls.inspectLaunchd,
controls.platform,
serviceId ?? undefined,
);
const report = snapshot.value;
emit(
asJson
? `${JSON.stringify(report)}\n`
: renderStatusText(report, "Runtime 状态"),
);
return statusExitCode(report);
}
if (named === "ui") {
if (arguments_.length !== (asJson ? 2 : 1)) return usage();
// Contract: docs/contracts/c0-ui-and-restore-contract.md (C0, H25).
// Core owns session establishment: it hands back a one-time code URL and
// converts it to an HttpOnly/SameSite=Strict cookie. The CLI never puts the
// Core bearer token in a URL — it only forwards the loopback URL Core
// minted. If Core predates this contract the endpoint 404s and we report
// the stable, honest "contract unavailable" block.
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
const attachment = await attachToRuntime(config.dataDir);
const emitUi = (result: UiResult, detail?: string) => {
const document =
result.state === "opened"
? {
schema_version: 1 as const,
product: PRODUCT,
command: "ui",
state: result.state,
url: result.url,
...(detail === undefined ? {} : { next_step: detail }),
}
: {
schema_version: 1 as const,
product: PRODUCT,
command: "ui",
state: result.state,
code: result.code,
...(detail === undefined ? {} : { next_step: detail }),
};
emit(
asJson
? `${JSON.stringify(document)}\n`
: result.state === "opened"
? `已在系统浏览器打开 Web Console:${result.url}\n`
: `ui 未打开:${result.code}\n${detail === undefined ? "" : `下一步:${detail}\n`}`,
);
return uiExitCode(result);
};
if (attachment === null) {
const migration = await runtimeMigrationBlock(config.dataDir, "ui");
if (migration !== null) {
emit(
asJson
? `${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command: "ui",
state: "blocked",
code: migration.code,
message: migration.message,
log_file: migration.log_file,
issues_url: migration.issues_url,
})}\n`
: `ui 未打开:${migration.code}\n${migration.message}\n`,
);
return uiExitCode({ state: "blocked", code: migration.code });
}
return emitUi(
{ state: "blocked", code: "runtime_not_running" },
`先运行 ${PRODUCT} start 启动 Runtime。`,
);
}
const exchange = await requestUiSession(attachment).catch(() => null);
if (exchange === null) {
return emitUi(
{ state: "blocked", code: "ui_session_transport_failed" },
"无法连接 Core 的 loopback session 端点;确认 Runtime 可用后重试。",
);
}
if ("code" in exchange) {
return emitUi({ state: "blocked", code: exchange.code });
}
const opened = await (controls.openBrowser ?? openBrowser)(exchange.url);
return emitUi(
opened
? { state: "opened", url: exchange.url }
: { state: "failed", code: "ui_browser_open_failed" },
opened ? undefined : "无法调用系统默认浏览器;请手动打开上面的地址。",
);
}
if (named === "host") {
const parsed = parseHostCommand(arguments_.slice(1));
if (!parsed) return usage();
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
// `list` names the hosts on this machine, so it needs no Runtime. Every
// other subcommand goes through it, because that is where the layouts and
// every write to a host's config live.
if (parsed.kind === "host-list") {
const document = await listHosts(config.hostHome ?? os.homedir());
emitHostDocument(document, asJson);
return EXIT_OK;
}
const runtime = await inspectRuntimeStatus({
dataDir: config.dataDir,
probeIdentity,
requireServiceReceipt: true,
});
if (runtime.state !== "running") {
emitHostDocument(
{
schema_version: 1,
product: PRODUCT,
command: parsed.kind === "host-apply" ? "apply" : "status",
host_id: parsed.hostId,
state: "unavailable",
code: runtime.code,
},
asJson,
);
return runtime.state === "stopped" ? 3 : 1;
}
let document: HostFlowDocument;
try {
document = await readHost(
config.dataDir,
parsed.hostId,
parsed.operation,
parsed.args,
);
} catch (error) {
const code =
error instanceof Error ? error.message : "host_operation_failed";
emitHostDocument(
{
schema_version: 1,
product: PRODUCT,
command: parsed.kind === "host-apply" ? "apply" : "status",
host_id: parsed.hostId,
state: "unavailable",
code,
},
asJson,
);
return EXIT_FAILED;
}
emitHostDocument(document, asJson);
return hostExitCode(document);
}
if (named === "recall") {
const parsed = parseContextCommand(arguments_.slice(1));
if (!parsed) return usage();
let queryText = parsed.query;
if (parsed.queryFile) {
queryText = await readQueryFile(
parsed.queryFile,
CONTEXT_QUERY_MAX_CHARACTERS,
);
} else if (parsed.stdin) {
if (controls.stdinIsTTY ?? process.stdin.isTTY === true) return usage();
const bytes = await (controls.readStdin ?? readBoundedStdin)();
queryText = bytes ? parseContextText(bytes) : null;
}
if (queryText === null) return usage();
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
const runtime = await inspectRuntimeStatus({
dataDir: config.dataDir,
probeIdentity,
requireServiceReceipt: true,
});
if (runtime.state !== "running") {
const document: ContextSearchDocument = {
schema_version: 2,
product: PRODUCT,
state: "unavailable",
code: runtime.code,
items: [],
};
emit(
asJson ? `${JSON.stringify(document)}\n` : renderContextText(document),
);
return runtime.state === "stopped" ? 3 : 1;
}
const attached = await attachContextQuery(config.dataDir);
const document = attached.query
? await searchContext(attached.query, {
query: queryText,
kinds: parsed.kinds,
...(parsed.sources.length > 0
? { raw_source_ids: parsed.sources }
: {}),
limit: parsed.limit,
character_budget: parsed.characterBudget,
timeout_ms: parsed.timeoutMs,
...userIdParameters(userId),
})
: ({
schema_version: 2,
product: PRODUCT,
state: "unavailable",
code: attached.failure ?? "runtime_attach_failed",
items: [],
} satisfies ContextSearchDocument);
emit(
asJson ? `${JSON.stringify(document)}\n` : renderContextText(document),
);
return contextExitCode(document);
}
if (
named === "models" &&
(arguments_[1] === "list" || arguments_[1] === "show")
) {
const parsed = parseModelReadCommand(arguments_.slice(1));
if (!parsed) return usage();
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
const runtime = await inspectRuntimeStatus({
dataDir: config.dataDir,
probeIdentity,
requireServiceReceipt: true,
});
if (runtime.state !== "running") {
const document: ModelReportDocument = {
schema_version: 1,
product: PRODUCT,
state: "unavailable",
code: runtime.code,
};
emitModelReportDocument(document, asJson);
return runtime.state === "stopped" ? 3 : 1;
}
const attached = await attachReader(config.dataDir);
const document: ModelReportDocument = attached.reader
? parsed.kind === "list"
? await readModelProfiles(attached.reader)
: await readModelProfile(attached.reader, parsed.profileId)
: {
schema_version: 1,
product: PRODUCT,
state: "unavailable",
code: attached.failure ?? "runtime_attach_failed",
};
emitModelReportDocument(document, asJson);
return modelReportExitCode(document);
}
if (named === "models") {
const parsed = parseModelsCommand(arguments_.slice(1));
if (!parsed) return usage();
// Read every local input before touching the Runtime, so a bad file is a
// usage error that changed nothing.
const config =
parsed.kind === "add" || parsed.kind === "edit"
? await readBoundedJson(parsed.configFile)
: null;
if ((parsed.kind === "add" || parsed.kind === "edit") && config === null) {
return usage();
}
let apiKey: string | undefined;
if (
(parsed.kind === "add" || parsed.kind === "edit") &&
parsed.apiKeyFile !== undefined
) {
apiKey = parseApiKey(
await readBoundedFile(parsed.apiKeyFile, 16 * 1024 + 2, {
ownerOnly: true,
}),
);
if (apiKey === undefined) return usage();
}
if (
parsed.kind === "spaces" &&
parsed.cleanup !== undefined &&
!parsed.confirmed
) {
return emitConfirmPreview(asJson, {
command: "models spaces",
target: parsed.cleanup,
effect: `删除已停用的向量空间 ${parsed.cleanup}(不可恢复;正在使用的空间不能删除)`,
retry: `${PRODUCT} models spaces --cleanup ${parsed.cleanup} --yes`,
});
}
if (parsed.kind === "remove" && !parsed.confirmed) {
return emitConfirmPreview(asJson, {
command: "models remove",
target: parsed.profileId,
effect: `删除模型配置 ${parsed.profileId}(正在使用的配置不能删除)`,
retry: `${PRODUCT} models remove ${parsed.profileId} --yes`,
});
}
const connected = await connectRuntime(environment);
if (!connected.ok) {
emitFlowDocument(
{
schema_version: 1,
product: PRODUCT,
state: "unknown",
code: connected.code,
},
asJson,
);
return connected.exitCode;
}
const { reader, operations } = connected;
// Ctrl-C while following a switch stops watching, never the switch.
const interrupt = new AbortController();
const switchIo: SwitchIo = {
reader,
operations,
emit: (text) => {
emit(text);
},
asJson,
interactive:
!asJson && (controls.stdinIsTTY ?? process.stdin.isTTY) === true,
confirm: async (question) => {
const prompter =
controls.wizardPrompter ?? (await createTerminalPrompter());
try {
return /^(y|yes)$/i.test((await prompter.ask(question)).trim());
} finally {
if ("close" in prompter && typeof prompter.close === "function") {
prompter.close();
}
}
},
...(controls.watchWait ? { wait: controls.watchWait } : {}),
signal: controls.watchSignal ?? interrupt.signal,
};
const withInterrupt = async (run: () => Promise<number>) => {
const onInterrupt = () => interrupt.abort();
process.once("SIGINT", onInterrupt);
try {
return await run();
} finally {
process.removeListener("SIGINT", onInterrupt);
}
};
if (parsed.kind === "switch") {
return parsed.action === "status"
? await switchStatus(switchIo)
: await switchCancel(switchIo);
}
if (parsed.kind === "spaces") {
return parsed.cleanup === undefined
? await listSpaces(switchIo)
: await cleanupSpace(parsed.cleanup, switchIo);
}
if (parsed.kind === "add") {
const created = await createModelProfile(
config,
apiKey,
reader,
operations,
);
if (
!parsed.activate ||
(created.state !== "committed" && created.state !== "recovered")
) {
emitFlowDocument(created, asJson);
return flowExitCode(created);
}
// Core activates only a probed profile: probe first, then the same
// path as `models activate`, so an embedding change shows its plan.
const profileId = String(created.receipt.profile_id);
const probe = await probeUntilSettled(
reader,
operations,
profileId,
controls.watchWait,
);
const probed =
probe.state === "completed" &&
Boolean(
(
(probe.receipt.result as Record<string, unknown> | undefined)
?.capabilities as Record<string, unknown> | undefined
)?.embedding_available,
);
emit(
asJson
? `${JSON.stringify({
...created,
probe: {
state: probe.state,
...("operation_id" in probe && probe.operation_id
? { operation_id: probe.operation_id }
: {}),
...("code" in probe ? { code: probe.code } : {}),
},
})}\n`
: `${renderFlowText(created)}${
probed
? "探测通过。\n"
: `探测未通过(${"code" in probe ? probe.code : probe.state}),没有设为当前使用;用 ${PRODUCT} models test ${profileId} 查看原因。\n`
}`,
);
if (!probed) return EXIT_FAILED;
return await withInterrupt(() =>
activateWithEmbeddingSwitch(
profileId,
{ confirmed: parsed.confirmed, follow: parsed.follow },
switchIo,
),
);
}
if (parsed.kind === "activate") {
return await withInterrupt(() =>
activateWithEmbeddingSwitch(
parsed.profileId,
{ confirmed: parsed.confirmed, follow: parsed.follow },
switchIo,
),
);
}
if (parsed.kind === "edit") {
const document = await editModelProfile(
parsed.profileId,
config,
apiKey,
operations,
);
emitModelWriteDocument(document, asJson);
return modelWriteExitCode(document);
}
if (parsed.kind === "test") {
const document = await startModelProbe(
reader,
operations,
parsed.profileId,
);
emitModelProbeDocument(document, asJson);
return modelProbeExitCode(document);
}
if (parsed.kind === "receipt") {
return await emitModelReceipt(reader, parsed.operationId, asJson);
}
const removed = await removeModelProfile(
connected.dataDir,
parsed.profileId,
);
if (removed === "not_found" || removed === "active") {
// Two reasons, two next actions: pick another id, or stop using it
// first. Naming which one keeps the user from retrying blindly.
emit(
asJson
? `${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command: "models remove",
state: "rejected",
code:
removed === "active"
? "active_model_profile"
: "model_profile_not_found",
})}\n`
: removed === "active"
? "删除失败:该模型配置正在使用中,请先激活另一个配置后再删除。\n"
: "删除失败:未找到该模型配置。\n",
);
return EXIT_FAILED;
}
if (removed === "unavailable") {
return emitFailure(
asJson,
"models remove",
"model_remove_result_unknown",
);
}
emit(
asJson
? `${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command: "models remove",
state: "committed",
profile_id: parsed.profileId,
})}\n`
: `已删除模型配置:${parsed.profileId}\n`,
);
return EXIT_OK;
}
if (named === "sources" && arguments_[1] !== "list") {
const parsed = parseSourcesCommand(arguments_.slice(1));
if (
!parsed &&
arguments_.some((item) => item === "--user-id" || item === "--root")
) {
return reject(
descriptor,
asJson,
"--user-id 和 --root 只能用于接入单个 hermes 来源(Hermes profile),用户 ID 只能是 1–64 位字母、数字、点、下划线或连字符。用户 ID 不必等于 Hermes profile 名,例如 --user-id wife-work --root ~/.hermes/profiles/wife:work。",
);
}
if (!parsed) return usage();
if (parsed.kind === "receipt") {
const connected = await connectRuntime(environment);
if (!connected.ok) {
emitFlowDocument(
{
schema_version: 1,
product: PRODUCT,
state: "unknown",
code: connected.code,
},
asJson,
);
return connected.exitCode;
}
const document = await readSourceReceipt(
connected.reader,
parsed.requestId,
);
emitFlowDocument(document, asJson);
return flowExitCode(document);
}
return await connectSources(parsed, asJson, environment);
}
if (named === "init") {
// `init` is the friendly first run: on a terminal it walks the wizard,
// elsewhere it lists what can be connected and the exact next command.
// Scripted connects go through `sources connect` (the old
// `init --source/--all-detected` spellings are rewritten to it).
if (
arguments_.filter((item) => item === "--json").length > 1 ||
arguments_.slice(1).some((item) => item !== "--json")
) {
return usage();
}
const connected = await connectRuntime(environment);
if (!connected.ok) {
emit(
asJson
? `${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command: "init",
state: "unavailable",
code: connected.code,
})}\n`
: `未接入:${connected.code}\n${
connected.exitCode === EXIT_RUNTIME_STOPPED
? `下一步:先运行 ${PRODUCT} start 启动 Runtime。\n`
: ""
}`,
);
return connected.exitCode;
}
const outcome = await connected.reader<unknown>("/api/v1/sources");
const candidates =
outcome.state === "ok"
? validateSourceConnectionCandidates(outcome.data)
: null;
if (candidates === null) {
const code =
outcome.state === "ok" ? "source_response_invalid" : outcome.code;
emit(
asJson
? `${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command: "init",
state: "unavailable",
code,
})}\n`
: `未接入:${code}\n`,
);
return EXIT_FAILED;
}
// Interactive only when a real terminal is attached and JSON was not
// asked for (a machine-readable run must never wait on a prompt).
const interactive =
!asJson && (controls.stdinIsTTY ?? process.stdin.isTTY) === true;
if (interactive && candidates.length > 0) {
return await runInteractiveInit(
candidates,
{ reader: connected.reader, operations: connected.operations },
asJson,
emit,
controls.wizardPrompter,
connected.dataDir,
resolveRuntimeCliConfig(environment, process.argv[1]).hostHome ??
os.homedir(),
);
}
if (asJson) {
emit(
`${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command: "init",
state: "choose",
candidates: candidates.map((candidate) => ({
source_id: candidate.source_id,
display_name: candidate.display_name,
requires_override_confirmation:
candidate.requires_override_confirmation,
})),
next_step: `${PRODUCT} sources connect <id> [<id> ...] --import [--confirm-overrides]`,
})}\n`,
);
return EXIT_OK;
}
if (candidates.length === 0) {
emit("本机没有检测到可接入的来源。\n");
return EXIT_OK;
}
const lines = candidates.map(
(candidate) =>
` ${candidate.source_id}${
candidate.requires_override_confirmation
? " (目录来自环境变量覆盖,需加 --confirm-overrides)"
: ""
} ${candidate.display_name}`,
);
emit(
`可接入的来源:\n${lines.join("\n")}\n\n下一步:${PRODUCT} sources connect <id> [<id> ...] --import\n`,
);
return EXIT_OK;
}
if (named === "sources" || named === "sync" || named === "progress") {
const parsed = parseReadCommand(named, arguments_.slice(1));
if (!parsed) return usage();
if (parsed.kind === "sync-set") {
const connected = await connectRuntime(environment);
if (!connected.ok) {
const document: SourceSyncFlowDocument = {
schema_version: 1,
product: PRODUCT,
state: "unknown",
code: connected.code,
};
emitSourceSyncDocument(document, asJson);
return connected.exitCode;
}
const document = await setSourceSync(
connected.reader,
connected.operations,
{
sourceId: parsed.sourceId,
action: parsed.action,
},
);
emitSourceSyncDocument(document, asJson);
return sourceSyncExitCode(document);
}
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
if (parsed.kind === "progress" && parsed.watch) {
return runCliWatch({
kind: "progress",
intervalMs: parsed.intervalMs,
asJson,
controls,
read: (signal) => readProgressSnapshot(config.dataDir, parsed, signal),
render: renderProgressText,
});
}
const runtime = await inspectRuntimeStatus({
dataDir: config.dataDir,
probeIdentity,
requireServiceReceipt: true,
});
if (runtime.state !== "running") {
const code = runtime.code;
const document =
named === "sources"
? unreadSources("not_checked", code)
: named === "sync"
? unreadSync("not_checked", code)
: unreadProgress("not_checked", code);
emitReadDocument(named, document, asJson);
return runtime.state === "stopped" ? 3 : 1;
}
const attached = await attachReader(config.dataDir);
if (!attached.reader) {
const code = attached.failure ?? "runtime_attach_failed";
const document =
named === "sources"
? unreadSources("unavailable", code)
: named === "sync"
? unreadSync("unavailable", code)
: unreadProgress("unavailable", code);
emitReadDocument(named, document, asJson);
return EXIT_FAILED;
}
const document =
parsed.kind === "sources"
? await readSources(attached.reader)
: parsed.kind === "sync"
? await readSync(attached.reader)
: await readProgress(attached.reader, parsed);
emitReadDocument(named, document, asJson);
return sourceProgressExitCode(document);
}
if (named === "profile") {
const profileFlags = arguments_.slice(1);
if (
profileFlags.filter((item) => item === "--json").length > 1 ||
profileFlags.filter((item) => item === "--versions").length > 1 ||
profileFlags.some((value) => value !== "--json" && value !== "--versions")
) {
return usage();
}
if (profileFlags.includes("--versions")) {
return await profileVersionsCommand(environment, { asJson, userId });
}
const connected = await connectRuntime(environment, {
readerTimeoutMs: 15_000,
});
if (!connected.ok) {
const document = {
schema_version: 2,
product: PRODUCT,
state: "unavailable",
code: connected.code,
};
emit(
asJson
? `${JSON.stringify(document)}\n`
: `用户画像读取失败:${document.code}\n`,
);
return connected.exitCode;
}
const profileUser = userIdParameters(userId);
const outcome =
profileUser.user_id === undefined
? await connected.reader<unknown>("/api/v2/user-profile")
: await connected.reader<unknown>("/api/v2/user-profile", profileUser);
const value =
outcome.state === "ok"
? (outcome.data as {
request_id?: unknown;
profile?: unknown;
elapsed_ms?: unknown;
})
: null;
const valid =
value !== null &&
typeof value.request_id === "string" &&
(value.profile === null ||
(typeof value.profile === "object" && !Array.isArray(value.profile))) &&
typeof value.elapsed_ms === "number";
if (!valid) {
const document = {
schema_version: 2,
product: PRODUCT,
state: outcome.state === "ok" ? "invalid" : outcome.state,
code: outcome.state === "ok" ? "response_invalid" : outcome.code,
};
emit(
asJson
? `${JSON.stringify(document)}\n`
: `用户画像读取失败:${document.code}\n`,
);
return EXIT_FAILED;
}
const document = {
schema_version: 2,
product: PRODUCT,
state: "ok",
...value,
};
const content =
value.profile && typeof value.profile === "object"
? (value.profile as { content?: unknown }).content
: null;
emit(
asJson
? `${JSON.stringify(document)}\n`
: value.profile === null
? "尚未形成用户长期画像。\n"
: `${typeof content === "string" ? content : JSON.stringify(value.profile)}\n`,
);
return EXIT_OK;
}
if (named === "memories") {
const parsed = await parseMemoryCommand(arguments_.slice(1));
if (!parsed) return usage();
// Local inputs first: an unreadable file changes nothing and is a usage
// error, before the Runtime is even contacted.
let text: string | null = null;
if (parsed.kind === "update") {
text = await readMemoryContentFile(parsed.contentFile);
if (text === null) return usage();
}
if (parsed.kind === "add") {
text =
parsed.text ??
(parsed.contentFile === undefined
? null
: await readMemoryContentFile(parsed.contentFile));
if (text === null || !validMemoryText(text)) return usage();
}
if (parsed.kind === "delete" && !parsed.confirmed) {
const namespace = namespaceFlags(parsed.namespace);
return emitConfirmPreview(asJson, {
command: "memories delete",
schemaVersion: 2,
target: parsed.memoryId,
effect: `物理删除记忆 ${parsed.memoryId},删除后无法恢复`,
retry: `${PRODUCT} memories delete ${parsed.memoryId}${namespace} --yes`,
});
}
const connected = await connectRuntime(environment, {
readerTimeoutMs: 15_000,
});
if (!connected.ok) {
const document = {
schema_version: 2,
product: PRODUCT,
command: `memories ${parsed.kind}`,
// A write whose Runtime is absent has an unknown outcome to report;
// a read simply was not checked.
state:
parsed.kind === "add" ||
parsed.kind === "update" ||
parsed.kind === "delete"
? "unknown"
: connected.exitCode === EXIT_RUNTIME_STOPPED
? "not_checked"
: "unavailable",
code: connected.code,
};
emit(
asJson
? `${JSON.stringify(document)}\n`
: `记忆操作未完成:${document.state}(${document.code})\n`,
);
return connected.exitCode;
}
const { reader, operations } = connected;
if (parsed.kind === "list") {
const document = await readMemoryPage(reader, parsed.query, userId);
emit(
asJson
? `${JSON.stringify(document)}\n`
: renderMemoryPageText(document),
);
return document.page.state === "ok" ? EXIT_OK : EXIT_FAILED;
}
if (parsed.kind === "show") {
const document = await readMemoryDetail(
reader,
parsed.memoryId,
parsed.namespace,
userId,
);
emit(
asJson
? `${JSON.stringify(document)}\n`
: renderMemoryDetailText(document),
);
return document.memory.state === "ok" ? EXIT_OK : EXIT_FAILED;
}
if (parsed.kind === "add" || parsed.kind === "add-status") {
const document =
parsed.kind === "add"
? await addMemory(operations, text ?? "", parsed.requestId, userId)
: await readMemoryAddStatus(reader, parsed.requestId, userId);
emit(
asJson
? `${JSON.stringify(document)}\n`
: renderMemoryAddText(document),
);
return memoryAddExitCode(document);
}
const memoryPath = `/api/v2/memories/${encodeURIComponent(parsed.memoryId)}`;
const outcome =
parsed.kind === "update"
? await operations.put<unknown>(memoryPath, {
content: text,
...namespaceParameters(parsed.namespace),
...(parsed.task === undefined ? {} : { task: parsed.task }),
...userIdParameters(userId),
})
: await operations.delete<unknown>(
withQuery(memoryPath, {
...namespaceParameters(parsed.namespace),
...userIdParameters(userId),
}),
);
// A delete answered 404 `memory_not_found` is already gone (deleted
// before, or never this user's): what was asked for holds, so it exits 0
// as committed and says so, instead of failing a retry or a double run.
const alreadyGone =
parsed.kind === "delete" &&
outcome.state === "rejected" &&
outcome.code === "memory_not_found";
const state = alreadyGone ? "committed" : outcome.state;
const document = {
schema_version: 2,
product: PRODUCT,
command: `memories ${parsed.kind}`,
state,
operation: parsed.kind,
memory_id: parsed.memoryId,
...(alreadyGone
? { already_gone: true }
: outcome.state === "committed"
? parsed.kind === "update"
? { result: outcome.data }
: {}
: { code: outcome.code }),
};
emit(
asJson
? `${JSON.stringify(document)}\n`
: alreadyGone
? `未找到记忆 ${parsed.memoryId}(可能已删除),无需再删。\n`
: outcome.state === "committed"
? `已${parsed.kind === "update" ? "更新" : "删除"}记忆 ${parsed.memoryId}\n`
: outcome.code === "memory_id_ambiguous"
? "这个 ID 同时存在于多个场景;请加 --kind(以及 --variant)指明是哪一条。\n"
: `操作失败:${outcome.code}\n`,
);
return state === "committed" ? EXIT_OK : EXIT_FAILED;
}
if (
named === "settings" &&
arguments_.slice(1).find((value) => value !== "--json") === "features"
) {
const parsed = parseFeaturesCommand(
arguments_
.slice(1)
.filter((value, index, all) => index !== all.indexOf("features")),
);
if (!parsed) return usage();
const connected = await connectRuntime(environment);
if (!connected.ok) {
const document: FeaturesDocument = {
schema_version: 1,
product: PRODUCT,
command: "settings features",
state: "unavailable",
code: connected.code,
};
emit(
asJson ? `${JSON.stringify(document)}\n` : renderFeaturesText(document),
);
return connected.exitCode;
}
const document = await runFeaturesCommand(
parsed,
connected.reader,
connected.operations,
);
emit(
asJson ? `${JSON.stringify(document)}\n` : renderFeaturesText(document),
);
return featuresExitCode(document);
}
if (
named === "settings" &&
arguments_.slice(1).find((value) => value !== "--json") === "language"
) {
const parsed = parseLanguageCommand(
arguments_
.slice(1)
.filter((value, index, all) => index !== all.indexOf("language")),
);
if (!parsed) return usage();
const connected = await connectRuntime(environment);
if (!connected.ok) {
const document: LanguageDocument = {
schema_version: 1,
product: PRODUCT,
command: "settings language",
state: "unavailable",
code: connected.code,
};
emit(
asJson ? `${JSON.stringify(document)}\n` : renderLanguageText(document),
);
return connected.exitCode;
}
const document = await runLanguageCommand(
parsed,
connected.reader,
connected.operations,
);
emit(
asJson ? `${JSON.stringify(document)}\n` : renderLanguageText(document),
);
return languageExitCode(document);
}
if (named === "settings") {
const parsed = parseSettingsCommand(arguments_.slice(1));
if (!parsed) return usage();
const connected = await connectRuntime(environment);
if (!connected.ok) {
const document: ModeDocument = {
schema_version: 1,
product: PRODUCT,
command: "settings mode",
state: "unavailable",
code: connected.code,
};
emit(asJson ? `${JSON.stringify(document)}\n` : renderModeText(document));
return connected.exitCode;
}
const document = await runModeCommand(
parsed,
connected.reader,
connected.operations,
);
emit(asJson ? `${JSON.stringify(document)}\n` : renderModeText(document));
return modeExitCode(document);
}
if (named === "evolve") {
const parsed = parseEvolveCommand(arguments_.slice(1));
if (!parsed) return usage();
const connected = await connectRuntime(environment);
if (!connected.ok) {
const document: EvolveDocument = {
schema_version: 1,
product: PRODUCT,
command: "evolve",
state: "unavailable",
code: connected.code,
};
emit(
asJson ? `${JSON.stringify(document)}\n` : renderEvolveText(document),
);
return connected.exitCode;
}
const document = await runEvolveCommand(parsed, {
userId: userIdParameters(userId).user_id,
reader: connected.reader,
operations: connected.operations,
sleep: controls.sleep,
});
emit(asJson ? `${JSON.stringify(document)}\n` : renderEvolveText(document));
return evolveExitCode(document);
}
if (named === "graph-width") {
const parsed = parseGraphWidthCommand(arguments_.slice(1));
if (!parsed) return usage();
if (graphWidthDecisionIsDestructive(parsed.decision) && !parsed.confirmed) {
return emitConfirmPreview(asJson, {
command: "graph-width",
target: parsed.decision,
effect:
"先导出现有图谱,再归档旧图谱并按新的嵌入维度重建;归档后旧图谱不再使用",
retry: `${PRODUCT} graph-width --decision ${parsed.decision} --yes`,
});
}
const connected = await connectRuntime(environment);
if (!connected.ok) {
emit(
asJson
? `${JSON.stringify({ schema_version: 1, product: PRODUCT, command: "graph-width", state: "unknown", decision: parsed.decision, code: connected.code })}\n`
: `图谱宽度决定未完成:${connected.code}\n`,
);
return connected.exitCode;
}
const document = await decideGraphWidth(
parsed.decision,
connected.operations,
);
emit(
asJson ? `${JSON.stringify(document)}\n` : renderGraphWidthText(document),
);
return graphWidthExitCode(document);
}
if (command === "service-plan") {
const options = parseServiceOptions(arguments_.slice(1), false);
if (!options) return usage();
const platform = options.values.get("platform") ?? process.platform;
if (platform !== "darwin" && platform !== "linux" && platform !== "win32") {
return usage();
}
const home = options.values.get("home") ?? os.homedir();
const installRoot = options.values.get("install-root");
if (!installRoot) return usage();
const dataDir = serviceDataDirectory(
options.values.get("data-dir"),
environment,
platform,
);
if (dataDir === null) return usage();
const uidText = options.values.get("uid");
const uid = uidText === undefined ? process.getuid?.() : Number(uidText);
const plan = planUserService(
runtimeInstallLayout({
platform,
home,
installRoot: canonicalManagedServiceRoot(platform, installRoot),
dataDir,
...hourlyIsolationFromEnvironment(environment),
}),
{ uid, xdgConfigHome: options.values.get("xdg-config-home") },
);
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
return plan.status === "ready" ? 0 : 4;
}
if (command === "service-install") {
const options = parseServiceOptions(arguments_.slice(1), true);
if (!options) return usage();
if (packagedAppCliPath(process.argv[1], environment)) {
process.stdout.write(
`${JSON.stringify(
{
schema_version: 1,
product: PRODUCT,
command: "service-install",
state: "managed_by_app",
code: "runtime_managed_by_memhub_app",
apply: false,
message: "后台已由 MemHub App 管理,不会安装第二套服务。",
},
null,
2,
)}\n`,
);
return EXIT_OK;
}
const platform = options.values.get("platform") ?? process.platform;
if (platform !== "darwin" && platform !== "linux" && platform !== "win32") {
return usage();
}
if (localExperienceMode) {
return emitLocalExperienceUnavailable(
"service-install",
options.confirmed,
asJson,
);
}
const home = options.values.get("home") ?? os.homedir();
const installRoot = options.values.get("install-root");
if (!installRoot) return usage();
const dataDir = serviceDataDirectory(
options.values.get("data-dir"),
environment,
platform,
);
if (dataDir === null) return usage();
const uidText = options.values.get("uid");
const uid = uidText === undefined ? process.getuid?.() : Number(uidText);
const layout = runtimeInstallLayout({
platform,
home,
installRoot: canonicalManagedServiceRoot(platform, installRoot),
dataDir,
...hourlyIsolationFromEnvironment(environment),
});
const plan = planUserService(layout, {
uid,
xdgConfigHome: options.values.get("xdg-config-home"),
});
if (!options.confirmed) {
process.stdout.write(
`${JSON.stringify({ ...plan, apply: false }, null, 2)}\n`,
);
return plan.status === "ready" ? 0 : 4;
}
const serviceOptions = {
uid,
xdgConfigHome: options.values.get("xdg-config-home"),
};
const applied = await prepareServiceInstall(layout, serviceOptions)
.then((prepared) => applyPreparedServiceInstall(prepared))
.then(async (plan) => {
// Registration is not activation. `launchctl bootstrap`/`systemctl
// enable --now` can return success while the managed Runtime is still
// starting or has already failed. Require the same verified identity
// probe used by start/restart/install/upgrade before reporting success.
const ready = await (
controls.waitForRuntimeReady ?? waitForRuntimeReady
)(layout.dataDir);
if (!ready) throw new Error("runtime_service_activation_unverified");
return plan;
})
.catch(async (error: unknown) => {
const migration =
error instanceof Error &&
error.message === "runtime_service_activation_unverified"
? await runtimeMigrationBlock(layout.dataDir, "service-install")
: null;
process.stderr.write(
`${JSON.stringify(
migration ?? serviceErrorDocument("service-install", error),
null,
2,
)}\n`,
);
return null;
});
if (applied === null) return EXIT_FAILED;
process.stdout.write(
`${JSON.stringify({ ...applied, apply: true }, null, 2)}\n`,
);
return 0;
}
if (command === "service-uninstall") {
const options = parseServiceOptions(arguments_.slice(1), true);
if (!options) return usage();
const platform = options.values.get("platform") ?? process.platform;
if (platform !== "darwin" && platform !== "linux" && platform !== "win32") {
return usage();
}
const home = options.values.get("home") ?? os.homedir();
const installRoot = options.values.get("install-root");
if (!installRoot) return usage();
const dataDir = serviceDataDirectory(
options.values.get("data-dir"),
environment,
platform,
);
if (dataDir === null) return usage();
const uidText = options.values.get("uid");
const uid = uidText === undefined ? process.getuid?.() : Number(uidText);
const layout = runtimeInstallLayout({
platform,
home,
installRoot: canonicalManagedServiceRoot(platform, installRoot),
dataDir,
...hourlyIsolationFromEnvironment(environment),
});
const plan = planUserService(layout, {
uid,
xdgConfigHome: options.values.get("xdg-config-home"),
});
if (!options.confirmed) {
process.stdout.write(
`${JSON.stringify({ ...plan, apply: false }, null, 2)}\n`,
);
return plan.status === "ready" ? 0 : 4;
}
const result = await uninstallServicePreservingData(layout, {
uid,
xdgConfigHome: options.values.get("xdg-config-home"),
// `memhub stop` already booted the label out; booting it out again is the
// desired end state, not a failure. Without this the plist would be left
// behind and block the next install.
...(layout.platform === "darwin"
? {
runCommand: createTolerantLaunchctlRunner(runLaunchctlCaptured),
}
: {}),
}).catch((error: unknown) => {
// Mirror install/upgrade: a refused uninstall must be a structured
// error, not a bare throw, so the caller can tell a protected-file
// refusal from a crash and gets a next step.
process.stderr.write(
`${JSON.stringify(
serviceErrorDocument("service-uninstall", error),
null,
2,
)}\n`,
);
return null;
});
if (result === null) return EXIT_FAILED;
await removeRuntimeServiceOwner(layout.dataDir).catch(() => undefined);
process.stdout.write(
`${JSON.stringify({ ...plan, apply: true, result }, null, 2)}\n`,
);
return 0;
}
if (command === "install") {
const parsed = parseInstallOptions(
arguments_.slice(1),
environment,
process.argv[1],
);
if (!parsed) return usage();
const appCli = packagedAppCliPath(process.argv[1], environment);
if (appCli) {
const binDirectory = path.resolve(
parsed.binDirectory ?? path.join(parsed.home, ".local", "bin"),
);
const baseLink = {
schema_version: 1,
platform: "darwin",
home: path.resolve(parsed.home),
install_root: "/Applications/MemHub.app",
bin_directory: binDirectory,
status: "create",
blocked_reason: null,
link_path: path.join(binDirectory, "memhub"),
target_path: appCli,
} as const;
const links = await Promise.all(
OBJECTIVE_CLI_ALIASES.map((name) => planManagedCliLink(baseLink, name)),
);
const blocked = links.find((link) => link.status === "blocked");
if (!parsed.confirmed) {
process.stdout.write(
`${JSON.stringify({ cli: links, apply: false }, null, 2)}\n`,
);
return blocked ? EXIT_PLAN_BLOCKED : EXIT_OK;
}
if (blocked) return EXIT_PLAN_BLOCKED;
const receipts: Awaited<ReturnType<typeof installManagedCliLink>>[] = [];
try {
for (const link of links) {
receipts.push(await installManagedCliLink(link));
}
process.stdout.write(
`${JSON.stringify({ cli: receipts, apply: true }, null, 2)}\n`,
);
return EXIT_OK;
} catch (error) {
for (const receipt of receipts.reverse()) {
await rollbackManagedCliLink(receipt).catch(() => undefined);
}
process.stderr.write(
`${JSON.stringify(serviceErrorDocument("install", error), null, 2)}\n`,
);
return EXIT_FAILED;
}
}
if (localExperienceMode) {
const expectedInstallRoot = defaultInstallRoot(
process.platform,
parsed.home,
environment.LOCALAPPDATA,
hourlyIsolationFromEnvironment(environment).hourlyPackageId,
);
if (
path.resolve(parsed.installRoot) !== path.resolve(expectedInstallRoot)
) {
return emitLocalExperienceUnavailable(
"install",
parsed.confirmed,
asJson,
);
}
const plan = await planLocalInstallOrError(
"install",
asJson,
parsed.source,
parsed.installRoot,
parsed.allowDowngrade,
);
if (plan === null) return EXIT_FAILED;
const unavailable = {
status: "blocked",
code: "local_experience_operation_unavailable",
reason: "体验版暂不支持",
};
if (!parsed.confirmed) {
process.stdout.write(
`${JSON.stringify({ distribution: plan, cli: unavailable, cli_aliases: unavailable, memhub_cli: unavailable, shell_path: unavailable, service: unavailable, apply: false }, null, 2)}\n`,
);
return EXIT_OK;
}
const receipt = await applyLocalInstall(plan).catch((error: unknown) => {
process.stderr.write(
`${JSON.stringify(serviceErrorDocument("install", error), null, 2)}\n`,
);
return null;
});
if (receipt === null) return EXIT_FAILED;
process.stdout.write(
`${JSON.stringify({ distribution: receipt, cli: unavailable, cli_aliases: unavailable, memhub_cli: unavailable, shell_path: unavailable, service: unavailable, apply: true }, null, 2)}\n`,
);
return EXIT_OK;
}
// Only persist the receipt when the home is explicitly provided. Tests and
// embedders pass `{}` with no `--home`; writing to the ambient
// `os.homedir()` there would touch the developer's real `~/.one-memory`.
const explicitHome =
environment.HOME !== undefined || arguments_.includes("--home");
const sourceSelection = await selectInstallSource(
parsed,
environment,
).catch((error: unknown) => {
process.stderr.write(
`${JSON.stringify(serviceErrorDocument("install", error), null, 2)}\n`,
);
return null;
});
if (sourceSelection === null) return EXIT_FAILED;
if (sourceSelection.kind === "remote" && !parsed.confirmed) {
const resolved = await resolveRemoteInstallPreview(
sourceSelection,
controls,
).catch((error: unknown) => {
process.stderr.write(
`${JSON.stringify(serviceErrorDocument("install", error), null, 2)}\n`,
);
return null;
});
if (resolved === null) return EXIT_FAILED;
process.stdout.write(
`${JSON.stringify({ release: remoteReleasePlan(resolved), install_root: parsed.installRoot, data_dir: parsed.dataDir ?? path.join(parsed.home, ".one-memory"), data_preserved: true, apply: false }, null, 2)}\n`,
);
return EXIT_OK;
}
await using acquired =
sourceSelection.kind === "remote"
? await acquireRemoteInstallSource(sourceSelection, controls).catch(
(error: unknown) => {
process.stderr.write(
`${JSON.stringify(serviceErrorDocument("install", error), null, 2)}\n`,
);
return null;
},
)
: null;
if (sourceSelection.kind === "remote" && acquired === null) {
return EXIT_FAILED;
}
const installSource =
sourceSelection.kind === "local"
? sourceSelection.source
: acquired!.distribution;
const plan = await planLocalInstallOrError(
"install",
asJson,
installSource,
parsed.installRoot,
parsed.allowDowngrade,
);
if (plan === null) return EXIT_FAILED;
const layout = runtimeInstallLayout({
platform: plan.platform,
home: parsed.home,
installRoot: canonicalManagedServiceRoot(
plan.platform,
plan.current_link,
),
dataDir: parsed.dataDir,
...hourlyIsolationFromEnvironment(environment),
});
const cliLink = await planCliLink({
home: parsed.home,
installRoot: plan.install_root,
binDirectory: parsed.binDirectory,
platform: plan.platform,
});
const companionLinks = await planCliCompanionLinks(cliLink);
const memhubLink =
companionLinks.find(
(link) => path.posix.basename(link.link_path) === "memhub",
) ?? null;
const blockedCompanion = companionLinks.find(
(link) => link.status === "blocked",
);
// "装完即短指令可用": make the managed bin directory reachable from a new
// shell. This is part of the same preview/apply transaction as the links
// and service, so a user sees it before `--yes` and it rolls back with the
// rest on failure.
const shellPath = await planShellPath({
binDirectory: cliLink.bin_directory,
home: parsed.home,
platform: plan.platform,
// `--no-shell-path` keeps memhub out of the login profile entirely;
// `--shell-profile` redirects the block to a caller-named file so an
// isolated install never edits the real `~/.zprofile`/`~/.profile`.
// An explicit `--home` (or a HOME that is not the real account home)
// marks an isolated/sandboxed install: writing the login profile there
// would touch host configuration the caller never asked us to modify, so
// the block is skipped unless the caller names a profile explicitly.
...(parsed.shellPathDisabled
? { disabled: true }
: parsed.shellProfile
? { profilePath: parsed.shellProfile }
: isIsolatedHome(parsed.home)
? { disabled: true }
: {}),
});
const serviceOptions = {
uid: parsed.uid,
xdgConfigHome: parsed.xdgConfigHome,
};
const service = planUserService(layout, serviceOptions);
if (!parsed.confirmed) {
process.stdout.write(
`${JSON.stringify({ distribution: plan, cli: cliLink, cli_aliases: companionLinks, memhub_cli: memhubLink, shell_path: shellPath, service, apply: false }, null, 2)}\n`,
);
return service.status === "ready" &&
cliLink.status !== "blocked" &&
blockedCompanion === undefined &&
shellPath.status !== "blocked"
? 0
: 4;
}
if (
service.status !== "ready" ||
cliLink.status === "blocked" ||
blockedCompanion !== undefined ||
shellPath.status === "blocked"
)
return 4;
const preparedService = await prepareServiceInstall(
layout,
serviceOptions,
).catch((error: unknown) => {
process.stderr.write(
`${JSON.stringify(serviceErrorDocument("install", error), null, 2)}\n`,
);
return null;
});
if (preparedService === null) return EXIT_FAILED;
// `applyLocalInstall` re-verifies the plan and can throw at apply time
// (unmanaged `current`, source drift). Report that as the structured
// envelope, not a bare thrown string.
const receipt = await applyLocalInstall(plan).catch((error: unknown) => {
process.stderr.write(
`${JSON.stringify(serviceErrorDocument("install", error), null, 2)}\n`,
);
return null;
});
if (receipt === null) return EXIT_FAILED;
let cliReceipt: Awaited<ReturnType<typeof installCliLink>> | null = null;
const companionReceipts: Array<
Awaited<ReturnType<typeof installManagedCliLink>>
> = [];
let shellPathReceipt: Awaited<ReturnType<typeof applyShellPath>> | null =
null;
let installReceiptWritten = false;
let serviceApplied = false;
let migrated = false;
// `null` until the migration is handed this home; "failed" if it threw.
let migrationState: SdkMigrationResult["state"] | "failed" | null = null;
let deferredCode: string | null = null;
let deferredRestored = false;
let previousOwner:
| Awaited<ReturnType<typeof claimCliServiceOwnership>>
| undefined;
try {
cliReceipt = await installCliLink(cliLink);
for (const link of companionLinks) {
companionReceipts.push(await installManagedCliLink(link));
}
shellPathReceipt = await applyShellPath(shellPath);
previousOwner = await claimCliServiceOwnership(layout.dataDir);
// `install` over an existing home (reinstall, App-to-CLI takeover) must
// migrate it too, before the new Runtime starts serving it.
migrationState = "failed";
const migration = await (
controls.migrateSdkData ?? migrateSdkDataBeforeStart
)({
coreCommand: layout.coreCommand,
dataDir: layout.dataDir,
runtimeBuild: String(plan.build_number ?? plan.version),
asJson: true,
caller: "cli-install",
sourceRuntime: await readInstalledRuntimeSource(
plan.install_root,
receipt.previous_current_target,
),
});
migrationState = migration.state;
// A deferred migration keeps this install: the new Runtime service is
// registered and its migration gate keeps Core down until the home is
// migrated. Rolling back would hand the home to the previous (App or CLI)
// Runtime, which has no gate and would serve the legacy data.
if (migration.state === "deferred") {
deferredCode = migration.code ?? "sdk_migration_incomplete";
deferredRestored = migration.dataRestored === true;
}
migrated =
migration.state === "cutover" || migration.state === "complete";
// From here the previous LaunchAgent is never re-bootstrapped: it may
// be an older Runtime with no migration gate.
await applyPreparedServiceInstall({
...preparedService,
restorePreviousOnFailure: false,
});
serviceApplied = true;
// The receipt exists so a later command can find the data dir without
// flags. An isolated install named its data dir explicitly, and writing
// `<home>/.one-memory` would add a product entry to a HOME the caller
// does not own (the C7 matrix flags exactly that). Keep it for real
// installs where home is the account home.
if (explicitHome && !isIsolatedHome(parsed.home)) {
await writeInstallReceipt({
path: defaultInstallReceiptPath(parsed.home),
dataDir: layout.dataDir,
installRoot: plan.install_root,
home: parsed.home,
});
installReceiptWritten = true;
}
if (deferredCode !== null) throw new SdkMigrationDeferred(deferredCode);
// Registering the service is not the same as a working install: on a host
// where Core cold-start stalls, launchd reports a pid while status is
// still `runtime_service_running`. Gate on real readiness (same predicate
// as start/restart/upgrade); failure rolls the whole transaction back.
const installReady = await (
controls.waitForRuntimeReady ?? waitForRuntimeReady
)(layout.dataDir);
if (!installReady) {
throw new Error("install_runtime_not_ready");
}
} catch (error) {
// Reverse order for filesystem changes. The service itself is restored
// last: an App-owned service may have existed before this CLI takeover,
// and it must restart only after `current` and its ownership document
// point back to the original App Runtime.
const rollbackFilesAndOwner = async () => {
if (shellPathReceipt?.created) {
await removeShellPath(shellPath).catch(() => undefined);
}
if (installReceiptWritten) {
await removeInstallReceipt(
defaultInstallReceiptPath(parsed.home),
).catch(() => undefined);
}
for (const receipt of companionReceipts.reverse()) {
await rollbackManagedCliLink(receipt);
}
if (cliReceipt) await rollbackCliLink(cliReceipt);
await rollbackLocalInstall(receipt);
if (previousOwner !== undefined) {
await restoreServiceOwnership(layout.dataDir, previousOwner).catch(
() => undefined,
);
}
};
// Once the migration has been handed this home nothing rolls back: the
// previous version (App or CLI) may be an older Runtime without the
// migration gate, and restoring it could serve a home that is not on SDK
// authority. This install stays; its service keeps Core down until the
// home is migrated. Only a failure before the migration ran (the
// previous Runtime was never stopped and the service never touched) is
// rolled back below.
if (migrationState !== null) {
if (!serviceApplied && !migrated && deferredCode === null) {
// The migration threw. Hand the data dir to this version's gated
// service anyway so that the previous Runtime does not come back.
await applyPreparedServiceInstall({
...preparedService,
restorePreviousOnFailure: false,
}).catch(() => undefined);
}
process.stderr.write(
`${JSON.stringify(
deferredCode !== null
? sdkMigrationFailure(
"install",
deferredCode,
layout.dataDir,
"upgrade",
deferredRestored,
)
: migrated
? notReadyAfterMigration("install")
: migrationState === "failed"
? sdkMigrationFailure(
"install",
sdkMigrationErrorCode(error),
layout.dataDir,
)
: notReadyAfterUpgrade("install", error),
null,
2,
)}\n`,
);
return EXIT_FAILED;
}
if (serviceApplied) {
await rollbackPreparedServiceInstall(
preparedService,
rollbackFilesAndOwner,
).catch(() => undefined);
} else {
await rollbackFilesAndOwner();
}
process.stderr.write(
`${JSON.stringify(serviceErrorDocument("install", error), null, 2)}\n`,
);
return EXIT_FAILED;
}
process.stdout.write(
`${JSON.stringify({ distribution: plan, cli: cliLink, cli_aliases: companionLinks, memhub_cli: memhubLink, shell_path: shellPath, service, apply: true }, null, 2)}\n`,
);
return 0;
}
if (command === "uninstall") {
const parsed = parseInstallOptions(
arguments_.slice(1),
environment,
process.argv[1],
);
if (!parsed) return usage();
const appCli = packagedAppCliPath(process.argv[1], environment);
if (appCli) {
const binDirectory = path.resolve(
parsed.binDirectory ?? path.join(parsed.home, ".local", "bin"),
);
const baseLink = {
schema_version: 1,
platform: "darwin",
home: path.resolve(parsed.home),
install_root: "/Applications/MemHub.app",
bin_directory: binDirectory,
status: "create",
blocked_reason: null,
link_path: path.join(binDirectory, "memhub"),
target_path: appCli,
} as const;
const links = await Promise.all(
OBJECTIVE_CLI_ALIASES.map((name) => planManagedCliLink(baseLink, name)),
);
const blocked = links.find((link) => link.status === "blocked");
if (!parsed.confirmed) {
process.stdout.write(
`${JSON.stringify({ command: "uninstall", state: "preview", cli: links, data_preserved: true, apply: false }, null, 2)}\n`,
);
return blocked ? EXIT_PLAN_BLOCKED : EXIT_OK;
}
if (blocked) return EXIT_PLAN_BLOCKED;
const results = [];
for (const link of links) {
results.push({
link_path: link.link_path,
result: await removeManagedCliLink(link),
});
}
process.stdout.write(
`${JSON.stringify({ command: "uninstall", state: "cli_links_removed", cli: results, data_preserved: true, apply: true, next_step: "如需卸载 App,请将 MemHub 拖到废纸篓。" }, null, 2)}\n`,
);
return EXIT_OK;
}
// Narrowed explicitly rather than trusting `process.platform`: the managed
// layout only exists for these three, and removing files on anything else
// would be guessing about paths we never wrote.
const platform = process.platform;
if (platform !== "darwin" && platform !== "linux" && platform !== "win32") {
process.stdout.write(
`${JSON.stringify({ status: "blocked", reason: "platform_unsupported" }, null, 2)}\n`,
);
return EXIT_FAILED;
}
if (localExperienceMode) {
return emitLocalExperienceUnavailable(
"uninstall",
parsed.confirmed,
asJson,
);
}
const layout = runtimeInstallLayout({
platform,
home: parsed.home,
installRoot: canonicalManagedServiceRoot(platform, parsed.installRoot),
dataDir: parsed.dataDir,
...hourlyIsolationFromEnvironment(environment),
});
const cliLink = await planCliLink({
home: parsed.home,
installRoot: parsed.installRoot,
binDirectory: parsed.binDirectory,
platform,
});
// Uninstall must clean every objective alias that is not the primary link.
// `applyLocalUninstall` deletes only `plan.cli_link`, so the compat
// `one-memory` link it created would otherwise dangle on PATH.
const companionLinks = await planCliRemovalLinks(cliLink);
const memhubLink =
companionLinks.find(
(link) => path.posix.basename(link.link_path) === "memhub",
) ?? null;
const blockedCompanion = companionLinks.find(
(link) => link.status === "blocked",
);
const shellPath = await planShellPath({
binDirectory: cliLink.bin_directory,
home: parsed.home,
platform,
});
const service = planUserService(layout, {
uid: parsed.uid,
xdgConfigHome: parsed.xdgConfigHome,
});
const plan = await planLocalUninstall({
installRoot: parsed.installRoot,
home: parsed.home,
dataDir: parsed.dataDir ?? null,
cliLinkPath: cliLink.link_path,
stopsService: service.status === "ready",
// Without this, a data dir that happens to live under
// `<install-root>/versions/` made the safety guard refuse uninstall with
// no in-product way out. The flag is a per-invocation statement of what
// this caller is protecting; data is still never deleted here.
...(parsed.preserveDataDir ? { preserveDataDir: true } : {}),
});
if (!parsed.confirmed) {
process.stdout.write(
`${JSON.stringify({ ...plan, cli_aliases: companionLinks, memhub_cli: memhubLink, shell_path: shellPath, apply: false }, null, 2)}\n`,
);
return blockedCompanion !== undefined ? EXIT_PLAN_BLOCKED : EXIT_OK;
}
if (blockedCompanion !== undefined) {
return EXIT_PLAN_BLOCKED;
}
// The service is stopped first: removing files from under a running daemon
// leaves a service that cannot start and does not say why.
if (plan.stops_service) {
await uninstallServicePreservingData(layout, {
uid: parsed.uid,
xdgConfigHome: parsed.xdgConfigHome,
// Same as `service-uninstall`: `stop` may have already booted the
// label out, and that is the desired end state.
...(layout.platform === "darwin"
? {
runCommand: createTolerantLaunchctlRunner(runLaunchctlCaptured),
}
: {}),
});
}
const result = await applyLocalUninstall(plan);
await removeRuntimeServiceOwner(layout.dataDir).catch(() => undefined);
// Runtime-control removes its primary link (`cli_link`) but, in its
// `01eedd8` shape, leaves the `one-memory` compat link behind. Remove it
// here so uninstall does not leave a dangling link; when the package does
// not declare a compat link, the CLI removes its own companion instead.
// Remove every objective alias the CLI may have created; runtime-control's
// own link(s) were already removed by `applyLocalUninstall`. A not-installed
// alias is not an error — installs predating `memhub-cli` never had it.
const companionResults = await Promise.all(
companionLinks.map(async (link) => ({
link_path: link.link_path,
result: await removeManagedCliLink(link).catch(() => "not-installed"),
})),
);
// The profile block is ours to remove; data is never touched here.
const shellPathResult = await removeShellPath(shellPath);
const installReceiptResult = await removeInstallReceipt(
defaultInstallReceiptPath(parsed.home),
);
process.stdout.write(
`${JSON.stringify({ ...plan, cli_aliases: companionLinks, memhub_cli: memhubLink, shell_path: shellPath, apply: true, result, cli_alias_results: companionResults, shell_path_result: shellPathResult, install_receipt_result: installReceiptResult }, null, 2)}\n`,
);
return 0;
}
if (command === "delete-data") {
const parsed = parseDataDeletionCommand(arguments_.slice(1));
if (!parsed) return usage();
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
const dataDir = parsed.dataDir ?? config.dataDir;
const status = await inspectRuntimeStatus({
dataDir,
probeIdentity,
requireServiceReceipt: true,
});
const plan = await planDataDeletion({
dataDir,
home: environment.HOME ?? os.homedir(),
runtimeRunning: status.state === "running",
});
if (!parsed.confirmed) {
// The preview names the exact directory; `--yes` deletes it.
process.stdout.write(
`${JSON.stringify(
{
...plan,
apply: false,
...(plan.state === "preview"
? {
next_step: `确认要永久删除这个数据目录后运行(--confirm 必须再写一遍同一个目录):${PRODUCT} delete-data --data-dir ${dataDir} --yes --confirm ${dataDir}`,
}
: {}),
},
null,
2,
)}\n`,
);
return plan.state === "preview" ? EXIT_OK : EXIT_PLAN_BLOCKED;
}
if (plan.state !== "preview") {
process.stdout.write(
`${JSON.stringify({ ...plan, apply: false }, null, 2)}\n`,
);
return EXIT_PLAN_BLOCKED;
}
if (parsed.confirmPath === undefined) {
// Deleting every memory is the one action with no way back: `--yes`
// alone is not enough, the directory has to be typed a second time.
process.stdout.write(
`${JSON.stringify(
{
...plan,
state: "blocked",
apply: false,
code: "data_deletion_confirmation_required",
next_step: `删除需要再写一遍数据目录:${PRODUCT} delete-data --data-dir ${plan.data_dir} --yes --confirm ${plan.data_dir}`,
},
null,
2,
)}\n`,
);
return EXIT_PLAN_BLOCKED;
}
try {
// `--confirm <path>` must name this exact directory.
const receipt = await applyDataDeletion(plan, parsed.confirmPath);
await removeInstallReceipt(
defaultInstallReceiptPath(environment.HOME ?? os.homedir()),
);
process.stdout.write(
`${JSON.stringify({ ...receipt, apply: true }, null, 2)}\n`,
);
return EXIT_OK;
} catch (error) {
process.stdout.write(
`${JSON.stringify(
{
schema_version: 1,
product: PRODUCT,
command: "delete-data",
status: "error",
code:
error instanceof Error ? error.message : "data_deletion_failed",
exit_code: EXIT_FAILED,
next_step: "未完成删除;请确认目标目录后重试。",
},
null,
2,
)}\n`,
);
return EXIT_FAILED;
}
}
if (command === "upgrade") {
const parsed = parseInstallOptions(
arguments_.slice(1),
environment,
process.argv[1],
);
if (!parsed) return usage();
const explicitHome =
environment.HOME !== undefined || arguments_.includes("--home");
const sourceSelection = await selectInstallSource(
parsed,
environment,
).catch((error: unknown) => {
process.stderr.write(
`${JSON.stringify(serviceErrorDocument("upgrade", error), null, 2)}\n`,
);
return null;
});
if (sourceSelection === null) return EXIT_FAILED;
if (sourceSelection.kind === "remote" && !parsed.confirmed) {
const resolved = await resolveRemoteInstallPreview(
sourceSelection,
controls,
).catch((error: unknown) => {
process.stderr.write(
`${JSON.stringify(serviceErrorDocument("upgrade", error), null, 2)}\n`,
);
return null;
});
if (resolved === null) return EXIT_FAILED;
process.stdout.write(
`${JSON.stringify({ release: remoteReleasePlan(resolved), install_root: parsed.installRoot, data_dir: parsed.dataDir ?? path.join(parsed.home, ".one-memory"), data_preserved: true, apply: false }, null, 2)}\n`,
);
return EXIT_OK;
}
await using acquired =
sourceSelection.kind === "remote"
? await acquireRemoteInstallSource(sourceSelection, controls).catch(
(error: unknown) => {
process.stderr.write(
`${JSON.stringify(serviceErrorDocument("upgrade", error), null, 2)}\n`,
);
return null;
},
)
: null;
if (sourceSelection.kind === "remote" && acquired === null) {
return EXIT_FAILED;
}
const installSource =
sourceSelection.kind === "local"
? sourceSelection.source
: acquired!.distribution;
const plan = await planLocalInstallOrError(
"upgrade",
asJson,
installSource,
parsed.installRoot,
parsed.allowDowngrade,
);
if (plan === null) return EXIT_FAILED;
const layout = runtimeInstallLayout({
platform: plan.platform,
home: parsed.home,
installRoot: canonicalManagedServiceRoot(
plan.platform,
plan.current_link,
),
dataDir: parsed.dataDir,
...hourlyIsolationFromEnvironment(environment),
});
const cliLink = await planCliLink({
home: parsed.home,
installRoot: plan.install_root,
binDirectory: parsed.binDirectory,
platform: plan.platform,
});
const companionLinks = await planCliCompanionLinks(cliLink);
const memhubLink =
companionLinks.find(
(link) => path.posix.basename(link.link_path) === "memhub",
) ?? null;
const blockedCompanion = companionLinks.find(
(link) => link.status === "blocked",
);
const serviceOptions = {
uid: parsed.uid,
xdgConfigHome: parsed.xdgConfigHome,
};
const service = planUserService(layout, serviceOptions);
if (!parsed.confirmed) {
process.stdout.write(
`${JSON.stringify({ distribution: plan, cli: cliLink, cli_aliases: companionLinks, memhub_cli: memhubLink, service, apply: false }, null, 2)}\n`,
);
return service.status === "ready" &&
cliLink.status === "present" &&
blockedCompanion === undefined
? 0
: 4;
}
if (
service.status !== "ready" ||
cliLink.status !== "present" ||
blockedCompanion !== undefined
)
return 4;
const receipt = await applyLocalInstall(plan).catch((error: unknown) => {
process.stderr.write(
`${JSON.stringify(serviceErrorDocument("upgrade", error), null, 2)}\n`,
);
return null;
});
if (receipt === null) return EXIT_FAILED;
const memhubReceipts: Array<
Awaited<ReturnType<typeof installManagedCliLink>>
> = [];
let previousOwner:
| Awaited<ReturnType<typeof claimCliServiceOwnership>>
| undefined;
let migrated = false;
// `null` until the migration is handed this home; "failed" if it threw.
let migrationState: SdkMigrationResult["state"] | "failed" | null = null;
/**
* Restore the previous version's files and ownership. Only used for a
* failure before the migration ran: the previous Runtime was never stopped
* and the service never restarted, so nothing new starts serving. After
* the migration has been handed the home the upgrade never rolls back:
* the previous Runtime may predate the migration gate and would serve a
* home that is not on SDK authority.
*/
const rollbackUpgrade = async (): Promise<void> => {
for (const receipt of memhubReceipts.reverse()) {
await rollbackManagedCliLink(receipt);
}
await rollbackLocalInstall(receipt);
if (previousOwner !== undefined) {
await restoreServiceOwnership(layout.dataDir, previousOwner).catch(
() => undefined,
);
}
};
/**
* After a migration that did not finish, make sure the process holding the
* data dir is the new version's service (gated: it keeps Core down while
* the migration is deferred) and not the previous Runtime. The migration
* fence already stopped it unless the status probe itself failed.
*/
const keepUpgradedRuntimeOffLegacyHome = async (): Promise<void> => {
try {
await restartUserService(layout, serviceOptions);
} catch {
await (controls.stopRuntime ?? stopRuntimeProcess)({
dataDir: parsed.dataDir ?? layout.dataDir,
probeIdentity,
}).catch(() => undefined);
}
};
try {
for (const link of companionLinks) {
memhubReceipts.push(await installManagedCliLink(link));
}
previousOwner = await claimCliServiceOwnership(layout.dataDir);
migrationState = "failed";
const migration = await (
controls.migrateSdkData ?? migrateSdkDataBeforeStart
)({
coreCommand: layout.coreCommand,
dataDir: parsed.dataDir ?? layout.dataDir,
runtimeBuild: String(plan.build_number ?? plan.version),
asJson,
caller: "cli-upgrade",
sourceRuntime: await readInstalledRuntimeSource(
plan.install_root,
receipt.previous_current_target,
),
});
migrationState = migration.state;
if (migration.state === "deferred") {
// Stay on the new version. Its Runtime service refuses to spawn Core
// while the migration is deferred, so nothing serves the legacy home;
// rolling back would restart the old Runtime, which has no such gate.
await keepUpgradedRuntimeOffLegacyHome();
process.stderr.write(
`${JSON.stringify(sdkMigrationFailure("upgrade", migration.code ?? "sdk_migration_incomplete", parsed.dataDir ?? layout.dataDir, "upgrade", migration.dataRestored === true), null, 2)}\n`,
);
return EXIT_FAILED;
}
// From here a failure keeps the new version: an older Runtime either
// refuses a migrated home or, predating the migration gate, would serve
// one that is not on SDK authority.
migrated =
migration.state === "cutover" || migration.state === "complete";
await restartUserService(layout, serviceOptions);
// A restart that returns without error only means launchd/systemd
// accepted the command. O-22: the first `upgrade` implementation
// reported success even when the new artifact crash-looped, leaving
// `current` on a version that never came up. Verify the restarted
// Runtime actually becomes healthy; if it does not, fail loudly. It is
// not rolled back (product rule: SDK authority or not serving at all).
const dataDir = parsed.dataDir ?? layout.dataDir;
const ready = await (controls.waitForRuntimeReady ?? waitForRuntimeReady)(
dataDir,
);
if (!ready && migrated) {
process.stderr.write(
`${JSON.stringify(notReadyAfterMigration("upgrade"), null, 2)}\n`,
);
return EXIT_FAILED;
}
if (!ready) {
// Not rolled back: see `rollbackUpgrade`. If the service is blocked on
// the migration (it re-runs it before spawning Core), say so.
const blocked = await runtimeMigrationBlock(dataDir, "upgrade");
process.stderr.write(
`${JSON.stringify(blocked ?? notReadyAfterUpgrade("upgrade"), null, 2)}\n`,
);
return EXIT_FAILED;
}
if (explicitHome) {
await writeInstallReceipt({
path: defaultInstallReceiptPath(parsed.home),
dataDir: layout.dataDir,
installRoot: plan.install_root,
home: parsed.home,
});
}
} catch (error) {
if (migrationState === null) {
await rollbackUpgrade();
process.stderr.write(
`${JSON.stringify(serviceErrorDocument("upgrade", error), null, 2)}\n`,
);
return EXIT_FAILED;
}
if (migrated) {
// An older Runtime refuses a migrated home: keep the new version.
process.stderr.write(
`${JSON.stringify(notReadyAfterMigration("upgrade"), null, 2)}\n`,
);
return EXIT_FAILED;
}
if (migrationState === "failed") {
// The migration itself threw: its outcome is unknown, so treat the
// home as not migrated and keep the old Runtime off it.
await keepUpgradedRuntimeOffLegacyHome();
process.stderr.write(
`${JSON.stringify(sdkMigrationFailure("upgrade", sdkMigrationErrorCode(error), parsed.dataDir ?? layout.dataDir), null, 2)}\n`,
);
return EXIT_FAILED;
}
// `not_required`: the restart itself failed. Stay on the new version.
process.stderr.write(
`${JSON.stringify(notReadyAfterUpgrade("upgrade", error), null, 2)}\n`,
);
return EXIT_FAILED;
}
process.stdout.write(
`${JSON.stringify({ distribution: plan, cli: cliLink, cli_aliases: companionLinks, memhub_cli: memhubLink, service, apply: true }, null, 2)}\n`,
);
return 0;
}
return usage();
}
export function packagedAppCliPath(
executable: string | undefined,
environment: NodeJS.ProcessEnv,
): string | null {
const candidate =
environment.ONE_MEMORY_TEST_ISOLATED === "1" &&
environment.ONE_MEMORY_APP_BUNDLE_CLI
? environment.ONE_MEMORY_APP_BUNDLE_CLI
: executable;
const isolatedAppFixture =
environment.ONE_MEMORY_TEST_ISOLATED === "1" &&
environment.ONE_MEMORY_APP_BUNDLE_CLI !== undefined;
if (!candidate || (process.platform !== "darwin" && !isolatedAppFixture)) {
return null;
}
const resolved = path.resolve(candidate);
return resolved ===
"/Applications/MemHub.app/Contents/Resources/cli/one-memory.cjs"
? resolved
: environment.ONE_MEMORY_TEST_ISOLATED === "1" &&
environment.ONE_MEMORY_APP_BUNDLE_CLI
? resolved
: null;
}
/** Delete a model profile, reporting *why* the server refused when it did.
*
* "ok" | "not_found" | "active" | "unavailable" — the middle two are actionable
* and must not collapse into a generic failure, or the user retries a command
* that cannot succeed.
*/
async function removeModelProfile(
dataDir: string,
profileId: string,
): Promise<"ok" | "not_found" | "active" | "unavailable"> {
const attachment = await attachToRuntime(dataDir);
if (attachment === null) return "unavailable";
try {
if (await deleteModelProfile(attachment, profileId)) return "ok";
// 404 (unknown id) and 409 (still active) both arrive here. Ask which,
// rather than guessing — a wrong message sends the user down the wrong path.
return await classifyRefusedRemoval(attachment, profileId);
} catch {
return "unavailable";
}
}
/** Distinguish "no such profile" from "profile is still the active one". */
async function classifyRefusedRemoval(
attachment: { host: string; port: number; apiToken: string },
profileId: string,
): Promise<"not_found" | "active"> {
try {
const response = await axios.get(
`http://${attachment.host}:${attachment.port}/api/v1/model-profiles`,
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 10_000,
proxy: false,
maxRedirects: 0,
validateStatus: () => true,
},
);
if (response.status === 200) {
const profiles = (response.data?.profiles ?? []) as Array<{
profile_id?: string;
is_active?: boolean;
}>;
const match = profiles.find((item) => item.profile_id === profileId);
return match?.is_active ? "active" : "not_found";
}
} catch {
// fall through
}
return "not_found";
}
/** `backup create`: ask Runtime to copy its main database.
*
* Only `one-memory.sqlite3` is copied (sources, queues, settings); the SDK
* memory store under `hy-memory/` is not part of it.
*/
async function applyBackupCreate(
asJson: boolean,
descriptor: CommandDescriptor | undefined,
environment: NodeJS.ProcessEnv,
parsed: { target: string },
): Promise<number> {
const attachment = await attachToRuntimeOrNull(environment);
if (attachment === null) return runtimeUnavailable(descriptor, asJson);
try {
const result = await submitBackup(attachment, parsed.target);
process.stdout.write(
asJson
? `${JSON.stringify({ schema_version: 1, product: PRODUCT, command: "backup", verb: "create", state: "committed", backup: result })}\n`
: `target: ${result.target}\nbytes: ${result.bytes}\n`,
);
return EXIT_OK;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Runtime refusing the target (e.g. `backup_target_outside_data_dir`) is a
// security rejection, not a malformed argument: the flags were fine.
const code = /^[a-z0-9_.:-]{1,128}$/.test(message)
? message
: "backup_failed";
const nextStep =
"备份目标必须是数据目录内的路径;要能用 backup restore 恢复,请放在 <数据目录>/backups/ 下。";
if (asJson) {
process.stdout.write(
`${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command: "backup",
verb: "create",
state: "rejected",
code,
exit_code: EXIT_FAILED,
next_step: nextStep,
})}\n`,
);
} else {
process.stderr.write(`备份失败:${code}\n下一步:${nextStep}\n`);
}
return EXIT_FAILED;
}
}
/** `backup restore`: replace the main database with a managed backup.
*
* Contract: docs/contracts/c0-ui-and-restore-contract.md (C0, H25). A restore
* overwrites data, so without `--yes` it only previews. It needs a *stopped*
* Runtime; Core refuses it from a runtime-owning process, so the CLI drives a
* non-owning maintenance Core.
*/
async function applyBackupRestore(
asJson: boolean,
environment: NodeJS.ProcessEnv,
controls: RunControls,
parsed: { source: string; confirmed: boolean; confirmPath?: string },
): Promise<number> {
const refuse = (code: string, nextStep: string, exitCode = EXIT_FAILED) => {
const document = {
schema_version: 1 as const,
product: PRODUCT,
command: "backup",
verb: "restore" as const,
state: "rejected" as const,
code,
next_step: nextStep,
};
emit(
asJson
? `${JSON.stringify(document)}\n`
: `backup restore 被拒绝:${code}\n下一步:${nextStep}\n`,
);
return exitCode;
};
if (!restoreConfirmationMatches(parsed.source, parsed.confirmPath)) {
return refuse(
"backup_restore_confirmation_mismatch",
`--confirm 必须与 --source 相同:${PRODUCT} backup restore --source ${parsed.source} --yes --confirm ${parsed.source}`,
);
}
if (!parsed.confirmed) {
return emitConfirmPreview(asJson, {
command: "backup restore",
target: parsed.source,
effect:
"用这份受管备份覆盖当前主库(来源、写入队列、配置);先自动做一份保护性备份。SDK 记忆库 hy-memory/ 不会被替换。需要 Runtime 已停止",
retry: `${PRODUCT} backup restore --source ${parsed.source} --yes --confirm ${parsed.source}`,
});
}
if (parsed.confirmPath === undefined) {
// Restoring overwrites the live database: the backup has to be named a
// second time, the same rule as delete-data.
return refuse(
"backup_restore_confirmation_required",
`恢复需要再写一遍备份路径:${PRODUCT} backup restore --source ${parsed.source} --yes --confirm ${parsed.source}`,
EXIT_PLAN_BLOCKED,
);
}
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
const status = await inspectRuntimeStatus({
dataDir: config.dataDir,
probeIdentity,
requireServiceReceipt: true,
});
if (status.state === "running") {
return refuse(
"backup_restore_runtime_running",
`先运行 ${PRODUCT} stop 停止 Runtime;恢复会覆盖现有数据,必须在停机时进行。`,
);
}
// A missing runtime receipt is not proof that no Runtime owns the data
// directory. If the service lease still names a live process, fail closed
// before starting a maintenance Core.
const lease = await inspectRuntimeServiceLock(config.dataDir);
if (lease !== "clear") {
return refuse(
"backup_restore_runtime_state_unknown",
"Runtime 身份无法确认(receipt 缺失但仍有服务锁)。请先运行 memhub stop 并确认进程退出后重试。",
);
}
const runMaintenance =
controls.runMaintenanceRestore ?? runMaintenanceRestore;
const restored = await runMaintenance({
coreCommand: config.coreCommand,
dataDir: config.dataDir,
source: parsed.source,
});
if (restored.state === "restored") {
const document = {
schema_version: 1 as const,
product: PRODUCT,
command: "backup",
verb: "restore" as const,
state: "restored" as const,
restored_bytes: restored.result.restoredBytes,
previous_backup_path: restored.result.previousBackupPath,
source_schema_version: restored.result.sourceSchemaVersion,
};
emit(
asJson
? `${JSON.stringify(document)}\n`
: `已恢复受管备份:${restored.result.restoredBytes} 字节;保护性备份 ${restored.result.previousBackupPath}\n`,
);
return EXIT_OK;
}
return refuse(
restored.code,
restored.code === "backup_restore_contract_unavailable"
? "当前 Core 不提供受管恢复端点;请升级到包含该合同的 Runtime。"
: "检查来源是否为 <数据目录>/backups/ 中的受管备份后重试。",
restored.code === "backup_restore_contract_unavailable"
? EXIT_PLAN_BLOCKED
: EXIT_FAILED,
);
}
/** `scan --source <id>`: ask Runtime to sync one source now. */
async function applyScan(
asJson: boolean,
descriptor: CommandDescriptor | undefined,
environment: NodeJS.ProcessEnv,
sourceId: string,
): Promise<number> {
const attachment = await attachToRuntimeOrNull(environment);
if (attachment === null) return runtimeUnavailable(descriptor, asJson);
const job = await submitScan(attachment, sourceId).catch((error: unknown) => {
const message = error instanceof Error ? error.message : "";
return {
failed: /^scan_rejected (http_\d+)$/.exec(message)?.[1] ?? "scan_failed",
};
});
if (job !== null && "failed" in job) {
return emitFailure(asJson, "scan", job.failed);
}
if (job === null) {
// The source cannot be synced — a property of the source, not a failure
// of the transport. Saying so keeps "not synced" distinct.
return emitFailure(
asJson,
"scan",
"live_sync_unavailable",
`来源 ${sourceId} 尚未开启持续同步或不支持增量同步。先运行 memhub sources list 确认来源已连接,再运行 memhub sync resume --source ${sourceId}。`,
);
}
process.stdout.write(
asJson
? `${JSON.stringify({ schema_version: 1, product: PRODUCT, command: "scan", state: "committed", scan: job })}\n`
: `job_id: ${job.job_id}\nsource_id: ${job.source_id}\nstatus: ${job.status}\n`,
);
return EXIT_OK;
}
type SourceConnectCommand = Extract<SourcesCommand, { kind: "ids" | "all" }>;
/**
* `sources connect`: connect sources directly.
*
* - With `--import`: one `/sources/connect` (grant, history import and live
* sync); the answer carries the request id that `sources receipt` reads.
* - Without it: grant each source only (`/sources/{id}/authorize`). Processing
* stays frozen: nothing is imported and new sessions are not scanned until
* `sync resume` or a later `connect --import`.
*
* A source whose location comes from an environment override is only
* connected with `--confirm-overrides`.
*/
async function connectSources(
parsed: SourceConnectCommand,
asJson: boolean,
environment: NodeJS.ProcessEnv,
): Promise<number> {
const connected = await connectRuntime(environment);
if (!connected.ok) {
emitFlowDocument(
{
schema_version: 1,
product: PRODUCT,
state: "unknown",
code: connected.code,
},
asJson,
);
return connected.exitCode;
}
const { reader, operations } = connected;
if (parsed.kind === "all") {
const batch = await planBatchImport(reader);
if (batch.state !== "preview") {
const document: BatchScanDocument = {
schema_version: 1,
product: PRODUCT,
command: "sources connect",
state: batch.state === "no_sources" ? "no_sources" : batch.state,
request_id: null,
accepted_source_ids: [],
sources: [],
not_found_source_ids: batch.notFound,
code: batch.code,
};
emit(
asJson
? `${JSON.stringify(document)}\n`
: renderBatchScanText(document),
);
return batch.state === "no_sources" ? EXIT_OK : EXIT_FAILED;
}
if (parsed.importHistory) {
const document = await executeBatchImport(
batch.candidates,
reader,
operations,
batch.notFound,
{ confirmOverrides: parsed.confirmOverrides },
);
emit(
asJson
? `${JSON.stringify(document)}\n`
: renderBatchScanText(document),
);
return batchScanExitCode(document);
}
return await grantSources(
batch.candidates,
parsed.confirmOverrides,
operations,
asJson,
);
}
// Another user's Hermes profile is granted and imported in one connect
// request: only that request can carry a user and a root.
if (
parsed.importHistory ||
parsed.userId !== undefined ||
parsed.root !== undefined
) {
const document = await connectSourcesWithImport(reader, operations, {
sourceIds: parsed.sourceIds,
confirmOverrides: parsed.confirmOverrides,
...(parsed.requestId === undefined
? {}
: { requestId: parsed.requestId }),
...(parsed.userId === undefined ? {} : { userId: parsed.userId }),
...(parsed.root === undefined ? {} : { root: parsed.root }),
});
emitFlowDocument(document, asJson);
return flowExitCode(document);
}
const outcome = await reader<unknown>("/api/v1/sources");
const candidates =
outcome.state === "ok"
? validateSourceConnectionCandidates(outcome.data)
: null;
if (candidates === null) {
emitFlowDocument(
{
schema_version: 1,
product: PRODUCT,
state: outcome.state === "ok" ? "invalid" : "unknown",
code: outcome.state === "ok" ? "source_response_invalid" : outcome.code,
},
asJson,
);
return EXIT_FAILED;
}
const selected = parsed.sourceIds.map((id) =>
candidates.find((candidate) => candidate.source_id === id),
);
if (selected.some((candidate) => candidate === undefined)) {
emitFlowDocument(
{
schema_version: 1,
product: PRODUCT,
state: "invalid",
code: "source_unavailable",
},
asJson,
);
return EXIT_FAILED;
}
return await grantSources(
selected as SourceConnectionCandidate[],
parsed.confirmOverrides,
operations,
asJson,
);
}
/** Grant sources without importing: processing stays frozen. */
async function grantSources(
candidates: readonly SourceConnectionCandidate[],
confirmOverrides: boolean,
operations: CoreOperations,
asJson: boolean,
): Promise<number> {
const granted: string[] = [];
const failed: Record<string, string> = {};
const overrideRequired: string[] = [];
for (const candidate of candidates) {
if (candidate.requires_override_confirmation && !confirmOverrides) {
overrideRequired.push(candidate.source_id);
continue;
}
const result = await operations.post<unknown>(
`/api/v1/sources/${encodeURIComponent(candidate.source_id)}/authorize`,
{
user_confirmed: true,
expected_root_id: candidate.candidate_root_id,
confirm_environment_override:
confirmOverrides && candidate.requires_override_confirmation,
},
);
if (result.state === "committed") granted.push(candidate.source_id);
else failed[candidate.source_id] = result.code;
}
const failedCount = Object.keys(failed).length + overrideRequired.length;
const state =
failedCount === 0
? "committed"
: granted.length === 0
? "rejected"
: "partial";
const document = {
schema_version: 1,
product: PRODUCT,
command: "sources connect",
state,
import: false,
granted_source_ids: granted,
failed_sources: failed,
...(overrideRequired.length > 0
? {
override_required_source_ids: overrideRequired,
code:
granted.length === 0 && Object.keys(failed).length === 0
? "environment_override_confirmation_required"
: undefined,
}
: {}),
next_step:
"处理默认冻结:不导入历史、不扫描新会话。加 --import 重新运行即导入历史并开启持续同步,或用 memhub sync resume --source <id> 只开启持续同步。",
};
const lines = [
`已接入来源:${granted.join("、") || "无"}`,
"处理:已冻结(未导入历史,不扫描新会话)",
...Object.entries(failed).map(
([sourceId, code]) => `接入未完成:${sourceId}(${code})`,
),
...(overrideRequired.length > 0
? [
`未接入(目录来自环境变量覆盖,需确认):${overrideRequired.join("、")};确认后加 --confirm-overrides 重新运行。`,
]
: []),
`下一步:${PRODUCT} sources connect <id> --import 导入历史并开启持续同步。`,
];
emit(asJson ? `${JSON.stringify(document)}\n` : `${lines.join("\n")}\n`);
return state === "committed" ? EXIT_OK : EXIT_FAILED;
}
/** `models receipt <operation-id>`: a creation or a probe.
*
* Both are UUIDs minted by the CLI, so the id alone cannot say which. The
* creation receipt is read first; when Core does not know that id as a
* creation, the probe receipt is read.
*/
async function emitModelReceipt(
reader: CoreReader,
operationId: string,
asJson: boolean,
): Promise<number> {
const creation = await readModelReceipt(reader, operationId);
const unknownCreation =
creation.state === "not_found" ||
(creation.state === "unknown" &&
creation.code === "model_operation_not_found");
if (!unknownCreation) {
const document = { ...creation, operation: "model_profile_create" };
emit(asJson ? `${JSON.stringify(document)}\n` : renderFlowText(creation));
return flowExitCode(creation);
}
const probe = await readModelProbeReceipt(reader, operationId);
if (probe.state === "not_found") {
emit(
asJson
? `${JSON.stringify({ schema_version: 1, product: PRODUCT, state: "not_found", operation_id: operationId, code: "model_operation_not_found" })}\n`
: `未找到该操作:${operationId}\n`,
);
return EXIT_FAILED;
}
const document = { ...probe, operation: "model_profile_probe" };
emit(asJson ? `${JSON.stringify(document)}\n` : renderModelProbeText(probe));
return modelProbeExitCode(probe);
}
/** Attach to a verified Runtime, telling a stopped one (exit 3) from one
* that is up but unusable (exit 1). */
/**
* `memhub profile --versions`: the long-term profile's L0 version chains,
* latest first, so a value that replaced a better one can be found by id.
*/
async function profileVersionsCommand(
environment: NodeJS.ProcessEnv,
options: { asJson: boolean; userId?: string },
): Promise<number> {
const fail = (state: string, code: string, exitCode: number): number => {
emit(
options.asJson
? `${JSON.stringify({ schema_version: 2, product: PRODUCT, state, code })}\n`
: `画像版本读取失败:${code}\n`,
);
return exitCode;
};
const connected = await connectRuntime(environment, {
readerTimeoutMs: 15_000,
});
if (!connected.ok) {
return fail("unavailable", connected.code, connected.exitCode);
}
const outcome = await connected.reader<unknown>(
"/api/v2/user-profile/versions",
userIdParameters(options.userId),
);
const chains = outcome.state === "ok" ? profileChains(outcome.data) : null;
if (outcome.state !== "ok" || chains === null) {
return fail(
outcome.state === "ok" ? "invalid" : outcome.state,
outcome.state === "ok" ? "response_invalid" : outcome.code,
EXIT_FAILED,
);
}
if (options.asJson) {
emit(
`${JSON.stringify({
schema_version: 2,
product: PRODUCT,
state: "ok",
...(outcome.data as object),
})}\n`,
);
return EXIT_OK;
}
if (chains.length === 0) {
emit("尚未形成用户画像版本。\n");
return EXIT_OK;
}
const lines: string[] = [];
for (const chain of chains) {
lines.push(`画像 ${chain.memory_id}(${chain.status})`);
for (const version of chain.versions) {
const when =
typeof version.gmt_created === "number"
? new Date(version.gmt_created * 1000).toISOString()
: "时间未知";
lines.push(
` ${version.current ? "*" : " "} ${version.memory_id} ${when} ${version.content}`,
);
}
}
lines.push(
"* 表示当前生效的版本;删除错误版本:memhub memories delete <memory-id> --yes",
);
emit(`${lines.join("\n")}\n`);
return EXIT_OK;
}
type ProfileChainView = {
memory_id: string;
status: string;
versions: {
memory_id: string;
content: string;
gmt_created?: number | null;
current: boolean;
}[];
};
function profileChains(value: unknown): ProfileChainView[] | null {
if (!value || typeof value !== "object") return null;
const chains = (value as { chains?: unknown }).chains;
if (!Array.isArray(chains)) return null;
const valid = chains.every(
(chain) =>
chain &&
typeof chain === "object" &&
typeof (chain as ProfileChainView).memory_id === "string" &&
typeof (chain as ProfileChainView).status === "string" &&
Array.isArray((chain as ProfileChainView).versions) &&
(chain as ProfileChainView).versions.every(
(version) =>
version &&
typeof version.memory_id === "string" &&
typeof version.content === "string" &&
typeof version.current === "boolean",
),
);
return valid ? (chains as ProfileChainView[]) : null;
}
async function connectRuntime(
environment: NodeJS.ProcessEnv,
options?: { readerTimeoutMs?: number },
): Promise<
| {
ok: true;
dataDir: string;
reader: CoreReader;
operations: NonNullable<
Awaited<ReturnType<typeof attachClients>>["operations"]
>;
}
| { ok: false; exitCode: number; code: string }
> {
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
const runtime = await inspectRuntimeStatus({
dataDir: config.dataDir,
probeIdentity,
requireServiceReceipt: true,
});
if (runtime.state !== "running") {
return {
ok: false,
exitCode:
runtime.state === "stopped" ? EXIT_RUNTIME_STOPPED : EXIT_FAILED,
code: runtime.code,
};
}
const clients = await attachClients(config.dataDir, options);
if (!clients.reader || !clients.operations) {
return {
ok: false,
exitCode: EXIT_FAILED,
code: clients.failure ?? "runtime_attach_failed",
};
}
return {
ok: true,
dataDir: config.dataDir,
reader: clients.reader,
operations: clients.operations,
};
}
/** The verified attachment, or null when the Runtime is absent, unverified
* or its receipts are unreadable. */
async function attachToRuntimeOrNull(
environment: NodeJS.ProcessEnv,
): Promise<Awaited<ReturnType<typeof attachToRuntime>>> {
return attachToRuntime(
resolveRuntimeCliConfig(environment, process.argv[1]).dataDir,
).catch(() => null);
}
/** Core refused or failed a request whose arguments were fine: exit 1 with
* a machine-readable code, on stdout in JSON mode like every result. */
function emitFailure(
asJson: boolean,
command: string,
code: string,
nextStep?: string,
state: "failed" | "rejected" = "failed",
): number {
if (asJson) {
process.stdout.write(
`${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command,
state,
code,
exit_code: EXIT_FAILED,
...(nextStep === undefined ? {} : { next_step: nextStep }),
})}\n`,
);
} else {
process.stderr.write(
`${command} 未完成:${code}\n${nextStep === undefined ? "" : `下一步:${nextStep}\n`}`,
);
}
return EXIT_FAILED;
}
/**
* The one confirmation convention: an irreversible command without `--yes`
* says exactly what it would do, changes nothing, and exits 0.
*/
function emitConfirmPreview(
asJson: boolean,
options: {
command: string;
target: string;
effect: string;
retry: string;
schemaVersion?: 1 | 2;
},
): number {
emit(
asJson
? `${JSON.stringify({
schema_version: options.schemaVersion ?? 1,
product: PRODUCT,
command: options.command,
state: "preview",
target: options.target,
effect: options.effect,
applied: false,
next_step: options.retry,
})}\n`
: `将要执行:${options.effect}\n未执行任何改动。确认后运行:${options.retry}\n`,
);
return EXIT_OK;
}
function renderAdapterProbeText(sourceId: string, value: unknown): string {
const probe =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const fields = Object.entries(probe)
.filter(([, item]) => item === null || typeof item !== "object")
.map(([key, item]) => ` ${key}: ${String(item)}`);
return `来源 ${sourceId} 的探测结果:\n${fields.join("\n") || " (无字段)"}\n`;
}
function renderWriteReceiptText(receipt: MemoryWriteReceipt): string {
return [
`request_id: ${receipt.request_id}`,
`state: ${receipt.state}`,
...(receipt.raw_ids && receipt.raw_ids.length > 0
? [`raw_ids: ${receipt.raw_ids.join(",")}`]
: []),
...(receipt.memory_ids && receipt.memory_ids.length > 0
? [`memory_ids: ${receipt.memory_ids.join(",")}`]
: []),
...(receipt.error_code ? [`error_code: ${receipt.error_code}`] : []),
...(receipt.next_retry_at
? [`next_retry_at: ${receipt.next_retry_at}`]
: []),
"",
].join("\n");
}
function withQuery(pathname: string, parameters: Record<string, string>) {
const query = new URLSearchParams(parameters).toString();
return query ? `${pathname}?${query}` : pathname;
}
function namespaceFlags(namespace: MemoryNamespace): string {
return `${namespace.kind ? ` --kind ${namespace.kind}` : ""}${
namespace.variant ? ` --variant ${namespace.variant}` : ""
}`;
}
/** Poll until the Runtime at `dataDir` reports running, or the budget runs out.
*
* Used only after `upgrade` restarts the service. A crash-looping new artifact
* never answers `probeIdentity`, so this returns false and the caller rolls
* back. Bounded so a hard failure does not hang the command. */
/** Is this status proof the Runtime is actually serving?
*
* `runtime_verified` is the only code that means the receipts are published
* AND the identity probe succeeded, which is why it carries host/port. A
* launchd pid (`runtime_service_running`) or any other code is not readiness;
* treating one as readiness made `start` return in 0s while Core was still
* seconds from answering.
*/
export function runtimeIsReady(
status: {
state?: string;
code?: string;
host?: string;
port?: number;
} | null,
): boolean {
return (
status?.code === "runtime_verified" &&
status.host !== undefined &&
status.port !== undefined
);
}
const SDK_MIGRATION_STEPS: Record<string, string> = {
bundle: "正在备份记忆数据,数据较多时需要几分钟……",
prepare: "正在整理待处理的写入任务……",
cutover: "正在切换记忆数据源……",
cleanup: "正在清理旧数据表……",
};
/**
* Migrate a home written by an older release before the new Runtime serves it.
*
* Runs the new Runtime's own Core with the Runtime stopped. Progress goes to
* stderr so stdout stays one result document.
*/
async function migrateSdkDataBeforeStart(options: {
coreCommand: string;
dataDir: string;
runtimeBuild: string;
asJson: boolean;
caller?: "cli-install" | "cli-upgrade" | "cli-start" | "cli-restart";
sourceRuntime?: SdkMigrationSource | null;
}): Promise<SdkMigrationResult> {
return runSdkMigration({
dataDir: options.dataDir,
core: { command: options.coreCommand },
runtimeBuild: options.runtimeBuild,
...(options.caller === undefined ? {} : { caller: options.caller }),
...(options.sourceRuntime === undefined
? {}
: { sourceRuntime: options.sourceRuntime }),
onProgress: (step) => {
process.stderr.write(
options.asJson
? `${JSON.stringify({ schema_version: 1, product: PRODUCT, event: "sdk_migration.progress", step })}\n`
: `${SDK_MIGRATION_STEPS[step] ?? step}\n`,
);
},
});
}
class SdkMigrationDeferred extends Error {
constructor(readonly code: string) {
super(code);
}
}
function notReadyAfterMigration(command: "install" | "upgrade") {
return {
schema_version: 1,
product: PRODUCT,
command,
status: "error",
code: "runtime_not_ready_after_sdk_migration",
message:
"记忆数据已迁移到新版本,但新版本 Runtime 没有在限定时间内就绪。旧版本无法打开已迁移的数据,所以没有回滚。请运行 memhub restart,或 memhub doctor 查看原因。",
};
}
/**
* The new version did not come up and was not rolled back: the previous one
* may predate the SDK migration gate. `error` carries the underlying code
* (a failed service restart), never free text from a child process.
*/
function notReadyAfterUpgrade(command: "install" | "upgrade", error?: unknown) {
return {
schema_version: 1,
product: PRODUCT,
command,
status: "error",
code:
error !== undefined
? serviceErrorDocument(command, error).code
: command === "upgrade"
? "upgrade_health_check_failed"
: "install_runtime_not_ready",
rolled_back: false,
message:
"新版本 Runtime 没有启动或没有在限定时间内就绪。为避免旧版本打开记忆数据,已保留新版本,没有回滚。请运行 memhub restart 重试,或运行 memhub doctor 查看原因;如果仍然失败,请在 GitHub 提交反馈:" +
MIGRATION_ISSUES_URL,
issues_url: MIGRATION_ISSUES_URL,
};
}
const SDK_MIGRATION_FAILURES: Record<string, string> = {
sdk_migration_disk_space_insufficient:
"磁盘空间不足,记忆数据迁移没有完成。请腾出与记忆数据同等大小的空间后运行 memhub restart 重试。",
sdk_migration_memory_unreadable:
"核对发现新版本还读不到你的全部记忆,没有切换数据源。",
sdk_migration_model_profile_required:
"迁移需要先有可用的模型配置,用来核对新版本能否读回记忆。请先用 memhub models setup 完成配置,再运行 memhub restart 重试。",
sdk_migration_vector_profile_missing:
"记忆的向量来自一个已删除的模型配置,当前配置读不回它们,所以没有迁移。请重新添加并启用那个模型配置,再运行 memhub restart 重试。",
};
const MIGRATION_ISSUES_URL =
"https://github.com/Tencent-Hunyuan/Hy-Memory/issues/new";
/**
* Why MemHub is not serving: the home has not reached SDK authority.
*
* Product rule: after an upgrade the home is on SDK authority or MemHub does
* not serve it at all. Nothing here rolls back to an older Runtime (it has no
* migration gate and would serve the legacy home); the user gets the code, the
* migration log and where to report it.
*/
function sdkMigrationFailure(
command: string,
code: string,
dataDir: string,
context: "upgrade" | "blocked" = "upgrade",
dataRestored = false,
) {
const logFile = sdkMigrationLogPath(dataDir);
// Core put the home back exactly as it was before the upgrade, so the next
// (fixed) version migrates from the original data.
const restored = dataRestored ? "记忆数据已恢复到升级前的状态。" : "";
const reason =
SDK_MIGRATION_FAILURES[code] ??
"记忆数据迁移没有完成。可以运行 memhub restart 重试。";
const lead =
context === "upgrade"
? `${reason}已保留新版本;在迁移完成前 MemHub 不会启动,也不会用旧版本打开未迁移的数据。`
: `MemHub 没有启动:记忆数据还没有迁移到新版本(${code})。${reason}`;
return {
schema_version: 1,
product: PRODUCT,
command,
status: "error",
code,
message: `${lead}原有记忆数据不会丢失。${restored}迁移日志:${logFile}。请在 GitHub 提交反馈并附上这个日志文件:${MIGRATION_ISSUES_URL}`,
log_file: logFile,
issues_url: MIGRATION_ISSUES_URL,
...(dataRestored ? { data_restored: true } : {}),
};
}
/** A stable code from a thrown migration error, never its free text. */
function sdkMigrationErrorCode(error: unknown): string {
const message = error instanceof Error ? error.message : "";
return /^[a-z][a-z0-9_]{0,95}$/.test(message)
? message
: "sdk_migration_incomplete";
}
/**
* The Runtime service refuses to spawn Core while this home's migration is
* deferred and leaves a block note; report that instead of a generic
* "not ready within budget".
*/
async function runtimeMigrationBlock(
dataDir: string,
command: string,
): Promise<ReturnType<typeof sdkMigrationFailure> | null> {
const block = await readSdkMigrationBlock(dataDir).catch(() => null);
if (block === null) return null;
return sdkMigrationFailure(
command,
block.code,
dataDir,
"blocked",
block.data_restored === true,
);
}
async function waitForRuntimeReady(
dataDir: string,
timeoutMs = 45_000,
): Promise<boolean> {
const started = Date.now();
const deadline = started + timeoutMs;
while (Date.now() < deadline) {
const runtime = await inspectRuntimeStatus({
dataDir,
probeIdentity,
requireServiceReceipt: true,
}).catch(() => null);
if (runtimeIsReady(runtime)) return true;
// The service deferred its migration during this wait and will not spawn
// Core: stop waiting. An older note may predate a retry still running, so
// only a note written after the wait began ends it early.
// A frozen home: its service exits without migrating, so nothing will
// come up until a foreground migration lifts the freeze.
const blocked = await readSdkMigrationBlock(dataDir).catch(() => null);
if (
blocked !== null &&
(blocked.frozen !== undefined || Date.parse(blocked.at) >= started)
)
return false;
await new Promise((resolve) => setTimeout(resolve, 500));
}
return false;
}
async function probeIdentity(
port: number,
apiToken: string,
): Promise<HealthResponse> {
return (
await axios.get<HealthResponse>(
`http://127.0.0.1:${port}/api/v1/runtime/identity`,
{
headers: { Authorization: `Bearer ${apiToken}` },
timeout: 2_000,
proxy: false,
maxRedirects: 0,
},
)
).data;
}
function parseOptions(
arguments_: readonly string[],
): Map<string, string> | null {
if (arguments_.length % 2 !== 0) return null;
const allowed = new Set([
"platform",
"home",
"install-root",
"uid",
"xdg-config-home",
"data-dir",
]);
const values = new Map<string, string>();
for (let index = 0; index < arguments_.length; index += 2) {
const key = arguments_[index];
const value = arguments_[index + 1];
const name = key?.startsWith("--") ? key.slice(2) : "";
if (
!allowed.has(name) ||
values.has(name) ||
!value ||
value.startsWith("--")
)
return null;
values.set(name, value);
}
return values;
}
/** The read-only inspector must never make status fail. */
async function inspectBridge(
dataDir: string,
): Promise<BackgroundHealth | null> {
try {
return await inspectBackgroundHealth({ dataDir });
} catch {
return null;
}
}
async function readStatusSnapshot(
dataDir: string,
signal?: AbortSignal,
inspectLaunchd?: RunControls["inspectLaunchd"],
platform: NodeJS.Platform = process.platform,
serviceId = "com.tencent.onememory.runtime",
): Promise<WatchRead<StatusDocument>> {
assertNotAborted(signal);
let runtime = await inspectRuntimeStatus({
dataDir,
probeIdentity,
requireServiceReceipt: true,
});
assertNotAborted(signal);
// Receipts can vanish while a launchd-managed daemon is still alive (e.g.
// right after a stop that KeepAlive undid). Never report `stopped` when the
// service is actually running: consult launchd and report the truth.
if (runtime.state === "stopped" && platform === "darwin") {
const uid = process.getuid?.();
if (uid !== undefined) {
// A global label is only ours when its plist binds this data dir;
// otherwise a second install would be reported as this one. Tests inject
// this probe so they never read the host's real LaunchAgent.
const service = inspectLaunchd
? await inspectLaunchd(dataDir).catch(() => null)
: await inspectLaunchdService({
serviceId,
uid,
runCaptured: runLaunchctlCaptured,
dataDir,
}).catch(() => null);
if (service?.state === "running") {
const pid = service.pid;
runtime = {
state: "running",
code: "runtime_service_running",
instanceId: `service-pid-${pid}`,
};
}
}
}
const attached =
runtime.state === "running"
? await attachReader(dataDir)
: { reader: null, failure: undefined };
assertNotAborted(signal);
const report = await composeStatusReport({
dataDir,
runtime,
background: await inspectBridge(dataDir),
reader:
attached.reader && signal
? readerWithSignal(attached.reader, signal)
: attached.reader,
attachFailure: attached.failure,
});
assertNotAborted(signal);
return {
...(runtime.state === "running" ? { instanceId: runtime.instanceId } : {}),
value: report,
};
}
export function managedRuntimeServiceId(
config: ReturnType<typeof resolveRuntimeCliConfig>,
environment: NodeJS.ProcessEnv,
uid: number,
): string {
const home = config.hostHome ?? os.homedir();
const layout = runtimeInstallLayout({
platform: "darwin",
home,
installRoot:
environment.ONE_MEMORY_INSTALL_ROOT ??
defaultManagedInstallRoot("darwin", home),
dataDir: config.dataDir,
...hourlyIsolationFromEnvironment(environment),
});
return planUserService(layout, { uid }).service_id;
}
async function readDoctorSnapshot(
dataDir: string,
): Promise<{ report: DoctorDocument; runtimeStopped: boolean }> {
const runtime = await inspectRuntimeStatus({
dataDir,
probeIdentity,
requireServiceReceipt: true,
});
const attached =
runtime.state === "running"
? await attachReader(dataDir)
: { reader: null, failure: undefined };
const [status, progress] = await Promise.all([
composeStatusReport({
dataDir,
runtime,
background: await inspectBridge(dataDir),
reader: attached.reader,
attachFailure: attached.failure,
}),
attached.reader
? readProgress(attached.reader, {
issues: false,
limit: 500,
})
: Promise.resolve(null),
]);
return {
report: composeDoctorReport(status, progress),
runtimeStopped: runtime.state === "stopped",
};
}
async function readProgressSnapshot(
dataDir: string,
options: Extract<ReadCommand, { kind: "progress" }>,
signal: AbortSignal,
): Promise<WatchRead<ProgressDocument>> {
assertNotAborted(signal);
const runtime = await inspectRuntimeStatus({
dataDir,
probeIdentity,
requireServiceReceipt: true,
});
assertNotAborted(signal);
if (runtime.state !== "running") {
return { value: unreadProgress("not_checked", runtime.code) };
}
const attached = await attachReader(dataDir);
assertNotAborted(signal);
if (!attached.reader) {
return {
instanceId: runtime.instanceId,
value: unreadProgress(
"unavailable",
attached.failure ?? "runtime_attach_failed",
),
};
}
const value = await readProgress(
readerWithSignal(attached.reader, signal),
options,
);
assertNotAborted(signal);
return { instanceId: runtime.instanceId, value };
}
async function runCliWatch<T>(options: {
kind: "status" | "progress";
intervalMs: number;
asJson: boolean;
controls: RunControls;
read(signal: AbortSignal): Promise<WatchRead<T>>;
render(value: T): string;
}): Promise<number> {
const ownedController = options.controls.watchSignal
? null
: new AbortController();
const signal = options.controls.watchSignal ?? ownedController?.signal;
if (!signal) return EXIT_FAILED;
const cancel = () => ownedController?.abort();
if (ownedController) {
process.once("SIGINT", cancel);
process.once("SIGTERM", cancel);
}
try {
const result = await runWatchLoop({
kind: options.kind,
intervalMs: options.intervalMs,
signal,
read: options.read,
maxEvents: options.controls.watchMaxEvents,
wait: options.controls.watchWait,
now: options.controls.watchNow,
emit: (event) =>
emitWatchEvent(
event,
options.asJson,
options.intervalMs,
options.render,
),
});
return result.state === "failed" ? EXIT_FAILED : EXIT_OK;
} finally {
if (ownedController) {
process.off("SIGINT", cancel);
process.off("SIGTERM", cancel);
}
}
}
function emitWatchEvent<T>(
event: WatchEvent<T>,
asJson: boolean,
intervalMs: number,
render: (value: T) => string,
): void {
if (asJson) {
process.stdout.write(`${JSON.stringify(event)}\n`);
return;
}
if (process.stdout.isTTY) {
process.stdout.write("\x1b[2J\x1b[H");
process.stdout.write(
`每 ${Math.round(intervalMs / 1000)} 秒刷新一次,按 Ctrl-C 退出观察,后台不会停止\n`,
);
}
const clock = new Date(event.observed_at);
const observedAt = process.stdout.isTTY
? Number.isNaN(clock.getTime())
? event.observed_at
: clock.toLocaleTimeString("en-GB", { hour12: false })
: event.observed_at;
process.stdout.write(`\n[${observedAt}] ${event.event} #${event.sequence}\n`);
if (event.data !== undefined) {
process.stdout.write(render(event.data));
} else {
process.stdout.write(
`本轮读取未完成:${event.state ?? "unavailable"}(${event.code ?? "watch_failed"})\n`,
);
}
}
function readerWithSignal(reader: CoreReader, signal: AbortSignal): CoreReader {
return (path, params) => reader(path, params, { signal });
}
function assertNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw new DOMException("aborted", "AbortError");
}
/** A2 interactive onboarding: pick sources → read/write switches → import?
*
* The wizard runs only on a TTY. It reuses the exact same connect path
* (`connectSourcesWithImport`) and the same access endpoint as
* `sources connect`, so the UI/CLI/config state cannot diverge. A source that is picked
* but has read turned off is still granted (so it shows up in `sources list`),
* matching how the switches are meant to work.
*/
async function runInteractiveInit(
candidates: readonly SourceConnectionCandidate[],
clients: {
reader: CoreReader;
operations: CoreOperations & CoreDeleteOperations;
},
asJson: boolean,
emitLine: (line: string) => number,
injected: WizardPrompter | undefined,
dataDir: string,
hostHome: string,
): Promise<number> {
const prompter: WizardPrompter = injected ?? (await createTerminalPrompter());
try {
const outcome = await runInitWizard(
candidates.map((candidate) => ({
source_id: candidate.source_id,
display_name: candidate.requires_override_confirmation
? `${candidate.display_name}(目录来自环境变量覆盖)`
: candidate.display_name,
})),
prompter,
);
if (outcome.state === "cancelled") {
return emitLine("已取消,未接入任何来源。\n");
}
if (outcome.state === "invalid") {
return reject(undefined, asJson, `交互失败:${outcome.code}`);
}
const selected = candidates.filter((candidate) =>
outcome.answers.sourceIds.includes(candidate.source_id),
);
// Freeze grants the source identity but creates no history job. Thaw uses
// the normal connect path, which creates history work and enables future
// scanning in the same operation.
if (!outcome.answers.processing) {
const sourceCode = await runInteractiveInitGrantOnly(
selected,
clients,
emitLine,
);
if (sourceCode !== EXIT_OK || !prompter.askSecret) return sourceCode;
const setup = await runInteractiveModelSetup(prompter, clients, {
dataDir,
hostHome,
installHosts: true,
});
return await finishInteractiveOnboarding(
setup,
clients.operations,
emitLine,
);
}
// Picking a source in the wizard is the user's confirmation of the
// location shown for it, environment overrides included (the wizard
// labels those).
const document = await connectSourcesWithImport(
clients.reader,
clients.operations,
{ sourceIds: outcome.answers.sourceIds, confirmOverrides: true },
);
if (document.state === "committed" || document.state === "recovered") {
process.stdout.write(
`已接入来源:${outcome.answers.sourceIds.join("、")}\n处理:已开启(历史待办与新会话会继续处理)\n操作状态:${document.state}\n`,
);
const sourceCode = flowExitCode(document);
if (sourceCode !== EXIT_OK || !prompter.askSecret) return sourceCode;
const setup = await runInteractiveModelSetup(prompter, clients, {
dataDir,
hostHome,
installHosts: true,
});
return await finishInteractiveOnboarding(
setup,
clients.operations,
emitLine,
);
}
emitLine(
asJson ? `${JSON.stringify(document)}\n` : renderFlowText(document),
);
return flowExitCode(document);
} finally {
(prompter as { close?: () => void }).close?.();
}
}
async function finishInteractiveOnboarding(
setup: Awaited<ReturnType<typeof runInteractiveModelSetup>>,
operations: CoreOperations,
emitLine: (line: string) => number,
): Promise<number> {
if (setup.state === "failed") return EXIT_FAILED;
if (setup.state !== "configured") return EXIT_OK;
const completed = await operations.post<Record<string, unknown>>(
"/api/v1/onboarding/complete",
{},
);
if (completed.state !== "committed") {
emitLine(`首次设置未完成:${completed.code}\n`);
return EXIT_FAILED;
}
emitLine("首次设置完成。\n");
return EXIT_OK;
}
/** Grant selected Harnesses while keeping their processing pipeline frozen. */
async function runInteractiveInitGrantOnly(
selected: readonly SourceConnectionCandidate[],
clients: { reader: CoreReader; operations: CoreOperations },
emitLine: (line: string) => number,
): Promise<number> {
const granted: string[] = [];
const failures: Array<{ source_id: string; code: string }> = [];
for (const candidate of selected) {
const result = await clients.operations.post<unknown>(
`/api/v1/sources/${encodeURIComponent(candidate.source_id)}/authorize`,
{
user_confirmed: true,
expected_root_id: candidate.candidate_root_id,
confirm_environment_override: candidate.requires_override_confirmation,
},
);
if (result.state === "committed") {
granted.push(candidate.source_id);
} else {
failures.push({ source_id: candidate.source_id, code: result.code });
}
}
const lines = [
`已接入来源:${granted.join("、") || "无"}`,
"处理:已冻结(不扫描新会话,pending Job 保留)",
...failures.map(
(failure) => `接入未完成:${failure.source_id}(${failure.code})`,
),
];
emitLine(`${lines.join("\n")}\n`);
return failures.length === 0 ? EXIT_OK : EXIT_FAILED;
}
async function attachClients(
dataDir: string,
options?: { readerTimeoutMs?: number },
): Promise<{
reader: CoreReader | null;
operations:
| (CoreOperations &
CorePutOperations &
CoreDeleteOperations &
CorePatchOperations)
| null;
failure?: string;
}> {
try {
const attachment = await discoverRuntimeAttachment({
dataDir,
processIsAlive,
probeIdentity,
requireServiceReceipt: true,
});
if (attachment === null) {
return {
reader: null,
operations: null,
failure: "runtime_attach_unavailable",
};
}
return {
reader: createCoreReader({
...attachment,
...(options?.readerTimeoutMs === undefined
? {}
: { timeoutMs: options.readerTimeoutMs }),
}),
operations: createCoreOperations(attachment),
};
} catch (error) {
return {
reader: null,
operations: null,
failure:
error instanceof RuntimeDiscoveryError
? error.code
: "runtime_attach_failed",
};
}
}
/** Ask Core for a one-time UI exchange and preserve the failure class:
* missing contract, missing assets, HTTP failure, or transport failure. */
async function requestUiSession(attachment: {
host: string;
port: number;
apiToken: string;
}): Promise<{ url: string; expiresInSeconds: number } | { code: string }> {
const outcome = await createUiSessionRequest(attachment)();
if (outcome.state !== "ok") {
if (outcome.code === "ui_web_assets_unavailable") {
return { code: outcome.code };
}
if (outcome.code === "ui_session_code_invalid") {
return { code: outcome.code };
}
// A 404 means the Core build predates the contract. Other HTTP or
// transport failures are distinct: reporting every one as a missing
// contract hid a broken 500 behind unchanged output.
return {
code:
outcome.code === "http_404"
? "ui_session_contract_unavailable"
: outcome.code.startsWith("http_")
? "ui_session_exchange_failed"
: "ui_session_transport_failed",
};
}
const parsed = parseUiSessionExchange(outcome.data);
return parsed ?? { code: "ui_session_contract_unavailable" };
}
/** Restore a managed backup by starting a *non-owning* maintenance Core.
*
* Core refuses restore from a Runtime-owning process, and the CLI has no way
* to call Core's Python in-process, so it launches `one-memory serve
* --maintenance` against the same data dir with a freshly generated bearer,
* waits for the port to answer, calls the restore endpoint, then stops it. Any
* failure tears the process down and reports; a partial database is never left
* because Core itself makes the protective copy and the atomic swap.
*/
async function runMaintenanceRestore(options: {
coreCommand: string;
dataDir: string;
source: string;
}): Promise<
| { state: "restored"; result: RestoreResult }
| { state: "rejected"; code: string }
> {
const port = await findFreeLoopbackPort();
const apiToken = randomUUID().replaceAll("-", "");
const instanceId = randomUUID();
const child = spawn(options.coreCommand, ["serve", "--maintenance"], {
env: {
// Do not pass the caller's Runtime tuning variables into a
// maintenance process: a restore must start the product's minimal
// maintenance configuration, not inherit an unrelated experiment from
// the shell.
PATH: process.env.PATH,
HOME: process.env.HOME,
LANG: process.env.LANG,
LC_ALL: process.env.LC_ALL,
TMPDIR: process.env.TMPDIR,
ONE_MEMORY_DATA_DIR: options.dataDir,
ONE_MEMORY_HOST: "127.0.0.1",
ONE_MEMORY_PORT: String(port),
ONE_MEMORY_API_TOKEN: apiToken,
ONE_MEMORY_INSTANCE_ID: instanceId,
},
stdio: "ignore",
windowsHide: true,
});
let spawnFailed = false;
child.once("error", () => {
spawnFailed = true;
});
const stop = async () => {
if (child.exitCode === null && child.signalCode === null) {
const signaled = child.kill("SIGTERM");
if (!signaled) return;
await new Promise<void>((resolve) => {
let timer: NodeJS.Timeout;
const onExit = () => {
clearTimeout(timer);
resolve();
};
timer = setTimeout(() => {
child.removeListener("exit", onExit);
resolve();
}, 5_000);
child.once("exit", onExit);
});
}
};
try {
const ready = await waitForMaintenanceCore(port, child, () => spawnFailed);
if (!ready) {
return { state: "rejected", code: "backup_restore_failed" };
}
const identityVerified = await waitForMaintenanceIdentity(
port,
instanceId,
child,
() => spawnFailed,
);
if (!identityVerified) {
return {
state: "rejected",
code: "backup_restore_maintenance_identity_invalid",
};
}
return await callRestore(
{ host: "127.0.0.1", port, apiToken },
options.source,
);
} catch {
return { state: "rejected", code: "backup_restore_maintenance_failed" };
} finally {
await stop();
}
}
async function waitForMaintenanceIdentity(
port: number,
instanceId: string,
child: { exitCode: number | null; signalCode: NodeJS.Signals | null },
spawnFailed: () => boolean,
): Promise<boolean> {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
if (spawnFailed() || child.exitCode !== null || child.signalCode !== null) {
return false;
}
if (
await verifyMaintenanceCoreIdentity({
host: "127.0.0.1",
port,
instanceId,
})
) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
return false;
}
async function findFreeLoopbackPort(): Promise<number> {
const { createServer } = await import("node:net");
return new Promise((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port =
address === null || typeof address === "string" ? null : address.port;
server.close(() => {
if (port === null) reject(new Error("no port"));
else resolve(port);
});
});
});
}
async function waitForMaintenanceCore(
port: number,
child: { exitCode: number | null; signalCode: NodeJS.Signals | null },
spawnFailed: () => boolean,
): Promise<boolean> {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
if (spawnFailed() || child.exitCode !== null || child.signalCode !== null) {
return false;
}
const ok = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
resolve(false);
}, 500);
const socket = import("node:net");
socket.then(({ createConnection }) => {
const connection = createConnection({ host: "127.0.0.1", port });
connection.once("connect", () => {
clearTimeout(timer);
connection.destroy();
resolve(true);
});
connection.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
});
if (ok) return true;
await new Promise((resolve) => setTimeout(resolve, 250));
}
return false;
}
/** Attach to the running Runtime, or null when it is absent or unverified.
*
* Used by the A6 write commands. Same discovery call every other command uses,
* so the CLI never reaches Core by a second route.
*/
async function attachToRuntime(
dataDir: string,
): Promise<Awaited<ReturnType<typeof discoverRuntimeAttachment>> | null> {
return discoverRuntimeAttachment({
dataDir,
processIsAlive,
probeIdentity,
requireServiceReceipt: true,
});
}
async function sha256File(target: string): Promise<string> {
const digest = createHash("sha256");
for await (const chunk of createReadStream(target)) digest.update(chunk);
return digest.digest("hex");
}
async function diagnosticSummary(
config: ReturnType<typeof resolveRuntimeCliConfig>,
) {
const hosts = await listHosts(config.hostHome ?? os.homedir()).catch(
() => null,
);
const attachment = await attachToRuntime(config.dataDir).catch(() => null);
if (!attachment) return { entry: "cli", hosts };
const read = async (pathname: string): Promise<unknown> => {
const response = await axios.get(
`http://${attachment.host}:${attachment.port}${pathname}`,
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
proxy: false,
timeout: 2_000,
maxRedirects: 0,
},
);
return response.data;
};
const [sources, processing, models] = await Promise.all([
read("/api/v1/sources").catch(() => null),
read("/api/v1/memory-processing").catch(() => null),
read("/api/v1/model-profiles").catch(() => null),
]);
return {
entry: "cli",
sources,
memory_processing: processing,
hosts,
models,
};
}
async function attachReader(
dataDir: string,
): Promise<{ reader: CoreReader | null; failure?: string }> {
try {
const attachment = await discoverRuntimeAttachment({
dataDir,
processIsAlive,
probeIdentity,
requireServiceReceipt: true,
});
return attachment === null
? { reader: null, failure: "runtime_attach_unavailable" }
: { reader: createCoreReader(attachment) };
} catch (error) {
return {
reader: null,
failure:
error instanceof RuntimeDiscoveryError
? error.code
: "runtime_attach_failed",
};
}
}
async function attachContextQuery(
dataDir: string,
): Promise<{ query: CoreContextQuery | null; failure?: string }> {
try {
const attachment = await discoverRuntimeAttachment({
dataDir,
processIsAlive,
probeIdentity,
requireServiceReceipt: true,
});
return attachment === null
? { query: null, failure: "runtime_attach_unavailable" }
: { query: createCoreContextQuery(attachment) };
} catch (error) {
return {
query: null,
failure:
error instanceof RuntimeDiscoveryError
? error.code
: "runtime_attach_failed",
};
}
}
const OPERATION_ID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/** Core accepts any 1..256-character request id for `write submit`; the CLI
* also refuses control characters. */
function isWriteRequestId(value: string): boolean {
return (
value.length >= 1 &&
value.length <= 256 &&
![...value].some((character) => {
const code = character.charCodeAt(0);
return code <= 31 || code === 127;
})
);
}
function parseModelReadCommand(
rest: readonly string[],
): { kind: "list" } | { kind: "show"; profileId: string } | null {
if (rest.filter((item) => item === "--json").length > 1) return null;
const tokens = rest.filter((item) => item !== "--json");
if (tokens.length === 1 && tokens[0] === "list") return { kind: "list" };
return tokens.length === 2 &&
tokens[0] === "show" &&
OPERATION_ID.test(tokens[1])
? { kind: "show", profileId: tokens[1] }
: null;
}
type ModelsCommand =
| {
kind: "add";
configFile: string;
apiKeyFile?: string;
activate: boolean;
confirmed: boolean;
follow?: boolean;
}
| {
kind: "edit";
profileId: string;
configFile: string;
apiKeyFile?: string;
}
| {
kind: "activate";
profileId: string;
confirmed: boolean;
follow?: boolean;
}
| { kind: "switch"; action: "status" | "cancel" }
| { kind: "spaces"; cleanup?: string; confirmed: boolean }
| { kind: "test"; profileId: string }
| { kind: "receipt"; operationId: string }
| { kind: "remove"; profileId: string; confirmed: boolean };
/** Read `--flag value` pairs and bare switches after the positional
* arguments; null on anything unknown, repeated, or missing its value. */
function readFlags(
tokens: readonly string[],
valueFlags: readonly string[],
switchFlags: readonly string[],
): { values: Map<string, string>; switches: Set<string> } | null {
const values = new Map<string, string>();
const switches = new Set<string>();
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (switchFlags.includes(token)) {
if (switches.has(token)) return null;
switches.add(token);
continue;
}
if (!valueFlags.includes(token) || values.has(token)) return null;
const value = tokens[index + 1];
if (value === undefined || value.startsWith("--")) return null;
values.set(token, value);
index += 1;
}
return { values, switches };
}
/** `models add|edit|activate|test|receipt|remove` (`list`/`show`/`setup` are
* answered earlier). `--confirm` is a hidden alias of `--yes` on `remove`
* and is accepted and ignored on `edit`/`activate`, which are now direct. */
function parseModelsCommand(rest: readonly string[]): ModelsCommand | null {
if (rest.filter((item) => item === "--json").length > 1) return null;
const tokens = rest.filter((item) => item !== "--json");
const [action, target] = tokens;
if (action === "add") {
const flags = readFlags(
tokens.slice(1),
["--config-file", "--api-key-file"],
["--activate", "--yes", "--wait", "--no-wait"],
);
const configFile = flags?.values.get("--config-file");
if (!flags || configFile === undefined) return null;
const follow = followFlag(flags.switches);
if (follow === null) return null;
const activate = flags.switches.has("--activate");
// The switch flags only mean something when the add also activates.
if (
!activate &&
["--yes", "--wait", "--no-wait"].some((flag) => flags.switches.has(flag))
) {
return null;
}
const apiKeyFile = flags.values.get("--api-key-file");
return {
kind: "add",
configFile,
...(apiKeyFile === undefined ? {} : { apiKeyFile }),
activate,
confirmed: flags.switches.has("--yes"),
...(follow === undefined ? {} : { follow }),
};
}
if (action === "switch") {
if (tokens.length !== 2 || (target !== "status" && target !== "cancel")) {
return null;
}
return { kind: "switch", action: target };
}
if (action === "spaces") {
const flags = readFlags(tokens.slice(1), ["--cleanup"], ["--yes"]);
if (!flags) return null;
const cleanup = flags.values.get("--cleanup");
if (cleanup === undefined && flags.switches.has("--yes")) return null;
if (cleanup !== undefined && !OPERATION_ID.test(cleanup)) return null;
return {
kind: "spaces",
...(cleanup === undefined ? {} : { cleanup }),
confirmed: flags.switches.has("--yes"),
};
}
if (target === undefined || !OPERATION_ID.test(target)) return null;
if (action === "edit") {
const flags = readFlags(
tokens.slice(2),
["--config-file", "--api-key-file"],
["--confirm", "--yes"],
);
const configFile = flags?.values.get("--config-file");
if (!flags || configFile === undefined) return null;
const apiKeyFile = flags.values.get("--api-key-file");
return {
kind: "edit",
profileId: target,
configFile,
...(apiKeyFile === undefined ? {} : { apiKeyFile }),
};
}
if (action === "activate") {
const flags = readFlags(
tokens.slice(2),
[],
["--confirm", "--yes", "--wait", "--no-wait"],
);
if (!flags) return null;
const follow = followFlag(flags.switches);
if (follow === null) return null;
return {
kind: "activate",
profileId: target,
// `--confirm` stays the hidden alias of `--yes`.
confirmed: flags.switches.has("--yes") || flags.switches.has("--confirm"),
...(follow === undefined ? {} : { follow }),
};
}
if (action === "test") {
return tokens.length === 2 ? { kind: "test", profileId: target } : null;
}
if (action === "receipt") {
return tokens.length === 2
? { kind: "receipt", operationId: target }
: null;
}
if (action === "remove") {
const flags = readFlags(tokens.slice(2), [], ["--yes", "--confirm"]);
return flags
? {
kind: "remove",
profileId: target,
confirmed: flags.switches.size > 0,
}
: null;
}
return null;
}
/** `--wait` / `--no-wait`: undefined when neither, null when both. */
function followFlag(switches: ReadonlySet<string>): boolean | undefined | null {
const wait = switches.has("--wait");
const noWait = switches.has("--no-wait");
if (wait && noWait) return null;
return wait ? true : noWait ? false : undefined;
}
/** Start a probe and read its receipt until it settles (about two minutes). */
async function probeUntilSettled(
reader: CoreReader,
operations: CoreOperations,
profileId: string,
wait?: (milliseconds: number, signal: AbortSignal) => Promise<void>,
): Promise<ModelProbeFlowDocument> {
let document = await startModelProbe(reader, operations, profileId);
const pause =
wait ??
((milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
const signal = new AbortController().signal;
for (let attempt = 0; attempt < 120; attempt += 1) {
if (document.state !== "pending" || !document.operation_id) break;
await pause(1_000, signal);
document = await readModelProbeReceipt(reader, document.operation_id);
}
return document;
}
type SourcesCommand =
| {
kind: "ids";
sourceIds: string[];
importHistory: boolean;
confirmOverrides: boolean;
requestId?: string;
/** `--user-id`: whose grant (Hermes only). Absent for `local`. */
userId?: string;
/** `--root`: an absolute root other than the candidate (Hermes only). */
root?: string;
}
| { kind: "all"; importHistory: boolean; confirmOverrides: boolean }
| { kind: "receipt"; requestId: string };
const SOURCE_ID_SHAPE = /^[a-z0-9][a-z0-9-]{0,127}$/;
/** Only Hermes keeps one history per profile, so only Hermes may be
* connected for another user or from another root. */
const MULTI_USER_SOURCES = new Set(["hermes"]);
/** `sources connect <id> [<id> ...] | --all-detected [--import]
* [--confirm-overrides] [--request-id <uuid>]
* [--user-id <id>] [--root <path>]` and `sources receipt <id>`.
*
* Sources may be named positionally or with repeated `--source` (the old
* `init --source` spelling is rewritten to this command). */
function parseSourcesCommand(rest: readonly string[]): SourcesCommand | null {
if (rest.filter((item) => item === "--json").length > 1) return null;
const tokens = rest.filter((item) => item !== "--json");
if (tokens[0] === "receipt") {
return tokens.length === 2 && OPERATION_ID.test(tokens[1])
? { kind: "receipt", requestId: tokens[1] }
: null;
}
if (tokens[0] !== "connect") return null;
const sourceIds: string[] = [];
const switches = new Set<string>();
let requestId: string | undefined;
let userId: string | undefined;
let root: string | undefined;
for (let index = 1; index < tokens.length; index += 1) {
const token = tokens[index];
if (["--import", "--confirm-overrides", "--all-detected"].includes(token)) {
if (switches.has(token)) return null;
switches.add(token);
continue;
}
if (token === "--user-id" || token === "--root") {
const value = tokens[index + 1];
if (value === undefined || value.startsWith("--")) return null;
if (token === "--user-id") {
if (userId !== undefined || !isUserId(value)) return null;
userId = value;
} else {
if (root !== undefined) return null;
const expanded = expandRootPath(value);
if (expanded === null) return null;
root = expanded;
}
index += 1;
continue;
}
if (token === "--source" || token === "--request-id") {
const value = tokens[index + 1];
if (value === undefined || value.startsWith("--")) return null;
if (token === "--source") sourceIds.push(value);
else if (requestId !== undefined) return null;
else requestId = value;
index += 1;
continue;
}
if (token.startsWith("--")) return null;
sourceIds.push(token);
}
if (
sourceIds.length > 32 ||
new Set(sourceIds).size !== sourceIds.length ||
sourceIds.some((id) => !SOURCE_ID_SHAPE.test(id))
) {
return null;
}
const importHistory = switches.has("--import");
const confirmOverrides = switches.has("--confirm-overrides");
if (
requestId !== undefined &&
(!OPERATION_ID.test(requestId) ||
!(importHistory || userId !== undefined || root !== undefined))
) {
return null;
}
// `--user-id local` is the default and changes nothing.
if (userId === DEFAULT_USER_ID) userId = undefined;
if (userId !== undefined || root !== undefined) {
// One Hermes profile per connect: the user and root name one grant.
if (
switches.has("--all-detected") ||
sourceIds.length !== 1 ||
!MULTI_USER_SOURCES.has(sourceIds[0])
) {
return null;
}
}
if (switches.has("--all-detected")) {
// One names exact sources, the other means "whatever is detected".
if (sourceIds.length > 0 || requestId !== undefined) return null;
return { kind: "all", importHistory, confirmOverrides };
}
if (sourceIds.length === 0) return null;
return {
kind: "ids",
sourceIds,
importHistory,
confirmOverrides,
...(requestId === undefined ? {} : { requestId }),
...(userId === undefined ? {} : { userId }),
...(root === undefined ? {} : { root }),
};
}
/** `--root` as an absolute path: a leading `~` is the home directory, a
* relative path is taken from the working directory. Core expands nothing. */
function expandRootPath(value: string): string | null {
if (value.length === 0 || /[\0\r\n]/.test(value)) return null;
const expanded =
value === "~"
? os.homedir()
: value.startsWith("~/")
? path.join(os.homedir(), value.slice(2))
: value;
return path.resolve(expanded);
}
function parseGraphWidthCommand(
rest: readonly string[],
): { decision: GraphWidthDecision; confirmed: boolean } | null {
if (rest.filter((item) => item === "--json").length > 1) return null;
const flags = readFlags(
rest.filter((item) => item !== "--json"),
["--decision"],
["--yes", "--confirm"],
);
const decision = flags?.values.get("--decision");
if (
!flags ||
decision === undefined ||
!(GRAPH_WIDTH_DECISIONS as readonly string[]).includes(decision)
) {
return null;
}
return {
decision: decision as GraphWidthDecision,
confirmed: flags.switches.size > 0,
};
}
function emitFlowDocument(document: FlowDocument, asJson: boolean): void {
process.stdout.write(
asJson ? `${JSON.stringify(document)}\n` : renderFlowText(document),
);
}
function emitModelProbeDocument(
document: ModelProbeFlowDocument,
asJson: boolean,
): void {
process.stdout.write(
asJson ? `${JSON.stringify(document)}\n` : renderModelProbeText(document),
);
}
function emitHostDocument(document: HostFlowDocument, asJson: boolean): void {
process.stdout.write(
asJson ? `${JSON.stringify(document)}\n` : renderHostText(document),
);
}
type HostCommand =
| { kind: "host-list" }
| {
kind: "host-status";
hostId: string;
operation: HostOperation;
args: unknown[];
}
| {
kind: "host-apply";
hostId: string;
operation: HostOperation;
args: unknown[];
};
// Host ids are read with the same grammar as the permission layer and the
// grant store (`@one-memory/contracts`): `deepseek-harness:web` is a real id —
// that host is configured per profile — and `host list` prints it, so rejecting
// it here would mean listing a host the CLI then refuses to act on.
const FINGERPRINT = /^[0-9a-f]{64}$/;
/** Host subcommands.
*
* `install`/`uninstall` are split in two on purpose: the preview prints a
* fingerprint and changes nothing, and the change is only accepted when that
* fingerprint is presented back. That is the desktop's two-phase confirm, kept
* so a script cannot install into a host by accident.
*/
function parseHostCommand(rest: readonly string[]): HostCommand | null {
if (rest.filter((item) => item === "--json").length > 1) return null;
const tokens = rest.filter((item) => item !== "--json");
if (tokens.length === 1 && tokens[0] === "list") return { kind: "host-list" };
let hostId: string | undefined;
let approve: string | undefined;
let overwriteModified = false;
const positional: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (token === "--host") {
const value = tokens[index + 1];
if (!value || !isHostId(value)) return null;
if (hostId !== undefined) return null;
hostId = value;
index += 1;
continue;
}
if (token === "--approve") {
const value = tokens[index + 1];
if (!value || !FINGERPRINT.test(value)) return null;
if (approve !== undefined) return null;
approve = value;
index += 1;
continue;
}
if (token === "--overwrite-modified") {
if (overwriteModified) return null;
overwriteModified = true;
continue;
}
if (token?.startsWith("--")) return null;
positional.push(token ?? "");
}
// Short form: `memhub host install <host>` (objective's named shape). The
// first positional is the verb, the second may be the host when `--host` was
// not given. Ambiguity (both) is rejected rather than guessed.
let verb = positional[0];
let qualifier = positional[1];
if (
hostId === undefined &&
verb !== undefined &&
qualifier !== undefined &&
(verb === "status" || verb === "install" || verb === "uninstall")
) {
if (!isHostId(qualifier)) return null;
hostId = qualifier;
// Drop the consumed host positional so the verb/qualifier checks below see
// the same shape as the flag form.
positional.splice(1, 1);
verb = positional[0];
qualifier = positional[1];
}
// `memhub host sync skill <host>` — host trails the sub-verb here.
if (
hostId === undefined &&
verb === "sync" &&
qualifier === "skill" &&
positional[2] !== undefined
) {
const trailing = positional[2];
if (!isHostId(trailing)) return null;
hostId = trailing;
positional.splice(2, 1);
verb = positional[0];
qualifier = positional[1];
}
if (hostId === undefined || positional.length < 1) return null;
if (verb === "status") {
return positional.length === 1
? { kind: "host-status", hostId, operation: "getPermission", args: [] }
: null;
}
if (verb === "install") {
if (positional.length === 1 && approve !== undefined) {
return {
kind: "host-apply",
hostId,
operation: "install",
args: [approve],
};
}
return positional.length === 1
? { kind: "host-status", hostId, operation: "previewInstall", args: [] }
: null;
}
if (verb === "uninstall") {
if (positional.length === 1 && approve !== undefined) {
return {
kind: "host-apply",
hostId,
operation: "uninstall",
args: [approve],
};
}
return positional.length === 1
? { kind: "host-status", hostId, operation: "previewUninstall", args: [] }
: null;
}
if (verb === "sync") {
if (qualifier !== "skill") return null;
if (approve !== undefined) {
return {
kind: "host-apply",
hostId,
operation: "applySkillSync",
args: [approve, overwriteModified],
};
}
return positional.length === 2
? { kind: "host-status", hostId, operation: "previewSkillSync", args: [] }
: null;
}
return null;
}
function emitModelReportDocument(
document: ModelReportDocument,
asJson: boolean,
): void {
process.stdout.write(
asJson ? `${JSON.stringify(document)}\n` : renderModelReportText(document),
);
}
function emitSourceSyncDocument(
document: SourceSyncFlowDocument,
asJson: boolean,
): void {
process.stdout.write(
asJson ? `${JSON.stringify(document)}\n` : renderSourceSyncText(document),
);
}
type StatusCommand = { watch: boolean; intervalMs: number };
function parseStatusCommand(
command: "status" | "doctor",
rest: readonly string[],
): StatusCommand | null {
if (rest.filter((item) => item === "--json").length > 1) return null;
const tokens = rest.filter((item) => item !== "--json");
let watch = false;
let intervalSeconds = 5;
let intervalSet = false;
for (let index = 0; index < tokens.length; index += 1) {
const flag = tokens[index];
if (flag === "--watch" && !watch) {
watch = true;
continue;
}
if (flag !== "--interval" || intervalSet) return null;
const value = tokens[index + 1];
if (value === undefined || value.startsWith("--")) return null;
intervalSeconds = Number(value);
intervalSet = true;
index += 1;
}
if (command === "doctor" && (watch || intervalSet)) return null;
if (intervalSet && !watch) return null;
if (
!Number.isSafeInteger(intervalSeconds) ||
intervalSeconds < 1 ||
intervalSeconds > 300
) {
return null;
}
return { watch, intervalMs: intervalSeconds * 1_000 };
}
type ReadCommand =
| { kind: "sources" }
| { kind: "sync" }
| {
kind: "sync-set";
action: "pause" | "resume";
sourceId: string;
}
| {
kind: "progress";
jobId?: string;
issues: boolean;
limit: number;
watch: boolean;
intervalMs: number;
};
const READ_ID = /^[A-Za-z0-9][\w.-]{0,127}$/;
const CONTEXT_SOURCE_ID = /^[a-z0-9][a-z0-9-]{0,127}$/;
type ContextCommand = {
query: string | null;
queryFile?: string;
stdin: boolean;
kinds: Array<"raw" | "memory">;
sources: string[];
limit: number;
characterBudget: number;
timeoutMs: number;
};
function parseContextCommand(rest: readonly string[]): ContextCommand | null {
if (rest.filter((item) => item === "--json").length > 1) return null;
const tokens = rest.filter((item) => item !== "--json");
let query: string | null = null;
let queryFile: string | undefined;
let stdin = false;
const kinds: Array<"raw" | "memory"> = [];
const sources: string[] = [];
let limit = 8;
let characterBudget = 4_000;
let timeoutMs = 2_000;
let limitSet = false;
let characterBudgetSet = false;
let timeoutSet = false;
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token.startsWith("--")) {
if (query !== null || queryFile || stdin) return null;
query = token;
continue;
}
if (token === "--stdin") {
if (stdin || query !== null || queryFile) return null;
stdin = true;
continue;
}
const value = tokens[index + 1];
if (value === undefined || value.startsWith("--")) return null;
if (token === "--query-file") {
if (query !== null || queryFile || stdin) return null;
queryFile = value;
} else if (token === "--kind") {
if ((value !== "raw" && value !== "memory") || kinds.includes(value)) {
return null;
}
kinds.push(value);
} else if (token === "--source") {
if (!CONTEXT_SOURCE_ID.test(value) || sources.includes(value))
return null;
sources.push(value);
} else if (token === "--limit" && !limitSet) {
limit = Number(value);
limitSet = true;
} else if (token === "--character-budget" && !characterBudgetSet) {
characterBudget = Number(value);
characterBudgetSet = true;
} else if (token === "--timeout-ms" && !timeoutSet) {
timeoutMs = Number(value);
timeoutSet = true;
} else {
return null;
}
index += 1;
}
const selectedKinds: Array<"raw" | "memory"> =
kinds.length > 0 ? kinds : ["raw", "memory"];
if (
(query === null && !queryFile && !stdin) ||
(sources.length > 0 && !selectedKinds.includes("raw")) ||
!Number.isSafeInteger(limit) ||
limit < 1 ||
limit > 20 ||
!Number.isSafeInteger(characterBudget) ||
characterBudget < 200 ||
characterBudget > 20_000 ||
!Number.isSafeInteger(timeoutMs) ||
timeoutMs < 50 ||
timeoutMs > 30_000
) {
return null;
}
if (
query !== null &&
(query.length < 1 || query.length > CONTEXT_QUERY_MAX_CHARACTERS)
) {
return null;
}
return {
query,
...(queryFile ? { queryFile } : {}),
stdin,
kinds: selectedKinds,
sources,
limit,
characterBudget,
timeoutMs,
};
}
function parseReadCommand(
command: "sources" | "sync" | "progress",
rest: readonly string[],
): ReadCommand | null {
if (rest.filter((item) => item === "--json").length > 1) return null;
const tokens = rest.filter((item) => item !== "--json");
if (command === "sources") {
return tokens.length === 1 && tokens[0] === "list"
? { kind: "sources" }
: null;
}
if (command === "sync") {
if (tokens.length === 1 && tokens[0] === "status") return { kind: "sync" };
if (
(tokens[0] === "pause" || tokens[0] === "resume") &&
tokens.length === 3 &&
tokens[1] === "--source" &&
READ_ID.test(tokens[2])
) {
return { kind: "sync-set", action: tokens[0], sourceId: tokens[2] };
}
return null;
}
let jobId: string | undefined;
let issues = false;
let limit = 50;
let limitSet = false;
let watch = false;
let intervalSeconds = 5;
let intervalSet = false;
for (let index = 0; index < tokens.length; index += 1) {
const flag = tokens[index];
if (flag === "--issues") {
if (issues) return null;
issues = true;
continue;
}
if (flag === "--watch") {
if (watch) return null;
watch = true;
continue;
}
const value = tokens[index + 1];
if (value === undefined || value.startsWith("--")) return null;
if (flag === "--job" && jobId === undefined) jobId = value;
else if (flag === "--limit" && !limitSet) {
limit = Number(value);
limitSet = true;
} else if (flag === "--interval" && !intervalSet) {
intervalSeconds = Number(value);
intervalSet = true;
} else return null;
index += 1;
}
if (jobId !== undefined && !READ_ID.test(jobId)) return null;
if (issues && jobId === undefined) return null;
if (intervalSet && !watch) return null;
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) return null;
if (
!Number.isSafeInteger(intervalSeconds) ||
intervalSeconds < 1 ||
intervalSeconds > 300
) {
return null;
}
return {
kind: "progress",
jobId,
issues,
limit,
watch,
intervalMs: intervalSeconds * 1_000,
};
}
function emitReadDocument(
command: "sources" | "sync" | "progress",
document: SourcesDocument | SyncDocument | ProgressDocument,
asJson: boolean,
): void {
if (asJson) {
process.stdout.write(`${JSON.stringify(document)}\n`);
return;
}
process.stdout.write(
command === "sources"
? renderSourcesText(document as SourcesDocument)
: command === "sync"
? renderSyncText(document as SyncDocument)
: renderProgressText(document as ProgressDocument),
);
}
type MemoryCommand =
| { kind: "list"; query: MemoryQuery }
| { kind: "show"; memoryId: string; namespace: MemoryNamespace }
| {
kind: "add";
text?: string;
contentFile?: string;
requestId?: string;
}
| { kind: "add-status"; requestId: string }
| {
kind: "update";
memoryId: string;
contentFile: string;
namespace: MemoryNamespace;
task?: string;
}
| {
kind: "delete";
memoryId: string;
namespace: MemoryNamespace;
confirmed: boolean;
};
/** `--kind`/`--variant`: a chat memory takes no variant, an experience or a
* fact needs one (working|coding), and a variant never comes alone. */
function readNamespace(values: Map<string, string>): MemoryNamespace | null {
const kind = values.get("--kind");
const variant = values.get("--variant");
if (
kind !== undefined &&
!(MEMORY_KINDS as readonly string[]).includes(kind)
) {
return null;
}
if (
variant !== undefined &&
!(MEMORY_VARIANTS as readonly string[]).includes(variant)
) {
return null;
}
if (variant !== undefined && (kind === undefined || kind === "chat")) {
return null;
}
return {
...(kind === undefined ? {} : { kind: kind as MemoryKind }),
...(variant === undefined ? {} : { variant: variant as MemoryVariant }),
};
}
async function parseMemoryCommand(
rest: readonly string[],
): Promise<MemoryCommand | null> {
if (rest.filter((item) => item === "--json").length > 1) return null;
const tokens = rest.filter((item) => item !== "--json");
const action = tokens[0];
if (action === "add") {
// `memories add <text>` or `memories add --content-file <path>`.
const positional =
tokens[1] !== undefined && !tokens[1].startsWith("--")
? tokens[1]
: undefined;
const flags = readFlags(
tokens.slice(positional === undefined ? 1 : 2),
["--content-file", "--request-id"],
[],
);
if (!flags) return null;
const contentFile = flags.values.get("--content-file");
const requestId = flags.values.get("--request-id");
if ((positional === undefined) === (contentFile === undefined)) return null;
if (requestId !== undefined && !isRequestId(requestId)) return null;
return {
kind: "add",
...(positional === undefined ? {} : { text: positional }),
...(contentFile === undefined ? {} : { contentFile }),
...(requestId === undefined ? {} : { requestId }),
};
}
if (action === "add-status" || action === "status") {
return tokens.length === 2 && isRequestId(tokens[1])
? { kind: "add-status", requestId: tokens[1] }
: null;
}
if (action === "show" || action === "update" || action === "delete") {
const memoryId = tokens[1];
if (!memoryId || !isMemoryId(memoryId)) return null;
const flags = readFlags(
tokens.slice(2),
action === "update"
? ["--kind", "--variant", "--content-file", "--task"]
: ["--kind", "--variant"],
// `--confirm` is a hidden alias of `--yes` on delete; update is direct
// and accepts a legacy `--confirm`/`--yes` without effect.
action === "show" ? [] : ["--yes", "--confirm"],
);
if (!flags) return null;
const namespace = readNamespace(flags.values);
if (namespace === null) return null;
if (action === "show") return { kind: "show", memoryId, namespace };
if (action === "delete") {
return {
kind: "delete",
memoryId,
namespace,
confirmed: flags.switches.size > 0,
};
}
const contentFile = flags.values.get("--content-file");
const task = flags.values.get("--task");
if (contentFile === undefined) return null;
// Only an experience has a task; Core refuses it on anything else.
if (
task !== undefined &&
(namespace.kind !== "experience" ||
task.trim().length === 0 ||
[...task].length > 10_000)
) {
return null;
}
return {
kind: "update",
memoryId,
contentFile,
namespace,
...(task === undefined ? {} : { task }),
};
}
if (action !== "list") return null;
const flags = readFlags(
tokens.slice(1),
["--status", "--offset", "--limit", "--scene", "--source", "--query"],
[],
);
if (!flags) return null;
const status = flags.values.get("--status");
const scene = flags.values.get("--scene");
const source = flags.values.get("--source");
const query = flags.values.get("--query");
const limit = Number(flags.values.get("--limit") ?? 100);
const offset = Number(flags.values.get("--offset") ?? 0);
if (
status !== undefined &&
!(MEMORY_STATUSES as readonly string[]).includes(status)
) {
return null;
}
if (
scene !== undefined &&
!(MEMORY_SCENES as readonly string[]).includes(scene)
) {
return null;
}
if (source !== undefined && !SOURCE_ID_SHAPE.test(source)) return null;
if (
query !== undefined &&
(query.trim().length === 0 ||
[...query].length > 512 ||
// Core searches active memories only.
(status !== undefined && status !== "active"))
) {
return null;
}
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) return null;
if (!Number.isSafeInteger(offset) || offset < 0) return null;
return {
kind: "list",
query: {
...(status === undefined ? {} : { status }),
...(scene === undefined ? {} : { scene: scene as MemoryScene }),
...(source === undefined ? {} : { source }),
...(query === undefined ? {} : { query }),
offset,
limit,
},
};
}
async function readMemoryContentFile(file: string): Promise<string | null> {
const bytes = await readBoundedFile(file, 100_000 * 4 + 2);
return bytes ? parseMemoryContent(bytes) : null;
}
function emitModelWriteDocument(
document: ModelWriteDocument,
asJson: boolean,
): void {
emit(
asJson ? `${JSON.stringify(document)}\n` : renderModelWriteText(document),
);
}
/**
* Help is an exact request, not a token that wins wherever it appears:
* `<command> [--help|-h] [--json]` and nothing else. Anything extra goes back
* to the command's own validation, so a help flag can no longer stand in for a
* missing option value or hide a typo behind exit 0.
*/
function helpRequest(rest: readonly string[]): "none" | "help" | "invalid" {
const flags = rest.filter((item) => item === "--help" || item === "-h");
if (flags.length === 0) return "none";
const others = rest.filter((item) => item !== "--help" && item !== "-h");
const onlyJson =
others.length === 0 || (others.length === 1 && others[0] === "--json");
return flags.length === 1 && onlyJson ? "help" : "invalid";
}
function explain(topics: readonly string[], asJson: boolean): number {
const named = topics.filter((item) => item !== "--json");
const help = findCommand("help");
if (named.length > 1 || topics.length - named.length > 1) {
return reject(help, asJson, "参数无效。");
}
const requested =
named[0] === "--help" || named[0] === "-h" ? "help" : named[0];
if (requested === undefined) {
return emit(asJson ? renderCatalogDocument() : renderGeneralHelp());
}
const descriptor = findCommandOrAlias(requested);
if (!descriptor) return reject(undefined, asJson, "未知命令。");
return emit(
asJson ? renderCatalogDocument(descriptor) : renderCommandHelp(descriptor),
);
}
function emit(document: string): number {
process.stdout.write(document);
return EXIT_OK;
}
function emitLocalExperienceUnavailable(
command: string,
confirmed: boolean,
asJson: boolean,
): number {
const exitCode = confirmed ? EXIT_FAILED : EXIT_PLAN_BLOCKED;
const document = {
schema_version: 1,
product: PRODUCT,
command,
status: "blocked",
code: "local_experience_operation_unavailable",
reason: "体验版暂不支持",
apply: false,
exit_code: exitCode,
};
const output = asJson
? `${JSON.stringify(document)}\n`
: `${command}: 体验版暂不支持 [local_experience_operation_unavailable]\n`;
(confirmed ? process.stderr : process.stdout).write(output);
return exitCode;
}
function reject(
command: CommandDescriptor | undefined,
asJson: boolean,
message: string,
): number {
process.stderr.write(
asJson
? renderUsageErrorDocument(command, message)
: renderUsageError(command, message),
);
return EXIT_USAGE;
}
/** The Runtime is not running (or its identity did not verify).
*
* This is not an argument error: the arguments were fine, the backend is
* simply absent. Every other read command answers this with exit 3 and a
* `runtime_not_running` code; the lifecycle-adjacent commands that attach
* directly (`path`/`adapter` list+probe, `write`, `task`, `backup`,
* `applyPath`) used to fold it into `usage_error`/exit 2, which made a script
* unable to tell "you typed it wrong" from "start the Runtime".
*/
function runtimeUnavailable(
command: CommandDescriptor | undefined,
asJson: boolean,
): number {
if (asJson) {
// A result like any other: on stdout, so a `--json` caller reads every
// outcome from one stream (only usage errors go to stderr).
process.stdout.write(
`${JSON.stringify({
schema_version: 1,
product: PRODUCT,
command: command?.name,
state: "unavailable",
status: "error",
code: "runtime_not_running",
exit_code: EXIT_RUNTIME_STOPPED,
message: "Runtime 未运行或身份校验未通过。",
next_step: "运行 memhub start 启动 Runtime。",
})}\n`,
);
return EXIT_RUNTIME_STOPPED;
}
process.stderr.write(
`未完成:runtime_not_running\n下一步:运行 memhub start 启动 Runtime。\n`,
);
return EXIT_RUNTIME_STOPPED;
}
/** Run `planLocalInstall`, turning a planning failure into the structured error
* envelope instead of letting the throw escape as bare text.
*
* `install`/`upgrade` refuse an unmanaged `current`, a missing source, an
* overlapping root and similar conditions at the plan stage. Those used to
* surface as a raw `Error` on stderr even with `--json`, so a script got
* unparseable text. Returns the plan, or `null` after writing the envelope. */
async function planLocalInstallOrError(
command: "install" | "upgrade",
asJson: boolean,
source: string,
installRoot: string,
allowDowngrade: boolean,
): Promise<Awaited<ReturnType<typeof planLocalInstall>> | null> {
try {
return await planLocalInstall({
source,
installRoot,
platform: process.platform,
arch: process.arch,
allowDowngrade,
requireProtocolCompatibility: true,
});
} catch (error) {
process.stderr.write(
`${JSON.stringify(serviceErrorDocument(command, error), null, 2)}\n`,
);
return null;
}
}
/** A failed mutation, reported with the same envelope as a usage error.
*
* `runtime_service_command_failed` on its own told the caller nothing about
* which command failed or what to check. `next_step` names the two usual
* causes so the message is actionable without reading CLI source.
*/
function serviceErrorDocument(
command: string,
error: unknown,
): Record<string, unknown> {
const message =
error instanceof Error && error.message
? error.message
: "runtime_service_command_failed";
const code = message.split(":", 1)[0];
const nextSteps: Record<string, string> = {
runtime_install_incomplete:
"先运行 memhub install --yes 安装受管 Runtime,再执行 service-install。",
runtime_service_conflict_unmanaged_label:
"同名 LaunchAgent 不属于 MemHub;请先确认其来源,CLI 不会停止或覆盖它。",
runtime_service_conflict_unmanaged_file:
"服务文件不属于当前 MemHub 安装;请保留文件并确认其来源后再处理。",
runtime_service_managed_upgrade_failed:
"新服务启动失败,旧 MemHub 服务已恢复;检查新安装日志后重试。",
runtime_service_replacement_failed:
"新服务启动失败;记忆数据尚未处于新版本可用的状态,所以没有恢复旧服务。请运行 memhub doctor 检查服务状态。",
runtime_service_managed_upgrade_rollback_failed:
"新服务启动失败且旧服务恢复失败;请运行 memhub doctor 检查服务状态。",
runtime_service_file_modified:
"受管服务文件在注册后被改动,CLI 拒绝删除;请确认改动来源后重试或手工处理。",
runtime_service_file_unmanaged:
"服务文件不属于当前 MemHub 安装,CLI 拒绝删除;请确认其来源。",
runtime_service_registration_changed:
"注册信息在操作期间发生变化,未继续执行;请重新运行以读取最新状态。",
runtime_service_activation_unverified:
"服务已注册但 Runtime 未在预算内就绪;请运行 memhub logs 查看启动失败原因。",
release_version_unavailable:
"当前 CLI 没有可用于定位 Runtime 的 SemVer;请安装正式 npm 包,或使用 --source 指定离线发行目录。",
release_manifest_url_unavailable:
"当前发行构建没有内置 Release 下载源;请使用 --release-manifest-url、MEMHUB_RELEASE_MANIFEST_URL 或 MEMHUB_RELEASE_MANIFEST_URL_TEMPLATE 指定可信 HTTPS 地址。",
release_manifest_invalid:
"Release manifest 无效;请确认 CLI 与 Release 版本一致,并检查上传的 release-manifest.json。",
release_protocol_incompatible:
"当前 CLI 与该 Runtime 协议不兼容;请升级 CLI,或选择与当前 CLI 同版本的 Release。",
distribution_protocol_missing:
"Runtime 缺少兼容协议声明,已拒绝安装;请使用当前版本正式 Release 或重新构建离线包。",
distribution_protocol_incompatible:
"当前 CLI 与该离线 Runtime 协议不兼容;请升级 CLI 或使用匹配版本的 --source 包。",
release_artifact_unavailable:
"当前平台没有对应 Runtime 附件;请检查该版本 Release 的平台/架构资产。",
release_artifact_hash_mismatch:
"Runtime 下载校验失败,未进入安装事务;请重新下载或检查 Release 附件是否被替换。",
release_artifact_size_mismatch:
"Runtime 下载大小与 manifest 不一致,已拒绝安装;请检查 Release 附件。",
release_url_untrusted:
"Release 跳转到了未授权域名;请核对发布源,或显式配置 MEMHUB_RELEASE_ALLOWED_HOSTS。",
release_download_timeout:
"Runtime 下载超时且临时内容已清理;请检查网络/代理后重试。",
release_disk_space_insufficient:
"磁盘空间不足,未下载或切换 Runtime;请释放空间后重试。",
ENOENT:
command === "install" || command === "upgrade"
? "离线发行目录不存在或不完整;请检查 --source 路径及其中的 manifest.json,或移除 --source 改用同版本 Release。"
: "目标文件不存在;请核对路径后重试。",
};
const releaseHttpNextStep = code.startsWith("release_http_status_")
? "Release 请求失败;请确认同版本 Release 已发布、manifest URL 可访问且当前账号有权限。"
: undefined;
return {
schema_version: 1,
product: PRODUCT,
command,
status: "error",
code,
message,
exit_code: EXIT_FAILED,
next_step:
nextSteps[code] ??
releaseHttpNextStep ??
"运行 memhub doctor 检查 Runtime 与用户服务状态,再按报告处理。",
};
}
function parseServiceOptions(
arguments_: readonly string[],
allowConfirmation: boolean,
): { values: Map<string, string>; confirmed: boolean } | null {
let confirmed = false;
let json = false;
const filtered: string[] = [];
for (const item of arguments_) {
if (item === "--yes") {
if (!allowConfirmation || confirmed) return null;
confirmed = true;
} else if (item === "--json") {
// These commands always emit JSON. Accepting the flag keeps `--json`
// uniform across the CLI instead of failing with a usage error.
if (json) return null;
json = true;
} else filtered.push(item);
}
const values = parseOptions(filtered);
return values ? { values, confirmed } : null;
}
type JobsCommand =
| { kind: "list"; confirmed: false }
| { kind: "show"; jobId: string; confirmed: false }
| {
kind: "start";
sourceId: string;
operation: "history" | "sync";
confirmed: boolean;
}
| {
kind: "action";
action: "pause" | "resume" | "cancel";
jobId: string;
confirmed: boolean;
};
function parseJobsCommand(arguments_: readonly string[]): JobsCommand | null {
if (arguments_.filter((item) => item === "--json").length > 1) return null;
const normalized = arguments_.filter((item) => item !== "--json");
if (normalized.length === 1 && normalized[0] === "list") {
return { kind: "list", confirmed: false };
}
if (
normalized.length === 2 &&
normalized[0] === "show" &&
normalized[1] !== undefined &&
!normalized[1].startsWith("--")
) {
return { kind: "show", jobId: normalized[1], confirmed: false };
}
let confirmed = false;
const values = new Map<string, string>();
for (let index = 1; index < normalized.length; index += 1) {
const item = normalized[index];
if (item === "--confirm") {
if (confirmed) return null;
confirmed = true;
continue;
}
const value = normalized[index + 1];
if (
value === undefined ||
value.startsWith("--") ||
!["--source", "--operation", "--job"].includes(item ?? "") ||
values.has(item as string)
) {
return null;
}
values.set(item as string, value);
index += 1;
}
if (normalized[0] === "start") {
const sourceId = values.get("--source");
const operation = values.get("--operation") ?? "history";
if (
sourceId === undefined ||
values.size !== (values.has("--operation") ? 2 : 1) ||
(operation !== "history" && operation !== "sync")
) {
return null;
}
return { kind: "start", sourceId, operation, confirmed };
}
const action = normalized[0];
const jobId = values.get("--job");
if (
(action !== "pause" && action !== "resume" && action !== "cancel") ||
jobId === undefined ||
values.size !== 1
) {
return null;
}
return { kind: "action", action, jobId, confirmed };
}
async function readJobs(reader: CoreReader): Promise<JobFlowDocument> {
const result = await reader<unknown>("/api/v1/import-jobs");
if (result.state !== "ok") {
return {
schema_version: 1,
product: PRODUCT,
command: "jobs",
state: result.state === "not_found" ? "not_found" : "rejected",
code: result.code,
};
}
const jobs = Array.isArray(result.data)
? result.data.map(validateImportJob)
: [];
if (jobs.some((job) => job === null)) {
return {
schema_version: 1,
product: PRODUCT,
command: "jobs",
state: "invalid",
code: "job_receipt_invalid",
};
}
return {
schema_version: 1,
product: PRODUCT,
command: "jobs",
state: "committed",
operation_id: "list",
jobs: jobs.filter((job): job is ImportJob => job !== null),
} as unknown as JobFlowDocument;
}
async function readJob(
reader: CoreReader,
jobId: string,
): Promise<JobFlowDocument> {
const result = await reader<unknown>(
`/api/v1/import-jobs/${encodeURIComponent(jobId)}`,
);
if (result.state !== "ok") {
return {
schema_version: 1,
product: PRODUCT,
command: "jobs",
state: result.state === "not_found" ? "not_found" : "rejected",
operation_id: jobId,
code: result.code,
};
}
const job = validateImportJob(result.data);
if (!job) {
return {
schema_version: 1,
product: PRODUCT,
command: "jobs",
state: "invalid",
operation_id: jobId,
code: "job_receipt_invalid",
};
}
return {
schema_version: 1,
product: PRODUCT,
command: "jobs",
state: "committed",
operation_id: jobId,
job,
};
}
function emitJobDocument(document: JobFlowDocument, asJson: boolean): void {
if (asJson) {
process.stdout.write(`${JSON.stringify(document)}\n`);
return;
}
if (document.state === "preview") {
process.stdout.write(`计划:${JSON.stringify(document.plan)}\n`);
return;
}
process.stdout.write(`${JSON.stringify(document)}\n`);
}
function emitJobUnavailable(code: string, asJson: boolean): void {
const document = {
schema_version: 1,
product: PRODUCT,
command: "jobs",
state: "rejected",
code,
} as const;
process.stdout.write(asJson ? `${JSON.stringify(document)}\n` : `${code}\n`);
}
function parseInstallOptions(arguments_: readonly string[]): {
source: string;
sourceExplicit: boolean;
releaseManifestUrl?: string;
releaseChannel: "stable" | "prerelease";
installRoot: string;
home: string;
uid?: number;
xdgConfigHome?: string;
binDirectory?: string;
dataDir?: string;
shellPathDisabled: boolean;
shellProfile?: string;
preserveDataDir: boolean;
allowDowngrade: boolean;
confirmed: boolean;
} | null;
function parseInstallOptions(
arguments_: readonly string[],
environment: NodeJS.ProcessEnv,
executable: string | undefined,
): {
source: string;
sourceExplicit: boolean;
releaseManifestUrl?: string;
releaseChannel: "stable" | "prerelease";
installRoot: string;
home: string;
uid?: number;
xdgConfigHome?: string;
binDirectory?: string;
dataDir?: string;
shellPathDisabled: boolean;
shellProfile?: string;
preserveDataDir: boolean;
allowDowngrade: boolean;
confirmed: boolean;
} | null;
function parseInstallOptions(
arguments_: readonly string[],
environment: NodeJS.ProcessEnv = process.env,
executable: string | undefined = process.argv[1],
) {
let source: string | undefined;
let releaseManifestUrl: string | undefined;
let releaseChannel: "stable" | "prerelease" | undefined;
let installRoot: string | undefined;
let home: string | undefined;
let uid: number | undefined;
let xdgConfigHome: string | undefined;
let binDirectory: string | undefined;
let dataDir: string | undefined;
let shellPathDisabled = false;
let shellProfile: string | undefined;
let preserveDataDir = false;
let allowDowngrade = false;
let confirmed = false;
let json = false;
for (let index = 0; index < arguments_.length; index += 1) {
const item = arguments_[index];
if (item === "--yes") {
if (confirmed) return null;
confirmed = true;
continue;
}
// Isolation escape hatch: a caller that manages its own PATH (or runs under
// an isolated HOME) must be able to keep memhub out of the login shell
// profile entirely. Without this, `install --yes` wrote the real
// `~/.zprofile` even when HOME/install-root/data-dir were all explicit.
if (item === "--no-shell-path") {
if (shellPathDisabled) return null;
shellPathDisabled = true;
continue;
}
// C7's uninstall guard refuses when the data dir lives inside the subtree
// it deletes; this flag is the explicit, per-invocation escape hatch. It
// changes only which directory the plan protects -- never deletes data.
if (item === "--preserve-data-dir") {
if (preserveDataDir) return null;
preserveDataDir = true;
continue;
}
// Explicit, per-invocation opt-in for installing a lower build. The plan
// records it, so apply re-verifies under the same authorization; it is
// never enabled by an environment variable or persisted config.
if (item === "--allow-downgrade") {
if (allowDowngrade) return null;
allowDowngrade = true;
continue;
}
if (item === "--json") {
// `install`/`upgrade`/`uninstall` always emit JSON; accept the flag so it
// is uniform across commands instead of a usage error. A duplicate is
// still a malformed argv and must fail closed.
if (json) return null;
json = true;
continue;
}
const value = arguments_[index + 1];
if (!value || value.startsWith("--")) return null;
if (item === "--source" && source === undefined) source = value;
else if (
item === "--release-manifest-url" &&
releaseManifestUrl === undefined
)
releaseManifestUrl = value;
else if (item === "--channel" && releaseChannel === undefined) {
if (value !== "stable" && value !== "prerelease") return null;
releaseChannel = value;
} else if (item === "--install-root" && installRoot === undefined)
installRoot = value;
else if (item === "--home" && home === undefined) home = value;
else if (item === "--xdg-config-home" && xdgConfigHome === undefined)
xdgConfigHome = value;
else if (item === "--bin-dir" && binDirectory === undefined)
binDirectory = value;
else if (item === "--data-dir" && dataDir === undefined) dataDir = value;
else if (item === "--shell-profile" && shellProfile === undefined)
shellProfile = value;
else if (item === "--uid" && uid === undefined) {
uid = Number(value);
if (!Number.isSafeInteger(uid) || uid < 1) return null;
} else return null;
index += 1;
}
if (source !== undefined && releaseManifestUrl !== undefined) return null;
const resolvedHome = home ?? environment.HOME ?? os.homedir();
const resolvedDataDir = serviceDataDirectory(
dataDir,
environment,
process.platform,
);
if (resolvedDataDir === null) return null;
const inferredSource = executable
? path.resolve(path.dirname(executable), "..")
: undefined;
const resolvedSource = source ?? inferredSource;
const resolvedInstallRoot =
installRoot ??
defaultInstallRoot(
process.platform,
resolvedHome,
environment.LOCALAPPDATA,
hourlyIsolationFromEnvironment(environment).hourlyPackageId,
);
return resolvedSource
? {
source: resolvedSource,
sourceExplicit: source !== undefined,
...(releaseManifestUrl ? { releaseManifestUrl } : {}),
releaseChannel:
releaseChannel ??
(RUNTIME_RELEASE_VERSION.includes("-") ? "prerelease" : "stable"),
installRoot: resolvedInstallRoot,
home: resolvedHome,
uid: uid ?? process.getuid?.(),
xdgConfigHome,
binDirectory,
dataDir: resolvedDataDir,
shellPathDisabled,
...(shellProfile ? { shellProfile } : {}),
preserveDataDir,
allowDowngrade,
confirmed,
}
: null;
}
/** True when this install targets something other than the real account home.
*
* `memhub install --home <dir>` (or a `HOME` env that differs from the OS
* account home) is an isolated or scripted install. Writing the login shell
* profile there would edit host configuration the caller never asked us to
* change, so the automatic PATH block is skipped; the caller can still opt in
* with `--shell-profile <path>`.
*/
function isIsolatedHome(home: string): boolean {
const resolved = path.resolve(home);
let accountHome: string;
try {
accountHome = os.userInfo().homedir;
} catch {
accountHome = os.homedir();
}
// A real install resolves to the account home; anything else (an explicit
// --home, or an isolated HOME in CI/packaged tests) is deliberately scoped.
return accountHome ? resolved !== path.resolve(accountHome) : true;
}
function serviceDataDirectory(
explicit: string | undefined,
environment: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
): string | undefined | null {
const value = explicit ?? environment.ONE_MEMORY_DATA_DIR;
const paths = platform === "win32" ? path.win32 : path.posix;
if (
value !== undefined &&
(!paths.isAbsolute(value) || /[\0\r\n]/.test(value))
) {
return null;
}
return value;
}
/** Parse `delete-data`: preview by default; deleting needs `--yes` AND
* `--confirm <path>` repeating the exact directory (`--yes` alone is refused). */
function parseDataDeletionCommand(
arguments_: readonly string[],
): { dataDir?: string; confirmed: boolean; confirmPath?: string } | null {
if (arguments_.filter((item) => item === "--yes").length > 1) return null;
if (arguments_.filter((item) => item === "--json").length > 1) return null;
let dataDir: string | undefined;
let confirmPath: string | undefined;
let confirmed = false;
for (let index = 0; index < arguments_.length; index += 1) {
const item = arguments_[index];
if (item === "--yes") {
confirmed = true;
continue;
}
if (item === "--json") continue;
const value = arguments_[index + 1];
if (!value || value.startsWith("--")) return null;
if (item === "--data-dir" && dataDir === undefined) dataDir = value;
else if (item === "--confirm" && confirmPath === undefined)
confirmPath = value;
else return null;
index += 1;
}
return { dataDir, confirmed, confirmPath };
}
/** Extra managed links the CLI must create beside `runtime-control`'s own.
*
* `runtime-control/cli-link.ts` owns the managed link names. Its current shape
* (commit `01eedd8`) declares a `compat_link_path`, meaning it creates BOTH
* `<bin>/memhub` and `<bin>/one-memory` inside `installCliLink`. In that case
* the CLI manages none of those two: planning against the pre-install state is
* inherently stale, and creating them too raised `cli_alias_plan_stale`.
*
* The objective nonetheless names `memhub` AND `memhub-cli` as the primary
* short entries (plus `one-memory` as a compatibility alias). The npm tarball
* ships all three `bin` names; the managed installer must create the same set,
* or "装完即短指令可用" is only true for whichever names the package happened
* to install. So the CLI always plans the ones runtime-control does not own.
*/
const OBJECTIVE_CLI_ALIASES = ["memhub", "memhub-cli", "one-memory"] as const;
function cliCompanionLinkNames(cliLink: {
link_path: string;
compat_link_path?: string;
}): string[] {
const owned = new Set<string>();
const primary = path.posix.basename(cliLink.link_path);
owned.add(primary);
if (typeof cliLink.compat_link_path === "string") {
owned.add(path.posix.basename(cliLink.compat_link_path));
}
return OBJECTIVE_CLI_ALIASES.filter((name) => !owned.has(name));
}
/** Plan the CLI's companion links for install/upgrade (possibly empty).
*
* `runtime-control` creates its primary link and the `compat_link_path` itself,
* so the CLI must not duplicate those on install (`cli_alias_plan_stale`).
*/
async function planCliCompanionLinks(
cliLink: Parameters<typeof planManagedCliLink>[0],
) {
const names = cliCompanionLinkNames(cliLink);
return Promise.all(names.map((name) => planManagedCliLink(cliLink, name)));
}
/** Plan the links the CLI must *remove* on uninstall.
*
* `applyLocalUninstall` only deletes `plan.cli_link`; it never deletes the
* `compat_link_path` it created, and it deletes `versions/`+`current` first, so
* a compat link left behind becomes a dangling entry on PATH. The CLI therefore
* cleans up every objective alias that is not the primary link, whether or not
* `runtime-control` created it. Removing a link that is already gone is a
* no-op (`not-installed`), so this is safe for installs that predate an alias.
*/
async function planCliRemovalLinks(
cliLink: Parameters<typeof planManagedCliLink>[0],
) {
const primary = path.posix.basename(cliLink.link_path);
const names = OBJECTIVE_CLI_ALIASES.filter((name) => name !== primary);
return Promise.all(names.map((name) => planManagedCliLink(cliLink, name)));
}
export function defaultInstallRoot(
platform: NodeJS.Platform,
home: string,
localAppData?: string,
hourlyPackageId?: string,
): string {
if (platform === "darwin") {
return defaultManagedInstallRoot(
platform,
home,
localAppData,
hourlyPackageId !== undefined,
hourlyPackageId,
);
}
if (platform === "linux" || platform === "win32") {
return defaultManagedInstallRoot(platform, home, localAppData);
}
return path.join(home, ".local", "share", "one-memory");
}
/** Commands that parse `--data-dir` themselves instead of the global lift. */
const OWN_DATA_DIR_COMMANDS = new Set([
"install",
"uninstall",
"upgrade",
"service-plan",
"service-install",
"service-uninstall",
"delete-data",
"help",
"--help",
"-h",
]);
/** Remove one `--data-dir <absolute path>` from argv; null when it is
* repeated, missing its value, or not an absolute path. */
/** Memory commands that honour the global `--user-id`. */
const USER_ID_COMMANDS = new Set([
"memories",
"recall",
"profile",
"write",
"evolve",
]);
/**
* Take `--user-id <id>` out of the arguments. The default user (`local`) is
* returned as undefined, so a default request names no user at all and stays
* byte-identical to one sent before multi-user support.
*/
function liftUserIdOption(
arguments_: readonly string[],
): { arguments_: string[]; userId?: string } | null {
const index = arguments_.indexOf("--user-id");
if (index === -1) return { arguments_: [...arguments_] };
if (arguments_.indexOf("--user-id", index + 1) !== -1) return null;
const value = arguments_[index + 1];
if (value === undefined || value.startsWith("--") || !isUserId(value)) {
return null;
}
return {
arguments_: [...arguments_.slice(0, index), ...arguments_.slice(index + 2)],
...(value === DEFAULT_USER_ID ? {} : { userId: value }),
};
}
function liftDataDirOption(
arguments_: readonly string[],
): { arguments_: string[]; dataDir?: string } | null {
const index = arguments_.indexOf("--data-dir");
if (index === -1) return { arguments_: [...arguments_] };
if (arguments_.indexOf("--data-dir", index + 1) !== -1) return null;
const value = arguments_[index + 1];
if (
value === undefined ||
value.startsWith("--") ||
!path.isAbsolute(value) ||
/[\0\r\n]/.test(value)
) {
return null;
}
return {
arguments_: [...arguments_.slice(0, index), ...arguments_.slice(index + 2)],
dataDir: value,
};
}
/**
* Old spellings that still work but are no longer listed in help:
*
* - `task --id <id>` -> `write status --id <id>`
* - `path ...` -> `scan-roots ...`
* - `graph width ...` -> `graph-width ...`
* - `init --source/--all-detected ...` -> `sources connect ...`
*/
function rewriteLegacyCommand(arguments_: readonly string[]): string[] {
const [first, ...rest] = arguments_;
if (first === "task") {
// `task --help` explains `write`; anything else is `write status`.
return rest.some((item) => item === "--help" || item === "-h")
? ["write", ...rest]
: ["write", "status", ...rest];
}
if (first === "path") return ["scan-roots", ...rest];
if (first === "graph" && rest[0] === "width") {
return ["graph-width", ...rest.slice(1)];
}
if (
first === "init" &&
rest.some((item) => item === "--source" || item === "--all-detected")
) {
return ["sources", "connect", ...rest];
}
return [...arguments_];
}
/** Fill in the install-time data dir when the caller did not name one.
*
* Precedence: explicit `ONE_MEMORY_DATA_DIR` > managed install receipt >
* default `~/.one-memory`. A receipt is only a fallback; an explicit flag or
* environment variable always wins so scripts and tests stay deterministic.
*/
async function environmentWithManagedDataDir(
environment: NodeJS.ProcessEnv,
): Promise<NodeJS.ProcessEnv> {
if (environment.ONE_MEMORY_DATA_DIR !== undefined) return environment;
const dataDir = await resolveInstalledDataDir(environment);
return dataDir === undefined
? environment
: { ...environment, ONE_MEMORY_DATA_DIR: dataDir };
}
function runtimeEnvironment(
config: ReturnType<typeof resolveRuntimeCliConfig>,
): NodeJS.ProcessEnv {
return {
ONE_MEMORY_RUNTIME_ENTRY: config.runtimeEntry,
ONE_MEMORY_DATA_DIR: config.dataDir,
ONE_MEMORY_CORE_COMMAND: config.coreCommand,
ONE_MEMORY_MCP_ENTRY: config.mcpEntryPath,
ONE_MEMORY_RECALL_CLI: config.recallCliPath,
ONE_MEMORY_SYNC_HOOK_ASSETS: config.syncHookAssetsDirectory,
ONE_MEMORY_NODE_COMMAND: config.nodeCommand,
};
}