Compare commits

..
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 dd3809a34b chore(release): bump MemHub build number to 32
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 20:16:09 +08:00
alvinfeiandClaude Opus 5.5 efe7df4048 feat(memhub): delete-data and backup restore need the path typed twice
`--yes` alone no longer deletes data or restores a backup: the target
directory has to be repeated with `--confirm <path>`. Without it the
command refuses with exit 4 and shows the exact command to run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 19:49:30 +08:00
alvinfeiandClaude Opus 5.5 072e0e5e90 feat(memhub): simplify the CLI and drop the notes concept
API: adding one memory is POST /api/v2/memories with its status at
GET /api/v2/memories/additions/{request_id}; /api/v1/memory-notes is
removed. Error codes memory_note_* become memory_add_*, contracts types
MemoryAddInput/MemoryAddReceipt, broker /memories, MCP MemHub_write and
the web composer follow. Stored names (memory_note_requests,
one-memory-manual, direct_memory_note) stay. /api/v2/memory-library
takes an optional scene filter.

CLI:
- No plan files. memories add/add-status, models add/test/receipt and
  sources connect execute directly; async receipts stay.
- One --yes, only for irreversible operations (memories delete, models
  remove, delete-data, backup restore, graph-width rebuild); without it
  they preview and exit 0. --confirm is a hidden alias.
- Removed notes, calls, setup plan, sources import, models
  plan/configure/probe*, and the unreachable processing, extraction and
  import-file commands, with their dead allow-list entries.
- memories list gets --scene/--source/--query; show/update/delete take
  --kind/--variant (and --task). All memories JSON is schema_version 2.
- Fixes from the CLI review: global --data-dir, real
  --confirm-overrides and help that matches init, server failures exit 1
  not 2, catalog exit codes match, JSON errors on stdout, bad filters are
  rejected instead of "busy", write submit prints its request_id for
  retries, recall labels raw excerpts, stop says it disables login
  auto-start, backup help says memories are not included yet.
- Hidden aliases: path -> scan-roots, task -> write status,
  graph width -> graph-width, init --source -> sources connect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 19:21:34 +08:00
feitianxiang a0221bc434 Merge pull request #196 from Tencent-Hunyuan/fix/migration-failure-freeze
fix(memhub): restore and freeze the home when an upgrade migration fails
2026-10-27 19:18:19 +08:00
alvinfeiandClaude Opus 5.5 754555e3c3 fix(memhub): restore and freeze the home when an upgrade migration fails
A failed SDK migration left a half-migrated home (schema migrations,
moved legacy work, renamed Chroma collections) that the next run
resumed on top of, the maintenance fence re-enabled the LaunchAgent so
launchd retried at once, a deferred service idled forever, and capture
hooks kept writing into the legacy database.

Restore: before the first mutation the run records a restore point
(the pre-cutover bundle, or a fresh bundle when resuming an old ledger
row). Any failure before SDK authority is committed restores the
management database (with WAL), config.json and hy-memory/governed-v2
through a verified staging copy and a journaled rename swap; the failed
state is kept aside in backups/sdk-migration-failed/. A failure after
the commit, or an unreadable authority, never restores. An interrupted
swap is finished on the next start; meanwhile status reports required
and Core refuses to serve. The migration's own code stays the result;
the restore outcome is logged and reported separately.

Freeze: a deferred migration turns the service enable marker off, makes
the runtime service exit instead of idling, and leaves a frozen capture
fence that hooks honour. Desktop launch, memhub install/upgrade and now
start/restart re-run the migration and unfreeze on success, restoring
the previous auto-start.

The failure dialog says when data was restored, and its GitHub issue now
prefills the body: from/to version, code, failing step, whether data was
restored, macOS version and the log path shown with ~. migrate.invoked
records the pre-upgrade version. sdk_data_plane_* codes now reach the
dialog too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 19:11:04 +08:00
feitianxiang b43c967baa Merge pull request #195 from Tencent-Hunyuan/feat/memory-panel-all-scenes
feat(memhub): memory panel lists and searches chat, working and coding
2026-10-27 18:04:32 +08:00
107 changed files with 8664 additions and 7366 deletions

No files matched your search

+14
View File
@@ -172,6 +172,20 @@ result = client.add_extracted(
该入口仍走 SDK reconcile/持久化;MemHub 只做管理面门禁与可选
`memory_id` 记录。
应用的「添加记忆」、MCP `MemHub_write`(经 broker `POST /memories` 以本机可信身份转发)
共用同一个本机 HTTP 合同,直接新增一条记忆:
```text
POST /api/v2/memories {request_id, text, scope: "all-apps"}
GET /api/v2/memories/additions/{request_id} # 该次新增的状态
```
两者返回同一回执(`evidence_id`、`text`、`scope`、`created_at`、`state`、`task_id`、
`memory_ids`、`error`)。同一 `request_id` 重放返回原回执;内容不同返回 409
`memory_add_conflict`,未知 `request_id` 返回 404 `memory_add_not_found`。新增走持久
写入队列的优先通道,最终调用 `add_extracted` + reconcile。带外部读取信封的请求
一律拒绝(403 `external_endpoint_denied`)。
## Search:搜索记忆
```python
+3 -2
View File
@@ -109,8 +109,9 @@ Node、Host Bridge 或模型。用户显式执行 `memhub install` 后,CLI 才
Python Core 和运行所需的 Python 依赖位于 MemHub 受管 Runtime,不写入系统 Python。
MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime 版本目录中。
`npm uninstall` 只移除 npm CLI;`memhub uninstall` 移除服务、受管 Runtime 和 CLI 链接,
默认保留 Memory。只有独立的 `memhub delete-data` 在 Runtime 停止且二次确认精确路径后
才会删除数据。
默认保留 Memory。只有独立的 `memhub delete-data` 在 Runtime 停止、显式加 `--yes` 并用
`--confirm <数据目录>` 再写一遍同一个目录后才会删除数据(不加参数只预览要删除的目录)。`memhub stop` 停止受管服务时也会关闭
登录自动启动,再次 `memhub start` 会恢复。
当前平台能力和安装边界以源码、CI 与发布说明为准。未完成真机验收的平台不会仅凭跨平台
测试宣称可用。
@@ -30,9 +30,7 @@ const REQUIRED_COMMAND_SURFACE = [
"sync",
"memories",
"profile",
"calls",
"notes",
"graph",
"graph-width",
"models",
"host",
"doctor",
@@ -50,19 +48,17 @@ const REQUIRED_COMMAND_SURFACE = [
"uninstall",
"delete-data",
"recall",
"setup",
"scan",
"progress",
"jobs",
"path",
"scan-roots",
"adapter",
"write",
"task",
"serve",
];
const REQUIRED_MARKERS = [
"backup_restore_confirmation_required",
"backup_restore_confirmation_mismatch",
"backup_restore_runtime_state_unknown",
"backup_restore_maintenance_identity_invalid",
"runtime_service_activation_unverified",
@@ -79,16 +75,18 @@ const REQUIRED_MARKERS = [
* exact literal the catalog usage string contains.
*/
const REQUIRED_SUBCOMMANDS = [
{ command: "calls", marker: "memhub calls clear" },
{ command: "notes", marker: "memhub notes add" },
{ command: "notes", marker: "memhub notes process" },
{ command: "memories", marker: "memhub memories add" },
{ command: "memories", marker: "memhub memories add-status" },
{ command: "memories", marker: "memhub memories update" },
{ command: "memories", marker: "memhub memories delete" },
{ command: "write", marker: "memhub write submit" },
{ command: "write", marker: "memhub write status" },
{ command: "graph", marker: "memhub graph width" },
{ command: "graph-width", marker: "memhub graph-width --decision" },
{ command: "models", marker: "memhub models add" },
{ command: "models", marker: "memhub models test" },
{ command: "models", marker: "memhub models edit" },
{ command: "models", marker: "memhub models activate" },
{ command: "sources", marker: "memhub sources connect" },
{ command: "jobs", marker: "memhub jobs start" },
{ command: "jobs", marker: "memhub jobs <pause|resume|cancel>" },
];
@@ -100,57 +98,30 @@ const REQUIRED_SUBCOMMANDS = [
* counterexamples on the packaged Runtime") had no machine-checkable probe.
* These run without `--lifecycle`, so they are safe against any package.
*/
const PROFILE_ID = "44444444-4444-4444-8444-444444444444";
const STOPPED_DESTRUCTIVE_PROBES = [
{ name: "memories_add", args: ["memories", "add", "synthetic memory"] },
{
name: "calls_clear_preview",
args: ["calls", "clear", "--event", "11111111-1111-4111-8111-111111111111"],
},
{
name: "calls_clear_apply",
args: [
"calls",
"clear",
"--event",
"11111111-1111-4111-8111-111111111111",
"--confirm",
],
},
{
name: "calls_clear_all_apply",
args: ["calls", "clear", "--all", "--confirm"],
},
{
name: "notes_process_apply",
args: [
"notes",
"process",
"33333333-3333-4333-8333-333333333333",
"--confirm",
],
name: "memories_delete_apply",
args: ["memories", "delete", "m-1", "--yes"],
},
{
name: "graph_width_apply",
args: ["graph", "width", "--decision", "keep_existing", "--confirm"],
args: ["graph-width", "--decision", "keep_existing"],
},
{
name: "models_activate_apply",
args: [
"models",
"activate",
"44444444-4444-4444-8444-444444444444",
"--confirm",
],
name: "graph_width_rebuild_apply",
args: ["graph-width", "--decision", "rebuild_with_new", "--yes"],
},
{ name: "models_activate_apply", args: ["models", "activate", PROFILE_ID] },
{ name: "models_test", args: ["models", "test", PROFILE_ID] },
{
name: "models_remove_apply",
args: ["models", "remove", PROFILE_ID, "--yes"],
},
{
name: "models_edit_apply",
args: [
"models",
"edit",
"44444444-4444-4444-8444-444444444444",
"--config-file",
"/nonexistent/patch.json",
"--confirm",
],
name: "sources_connect_import",
args: ["sources", "connect", "codex", "--import"],
},
];
@@ -119,6 +119,7 @@ describe("A19 batch execution", () => {
readerFor([]),
operations,
planned.notFound,
{ confirmOverrides: false },
);
// Exactly one request for two sources — the batch endpoint, per contract.
expect(post).toHaveBeenCalledTimes(1);
@@ -165,6 +166,7 @@ describe("A19 batch execution", () => {
readerFor([]),
operations,
planned.notFound,
{ confirmOverrides: false },
);
expect(document.state).toBe("partial");
const byId = Object.fromEntries(
@@ -206,6 +208,7 @@ describe("A19 batch execution", () => {
readerFor([]),
operations,
[],
{ confirmOverrides: false },
);
expect(document.sources.every((row) => row.status === "failed")).toBe(true);
expect(
@@ -249,6 +252,7 @@ describe("A19 batch execution", () => {
readerFor([]),
operations,
[],
{ confirmOverrides: false },
);
const busy = document.sources.find((row) => row.source_id === "cursor");
expect(document.state).toBe("partial");
@@ -288,6 +292,7 @@ describe("A19 batch execution", () => {
readerFor([]),
operations,
planned.notFound,
{ confirmOverrides: false },
);
const notFoundRow = document.sources.find(
(row) => row.source_id === "cursor",
@@ -296,3 +301,82 @@ describe("A19 batch execution", () => {
expect(document.not_found_source_ids).toContain("cursor");
});
});
describe("environment-override sources need --confirm-overrides", () => {
it("leaves them out and names them unless confirmed", async () => {
const planned = await planBatchImport(
readerFor([
found("codex"),
found("dsh", {
candidate_kind: "environment_override",
requires_override_confirmation: true,
}),
]),
);
if (planned.state !== "preview") throw new Error("expected preview");
const post = vi.fn(
async (_path: string, body: Record<string, unknown>) => ({
state: "committed" as const,
data: {
request_id: body.request_id,
accepted_source_ids: body.source_ids,
failed_sources: {},
jobs: [],
},
}),
);
const operations = { post } as unknown as CoreOperations;
const skipped = await executeBatchImport(
planned.candidates,
readerFor([]),
operations,
planned.notFound,
{ confirmOverrides: false },
);
expect(post.mock.calls[0]![1]).toMatchObject({
source_ids: ["codex"],
confirm_environment_overrides: false,
});
expect(skipped.override_required_source_ids).toEqual(["dsh"]);
expect(renderBatchScanText(skipped)).toContain("--confirm-overrides");
const confirmed = await executeBatchImport(
planned.candidates,
readerFor([]),
operations,
planned.notFound,
{ confirmOverrides: true },
);
expect(post.mock.calls[1]![1]).toMatchObject({
source_ids: ["codex", "dsh"],
confirm_environment_overrides: true,
});
expect(confirmed.override_required_source_ids).toBeUndefined();
});
it("refuses without a request when every detected source needs confirmation", async () => {
const planned = await planBatchImport(
readerFor([
found("dsh", {
candidate_kind: "environment_override",
requires_override_confirmation: true,
}),
]),
);
if (planned.state !== "preview") throw new Error("expected preview");
const post = vi.fn();
const document = await executeBatchImport(
planned.candidates,
readerFor([]),
{ post } as unknown as CoreOperations,
planned.notFound,
{ confirmOverrides: false },
);
expect(post).not.toHaveBeenCalled();
expect(document).toMatchObject({
state: "rejected",
code: "environment_override_confirmation_required",
});
expect(batchScanExitCode(document)).toBe(1);
});
});
+47 -9
View File
@@ -1,4 +1,5 @@
/** A19: one-shot scan/import of every *detected* source.
/** A19: one-shot connect/import of every *detected* source
* (`sources connect --all-detected --import`).
*
* The engine already takes many sources in a single `POST /api/v1/sources/connect`
* (`source_ids` is a 1..32 tuple) and answers per source. The CLI used to reject
@@ -37,13 +38,16 @@ export interface BatchSourceRow {
export interface BatchScanDocument {
schema_version: 1;
product: string;
command: "scan" | "init";
command: "sources connect";
state: "committed" | "partial" | "rejected" | "unknown" | "no_sources";
request_id: string | null;
accepted_source_ids: string[];
sources: BatchSourceRow[];
/** `location_state != found` rows are listed so the user can see what was skipped. */
not_found_source_ids: string[];
/** Detected sources whose location comes from an environment override and
* that were left out because `--confirm-overrides` was not given. */
override_required_source_ids?: string[];
code?: string;
}
@@ -118,12 +122,39 @@ export async function planBatchImport(reader: CoreReader): Promise<
/** Run the batch connect once and fold the per-source receipt into rows. */
export async function executeBatchImport(
candidates: readonly SourceConnectionCandidate[],
detected: readonly SourceConnectionCandidate[],
reader: CoreReader,
operations: CoreOperations,
notFound: readonly string[],
options: { confirmOverrides: boolean },
): Promise<BatchScanDocument> {
const requestId = randomUUID();
// An environment-override location is a choice the user has to confirm:
// without `--confirm-overrides` those sources are left out and named.
const skipped = options.confirmOverrides
? []
: detected
.filter((candidate) => candidate.requires_override_confirmation)
.map((candidate) => candidate.source_id);
const overrideFields =
skipped.length > 0 ? { override_required_source_ids: skipped } : {};
const candidates = detected.filter(
(candidate) => !skipped.includes(candidate.source_id),
);
if (candidates.length === 0) {
return {
schema_version: 1,
product: PRODUCT,
command: "sources connect",
state: "rejected",
request_id: null,
accepted_source_ids: [],
sources: [],
not_found_source_ids: [...notFound],
...overrideFields,
code: "environment_override_confirmation_required",
};
}
// Same shared builder as the single-source import and the Web console, so a
// batch connect cannot drift from either.
const request = buildSourceImportRequest({
@@ -141,9 +172,9 @@ export async function executeBatchImport(
: [[candidate.source_id, candidate.candidate_revision]],
),
),
confirmEnvironmentOverrides: candidates.some(
(candidate) => candidate.requires_override_confirmation,
),
confirmEnvironmentOverrides:
options.confirmOverrides &&
candidates.some((candidate) => candidate.requires_override_confirmation),
fullRescan: false,
onboarding: true,
enableSync: true,
@@ -154,7 +185,7 @@ export async function executeBatchImport(
return {
schema_version: 1,
product: PRODUCT,
command: "scan",
command: "sources connect",
state: result.state,
request_id: requestId,
accepted_source_ids: [],
@@ -168,6 +199,7 @@ export async function executeBatchImport(
failure_reason: result.code,
})),
not_found_source_ids: [...notFound],
...overrideFields,
code: result.code,
};
}
@@ -254,7 +286,7 @@ export async function executeBatchImport(
return {
schema_version: 1,
product: PRODUCT,
command: "scan",
command: "sources connect",
state:
failedCount === 0
? "committed"
@@ -270,6 +302,7 @@ export async function executeBatchImport(
accepted_source_ids: accepted,
sources: rows,
not_found_source_ids: [...notFound],
...overrideFields,
};
}
@@ -301,5 +334,10 @@ export function renderBatchScanText(document: BatchScanDocument): string {
: document.state === "partial"
? `部分完成:${document.accepted_source_ids.length} 个成功,其余见上方`
: `未完成:${document.code ?? "unknown"}`;
return `${summary}\n${lines.join("\n")}\n`;
const overrides = document.override_required_source_ids ?? [];
const hint =
overrides.length > 0
? `\n未接入(目录来自环境变量覆盖,需确认):${overrides.join("、")}\n确认目录无误后加 --confirm-overrides 重新运行。`
: "";
return `${summary}\n${lines.join("\n")}${hint}\n`;
}
@@ -0,0 +1,371 @@
// Contract fixes from the CLI review: exit codes, where JSON goes, the global
// `--data-dir`, and the write idempotency key.
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { run } from "./cli.js";
import { COMMANDS } from "./command-catalog.js";
const transport = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
request: vi.fn(),
}));
vi.mock("axios", () => ({
default: {
create: vi.fn(() => transport),
get: transport.get,
post: transport.post,
request: transport.request,
isAxiosError: () => false,
},
}));
const runtime = vi.hoisted(() => ({
inspectRuntimeStatus: vi.fn(),
discoverRuntimeAttachment: vi.fn(),
inspectBackgroundHealth: vi.fn(),
}));
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
...runtime,
}));
const ATTACHED = {
instanceId: "instance-1",
apiToken: "synthetic-runtime-token",
host: "127.0.0.1",
port: 8765,
servicePid: 4242,
health: {
status: "ok",
service: "one-memory-core",
version: "1.2.3",
instance_id: "instance-1",
},
};
beforeEach(() => {
vi.clearAllMocks();
for (const method of Object.values(transport)) method.mockReset();
runtime.inspectRuntimeStatus.mockResolvedValue({
state: "running",
code: "runtime_verified",
instanceId: "instance-1",
host: "127.0.0.1",
port: 8765,
version: "1.2.3",
});
runtime.discoverRuntimeAttachment.mockResolvedValue(ATTACHED);
runtime.inspectBackgroundHealth.mockResolvedValue(null);
});
describe("--data-dir is a global option", () => {
it("points a Runtime-attached command at that data directory", async () => {
transport.get.mockResolvedValue({
status: 200,
data: { request_id: "r", profile: null, elapsed_ms: 1 },
});
const result = await capturedRun(
["profile", "--data-dir", "/synthetic/other", "--json"],
{ ONE_MEMORY_DATA_DIR: "/synthetic/default" },
);
expect(result.code).toBe(0);
expect(runtime.inspectRuntimeStatus).toHaveBeenCalledWith(
expect.objectContaining({ dataDir: "/synthetic/other" }),
);
});
it("is accepted by every command the catalog used to advertise it on", async () => {
runtime.inspectRuntimeStatus.mockResolvedValue({
state: "stopped",
code: "runtime_not_running",
});
runtime.discoverRuntimeAttachment.mockResolvedValue(null);
for (const argv of [
["profile"],
["scan-roots", "list"],
["adapter", "list"],
["scan", "--source", "codex"],
["write", "status", "r-1"],
["backup", "create", "--target", "/synthetic/data/backups/a"],
["memories", "list"],
]) {
const result = await capturedRun([
...argv,
"--data-dir",
"/synthetic/other",
"--json",
]);
expect(result.code, argv.join(" ")).toBe(3);
}
});
it("refuses a relative or repeated --data-dir", async () => {
for (const argv of [
["profile", "--data-dir", "relative"],
["profile", "--data-dir", "/a", "--data-dir", "/b"],
["profile", "--data-dir"],
]) {
expect((await capturedRun(argv)).code).toBe(2);
}
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
});
});
describe("Core failures are exit 1, not usage errors", () => {
it("adapter probe reports a Core refusal with its code on stdout", async () => {
transport.request.mockResolvedValue({
status: 409,
data: { detail: "source_not_authorized" },
});
const result = await capturedRun([
"adapter",
"probe",
"--source",
"codex",
"--json",
]);
expect(result.code).toBe(1);
expect(result.stderr).toBe("");
expect(JSON.parse(result.stdout)).toMatchObject({
command: "adapter probe",
state: "failed",
code: "source_not_authorized",
exit_code: 1,
});
});
it("adapter probe prints readable text without --json", async () => {
transport.request.mockResolvedValue({
status: 200,
data: { source: "codex", available: true },
});
const result = await capturedRun(["adapter", "probe", "--source", "codex"]);
expect(result.code).toBe(0);
expect(result.stdout).toContain("来源 codex 的探测结果");
expect(result.stdout).toContain("available: true");
});
it("scan-roots add posts directly and reports a refusal as exit 1", async () => {
transport.request.mockResolvedValue({
status: 409,
data: { detail: "scan_root_conflict" },
});
const result = await capturedRun([
"scan-roots",
"add",
"--root",
"/synthetic/root",
"--source",
"codex",
"--json",
]);
expect(result.code).toBe(1);
expect(transport.request).toHaveBeenCalledWith(
expect.objectContaining({
method: "post",
url: "http://127.0.0.1:8765/api/v1/scan-roots",
data: { root: "/synthetic/root", source: "codex", recursive: true },
}),
);
expect(JSON.parse(result.stdout).code).toBe("scan_root_conflict");
});
it("the old `path add` spelling still works", async () => {
transport.request.mockResolvedValue({ status: 200, data: { ok: true } });
const result = await capturedRun([
"path",
"remove",
"--root",
"/synthetic/root",
"--yes",
]);
expect(result.code).toBe(0);
expect(transport.request.mock.calls[0][0]).toMatchObject({
method: "delete",
data: { root: "/synthetic/root" },
});
});
it("scan --source runs directly", async () => {
transport.post.mockResolvedValue({
status: 200,
data: { job_id: "j1", source_id: "codex", status: "pending" },
});
const result = await capturedRun(["scan", "--source", "codex", "--json"]);
expect(result.code).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
command: "scan",
state: "committed",
scan: { job_id: "j1" },
});
// A scan without a source is a usage error, not a preview.
expect((await capturedRun(["scan"])).code).toBe(2);
});
});
describe("write submit keeps its idempotency key", () => {
async function messagesFile(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "memhub-write-"));
const file = path.join(directory, "messages.json");
await writeFile(file, JSON.stringify([{ role: "user", content: "hi" }]));
return file;
}
it("generates a request id, sends it and prints it with a retry hint", async () => {
transport.post.mockImplementation(
async (_url: string, body: { request_id: string }) => ({
status: 200,
data: {
request_id: body.request_id,
state: "accepted",
created_at: "T",
updated_at: "T",
},
}),
);
const result = await capturedRun([
"write",
"submit",
"--session",
"s1",
"--messages-file",
await messagesFile(),
]);
expect(result.code).toBe(0);
const sent = transport.post.mock.calls[0][1].request_id as string;
expect(sent).toMatch(/^[0-9a-f-]{36}$/);
expect(result.stdout).toContain(`request_id: ${sent}`);
expect(result.stdout).toContain(`--request-id ${sent}`);
});
it("reports a 409 conflict as JSON on stdout with the request id", async () => {
transport.post.mockResolvedValue({
status: 409,
data: { detail: "memory_write_conflict" },
});
const result = await capturedRun([
"write",
"submit",
"--session",
"s1",
"--messages-file",
await messagesFile(),
"--request-id",
"req-1",
"--json",
]);
expect(result.code).toBe(1);
expect(result.stderr).toBe("");
expect(JSON.parse(result.stdout)).toMatchObject({
command: "write submit",
state: "rejected",
request_id: "req-1",
code: "memory_write_conflict",
});
});
it("write status of an unknown id is exit 1 with state not_found", async () => {
transport.get.mockResolvedValue({ status: 404, data: {} });
const error = Object.assign(new Error("404"), {
response: { status: 404 },
});
transport.get.mockRejectedValue(error);
const result = await capturedRun(["task", "--id", "nope", "--json"]);
// `axios.isAxiosError` is mocked false, so the read error surfaces as a
// failed read; either way it is exit 1, never a silent exit 0.
expect(result.code).toBe(1);
});
});
describe("doctor and ui use exit 3 for a stopped Runtime", () => {
it("doctor exits 3 when the Runtime is stopped", async () => {
runtime.inspectRuntimeStatus.mockResolvedValue({
state: "stopped",
code: "runtime_not_running",
});
const result = await capturedRun(["doctor", "--json"]);
expect(result.code).toBe(3);
expect(JSON.parse(result.stdout).schema_version).toBe(1);
});
});
describe("catalog exit codes", () => {
it("lists exit 2 for every command, since every parser can reject", () => {
for (const command of COMMANDS) {
expect(command.exit_codes, command.name).toContain(2);
}
});
it("lists exit 3 for every command that attaches to the Runtime", () => {
for (const name of [
"status",
"doctor",
"init",
"sources",
"sync",
"progress",
"jobs",
"models",
"memories",
"profile",
"graph-width",
"host",
"recall",
"scan-roots",
"adapter",
"scan",
"write",
"backup",
"ui",
"onboarding",
]) {
const command = COMMANDS.find((entry) => entry.name === name);
expect(command?.exit_codes, name).toContain(3);
}
});
it("does not claim exit 3 or 4 for lifecycle commands that never return them", () => {
for (const name of ["start", "stop", "restart"]) {
const command = COMMANDS.find((entry) => entry.name === name);
expect(command?.exit_codes).toEqual([0, 1, 2]);
}
});
it("says stop turns login auto-start off for a managed service", () => {
const stop = COMMANDS.find((entry) => entry.name === "stop");
expect(stop?.summary).toContain("登录时也不会自动启动");
});
});
async function capturedRun(
arguments_: string[],
environment: NodeJS.ProcessEnv = { ONE_MEMORY_DATA_DIR: "/synthetic/data" },
) {
let stdout = "";
let stderr = "";
const stdoutSpy = vi
.spyOn(process.stdout, "write")
.mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation((chunk) => {
stderr += String(chunk);
return true;
});
try {
return { code: await run(arguments_, environment), stdout, stderr };
} finally {
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
}
}
+317 -22
View File
@@ -1,8 +1,16 @@
import { mkdtemp, readdir, readFile, realpath, rm } from "node:fs/promises";
import {
mkdir,
mkdtemp,
readdir,
readFile,
realpath,
rm,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
claimRuntimeServiceForApp,
clearSdkMigrationBlock,
readRuntimeServiceOwner,
recordSdkMigrationBlock,
resolveRuntimeCliConfig,
@@ -922,6 +930,38 @@ describe("public One Memory CLI", () => {
target_path: "/tmp/install/current/bin/one-memory",
};
it("says the data was restored when a failed migration put it back", async () => {
vi.clearAllMocks();
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
runtimeMocks.runSdkMigration.mockResolvedValueOnce({
state: "deferred",
code: "sdk_migration_memory_unreadable",
dataRestored: true,
} as never);
const streams = captureStreams();
let code: number;
try {
code = await run(
upgradeArguments,
{},
{ waitForRuntimeReady: async () => true },
);
} finally {
streams.restore();
}
expect(code!).toBe(1);
const failure = JSON.parse(streams.stderr());
expect(failure).toMatchObject({
code: "sdk_migration_memory_unreadable",
data_restored: true,
log_file: "/tmp/custom data/logs/sdk-migration.log",
});
expect(failure.message).toContain("记忆数据已恢复到升级前的状态。");
expect(failure.message).toContain(
"/tmp/custom data/logs/sdk-migration.log",
);
});
it("migrates the home with the new Core before restarting the Runtime", async () => {
vi.clearAllMocks();
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
@@ -1971,16 +2011,11 @@ describe("stopped Runtime is exit 3, not a usage error", () => {
["path", "list", "--json"],
["adapter", "list", "--json"],
["task", "--id", "nope", "--json"],
[
"write",
"submit",
"--session",
"s",
"--messages-file",
"/tmp/messages.json",
"--json",
],
["backup", "create", "--target", "/tmp/x", "--yes", "--json"],
["write", "status", "nope", "--json"],
["backup", "create", "--target", "/tmp/x", "--json"],
["scan-roots", "add", "--root", "/tmp/x", "--json"],
["scan", "--source", "codex", "--json"],
["onboarding", "reset", "--json"],
])("%s ... --json exits 3 with runtime_not_running", async (...argv) => {
const streams = captureStreams();
let code: number;
@@ -1990,7 +2025,9 @@ describe("stopped Runtime is exit 3, not a usage error", () => {
streams.restore();
}
expect(code!).toBe(3);
const document = JSON.parse(streams.stderr());
// A result, not an error: on stdout like every other --json document.
expect(streams.stderr()).toBe("");
const document = JSON.parse(streams.stdout());
expect(document).toMatchObject({
code: "runtime_not_running",
exit_code: 3,
@@ -1999,6 +2036,48 @@ describe("stopped Runtime is exit 3, not a usage error", () => {
expect(document.status).not.toBe("usage_error");
});
it("write submit validates its messages file before attaching, then exits 3", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "memhub-write-"));
const file = path.join(directory, "messages.json");
await import("node:fs/promises").then(({ writeFile }) =>
writeFile(file, JSON.stringify([{ role: "user", content: "hi" }])),
);
const streams = captureStreams();
let missing: number;
let code: number;
try {
missing = await run(
[
"write",
"submit",
"--session",
"s",
"--messages-file",
path.join(directory, "absent.json"),
"--json",
],
{},
);
code = await run(
[
"write",
"submit",
"--session",
"s",
"--messages-file",
file,
"--json",
],
{},
);
} finally {
streams.restore();
await rm(directory, { recursive: true, force: true });
}
expect(missing!).toBe(2);
expect(code!).toBe(3);
});
it.each(["pause", "resume"] as const)(
"parses sync %s as the direct sync-set command",
async (action) => {
@@ -2134,6 +2213,94 @@ describe("lifecycle commands report a migration block", () => {
expect(output.value()).not.toContain("runtime_not_ready_within_budget");
});
it("restart on a frozen home retries the migration in the foreground first", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "memhub-frozen-"));
await recordSdkMigrationBlock(
dataDir,
"sdk_migration_memory_unreadable",
"desktop",
{ frozen: { service_was_enabled: true }, data_restored: true },
);
const controls = {
...blockedControls(),
migrateSdkData: vi.fn(async () => ({
state: "deferred" as const,
code: "sdk_migration_memory_unreadable",
dataRestored: true,
})),
};
const output = captureStdout();
let code: number;
try {
code = await run(
["restart", "--json"],
{ ONE_MEMORY_DATA_DIR: dataDir },
controls,
);
} finally {
output.restore();
await rm(dataDir, { recursive: true, force: true });
}
expect(code!).toBe(1);
expect(controls.migrateSdkData).toHaveBeenCalledWith(
expect.objectContaining({ dataDir, caller: "cli-restart" }),
);
// Still frozen: the service is not started to retry on its own.
expect(controls.restartManagedRuntime).not.toHaveBeenCalled();
expect(controls.ensureRuntime).not.toHaveBeenCalled();
expect(JSON.parse(output.value())).toMatchObject({
command: "restart",
state: "blocked",
code: "sdk_migration_memory_unreadable",
data_restored: true,
});
});
it("restart on a frozen home restarts the Runtime once the retry succeeds", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "memhub-frozen-"));
await recordSdkMigrationBlock(
dataDir,
"sdk_migration_memory_unreadable",
"desktop",
{ frozen: { service_was_enabled: false } },
);
const order: string[] = [];
const migrateSdkData = vi.fn(async () => {
order.push("migrate");
await clearSdkMigrationBlock(dataDir);
return { state: "cutover" as const };
});
const output = captureStreams();
let code: number;
try {
code = await run(
["restart", "--json"],
{ ONE_MEMORY_DATA_DIR: dataDir },
{
migrateSdkData,
waitForRuntimeReady: vi.fn(async () => true),
restartManagedRuntime: vi.fn(async () => {
order.push("restart");
return "restarted" as const;
}),
stopRuntime: vi.fn(async () => "stopped" as const),
ensureRuntime: vi.fn(async () => {
order.push("restart");
return { pid: 1 } as never;
}),
},
);
} finally {
output.restore();
await rm(dataDir, { recursive: true, force: true });
}
expect(code!).toBe(0);
expect(migrateSdkData).toHaveBeenCalledWith(
expect.objectContaining({ caller: "cli-restart" }),
);
expect(order).toEqual(["migrate", "restart"]);
});
it("restart in text mode names the log file and GitHub issues", async () => {
const dataDir = await blockedHome();
const output = captureStdout();
@@ -2583,7 +2750,8 @@ describe("ui wires the Core session contract without token leakage", () => {
} finally {
output.restore();
}
expect(code!).toBe(4);
// A stopped Runtime is exit 3, like every other command.
expect(code!).toBe(3);
const document = JSON.parse(output.value());
expect(document).toMatchObject({
schema_version: 1,
@@ -2612,6 +2780,120 @@ describe("ui wires the Core session contract without token leakage", () => {
// refuse while the Runtime owns the data dir, stop+call Core from a
// maintenance process, and never report success from a malformed response.
describe("backup restore drives the Core managed-restore contract", () => {
it("previews without --yes, exits 0 and starts nothing", async () => {
const maintenance = vi.fn();
const output = captureStdout();
let code: number;
try {
code = await run(
["backup", "restore", "--source", "/tmp/x", "--json"],
{},
{ runMaintenanceRestore: maintenance },
);
} finally {
output.restore();
}
expect(code!).toBe(0);
expect(maintenance).not.toHaveBeenCalled();
expect(JSON.parse(output.value())).toMatchObject({
command: "backup restore",
state: "preview",
target: "/tmp/x",
applied: false,
next_step: "memhub backup restore --source /tmp/x --yes --confirm /tmp/x",
});
});
it("deletes data only when --confirm repeats the data directory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "memhub-delete-home-"));
const dataDir = path.join(home, ".one-memory");
await mkdir(path.join(dataDir, "logs"), { recursive: true });
const deleteData = async (...extra: string[]) => {
const output = captureStdout();
try {
const code = await run(
["delete-data", "--data-dir", dataDir, ...extra, "--json"],
{ HOME: home },
);
return { code, document: JSON.parse(output.value()) };
} finally {
output.restore();
}
};
try {
const refused = await deleteData("--yes");
expect(refused.code).toBe(4);
expect(refused.document).toMatchObject({
state: "blocked",
code: "data_deletion_confirmation_required",
});
expect(await readdir(dataDir)).toEqual(["logs"]);
const deleted = await deleteData("--yes", "--confirm", dataDir);
expect(deleted.code).toBe(0);
expect(await readdir(dataDir).catch(() => [])).toEqual([]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it("refuses --yes without repeating the backup path", async () => {
const maintenance = vi.fn();
const output = captureStdout();
let code: number;
try {
code = await run(
["backup", "restore", "--source", "/tmp/x", "--yes", "--json"],
{},
{ runMaintenanceRestore: maintenance },
);
} finally {
output.restore();
}
expect(code!).toBe(4);
expect(maintenance).not.toHaveBeenCalled();
expect(JSON.parse(output.value())).toMatchObject({
state: "rejected",
code: "backup_restore_confirmation_required",
next_step:
"恢复需要再写一遍备份路径:memhub backup restore --source /tmp/x --yes --confirm /tmp/x",
});
});
it("restores with --yes and the repeated backup path", async () => {
const maintenance = vi.fn(async () => ({
state: "restored" as const,
result: {
state: "restored" as const,
restoredBytes: 1,
previousBackupPath: "/tmp/pre.sqlite3",
sourceSchemaVersion: 34,
},
}));
const output = captureStdout();
let code: number;
try {
code = await run(
[
"backup",
"restore",
"--source",
"/tmp/x",
"--yes",
"--confirm",
"/tmp/x",
"--json",
],
{},
{ runMaintenanceRestore: maintenance },
);
} finally {
output.restore();
}
expect(code!).toBe(0);
expect(maintenance).toHaveBeenCalledTimes(1);
});
it("rejects while the Runtime is running, with a distinct exit code", async () => {
runtimeMocks.inspectRuntimeStatus.mockResolvedValueOnce({
state: "running",
@@ -2728,7 +3010,7 @@ describe("backup restore drives the Core managed-restore contract", () => {
});
});
it("requires --confirm to repeat the exact source path", async () => {
it("refuses a legacy --confirm that does not repeat the source path", async () => {
const maintenance = vi.fn();
const output = captureStdout();
let code: number;
@@ -2756,7 +3038,7 @@ describe("backup restore drives the Core managed-restore contract", () => {
command: "backup",
verb: "restore",
state: "rejected",
code: "backup_restore_confirmation_required",
code: "backup_restore_confirmation_mismatch",
});
});
@@ -2908,7 +3190,7 @@ describe("init onboarding", () => {
// Boolean flags were idempotent, but accepting them twice hid a duplicated
// argv fragment that could also have duplicated a value-bearing flag.
it.each(["--import", "--full-rescan", "--confirm-overrides", "--dry-run"])(
it.each(["--import", "--confirm-overrides"])(
"refuses a repeated init %s flag",
async (flag) => {
expect(await run(["init", "--source", "codex", flag, flag], {})).toBe(2);
@@ -2981,12 +3263,25 @@ describe("init onboarding", () => {
},
);
it("accepts --dry-run together with --import", async () => {
// Parsing must accept the pair; it stops at the Runtime check, which is
// not a usage failure.
expect(
await run(["init", "--source", "codex", "--import", "--dry-run"], {}),
).not.toBe(2);
// `--dry-run` and `--full-rescan` never did anything (P0-2/P0-3): they are
// gone rather than accepted as no-ops.
it.each(["--dry-run", "--full-rescan"])(
"refuses the removed no-op %s flag",
async (flag) => {
expect(
await run(["init", "--source", "codex", "--import", flag], {}),
).toBe(2);
},
);
it("still parses the old `init --source --import` spelling", async () => {
// It stops at the Runtime check (exit 3), which is not a usage failure.
const streams = captureStreams();
try {
expect(await run(["init", "--source", "codex", "--import"], {})).toBe(3);
} finally {
streams.restore();
}
});
});
+1664 -2042
View File
File diff suppressed because it is too large. Load diff
+226 -195
View File
@@ -58,6 +58,12 @@ const CONFIRM_OPTION: CommandOption = {
flag: "--yes",
summary: "确认执行;不加该参数时只输出计划,不做任何改动。",
};
/** The one confirmation convention, for irreversible operations only. */
const DESTRUCTIVE_CONFIRM_OPTION: CommandOption = {
flag: "--yes",
summary:
"确认这次不可逆操作;不加时只说明将要做什么,不做任何改动,exit 0。(旧的 --confirm 仍作为别名接受。)",
};
const JSON_OPTION: CommandOption = {
flag: "--json",
summary: "以 JSON 输出到 stdout,供脚本与 Agent 消费。",
@@ -70,21 +76,6 @@ const INTERVAL_OPTION: CommandOption = {
flag: "--interval <seconds>",
summary: "刷新间隔,默认 5 秒,范围 1..300;必须与 --watch 一起使用。",
};
const PLAN_FILE_OPTION: CommandOption = {
flag: "--plan-file <path>",
summary: "读取先前 --json 保存的有界计划文件。",
};
const EXPLICIT_CONFIRM_OPTION: CommandOption = {
flag: "--confirm",
summary: "确认执行计划文件逐字描述的操作;缺少时绝不写入。",
};
// Same flag, but for commands that name their target directly instead of
// going through a saved plan file; the plan-file wording would be wrong here.
const DIRECT_CONFIRM_OPTION: CommandOption = {
flag: "--confirm",
summary: "确认执行上面预览的这次操作;缺少时绝不写入。",
};
const SERVICE_OPTIONS: readonly CommandOption[] = [
PLATFORM_OPTION,
HOME_OPTION,
@@ -154,55 +145,77 @@ const INSTALL_OPTIONS: readonly CommandOption[] = [
const INSTALL_USAGE =
"[--source <path> | --release-manifest-url <https-url>] [--channel <stable|prerelease>] [--install-root <path>] [--home <path>] [--bin-dir <path>] [--data-dir <path>] [--uid <id>] [--xdg-config-home <path>] [--no-shell-path] [--shell-profile <path>] [--preserve-data-dir] [--allow-downgrade] [--yes]";
const USAGE_EXITS = [EXIT_OK, EXIT_FAILED, EXIT_USAGE] as const;
const RUNTIME_EXITS = [
EXIT_OK,
EXIT_FAILED,
EXIT_USAGE,
EXIT_RUNTIME_STOPPED,
] as const;
const KIND_OPTION: CommandOption = {
flag: "--kind <chat|experience|fact>",
summary:
"记忆所在的命名空间;工作/编码场景的经验和事实需要同时给 --variant。list 输出里每条都带 kind/variant。",
};
const VARIANT_OPTION: CommandOption = {
flag: "--variant <working|coding>",
summary:
"经验或事实属于工作还是编码场景;必须和 --kind experience|fact 一起用。",
};
export const COMMANDS: readonly CommandDescriptor[] = [
{
name: "serve",
summary: "在前台启动完整 Runtime(Core 与 Host Gateway),不注册系统服务。",
usage: "memhub serve",
options: [],
exit_codes: [EXIT_OK, EXIT_FAILED],
exit_codes: USAGE_EXITS,
},
{
name: "start",
summary: "启动本机 Runtime;已有通过身份校验的实例时只连接,不重复启动。",
summary:
"启动本机 Runtime;已有通过身份校验的实例时只连接,不重复启动。受管服务会同时恢复登录时自动启动。",
usage: "memhub start [--json]",
options: [JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_RUNTIME_STOPPED],
exit_codes: USAGE_EXITS,
},
{
name: "stop",
summary: "停止通过身份校验的本机 Runtime;身份不明确时不发送信号。",
summary:
"停止通过身份校验的本机 Runtime;身份不明确时不发送信号。受管服务(launchd/systemd、App)停止后登录时也不会自动启动,直到再次运行 memhub start。",
usage: "memhub stop [--json]",
options: [JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED],
exit_codes: USAGE_EXITS,
},
{
name: "restart",
summary: "先停止再启动本机 Runtime,两步串行执行。",
usage: "memhub restart [--json]",
options: [JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED],
exit_codes: USAGE_EXITS,
},
{
name: "status",
summary: "只读查询 Runtime 运行状态;不启动服务、不迁移数据、不改配置。",
usage: "memhub status [--watch] [--interval <seconds>] [--json]",
options: [WATCH_OPTION, INTERVAL_OPTION, JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_RUNTIME_STOPPED],
exit_codes: RUNTIME_EXITS,
},
{
name: "doctor",
summary: "按诊断口径做同样的只读检查;默认不修复任何东西。",
summary:
"按诊断口径做只读检查并给出修复建议;默认不修复任何东西。Runtime 未运行时 exit 3。",
usage: "memhub doctor [--json]",
options: [JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_RUNTIME_STOPPED],
exit_codes: RUNTIME_EXITS,
},
{
name: "logs",
summary:
"读取本机脱敏诊断日志;logs export 会把排查包直接存到下载目录。默认显示最近一次会话的 200 条事件。",
"读取本机脱敏诊断日志;logs export 会把排查包(含系统崩溃报告)直接存到下载目录。默认显示最近一次会话的 200 条事件。",
usage:
"memhub logs export [--json]\nmemhub logs [--since <15m|2h|7d|RFC3339>] [--session <id>] [--trace <id>] [--incident <id>] [--operation <id>] [--request <id>] [--job <id>] [--task <id>] [--component <name>] [--event <name>] [--errors-only] [--tail <1..5000>] [--follow] [--json]",
"memhub logs export [--json]\n memhub logs [--since <15m|2h|7d|RFC3339>] [--session <id>] [--trace <id>] [--incident <id>] [--operation <id>] [--request <id>] [--job <id>] [--task <id>] [--component <name>] [--event <name>] [--errors-only] [--tail <1..5000>] [--follow] [--json]",
options: [
{
flag: "--since <time>",
@@ -226,64 +239,46 @@ export const COMMANDS: readonly CommandDescriptor[] = [
"以 NDJSON 输出到 stdout(每行一个脱敏事件;非单 JSON 文档),供脚本与 Agent 消费。",
},
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE],
exit_codes: USAGE_EXITS,
},
{
name: "init",
summary:
"首次接入:选择来源 → 选择是否导入历史。接入即开启该 Harness 的处理;后续可用 sync pause/resume 冻结或恢复。在终端且不带 --source 时进入交互引导(默认全选);非终端或加 --json 时不读 stdin、只列出可用来源和下一步。--all-detected 一次接入所有已检测来源。",
"首次接入引导。在终端里(未加 --json)进入交互向导:选择来源 → 是否开启处理 → 配置模型。非终端或加 --json 时不读 stdin,只列出可接入的来源和下一步命令。脚本接入请用 sources connect。",
usage: "memhub init [--json]",
options: [JSON_OPTION],
exit_codes: RUNTIME_EXITS,
},
{
name: "sources",
summary:
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。",
usage:
"memhub init [--json]\n memhub init --source <id> [--source <id> ...] [--import] [--dry-run] [--full-rescan] [--confirm-overrides] [--json]\n memhub init --all-detected [--import] [--dry-run] [--json]",
"memhub sources list [--json]\n memhub sources connect <id> [<id> ...] [--import] [--confirm-overrides] [--request-id <uuid>] [--json]\n memhub sources connect --all-detected [--import] [--confirm-overrides] [--json]\n memhub sources receipt <request-id> [--json]",
options: [
{
flag: "--source <id>",
summary: "要接入的来源,可重复;终端下不给则进入交互引导。",
flag: "--import",
summary:
"接入后立即导入历史会话并开启持续同步;不加时只授权,处理保持冻结。",
},
{
flag: "--all-detected",
summary:
"接入并(加 --import 时)导入全部 location_state=found 的来源。",
},
{
flag: "--import",
summary: "接入后立即导入该来源的历史会话(默认只接入不导入)。",
},
{
flag: "--dry-run",
summary: "只出计划,不接入也不导入;用于先看会做什么。",
},
{
flag: "--full-rescan",
summary: "导入时忽略既有游标做全量重扫。",
"接入全部已检测到(location_state=found)的来源;与具体来源互斥。",
},
{
flag: "--confirm-overrides",
summary: "确认接受环境覆盖类来源(如 DSH_HOME 指定的目录)。",
summary:
"确认接受目录来自环境变量覆盖的来源(如 DSH_HOME 指定的目录);不加时这些来源被拒绝(指定来源时)或跳过并列出(--all-detected 时)。",
},
JSON_OPTION,
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
},
{
name: "setup",
summary: "生成无头首次导入计划;只读取已有来源,不探测目录、不写授权。",
usage: "memhub setup plan --source <id> [--source <id> ...] [--json]",
options: [
{
flag: "--source <id>",
summary: "计划导入的来源,可重复 1..32 次。",
flag: "--request-id <uuid>",
summary:
"--import 的幂等键;用同一个 id 重试只会读回第一次的回执,不会重复导入。",
},
JSON_OPTION,
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
},
{
name: "sources",
summary: "列出来源、执行已确认的导入计划。",
usage:
"memhub sources list [--json]\n memhub sources import --plan-file <path> --confirm [--json]\n memhub sources receipt <request-id> [--json]",
options: [PLAN_FILE_OPTION, EXPLICIT_CONFIRM_OPTION, JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
exit_codes: RUNTIME_EXITS,
},
{
name: "sync",
@@ -297,7 +292,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
},
JSON_OPTION,
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
exit_codes: RUNTIME_EXITS,
},
{
name: "progress",
@@ -318,17 +313,17 @@ export const COMMANDS: readonly CommandDescriptor[] = [
INTERVAL_OPTION,
JSON_OPTION,
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
exit_codes: RUNTIME_EXITS,
},
{
name: "jobs",
summary:
"启动或控制单个导入任务:start 创建 history/sync 作业;pause/resume/cancel 控制已有作业。所有写操作先出计划,必须显式 --confirm;list/show 为只读。",
"查看或控制导入任务:start 创建 history/sync 任务;pause/resume/cancel 控制已有任务。都直接执行。",
usage:
"memhub jobs list [--json]\n" +
" memhub jobs show <job-id> [--json]\n" +
" memhub jobs start --source <id> [--operation <history|sync>] [--confirm] [--json]\n" +
" memhub jobs <pause|resume|cancel> --job <id> [--confirm] [--json]",
" memhub jobs start --source <id> [--operation <history|sync>] [--json]\n" +
" memhub jobs <pause|resume|cancel> --job <id> [--json]",
options: [
{
flag: "--source <id>",
@@ -336,123 +331,116 @@ export const COMMANDS: readonly CommandDescriptor[] = [
},
{
flag: "--operation <history|sync>",
summary: "start 的导入类型,默认 history。",
summary:
"start 的导入类型,默认 history;sync 与 scan --source 相同,立即增量同步一次。",
},
{
flag: "--job <id>",
summary: "pause/resume/cancel 的目标导入任务。",
},
DIRECT_CONFIRM_OPTION,
JSON_OPTION,
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
exit_codes: RUNTIME_EXITS,
},
{
name: "models",
summary:
"交互配置模型,或生成配置/探测计划、执行已确认操作与查询回执;脚本模式的凭据只从受控文件读取。",
"管理生成模型配置。add/test 直接提交并返回操作 ID(异步,用 receipt 查结果);edit/activate 直接执行;remove 不可逆,需 --yes。<profile-id>/<operation-id> 都是 UUID;脚本模式的凭据只从受控文件读取。",
usage:
"memhub models list [--json]\n memhub models show <profile-id> [--json]\n memhub models plan --config-file <path> [--json]\n memhub models configure --plan-file <path> --confirm [--api-key-file <path>] [--json]\n memhub models receipt <operation-id> [--json]\n memhub models edit <profile-id> --config-file <path> [--confirm] [--api-key-file <path>] [--json]\n memhub models activate <profile-id> [--confirm] [--json]\n memhub models probe-plan <profile-id> [--json]\n memhub models probe --plan-file <path> --confirm [--json]\n memhub models probe-receipt <operation-id> [--json]\n memhub models remove <profile-id> [--confirm] [--json]",
"memhub models list [--json]\n memhub models show <profile-id> [--json]\n memhub models add --config-file <path> [--api-key-file <path>] [--activate] [--json]\n memhub models test <profile-id> [--json]\n memhub models receipt <operation-id> [--json]\n memhub models edit <profile-id> --config-file <path> [--api-key-file <path>] [--json]\n memhub models activate <profile-id> [--json]\n memhub models remove <profile-id> [--yes] [--json]",
alternative_usage: "memhub models setup",
options: [
{
flag: "--config-file <path>",
summary: "不含 api_key 的有界 JSON 模型配置。",
summary:
"有界 JSON 模型配置,不能含 api_key;edit 的补丁必须带 expected_revision。",
},
PLAN_FILE_OPTION,
EXPLICIT_CONFIRM_OPTION,
{
flag: "--api-key-file <path>",
summary: "从 regular file 读取凭据;内容不进入参数、输出或错误。",
summary: "从属主专用的普通文件读取凭据;内容不进入参数、输出或错误。",
},
{
flag: "--activate",
summary: "add 成功后把新配置设为当前使用。",
},
DESTRUCTIVE_CONFIRM_OPTION,
JSON_OPTION,
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
exit_codes: RUNTIME_EXITS,
},
{
name: "memories",
summary: "通过 SDK 数据面列出、查看、修改或物理删除记忆。",
summary:
"列出、搜索、查看、添加、修改或物理删除记忆(对话、工作、编码三个场景)。add 和 App 的「添加记忆」是同一个入口;delete 不可逆,需 --yes。",
usage:
"memhub memories list [--status <state>] [--offset <n>] [--limit <n>] [--json]\n memhub memories show <memory-id> [--json]\n memhub memories update <memory-id> --content-file <path> [--confirm] [--json]\n memhub memories delete <memory-id> [--confirm] [--json]",
"memhub memories list [--scene <all|chat|working|coding>] [--source <id>] [--query <text>] [--status <state>] [--offset <n>] [--limit <n>] [--json]\n memhub memories show <memory-id> [--kind <kind>] [--variant <variant>] [--json]\n memhub memories add <text> [--request-id <uuid>] [--json]\n memhub memories add --content-file <path> [--request-id <uuid>] [--json]\n memhub memories add-status <request-id> [--json]\n memhub memories update <memory-id> --content-file <path> [--kind <kind>] [--variant <variant>] [--task <text>] [--json]\n memhub memories delete <memory-id> [--kind <kind>] [--variant <variant>] [--yes] [--json]",
options: [
{
flag: "--scene <all|chat|working|coding>",
summary: "只列一个场景,默认 all。working/coding 列的是该场景的经验。",
},
{ flag: "--source <id>", summary: "只列某个来源写入的记忆。" },
{
flag: "--query <text>",
summary:
"关键词搜索(只搜 active 记忆,在最相关的一批里匹配所有关键词,输出 search_window 时表示可能少计)。",
},
{
flag: "--status <state>",
summary: `记忆状态,取值 ${MEMORY_STATUSES.join("、")}。`,
summary: `记忆状态,取值 ${MEMORY_STATUSES.join("、")},默认 active。`,
},
{ flag: "--offset <n>", summary: "跳过前 n 条,默认 0。" },
{
flag: "--limit <n>",
summary: "本页最多返回多少条,默认 100,上限 500。",
},
KIND_OPTION,
VARIANT_OPTION,
{
flag: "--content-file <path>",
summary:
"从有界普通文件读取 update 的新正文;预览只显示摘要,--confirm 时才写入。",
"从有界普通文件读取正文:add 的新记忆(≤12000 字),或 update 的新内容(经验为 solution)。",
},
DIRECT_CONFIRM_OPTION,
{
flag: "--task <text>",
summary: "update 经验(--kind experience)时同时改写 task。",
},
{
flag: "--request-id <uuid>",
summary:
"add 的幂等键;用同一个 id 重试只会读回第一次的结果,不会重复添加。不给时自动生成并输出。",
},
DESTRUCTIVE_CONFIRM_OPTION,
JSON_OPTION,
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
exit_codes: RUNTIME_EXITS,
},
{
name: "profile",
summary: "读取 SDK 组装后的用户长期画像(L0 + 已启用的长期画像补充)。",
usage: "memhub profile [--json]",
options: [DATA_DIR_OPTION, JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
options: [JSON_OPTION],
exit_codes: RUNTIME_EXITS,
},
{
name: "calls",
name: "graph-width",
summary:
"清除本机调用记录;不可逆删除,默认只出计划,必须显式 --confirm 才执行。",
"嵌入维度变化、图谱宽度不匹配时的处置决定。keep_existing 与 export_first 直接执行;rebuild_with_new 会归档旧图谱并重建,需 --yes。",
usage:
"memhub calls clear --event <id> [--event <id> ...] [--confirm] [--json]\n memhub calls clear --all [--confirm] [--json]",
options: [
{
flag: "--event <id>",
summary: "要清除的调用记录事件 ID,可重复,最多 100 条。",
},
{
flag: "--all",
summary: "清除全部调用记录;与 --event 互斥。",
},
DIRECT_CONFIRM_OPTION,
JSON_OPTION,
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
},
{
name: "notes",
summary:
"端内新增记忆(memory-notes):从普通文件新增,或显式确认后处理已有笔记。",
usage:
"memhub notes add --content-file <path> [--json]\n memhub notes apply --plan-file <path> --content-file <path> --confirm [--json]\n memhub notes process <note-id> [--confirm] [--json]",
options: [
{
flag: "--content-file <path>",
summary: "从有界普通文件读取新增正文;计划只保存摘要,执行时重新校验。",
},
PLAN_FILE_OPTION,
DIRECT_CONFIRM_OPTION,
JSON_OPTION,
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
},
{
name: "graph",
summary: "图谱宽度不匹配时的用户决定;默认只出计划,--confirm 后才执行。",
usage:
"memhub graph width --decision <keep_existing|rebuild_with_new|export_first> [--confirm] [--json]",
"memhub graph-width --decision <keep_existing|export_first|rebuild_with_new> [--yes] [--json]",
options: [
{
flag: "--decision <decision>",
summary:
"keep_existing(保持)、export_first(先导出)、rebuild_with_new(导出后重建)。",
"keep_existing(保持现有图谱)、export_first(先导出)、rebuild_with_new(导出后归档旧图谱并按新维度重建)。",
},
{
...DESTRUCTIVE_CONFIRM_OPTION,
summary: "rebuild_with_new 必须加;其它决定不需要。",
},
DIRECT_CONFIRM_OPTION,
JSON_OPTION,
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
exit_codes: RUNTIME_EXITS,
},
{
name: "host",
@@ -480,12 +468,12 @@ export const COMMANDS: readonly CommandDescriptor[] = [
},
JSON_OPTION,
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
exit_codes: RUNTIME_EXITS,
},
{
name: "recall",
summary:
"从当前授权的原文与已沉淀记忆统一检索上下文;本地只读,不调用模型、不触发补索引。",
"从原始对话与已沉淀记忆统一检索上下文;本地只读,不调用模型、不触发补索引。文本输出标明每条是「原始对话摘录」还是「记忆」(JSON 里是 kind: raw|memory)。",
usage:
"memhub recall <query> [--kind <raw|memory>] [--source <id>] [--limit <n>] [--character-budget <n>] [--timeout-ms <n>] [--json]\n memhub recall --query-file <path> [...]\n memhub recall --stdin [...]",
options: [
@@ -516,21 +504,21 @@ export const COMMANDS: readonly CommandDescriptor[] = [
},
JSON_OPTION,
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
exit_codes: RUNTIME_EXITS,
},
{
name: "service-plan",
summary: "生成用户级服务计划;只输出计划,不写文件也不执行命令。",
usage: `memhub service-plan ${SERVICE_USAGE}`,
options: SERVICE_OPTIONS,
exit_codes: [EXIT_OK, EXIT_PLAN_BLOCKED],
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_PLAN_BLOCKED],
},
{
name: "service-install",
summary: "预览用户级服务安装;只有 --yes 才写入服务文件并注册。",
usage: `memhub service-install ${SERVICE_USAGE} [--yes]`,
options: [...SERVICE_OPTIONS, CONFIRM_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
},
{
name: "service-uninstall",
@@ -538,15 +526,15 @@ export const COMMANDS: readonly CommandDescriptor[] = [
"预览用户级服务卸载;只有 --yes 才注销并删除逐字匹配的受管服务文件,不删数据。",
usage: `memhub service-uninstall ${SERVICE_USAGE} [--yes]`,
options: [...SERVICE_OPTIONS, CONFIRM_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
},
{
name: "install",
summary:
"预览发行目录安装、memhub/memhub-cli/one-memory 短链接、PATH 条目与用户服务计划;只有 --yes 才执行并在失败时回滚。",
"预览发行目录安装、memhub/memhub-cli/one-memory 短链接、PATH 条目与用户服务计划;只有 --yes 才执行。记忆数据迁移开始前失败会回滚;迁移开始后失败保留新版本、拒绝启动,并输出迁移日志与反馈地址。",
usage: `memhub install ${INSTALL_USAGE}`,
options: INSTALL_OPTIONS,
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
},
{
name: "uninstall",
@@ -559,24 +547,31 @@ export const COMMANDS: readonly CommandDescriptor[] = [
{
name: "upgrade",
summary:
"预览升级;只有 --yes 才切换受管版本并重启服务,失败时恢复旧版本,不删数据。",
"预览升级;只有 --yes 才切换受管版本并重启服务,不删数据。记忆数据迁移开始前失败会恢复旧版本;迁移开始后失败保留新版本、拒绝启动,并输出迁移日志与反馈地址(不会回滚到旧版本)。",
usage: `memhub upgrade ${INSTALL_USAGE}`,
options: INSTALL_OPTIONS,
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
},
{
name: "delete-data",
summary:
"独立、二次确认地删除 One Memory Home(数据)。这是唯一会删除数据的命令;uninstall 不会。默认只出预览;需 Runtime 已停止,并显式 --confirm <path> 复述该目录。",
"删除 MemHub 数据目录(记忆、来源、配置),不可逆。这是唯一会删除数据的命令;uninstall 不会。需要 Runtime 已停止;不加参数时只预览要删除的目录;删除需要 --yes 并用 --confirm 再写一遍同一个目录。",
usage:
"memhub delete-data [--data-dir <path>] [--yes --confirm <path>] [--json]",
options: [
DATA_DIR_OPTION,
CONFIRM_OPTION,
{
flag: "--data-dir <path>",
summary: "要删除的数据目录绝对路径;默认当前使用的数据目录。",
},
{
...DESTRUCTIVE_CONFIRM_OPTION,
summary:
"删除时必须加,且要同时给出 --confirm <path>;不加时只预览,exit 0。",
},
{
flag: "--confirm <path>",
summary:
"第二次确认:逐字复述预览里的数据目录;缺少它时即使 --yes 也不删除。",
"删除时必须加:再写一遍要删除的数据目录,与预览中的目录完全一致才执行。",
},
JSON_OPTION,
],
@@ -588,113 +583,149 @@ export const COMMANDS: readonly CommandDescriptor[] = [
"onboarding reset 回到首次运行的引导流程。只清除引导完成状态:不删记忆数据,也不需要卸载重装。",
usage: "memhub onboarding reset [--json]",
options: [JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE],
exit_codes: RUNTIME_EXITS,
},
{
name: "help",
summary: "显示总帮助或单个命令的用法。",
usage: "memhub help [command] [--json]",
options: [JSON_OPTION],
exit_codes: [EXIT_OK],
exit_codes: [EXIT_OK, EXIT_USAGE],
},
{
name: "version",
summary: "显示 CLI 版本。",
usage: "memhub version [--json]",
options: [JSON_OPTION],
exit_codes: [EXIT_OK],
exit_codes: [EXIT_OK, EXIT_USAGE],
},
{
name: "path",
name: "scan-roots",
summary:
"查看或登记被扫描的目录(scan roots)。list 只读;add/remove 预览后加 --yes 才提交;均已接入 Runtime 的 scan-roots 端点。",
"查看或登记被扫描的目录(scan roots)。add/remove 直接执行。(旧名 path 仍可用。)",
usage:
"memhub path list [--json]\n" +
" memhub path add --root <path> [--source <id>] [--no-recursive] [--yes]\n" +
" memhub path remove --root <path> [--yes]",
options: [DATA_DIR_OPTION, JSON_OPTION, CONFIRM_OPTION],
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED, EXIT_PLAN_BLOCKED],
"memhub scan-roots list [--json]\n" +
" memhub scan-roots add --root <path> [--source <id>] [--no-recursive] [--json]\n" +
" memhub scan-roots remove --root <path> [--json]",
options: [
{ flag: "--root <path>", summary: "要登记或移除的目录。" },
{
flag: "--source <id>",
summary: "把目录绑定到某个来源;不给时由 Runtime 自动识别。",
},
{ flag: "--no-recursive", summary: "只扫描该目录本身,不进入子目录。" },
JSON_OPTION,
],
exit_codes: RUNTIME_EXITS,
},
{
name: "adapter",
summary:
"查看 Harness Adapter 的就绪状态与最近一次探测结果(list),或现场探测某个来源(probe --source <id>);两者均为只读,已接入 Runtime 的 adapters/探测端点。",
"查看 Harness Adapter 的就绪状态与最近一次探测结果(list),或现场探测某个来源(probe --source <id>);两者均为只读。",
usage:
"memhub adapter list [--json]\n" +
" memhub adapter probe --source <id> [--json]",
options: [DATA_DIR_OPTION, JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
options: [
{ flag: "--source <id>", summary: "probe 的目标来源。" },
JSON_OPTION,
],
exit_codes: RUNTIME_EXITS,
},
{
name: "scan",
summary:
"立即扫描;默认只输出计划,加 --yes 才真正执行。指定 --source 扫描单个来源,--all 一次扫描/导入全部已检测来源(Runtime 按来源寻址,不接受裸路径)。",
usage:
"memhub scan [--source <id>] [--full] [--yes] [--json]\n memhub scan --all [--yes] [--json]",
"立即增量扫描一个已连接并开启同步的来源,返回导入任务(与 jobs start --operation sync 相同)。接入全部来源请用 sources connect --all-detected --import。",
usage: "memhub scan --source <id> [--json]",
options: [
{
flag: "--source <id>",
summary: "按单个来源扫描;与 --all 互斥。",
summary: "要扫描的来源。",
},
{
flag: "--all",
summary:
"处理全部 location_state=found 的来源,按来源逐条回执;部分成功返回非 0。",
},
{
flag: "--full",
summary: "忽略既有游标做全量重扫;默认只读取增量。",
},
DATA_DIR_OPTION,
JSON_OPTION,
CONFIRM_OPTION,
],
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED, EXIT_PLAN_BLOCKED],
exit_codes: RUNTIME_EXITS,
},
{
name: "write",
summary: "提交 OpenAI messages 到 SDK 写入队列,或按 request id 查询状态。",
usage:
"memhub write submit --messages-file <path> --session <id> [--source <id>] [--request-id <id>] [--memory-at <RFC3339>] [--live] [--json]\n memhub write status --id <request-id> [--json]",
options: [DATA_DIR_OPTION, JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
},
{
name: "task",
summary:
"兼容别名:按 request id 查询写入状态;新脚本请使用 write status。",
usage: "memhub task --id <request-id> [--json]",
options: [DATA_DIR_OPTION, JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
"提交一段 OpenAI 格式的对话(messages)到写入队列,异步加工成记忆;或按 request id 查询状态。request id 是幂等键:不给时自动生成并输出,重试请带上同一个。新增一条结论式记忆请用 memories add。",
usage:
"memhub write submit --messages-file <path> --session <id> [--source <id>] [--request-id <id>] [--memory-at <RFC3339>] [--live] [--json]\n memhub write status <request-id> [--json]",
options: [
{
flag: "--messages-file <path>",
summary:
"OpenAI messages 数组(或 {messages:[…]}),1..200 条,role 为 user/assistant/tool,至少一条 user。",
},
{ flag: "--session <id>", summary: "会话 ID。" },
{ flag: "--source <id>", summary: "来源应用,默认 memhub-cli。" },
{
flag: "--request-id <id>",
summary:
"幂等键:同 id 同内容不会重复入队,同 id 不同内容被拒绝(409)。",
},
{ flag: "--memory-at <RFC3339>", summary: "对话发生时间,必须带时区。" },
{ flag: "--live", summary: "优先处理。" },
JSON_OPTION,
],
exit_codes: RUNTIME_EXITS,
},
{
name: "backup",
summary:
"备份与恢复 MemHub 数据(两阶段)。create 调用 Runtime 的备份端点;restore 会覆盖现有数据,要求 Runtime 已停止且 `--confirm` 逐字重复 `--source`,随后由非 Runtime 维护态 Core 完成受管校验、保护性备份和原子替换。",
"备份与恢复主库(来源、写入队列、配置)。注意:不包含记忆本身——SDK 记忆库 hy-memory/ 不在备份里,restore 也不会替换它。create 的目标要放在 <数据目录>/backups/ 下才能被 restore 使用;restore 覆盖现有主库,需 Runtime 已停止,并加 --yes 与 --confirm <同一个备份路径>。",
usage:
"memhub backup create --target <path> [--yes] [--json]\n" +
" memhub backup restore --source <path> --yes --confirm <path> [--json]",
"memhub backup create --target <path> [--json]\n" +
" memhub backup restore --source <path> [--yes --confirm <path>] [--json]",
options: [
DATA_DIR_OPTION,
JSON_OPTION,
CONFIRM_OPTION,
{
flag: "--target <path>",
summary: "create 的备份文件路径,必须在数据目录内。",
},
{
flag: "--source <path>",
summary:
"restore 的备份文件,必须是 <数据目录>/backups/ 下的受管备份。",
},
{
...DESTRUCTIVE_CONFIRM_OPTION,
summary:
"restore 必须加,且要同时给出 --confirm <path>;create 不需要。",
},
{
flag: "--confirm <path>",
summary: "restore 要求逐字重复 --source 路径;不一致时不做任何改动。",
summary:
"restore 必须加:再写一遍 --source 的备份路径,完全一致才执行。",
},
JSON_OPTION,
],
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED, EXIT_PLAN_BLOCKED],
exit_codes: [...RUNTIME_EXITS, EXIT_PLAN_BLOCKED],
},
{
name: "ui",
summary:
"用系统默认浏览器打开 Runtime 托管的 Web Console:先向 Core 换取一次性 loopback 登录码,再交给系统浏览器由 Core 置 HttpOnly/SameSite=Strict session;CLI 不向浏览器暴露裸 Core token。Core 未提供该合同时如实返回 ui_session_contract_unavailable(exit 4)。",
"用系统默认浏览器打开 Runtime 托管的 Web Console:先向 Core 换取一次性 loopback 登录码,再交给系统浏览器由 Core 置 HttpOnly/SameSite=Strict session;CLI 不向浏览器暴露裸 Core token。Runtime 未运行时 exit 3;Core 未提供该合同时如实返回 ui_session_contract_unavailable(exit 4)。",
usage: "memhub ui [--json]",
options: [JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
exit_codes: [...RUNTIME_EXITS, EXIT_PLAN_BLOCKED],
},
];
/** Old names that still run but are not listed: `path` → `scan-roots`,
* `task` → `write status`, `graph width` → `graph-width`. */
export const HIDDEN_COMMAND_ALIASES: Readonly<Record<string, string>> = {
path: "scan-roots",
task: "write",
graph: "graph-width",
};
export function findCommand(name: string): CommandDescriptor | undefined {
return COMMANDS.find((command) => command.name === name);
}
/** A listed command, or the command a hidden alias now runs as. */
export function findCommandOrAlias(
name: string,
): CommandDescriptor | undefined {
return findCommand(HIDDEN_COMMAND_ALIASES[name] ?? name);
}
+114 -30
View File
@@ -69,7 +69,6 @@ const COMMANDS = [
"doctor",
"logs",
"init",
"setup",
"sources",
"sync",
"progress",
@@ -77,10 +76,7 @@ const COMMANDS = [
"models",
"memories",
"profile",
// C6 App BLOCK (25f4ecf): UI write operations that had no CLI equivalent.
"calls",
"notes",
"graph",
"graph-width",
"host",
"recall",
"service-plan",
@@ -93,19 +89,20 @@ const COMMANDS = [
"onboarding",
"help",
"version",
// A6 product commands. Appended, not inserted: the catalog is a stable
// contract, so existing names must not shift position.
"path",
"scan-roots",
"adapter",
"scan",
"write",
"task",
"backup",
// `ui` is declared but blocked on a Core session contract; it is listed so
// `help` documents it and the blocked shape is machine-readable.
"ui",
] as const;
/** Old names that still run and still answer `--help`, but are not listed. */
const HIDDEN_ALIASES = [
["path", "scan-roots"],
["task", "write"],
] as const;
describe("CLI-22 shared output contract", () => {
describe("help", () => {
it.each([["help"], ["--help"], ["-h"]])(
@@ -146,6 +143,56 @@ describe("CLI-22 shared output contract", () => {
},
);
it.each(HIDDEN_ALIASES)(
"documents the hidden alias %s as %s and keeps it out of the listing",
async (alias, target) => {
const streams = captureStreams();
try {
expect(await run(["help", alias], {})).toBe(0);
expect(await run([alias, "--help"], {})).toBe(0);
} finally {
streams.restore();
}
expect(streams.stdout()).toContain(`${PRODUCT} ${target}`);
const listing = captureStreams();
try {
expect(await run(["help", "--json"], {})).toBe(0);
} finally {
listing.restore();
}
const names = JSON.parse(listing.stdout()).commands.map(
(entry: { name: string }) => entry.name,
);
expect(names).not.toContain(alias);
},
);
it("lists no removed command", async () => {
const streams = captureStreams();
try {
expect(await run(["help", "--json"], {})).toBe(0);
} finally {
streams.restore();
}
const catalog = streams.stdout();
for (const removed of [
'"name":"setup"',
'"name":"notes"',
'"name":"calls"',
'"name":"graph"',
"--plan-file",
"probe-plan",
"probe-receipt",
"models configure",
"sources import",
"--full-rescan",
"--dry-run",
"--full]",
]) {
expect(catalog).not.toContain(removed);
}
});
it("never executes the documented command", async () => {
for (const command of COMMANDS) {
for (const argv of [
@@ -201,27 +248,63 @@ describe("CLI-22 shared output contract", () => {
expect(payload.commands).toBeUndefined();
});
it("documents the destructive restore confirmation explicitly", async () => {
it("documents the one --yes convention for destructive commands", async () => {
const general = captureStreams();
try {
expect(await run(["help"], {})).toBe(0);
} finally {
general.restore();
}
expect(general.stdout()).toContain("确认约定");
for (const [command, usage] of [
[
"backup",
"memhub backup restore --source <path> [--yes --confirm <path>]",
],
["memories", "memhub memories delete <memory-id>"],
["models", "memhub models remove <profile-id> [--yes]"],
[
"delete-data",
"memhub delete-data [--data-dir <path>] [--yes --confirm <path>]",
],
["graph-width", "[--yes]"],
] as const) {
const streams = captureStreams();
try {
expect(await run(["help", command], {})).toBe(0);
} finally {
streams.restore();
}
expect(streams.stdout()).toContain(usage);
// Only the two commands with no way back repeat the target path.
if (command !== "backup" && command !== "delete-data") {
expect(streams.stdout()).not.toContain("--confirm <path>");
}
}
});
it("says backup does not include the memories themselves", async () => {
const streams = captureStreams();
try {
expect(await run(["help", "backup"], {})).toBe(0);
} finally {
streams.restore();
}
expect(streams.stdout()).toContain(
"memhub backup restore --source <path> --yes --confirm <path>",
);
expect(streams.stdout()).toContain("逐字重复 --source");
expect(streams.stdout()).toContain("不包含记忆本身");
});
it("does not attach restore's confirmation flag to path", async () => {
const streams = captureStreams();
try {
expect(await run(["help", "path"], {})).toBe(0);
} finally {
streams.restore();
it("does not promise a rollback after the migration started", async () => {
for (const command of ["install", "upgrade"]) {
const streams = captureStreams();
try {
expect(await run(["help", command], {})).toBe(0);
} finally {
streams.restore();
}
expect(streams.stdout()).toContain("迁移开始后失败保留新版本");
expect(streams.stdout()).not.toContain("失败时恢复旧版本");
expect(streams.stdout()).not.toContain("失败时回滚");
}
expect(streams.stdout()).not.toContain("--confirm <path>");
});
});
@@ -545,22 +628,23 @@ describe("documented commands", () => {
models: [
"list",
"show",
"plan",
"configure",
"add",
"test",
"receipt",
"probe-plan",
"probe",
"probe-receipt",
"edit",
"activate",
"remove",
],
// Source processing is one Freeze/Thaw switch under `sync`; the obsolete
// independent read/write `sources access` command must not return here.
sources: ["list", "import", "receipt"],
sources: ["list", "connect", "receipt"],
jobs: ["list", "show", "start", "<pause|resume|cancel>"],
// `sync <pause|resume>` is documented as one alternation, so it is
// checked as an entry rather than as two literal subcommands.
sync: ["status", "<pause|resume>"],
memories: ["list", "show", "update", "delete"],
memories: ["list", "show", "add", "add-status", "update", "delete"],
"scan-roots": ["list", "add", "remove"],
write: ["submit", "status"],
host: ["list", "status", "install", "uninstall", "sync"],
};
@@ -133,6 +133,20 @@ describe("unified context search", () => {
expect(output).not.toMatch(/^- raw |memory-1|raw-1|采用状态/m);
});
it("labels raw hits as original-conversation excerpts, not memories", async () => {
const streams = captureStreams();
try {
expect(await run(["recall", "synthetic question"])).toBe(0);
} finally {
streams.restore();
}
const output = streams.stdout();
expect(output).toContain(
"- [原始对话摘录 · codex] Synthetic raw citation.",
);
expect(output).toContain("- [记忆] Synthetic SDK memory.");
});
it("queries the SDK context endpoint directly and preserves item ids", async () => {
const streams = captureStreams();
try {
+11 -1
View File
@@ -23,6 +23,7 @@ export interface ContextSearchDocument {
| "unavailable"
| "denied"
| "not_found"
| "rejected"
| "invalid";
code?: string;
request_id?: string;
@@ -97,6 +98,9 @@ export function renderContextText(document: ContextSearchDocument): string {
if (document.state === "unavailable") return "暂时无法查记忆,请稍后重试。\n";
if (document.state === "denied") return "当前应用没有查记忆权限。\n";
if (document.state === "invalid") return "查记忆返回了无法识别的结果。\n";
if (document.state === "rejected") {
return `查记忆被拒绝(${document.code}),请检查参数。\n`;
}
const lines = [
document.state === "empty"
? "没有查到相关记忆。"
@@ -104,8 +108,14 @@ export function renderContextText(document: ContextSearchDocument): string {
? "查到以下内容,部分内容还在整理中:"
: "查到以下相关内容:",
];
// A raw hit is an excerpt of an original conversation, not a distilled
// memory; say which one each line is so it is not read as a conclusion.
for (const item of document.items) {
lines.push(`- ${String(item.content)}`);
const label =
item.kind === "raw"
? `[原始对话摘录 · ${String(item.source)}]`
: "[记忆]";
lines.push(`- ${label} ${String(item.content)}`);
}
if (document.truncated) lines.push("结果已按请求预算截断。");
return `${lines.join("\n")}\n`;
+125 -76
View File
@@ -18,6 +18,9 @@ const READ_ONLY_PATHS = [
"/api/v2/user-profile",
] as const;
const SDK_MEMORY_DETAIL = /^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}$/;
/** Status of one `POST /api/v2/memories` addition, by its request id. */
const MEMORY_ADDITION =
/^\/api\/v2\/memories\/additions\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/** Contract (C0, H25): the one-time loopback code exchange for `memhub ui`.
* It is a POST because it mints a short-lived code; the CLI sends the bearer
@@ -79,9 +82,15 @@ export const RESERVED_MEMORY_SEGMENTS = ["ask", "graph"] as const;
/** A response larger than this is refused before it is buffered. */
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
/** `rejected` is Core refusing the request itself (400/409/422: a bad filter,
* an ambiguous id). It is not "Core is busy" — only a transport failure or a
* 5xx is `unavailable`. */
export type ReadOutcome<T> =
| { state: "ok"; data: T }
| { state: "denied" | "unavailable" | "not_found"; code: string };
| {
state: "denied" | "unavailable" | "not_found" | "rejected";
code: string;
};
export type CoreReader = <T>(
path: string,
@@ -140,10 +149,27 @@ export function createCoreReader(attachment: {
if (response.status === 404) {
return { state: "not_found" as const, code: "http_404" };
}
if ([400, 409, 422].includes(response.status)) {
return {
state: "rejected" as const,
code: detailCode(response.data, response.status),
};
}
return { state: "unavailable" as const, code: `http_${response.status}` };
};
}
/** Core's stable `detail` code when it sent one, else the bare status. */
function detailCode(value: unknown, status: number): string {
const detail =
value && typeof value === "object" && !Array.isArray(value)
? (value as { detail?: unknown }).detail
: undefined;
return typeof detail === "string" && /^[a-z0-9_.:-]{1,128}$/.test(detail)
? detail
: `http_${status}`;
}
/**
* Context search is semantically read-only but intentionally uses POST so the
* private query and filters are carried in a bounded body, never in a URL.
@@ -192,6 +218,7 @@ export function isReadOnlyPath(path: string): boolean {
if ((READ_ONLY_PATHS as readonly string[]).includes(path)) return true;
if (IMPORT_JOB.test(path)) return true;
if (OPERATION_RECEIPT.test(path)) return true;
if (MEMORY_ADDITION.test(path)) return true;
if (!SDK_MEMORY_DETAIL.test(path)) return false;
const segment = path.slice("/api/v2/memories/".length);
return !(RESERVED_MEMORY_SEGMENTS as readonly string[]).includes(segment);
@@ -199,8 +226,10 @@ export function isReadOnlyPath(path: string): boolean {
/** A write accepted by the unified Memory Server entry.
*
* `request_id` is what the caller keeps and queries later. Retrying is unsafe:
* HY has no idempotency key, so a resubmit duplicates the memory.
* `request_id` is the idempotency key: Core keys the write on it, so a retry
* with the same id and the same messages is answered from the first write, and
* the same id with different messages is refused (409). A retry without the
* original id is a new write.
*/
export interface MemoryWriteReceipt {
request_id: string;
@@ -267,6 +296,11 @@ function baseUrlOf(attachment: RuntimeAttachmentLike): string {
return `http://${attachment.host}:${attachment.port}`;
}
export type MemoryWriteOutcome =
| { state: "accepted"; receipt: MemoryWriteReceipt }
| { state: "rejected"; code: string; http_status: number }
| { state: "unknown"; code: string };
export async function submitMemoryWrite(
attachment: RuntimeAttachmentLike,
request: {
@@ -277,28 +311,48 @@ export async function submitMemoryWrite(
memory_at?: string;
priority?: "live" | "normal";
},
): Promise<MemoryWriteReceipt> {
const response = await axios.post<MemoryWriteReceipt>(
`${baseUrlOf(attachment)}/api/v2/memory-writes`,
{
request_id: request.request_id,
session_id: request.session,
source_id: request.source,
messages: [...request.messages],
...(request.memory_at === undefined
? {}
: { memory_at: request.memory_at }),
...(request.priority === undefined ? {} : { priority: request.priority }),
},
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 15_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
},
);
return response.data;
): Promise<MemoryWriteOutcome> {
let response: { status: number; data: unknown };
try {
response = await axios.post<MemoryWriteReceipt>(
`${baseUrlOf(attachment)}/api/v2/memory-writes`,
{
request_id: request.request_id,
session_id: request.session,
source_id: request.source,
messages: [...request.messages],
...(request.memory_at === undefined
? {}
: { memory_at: request.memory_at }),
...(request.priority === undefined
? {}
: { priority: request.priority }),
},
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 15_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
validateStatus: () => true,
},
);
} catch {
// The request may or may not have reached Core; the same request id makes
// a retry safe.
return { state: "unknown", code: "transport_result_unknown" };
}
if (response.status >= 200 && response.status < 300) {
return { state: "accepted", receipt: response.data as MemoryWriteReceipt };
}
if (response.status >= 400 && response.status < 500) {
return {
state: "rejected",
code: detailCode(response.data, response.status),
http_status: response.status,
};
}
return { state: "unknown", code: `http_${response.status}_result_unknown` };
}
/** Ask Runtime to scan one source now, returning the import job it started.
@@ -495,48 +549,52 @@ export async function readScanRoots(
}
}
/** A direct Core write whose refusal carries Core's stable code. */
export type CoreWriteResult =
| { ok: true; data: unknown }
| { ok: false; code: string };
async function coreWrite(
attachment: RuntimeAttachmentLike,
method: "post" | "delete",
pathname: string,
body: Record<string, unknown>,
timeout = 10_000,
): Promise<CoreWriteResult> {
let response: { status: number; data: unknown };
try {
response = await axios.request({
method,
url: `${baseUrlOf(attachment)}${pathname}`,
data: body,
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
validateStatus: () => true,
});
} catch {
return { ok: false, code: "transport_failed" };
}
if (response.status >= 200 && response.status < 300) {
return { ok: true, data: response.data };
}
return { ok: false, code: detailCode(response.data, response.status) };
}
export async function addScanRoot(
attachment: RuntimeAttachmentLike,
body: { root: string; source?: string; recursive?: boolean },
): Promise<unknown | null> {
try {
const response = await axios.post<unknown>(
`${baseUrlOf(attachment)}/api/v1/scan-roots`,
body,
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 10_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
},
);
return response.data;
} catch (error) {
return { _error: error instanceof Error ? error.message : String(error) };
}
): Promise<CoreWriteResult> {
return coreWrite(attachment, "post", "/api/v1/scan-roots", body);
}
export async function removeScanRoot(
attachment: RuntimeAttachmentLike,
body: { root: string },
): Promise<unknown | null> {
try {
const response = await axios.delete<unknown>(
`${baseUrlOf(attachment)}/api/v1/scan-roots`,
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
data: body,
timeout: 10_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
},
);
return response.data;
} catch (error) {
return { _error: error instanceof Error ? error.message : String(error) };
}
): Promise<CoreWriteResult> {
return coreWrite(attachment, "delete", "/api/v1/scan-roots", body);
}
export async function readAdapters(
@@ -562,21 +620,12 @@ export async function readAdapters(
export async function probeAdapter(
attachment: RuntimeAttachmentLike,
source: string,
): Promise<unknown | null> {
try {
const response = await axios.post<unknown>(
`${baseUrlOf(attachment)}/api/v1/adapters/probe`,
{ source },
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 15_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
},
);
return response.data;
} catch (error) {
return { _error: error instanceof Error ? error.message : String(error) };
}
): Promise<CoreWriteResult> {
return coreWrite(
attachment,
"post",
"/api/v1/adapters/probe",
{ source },
15_000,
);
}
+9 -94
View File
@@ -4,9 +4,11 @@ const WRITE_PATHS = [
"/api/v1/sources/connect",
"/api/v1/model-profiles",
] as const;
const MEMORY_WRITE_PATH =
/^\/api\/v1\/memories(?:\/[A-Za-z0-9][\w.-]{0,127}\/(?:correct|scopes|actions\/delete))?$/;
const SDK_MEMORY_PATH = /^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}$/;
/** `POST /api/v2/memories` adds one memory (the app's 添加记忆). */
const MEMORY_ADD_PATH = "/api/v2/memories";
/** One SDK memory; delete names its namespace in the query string. */
const SDK_MEMORY_PATH =
/^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}(?:\?kind=(?:chat|experience|fact)(?:&variant=(?:working|coding))?)?$/;
const MODEL_PROBE_PATH =
/^\/api\/v1\/model-profiles\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/probe$/;
const MODEL_PROFILE_PATH =
@@ -22,18 +24,7 @@ const SOURCE_REVOKE_PATH =
const SOURCE_IMPORT_JOB_PATH = "/api/v1/import-jobs";
const IMPORT_JOB_ACTION_PATH =
/^\/api\/v1\/import-jobs\/[A-Za-z0-9][\w.-]{0,127}\/(?:pause|resume|cancel)$/;
const CALL_EVENTS_CLEAR_PATH = "/api/v1/call-events/clear";
const CALL_EVENTS_CLEAR_ALL_PATH = "/api/v1/call-events/clear-all";
const IMPORT_FILE_COMMIT =
/^\/api\/v1\/import-files\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/commit$/;
const IMPORT_FILE =
/^\/api\/v1\/import-files\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const MEMORY_NOTES_PATH = "/api/v1/memory-notes";
const MEMORY_NOTE_PROCESS =
/^\/api\/v1\/memory-notes\/[A-Za-z0-9][\w.:-]{0,127}\/process$/;
const EXTRACTION_SETTINGS_PATH = "/api/v1/extraction-settings";
const GRAPH_WIDTH_PATH = "/api/v1/graph-width";
const MANUAL_EXTRACT_PATH = "/api/v1/memory-processing/manual-extract";
const ONBOARDING_COMPLETE_PATH = "/api/v1/onboarding/complete";
const MAX_BYTES = 4 * 1024 * 1024;
@@ -70,18 +61,6 @@ export interface CorePatchOperations {
): Promise<OperationOutcome<T>>;
}
export interface CoreUploadOperations {
uploadImportFile<T>(
input: {
bytes: Uint8Array;
filename: string;
inputKind: "raw_conversation" | "memory_content";
operationId: string;
},
signal?: AbortSignal,
): Promise<OperationOutcome<T>>;
}
export function createCoreOperations(attachment: {
host: string;
port: number;
@@ -90,8 +69,7 @@ export function createCoreOperations(attachment: {
}): CoreOperations &
CoreDeleteOperations &
CorePutOperations &
CorePatchOperations &
CoreUploadOperations {
CorePatchOperations {
const client = axios.create({
baseURL: `http://${attachment.host}:${attachment.port}`,
headers: { Authorization: `Bearer ${attachment.apiToken}` },
@@ -102,18 +80,6 @@ export function createCoreOperations(attachment: {
maxBodyLength: 128 * 1024,
validateStatus: () => true,
});
// Uploads carry up to 128 MiB of raw octet-stream, so they need a separate
// body limit and a deadline that leaves room for parsing on the Core side.
const uploadClient = axios.create({
baseURL: `http://${attachment.host}:${attachment.port}`,
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 120_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_BYTES,
maxBodyLength: 128 * 1024 * 1024,
validateStatus: () => true,
});
return {
async post<T>(
path: string,
@@ -123,19 +89,13 @@ export function createCoreOperations(attachment: {
if (
!(WRITE_PATHS as readonly string[]).includes(path) &&
path !== SOURCE_IMPORT_JOB_PATH &&
!MEMORY_WRITE_PATH.test(path) &&
path !== MEMORY_ADD_PATH &&
!MODEL_PROBE_PATH.test(path) &&
!SOURCE_SYNC_PATH.test(path) &&
!IMPORT_JOB_ACTION_PATH.test(path) &&
!SOURCE_AUTHORIZE_PATH.test(path) &&
!SOURCE_REVOKE_PATH.test(path) &&
path !== CALL_EVENTS_CLEAR_PATH &&
path !== CALL_EVENTS_CLEAR_ALL_PATH &&
!IMPORT_FILE_COMMIT.test(path) &&
path !== MEMORY_NOTES_PATH &&
!MEMORY_NOTE_PROCESS.test(path) &&
path !== GRAPH_WIDTH_PATH &&
path !== MANUAL_EXTRACT_PATH &&
path !== ONBOARDING_COMPLETE_PATH &&
!MODEL_PROFILE_ACTIVATE.test(path)
) {
@@ -171,7 +131,7 @@ export function createCoreOperations(attachment: {
body: Record<string, unknown>,
signal?: AbortSignal,
) {
if (path !== EXTRACTION_SETTINGS_PATH && !SDK_MEMORY_PATH.test(path)) {
if (!SDK_MEMORY_PATH.test(path) || path.includes("?")) {
throw new Error("core_operation_path_not_allowed");
}
let response: { status: number; data: unknown };
@@ -233,7 +193,7 @@ export function createCoreOperations(attachment: {
};
},
async delete<T>(path: string, signal?: AbortSignal) {
if (!IMPORT_FILE.test(path) && !SDK_MEMORY_PATH.test(path)) {
if (!SDK_MEMORY_PATH.test(path) && !MODEL_PROFILE_PATH.test(path)) {
throw new Error("core_operation_path_not_allowed");
}
let response: { status: number; data: unknown };
@@ -257,51 +217,6 @@ export function createCoreOperations(attachment: {
code: `http_${response.status}_result_unknown`,
};
},
async uploadImportFile<T>(
input: {
bytes: Uint8Array;
filename: string;
inputKind: "raw_conversation" | "memory_content";
operationId: string;
},
signal?: AbortSignal,
) {
const query = new URLSearchParams({
filename: input.filename,
input_kind: input.inputKind,
operation_id: input.operationId,
});
let response: { status: number; data: unknown };
try {
response = await uploadClient.post(
`/api/v1/import-files?${query.toString()}`,
input.bytes,
{
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(input.bytes.byteLength),
},
...(signal ? { signal } : {}),
},
);
} catch {
return { state: "unknown", code: "transport_result_unknown" };
}
if (response.status >= 200 && response.status < 300) {
return { state: "committed", data: response.data as T };
}
if (response.status >= 400 && response.status < 500) {
return {
state: "rejected",
code: safeErrorCode(response.data, response.status),
http_status: response.status,
};
}
return {
state: "unknown",
code: `http_${response.status}_result_unknown`,
};
},
};
}
+11 -24
View File
@@ -23,39 +23,26 @@ function accountHomeDirectory(): string | undefined {
async function main(): Promise<number> {
const arguments_ = process.argv.slice(2);
const command = arguments_[0];
const action = arguments_[1];
// A preview (`--yes` absent) must not create or write any diagnostic file:
// the contract is that it only emits a plan. Diagnostics for these commands
// are initialized only once the caller has confirmed the mutation.
const confirmed = arguments_.includes("--yes")
? true
: arguments_.includes("--confirm");
const confirmed = arguments_.includes("--yes");
const installLike = [
"install",
"upgrade",
"service-install",
"service-uninstall",
].includes(command ?? "");
const planFileApply =
(command === "sources" && action === "import") ||
(command === "models" && (action === "configure" || action === "probe")) ||
(command === "processing" && action === "apply") ||
(command === "memories" && action === "apply");
const isPreview = (installLike || planFileApply) && !confirmed;
const mutating =
[
"start",
"stop",
"restart",
"install",
"upgrade",
"service-install",
"service-uninstall",
].includes(command ?? "") ||
(command === "sources" && action === "import") ||
(command === "models" && ["configure", "probe"].includes(action ?? "")) ||
(command === "processing" && action === "apply") ||
(command === "memories" && action === "apply");
const isPreview = installLike && !confirmed;
const mutating = [
"start",
"stop",
"restart",
"install",
"upgrade",
"service-install",
"service-uninstall",
].includes(command ?? "");
let diagnostics: DiagnosticLogger | null = null;
if (mutating && !isPreview) {
try {
+93
View File
@@ -0,0 +1,93 @@
/**
* `memhub graph-width`: the user's decision when the embedding width changed
* and the memory graph no longer matches it (`POST /api/v1/graph-width`).
*
* `keep_existing` and `export_first` change nothing irreversible and run
* directly. `rebuild_with_new` archives the current graph and rebuilds it, so
* the CLI asks for `--yes` before sending it.
*/
import { parseGraphWidthDecisionReceipt } from "@one-memory/operations";
import type { CoreOperations } from "./core-operations.js";
import { PRODUCT } from "./version.js";
export const GRAPH_WIDTH_DECISIONS = [
"keep_existing",
"rebuild_with_new",
"export_first",
] as const;
export type GraphWidthDecision = (typeof GRAPH_WIDTH_DECISIONS)[number];
/** The one decision that cannot be taken back. */
export function graphWidthDecisionIsDestructive(
decision: GraphWidthDecision,
): boolean {
return decision === "rebuild_with_new";
}
export type GraphWidthDocument =
| {
schema_version: 1;
product: string;
command: "graph-width";
state: "committed";
decision: GraphWidthDecision;
receipt: Record<string, unknown>;
}
| {
schema_version: 1;
product: string;
command: "graph-width";
state: "rejected" | "unknown";
decision: GraphWidthDecision;
code: string;
};
export async function decideGraphWidth(
decision: GraphWidthDecision,
operations: CoreOperations,
): Promise<GraphWidthDocument> {
const base = {
schema_version: 1 as const,
product: PRODUCT,
command: "graph-width" as const,
decision,
};
const result = await operations.post<unknown>("/api/v1/graph-width", {
decision,
});
if (result.state !== "committed") {
return { ...base, state: result.state, code: result.code };
}
const receipt = parseGraphWidthDecisionReceipt(result.data);
if (receipt === null) {
return { ...base, state: "unknown", code: "graph_width_receipt_invalid" };
}
return {
...base,
state: "committed",
receipt: {
archived_path: receipt.archived_path ?? null,
exported_path: receipt.exported_path ?? null,
mismatch: receipt.mismatch ?? null,
},
};
}
export function graphWidthExitCode(document: GraphWidthDocument): number {
return document.state === "committed" ? 0 : 1;
}
export function renderGraphWidthText(document: GraphWidthDocument): string {
if (document.state === "committed") {
const lines = [`已执行图谱宽度决定:${document.decision}`];
if (document.receipt.exported_path) {
lines.push(`导出:${document.receipt.exported_path}`);
}
if (document.receipt.archived_path) {
lines.push(`旧图谱已归档:${document.receipt.archived_path}`);
}
return `${lines.join("\n")}\n`;
}
return `图谱宽度决定未完成:${document.state}(${document.code})\n`;
}
File diff suppressed because it is too large. Load diff
+82 -287
View File
@@ -1,4 +1,4 @@
import { createHash, randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import type { ImportJob, ModelProfile } from "@one-memory/contracts";
import {
buildModelProfileCreateRequest,
@@ -26,45 +26,7 @@ const PROVIDERS = [
"local_onnx",
] as const;
interface SourceImportPlanBody {
schema_version: 1;
kind: "source_import";
request_id: string;
source_ids: string[];
source_roots: Record<string, string>;
source_candidate_revisions: Record<string, number>;
confirm_environment_overrides: boolean;
full_rescan: false;
onboarding: true;
}
export type SourceImportPlan = SourceImportPlanBody & { digest: string };
interface ModelCreatePlanBody {
schema_version: 1;
kind: "model_profile_create";
operation_id: string;
config: Record<string, unknown>;
}
export type ModelCreatePlan = ModelCreatePlanBody & { digest: string };
/** A removal only needs an id: there is no config to review, and inventing one
* to fit the create shape would misrepresent what the command does.
*/
export interface ModelRemovePlan {
schema_version: 1;
kind: "model_profile_remove";
target: string;
}
export type FlowDocument =
| {
schema_version: 1;
product: string;
state: "preview";
plan: SourceImportPlan | ModelCreatePlan | ModelRemovePlan;
}
| {
schema_version: 1;
product: string;
@@ -133,10 +95,24 @@ function classifySourceImport(
return committed(operationId, receipt);
}
export async function planSourceImport(
/**
* Connect sources and import their history in one direct request.
*
* Reads the current candidates, refuses a source whose location comes from an
* environment override unless the caller confirmed overrides, and posts one
* `/sources/connect` (grant + history import + live sync). `requestId` is the
* idempotency key: re-running with the same id reads the first receipt.
*/
export async function connectSourcesWithImport(
reader: CoreReader,
sourceIds: readonly string[],
operations: CoreOperations,
input: {
sourceIds: readonly string[];
confirmOverrides: boolean;
requestId?: string;
},
): Promise<FlowDocument> {
const sourceIds = input.sourceIds;
if (
sourceIds.length < 1 ||
sourceIds.length > 32 ||
@@ -145,6 +121,8 @@ export async function planSourceImport(
) {
return invalid("invalid_source_selection");
}
const requestId = input.requestId ?? randomUUID();
if (!UUID.test(requestId)) return invalid("invalid_request_id");
const outcome = await reader<unknown>("/api/v1/sources");
if (outcome.state !== "ok") return fromReadFailure(outcome);
const candidates = validateSourceConnectionCandidates(outcome.data);
@@ -156,69 +134,59 @@ export async function planSourceImport(
return invalid("source_unavailable");
}
const items = selected.map(([candidate]) => candidate);
const body: SourceImportPlanBody = {
schema_version: 1,
kind: "source_import",
request_id: randomUUID(),
source_ids: [...sourceIds],
source_roots: Object.fromEntries(
items.map((item) => [item.source_id, item.candidate_root_id]),
),
source_candidate_revisions: Object.fromEntries(
items.flatMap((item) =>
item.candidate_revision === undefined
? []
: [[item.source_id, item.candidate_revision]],
),
),
confirm_environment_overrides: items.some(
(item) => item.requires_override_confirmation,
),
full_rescan: false,
onboarding: true,
};
return preview(withDigest(body));
}
export async function executeSourceImport(
value: unknown,
reader: CoreReader,
operations: CoreOperations,
): Promise<FlowDocument> {
const plan = validateSourcePlan(value);
if (!plan) return invalid("source_plan_invalid");
const needsOverride = items
.filter((item) => item.requires_override_confirmation)
.map((item) => item.source_id);
if (needsOverride.length > 0 && !input.confirmOverrides) {
return {
schema_version: 1,
product: PRODUCT,
state: "rejected",
operation_id: requestId,
code: "environment_override_confirmation_required",
receipt: { override_required_source_ids: needsOverride },
};
}
// The shared builder owns the connect body's field set and coalescing, so
// the CLI and the Web console send the same request by construction.
let request: ReturnType<typeof buildSourceImportRequest>;
try {
request = buildSourceImportRequest({
sourceIds: plan.source_ids,
sourceRoots: plan.source_roots,
sourceCandidateRevisions: plan.source_candidate_revisions,
confirmEnvironmentOverrides: plan.confirm_environment_overrides,
fullRescan: plan.full_rescan,
onboarding: plan.onboarding,
sourceIds: [...sourceIds],
sourceRoots: Object.fromEntries(
items.map((item) => [item.source_id, item.candidate_root_id]),
),
sourceCandidateRevisions: Object.fromEntries(
items.flatMap((item) =>
item.candidate_revision === undefined
? []
: [[item.source_id, item.candidate_revision]],
),
),
confirmEnvironmentOverrides: needsOverride.length > 0,
fullRescan: false,
onboarding: true,
enableSync: true,
requestId: plan.request_id,
requestId,
});
} catch (error) {
if (error instanceof SourceImportInputError) {
return invalid("source_plan_invalid");
return invalid("invalid_source_selection");
}
throw error;
}
const result = await operations.post<unknown>(request.path, request.body);
if (result.state === "rejected") {
return rejected(plan.request_id, result.code);
return rejected(requestId, result.code);
}
if (result.state === "committed") {
const receipt = validateSourceReceipt(result.data, plan.request_id);
if (!receipt) return unknown(plan.request_id, "source_receipt_invalid");
return classifySourceImport(plan.request_id, receipt);
const receipt = validateSourceReceipt(result.data, requestId);
if (!receipt) return unknown(requestId, "source_receipt_invalid");
return classifySourceImport(requestId, receipt);
}
const recovered = await readSourceReceipt(reader, plan.request_id);
const recovered = await readSourceReceipt(reader, requestId);
if (recovered.state === "unknown" || recovered.state === "not_found") {
return unknown(plan.request_id, result.code);
return unknown(requestId, result.code);
}
// A recovered receipt must be classified the same way as a fresh one: an
// all-rejected or all-busy replay is still not success. Only a terminal
@@ -262,26 +230,23 @@ export async function readSourceReceipt(
return committed(requestId, receipt);
}
export function planModelProfile(value: unknown): FlowDocument {
const config = validateModelConfig(value);
if (!config) return invalid("model_config_invalid");
const body: ModelCreatePlanBody = {
schema_version: 1,
kind: "model_profile_create",
operation_id: randomUUID(),
config,
};
return preview(withDigest(body));
}
export async function executeModelProfile(
/**
* Create a model profile directly from a bounded config (never containing the
* key) plus an optional key read from a controlled file.
*
* `operationId` is Core's creation idempotency key; the receipt can be read
* later with `readModelReceipt`.
*/
export async function createModelProfile(
value: unknown,
apiKey: string | undefined,
reader: CoreReader,
operations: CoreOperations,
operationId: string = randomUUID(),
): Promise<FlowDocument> {
const plan = validateModelPlan(value);
if (!plan) return invalid("model_plan_invalid");
const config = validateModelConfig(value);
if (!config) return invalid("model_config_invalid");
if (!UUID.test(operationId)) return invalid("invalid_operation_id");
// The shared create builder owns the body's allow-list, bounds and the
// creation_operation_id/api_key handling, so the CLI and the Web console
// send the same create request.
@@ -289,31 +254,31 @@ export async function executeModelProfile(
try {
request = buildModelProfileCreateRequest(
{
...(plan.config as Record<string, unknown>),
...config,
...(apiKey === undefined ? {} : { api_key: apiKey }),
} as Parameters<typeof buildModelProfileCreateRequest>[0],
plan.operation_id,
operationId,
);
} catch (error) {
if (error instanceof ModelProfileInputError) {
return invalid("model_plan_invalid");
return invalid("model_config_invalid");
}
throw error;
}
const result = await operations.post<unknown>(request.path, request.body);
if (result.state === "rejected") {
return rejected(plan.operation_id, result.code);
return rejected(operationId, result.code);
}
if (result.state === "committed") {
const profile = validateModelProfile(result.data);
return profile
? committed(plan.operation_id, projectProfile(profile))
: unknown(plan.operation_id, "model_receipt_invalid");
? committed(operationId, projectProfile(profile))
: unknown(operationId, "model_receipt_invalid");
}
const recovered = await readModelReceipt(reader, plan.operation_id);
const recovered = await readModelReceipt(reader, operationId);
return recovered.state === "committed"
? { ...recovered, state: "recovered" }
: unknown(plan.operation_id, result.code);
: unknown(operationId, result.code);
}
export async function readModelReceipt(
@@ -362,27 +327,6 @@ export function parseApiKey(bytes: Buffer | null): string | undefined {
}
export function renderFlowText(document: FlowDocument): string {
if (document.state === "preview") {
const plan = document.plan;
// A removal has no config digest or operation id to confirm: the target id
// *is* the thing being reviewed. Rendering fields it does not have would
// print "undefined".
if (plan.kind === "model_profile_remove") {
return [
`预览:${plan.kind}`,
`目标配置:${plan.target}`,
"未执行任何写操作。加 --confirm 才会真正删除。",
"",
].join("\n");
}
return [
`预览:${plan.kind}`,
`操作 ID:${"request_id" in plan ? plan.request_id : plan.operation_id}`,
`确认摘要:${plan.digest}`,
"未执行任何写操作。请把 --json 输出保存为 plan 文件,再用对应命令加 --confirm。",
"",
].join("\n");
}
const lines = [
`操作状态:${document.state}`,
`操作 ID:${document.operation_id ?? "无"}`,
@@ -407,98 +351,20 @@ export function renderFlowText(document: FlowDocument): string {
if (document.state === "partial" && Array.isArray(accepted)) {
lines.push(` 已接入:${accepted.join("、")}`);
}
const overrides = document.receipt.override_required_source_ids;
if (Array.isArray(overrides)) {
lines.push(
` 这些来源的目录来自环境变量覆盖,需要确认:${overrides.join("、")}`,
" 确认目录无误后加 --confirm-overrides 重新运行。",
);
}
}
lines.push("");
return lines.join("\n");
}
export function flowExitCode(document: FlowDocument): number {
return ["preview", "committed", "recovered"].includes(document.state) ? 0 : 1;
}
export function inspectExecutionPlan(value: unknown): {
kind: "source_import" | "model_profile_create";
operationId: string;
digest: string;
} | null {
const source = validateSourcePlan(value);
if (source) {
return {
kind: source.kind,
operationId: source.request_id,
digest: source.digest,
};
}
const model = validateModelPlan(value);
return model
? {
kind: model.kind,
operationId: model.operation_id,
digest: model.digest,
}
: null;
}
function validateSourcePlan(value: unknown): SourceImportPlan | null {
const plan = record(value);
const requestId = asString(plan?.request_id);
if (
plan?.schema_version !== 1 ||
plan.kind !== "source_import" ||
!UUID.test(requestId) ||
!Array.isArray(plan.source_ids) ||
plan.source_ids.length < 1 ||
plan.source_ids.length > 32 ||
plan.source_ids.some(
(id) => typeof id !== "string" || !SOURCE_ID.test(id),
) ||
new Set(plan.source_ids).size !== plan.source_ids.length ||
plan.full_rescan !== false ||
plan.onboarding !== true ||
typeof plan.confirm_environment_overrides !== "boolean" ||
typeof plan.digest !== "string"
) {
return null;
}
const sourceIds = plan.source_ids as string[];
const roots = stringMap(plan.source_roots, sourceIds);
const revisions = numberMap(plan.source_candidate_revisions, sourceIds);
if (!roots || !revisions) return null;
const body: SourceImportPlanBody = {
schema_version: 1,
kind: "source_import",
request_id: requestId,
source_ids: [...sourceIds],
source_roots: roots,
source_candidate_revisions: revisions,
confirm_environment_overrides: plan.confirm_environment_overrides,
full_rescan: false,
onboarding: true,
};
const expected = withDigest(body);
return timingSafeText(expected.digest, plan.digest) ? expected : null;
}
function validateModelPlan(value: unknown): ModelCreatePlan | null {
const plan = record(value);
const operationId = asString(plan?.operation_id);
if (
plan?.schema_version !== 1 ||
plan.kind !== "model_profile_create" ||
!UUID.test(operationId) ||
typeof plan.digest !== "string"
) {
return null;
}
const config = validateModelConfig(plan.config);
if (!config) return null;
const expected = withDigest<ModelCreatePlanBody>({
schema_version: 1,
kind: "model_profile_create",
operation_id: operationId,
config,
});
return timingSafeText(expected.digest, plan.digest) ? expected : null;
return ["committed", "recovered"].includes(document.state) ? 0 : 1;
}
function validateModelConfig(value: unknown): Record<string, unknown> | null {
@@ -644,37 +510,6 @@ function projectJob(job: ImportJob): Record<string, unknown> {
};
}
function withDigest<T extends object>(body: T): T & { digest: string } {
return { ...body, digest: digest(body) };
}
function digest(value: unknown): string {
return createHash("sha256").update(canonical(value)).digest("hex");
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map(
(key) =>
`${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key])}`,
)
.join(",")}}`;
}
return JSON.stringify(value);
}
function preview(plan: SourceImportPlan | ModelCreatePlan): FlowDocument {
return {
schema_version: 1,
product: PRODUCT,
state: "preview",
plan,
};
}
function committed(
operationId: string,
receipt: Record<string, unknown>,
@@ -767,43 +602,3 @@ function optionalInteger(
(value as number) <= maximum)
);
}
function stringMap(
value: unknown,
keys: readonly string[],
): Record<string, string> | null {
const map = record(value);
if (
!map ||
Object.keys(map).length !== keys.length ||
keys.some((key) => !boundedText(map[key], 256))
) {
return null;
}
return Object.fromEntries(keys.map((key) => [key, map[key] as string]));
}
function numberMap(
value: unknown,
allowedKeys: readonly string[],
): Record<string, number> | null {
const map = record(value);
if (
!map ||
Object.entries(map).some(
([key, revision]) =>
!allowedKeys.includes(key) ||
!Number.isSafeInteger(revision) ||
(revision as number) < 0,
)
) {
return null;
}
return Object.fromEntries(
Object.entries(map).map(([key, revision]) => [key, revision as number]),
);
}
function timingSafeText(expected: string, actual: unknown): boolean {
return typeof actual === "string" && expected === actual;
}
@@ -1,64 +0,0 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { initPlanAttemptPath } from "./cli.js";
import { interactiveInitAttemptPath } from "./init-attempt.js";
// Regression: `init --source <id> --import` used to pass the literal string
// `"init"` to `claimPlanAttempt`, which resolved to `<cwd>/init.one-memory-attempt`.
// Two consequences: the fence was shared across unrelated runs in the same
// directory, and any unrelated file named `init` could make a valid import
// report a misleading usage error (exit 2) instead of reading its receipt.
describe("init import attempt fence", () => {
it("lives under the data dir, not the current working directory", () => {
const fence = initPlanAttemptPath(
"/tmp/example data",
"11111111-2222-4333-8444-555555555555",
);
expect(fence).toBe(
path.join(
"/tmp/example data",
".cli-init-attempt-11111111-2222-4333-8444-555555555555",
),
);
expect(path.isAbsolute(fence)).toBe(true);
expect(fence).not.toBe("init");
// Directly under the data dir: no nested directory that could fail to be
// created and turn a valid import into a usage error.
expect(path.dirname(fence)).toBe("/tmp/example data");
});
it("is per data dir and per operation, so installs/runs cannot collide", () => {
const a = initPlanAttemptPath("/tmp/data-a", "op-1");
const b = initPlanAttemptPath("/tmp/data-b", "op-1");
const c = initPlanAttemptPath("/tmp/data-a", "op-2");
expect(new Set([a, b, c]).size).toBe(3);
});
});
// QA-CLI R3 F1: the interactive wizard passed a CWD-relative singleton
// (`init` or `<journalDir>/init`) to `claimPlanAttempt`. A stale
// `init.one-memory-attempt` in the working directory then made every later
// `memhub init` fail with "交互计划无效 / exit 2".
describe("interactive init attempt fence", () => {
it("lives under the data dir and is unique per operation", () => {
const fence = interactiveInitAttemptPath(
"/tmp/data",
"11111111-2222-4333-8444-555555555555",
);
expect(fence).toBe(
path.join(
"/tmp/data",
".cli-init-attempt-11111111-2222-4333-8444-555555555555",
),
);
expect(path.isAbsolute(fence)).toBe(true);
expect(fence).not.toBe("init");
});
it("does not reuse one fence for two distinct plans", () => {
const first = interactiveInitAttemptPath("/tmp/data", "op-1");
const second = interactiveInitAttemptPath("/tmp/data", "op-2");
expect(first).not.toBe(second);
});
});
-16
View File
@@ -1,16 +0,0 @@
import path from "node:path";
/** Where the interactive `init` wizard keeps its durable attempt fence.
*
* The wizard originally passed a CWD-relative singleton (`init`) to
* `claimPlanAttempt`. That shared one fence across unrelated runs and let a
* stale `init.one-memory-attempt` in the working directory turn every later
* wizard run into "交互计划无效 / exit 2". Keep the fence under the Runtime's
* data dir, keyed by the operation id, exactly like `init --source --import`.
*/
export function interactiveInitAttemptPath(
dataDir: string,
operationId: string,
): string {
return path.join(dataDir, `.cli-init-attempt-${operationId}`);
}
+6 -6
View File
@@ -418,9 +418,9 @@ describe("A2 dual-mode init", () => {
});
// QA-CLI R3 F1: a stale CWD `init.one-memory-attempt` used to poison every
// later wizard run. The fence now lives under the isolated data dir and is
// keyed by the operation id.
it("ignores a stale CWD init fence and keeps its fence under the data dir", async () => {
// later wizard run. There are no plan files or attempt fences any more: the
// connect request id is the idempotency key Core keeps.
it("ignores a stale CWD init fence and writes no fence files", async () => {
transport.get.mockImplementation(async (url: string) =>
url === "/api/v1/sources"
? { status: 200, data: [candidate("codex", "Codex")] }
@@ -471,7 +471,7 @@ describe("A2 dual-mode init", () => {
const dataEntries = await readdir(dataDir);
expect(
dataEntries.some((entry) => entry.startsWith(".cli-init-attempt-")),
).toBe(true);
).toBe(false);
});
it("keeps selected Harnesses frozen without creating history jobs", async () => {
@@ -506,7 +506,7 @@ describe("A2 dual-mode init", () => {
});
});
describe("A19 init --all-detected", () => {
describe("A19 sources connect --all-detected (old spelling: init --all-detected)", () => {
it("connects every found source in one batch call without prompting", async () => {
transport.get.mockImplementation(async (url: string) =>
url === "/api/v1/sources"
@@ -556,7 +556,7 @@ describe("A19 init --all-detected", () => {
expect(prompter.asked).toBe(0);
expect(code).toBe(0);
const document = JSON.parse(streams.value());
expect(document.command).toBe("init");
expect(document.command).toBe("sources connect");
expect(document.state).toBe("committed");
expect(
document.sources.map((row: { source_id: string }) => row.source_id),
+23 -50
View File
@@ -4,18 +4,11 @@ import type {
CoreDeleteOperations,
CoreOperations,
} from "./core-operations.js";
import {
executeModelProfile,
type FlowDocument,
planModelProfile,
} from "./headless-flows.js";
import { createModelProfile, type FlowDocument } from "./headless-flows.js";
import { approvalFingerprint, listHosts, readHost } from "./host-flow.js";
import { parseSelection, type WizardPrompter } from "./init-wizard.js";
import { executeModelProbe, planModelProbe } from "./model-probe-flow.js";
import {
executeModelProfileActivate,
planModelProfileActivate,
} from "./model-write-flows.js";
import { startModelProbe } from "./model-probe-flow.js";
import { activateModelProfile } from "./model-write-flows.js";
type ProviderPreset = {
id: string;
@@ -359,30 +352,26 @@ export async function runInteractiveModelSetup(
);
})
: new Map<string, string>();
const planned = planModelProfile({
name: preset.label,
provider_kind: preset.provider_kind,
base_url: baseUrl,
model,
embedding_provider_kind: "local_onnx",
embedding_model: "bge-small-zh-v1.5",
embedding_dimensions: 512,
timeout_seconds: 90,
max_concurrency: 4,
disable_thinking: true,
});
if (
planned.state !== "preview" ||
planned.plan.kind !== "model_profile_create"
) {
return { state: "failed", code: "model_config_invalid" };
}
const created = await executeModelProfile(
planned.plan,
const created = await createModelProfile(
{
name: preset.label,
provider_kind: preset.provider_kind,
base_url: baseUrl,
model,
embedding_provider_kind: "local_onnx",
embedding_model: "bge-small-zh-v1.5",
embedding_dimensions: 512,
timeout_seconds: 90,
max_concurrency: 4,
disable_thinking: true,
},
apiKey,
clients.reader,
clients.operations,
);
if (created.state === "invalid") {
return { state: "failed", code: "model_config_invalid" };
}
const profile = receiptProfile(created);
if (profile === null) {
prompter.write("模型配置未保存。");
@@ -391,18 +380,11 @@ export async function runInteractiveModelSetup(
}
try {
const probePlan = await planModelProbe(
const probed = await startModelProbe(
clients.reader,
clients.operations,
profile.profile_id,
);
const probed =
probePlan.state === "preview"
? await executeModelProbe(
probePlan.plan,
clients.reader,
clients.operations,
)
: probePlan;
if (!probePassed(probed)) {
await clients.operations.delete(
`/api/v1/model-profiles/${encodeURIComponent(profile.profile_id)}`,
@@ -421,17 +403,8 @@ export async function runInteractiveModelSetup(
throw error;
}
const activation = planModelProfileActivate({
profileId: profile.profile_id,
});
if (
activation.state !== "preview" ||
activation.plan.kind !== "model_profile_activate"
) {
return { state: "failed", code: "model_activate_plan_invalid" };
}
const activated = await executeModelProfileActivate(
activation.plan,
const activated = await activateModelProfile(
profile.profile_id,
clients.operations,
);
if (activated.state !== "committed") {
+13 -13
View File
@@ -101,7 +101,7 @@ describe("memhub jobs", () => {
expect(transport.post).not.toHaveBeenCalled();
});
it("start previews without a POST", async () => {
it("start posts directly, without a preview step", async () => {
const output = captureStdout();
let code: number;
try {
@@ -110,14 +110,14 @@ describe("memhub jobs", () => {
output.restore();
}
expect(code!).toBe(0);
expect(transport.post).not.toHaveBeenCalled();
expect(JSON.parse(output.value())).toMatchObject({
state: "preview",
plan: { source_id: "codex", operation: "history" },
});
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/import-jobs",
{ source_id: "codex", operation: "history" },
undefined,
);
});
it("start --confirm posts the exact Core request", async () => {
it("start still accepts a legacy --confirm", async () => {
const output = captureStdout();
let code: number;
try {
@@ -146,7 +146,7 @@ describe("memhub jobs", () => {
});
it.each(["pause", "resume", "cancel"] as const)(
"%s previews without a POST",
"%s posts the action endpoint directly",
async (action) => {
const output = captureStdout();
let code: number;
@@ -156,11 +156,11 @@ describe("memhub jobs", () => {
output.restore();
}
expect(code!).toBe(0);
expect(transport.post).not.toHaveBeenCalled();
expect(JSON.parse(output.value())).toMatchObject({
state: "preview",
plan: { job_id: "job-1", action },
});
expect(transport.post).toHaveBeenCalledWith(
`/api/v1/import-jobs/job-1/${action}`,
{},
undefined,
);
},
);
+583
View File
@@ -0,0 +1,583 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { run } from "./cli.js";
const transport = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
}));
vi.mock("axios", () => ({
default: { create: vi.fn(() => transport), get: vi.fn() },
}));
const runtime = vi.hoisted(() => ({
inspectRuntimeStatus: vi.fn(),
discoverRuntimeAttachment: vi.fn(),
}));
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
...runtime,
}));
const REQUEST = "33333333-3333-4333-8333-333333333333";
const EVIDENCE = "44444444-4444-4444-8444-444444444444";
function addReceipt(state = "pending", memoryIds: string[] = []) {
return {
evidence_id: EVIDENCE,
text: "Prefers tea",
scope: "all-apps",
created_at: "2026-10-27T00:00:00Z",
state,
task_id: null,
memory_ids: memoryIds,
error: null,
};
}
function memoryRecord(overrides: Record<string, unknown> = {}) {
return {
memory_id: "exp-1",
content: "deploy\nrun make",
layer: "experience",
status: "active",
is_latest: true,
supersedes: [],
superseded_by: [],
tags: [],
owner: null,
source_raw_ids: [],
user_id: "local",
agent_id: "",
session_id: null,
source: "codebuddy",
memory_at: null,
gmt_created: 1_790_000_000,
scene: "working",
kind: "experience",
variant: "working",
task: "deploy",
solution: "run make",
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
transport.get.mockReset();
transport.post.mockReset();
transport.put.mockReset();
transport.patch.mockReset();
transport.delete.mockReset();
runtime.inspectRuntimeStatus.mockResolvedValue({
state: "running",
code: "runtime_verified",
instanceId: "instance-1",
host: "127.0.0.1",
port: 8765,
version: "1.2.3",
});
runtime.discoverRuntimeAttachment.mockResolvedValue({
instanceId: "instance-1",
apiToken: "synthetic-runtime-token",
host: "127.0.0.1",
port: 8765,
servicePid: 4242,
health: {
status: "ok",
service: "one-memory-core",
version: "1.2.3",
instance_id: "instance-1",
},
});
});
describe("memories add", () => {
it("adds one memory directly and prints the receipt with its request id", async () => {
transport.post.mockResolvedValue({ status: 201, data: addReceipt() });
const result = await capturedRun([
"memories",
"add",
"Prefers tea",
"--json",
]);
expect(result.code).toBe(0);
const [url, body] = transport.post.mock.calls[0];
expect(url).toBe("/api/v2/memories");
expect(body).toMatchObject({ text: "Prefers tea", scope: "all-apps" });
expect(body.request_id).toMatch(/^[0-9a-f-]{36}$/);
expect(JSON.parse(result.stdout)).toMatchObject({
schema_version: 2,
command: "memories add",
state: "accepted",
request_id: body.request_id,
receipt: { evidence_id: EVIDENCE, state: "pending" },
});
});
it("reuses --request-id so a retry is idempotent, and says so in text", async () => {
transport.post.mockResolvedValue({ status: 201, data: addReceipt() });
const result = await capturedRun([
"memories",
"add",
"Prefers tea",
"--request-id",
REQUEST,
]);
expect(result.code).toBe(0);
expect(transport.post.mock.calls[0][1].request_id).toBe(REQUEST);
expect(result.stdout).toContain(`request_id: ${REQUEST}`);
expect(result.stdout).toContain(`memhub memories add-status ${REQUEST}`);
});
it("reads the text from a bounded file", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "om-memory-add-"));
const file = path.join(directory, "memory.txt");
await writeFile(file, "Prefers tea\n", { mode: 0o600 });
transport.post.mockResolvedValue({ status: 201, data: addReceipt() });
const result = await capturedRun([
"memories",
"add",
"--content-file",
file,
"--json",
]);
expect(result.code).toBe(0);
expect(transport.post.mock.calls[0][1].text).toBe("Prefers tea");
});
it("refuses bad input before contacting Core", async () => {
for (const argv of [
["memories", "add"],
["memories", "add", "a", "--content-file", "/tmp/x"],
["memories", "add", "a", "--request-id", "not-a-uuid"],
["memories", "add", " "],
["memories", "add", "x".repeat(12_001)],
]) {
expect((await capturedRun(argv)).code).toBe(2);
}
expect(transport.post).not.toHaveBeenCalled();
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
});
it("reports a Core conflict as rejected, on stdout, exit 1", async () => {
transport.post.mockResolvedValue({
status: 409,
data: { detail: "memory_add_conflict" },
});
const result = await capturedRun([
"memories",
"add",
"Other text",
"--request-id",
REQUEST,
"--json",
]);
expect(result.code).toBe(1);
expect(JSON.parse(result.stdout)).toMatchObject({
state: "rejected",
code: "memory_add_conflict",
request_id: REQUEST,
});
});
it("never reports a malformed success body as accepted", async () => {
transport.post.mockResolvedValue({
status: 201,
data: { evidence_id: "x" },
});
const result = await capturedRun(["memories", "add", "tea", "--json"]);
expect(result.code).toBe(1);
expect(JSON.parse(result.stdout).state).toBe("unknown");
});
it("answers a stopped Runtime with exit 3 and writes nothing", async () => {
runtime.inspectRuntimeStatus.mockResolvedValue({
state: "stopped",
code: "runtime_not_running",
});
const result = await capturedRun(["memories", "add", "tea", "--json"]);
expect(result.code).toBe(3);
expect(JSON.parse(result.stdout).code).toBe("runtime_not_running");
expect(transport.post).not.toHaveBeenCalled();
});
});
describe("memories add-status", () => {
it("reads the addition by request id", async () => {
transport.get.mockResolvedValue({
status: 200,
data: addReceipt("ready", ["m-1"]),
});
const result = await capturedRun([
"memories",
"add-status",
REQUEST,
"--json",
]);
expect(result.code).toBe(0);
expect(transport.get).toHaveBeenCalledWith(
`/api/v2/memories/additions/${REQUEST}`,
undefined,
);
expect(JSON.parse(result.stdout)).toMatchObject({
command: "memories add-status",
state: "accepted",
receipt: { state: "ready", memory_ids: ["m-1"] },
});
});
it("reports an unknown request id as not_found, exit 1", async () => {
transport.get.mockResolvedValue({
status: 404,
data: { detail: "memory_add_not_found" },
});
const result = await capturedRun(["memories", "status", REQUEST]);
expect(result.code).toBe(1);
expect(result.stdout).toContain("未找到该 request id");
});
it("refuses a non-UUID request id", async () => {
expect((await capturedRun(["memories", "add-status", "abc"])).code).toBe(2);
expect(transport.get).not.toHaveBeenCalled();
});
});
describe("memories update / delete pass the namespace", () => {
it("updates an experience with kind, variant and task, directly", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "om-memory-update-"));
const file = path.join(directory, "solution.txt");
await writeFile(file, "run make deploy\n", { mode: 0o600 });
transport.put.mockResolvedValue({ status: 200, data: memoryRecord() });
const result = await capturedRun([
"memories",
"update",
"exp-1",
"--content-file",
file,
"--kind",
"experience",
"--variant",
"working",
"--task",
"deploy",
"--json",
]);
expect(result.code).toBe(0);
expect(transport.put).toHaveBeenCalledWith(
"/api/v2/memories/exp-1",
{
content: "run make deploy",
kind: "experience",
variant: "working",
task: "deploy",
},
undefined,
);
expect(JSON.parse(result.stdout)).toMatchObject({
schema_version: 2,
state: "committed",
operation: "update",
});
});
it("refuses --task on anything but an experience, and a lone --variant", async () => {
for (const argv of [
["memories", "update", "m-1", "--content-file", "/tmp/x", "--task", "t"],
[
"memories",
"update",
"m-1",
"--content-file",
"/tmp/x",
"--kind",
"fact",
"--variant",
"coding",
"--task",
"t",
],
["memories", "delete", "m-1", "--variant", "coding"],
["memories", "delete", "m-1", "--kind", "chat", "--variant", "coding"],
["memories", "show", "m-1", "--kind", "nope"],
]) {
expect((await capturedRun(argv)).code).toBe(2);
}
expect(transport.put).not.toHaveBeenCalled();
expect(transport.delete).not.toHaveBeenCalled();
});
it("previews a delete without --yes and changes nothing", async () => {
const result = await capturedRun([
"memories",
"delete",
"fact-1",
"--kind",
"fact",
"--variant",
"coding",
"--json",
]);
expect(result.code).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
schema_version: 2,
command: "memories delete",
state: "preview",
target: "fact-1",
applied: false,
});
expect(JSON.parse(result.stdout).next_step).toContain(
"--kind fact --variant coding --yes",
);
expect(transport.delete).not.toHaveBeenCalled();
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
});
it("deletes with --yes (or the legacy --confirm) and names the namespace", async () => {
transport.delete.mockResolvedValue({ status: 204, data: "" });
for (const flag of ["--yes", "--confirm"]) {
const result = await capturedRun([
"memories",
"delete",
"fact-1",
"--kind",
"fact",
"--variant",
"coding",
flag,
"--json",
]);
expect(result.code).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
schema_version: 2,
state: "committed",
operation: "delete",
});
}
expect(transport.delete).toHaveBeenCalledWith(
"/api/v2/memories/fact-1?kind=fact&variant=coding",
undefined,
);
});
it("explains an ambiguous id instead of a bare code", async () => {
transport.delete.mockResolvedValue({
status: 409,
data: { detail: "memory_id_ambiguous" },
});
const result = await capturedRun(["memories", "delete", "dup", "--yes"]);
expect(result.code).toBe(1);
expect(result.stdout).toContain("--kind");
});
it("shows a memory in its namespace", async () => {
transport.get.mockResolvedValue({ status: 200, data: memoryRecord() });
const result = await capturedRun([
"memories",
"show",
"exp-1",
"--kind",
"experience",
"--variant",
"working",
"--json",
]);
expect(result.code).toBe(0);
expect(transport.get).toHaveBeenCalledWith("/api/v2/memories/exp-1", {
params: { kind: "experience", variant: "working" },
});
const document = JSON.parse(result.stdout);
expect(document.schema_version).toBe(2);
expect(document).not.toHaveProperty("evidence");
expect(document).not.toHaveProperty("history");
expect(document.memory).toMatchObject({
kind: "experience",
variant: "working",
task: "deploy",
});
});
});
describe("memories list filters", () => {
const page = {
memories: [memoryRecord()],
total: 1,
limit: 100,
offset: 0,
search_window: 200,
window_id: "w1",
window_reset: false,
};
it("sends --scene, --source and --query to the library", async () => {
transport.get.mockResolvedValue({ status: 200, data: page });
const result = await capturedRun([
"memories",
"list",
"--scene",
"working",
"--source",
"codebuddy",
"--query",
"deploy",
"--json",
]);
expect(result.code).toBe(0);
expect(transport.get).toHaveBeenCalledWith("/api/v2/memory-library", {
params: {
offset: 0,
limit: 100,
scene: "working",
source: "codebuddy",
query: "deploy",
},
});
const document = JSON.parse(result.stdout);
expect(document.schema_version).toBe(2);
expect(document.page).toMatchObject({
state: "ok",
scene: "working",
query: "deploy",
search_window: 200,
});
expect(document.memories[0]).toMatchObject({
kind: "experience",
variant: "working",
});
});
it("omits scene for the default all", async () => {
transport.get.mockResolvedValue({ status: 200, data: page });
await capturedRun(["memories", "list", "--scene", "all", "--json"]);
expect(transport.get.mock.calls[0][1].params).not.toHaveProperty("scene");
});
it("validates --status, --scene and --query locally instead of reporting a busy backend", async () => {
for (const argv of [
["memories", "list", "--status", "bogus"],
["memories", "list", "--scene", "normal"],
["memories", "list", "--query", "x", "--status", "archived"],
["memories", "list", "--source", "Bad Source"],
]) {
expect((await capturedRun(argv)).code).toBe(2);
}
expect(transport.get).not.toHaveBeenCalled();
});
it("reports a Core 422 as rejected, not as 后台正忙", async () => {
transport.get.mockResolvedValue({
status: 422,
data: { detail: "sdk_search_supports_active_status_only" },
});
const result = await capturedRun(["memories", "list"]);
expect(result.code).toBe(1);
expect(result.stdout).not.toContain("后台正忙");
expect(result.stdout).toContain("sdk_search_supports_active_status_only");
});
});
describe("graph-width", () => {
it("runs a non-destructive decision directly", async () => {
transport.post.mockResolvedValue({
status: 200,
data: { archived_path: null, exported_path: "/tmp/x", mismatch: null },
});
const result = await capturedRun([
"graph-width",
"--decision",
"export_first",
"--json",
]);
expect(result.code).toBe(0);
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/graph-width",
{ decision: "export_first" },
undefined,
);
expect(JSON.parse(result.stdout).state).toBe("committed");
});
it("previews rebuild_with_new without --yes and applies it with --yes", async () => {
const preview = await capturedRun([
"graph-width",
"--decision",
"rebuild_with_new",
"--json",
]);
expect(preview.code).toBe(0);
expect(JSON.parse(preview.stdout)).toMatchObject({
state: "preview",
applied: false,
});
expect(transport.post).not.toHaveBeenCalled();
transport.post.mockResolvedValue({
status: 200,
data: { archived_path: "/tmp/old", exported_path: null, mismatch: null },
});
const applied = await capturedRun([
"graph",
"width",
"--decision",
"rebuild_with_new",
"--yes",
"--json",
]);
expect(applied.code).toBe(0);
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/graph-width",
{ decision: "rebuild_with_new" },
undefined,
);
});
});
describe("removed commands", () => {
it.each([
["notes", "add", "--content-file", "/tmp/x"],
["notes", "process", REQUEST, "--confirm"],
["calls", "clear", "--all", "--confirm"],
["processing", "extract", "--evidence", REQUEST, "--confirm"],
["extraction", "set", "--window", "6m", "--confirm"],
["import-file", "upload", "--file", "/tmp/x", "--confirm"],
])("%s is an unknown command that writes nothing", async (...argv) => {
const result = await capturedRun(argv as string[]);
expect(result.code).toBe(2);
expect(result.stderr).toContain("未知命令");
expect(transport.post).not.toHaveBeenCalled();
});
});
async function capturedRun(arguments_: string[]) {
let stdout = "";
let stderr = "";
const stdoutSpy = vi
.spyOn(process.stdout, "write")
.mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation((chunk) => {
stderr += String(chunk);
return true;
});
try {
return {
code: await run(arguments_, {
ONE_MEMORY_DATA_DIR: "/synthetic/data",
}),
stdout,
stderr,
};
} finally {
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
}
}
+236 -19
View File
@@ -1,5 +1,17 @@
import type { SdkMemoryPage, SdkMemoryRecord } from "@one-memory/contracts";
import { randomUUID } from "node:crypto";
import type {
MemoryAddReceipt,
SdkMemoryPage,
SdkMemoryRecord,
} from "@one-memory/contracts";
import {
buildMemoryAddRequest,
MemoryAddInputError,
memoryAddStatusPath,
parseMemoryAddReceipt,
} from "@one-memory/operations";
import { type CoreReader, RESERVED_MEMORY_SEGMENTS } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import { PRODUCT } from "./version.js";
/** Output budget: a long memory is shortened visibly, never dropped silently. */
@@ -38,27 +50,52 @@ export function parseMemoryContent(bytes: Buffer): string | null {
: null;
}
export const MEMORY_SCENES = ["all", "chat", "working", "coding"] as const;
export type MemoryScene = (typeof MEMORY_SCENES)[number];
export const MEMORY_KINDS = ["chat", "experience", "fact"] as const;
export type MemoryKind = (typeof MEMORY_KINDS)[number];
export const MEMORY_VARIANTS = ["working", "coding"] as const;
export type MemoryVariant = (typeof MEMORY_VARIANTS)[number];
/** Which SDK namespace a memory lives in. A chat memory needs neither; a
* working/coding experience or fact needs both so Core does not have to guess
* (an id present in two namespaces is refused as ambiguous). */
export interface MemoryNamespace {
kind?: MemoryKind;
variant?: MemoryVariant;
}
export function namespaceParameters(
namespace: MemoryNamespace,
): Record<string, string> {
return {
...(namespace.kind === undefined ? {} : { kind: namespace.kind }),
...(namespace.variant === undefined ? {} : { variant: namespace.variant }),
};
}
export interface MemoryQuery {
query?: string;
status?: string;
source?: string;
scene?: MemoryScene;
offset: number;
limit: number;
}
/** Every `memories` document is schema 2: list/show share it with
* add/update/delete, and the v1-era `evidence`/`history` fields are gone. */
export interface MemoryPageDocument {
schema_version: 1;
schema_version: 2;
product: string;
page: Record<string, unknown>;
memories: Record<string, unknown>[];
}
export interface MemoryDetailDocument {
schema_version: 1;
schema_version: 2;
product: string;
memory: Record<string, unknown>;
evidence: Record<string, unknown>[];
history: Record<string, unknown>[];
}
export async function readMemoryPage(
@@ -70,10 +107,15 @@ export async function readMemoryPage(
limit: query.limit,
};
if (query.status !== undefined) parameters.statuses = query.status;
if (query.source !== undefined) parameters.source = query.source;
if (query.query !== undefined) parameters.query = query.query;
if (query.scene !== undefined && query.scene !== "all") {
parameters.scene = query.scene;
}
const outcome = await reader<unknown>("/api/v2/memory-library", parameters);
if (outcome.state !== "ok") {
return {
schema_version: 1,
schema_version: 2,
product: PRODUCT,
page: { state: outcome.state, code: outcome.code },
memories: [],
@@ -82,7 +124,7 @@ export async function readMemoryPage(
const page = sdkMemoryPage(outcome.data);
if (page === null) {
return {
schema_version: 1,
schema_version: 2,
product: PRODUCT,
page: { state: "invalid", code: "response_invalid" },
memories: [],
@@ -90,15 +132,21 @@ export async function readMemoryPage(
}
const items = page.memories;
return {
schema_version: 1,
schema_version: 2,
product: PRODUCT,
page: {
state: "ok",
scene: query.scene ?? "all",
...(query.query === undefined ? {} : { query: query.query }),
total: page.total,
offset: page.offset,
limit: page.limit,
returned: items.length,
has_more: page.offset + items.length < page.total,
// A keyword search only looks among this many most relevant memories.
...(typeof page.search_window === "number"
? { search_window: page.search_window }
: {}),
},
memories: items.map((item) => describeMemory(item)),
};
@@ -107,42 +155,195 @@ export async function readMemoryPage(
export async function readMemoryDetail(
reader: CoreReader,
memoryId: string,
namespace: MemoryNamespace = {},
): Promise<MemoryDetailDocument> {
const outcome = await reader<unknown>(`/api/v2/memories/${memoryId}`);
const parameters = namespaceParameters(namespace);
const outcome = await reader<unknown>(
`/api/v2/memories/${memoryId}`,
Object.keys(parameters).length > 0 ? parameters : undefined,
);
if (outcome.state !== "ok") {
return {
schema_version: 1,
schema_version: 2,
product: PRODUCT,
memory: { state: outcome.state, code: outcome.code },
evidence: [],
history: [],
};
}
const memory = sdkMemory(outcome.data);
if (memory === null || memory.memory_id !== memoryId) {
return {
schema_version: 1,
schema_version: 2,
product: PRODUCT,
memory: { state: "invalid", code: "response_invalid" },
evidence: [],
history: [],
};
}
return {
schema_version: 1,
schema_version: 2,
product: PRODUCT,
memory: { state: "ok", ...describeMemory(memory) },
evidence: [],
history: [],
};
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/** The same bound the app's 添加记忆 box applies. */
export const MEMORY_ADD_MAX_CHARACTERS = 12_000;
export interface MemoryAddDocument {
schema_version: 2;
product: string;
command: "memories add" | "memories add-status";
state: "accepted" | "rejected" | "unknown" | "not_found" | "invalid";
request_id: string;
code?: string;
receipt?: Record<string, unknown>;
}
export function isRequestId(value: string): boolean {
return UUID.test(value);
}
/** A memory text: non-blank, bounded, no control characters except tab and
* newline. */
export function validMemoryText(text: string): boolean {
return (
text.trim().length > 0 &&
[...text].length <= MEMORY_ADD_MAX_CHARACTERS &&
![...text].some((character) => {
const code = character.charCodeAt(0);
return code <= 8 || (code >= 11 && code <= 31) || code === 127;
})
);
}
function addReceipt(receipt: MemoryAddReceipt): Record<string, unknown> {
return {
evidence_id: receipt.evidence_id,
state: receipt.state,
created_at: receipt.created_at,
task_id: receipt.task_id,
memory_ids: receipt.memory_ids,
error: receipt.error,
};
}
/**
* Add one memory directly, the same request as the app's 添加记忆.
*
* `requestId` is Core's idempotency key: retrying with the same id and text
* returns the first receipt; the same id with other text is refused.
*/
export async function addMemory(
operations: CoreOperations,
text: string,
requestId: string = randomUUID(),
): Promise<MemoryAddDocument> {
const base = {
schema_version: 2 as const,
product: PRODUCT,
command: "memories add" as const,
request_id: requestId,
};
if (!validMemoryText(text) || !UUID.test(requestId)) {
return { ...base, state: "invalid", code: "memory_add_invalid" };
}
let request: ReturnType<typeof buildMemoryAddRequest>;
try {
request = buildMemoryAddRequest({
request_id: requestId,
text,
scope: "all-apps",
});
} catch (error) {
if (error instanceof MemoryAddInputError) {
return { ...base, state: "invalid", code: "memory_add_invalid" };
}
throw error;
}
const result = await operations.post<unknown>(request.path, request.body);
if (result.state !== "committed") {
return { ...base, state: result.state, code: result.code };
}
const receipt = parseMemoryAddReceipt(result.data);
return receipt === null
? { ...base, state: "unknown", code: "memory_add_receipt_invalid" }
: { ...base, state: "accepted", receipt: addReceipt(receipt) };
}
/** Read how one addition is doing, by the request id it was submitted with. */
export async function readMemoryAddStatus(
reader: CoreReader,
requestId: string,
): Promise<MemoryAddDocument> {
const base = {
schema_version: 2 as const,
product: PRODUCT,
command: "memories add-status" as const,
request_id: requestId,
};
if (!UUID.test(requestId)) {
return { ...base, state: "invalid", code: "invalid_request_id" };
}
const outcome = await reader<unknown>(memoryAddStatusPath(requestId));
if (outcome.state === "not_found") {
return { ...base, state: "not_found", code: "memory_add_not_found" };
}
if (outcome.state !== "ok") {
return {
...base,
state: outcome.state === "rejected" ? "rejected" : "unknown",
code: outcome.code,
};
}
const receipt = parseMemoryAddReceipt(outcome.data);
return receipt === null
? { ...base, state: "unknown", code: "memory_add_receipt_invalid" }
: { ...base, state: "accepted", receipt: addReceipt(receipt) };
}
export function memoryAddExitCode(document: MemoryAddDocument): number {
return document.state === "accepted" ? 0 : 1;
}
export function renderMemoryAddText(document: MemoryAddDocument): string {
if (document.state === "accepted" && document.receipt) {
return [
`request_id: ${document.request_id}`,
`evidence_id: ${document.receipt.evidence_id}`,
`state: ${document.receipt.state}`,
...((document.receipt.memory_ids as string[]).length > 0
? [`memory_ids: ${(document.receipt.memory_ids as string[]).join(",")}`]
: []),
document.command === "memories add"
? `查询进度:memhub memories add-status ${document.request_id};重试请带上同一个 --request-id,不会重复添加。`
: "",
"",
]
.filter((line, index, all) => line !== "" || index === all.length - 1)
.join("\n");
}
if (document.state === "not_found") {
return `未找到该 request id:${document.request_id}\n`;
}
return [
`添加记忆未完成:${document.state}(${document.code})`,
document.state === "unknown"
? `结果未知;可以用同一个 --request-id ${document.request_id} 安全重试。`
: "",
"",
]
.filter((line, index, all) => line !== "" || index === all.length - 1)
.join("\n");
}
export function renderMemoryPageText(document: MemoryPageDocument): string {
const page = document.page;
if (page.state !== "ok") {
if (page.state === "unavailable") {
return "后台正忙,记忆列表这次没查完,不代表没有记忆,请稍后再试\n";
}
if (page.state === "rejected") {
return `记忆查询被拒绝(${page.code}),请检查参数。\n`;
}
return `记忆查询未完成:${page.state}(${page.code})\n`;
}
const lines = [
@@ -150,9 +351,16 @@ export function renderMemoryPageText(document: MemoryPageDocument): string {
page.has_more ? ",还有更多,请用 --offset 继续" : ""
}`,
];
if (typeof page.search_window === "number") {
lines.push(`(搜索只在最相关的 ${page.search_window} 条里匹配关键词)`);
}
for (const memory of document.memories) {
const namespace =
memory.kind === "chat"
? ""
: ` kind=${memory.kind} variant=${memory.variant}`;
lines.push(
`- ${memory.memory_id} [${memory.scene}] ${memory.status}/${memory.layer} source=${memory.source ?? "未知"}`,
`- ${memory.memory_id} [${memory.scene}] ${memory.status}/${memory.layer} source=${memory.source ?? "未知"}${namespace}`,
` ${memory.content}${memory.content_truncated ? ` …(共 ${memory.content_characters} 字)` : ""}`,
);
}
@@ -167,6 +375,9 @@ export function renderMemoryDetailText(document: MemoryDetailDocument): string {
if (memory.state === "unavailable") {
return "后台正忙,记忆详情这次没查完,不代表没有这条记忆,请稍后再试\n";
}
if (memory.code === "memory_id_ambiguous") {
return "这个 ID 同时存在于多个场景;请加 --kind(以及 --variant)指明是哪一条。\n";
}
return `记忆读取未完成:${memory.state}(${memory.code})\n`;
}
const lines = [
@@ -204,6 +415,9 @@ function describeMemory(memory: SdkMemoryRecord): Record<string, unknown> {
tags: memory.tags ?? [],
memory_at: memory.memory_at,
gmt_created: memory.gmt_created,
...(memory.task === undefined || memory.task === null
? {}
: { task: memory.task }),
content: budgeted.text,
content_truncated: budgeted.truncated,
content_characters: budgeted.characters,
@@ -221,6 +435,9 @@ function sdkMemoryPage(value: unknown): SdkMemoryPage | null {
total: value.total,
limit: value.limit,
offset: value.offset,
...(integer(value.search_window)
? { search_window: value.search_window }
: {}),
};
}
+88 -63
View File
@@ -3,9 +3,9 @@ import { describe, expect, it, vi } from "vitest";
import type { CoreReader } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import {
executeModelProbe,
planModelProbe,
modelProbeExitCode,
readModelProbeReceipt,
startModelProbe,
} from "./model-probe-flow.js";
const PROFILE_ID = "22222222-2222-4222-8222-222222222222";
@@ -83,67 +83,101 @@ function completed(operationId: string) {
};
}
describe("model probe operation flow", () => {
it("plans against the exact current profile revision", async () => {
const reader = vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] });
const document = await planModelProbe(reader as CoreReader, PROFILE_ID);
expect(document.state).toBe("preview");
if (document.state !== "preview") throw new Error("expected preview");
expect(document.plan).toMatchObject({
kind: "model_profile_probe",
profile_id: PROFILE_ID,
expected_profile_revision: 3,
});
expect(document.plan.operation_id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(document.plan.digest).toMatch(/^[0-9a-f]{64}$/);
});
const OPERATION_ID = "33333333-3333-4333-8333-333333333333";
it("proves operation support with schema-1 not_found before posting", async () => {
const planned = await planModelProbe(
vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] }) as CoreReader,
PROFILE_ID,
);
if (planned.state !== "preview") throw new Error("expected preview");
const operationId = planned.plan.operation_id;
const reader = vi
.fn()
.mockResolvedValueOnce({ state: "ok", data: notFound(operationId) });
/** Reader that answers the profile list, then the queued operation reads. */
function readerWith(...operationReads: unknown[]) {
const reader = vi.fn(async (path: string) => {
if (path === "/api/v1/model-profiles") {
return { state: "ok", data: [PROFILE] };
}
return operationReads.shift() ?? { state: "not_found", code: "http_404" };
});
return reader;
}
describe("model probe operation flow", () => {
it("probes the exact current profile revision once support is proven", async () => {
const reader = readerWith({ state: "ok", data: notFound(OPERATION_ID) });
const post = vi
.fn()
.mockResolvedValue({ state: "committed", data: completed(operationId) });
.mockResolvedValue({ state: "committed", data: completed(OPERATION_ID) });
const result = await executeModelProbe(
planned.plan,
reader as CoreReader,
const result = await startModelProbe(
reader as unknown as CoreReader,
{ post } as CoreOperations,
PROFILE_ID,
OPERATION_ID,
);
expect(result.state).toBe("completed");
expect(reader).toHaveBeenCalledWith(
`/api/v1/model-probe-operations/${operationId}`,
`/api/v1/model-probe-operations/${OPERATION_ID}`,
);
expect(post).toHaveBeenCalledWith(
`/api/v1/model-profiles/${PROFILE_ID}/probe`,
{ operation_id: operationId, expected_profile_revision: 3 },
{ operation_id: OPERATION_ID, expected_profile_revision: 3 },
);
});
it("does not post when the Core lacks the exact operation contract", async () => {
const planned = await planModelProbe(
vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] }) as CoreReader,
it("generates its own operation id and treats a pending probe as submitted", async () => {
const post = vi.fn(
async (_path: string, body: Record<string, unknown>) => ({
state: "committed",
data: {
...notFound(String(body.operation_id)),
profile_id: PROFILE_ID,
expected_profile_revision: 3,
state: "pending",
stale: false,
},
}),
);
const reader = vi.fn(async (path: string) =>
path === "/api/v1/model-profiles"
? { state: "ok", data: [PROFILE] }
: {
state: "ok",
data: notFound(path.split("/").at(-1) ?? ""),
},
);
const result = await startModelProbe(
reader as unknown as CoreReader,
{ post } as unknown as CoreOperations,
PROFILE_ID,
);
if (planned.state !== "preview") throw new Error("expected preview");
expect(result.state).toBe("pending");
expect(result.operation_id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(modelProbeExitCode(result)).toBe(0);
});
it("refuses an unknown profile without posting", async () => {
const post = vi.fn();
const result = await executeModelProbe(
planned.plan,
vi.fn().mockResolvedValue({
const result = await startModelProbe(
readerWith() as unknown as CoreReader,
{ post } as CoreOperations,
"44444444-4444-4444-8444-444444444444",
OPERATION_ID,
);
expect(result).toMatchObject({
state: "not_found",
code: "model_profile_not_found",
});
expect(post).not.toHaveBeenCalled();
});
it("does not post when the Core lacks the exact operation contract", async () => {
const post = vi.fn();
const result = await startModelProbe(
readerWith({
state: "not_found",
code: "http_404",
}) as CoreReader,
}) as unknown as CoreReader,
{ post } as CoreOperations,
PROFILE_ID,
OPERATION_ID,
);
expect(result).toMatchObject({
state: "disabled",
@@ -153,19 +187,15 @@ describe("model probe operation flow", () => {
});
it("does not post when a legacy Core returns a non-operation body", async () => {
const planned = await planModelProbe(
vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] }) as CoreReader,
PROFILE_ID,
);
if (planned.state !== "preview") throw new Error("expected preview");
const post = vi.fn();
const result = await executeModelProbe(
planned.plan,
vi.fn().mockResolvedValue({
const result = await startModelProbe(
readerWith({
state: "ok",
data: { detail: "legacy_route_shape" },
}) as CoreReader,
}) as unknown as CoreReader,
{ post } as CoreOperations,
PROFILE_ID,
OPERATION_ID,
);
expect(result).toMatchObject({
state: "disabled",
@@ -175,24 +205,19 @@ describe("model probe operation flow", () => {
});
it("reconciles an unknown POST by GET and never treats not_found as replay permission", async () => {
const planned = await planModelProbe(
vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] }) as CoreReader,
PROFILE_ID,
const reader = readerWith(
{ state: "ok", data: notFound(OPERATION_ID) },
{ state: "ok", data: notFound(OPERATION_ID) },
);
if (planned.state !== "preview") throw new Error("expected preview");
const operationId = planned.plan.operation_id;
const reader = vi
.fn()
.mockResolvedValueOnce({ state: "ok", data: notFound(operationId) })
.mockResolvedValueOnce({ state: "ok", data: notFound(operationId) });
const post = vi.fn().mockResolvedValue({
state: "unknown",
code: "transport_result_unknown",
});
const result = await executeModelProbe(
planned.plan,
reader as CoreReader,
const result = await startModelProbe(
reader as unknown as CoreReader,
{ post } as CoreOperations,
PROFILE_ID,
OPERATION_ID,
);
expect(result).toMatchObject({
state: "unknown",
+42 -124
View File
@@ -1,11 +1,10 @@
import { createHash, randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import type { CoreReader, ReadOutcome } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import { validateModelProfiles } from "./core-responses.js";
import { PRODUCT } from "./version.js";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const DIGEST = /^[0-9a-f]{64}$/;
const RECEIPT_KEYS = new Set([
"schema_version",
"operation_id",
@@ -59,23 +58,7 @@ const CODES = new Set([
"embedding_unavailable",
]);
interface ModelProbePlanBody {
schema_version: 1;
kind: "model_profile_probe";
operation_id: string;
profile_id: string;
expected_profile_revision: number;
}
export type ModelProbePlan = ModelProbePlanBody & { digest: string };
export type ModelProbeFlowDocument =
| {
schema_version: 1;
product: string;
state: "preview";
plan: ModelProbePlan;
}
| {
schema_version: 1;
product: string;
@@ -91,11 +74,25 @@ export type ModelProbeFlowDocument =
code: string;
};
export async function planModelProbe(
/**
* Start one capability probe of a model profile (one real model request).
*
* Reads the profile's current revision so Core can refuse a probe of a
* profile that changed meanwhile, checks that this Core tracks probe
* operations (an old Core would start an untracked request), then submits it.
* The probe runs asynchronously: the answer is usually `pending`, and
* `models receipt <operation-id>` reads the result.
*/
export async function startModelProbe(
reader: CoreReader,
operations: CoreOperations,
profileId: string,
operationId: string = randomUUID(),
): Promise<ModelProbeFlowDocument> {
if (!UUID.test(profileId)) return failure("invalid", "invalid_profile_id");
if (!UUID.test(operationId)) {
return failure("invalid", "invalid_probe_operation_id");
}
const outcome = await reader<unknown>("/api/v1/model-profiles");
if (outcome.state !== "ok") return fromReadFailure(outcome);
const profiles = validateModelProfiles(outcome.data);
@@ -105,71 +102,46 @@ export async function planModelProbe(
);
if (matches.length !== 1)
return failure("not_found", "model_profile_not_found");
const profile = matches[0];
if (!Number.isSafeInteger(profile.revision) || profile.revision < 1) {
const revision = matches[0].revision;
if (!Number.isSafeInteger(revision) || revision < 1) {
return failure("invalid", "model_profile_revision_invalid");
}
const plan = withDigest({
schema_version: 1 as const,
kind: "model_profile_probe" as const,
operation_id: randomUUID(),
profile_id: profileId,
expected_profile_revision: profile.revision,
});
return { schema_version: 1, product: PRODUCT, state: "preview", plan };
}
export async function executeModelProbe(
value: unknown,
reader: CoreReader,
operations: CoreOperations,
): Promise<ModelProbeFlowDocument> {
const plan = validatePlan(value);
if (!plan) return failure("invalid", "model_probe_plan_invalid");
// A 404 or a legacy/malformed body is not support. Some old Core versions
// ignore the operation body and start an untracked external model request.
const support = await reader<unknown>(operationPath(plan.operation_id));
const support = await reader<unknown>(operationPath(operationId));
if (support.state !== "ok") {
return failure(
"disabled",
"model_probe_operations_unsupported",
plan.operation_id,
operationId,
);
}
const supportReceipt = validateReceipt(support.data, plan.operation_id);
const supportReceipt = validateReceipt(support.data, operationId);
if (supportReceipt?.state !== "not_found") {
return failure(
"disabled",
"model_probe_operations_unsupported",
plan.operation_id,
operationId,
);
}
const outcome = await operations.post<unknown>(
`/api/v1/model-profiles/${plan.profile_id}/probe`,
{
operation_id: plan.operation_id,
expected_profile_revision: plan.expected_profile_revision,
},
const posted = await operations.post<unknown>(
`/api/v1/model-profiles/${profileId}/probe`,
{ operation_id: operationId, expected_profile_revision: revision },
);
if (outcome.state === "rejected") {
return failure("rejected", outcome.code, plan.operation_id);
if (posted.state === "rejected") {
return failure("rejected", posted.code, operationId);
}
if (outcome.state === "committed") {
return receiptDocument(
outcome.data,
plan.operation_id,
plan.profile_id,
plan.expected_profile_revision,
);
if (posted.state === "committed") {
return receiptDocument(posted.data, operationId, profileId, revision);
}
const reconciled = await readModelProbeReceipt(reader, plan.operation_id);
const reconciled = await readModelProbeReceipt(reader, operationId);
return reconciled.state === "not_found"
? failure(
"unknown",
"model_probe_receipt_not_found_after_attempt",
plan.operation_id,
operationId,
)
: reconciled;
}
@@ -190,32 +162,24 @@ export async function readModelProbeReceipt(
return receiptDocument(outcome.data, operationId);
}
export function inspectModelProbePlan(value: unknown): ModelProbePlan | null {
return validatePlan(value);
}
export function modelProbeExitCode(document: ModelProbeFlowDocument): number {
return ["preview", "completed"].includes(document.state) ? 0 : 1;
// `pending` is a submitted probe still running: the submit succeeded.
return ["completed", "pending"].includes(document.state) ? 0 : 1;
}
export function renderModelProbeText(document: ModelProbeFlowDocument): string {
if (document.state === "preview") {
return [
"预览:模型能力探测",
`模型配置:${document.plan.profile_id}`,
`预期版本:${document.plan.expected_profile_revision}`,
`操作 ID:${document.plan.operation_id}`,
`确认摘要:${document.plan.digest}`,
"未发起模型请求。请保存 --json 输出,再用 models probe --plan-file <path> --confirm。",
"",
].join("\n");
}
return [
const lines = [
`探测状态:${document.state}`,
`操作 ID:${document.operation_id ?? "无"}`,
"code" in document ? `代码:${document.code}` : "已返回有界操作回执。",
"",
].join("\n");
];
if (document.state === "pending" && document.operation_id) {
lines.push(
`探测在后台进行;用 memhub models receipt ${document.operation_id} 查看结果。`,
);
}
lines.push("");
return lines.join("\n");
}
function receiptDocument(
@@ -249,31 +213,6 @@ function receiptDocument(
};
}
function validatePlan(value: unknown): ModelProbePlan | null {
const plan = record(value);
if (
plan?.schema_version !== 1 ||
plan.kind !== "model_profile_probe" ||
typeof plan.operation_id !== "string" ||
!UUID.test(plan.operation_id) ||
typeof plan.profile_id !== "string" ||
!UUID.test(plan.profile_id) ||
!positiveInteger(plan.expected_profile_revision) ||
typeof plan.digest !== "string" ||
!DIGEST.test(plan.digest)
) {
return null;
}
const expected = withDigest({
schema_version: 1 as const,
kind: "model_profile_probe" as const,
operation_id: plan.operation_id,
profile_id: plan.profile_id,
expected_profile_revision: plan.expected_profile_revision,
});
return expected.digest === plan.digest ? expected : null;
}
function validateReceipt(
value: unknown,
operationId: string,
@@ -409,27 +348,6 @@ function failure(
};
}
function withDigest<T extends object>(body: T): T & { digest: string } {
return {
...body,
digest: createHash("sha256").update(canonical(body)).digest("hex"),
};
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map(
(key) =>
`${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key])}`,
)
.join(",")}}`;
}
return JSON.stringify(value);
}
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
+2 -2
View File
@@ -41,7 +41,7 @@ export type ModelReportDocument =
| {
schema_version: 1;
product: string;
state: "denied" | "unavailable" | "not_found" | "invalid";
state: "denied" | "unavailable" | "not_found" | "invalid" | "rejected";
code: string;
};
@@ -136,7 +136,7 @@ function project(profile: ModelProfile): SafeModelProfile {
}
function failure(
state: "denied" | "unavailable" | "not_found" | "invalid",
state: "denied" | "unavailable" | "not_found" | "invalid" | "rejected",
code: string,
): ModelReportDocument {
return { schema_version: 1, product: PRODUCT, state, code };
+15 -42
View File
@@ -126,29 +126,12 @@ describe("models activate", () => {
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
});
it("previews without contacting Core", async () => {
const result = await capturedRun(["models", "activate", PROFILE, "--json"]);
expect(result.code).toBe(0);
const document = JSON.parse(result.stdout);
expect(document.state).toBe("preview");
expect(document.plan.kind).toBe("model_profile_activate");
// Activation has no CAS body, so the plan must not pretend to carry one.
expect(document.plan).not.toHaveProperty("expected_revision");
expect(transport.post).not.toHaveBeenCalled();
});
it("posts the activate endpoint with --confirm and reports the profile", async () => {
it("posts the activate endpoint directly and reports the profile", async () => {
transport.post.mockResolvedValue({
status: 200,
data: profileResponse(),
});
const result = await capturedRun([
"models",
"activate",
PROFILE,
"--confirm",
"--json",
]);
const result = await capturedRun(["models", "activate", PROFILE, "--json"]);
expect(result.code).toBe(0);
expect(transport.post).toHaveBeenCalledWith(
`/api/v1/model-profiles/${PROFILE}/activate`,
@@ -196,31 +179,22 @@ describe("models activate", () => {
});
});
describe("models edit", () => {
it("previews a patch from a config file without writing", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "om-model-edit-"));
const config = path.join(directory, "patch.json");
await writeFile(
config,
JSON.stringify({ expected_revision: 4, name: "Renamed" }),
{ mode: 0o600 },
);
const result = await capturedRun([
"models",
"edit",
PROFILE,
"--config-file",
config,
"--json",
]);
describe("models remove", () => {
it("previews without --yes and changes nothing", async () => {
const result = await capturedRun(["models", "remove", PROFILE, "--json"]);
expect(result.code).toBe(0);
const document = JSON.parse(result.stdout);
expect(document.state).toBe("preview");
expect(document.plan.patch).toEqual({ name: "Renamed" });
expect(transport.patch).not.toHaveBeenCalled();
expect(JSON.parse(result.stdout)).toMatchObject({
command: "models remove",
state: "preview",
target: PROFILE,
applied: false,
});
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
});
});
it("sends the exact patch body with the CAS revision on confirm", async () => {
describe("models edit", () => {
it("sends the exact patch body with the CAS revision directly", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "om-model-edit-"));
const config = path.join(directory, "patch.json");
await writeFile(
@@ -242,7 +216,6 @@ describe("models edit", () => {
PROFILE,
"--config-file",
config,
"--confirm",
"--json",
]);
expect(result.code).toBe(0);
+34 -190
View File
@@ -3,14 +3,12 @@
* activate (`POST /model-profiles/{id}/activate`) and edit
* (`PATCH /model-profiles/{id}`).
*
* Core owns the semantics. These flows reuse the same two-phase convention as
* `models configure`: plan without `--confirm` (or produce a plan file), then
* apply exactly that plan. The edit body mirrors the Web console's
* `ModelProfilePatch`, including `expected_revision` as the compare-and-swap
* guard Core requires.
* Core owns the semantics. Both run directly: activation is reversible (activate
* another profile) and an edit is guarded by `expected_revision`, the
* compare-and-swap Core requires, mirroring the Web console's
* `ModelProfilePatch`.
*/
import { createHash, randomUUID } from "node:crypto";
import type { ModelProfile } from "@one-memory/contracts";
import {
buildModelProfileUpdateRequest,
@@ -22,29 +20,7 @@ import { PRODUCT } from "./version.js";
const PROFILE_ID = /^[A-Za-z0-9][\w.-]{0,127}$/;
export type ModelWritePlan =
| {
schema_version: 1;
kind: "model_profile_activate";
profile_id: string;
digest: string;
}
| {
schema_version: 1;
kind: "model_profile_edit";
profile_id: string;
expected_revision: number;
patch: Record<string, unknown>;
digest: string;
};
export type ModelWriteDocument =
| {
schema_version: 1;
product: string;
state: "preview";
plan: ModelWritePlan;
}
| {
schema_version: 1;
product: string;
@@ -60,38 +36,6 @@ export type ModelWriteDocument =
code: string;
};
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value !== null && typeof value === "object") {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`)
.join(",")}}`;
}
if (value === undefined) return "null";
return JSON.stringify(value);
}
function digestOf(value: unknown): string {
return createHash("sha256").update(canonical(value)).digest("hex");
}
function preview(
plan:
| Omit<
Extract<ModelWritePlan, { kind: "model_profile_activate" }>,
"digest"
>
| Omit<Extract<ModelWritePlan, { kind: "model_profile_edit" }>, "digest">,
): ModelWriteDocument {
return {
schema_version: 1,
product: PRODUCT,
state: "preview",
plan: { ...plan, digest: digestOf(plan) } as ModelWritePlan,
};
}
function invalid(code: string): ModelWriteDocument {
return { schema_version: 1, product: PRODUCT, state: "invalid", code };
}
@@ -122,87 +66,45 @@ function projectProfile(profile: ModelProfile): Record<string, unknown> {
};
}
// --- activate -------------------------------------------------------------
/**
* Activation has no request body, so the plan carries only the target. There
* is deliberately no `expected_revision`: Core would never read one, and
* recording a check it does not perform would be a fake guard.
*/
export function planModelProfileActivate(input: {
profileId: string;
}): ModelWriteDocument {
return PROFILE_ID.test(input.profileId)
? preview({
schema_version: 1,
kind: "model_profile_activate",
profile_id: input.profileId,
})
: invalid("model_profile_activate_input_invalid");
}
export async function executeModelProfileActivate(
plan: Extract<ModelWritePlan, { kind: "model_profile_activate" }>,
/** Make `profileId` the active profile. Activation has no request body, so
* there is no revision for Core to check. */
export async function activateModelProfile(
profileId: string,
operations: CoreOperations,
): Promise<ModelWriteDocument> {
if (!PROFILE_ID.test(profileId)) {
return invalid("model_profile_activate_input_invalid");
}
const result = await operations.post<unknown>(
`/api/v1/model-profiles/${encodeURIComponent(plan.profile_id)}/activate`,
`/api/v1/model-profiles/${encodeURIComponent(profileId)}/activate`,
{},
);
return modelProfileResult(plan.profile_id, result);
return modelProfileResult(profileId, result);
}
// --- edit -----------------------------------------------------------------
export function planModelProfileEdit(input: {
profileId: string;
patch: unknown;
}): ModelWriteDocument {
// The shared builder owns patch validation (14-key allow-list,
// expected_revision CAS, field bounds, api_key/clear_api_key exclusivity).
// A rejected patch is `invalid`, never a plan the caller could confirm.
let request: ReturnType<typeof buildModelProfileUpdateRequest>;
try {
request = buildModelProfileUpdateRequest(input.profileId, input.patch);
} catch (error) {
if (error instanceof ModelProfileInputError) {
return invalid("model_profile_edit_input_invalid");
}
throw error;
}
const patch = request.body as Record<string, unknown>;
if (!PROFILE_ID.test(input.profileId)) {
/** Apply a bounded patch (carrying `expected_revision`) to one profile; the
* key, when given, comes from a controlled file and never from the patch. */
export async function editModelProfile(
profileId: string,
patch: unknown,
apiKey: string | undefined,
operations: CorePatchOperations,
): Promise<ModelWriteDocument> {
if (
!PROFILE_ID.test(profileId) ||
patch === null ||
typeof patch !== "object" ||
Array.isArray(patch) ||
"api_key" in patch
) {
return invalid("model_profile_edit_input_invalid");
}
return preview({
schema_version: 1,
kind: "model_profile_edit",
profile_id: input.profileId,
expected_revision: patch.expected_revision as number,
// `api_key` is read from a controlled file at apply time, exactly like
// `models configure`; the plan never carries the credential. The revision
// is a top-level field, so it is stripped from the nested patch rather
// than being duplicated in both places.
patch: Object.fromEntries(
Object.entries(patch).filter(
([key]) => key !== "api_key" && key !== "expected_revision",
),
),
});
}
export async function executeModelProfileEdit(
plan: Extract<ModelWritePlan, { kind: "model_profile_edit" }>,
operations: CorePatchOperations,
apiKey: string | undefined,
): Promise<ModelWriteDocument> {
// Re-validate through the shared builder at apply time too, so a hand-edited
// plan cannot smuggle an out-of-bounds patch past the write.
// The shared builder owns patch validation (allow-list, expected_revision
// CAS, field bounds, api_key/clear_api_key exclusivity).
let request: ReturnType<typeof buildModelProfileUpdateRequest>;
try {
request = buildModelProfileUpdateRequest(plan.profile_id, {
...plan.patch,
expected_revision: plan.expected_revision,
request = buildModelProfileUpdateRequest(profileId, {
...(patch as Record<string, unknown>),
...(apiKey === undefined ? {} : { api_key: apiKey }),
});
} catch (error) {
@@ -212,7 +114,7 @@ export async function executeModelProfileEdit(
throw error;
}
const result = await operations.patch<unknown>(request.path, request.body);
return modelProfileResult(plan.profile_id, result);
return modelProfileResult(profileId, result);
}
function modelProfileResult(
@@ -254,69 +156,11 @@ function modelProfileResult(
return committed(operationId, profile);
}
export function validateModelWritePlan(value: unknown): ModelWritePlan | null {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const candidate = value as Record<string, unknown>;
if (candidate.schema_version !== 1 || typeof candidate.kind !== "string") {
return null;
}
const { digest, ...withoutDigest } = candidate;
if (typeof digest !== "string" || digest !== digestOf(withoutDigest)) {
return null;
}
switch (candidate.kind) {
case "model_profile_activate":
return typeof candidate.profile_id === "string" &&
PROFILE_ID.test(candidate.profile_id) &&
Object.keys(candidate).length === 3
? (candidate as unknown as ModelWritePlan)
: null;
case "model_profile_edit": {
if (
typeof candidate.profile_id !== "string" ||
!PROFILE_ID.test(candidate.profile_id) ||
!Number.isSafeInteger(candidate.expected_revision)
) {
return null;
}
// The shared builder is the single judge of a patch body; the plan must
// not carry `api_key` (that is read from a controlled file at apply).
let built: ReturnType<typeof buildModelProfileUpdateRequest>;
try {
built = buildModelProfileUpdateRequest(candidate.profile_id, {
...(candidate.patch as Record<string, unknown>),
expected_revision: candidate.expected_revision,
});
} catch {
return null;
}
if ("api_key" in built.body) return null;
return Object.keys(candidate).length === 6
? (candidate as unknown as ModelWritePlan)
: null;
}
default:
return null;
}
}
export function modelWriteExitCode(document: ModelWriteDocument): number {
return ["preview", "committed"].includes(document.state) ? 0 : 1;
return document.state === "committed" ? 0 : 1;
}
export function renderModelWriteText(document: ModelWriteDocument): string {
if (document.state === "preview") {
const plan = document.plan;
return [
`预览:${plan.kind}`,
`目标配置:${plan.profile_id}`,
`确认摘要:${plan.digest}`,
"未执行任何写操作。请把 --json 输出保存为 plan 文件,再用对应命令加 --confirm。",
"",
].join("\n");
}
if (document.state === "committed") {
return `已保存模型配置:${document.receipt.profile_id}\nrevision=${document.receipt.revision}\n`;
}
-57
View File
@@ -1,57 +0,0 @@
import { constants } from "node:fs";
import { type FileHandle, open } from "node:fs/promises";
import { readBoundedJson } from "./bounded-file.js";
export type PlanAttempt = {
schema_version: 1;
operation_id: string;
plan_digest: string;
};
/**
* Durable client fence written before a mutating request. Once present, the
* same plan may only query its operation receipt; it is never blindly posted
* again after an unknown result.
*/
export async function claimPlanAttempt(
planFile: string,
attempt: PlanAttempt,
): Promise<"first" | "prior" | "invalid"> {
const journal = `${planFile}.one-memory-attempt`;
let handle: FileHandle;
try {
handle = await open(
journal,
constants.O_WRONLY |
constants.O_CREAT |
constants.O_EXCL |
(constants.O_NOFOLLOW ?? 0),
0o600,
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return "invalid";
const current = await readBoundedJson(journal, 1024);
return sameAttempt(current, attempt) ? "prior" : "invalid";
}
try {
await handle.writeFile(`${JSON.stringify(attempt)}\n`, "utf8");
await handle.sync();
return "first";
} catch {
return "invalid";
} finally {
await handle.close().catch(() => undefined);
}
}
function sameAttempt(value: unknown, expected: PlanAttempt): boolean {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
(value as Record<string, unknown>).schema_version === 1 &&
(value as Record<string, unknown>).operation_id === expected.operation_id &&
(value as Record<string, unknown>).plan_digest === expected.plan_digest &&
Object.keys(value).length === 3
);
}
+6 -10
View File
@@ -104,7 +104,7 @@ describe("package matrix harness", () => {
});
});
it("fails closed on every destructive UI-write command without a Runtime", {
it("fails closed on every write command without a Runtime", {
timeout: 120_000,
}, async () => {
const runtimeDist = await mkdtemp(
@@ -140,7 +140,7 @@ describe("package matrix harness", () => {
expect(document.state).toBe("verified");
const results = document.commands.stopped_destructive.results;
// Every destructive command fails closed while Runtime is stopped.
expect(Object.keys(results)).toHaveLength(7);
expect(Object.keys(results)).toHaveLength(8);
for (const [name, result] of Object.entries(results)) {
expect(result, `${name} did not fail closed`).toEqual({
exit: 3,
@@ -164,7 +164,7 @@ describe("package matrix harness", () => {
const shim = [
"#!/usr/bin/env node",
"const a=process.argv.slice(2);",
"if(['calls','notes','graph','models','memories'].includes(a[0])){",
"if(['graph-width','models','memories','sources'].includes(a[0])){",
' process.stdout.write(\'{"schema_version":1,"product":"memhub","state":"committed","operation_id":"x","receipt":{}}\\n\');',
" process.exit(0);",
"}",
@@ -190,7 +190,7 @@ describe("package matrix harness", () => {
expect(failure?.state).toBe("failed");
expect(
failure?.failures.filter((item) => item.includes("did not fail closed")),
).toHaveLength(7);
).toHaveLength(8);
});
it("can require an expected bundle SHA and reports bundle metadata", {
@@ -291,9 +291,7 @@ describe("package matrix harness", () => {
"sync",
"memories",
"profile",
"calls",
"notes",
"graph",
"graph-width",
"models",
"host",
"doctor",
@@ -311,14 +309,12 @@ describe("package matrix harness", () => {
"uninstall",
"delete-data",
"recall",
"setup",
"scan",
"progress",
"jobs",
"path",
"scan-roots",
"adapter",
"write",
"task",
"serve",
];
const catalog = {
@@ -145,13 +145,15 @@ describe("memhub short-command contract", () => {
[command.usage, command.alternative_usage ?? ""].join("\n"),
).join("\n");
for (const shortForm of [
"memhub calls clear",
"memhub notes add",
"memhub notes process",
"memhub memories add",
"memhub memories add-status",
"memhub models setup",
"memhub models add",
"memhub models test",
"memhub models activate",
"memhub models edit",
"memhub graph width",
"memhub graph-width",
"memhub sources connect",
"memhub write submit",
"memhub write status",
"memhub memories update",
@@ -159,6 +161,10 @@ describe("memhub short-command contract", () => {
]) {
expect(flattened).toContain(shortForm);
}
// No "note" concept and no plan files on the CLI surface.
for (const removed of ["memhub notes", "memhub calls", "--plan-file"]) {
expect(flattened).not.toContain(removed);
}
expect(COMMANDS.some((command) => command.name === "permission")).toBe(
false,
);
@@ -211,7 +217,7 @@ describe("memhub short-command contract", () => {
// but an earlier catalog text still claimed they were "尚不可用". A user
// reading `help path` would not try a command that works.
it("does not call the wired path/adapter commands unavailable", () => {
for (const name of ["path", "adapter"]) {
for (const name of ["scan-roots", "adapter"]) {
const command = COMMANDS.find((entry) => entry.name === name);
expect(command).toBeDefined();
const text = [
@@ -277,7 +283,7 @@ describe("memhub short-command contract", () => {
}
});
it("keeps plan-file apply previews free of diagnostic side effects", {
it("keeps destructive previews free of diagnostic side effects", {
timeout: 20_000,
}, async () => {
expect(
@@ -291,9 +297,10 @@ describe("memhub short-command contract", () => {
);
try {
for (const [index, argv] of [
["sources", "import", "--plan-file", path.join(root, "plan.json")],
["models", "configure", "--plan-file", path.join(root, "plan.json")],
["memories", "apply", "--plan-file", path.join(root, "plan.json")],
["memories", "delete", "m-1"],
["models", "remove", "44444444-4444-4444-8444-444444444444"],
["backup", "restore", "--source", path.join(root, "backup.sqlite3")],
["graph-width", "--decision", "rebuild_with_new"],
].entries()) {
const dataDir = path.join(root, `data-${index}`);
await import("node:fs/promises").then(({ mkdir }) =>
@@ -305,8 +312,8 @@ describe("memhub short-command contract", () => {
stdio: "pipe",
});
} catch {
// The missing plan or stopped Runtime is expected; the guard is
// that even the failure path did not add a diagnostic file.
// A failure path is acceptable; the guard is that no preview adds
// a diagnostic file.
}
expect(await readdir(dataDir)).toEqual([]);
}
+122 -264
View File
@@ -1,122 +1,56 @@
// A6: the rules behind the six product commands.
// The rules behind the commands that attach to the Runtime directly.
//
// The parsers are where the contract actually lives — "needs --yes to change
// anything" and "no write without evidence" are decisions, not formatting — so
// they are tested directly rather than only through the CLI.
// The parsers are where the contract actually lives — "only irreversible
// operations ask for --yes" and "unknown flags are rejected" are decisions,
// not formatting — so they are tested directly rather than only through the
// CLI.
import { describe, expect, it } from "vitest";
import { parseOpenAIMessages } from "./core-client.js";
import {
asPlanDocument,
describe as describePlan,
parseProductCommand,
parseBackupCommand,
parseScanCommand,
parseScanRootsCommand,
parseTaskCommand,
parseWriteCommand,
restoreConfirmationMatches,
} from "./product-flows.js";
describe("mutating commands are two-phase", () => {
it("plans without --yes", () => {
const parsed = parseProductCommand("scan", ["--root", "/tmp/a"]);
expect(parsed?.confirmed).toBe(false);
});
it("confirms with --yes", () => {
const parsed = parseProductCommand("scan", ["--root", "/tmp/a", "--yes"]);
expect(parsed?.confirmed).toBe(true);
});
it("says what it would do", () => {
const parsed = parseProductCommand("scan", ["--root", "/tmp/a"]);
// `?.plan!` would let a null parse pass silently; assert the parse first.
if (parsed === null) {
throw new Error("scan with a root must parse");
}
expect(describePlan(parsed.plan)).toContain("scan: /tmp/a");
});
it("marks a plan as not applied", () => {
// Assert the parse itself: `?.` let a null result satisfy this test for as
// long as `backup create` was unparseable.
const parsed = parseProductCommand("backup", [
"create",
"--target",
"/tmp/b",
]);
if (parsed === null) {
throw new Error("backup create must parse");
}
expect(parsed.plan.verb).toBe("create");
expect(asPlanDocument(parsed.plan).applied).toBe(false);
});
it("parses the verb for every command that takes one", () => {
// `path add` / `path remove` / `backup create` were rejected outright
// before the verb was skipped in parsing.
describe("scan-roots", () => {
it("reads the root, source and recursive flag off add", () => {
expect(
parseProductCommand("path", ["add", "--root", "/tmp/x"])?.plan.action,
).toBe("add scan root");
expect(
parseProductCommand("path", ["remove", "--root", "/tmp/x"])?.plan.action,
).toBe("remove scan root");
expect(
parseProductCommand("backup", ["restore", "--source", "/tmp/b"])?.plan
.action,
).toBe("restore backup");
parseScanRootsCommand([
"add",
"--root",
"/tmp/x",
"--source",
"codex",
"--no-recursive",
]),
).toEqual({
kind: "add",
root: "/tmp/x",
source: "codex",
recursive: false,
});
expect(parseScanRootsCommand(["add", "--root", "/tmp/x"])).toEqual({
kind: "add",
root: "/tmp/x",
recursive: true,
});
});
it("defaults a scan to all registered roots", () => {
const parsed = parseProductCommand("scan", []);
expect(parsed?.plan.target).toBe("all registered roots");
});
});
describe("unknown flags are rejected", () => {
it("rejects a typo'd flag rather than ignoring it", () => {
// A silently ignored --yes would turn a real apply into a plan, and the
// user would believe the change had been made.
expect(parseProductCommand("scan", ["--yesx"])).toBeNull();
expect(parseProductCommand("scan", ["--root"])).toBeNull();
expect(parseProductCommand("scan", ["--root", "--yes"])).toBeNull();
});
it("rejects an unknown verb for backup", () => {
it("distinguishes remove from add and refuses add-only flags on remove", () => {
expect(parseScanRootsCommand(["remove", "--root", "/tmp/x"])).toEqual({
kind: "remove",
root: "/tmp/x",
});
expect(
parseProductCommand("backup", ["make", "--target", "/tmp/b"]),
parseScanRootsCommand(["remove", "--root", "/tmp/x", "--no-recursive"]),
).toBeNull();
});
// `path` and `scan` share one parser. Before this guard, an unknown verb was
// silently treated as `path add` or a default `scan`, so `--yes` executed a
// different operation than the one the caller named.
it("rejects an unknown verb for path without turning it into add", () => {
expect(
parseProductCommand("path", ["frobnicate", "--root", "/tmp/x", "--yes"]),
).toBeNull();
});
it("rejects an unknown verb for scan without turning it into all", () => {
expect(
parseProductCommand("scan", ["frobnicate", "--all", "--yes"]),
).toBeNull();
expect(parseProductCommand("scan", ["frobnicate", "--yes"])).toBeNull();
});
it("still accepts the documented verbless scan", () => {
expect(parseProductCommand("scan", ["--all", "--yes"])?.plan.all).toBe(
true,
);
expect(
parseProductCommand("scan", ["--source", "codex"])?.plan.sourceId,
).toBe("codex");
});
it("rejects flags that the named verb does not consume", () => {
// These were accepted and then silently dropped. An operator who typed
// `path remove --source codex` would believe the source filter applied.
expect(
parseProductCommand("path", [
parseScanRootsCommand([
"remove",
"--root",
"/tmp/x",
@@ -124,137 +58,103 @@ describe("unknown flags are rejected", () => {
"codex",
]),
).toBeNull();
});
it("is direct, but keeps accepting a legacy --yes", () => {
expect(parseScanRootsCommand(["add", "--root", "/tmp/x", "--yes"])).toEqual(
{ kind: "add", root: "/tmp/x", recursive: true },
);
});
it("rejects unknown verbs, typos and repeated flags", () => {
expect(parseScanRootsCommand(["create", "--root", "/tmp/x"])).toBeNull();
expect(parseScanRootsCommand(["add", "--rot", "/tmp/x"])).toBeNull();
expect(parseScanRootsCommand(["add"])).toBeNull();
expect(
parseProductCommand("path", [
"remove",
"--root",
"/tmp/x",
"--no-recursive",
]),
parseScanRootsCommand(["add", "--root", "/a", "--root", "/b"]),
).toBeNull();
expect(
parseProductCommand("path", ["add", "--root", "/tmp/x", "--full"]),
parseScanRootsCommand(["add", "--root", "/a", "--yes", "--yes"]),
).toBeNull();
});
});
describe("scan", () => {
it("needs a source: there is no endpoint that scans a bare path", () => {
expect(parseScanCommand(["--source", "codex"])).toEqual({
kind: "source",
sourceId: "codex",
});
expect(parseScanCommand([])).toBeNull();
expect(parseScanCommand(["--root", "/tmp/a"])).toBeNull();
});
it("no longer accepts --full, which was never sent to Core", () => {
expect(parseScanCommand(["--source", "codex", "--full"])).toBeNull();
});
it("keeps --all as a hidden alias and a legacy --yes", () => {
expect(parseScanCommand(["--all", "--yes"])).toEqual({ kind: "all" });
expect(parseScanCommand(["--all", "--source", "codex"])).toBeNull();
expect(parseScanCommand(["--source", "codex", "--yes"])).toEqual({
kind: "source",
sourceId: "codex",
});
});
it("rejects a verb, a bad source id and repeated flags", () => {
expect(parseScanCommand(["all"])).toBeNull();
expect(parseScanCommand(["--source", "Bad Id"])).toBeNull();
expect(
parseScanCommand(["--source", "codex", "--source", "cursor"]),
).toBeNull();
expect(
parseProductCommand("backup", [
"create",
"--target",
"/tmp/b",
"--source",
"/tmp/s",
]),
parseScanCommand(["--source", "codex", "--json", "--json"]),
).toBeNull();
});
});
describe("backup", () => {
it("creates directly and requires a target", () => {
expect(parseBackupCommand(["create", "--target", "/tmp/b"])).toEqual({
verb: "create",
target: "/tmp/b",
});
expect(parseBackupCommand(["create"])).toBeNull();
expect(
parseProductCommand("backup", [
"restore",
"--source",
"/tmp/s",
"--target",
"/tmp/t",
]),
parseBackupCommand(["create", "--target", "/tmp/b", "--source", "/x"]),
).toBeNull();
});
// A later value silently won before this guard. For `scan --source` that
// means one extra shell/make variable can change which source is scanned
// while the operator believes both were applied.
it("rejects repeated value-bearing flags instead of letting the last one win", () => {
it("restores only with --yes and requires a source", () => {
expect(parseBackupCommand(["restore", "--source", "/tmp/b"])).toEqual({
verb: "restore",
source: "/tmp/b",
confirmed: false,
});
expect(
parseProductCommand("scan", ["--source", "codex", "--source", "cursor"]),
).toBeNull();
expect(
parseProductCommand("path", [
"add",
"--root",
"/tmp/a",
"--root",
"/tmp/b",
]),
).toBeNull();
expect(
parseProductCommand("backup", [
"create",
"--target",
"/tmp/a",
"--target",
"/tmp/b",
]),
).toBeNull();
parseBackupCommand(["restore", "--source", "/tmp/b", "--yes"]),
).toMatchObject({ confirmed: true });
expect(parseBackupCommand(["restore", "--yes"])).toBeNull();
});
it("rejects repeated boolean flags instead of treating them as idempotent", () => {
expect(parseProductCommand("scan", ["--all", "--all", "--yes"])).toBeNull();
expect(
parseProductCommand("scan", ["--full", "--full", "--yes"]),
).toBeNull();
expect(
parseProductCommand("path", [
"add",
"--root",
"/tmp/a",
"--no-recursive",
"--no-recursive",
]),
).toBeNull();
expect(
parseProductCommand("backup", [
"create",
"--target",
"/tmp/a",
"--yes",
"--yes",
]),
).toBeNull();
});
it("rejects a repeated global --json spelling", () => {
expect(
parseProductCommand("scan", ["--root", "/tmp/a", "--json", "--json"]),
).toBeNull();
expect(
parseProductCommand("backup", [
"create",
"--target",
"/tmp/a",
"--json",
"--json",
]),
).toBeNull();
});
it("requires a source when restoring", () => {
expect(
parseProductCommand("backup", ["restore", "--target", "/tmp/b"]),
).toBeNull();
});
it("parses the explicit restore confirmation path", () => {
const parsed = parseProductCommand("backup", [
it("accepts the legacy --confirm <path> and checks it lexically", () => {
const parsed = parseBackupCommand([
"restore",
"--source",
"/tmp/backups/a.sqlite3",
"/tmp/b",
"--yes",
"--confirm",
"/tmp/backups/a.sqlite3",
"/tmp/b",
]);
expect(parsed).not.toBeNull();
expect(parsed?.confirmPath).toBe("/tmp/backups/a.sqlite3");
expect(
restoreConfirmationMatches("/tmp/backups/a.sqlite3", parsed?.confirmPath),
).toBe(true);
expect(
restoreConfirmationMatches("/tmp/backups/a.sqlite3", undefined),
).toBe(false);
expect(
restoreConfirmationMatches(
"/tmp/backups/a.sqlite3",
"/tmp/backups/../backups/a.sqlite3",
),
).toBe(false);
expect(parsed).toMatchObject({ confirmPath: "/tmp/b" });
expect(restoreConfirmationMatches("/tmp/b", "/tmp/b")).toBe(true);
expect(restoreConfirmationMatches("/tmp/b", undefined)).toBe(true);
expect(restoreConfirmationMatches("/tmp/b", "/tmp/./b")).toBe(false);
});
it("requires a target when creating a backup", () => {
expect(parseProductCommand("backup", ["create"])).toBeNull();
it("rejects an unknown verb", () => {
expect(parseBackupCommand(["delete", "--target", "/tmp/b"])).toBeNull();
});
});
@@ -305,11 +205,19 @@ describe("write", () => {
).toBeNull();
});
it("parses status under write", () => {
it("parses status under write, positional or --id", () => {
expect(parseWriteCommand(["status", "--id", "request-1"])).toEqual({
kind: "status",
id: "request-1",
});
expect(parseWriteCommand(["status", "request-1"])).toEqual({
kind: "status",
id: "request-1",
});
});
it("rejects a repeated --session instead of letting the last one win", () => {
expect(parseWriteCommand([...base, "--session", "s2"])).toBeNull();
});
it("marks --live as priority live", () => {
@@ -366,53 +274,3 @@ describe("task", () => {
expect(parseTaskCommand(["--id", "req-1", "--json", "--json"])).toBeNull();
});
});
describe("path add and remove carry what the endpoint needs", () => {
it("reads the source and recursive flag off the path add", () => {
const parsed = parseProductCommand("path", [
"add",
"--root",
"/tmp/scanme",
"--source",
"codex",
"--no-recursive",
]);
// `?.plan!` let a null parse pass silently, so assert the parse itself.
if (parsed === null) {
throw new Error("path add must parse");
}
expect(parsed.plan.action).toBe("add scan root");
expect(parsed.plan.target).toBe("/tmp/scanme");
expect(parsed.plan.detail).toContain("source=codex");
expect(parsed.plan.detail).toContain("recursive=false");
});
it("distinguishes remove from add", () => {
const parsed = parseProductCommand("path", [
"remove",
"--root",
"/tmp/scanme",
]);
if (parsed === null) {
throw new Error("path remove must parse");
}
// The action is the discriminator at apply time: add and remove share a
// `--root`, so a mis-set action would register the directory it meant to delete.
expect(parsed.plan.action).toBe("remove scan root");
});
it("does not silently drop --yes", () => {
const parsed = parseProductCommand("path", [
"add",
"--root",
"/tmp/a",
"--yes",
]);
expect(parsed?.confirmed).toBe(true);
// Without this, a typo'd `--yes` would fall through to a plan and the user
// would believe the change had been made.
const without = parseProductCommand("path", ["add", "--root", "/tmp/a"]);
expect(without?.confirmed).toBe(false);
});
});
+153 -272
View File
@@ -1,258 +1,163 @@
// A6: the product commands Runtime exposes on the CLI.
// Parsers for the commands that attach to the Runtime directly:
// `scan-roots` (alias `path`), `scan`, `backup`, `write`.
//
// These are the six commands the objective names: path, adapter, scan, write,
// task, backup. Two rules run through all of them:
// Rules shared by all of them:
//
// - **Mutating commands are two-phase.** Without `--yes` they print a plan and
// change nothing. This is the convention the existing install/service
// commands already follow, and it is why `scan` and `backup` default to a
// plan rather than quietly doing work.
// - **Output is machine-readable.** `--json` emits one document on stdout so an
// agent can consume it; the human form is text. Never both, so a caller that
// parses stdout is not surprised by prose.
// - **Only irreversible operations ask for `--yes`.** `backup restore`
// overwrites the database, so without `--yes` it previews and changes
// nothing. Registering a scan root, scanning a source, creating a backup and
// submitting a write are direct. A legacy `--yes` on a direct command is
// accepted and ignored so older scripts keep working.
// - **Unknown or repeated flags are rejected**, never ignored: a dropped typo
// would silently change what the command does.
// - **Output is machine-readable.** `--json` emits one document on stdout.
//
// The commands talk to the Runtime's HTTP API, not to the database and not to
// HY. That is the A6 rule: CLI and Electron are clients of the same Runtime.
// the SDK: CLI and desktop are clients of the same Runtime.
/** What a mutating command would do, before it is allowed to do it. */
export interface Plan {
readonly action: string;
readonly target: string;
readonly detail?: string;
/** Which form of the command this plan is for, when one name has several.
*
* `backup` has `create` and `restore`, and applying the wrong one is not a
* harmless mistake — so the verb travels with the plan the user confirmed.
*/
readonly verb?: string;
/** The source the Runtime endpoint addresses, when the plan names one.
*
* Carried on the plan rather than re-parsed at apply time so the plan the
* user confirmed and the request actually sent cannot disagree.
*/
readonly sourceId?: string;
/** `scan --all`: every source Runtime actually found, not a named one. */
readonly all?: boolean;
export type ScanRootsCommand =
| {
readonly kind: "add";
readonly root: string;
readonly source?: string;
readonly recursive: boolean;
}
| { readonly kind: "remove"; readonly root: string };
export type ScanCommand =
| { readonly kind: "source"; readonly sourceId: string }
/** Hidden alias of `sources connect --all-detected --import`. */
| { readonly kind: "all" };
export type BackupCommand =
| { readonly verb: "create"; readonly target: string }
| {
readonly verb: "restore";
readonly source: string;
readonly confirmed: boolean;
/** Legacy `--confirm <path>`: accepted, and must repeat `--source`. */
readonly confirmPath?: string;
};
interface ParsedFlags {
readonly values: Map<string, string>;
readonly switches: Set<string>;
}
export function planAddRoot(
root: string,
source: string | undefined,
recursive: boolean,
): Plan {
return {
action: "add scan root",
target: root,
detail: `source=${source ?? "auto"} recursive=${recursive}`,
};
}
export function planRemoveRoot(root: string): Plan {
return { action: "remove scan root", target: root };
}
export function planScan(root: string | undefined, full: boolean): Plan {
return {
action: "scan",
target: root ?? "all registered roots",
detail: `full=${full}`,
};
}
export function planScanSource(sourceId: string, full: boolean): Plan {
return {
action: "scan",
target: `source ${sourceId}`,
detail: `full=${full}`,
sourceId,
};
}
export function planScanAll(full: boolean): Plan {
return {
action: "scan",
target: "all detected sources",
detail: `full=${full}`,
all: true,
};
}
export function planBackup(target: string): Plan {
return { action: "create backup", target, verb: "create" };
}
export function planRestore(source: string): Plan {
return { action: "restore backup", target: source, verb: "restore" };
}
export interface ProductCommand {
readonly plan: Plan;
readonly confirmed: boolean;
/** For destructive verbs with a named artifact, the exact value repeated by
* the user. `backup restore` requires this to match `--source`. */
readonly confirmPath?: string;
}
/** Parse a mutating product command.
*
* Unknown flags are rejected rather than ignored: a typo'd `--yes` that is
* silently dropped would turn a real apply into a plan, and the user would
* believe the change had been made.
*/
export function parseProductCommand(
name: string,
/** Read `--flag value` pairs and bare switches; null on anything unknown,
* repeated or missing its value. */
function parseFlags(
args: readonly string[],
): ProductCommand | null {
let root: string | undefined;
let source: string | undefined;
let target: string | undefined;
let recursive = true;
let full = false;
let all = false;
let confirmed = false;
let confirmPath: string | undefined;
const seen = new Set<string>();
const seenValues = new Set<string>();
const flags = new Set([
"--root",
"--source",
"--target",
"--confirm",
"--no-recursive",
"--full",
"--all",
"--yes",
"--json",
]);
// The first token is the verb for commands that have one (`path add`,
// `backup create`). It used to fall into the flag loop and be rejected as an
// unknown flag, which made every one of these commands permanently
// unparseable — `path add --root X` printed "参数无效" even though `help`
// documented it. The tests missed this because they asserted
// `parsed?.plan!` and null satisfies `?.` + `!`.
const verb = args[0];
const start = verb !== undefined && !verb.startsWith("--") ? 1 : 0;
for (let index = start; index < args.length; index += 1) {
valueFlags: readonly string[],
switchFlags: readonly string[],
): ParsedFlags | null {
const values = new Map<string, string>();
const switches = new Set<string>();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!flags.has(arg)) return null;
// A repeated flag is a malformed argv, not an idempotent spelling. The
// other command parsers already apply this rule to --json as well.
if (seen.has(arg)) return null;
seen.add(arg);
if (arg === "--yes") {
confirmed = true;
continue;
}
if (arg === "--confirm") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) return null;
if (confirmPath !== undefined) return null;
confirmPath = value;
index += 1;
continue;
}
if (arg === "--json") continue;
if (arg === "--no-recursive") {
recursive = false;
continue;
}
if (arg === "--full") {
full = true;
continue;
}
if (arg === "--all") {
all = true;
if (switchFlags.includes(arg)) {
if (switches.has(arg)) return null;
switches.add(arg);
continue;
}
if (!valueFlags.includes(arg) || values.has(arg)) return null;
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) return null;
if (seenValues.has(arg)) return null;
seenValues.add(arg);
values.set(arg, value);
index += 1;
if (arg === "--root") root = value;
else if (arg === "--source") source = value;
else target = value;
}
return { values, switches };
}
if (name === "path") {
if (confirmPath !== undefined) return null;
if (target !== undefined) return null;
if (root === undefined) return null;
if (source === undefined && args.includes("--source")) return null;
if (seen.has("--full") || seen.has("--all")) return null;
if (args[0] !== "add" && args[0] !== "remove") return null;
if (
args[0] === "remove" &&
(seen.has("--source") || seen.has("--no-recursive"))
) {
return null;
}
const SOURCE_ID = /^[a-z0-9][a-z0-9-]{0,127}$/;
/** `scan-roots add --root <path> [--source <id>] [--no-recursive]` and
* `scan-roots remove --root <path>` (`list` is answered separately). */
export function parseScanRootsCommand(
args: readonly string[],
): ScanRootsCommand | null {
const verb = args[0];
if (verb === "add") {
const flags = parseFlags(
args.slice(1),
["--root", "--source"],
["--no-recursive", "--yes", "--json"],
);
const root = flags?.values.get("--root");
if (!flags || root === undefined) return null;
const source = flags.values.get("--source");
if (source !== undefined && !SOURCE_ID.test(source)) return null;
return {
plan:
args[0] === "add"
? planAddRoot(root, source, recursive)
: planRemoveRoot(root),
confirmed,
kind: "add",
root,
...(source === undefined ? {} : { source }),
recursive: !flags.switches.has("--no-recursive"),
};
}
if (name === "scan") {
if (confirmPath !== undefined) return null;
if (target !== undefined) return null;
if (seen.has("--no-recursive")) return null;
if (args[0] !== undefined && !args[0].startsWith("--")) return null;
// `--all` means "every source Runtime actually found", so naming one too
// would make the command mean two different things.
if (all && source !== undefined) return null;
if (all) return { plan: planScanAll(full), confirmed };
// A source is the thing the Runtime scan endpoint addresses. A bare root is
// still accepted for planning (it means "all registered roots") but cannot
// be applied, because there is no endpoint that scans a raw path — the CLI
// must not pretend it can.
if (source === undefined) return { plan: planScan(root, full), confirmed };
return { plan: planScanSource(source, full), confirmed };
if (verb === "remove") {
const flags = parseFlags(args.slice(1), ["--root"], ["--yes", "--json"]);
const root = flags?.values.get("--root");
return flags && root !== undefined ? { kind: "remove", root } : null;
}
if (name === "backup") {
const verb = args[0];
if (verb !== "create" && verb !== "restore") return null;
if (seen.has("--full") || seen.has("--all") || seen.has("--no-recursive")) {
return null;
}
if (verb === "restore") {
if (source === undefined) return null;
if (target !== undefined) return null;
return {
plan: planRestore(source),
confirmed,
...(confirmPath === undefined ? {} : { confirmPath }),
};
}
if (confirmPath !== undefined) return null;
if (source !== undefined) return null;
if (target === undefined) return null;
return null;
}
/** `scan --source <id>`. A scan names a source: the Runtime has no endpoint
* that scans a bare path, so a missing `--source` is a usage error rather
* than a preview of something that can never run. */
export function parseScanCommand(args: readonly string[]): ScanCommand | null {
const flags = parseFlags(args, ["--source"], ["--all", "--yes", "--json"]);
if (!flags) return null;
const source = flags.values.get("--source");
if (flags.switches.has("--all")) {
return source === undefined ? { kind: "all" } : null;
}
return source !== undefined && SOURCE_ID.test(source)
? { kind: "source", sourceId: source }
: null;
}
/** `backup create --target <path>` and
* `backup restore --source <path> [--yes]`. */
export function parseBackupCommand(
args: readonly string[],
): BackupCommand | null {
const verb = args[0];
if (verb === "create") {
const flags = parseFlags(args.slice(1), ["--target"], ["--yes", "--json"]);
const target = flags?.values.get("--target");
return flags && target !== undefined ? { verb: "create", target } : null;
}
if (verb === "restore") {
const flags = parseFlags(
args.slice(1),
["--source", "--confirm"],
["--yes", "--json"],
);
const source = flags?.values.get("--source");
if (!flags || source === undefined) return null;
const confirmPath = flags.values.get("--confirm");
return {
plan: planBackup(target),
confirmed,
verb: "restore",
source,
confirmed: flags.switches.has("--yes"),
...(confirmPath === undefined ? {} : { confirmPath }),
};
}
return null;
}
/** Whether `--confirm <path>` names the same restore source.
/** Whether a legacy `--confirm <path>` names the same restore source.
*
* The comparison is lexical on purpose: the CLI must not resolve symlinks or
* touch the filesystem before Core has validated the managed source. A
* different string is a different confirmation, so a typo or a path escape
* can never be applied as a side effect of `--yes`.
* different string is a different confirmation.
*/
export function restoreConfirmationMatches(
source: string,
confirmPath: string | undefined,
): boolean {
return confirmPath === source;
return confirmPath === undefined || confirmPath === source;
}
export interface WriteSubmitCommand {
@@ -272,41 +177,33 @@ export interface WriteStatusCommand {
export type WriteCommand = WriteSubmitCommand | WriteStatusCommand;
/** Parse SDK-native `write submit|status`. */
/** Parse `write submit ...` and `write status <request-id>` (or `--id`). */
export function parseWriteCommand(
args: readonly string[],
): WriteCommand | null {
if (args.filter((item) => item === "--json").length > 1) return null;
const meaningful = args.filter((item) => item !== "--json");
if (meaningful[0] === "status") {
const status = parseTaskCommand(meaningful.slice(1));
const rest = meaningful.slice(1);
if (rest.length === 1 && !rest[0].startsWith("--")) {
return { kind: "status", id: rest[0] };
}
const status = parseTaskCommand(rest);
return status === null ? null : { kind: "status", id: status.id };
}
// `write --session ...` without `submit` stays accepted for older scripts.
const input = meaningful[0] === "submit" ? meaningful.slice(1) : meaningful;
let session: string | undefined;
let source = "memhub-cli";
let messagesFile: string | undefined;
let requestId: string | undefined;
let memoryAt: string | undefined;
let priority: "live" | "normal" | undefined;
for (let index = 0; index < input.length; index += 1) {
const arg = input[index];
if (arg === "--live") {
priority = "live";
continue;
}
const value = input[index + 1];
if (value === undefined || value.startsWith("--")) return null;
index += 1;
if (arg === "--session") session = value;
else if (arg === "--source") source = value;
else if (arg === "--messages-file") messagesFile = value;
else if (arg === "--request-id") requestId = value;
else if (arg === "--memory-at") memoryAt = value;
else return null;
}
const flags = parseFlags(
input,
["--session", "--source", "--messages-file", "--request-id", "--memory-at"],
["--live"],
);
if (!flags) return null;
const session = flags.values.get("--session");
const source = flags.values.get("--source") ?? "memhub-cli";
const messagesFile = flags.values.get("--messages-file");
const requestId = flags.values.get("--request-id");
const memoryAt = flags.values.get("--memory-at");
if (session === undefined || messagesFile === undefined || !source)
return null;
if (
@@ -323,39 +220,23 @@ export function parseWriteCommand(
...(requestId === undefined ? {} : { request_id: requestId }),
...(memoryAt === undefined ? {} : { memory_at: memoryAt }),
};
return priority === undefined ? command : { ...command, priority };
return flags.switches.has("--live")
? { ...command, priority: "live" }
: command;
}
export interface TaskCommand {
readonly id: string;
}
/** `--id <request-id>`: the legacy `task` spelling of `write status`. */
export function parseTaskCommand(args: readonly string[]): TaskCommand | null {
if (args.filter((item) => item === "--json").length > 1) return null;
let id: string | undefined;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--json") continue;
if (arg !== "--id") return null;
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) return null;
index += 1;
id = value;
}
const flags = parseFlags(
args.filter((item) => item !== "--json"),
["--id"],
[],
);
const id = flags?.values.get("--id");
return id === undefined ? null : { id };
}
/** The human form. Kept separate from the JSON form on purpose. */
export function describe(plan: Plan): string {
const line = `${plan.action}: ${plan.target}`;
return plan.detail ? `${line} (${plan.detail})` : line;
}
/** The machine form: one document, nothing else on stdout. */
export function asPlanDocument(plan: Plan): {
schema_version: 1;
plan: Plan;
applied: boolean;
} {
return { schema_version: 1, plan, applied: false };
}
@@ -878,8 +878,11 @@ describe("CLI-21 memory reads", () => {
layer: "L2_FACT",
status: "active",
});
expect(report.evidence).toEqual([]);
expect(report.history).toEqual([]);
// Schema 2, like add/update/delete; the v1-era always-empty
// `evidence`/`history` fields are gone.
expect(report.schema_version).toBe(2);
expect(report).not.toHaveProperty("evidence");
expect(report).not.toHaveProperty("history");
});
// Core registers /api/v2/memories/graph before /{memory_id}, and that static
@@ -1137,14 +1140,16 @@ describe("backup create target refusal is rejected, not usage_error", () => {
let code: number;
try {
code = await run(
["backup", "create", "--target", "/tmp/outside.db", "--yes", "--json"],
["backup", "create", "--target", "/tmp/outside.db", "--json"],
{},
);
} finally {
streams.restore();
}
expect(code!).toBe(1);
const document = JSON.parse(streams.stderr());
// A result, on stdout like every --json document.
expect(streams.stderr()).toBe("");
const document = JSON.parse(streams.stdout());
expect(document.status).not.toBe("usage_error");
expect(document).toMatchObject({
command: "backup",
@@ -341,7 +341,11 @@ function validated<T>(
):
| { state: "ok"; data: T }
| { state: Exclude<ReadState["state"], "ok">; code: string } {
if (outcome.state !== "ok") return outcome;
if (outcome.state !== "ok") {
// A Core refusal of the request itself reads as an invalid answer here.
const state = outcome.state === "rejected" ? "invalid" : outcome.state;
return { state, code: outcome.code };
}
const data = validate(outcome.data);
return data === null
? { state: "invalid", code: "response_invalid" }
+4
View File
@@ -102,6 +102,10 @@ export function uiSessionFailure(code: string): { code: string } {
export function uiExitCode(result: UiResult): number {
if (result.state === "opened") return 0;
// A stopped Runtime is the same exit 3 every other command reports.
if (result.state === "blocked" && result.code === "runtime_not_running") {
return 3;
}
if (result.state === "blocked") return 4;
return 1;
}
-479
View File
@@ -1,479 +0,0 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { run } from "./cli.js";
const transport = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
}));
vi.mock("axios", () => ({
default: { create: vi.fn(() => transport), get: vi.fn() },
}));
const runtime = vi.hoisted(() => ({
inspectRuntimeStatus: vi.fn(),
discoverRuntimeAttachment: vi.fn(),
}));
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
...runtime,
}));
const EVENT = "11111111-1111-4111-8111-111111111111";
const UPLOAD = "22222222-2222-4222-8222-222222222222";
const NOTE = "33333333-3333-4333-8333-333333333333";
beforeEach(() => {
vi.clearAllMocks();
// clearAllMocks keeps prior mock implementations, so a mockResolvedValue
// from an earlier test would leak into the next one and fake its response.
transport.get.mockReset();
transport.post.mockReset();
transport.put.mockReset();
transport.patch.mockReset();
transport.delete.mockReset();
runtime.inspectRuntimeStatus.mockResolvedValue({
state: "running",
code: "runtime_verified",
instanceId: "instance-1",
host: "127.0.0.1",
port: 8765,
version: "1.2.3",
});
runtime.discoverRuntimeAttachment.mockResolvedValue({
instanceId: "instance-1",
apiToken: "synthetic-runtime-token",
host: "127.0.0.1",
port: 8765,
servicePid: 4242,
health: {
status: "ok",
service: "one-memory-core",
version: "1.2.3",
instance_id: "instance-1",
},
});
});
describe("calls clear", () => {
it("reports runtime_stopped (exit 3) without attempting a write", async () => {
runtime.inspectRuntimeStatus.mockResolvedValue({
state: "stopped",
code: "runtime_not_running",
});
const result = await capturedRun([
"calls",
"clear",
"--all",
"--confirm",
"--json",
]);
expect(result.code).toBe(3);
expect(JSON.parse(result.stdout).code).toBe("runtime_not_running");
expect(transport.post).not.toHaveBeenCalled();
});
it("previews without contacting Core", async () => {
const result = await capturedRun([
"calls",
"clear",
"--event",
EVENT,
"--json",
]);
expect(result.code).toBe(0);
const document = JSON.parse(result.stdout);
expect(document.state).toBe("preview");
expect(document.plan.kind).toBe("call_events_clear");
expect(transport.post).not.toHaveBeenCalled();
expect(transport.delete).not.toHaveBeenCalled();
});
it("sends the exact Core body with --confirm", async () => {
transport.post.mockResolvedValue({ status: 200, data: { removed: 1 } });
const result = await capturedRun([
"calls",
"clear",
"--event",
EVENT,
"--confirm",
"--json",
]);
expect(result.code).toBe(0);
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/call-events/clear",
{ event_ids: [EVENT], user_confirmed: true },
undefined,
);
expect(JSON.parse(result.stdout).state).toBe("committed");
});
it("clears all without naming events", async () => {
transport.post.mockResolvedValue({ status: 200, data: { removed: 4 } });
const result = await capturedRun([
"calls",
"clear",
"--all",
"--confirm",
"--json",
]);
expect(result.code).toBe(0);
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/call-events/clear-all",
{ user_confirmed: true },
undefined,
);
});
it("rejects a malformed event id without contacting Core", async () => {
const result = await capturedRun([
"calls",
"clear",
"--event",
"not-a-uuid",
"--json",
]);
expect(result.code).toBe(2);
expect(transport.post).not.toHaveBeenCalled();
});
it("never reports committed for a malformed success body", async () => {
transport.post.mockResolvedValue({ status: 200, data: { wrong: true } });
const result = await capturedRun([
"calls",
"clear",
"--event",
EVENT,
"--confirm",
"--json",
]);
expect(result.code).toBe(1);
const document = JSON.parse(result.stdout);
expect(document.state).toBe("unknown");
expect(document.code).toBe("ui_write_receipt_invalid");
});
});
describe.skip("retired import-file", () => {
it("never uploads without --confirm", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "om-import-"));
const file = path.join(directory, "export.json");
await writeFile(file, '{"conversations":[]}', { mode: 0o600 });
const result = await capturedRun([
"import-file",
"upload",
"--file",
file,
"--json",
]);
expect(result.code).toBe(0);
const document = JSON.parse(result.stdout);
expect(document.state).toBe("preview");
expect(document.plan.operation_id).toBeDefined();
expect(transport.post).not.toHaveBeenCalled();
});
it("uploads the file with a deterministic operation id on confirm", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "om-import-"));
const file = path.join(directory, "export.json");
await writeFile(file, '{"conversations":[]}', { mode: 0o600 });
transport.post.mockResolvedValue({
status: 201,
data: {
schema_version: 1,
operation_id: "ignored-here",
state: "completed",
upload_id: UPLOAD,
upload_state: "staged",
},
});
const preview = await capturedRun([
"import-file",
"upload",
"--file",
file,
"--json",
]);
const plan = JSON.parse(preview.stdout).plan;
transport.post.mockResolvedValue({
status: 201,
data: {
schema_version: 1,
operation_id: plan.operation_id,
state: "completed",
upload_id: UPLOAD,
upload_state: "staged",
},
});
const result = await capturedRun([
"import-file",
"upload",
"--file",
file,
"--confirm",
"--json",
]);
expect(result.code).toBe(0);
const url = transport.post.mock.calls.at(-1)?.[0] as string;
expect(url).toContain("/api/v1/import-files?");
expect(url).toContain(`operation_id=${plan.operation_id}`);
expect(url).toContain("filename=export.json");
expect(JSON.parse(result.stdout).state).toBe("committed");
});
it("commits and discards only with explicit confirmation", async () => {
const preview = await capturedRun([
"import-file",
"commit",
UPLOAD,
"--json",
]);
expect(preview.code).toBe(0);
expect(JSON.parse(preview.stdout).state).toBe("preview");
expect(transport.post).not.toHaveBeenCalled();
transport.post.mockResolvedValue({
status: 200,
data: {
upload_id: UPLOAD,
filename: "x.json",
vendor: "generic",
source_id: "generic-export",
messages: 0,
conversations: 0,
human_messages: 0,
agent_messages: 0,
skipped_records: 0,
ignored_files: 0,
checksum: "a".repeat(64),
created_at: "T",
committed: true,
},
});
const committed = await capturedRun([
"import-file",
"commit",
UPLOAD,
"--confirm",
"--json",
]);
expect(committed.code).toBe(0);
expect(transport.post).toHaveBeenCalledWith(
`/api/v1/import-files/${UPLOAD}/commit`,
{ confirmed: true },
undefined,
);
transport.delete.mockResolvedValue({ status: 204, data: "" });
const discarded = await capturedRun([
"import-file",
"discard",
UPLOAD,
"--confirm",
"--json",
]);
expect(discarded.code).toBe(0);
expect(JSON.parse(discarded.stdout).state).toBe("committed");
expect(transport.delete).toHaveBeenCalledWith(
`/api/v1/import-files/${UPLOAD}`,
undefined,
);
});
it("maps a Core 404 to a stable rejected code, not a fake success", async () => {
transport.post.mockResolvedValue({
status: 404,
data: { detail: "import_file_not_found" },
});
const result = await capturedRun([
"import-file",
"commit",
UPLOAD,
"--confirm",
"--json",
]);
expect(result.code).toBe(1);
const document = JSON.parse(result.stdout);
expect(document.state).toBe("rejected");
expect(document.code).toBe("import_file_not_found");
});
});
describe("notes / extraction / graph / manual extract", () => {
it("adds a note from a bounded file and re-verifies content on apply", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "om-note-"));
const content = path.join(directory, "note.txt");
await writeFile(content, "记住这个决定", { mode: 0o600 });
const preview = await capturedRun([
"notes",
"add",
"--content-file",
content,
"--json",
]);
expect(preview.code).toBe(0);
const planFile = path.join(directory, "plan.json");
await writeFile(planFile, preview.stdout, { mode: 0o600 });
transport.post.mockResolvedValue({
status: 201,
// A realistic Core `MemoryNoteReceipt`: the shared parser requires
// `scope` and `memory_ids` too, so a minimal `{evidence_id,state}` stub
// is a malformed body, not a success.
data: {
evidence_id: NOTE,
text: "记住这个决定",
scope: "all-apps",
created_at: "2026-09-19T00:00:00Z",
state: "pending",
task_id: null,
memory_ids: [],
error: null,
},
});
const applied = await capturedRun([
"notes",
"apply",
"--plan-file",
planFile,
"--content-file",
content,
"--confirm",
"--json",
]);
expect(applied.code).toBe(0);
const body = transport.post.mock.calls.at(-1)?.[1] as Record<
string,
unknown
>;
expect(body.text).toBe("记住这个决定");
expect(body.scope).toBe("all-apps");
expect(JSON.parse(applied.stdout).state).toBe("committed");
});
it.skip("plans then applies the extraction window", async () => {
const preview = await capturedRun([
"extraction",
"set",
"--window",
"6m",
"--json",
]);
expect(preview.code).toBe(0);
expect(JSON.parse(preview.stdout).state).toBe("preview");
expect(transport.put).not.toHaveBeenCalled();
transport.put.mockResolvedValue({
status: 200,
data: { window_preset: "6m" },
});
const applied = await capturedRun([
"extraction",
"set",
"--window",
"6m",
"--confirm",
"--json",
]);
expect(applied.code).toBe(0);
expect(transport.put).toHaveBeenCalledWith(
"/api/v1/extraction-settings",
{ window_preset: "6m" },
undefined,
);
});
it("does not call graph width without --confirm", async () => {
const preview = await capturedRun([
"graph",
"width",
"--decision",
"rebuild_with_new",
"--json",
]);
expect(preview.code).toBe(0);
expect(transport.post).not.toHaveBeenCalled();
transport.post.mockResolvedValue({
status: 200,
data: { archived_path: "/tmp/old", exported_path: null, mismatch: null },
});
const applied = await capturedRun([
"graph",
"width",
"--decision",
"rebuild_with_new",
"--confirm",
"--json",
]);
expect(applied.code).toBe(0);
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/graph-width",
{ decision: "rebuild_with_new" },
undefined,
);
});
it.skip("plans manual extraction and applies it only on confirm", async () => {
const preview = await capturedRun([
"processing",
"extract",
"--evidence",
NOTE,
"--json",
]);
expect(preview.code).toBe(0);
expect(JSON.parse(preview.stdout).state).toBe("preview");
expect(transport.post).not.toHaveBeenCalled();
transport.post.mockResolvedValue({
status: 200,
data: { promoted: 1, batches_planned: 2, batches_current: 1 },
});
const applied = await capturedRun([
"processing",
"extract",
"--evidence",
NOTE,
"--confirm",
"--json",
]);
expect(applied.code).toBe(0);
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/memory-processing/manual-extract",
{ evidence_ids: [NOTE] },
undefined,
);
});
});
async function capturedRun(arguments_: string[]) {
let stdout = "";
let stderr = "";
const stdoutSpy = vi
.spyOn(process.stdout, "write")
.mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation((chunk) => {
stderr += String(chunk);
return true;
});
try {
return {
code: await run(arguments_, {
ONE_MEMORY_DATA_DIR: "/synthetic/data",
}),
stdout,
stderr,
};
} finally {
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
}
}
@@ -1,501 +0,0 @@
/**
* The remaining Web Console mutating operations the CLI did not expose:
* 端内新增记忆 (memory-notes), extraction window, graph width, and manual
* extraction.
*
* Core owns every rule; this module only validates the input it is about to
* send, performs one explicit two-phase write, and normalises the receipt so
* the CLI never reports a write as committed without evidence.
*/
import { createHash, randomUUID } from "node:crypto";
import {
buildMemoryNoteSaveRequest,
MemoryNoteInputError,
parseExtractionSettingsReceipt,
parseGraphWidthDecisionReceipt,
parseManualExtractReceipt,
parseMemoryNoteReceipt,
} from "@one-memory/operations";
import type { CoreOperations, CorePutOperations } from "./core-operations.js";
import { PRODUCT } from "./version.js";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const DIGEST = /^[0-9a-f]{64}$/;
const NOTE_MAX_CHARACTERS = 12_000;
const EXTRACTION_PRESETS = ["3m", "6m", "1y", "all"] as const;
const GRAPH_DECISIONS = [
"keep_existing",
"rebuild_with_new",
"export_first",
] as const;
const NOTE_STATES = [
"waiting_for_model",
"waiting_for_permission",
"pending",
"running",
"failed",
"ready",
"no_memories",
] as const;
export type NoteState = (typeof NOTE_STATES)[number];
export type ExtendedWritePlan =
| {
schema_version: 1;
kind: "memory_note_add";
request_id: string;
content_sha256: string;
content_characters: number;
digest: string;
}
| {
schema_version: 1;
kind: "memory_note_process";
evidence_id: string;
digest: string;
}
| {
schema_version: 1;
kind: "extraction_window_set";
window_preset: (typeof EXTRACTION_PRESETS)[number];
digest: string;
}
| {
schema_version: 1;
kind: "graph_width_decide";
decision: (typeof GRAPH_DECISIONS)[number];
digest: string;
}
| {
schema_version: 1;
kind: "manual_extract";
evidence_ids: string[];
digest: string;
};
export type ExtendedWriteOutcome =
| {
schema_version: 1;
product: string;
state: "preview";
plan: ExtendedWritePlan;
}
| {
schema_version: 1;
product: string;
state: "committed" | "recovered";
operation_id: string;
receipt: Record<string, unknown>;
}
| {
schema_version: 1;
product: string;
state: "rejected" | "unknown" | "invalid";
operation_id?: string;
code: string;
};
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value !== null && typeof value === "object") {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`)
.join(",")}}`;
}
if (value === undefined) return "null";
return JSON.stringify(value);
}
function digestOf(value: unknown): string {
return createHash("sha256").update(canonical(value)).digest("hex");
}
type Draft =
| Omit<Extract<ExtendedWritePlan, { kind: "memory_note_add" }>, "digest">
| Omit<Extract<ExtendedWritePlan, { kind: "memory_note_process" }>, "digest">
| Omit<
Extract<ExtendedWritePlan, { kind: "extraction_window_set" }>,
"digest"
>
| Omit<Extract<ExtendedWritePlan, { kind: "graph_width_decide" }>, "digest">
| Omit<Extract<ExtendedWritePlan, { kind: "manual_extract" }>, "digest">;
function preview(plan: Draft): ExtendedWriteOutcome {
return {
schema_version: 1,
product: PRODUCT,
state: "preview",
plan: { ...plan, digest: digestOf(plan) } as ExtendedWritePlan,
};
}
function invalid(code: string): ExtendedWriteOutcome {
return { schema_version: 1, product: PRODUCT, state: "invalid", code };
}
function committed(
operationId: string,
receipt: Record<string, unknown>,
state: "committed" | "recovered" = "committed",
): ExtendedWriteOutcome {
return {
schema_version: 1,
product: PRODUCT,
state,
operation_id: operationId,
receipt,
};
}
function rejected(
operationId: string | undefined,
code: string,
): ExtendedWriteOutcome {
return {
schema_version: 1,
product: PRODUCT,
state: "rejected",
...(operationId === undefined ? {} : { operation_id: operationId }),
code,
};
}
function unknown(
operationId: string | undefined,
code: string,
): ExtendedWriteOutcome {
return {
schema_version: 1,
product: PRODUCT,
state: "unknown",
...(operationId === undefined ? {} : { operation_id: operationId }),
code,
};
}
// --- memory-notes ---------------------------------------------------------
export function planMemoryNoteAdd(text: string): ExtendedWriteOutcome {
// The plan keeps only the note's identity, like `memories plan add`: the
//正文 lives in a bounded file and is re-read and re-hashed at apply time, so
// a plan file never carries the user's private text.
if (!validNoteText(text)) return invalid("memory_note_text_invalid");
const requestId = randomUUID();
return preview({
schema_version: 1,
kind: "memory_note_add",
request_id: requestId,
content_sha256: createHash("sha256").update(text, "utf8").digest("hex"),
content_characters: [...text].length,
});
}
export function validNoteText(text: string): boolean {
return (
text.length > 0 &&
[...text].length <= NOTE_MAX_CHARACTERS &&
text.trim().length > 0 &&
![...text].some((character) => {
const code = character.charCodeAt(0);
return code <= 8 || (code >= 11 && code <= 31) || code === 127;
})
);
}
export function planMemoryNoteProcess(
evidenceId: string,
): ExtendedWriteOutcome {
return UUID.test(evidenceId)
? preview({
schema_version: 1,
kind: "memory_note_process",
evidence_id: evidenceId,
})
: invalid("memory_note_id_invalid");
}
function noteReceipt(
operationId: string,
data: { evidence_id: string; state: NoteState },
): ExtendedWriteOutcome {
return committed(operationId, { ...data });
}
export async function executeMemoryNoteAdd(
plan: Extract<ExtendedWritePlan, { kind: "memory_note_add" }>,
operations: CoreOperations,
text: string,
): Promise<ExtendedWriteOutcome> {
if (
!validNoteText(text) ||
createHash("sha256").update(text, "utf8").digest("hex") !==
plan.content_sha256 ||
[...text].length !== plan.content_characters
) {
return invalid("memory_note_content_mismatch");
}
// The shared builder owns the save body (request_id/text/scope=all-apps), so
// the CLI and the Web console post the same three fields. Its own text check
// already ran above, so a throw here means an unusable plan.
let request: ReturnType<typeof buildMemoryNoteSaveRequest>;
try {
request = buildMemoryNoteSaveRequest({
request_id: plan.request_id,
text,
scope: "all-apps",
} as Parameters<typeof buildMemoryNoteSaveRequest>[0]);
} catch (error) {
if (error instanceof MemoryNoteInputError) {
return invalid("memory_note_content_mismatch");
}
throw error;
}
const result = await operations.post<unknown>(request.path, request.body);
if (result.state === "rejected") {
return rejected(plan.request_id, result.code);
}
if (result.state === "unknown") {
return unknown(plan.request_id, result.code);
}
const receipt = asNoteReceipt(result.data);
return receipt === null
? unknown(plan.request_id, "ui_write_receipt_invalid")
: noteReceipt(plan.request_id, receipt);
}
export async function executeMemoryNoteProcess(
plan: Extract<ExtendedWritePlan, { kind: "memory_note_process" }>,
operations: CoreOperations,
): Promise<ExtendedWriteOutcome> {
const result = await operations.post<unknown>(
`/api/v1/memory-notes/${plan.evidence_id}/process`,
{},
);
if (result.state === "rejected") {
return rejected(plan.evidence_id, result.code);
}
if (result.state === "unknown") {
return unknown(plan.evidence_id, result.code);
}
const receipt = asNoteReceipt(result.data);
return receipt === null
? unknown(plan.evidence_id, "ui_write_receipt_invalid")
: noteReceipt(plan.evidence_id, receipt);
}
function asNoteReceipt(
value: unknown,
): { evidence_id: string; state: NoteState } | null {
// The shared parser is the single judge of a note receipt; a malformed body
// returns null and the caller reports `unknown`, never a saved note.
const receipt = parseMemoryNoteReceipt(value);
return receipt === null
? null
: { evidence_id: receipt.evidence_id, state: receipt.state as NoteState };
}
// --- extraction window / graph width / manual extract ---------------------
export function planExtractionWindowSet(
windowPreset: string,
): ExtendedWriteOutcome {
return (EXTRACTION_PRESETS as readonly string[]).includes(windowPreset)
? preview({
schema_version: 1,
kind: "extraction_window_set",
window_preset: windowPreset as (typeof EXTRACTION_PRESETS)[number],
})
: invalid("extraction_window_preset_invalid");
}
export function planGraphWidthDecide(decision: string): ExtendedWriteOutcome {
return (GRAPH_DECISIONS as readonly string[]).includes(decision)
? preview({
schema_version: 1,
kind: "graph_width_decide",
decision: decision as (typeof GRAPH_DECISIONS)[number],
})
: invalid("graph_width_decision_invalid");
}
export function planManualExtract(evidenceIds: string[]): ExtendedWriteOutcome {
const ids = [...new Set(evidenceIds)];
return ids.length === evidenceIds.length && ids.every((id) => UUID.test(id))
? preview({
schema_version: 1,
kind: "manual_extract",
evidence_ids: ids,
})
: invalid("manual_extract_evidence_ids_invalid");
}
export async function executeExtractionWindowSet(
plan: Extract<ExtendedWritePlan, { kind: "extraction_window_set" }>,
operations: CorePutOperations,
): Promise<ExtendedWriteOutcome> {
const result = await operations.put<unknown>("/api/v1/extraction-settings", {
window_preset: plan.window_preset,
});
if (result.state === "rejected") {
return rejected(plan.window_preset, result.code);
}
if (result.state === "unknown") {
return unknown(plan.window_preset, result.code);
}
// The shared parser also enforces that the receipt echoes the requested
// preset, so a response belonging to another write cannot pass.
const parsed = parseExtractionSettingsReceipt(
result.data,
plan.window_preset,
);
if (parsed === null) {
return unknown(plan.window_preset, "ui_write_receipt_invalid");
}
return committed(plan.window_preset, { window_preset: plan.window_preset });
}
export async function executeGraphWidthDecide(
plan: Extract<ExtendedWritePlan, { kind: "graph_width_decide" }>,
operations: CoreOperations,
): Promise<ExtendedWriteOutcome> {
const result = await operations.post<unknown>("/api/v1/graph-width", {
decision: plan.decision,
});
if (result.state === "rejected") {
return rejected(plan.decision, result.code);
}
if (result.state === "unknown") {
return unknown(plan.decision, result.code);
}
const receipt = parseGraphWidthDecisionReceipt(result.data);
if (receipt === null) {
return unknown(plan.decision, "ui_write_receipt_invalid");
}
return committed(plan.decision, {
archived_path: receipt.archived_path ?? null,
exported_path: receipt.exported_path ?? null,
mismatch: receipt.mismatch ?? null,
});
}
export async function executeManualExtract(
plan: Extract<ExtendedWritePlan, { kind: "manual_extract" }>,
operations: CoreOperations,
): Promise<ExtendedWriteOutcome> {
const result = await operations.post<unknown>(
"/api/v1/memory-processing/manual-extract",
{ evidence_ids: plan.evidence_ids },
);
if (result.state === "rejected") {
return rejected("manual_extract", result.code);
}
if (result.state === "unknown") {
return unknown("manual_extract", result.code);
}
const receipt = parseManualExtractReceipt(result.data);
if (receipt === null) {
return unknown("manual_extract", "ui_write_receipt_invalid");
}
return committed("manual_extract", {
promoted: receipt.promoted,
batches_planned: receipt.batches_planned,
batches_current: receipt.batches_current,
});
}
export function validateExtendedWritePlan(
value: unknown,
): ExtendedWritePlan | null {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const candidate = value as Record<string, unknown>;
if (candidate.schema_version !== 1 || typeof candidate.kind !== "string") {
return null;
}
const { digest, ...withoutDigest } = candidate;
if (typeof digest !== "string" || digest !== digestOf(withoutDigest)) {
return null;
}
switch (candidate.kind) {
case "memory_note_add":
return typeof candidate.request_id === "string" &&
UUID.test(candidate.request_id) &&
typeof candidate.content_sha256 === "string" &&
DIGEST.test(candidate.content_sha256) &&
Number.isSafeInteger(candidate.content_characters) &&
(candidate.content_characters as number) >= 1 &&
(candidate.content_characters as number) <= NOTE_MAX_CHARACTERS &&
Object.keys(candidate).length === 6
? (candidate as unknown as ExtendedWritePlan)
: null;
case "memory_note_process":
return typeof candidate.evidence_id === "string" &&
UUID.test(candidate.evidence_id) &&
Object.keys(candidate).length === 4
? (candidate as unknown as ExtendedWritePlan)
: null;
case "extraction_window_set":
return (EXTRACTION_PRESETS as readonly string[]).includes(
String(candidate.window_preset),
) && Object.keys(candidate).length === 4
? (candidate as unknown as ExtendedWritePlan)
: null;
case "graph_width_decide":
return (GRAPH_DECISIONS as readonly string[]).includes(
String(candidate.decision),
) && Object.keys(candidate).length === 4
? (candidate as unknown as ExtendedWritePlan)
: null;
case "manual_extract":
return Array.isArray(candidate.evidence_ids) &&
candidate.evidence_ids.every(
(id) => typeof id === "string" && UUID.test(id),
) &&
Object.keys(candidate).length === 4
? (candidate as unknown as ExtendedWritePlan)
: null;
default:
return null;
}
}
export function extendedWriteExitCode(document: ExtendedWriteOutcome): number {
return ["preview", "committed", "recovered"].includes(document.state) ? 0 : 1;
}
export function renderExtendedWriteText(
document: ExtendedWriteOutcome,
): string {
if (document.state === "preview") {
const lines = ["以下操作尚未执行;加 --confirm 后才会写入:"];
const plan = document.plan;
switch (plan.kind) {
case "memory_note_add":
lines.push(`新增端内记忆:${plan.content_characters} 字符`);
break;
case "memory_note_process":
lines.push(`处理端内记忆:${plan.evidence_id}`);
break;
case "extraction_window_set":
lines.push(`设置提取窗口:${plan.window_preset}`);
break;
case "graph_width_decide":
lines.push(`图谱宽度决定:${plan.decision}`);
break;
case "manual_extract":
lines.push(`手动提取:${plan.evidence_ids.length} 条证据`);
break;
}
lines.push(`plan_digest:${plan.digest}`);
return `${lines.join("\n")}\n`;
}
if (document.state === "committed" || document.state === "recovered") {
return `${document.state === "recovered" ? "已从回执恢复" : "已提交"}:${document.operation_id}\n${JSON.stringify(document.receipt)}\n`;
}
const code = "code" in document ? document.code : "ui_write_state_invalid";
return `操作未完成:${document.state}(${code})\n`;
}
-144
View File
@@ -1,144 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type {
CoreDeleteOperations,
CoreOperations,
} from "./core-operations.js";
import {
executeCallEventsClear,
executeCallEventsClearAll,
executeImportFileCommit,
executeImportFileDiscard,
planCallEventsClear,
planCallEventsClearAll,
planImportFileCommit,
planImportFileDiscard,
planImportFileUpload,
validateUiWritePlan,
} from "./ui-write-flows.js";
const EVENT = "11111111-1111-4111-8111-111111111111";
const UPLOAD = "22222222-2222-4222-8222-222222222222";
function planOf(value: ReturnType<typeof planCallEventsClear>) {
if (value.state !== "preview") throw new Error("expected preview");
return value.plan;
}
describe("UI write plans", () => {
it("permits only valid call-event ids and rejects duplicates", () => {
expect(planCallEventsClear([EVENT]).state).toBe("preview");
expect(planCallEventsClear([]).state).toBe("invalid");
expect(planCallEventsClear([EVENT, EVENT]).state).toBe("invalid");
});
it("plans all remaining UI write operations", () => {
expect(planCallEventsClearAll().state).toBe("preview");
expect(planImportFileCommit(UPLOAD).state).toBe("preview");
expect(planImportFileDiscard(UPLOAD).state).toBe("preview");
expect(
planImportFileUpload({
path: "/tmp/export.json",
bytes: 42,
sha256: "a".repeat(64),
inputKind: "raw_conversation",
}).state,
).toBe("preview");
});
it("rejects a tampered plan digest", async () => {
const plan = planOf(planCallEventsClear([EVENT]));
expect(validateUiWritePlan(plan)).toEqual(plan);
expect(validateUiWritePlan({ ...plan, event_ids: [UPLOAD] })).toBeNull();
});
});
describe("UI write execution", () => {
it("posts clear with Core's confirmation field", async () => {
const post = vi.fn(async () => ({
state: "committed" as const,
data: { removed: 1 },
}));
const plan = planOf(planCallEventsClear([EVENT]));
if (plan.kind !== "call_events_clear") throw new Error("kind");
const result = await executeCallEventsClear(plan, {
post,
} as unknown as CoreOperations);
expect(result.state).toBe("committed");
expect(post).toHaveBeenCalledWith("/api/v1/call-events/clear", {
event_ids: [EVENT],
user_confirmed: true,
});
});
it("posts clear-all with Core's confirmation field", async () => {
const post = vi.fn(async () => ({
state: "committed" as const,
data: { removed: 2 },
}));
const plan = planOf(planCallEventsClearAll());
if (plan.kind !== "call_events_clear_all") throw new Error("kind");
await executeCallEventsClearAll(plan, {
post,
} as unknown as CoreOperations);
expect(post).toHaveBeenCalledWith("/api/v1/call-events/clear-all", {
user_confirmed: true,
});
});
it("uses POST commit and DELETE discard endpoints", async () => {
const post = vi.fn(async () => ({
state: "committed" as const,
data: {
upload_id: UPLOAD,
filename: "x.json",
vendor: "generic",
source_id: "generic-export",
messages: 0,
conversations: 0,
human_messages: 0,
agent_messages: 0,
skipped_records: 0,
ignored_files: 0,
checksum: "a".repeat(64),
created_at: "T",
committed: true,
},
}));
const del = vi.fn(async <T>() => ({
state: "committed" as const,
data: {} as T,
}));
const operations = {
post,
delete: del,
} as unknown as CoreOperations & CoreDeleteOperations;
const commit = planOf(planImportFileCommit(UPLOAD));
if (commit.kind !== "import_file_commit") throw new Error("kind");
await executeImportFileCommit(commit, operations);
expect(post).toHaveBeenCalledWith(`/api/v1/import-files/${UPLOAD}/commit`, {
confirmed: true,
});
const discard = planOf(planImportFileDiscard(UPLOAD));
if (discard.kind !== "import_file_discard") throw new Error("kind");
expect((await executeImportFileDiscard(discard, operations)).state).toBe(
"committed",
);
expect(del).toHaveBeenCalledWith(`/api/v1/import-files/${UPLOAD}`);
});
it("treats a malformed success receipt as unknown", async () => {
const plan = planOf(planCallEventsClear([EVENT]));
if (plan.kind !== "call_events_clear") throw new Error("kind");
const result = await executeCallEventsClear(plan, {
post: vi.fn(async () => ({
state: "committed" as const,
data: { wrong: true },
})),
} as unknown as CoreOperations);
expect(result).toMatchObject({
state: "unknown",
code: "ui_write_receipt_invalid",
});
});
});
-555
View File
@@ -1,555 +0,0 @@
/**
* The Web Console's remaining mutating operations, exposed to the CLI without
* duplicating Core semantics. Each operation is planned first; callers apply
* the exact plan they reviewed.
*/
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { type FileHandle, open } from "node:fs/promises";
import type {
CoreDeleteOperations,
CoreOperations,
CoreUploadOperations,
} from "./core-operations.js";
import { PRODUCT } from "./version.js";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const INPUT_KINDS = ["raw_conversation", "memory_content"] as const;
const MAX_UPLOAD_BYTES = 128 * 1024 * 1024;
export type UiWritePlan =
| {
schema_version: 1;
kind: "call_events_clear";
event_ids: string[];
digest: string;
}
| {
schema_version: 1;
kind: "call_events_clear_all";
digest: string;
}
| {
schema_version: 1;
kind: "import_file_commit";
upload_id: string;
digest: string;
}
| {
schema_version: 1;
kind: "import_file_discard";
upload_id: string;
digest: string;
}
| {
schema_version: 1;
kind: "import_file_upload";
operation_id: string;
path: string;
filename: string;
input_kind: (typeof INPUT_KINDS)[number];
bytes: number;
sha256: string;
digest: string;
};
export type UiWriteOutcome =
| {
schema_version: 1;
product: string;
state: "preview";
plan: UiWritePlan;
}
| {
schema_version: 1;
product: string;
state: "committed" | "recovered";
operation_id: string;
receipt: Record<string, unknown>;
}
| {
schema_version: 1;
product: string;
state: "rejected" | "unknown" | "invalid";
operation_id?: string;
code: string;
receipt?: Record<string, unknown>;
};
function digestOf(value: unknown): string {
return createHash("sha256").update(canonical(value)).digest("hex");
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value !== null && typeof value === "object") {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`)
.join(",")}}`;
}
if (value === undefined) return "null";
return JSON.stringify(value);
}
export type UiWritePlanDraft =
| Omit<Extract<UiWritePlan, { kind: "call_events_clear" }>, "digest">
| Omit<Extract<UiWritePlan, { kind: "call_events_clear_all" }>, "digest">
| Omit<Extract<UiWritePlan, { kind: "import_file_commit" }>, "digest">
| Omit<Extract<UiWritePlan, { kind: "import_file_discard" }>, "digest">
| Omit<Extract<UiWritePlan, { kind: "import_file_upload" }>, "digest">;
function preview(plan: UiWritePlanDraft): UiWriteOutcome {
return {
schema_version: 1,
product: PRODUCT,
state: "preview",
plan: { ...plan, digest: digestOf(plan) } as UiWritePlan,
};
}
function invalid(code: string): UiWriteOutcome {
return { schema_version: 1, product: PRODUCT, state: "invalid", code };
}
function committed(
operationId: string,
receipt: Record<string, unknown>,
state: "committed" | "recovered" = "committed",
): UiWriteOutcome {
return {
schema_version: 1,
product: PRODUCT,
state,
operation_id: operationId,
receipt,
};
}
export function planCallEventsClear(
eventIds: readonly string[],
): UiWriteOutcome {
if (
eventIds.length < 1 ||
eventIds.length > 100 ||
new Set(eventIds).size !== eventIds.length ||
eventIds.some((id) => !UUID.test(id))
) {
return invalid("call_event_ids_invalid");
}
return preview({
schema_version: 1,
kind: "call_events_clear",
event_ids: [...eventIds],
});
}
export function planCallEventsClearAll(): UiWriteOutcome {
return preview({ schema_version: 1, kind: "call_events_clear_all" });
}
export function planImportFileCommit(uploadId: string): UiWriteOutcome {
return UUID.test(uploadId)
? preview({
schema_version: 1,
kind: "import_file_commit",
upload_id: uploadId,
})
: invalid("import_file_id_invalid");
}
export function planImportFileDiscard(uploadId: string): UiWriteOutcome {
return UUID.test(uploadId)
? preview({
schema_version: 1,
kind: "import_file_discard",
upload_id: uploadId,
})
: invalid("import_file_id_invalid");
}
export function planImportFileUpload(input: {
path: string;
bytes: number;
sha256: string;
inputKind: "raw_conversation" | "memory_content";
}): UiWriteOutcome {
const filename = input.path.split(/[/\\]/).pop() ?? "";
if (
input.path.length === 0 ||
!Number.isSafeInteger(input.bytes) ||
input.bytes < 0 ||
input.bytes > MAX_UPLOAD_BYTES ||
!/^[0-9a-f]{64}$/.test(input.sha256) ||
!INPUT_KINDS.includes(input.inputKind) ||
filename.length === 0 ||
[...filename].length > 200 ||
!/\.(?:zip|json|md|txt)$/i.test(filename)
) {
return invalid("import_file_upload_invalid");
}
return preview({
schema_version: 1,
kind: "import_file_upload",
operation_id: fileUploadOperationId(
filename,
input.inputKind,
input.sha256,
),
path: input.path,
filename,
input_kind: input.inputKind,
bytes: input.bytes,
sha256: input.sha256,
});
}
/**
* Read one regular, non-symlinked export without buffering past the 128 MiB
* limit. Returns the bytes plus the checksum the plan records, so apply can
* prove the file it uploads is the one the caller reviewed.
*/
export async function readImportFileUpload(
filePath: string,
inputKind: "raw_conversation" | "memory_content",
): Promise<{ plan: UiWritePlan; bytes: Buffer } | null> {
let handle: FileHandle;
try {
handle = await open(
filePath,
constants.O_RDONLY |
(constants.O_NONBLOCK ?? 0) |
(constants.O_NOFOLLOW ?? 0),
);
} catch {
return null;
}
try {
const stats = await handle.stat();
if (!stats.isFile() || stats.nlink !== 1 || stats.size > MAX_UPLOAD_BYTES) {
return null;
}
const buffer = Buffer.alloc(Number(stats.size));
let offset = 0;
while (offset < buffer.length) {
const chunk = await handle.read(
buffer,
offset,
buffer.length - offset,
offset,
);
if (chunk.bytesRead === 0) break;
offset += chunk.bytesRead;
}
if (offset !== buffer.length) return null;
const checksum = createHash("sha256").update(buffer).digest("hex");
const result = planImportFileUpload({
path: filePath,
bytes: buffer.byteLength,
sha256: checksum,
inputKind,
});
if (result.state !== "preview") return null;
return { plan: result.plan, bytes: buffer };
} catch {
return null;
} finally {
await handle.close().catch(() => undefined);
}
}
/**
* Core keys upload idempotency by `operation_id` and rejects a replay whose
* fingerprint differs. Deriving it from the file's own identity means a retry
* after a dropped response resumes the same staging operation instead of
* creating a second upload; a genuinely different payload maps to a different
* operation and cannot overwrite the first.
*/
function fileUploadOperationId(
filename: string,
inputKind: (typeof INPUT_KINDS)[number],
checksum: string,
): string {
const hex = createHash("sha256")
.update(JSON.stringify([filename, inputKind, checksum]))
.digest("hex");
return [
hex.slice(0, 8),
hex.slice(8, 12),
`4${hex.slice(13, 16)}`,
`${(8 + (parseInt(hex[16]!, 16) % 4)).toString(16)}${hex.slice(17, 20)}`,
hex.slice(20, 32),
].join("-");
}
export function validateUiWritePlan(value: unknown): UiWritePlan | null {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const candidate = value as Record<string, unknown>;
if (candidate.schema_version !== 1 || typeof candidate.kind !== "string") {
return null;
}
const { digest, ...withoutDigest } = candidate;
if (typeof digest !== "string" || digest !== digestOf(withoutDigest)) {
return null;
}
switch (candidate.kind) {
case "call_events_clear":
return Array.isArray(candidate.event_ids) &&
candidate.event_ids.length >= 1 &&
candidate.event_ids.length <= 100 &&
new Set(candidate.event_ids).size === candidate.event_ids.length &&
candidate.event_ids.every(
(id) => typeof id === "string" && UUID.test(id),
)
? (candidate as unknown as UiWritePlan)
: null;
case "call_events_clear_all":
return Object.keys(candidate).length === 3
? (candidate as unknown as UiWritePlan)
: null;
case "import_file_commit":
case "import_file_discard":
return typeof candidate.upload_id === "string" &&
UUID.test(candidate.upload_id) &&
Object.keys(candidate).length === 4
? (candidate as unknown as UiWritePlan)
: null;
case "import_file_upload":
return typeof candidate.operation_id === "string" &&
UUID.test(candidate.operation_id) &&
typeof candidate.path === "string" &&
typeof candidate.filename === "string" &&
candidate.filename === candidate.path.split(/[/\\]/).pop() &&
/^[0-9a-f]{64}$/.test(String(candidate.sha256)) &&
INPUT_KINDS.includes(
candidate.input_kind as (typeof INPUT_KINDS)[number],
) &&
Number.isSafeInteger(candidate.bytes) &&
(candidate.bytes as number) >= 0 &&
(candidate.bytes as number) <= 128 * 1024 * 1024
? (candidate as unknown as UiWritePlan)
: null;
default:
return null;
}
}
export function uiWriteExitCode(document: UiWriteOutcome): number {
return ["preview", "committed", "recovered"].includes(document.state) ? 0 : 1;
}
export function renderUiWriteText(document: UiWriteOutcome): string {
if (document.state === "preview") {
return `以下操作尚未执行;加 --confirm 后才会写入:\n${document.plan.kind}\nplan_digest:${document.plan.digest}\n`;
}
if (document.state === "committed" || document.state === "recovered") {
return `已提交:${document.operation_id}\n${JSON.stringify(document.receipt)}\n`;
}
const code = "code" in document ? document.code : "ui_write_state_invalid";
return `操作未完成:${document.state}(${code})\n`;
}
export async function executeCallEventsClear(
plan: Extract<UiWritePlan, { kind: "call_events_clear" }>,
operations: CoreOperations,
): Promise<UiWriteOutcome> {
const result = await operations.post<unknown>("/api/v1/call-events/clear", {
event_ids: plan.event_ids,
user_confirmed: true,
});
return outcome(result, plan.event_ids.join(","), ["removed"]);
}
export async function executeCallEventsClearAll(
plan: Extract<UiWritePlan, { kind: "call_events_clear_all" }>,
operations: CoreOperations,
): Promise<UiWriteOutcome> {
const result = await operations.post<unknown>(
"/api/v1/call-events/clear-all",
{ user_confirmed: true },
);
return outcome(result, "all", ["removed"]);
}
export async function executeImportFileCommit(
plan: Extract<UiWritePlan, { kind: "import_file_commit" }>,
operations: CoreOperations,
): Promise<UiWriteOutcome> {
const result = await operations.post<unknown>(
`/api/v1/import-files/${plan.upload_id}/commit`,
{ confirmed: true },
);
return outcome(result, plan.upload_id, [
"upload_id",
"filename",
"vendor",
"source_id",
"messages",
"conversations",
"human_messages",
"agent_messages",
"skipped_records",
"ignored_files",
"checksum",
"created_at",
"committed",
]);
}
export async function executeImportFileDiscard(
plan: Extract<UiWritePlan, { kind: "import_file_discard" }>,
operations: CoreDeleteOperations,
): Promise<UiWriteOutcome> {
const result = await operations.delete<unknown>(
`/api/v1/import-files/${plan.upload_id}`,
);
// Core answers 204 with no body. A successful delete is a committed
// operation even though there is no receipt to validate; requiring a body
// here would report every real discard as `ui_write_receipt_invalid`.
return outcome(result, plan.upload_id, null);
}
/** Upload one local file through the same recoverable Core staging flow the
* Web console uses. The operation id is part of the plan, so a retry after a
* dropped response resumes the same staging record instead of duplicating it.
*/
export async function executeImportFileUpload(
plan: Extract<UiWritePlan, { kind: "import_file_upload" }>,
operations: CoreUploadOperations,
): Promise<UiWriteOutcome> {
// Re-read the file at apply time and re-derive its identity. A plan that
// names a path whose content changed after review is refused rather than
// uploading something the caller never saw.
const reread = await readImportFileUpload(plan.path, plan.input_kind);
if (reread === null) {
return invalid("import_file_upload_unreadable");
}
const bytes = reread.bytes;
const checksum = createHash("sha256").update(bytes).digest("hex");
if (
bytes.byteLength !== plan.bytes ||
checksum !== plan.sha256 ||
reread.plan.digest !== plan.digest
) {
return invalid("import_file_upload_content_mismatch");
}
const result = await operations.uploadImportFile<unknown>({
bytes,
filename: plan.filename,
inputKind: plan.input_kind,
operationId: plan.operation_id,
});
if (result.state === "rejected") {
return {
schema_version: 1,
product: PRODUCT,
state: "rejected",
operation_id: plan.operation_id,
code: result.code,
};
}
if (result.state === "unknown") {
return {
schema_version: 1,
product: PRODUCT,
state: "unknown",
operation_id: plan.operation_id,
code: result.code,
};
}
const receipt =
result.data !== null &&
typeof result.data === "object" &&
!Array.isArray(result.data)
? (result.data as Record<string, unknown>)
: null;
if (
receipt === null ||
receipt.operation_id !== plan.operation_id ||
typeof receipt.state !== "string"
) {
return {
schema_version: 1,
product: PRODUCT,
state: "unknown",
operation_id: plan.operation_id,
code: "ui_write_receipt_invalid",
};
}
// Core reports the staged upload as `completed`; `pending`/`not_found` here
// means the operation record did not settle, so the result is not known.
if (receipt.state !== "completed") {
return {
schema_version: 1,
product: PRODUCT,
state: "unknown",
operation_id: plan.operation_id,
code: `import_upload_${receipt.state}`,
};
}
const uploadId =
typeof receipt.upload_id === "string" ? receipt.upload_id : undefined;
if (uploadId === undefined || !UUID.test(uploadId)) {
return {
schema_version: 1,
product: PRODUCT,
state: "unknown",
operation_id: plan.operation_id,
code: "ui_write_receipt_invalid",
};
}
return committed(plan.operation_id, {
operation_id: plan.operation_id,
upload_id: uploadId,
upload_state: receipt.upload_state ?? null,
});
}
function outcome(
result:
| Awaited<ReturnType<CoreOperations["post"]>>
| Awaited<ReturnType<CoreDeleteOperations["delete"]>>,
operationId: string,
required: readonly string[] | null,
): UiWriteOutcome {
if (result.state === "rejected") {
return {
schema_version: 1,
product: PRODUCT,
state: "rejected",
operation_id: operationId,
code: result.code,
};
}
if (result.state === "unknown") {
return {
schema_version: 1,
product: PRODUCT,
state: "unknown",
operation_id: operationId,
code: result.code,
};
}
if (required === null) {
return committed(operationId, {});
}
const data =
result.data !== null &&
typeof result.data === "object" &&
!Array.isArray(result.data)
? (result.data as Record<string, unknown>)
: null;
if (data === null || required.some((key) => !Object.hasOwn(data, key))) {
return {
schema_version: 1,
product: PRODUCT,
state: "unknown",
operation_id: operationId,
code: "ui_write_receipt_invalid",
};
}
return committed(operationId, data);
}
+15 -1
View File
@@ -13,7 +13,16 @@ const GLOBAL_OPTIONS: readonly CommandOption[] = [
summary: "显示帮助;可与 --json 组合输出机器可读目录。",
},
{ flag: "--version, -V", summary: "显示 CLI 版本。" },
{ flag: "--json", summary: "以 JSON 输出到 stdout,供脚本与 Agent 消费。" },
{
flag: "--json",
summary:
"以 JSON 输出到 stdout,供脚本与 Agent 消费;结果与失败都在 stdout,只有参数错误写 stderr。",
},
{
flag: "--data-dir <path>",
summary:
"本次使用的 MemHub 数据目录(绝对路径);默认 ONE_MEMORY_DATA_DIR > 安装回执 > ~/.one-memory。",
},
];
export function renderGeneralHelp(): string {
@@ -33,6 +42,9 @@ export function renderGeneralHelp(): string {
(exit) => ` ${exit.code} ${pad(exit.name, 17)}${exit.summary}`,
),
"",
"确认约定:只有不可逆操作(memories delete、models remove、delete-data、backup restore、graph-width --decision rebuild_with_new)需要 --yes;不加时只说明将要做什么,不做任何改动,exit 0。delete-data 与 backup restore 还要用 --confirm <path> 再写一遍目标路径。install/upgrade/uninstall/service-install/service-uninstall 不加 --yes 时只出计划。其它写命令直接执行。",
"异步操作返回 ID,用对应命令查进度:sources receipt、models receipt、memories add-status、write status。",
"目前仅支持 macOS 与 Linux 连接本机 Runtime。",
"stdout 只输出数据,进度与诊断写入 stderr。",
`运行 ${PRODUCT} help <command> 查看单个命令的参数。`,
"",
@@ -55,6 +67,8 @@ export function renderCommandHelp(command: CommandDescriptor): string {
);
}
lines.push(
"Global options: --json、--data-dir <path>(见 memhub help)。",
"",
"Exit codes:",
...EXIT_CODES.filter((exit) => command.exit_codes.includes(exit.code)).map(
(exit) => ` ${exit.code} ${pad(exit.name, 17)}${exit.summary}`,
+23 -11
View File
@@ -163,14 +163,14 @@ from one_memory.imports.supervisor import ImportSupervisor
from one_memory.library_search_window import LibrarySearchWindows, window_key
from one_memory.library_upgrade import LibraryUpgradeError, prepare_library
from one_memory.loop_watchdog import LoopWatchdog
from one_memory.memory_additions import (
LegacyMemoryAdditionReader,
MemoryAdditionStore,
memory_add_router,
)
from one_memory.memory_governance import (
MemoryGovernanceStore,
)
from one_memory.memory_notes import (
LegacyMemoryNoteReader,
MemoryNoteStore,
memory_note_router,
)
from one_memory.memory_qa import MemoryAnswerError
from one_memory.memory_queue import (
ExtractionWindowPreset,
@@ -1050,10 +1050,10 @@ def create_app(
priority=priority,
)
app.state.memory_note_store = MemoryNoteStore(
app.state.memory_addition_store = MemoryAdditionStore(
app.state.evidence_store,
(
LegacyMemoryNoteReader(
LegacyMemoryAdditionReader(
app.state.memory_task_store,
app.state.memory_governance_store,
)
@@ -1460,7 +1460,7 @@ def create_app(
Depends(local_api_guard(runtime_settings, valid_ui_session=ui_session_valid)),
Depends(require_external_endpoint),
]
app.include_router(memory_note_router(protected))
app.include_router(memory_add_router(protected))
app.include_router(file_import_router(protected))
app.include_router(sdk_context_router(protected))
@@ -2856,11 +2856,14 @@ def create_app(
offset: int = Query(default=0, ge=0),
limit: int = Query(default=LIBRARY_PAGE_SIZE, ge=1, le=500),
window_id: str | None = Query(default=None, min_length=1, max_length=64),
scene: Literal["all", "chat", "working", "coding"] = Query(default="all"),
) -> HySdkMemoryPage:
"""One page of memories across chat, working and coding.
Without `query`: `list_memories(scene="all")`, newest update first;
`total` is the SDK's count across the three scenes.
`total` is the SDK's count across the three scenes. `scene` narrows
the page to one scene: chat lists the chat store, working/coding list
that scene's experiences (the SDK lists experiences only, all active).
With `query`: keyword search over one SDK relevance window (see
`_library_search_page`). The first request (no `window_id`) searches
@@ -2887,11 +2890,12 @@ def create_app(
offset=offset,
limit=limit,
window_id=window_id,
scene=scene,
)
raw = await asyncio.to_thread(
client.list_memories,
user_id="local",
scene="all",
scene=scene,
statuses=list(statuses),
include_sources=[source] if source else None,
offset=offset,
@@ -4540,6 +4544,7 @@ async def _library_search_page(
offset: int,
limit: int,
window_id: str | None,
scene: str = "all",
) -> HySdkMemoryPage:
"""One page of a keyword search, cut from a cached result window.
@@ -4551,7 +4556,7 @@ async def _library_search_page(
SDK so edits and deletes show. The whole library is never listed in Core.
"""
windows = _library_search_windows(app)
key = window_key(query, source, statuses)
key = window_key(query, source, statuses, scene)
window = windows.get(window_id, key) if window_id else None
window_reset = window_id is not None and window is None
if window is None:
@@ -4576,6 +4581,13 @@ async def _library_search_page(
reader=HY_MEMORY_READER,
)
hits, held = library_keyword_hits(search_raw, query)
if scene != "all":
# One scene: keep that scene's matches from the same fused window.
hits = tuple(
hit
for hit in hits
if (hit.kind == "chat" if scene == "chat" else hit.variant == scene)
)
window = windows.put(key, hits, fetch if held >= fetch else None)
if window_reset:
offset = 0
+47 -2
View File
@@ -314,6 +314,10 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
details={
"runtime_build": runtime_build,
"confirmed": confirmed,
# The Runtime the upgrade replaced, as the orchestrator read it
# from that Runtime's install manifest; never guessed.
"source_version": _migration_source("ONE_MEMORY_MIGRATION_SOURCE_VERSION"),
"source_build": _migration_source("ONE_MEMORY_MIGRATION_SOURCE_BUILD"),
**authority_snapshot(database_path),
},
)
@@ -386,6 +390,7 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
stream.write(f"{step_names.get(step, step)}\n")
stream.flush()
restore: dict[str, object] = {}
try:
with _migration_lock(settings.data_dir):
result = run_startup_migration(
@@ -394,6 +399,7 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
sdk_version=HY_MEMORY_VERSION,
namespace=HY_MEMORY_NAMESPACE,
progress=progress,
restore_report=restore,
)
except Exception as error:
from one_memory.sdk_migration_verify import MemoryReadbackFailed
@@ -402,6 +408,10 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
failed: dict[str, object] = {"schema_version": 1, "state": "failed", "code": code}
if isinstance(error, MemoryReadbackFailed):
failed["readback"] = error.report.as_document()
# The migration's own code stays the result; the restore is secondary.
failed["data_restored"] = restore.get("state") == "restored"
if restore.get("state") == "failed":
failed["restore_code"] = restore.get("code")
write_migration_log(
settings.data_dir,
writer="core-sdk-migrate",
@@ -417,10 +427,13 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
"code_is_fallback": code == "sdk_migration_preflight_failed"
and not _names_code(error, code),
**({"readback": failed["readback"]} if "readback" in failed else {}),
"restore": restore.get("state", "not_attempted"),
**({"restore_code": restore["code"]} if "code" in restore else {}),
**authority_snapshot(database_path),
},
)
_write_restore(stream, as_json, failed, f"迁移未完成:{code}")
restored_text = ";数据已恢复到升级前的状态" if failed["data_restored"] else ""
_write_restore(stream, as_json, failed, f"迁移未完成:{code}{restored_text}")
return 1
document = result.as_document()
write_migration_log(
@@ -446,6 +459,14 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
return 0
_SAFE_SOURCE = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.+\-]{0,63}$")
def _migration_source(name: str) -> str:
value = os.environ.get(name, "").strip()
return value if _SAFE_SOURCE.fullmatch(value) else "unknown"
@contextlib.contextmanager
def _migration_lock(data_dir: Path) -> Iterator[None]:
"""One `sdk-migrate` per home at a time.
@@ -545,8 +566,18 @@ def _sdk_migration_status(options: Sequence[str], stream: TextIO) -> int:
as_json = "--json" in options
settings = load_settings()
database_path = settings.data_dir / "one-memory.sqlite3"
if not database_path.is_file():
from one_memory.sdk_migration_restore import restore_pending
if restore_pending(settings.data_dir):
# A restore was killed between its renames; the database may be
# elsewhere. Only `sdk-migrate` may finish it.
document: dict[str, object] = {
"schema_version": 1,
"authority": "legacy",
"state": "required",
}
elif not database_path.is_file():
document = {
"schema_version": 1,
"authority": "sdk",
"state": "not_required",
@@ -619,8 +650,22 @@ def _refuse_unmigrated_home(data_dir: Path) -> None:
"""
from one_memory.sdk_data_plane_migration import MemoryAuthority, SdkDataPlaneMigrationStore
from one_memory.sdk_migration_log import authority_snapshot, write_migration_log
from one_memory.sdk_migration_restore import restore_pending
database_path = data_dir / "one-memory.sqlite3"
if restore_pending(data_dir):
# A killed restore may have moved the database aside: serving now
# would create an empty home over the user's data.
write_migration_log(
data_dir,
writer="core-startup",
event="core.refused_legacy",
outcome="refused",
code="sdk_migration_required",
details={"stage": "serve_entry", "restore_pending": True},
)
print("sdk_migration_required", file=sys.stderr, flush=True)
raise SystemExit(SDK_MIGRATION_REQUIRED_EXIT)
if not database_path.is_file():
return
try:
@@ -227,7 +227,7 @@ def _trailing_user_turn_is_open(turn: list[EvidenceRecord]) -> bool:
return (
_opens_turn(opener)
and opener.content_kind is EvidenceContentKind.RAW_CONVERSATION
and not _is_memory_note(opener)
and not _is_hand_added_memory(opener)
)
@@ -254,7 +254,7 @@ def _is_hy_input_turn(record: EvidenceRecord) -> bool:
return _HOST_ENVELOPE.search(source[:2_000]) is None
def _is_memory_note(record: EvidenceRecord) -> bool:
def _is_hand_added_memory(record: EvidenceRecord) -> bool:
meta = record.origin_meta.get(EvidenceOrigin.MANUAL_IMPORT)
return (
record.source_id == "one-memory-manual"
@@ -229,6 +229,10 @@ class HySdkMemoryPage(BaseModel):
raise HySdkReadError("hy_memory_list_shape_unknown")
if raw.get("scene") == "all":
return cls._parse_all_scenes(raw, limit=limit, offset=offset, skip_invalid=skip_invalid)
if raw.get("scene") == "productivity":
return cls._parse_experiences(
raw, limit=limit, offset=offset, skip_invalid=skip_invalid
)
rows: list[object] = []
total = 0
for bucket_name, rows_name in (("vdb", "memories"), ("graph", "nodes")):
@@ -325,6 +329,51 @@ class HySdkMemoryPage(BaseModel):
skipped,
)
@classmethod
def _parse_experiences(
cls,
raw: Mapping[str, object],
*,
limit: int,
offset: int,
skip_invalid: bool,
) -> tuple[HySdkMemoryPage, int]:
"""``list_memories(scene="working"|"coding")``: that scene's experiences.
The SDK names the scene once, in ``variant``; each row is a bare
experience, so the kind and variant are added before parsing.
"""
rows = raw.get("experiences")
total = raw.get("total")
variant = raw.get("variant")
if (
not isinstance(rows, list)
or not isinstance(total, int)
or isinstance(total, bool)
or total < 0
or variant not in {"working", "coding"}
):
raise HySdkReadError("hy_memory_list_shape_unknown")
memories: list[HySdkMemoryRecord] = []
skipped = 0
for row in rows:
try:
if not isinstance(row, Mapping):
raise HySdkReadError("hy_memory_record_shape_unknown")
record = HySdkMemoryRecord.from_sdk(
{**row, "kind": "experience", "variant": variant}
)
except HySdkReadError:
if not skip_invalid:
raise
skipped += 1
continue
memories.append(record)
return (
cls(memories=tuple(memories), total=total, limit=limit, offset=offset),
skipped,
)
class HySdkRawRecord(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -7,8 +7,9 @@ between pages. The matches are kept here under an opaque ``window_id`` that
the client passes back with the next offset.
A window is bound to the filters it was searched with (query, source,
statuses). It expires after ``ttl_seconds`` without being read, and the store
keeps at most ``max_windows`` windows, dropping the least recently read first.
statuses, scene). It expires after ``ttl_seconds`` without being read, and the
store keeps at most ``max_windows`` windows, dropping the least recently read
first.
Only memory identities are kept, never content: each page is read fresh from
the SDK, so an edit or a delete shows on the next page read.
"""
@@ -27,11 +28,13 @@ from one_memory.hy_sdk_types import LibraryHit
DEFAULT_WINDOW_TTL_SECONDS = 300.0
DEFAULT_MAX_WINDOWS = 32
WindowKey = tuple[str, str | None, tuple[str, ...]]
WindowKey = tuple[str, str | None, tuple[str, ...], str]
def window_key(query: str, source: str | None, statuses: list[str]) -> WindowKey:
return (query, source, tuple(sorted(set(statuses))))
def window_key(
query: str, source: str | None, statuses: list[str], scene: str = "all"
) -> WindowKey:
return (query, source, tuple(sorted(set(statuses))), scene)
@dataclass(frozen=True)
@@ -105,10 +105,17 @@ def capture_stdio(source_id: str, stdin: TextIO, output: TextIO) -> int:
def sdk_migration_capture_fenced(data_dir: Path) -> bool:
"""Ignore stale fences; only a live Desktop migration pauses hook writes."""
"""Ignore stale fences; a live migration or a failed one pauses hook writes.
A failed upgrade migration leaves the fence `frozen` (no pid): the home was
put back as it was before the upgrade and takes no writes until the next
migration attempt, which replaces the fence and lifts it on success.
"""
marker = data_dir / SDK_MIGRATION_CAPTURE_FENCE
try:
document = json.loads(marker.read_text(encoding="utf-8"))
if isinstance(document, dict) and document.get("frozen") is True:
return True
pid = int(document["pid"])
if pid < 1:
raise ValueError("invalid migration fence pid")
@@ -1,5 +1,13 @@
"""Add one memory directly: `POST /api/v2/memories` and its status read.
The public API has no "notes": a hand-added memory is filed as manual Evidence
and turned into memories by the durable write queue. Storage keeps its
pre-rename identifiers (`memory_note_requests`, source `one-memory-manual`,
`input_mode=direct_memory_note`, conversation `note:<request_id>`) because they
are persisted and feed evidence ids; renaming them would change stored data.
"""
import asyncio
import json
import sqlite3
from collections.abc import Sequence
from datetime import UTC, datetime
@@ -28,7 +36,7 @@ from one_memory.memory_tasks import MemoryTaskStatus, MemoryTaskStore
from one_memory.secrets import SecretStoreUnavailableError
SOURCE_ID = "one-memory-manual"
MemoryNoteState = Literal[
MemoryAddState = Literal[
"waiting_for_model",
"waiting_for_permission",
"pending",
@@ -39,7 +47,7 @@ MemoryNoteState = Literal[
]
class MemoryNoteInput(BaseModel):
class MemoryAddInput(BaseModel):
model_config = ConfigDict(extra="forbid")
request_id: UUID
@@ -50,25 +58,25 @@ class MemoryNoteInput(BaseModel):
@classmethod
def meaningful_text(cls, value: str) -> str:
if not value.strip():
raise ValueError("memory_note_empty")
raise ValueError("memory_add_empty")
return value
class MemoryNoteReceipt(BaseModel):
class MemoryAddReceipt(BaseModel):
model_config = ConfigDict(extra="forbid")
evidence_id: str
text: str
scope: Literal["all-apps"] = "all-apps"
created_at: datetime
state: MemoryNoteState
state: MemoryAddState
task_id: str | None = None
memory_ids: list[str] = Field(default_factory=list)
error: str | None = None
class LegacyMemoryNoteReader:
"""Read pre-cutover note receipts without retaining any write runtime."""
class LegacyMemoryAdditionReader:
"""Read pre-cutover addition receipts without retaining any write runtime."""
def __init__(
self,
@@ -79,7 +87,7 @@ class LegacyMemoryNoteReader:
self.governance = governance
class MemoryNoteStore:
class MemoryAdditionStore:
def __init__(
self,
evidence: EvidenceStore,
@@ -92,34 +100,34 @@ class MemoryNoteStore:
self.evidence = evidence
self.processor = processor
# D52: the production composition root passes a UnifiedWriteIntake so a
# manual note reaches the durable queue like every other entry. It stays
# manual addition reaches the durable queue like every other entry. It stays
# optional so isolated tests can keep constructing the store directly.
self.intake = intake
self.sdk_authority = sdk_authority
self.write_queue = write_queue
def save(self, note: MemoryNoteInput) -> EvidenceRecord:
def save(self, addition: MemoryAddInput) -> EvidenceRecord:
now = datetime.now(UTC)
value = EvidenceInput(
source_id=SOURCE_ID,
conversation_id=f"note:{note.request_id}",
message_id=str(note.request_id),
conversation_id=f"note:{addition.request_id}",
message_id=str(addition.request_id),
role="user",
written_by=WrittenBy.PERSON,
created_at=now,
verbatim=note.text,
verbatim=addition.text,
origin_meta={
EvidenceOrigin.MANUAL_IMPORT: OriginMetadata(
raw_meta={
"input_mode": "direct_memory_note",
"recall_scope": note.scope,
"recall_scope": addition.scope,
}
)
},
)
if self.sdk_authority:
if self.intake is None or self.write_queue is None:
raise RuntimeError("sdk_memory_note_intake_unavailable")
raise RuntimeError("sdk_memory_add_intake_unavailable")
record = materialize_write_input(value, observed_at=now)
with main_database(self.write_queue.database_path).read() as database:
existing = database.execute(
@@ -129,7 +137,7 @@ class MemoryNoteStore:
if existing is not None:
current = self.require(record.evidence_id)
if current.verbatim != record.verbatim:
raise ValueError("memory_note_request_conflict")
raise ValueError("memory_add_conflict")
return current
receipt = self.intake.submit_records(
(record,),
@@ -159,13 +167,13 @@ class MemoryNoteStore:
(SOURCE_ID,),
).fetchone()
if grant and grant[0] is not None:
raise ValueError("memory_note_source_revoked")
raise ValueError("memory_add_source_revoked")
existing = db.execute(
"SELECT verbatim FROM evidence WHERE evidence_id=?", (value.evidence_id,)
).fetchone()
if existing:
if existing[0] != note.text:
raise ValueError("memory_note_request_conflict")
if existing[0] != addition.text:
raise ValueError("memory_add_conflict")
else:
self.evidence.put(value, connection=db)
db.execute(
@@ -218,10 +226,10 @@ class MemoryNoteStore:
(SOURCE_ID,),
).fetchone()
if grant is None or grant[0] is not None:
raise ValueError("memory_note_source_revoked")
raise ValueError("memory_add_source_revoked")
return record
def receipt(self, evidence_id: str) -> MemoryNoteReceipt:
def receipt(self, evidence_id: str) -> MemoryAddReceipt:
record = self.require(evidence_id)
if self.sdk_authority:
assert self.write_queue is not None
@@ -232,7 +240,7 @@ class MemoryNoteStore:
).fetchone()[0]
durable = self.write_queue.get(str(request_id))
durable_state = durable.state if durable is not None else "failed"
mapped: MemoryNoteState = {
mapped: MemoryAddState = {
"pending": "pending",
"running": "running",
"waiting_for_model": "waiting_for_model",
@@ -241,7 +249,7 @@ class MemoryNoteStore:
"ready" if durable is not None and durable.sdk_memory_ids else "no_memories"
),
}.get(durable_state, "failed")
return MemoryNoteReceipt(
return MemoryAddReceipt(
evidence_id=evidence_id,
text=sanitize_model_input(record.verbatim),
created_at=record.created_at or record.imported_at,
@@ -250,7 +258,7 @@ class MemoryNoteStore:
error=durable.detail if durable is not None else "write_missing",
)
if self.processor is None:
raise RuntimeError("legacy_memory_note_processor_unavailable")
raise RuntimeError("legacy_memory_add_processor_unavailable")
task = self.processor.tasks.task_for_evidence(evidence_id)
memory_ids = []
if task:
@@ -258,7 +266,7 @@ class MemoryNoteStore:
memory = self.processor.governance.get(output.memory_id, user_id="local")
if memory and memory.status is MemoryStatus.CONFIRMED:
memory_ids.append(memory.memory_id)
state: MemoryNoteState
state: MemoryAddState
if task is None or task.status == MemoryTaskStatus.WAITING_FOR_MODEL:
state = "waiting_for_model"
elif task.status == MemoryTaskStatus.WAITING_FOR_PERMISSION:
@@ -274,7 +282,7 @@ class MemoryNoteStore:
else:
state = "failed"
error = task.last_error_code if task else None
return MemoryNoteReceipt(
return MemoryAddReceipt(
evidence_id=record.evidence_id,
text=sanitize_model_input(record.verbatim),
created_at=record.created_at or record.imported_at,
@@ -284,33 +292,7 @@ class MemoryNoteStore:
error=error,
)
def list(self) -> list[MemoryNoteReceipt]:
if self.sdk_authority:
assert self.write_queue is not None
with main_database(self.write_queue.database_path).read() as database:
ids = [
str(row[0])
for row in database.execute(
"SELECT note_id FROM memory_note_requests ORDER BY created_at DESC LIMIT 50"
)
]
return [self.receipt(key) for key in ids]
with main_database(self.evidence.database_path).read() as db:
ids = [
row[0]
for row in db.execute(
"SELECT e.evidence_id,o.raw_meta_json FROM evidence e "
"JOIN source_grants g USING(user_id,source_id) "
"JOIN evidence_origins o USING(evidence_id) WHERE e.user_id='local' "
"AND e.source_id=? AND g.revoked_at IS NULL AND o.origin='manual_import' "
"ORDER BY e.imported_at DESC,e.evidence_id LIMIT 50",
(SOURCE_ID,),
)
if json.loads(row[1]).get("input_mode") == "direct_memory_note"
]
return [self.receipt(key) for key in ids]
def receipt_for_request(self, request_id: UUID) -> MemoryNoteReceipt:
def receipt_for_request(self, request_id: UUID) -> MemoryAddReceipt:
identity = str(request_id)
if self.sdk_authority:
probe = EvidenceInput(
@@ -338,107 +320,65 @@ class MemoryNoteStore:
return self.receipt(str(row[0]))
def memory_note_router(dependencies: Sequence[Depends]) -> APIRouter:
router = APIRouter(
prefix="/api/v1/memory-notes", tags=["memory-notes"], dependencies=dependencies
)
def memory_add_router(dependencies: Sequence[Depends]) -> APIRouter:
"""`POST /api/v2/memories` and `GET /api/v2/memories/additions/{request_id}`.
def store(request: Request) -> MemoryNoteStore:
return cast(MemoryNoteStore, request.app.state.memory_note_store)
Both paths are outside the single-segment `/api/v2/memories/{memory_id}`
routes; external (enveloped) callers are refused by
`require_external_endpoint` because neither is a broker read route.
"""
router = APIRouter(prefix="/api/v2/memories", tags=["memories"], dependencies=dependencies)
async def schedule(
notes: MemoryNoteStore,
record: EvidenceRecord,
*,
readmit: bool = False,
) -> MemoryNoteReceipt:
def store(request: Request) -> MemoryAdditionStore:
return cast(MemoryAdditionStore, request.app.state.memory_addition_store)
async def schedule(additions: MemoryAdditionStore, record: EvidenceRecord) -> None:
try:
if notes.sdk_authority:
return await asyncio.to_thread(notes.receipt, record.evidence_id)
if notes.processor is None:
raise RuntimeError("legacy_memory_note_processor_unavailable")
if notes.intake is None:
if additions.sdk_authority:
return
if additions.processor is None:
raise RuntimeError("legacy_memory_add_processor_unavailable")
if additions.intake is None:
# Isolated tests construct the store without a queue.
await notes.processor.enqueue_conversation_off_thread(record)
await additions.processor.enqueue_conversation_off_thread(record)
else:
# Production: submit to the durable queue first. The drain runs
# the same supervisor path, so create/process semantics are
# unchanged; replaying the same request returns the original
# `entry:<evidence_id>` job instead of a second HY call.
if readmit:
# `/{evidence_id}/process` is the explicit retry signal.
# It also revives pre-upgrade permission waiters.
await asyncio.to_thread(notes.intake.queue.readmit_parked)
submitted = await asyncio.to_thread(
notes.intake.submit_evidence,
# Production: submit to the durable queue. Replaying the same
# request returns the original `entry:<evidence_id>` job
# instead of a second HY call.
await asyncio.to_thread(
additions.intake.submit_evidence,
record,
first_round=1,
last_round=1,
priority="live",
)
if readmit:
await asyncio.to_thread(
notes.intake.readmit_cancelled_evidence,
record,
job_id=submitted.record.job_id,
first_round=1,
last_round=1,
priority="live",
)
except SecretStoreUnavailableError:
saved = await asyncio.to_thread(notes.receipt, record.evidence_id)
return saved.model_copy(
update={"state": "waiting_for_model", "error": "secure_storage_unavailable"}
)
return await asyncio.to_thread(notes.receipt, record.evidence_id)
# The receipt already reports the saved text; the queue retries
# once secure storage is back.
return
@router.post("", response_model=MemoryNoteReceipt, status_code=201)
async def create(
note: MemoryNoteInput,
@router.post("", response_model=MemoryAddReceipt, status_code=201)
async def add_memory(
addition: MemoryAddInput,
request: Request,
background: BackgroundTasks,
) -> MemoryNoteReceipt:
notes = store(request)
) -> MemoryAddReceipt:
additions = store(request)
try:
record = await asyncio.to_thread(notes.save, note)
record = await asyncio.to_thread(additions.save, addition)
except ValueError as error:
raise HTTPException(409, str(error)) from error
saved = await asyncio.to_thread(notes.receipt, record.evidence_id)
background.add_task(schedule, notes, record)
saved = await asyncio.to_thread(additions.receipt, record.evidence_id)
background.add_task(schedule, additions, record)
return saved
@router.get("", response_model=list[MemoryNoteReceipt])
def listing(request: Request) -> list[MemoryNoteReceipt]:
return store(request).list()
@router.get("/operations/{request_id}", response_model=MemoryNoteReceipt)
def operation(request_id: UUID, request: Request) -> MemoryNoteReceipt:
@router.get("/additions/{request_id}", response_model=MemoryAddReceipt)
def memory_add_status(request_id: UUID, request: Request) -> MemoryAddReceipt:
try:
return store(request).receipt_for_request(request_id)
except KeyError as error:
raise HTTPException(404, "memory_note_operation_not_found") from error
@router.get("/{evidence_id}", response_model=MemoryNoteReceipt)
def get(evidence_id: str, request: Request) -> MemoryNoteReceipt:
try:
return store(request).receipt(evidence_id)
except KeyError as error:
raise HTTPException(404, "memory_note_not_found") from error
raise HTTPException(404, "memory_add_not_found") from error
except ValueError as error:
raise HTTPException(409, str(error)) from error
@router.post("/{evidence_id}/process", response_model=MemoryNoteReceipt)
async def process(evidence_id: str, request: Request) -> MemoryNoteReceipt:
notes = store(request)
try:
record = await asyncio.to_thread(notes.require, evidence_id)
except KeyError as error:
raise HTTPException(404, "memory_note_not_found") from error
except ValueError as error:
raise HTTPException(409, str(error)) from error
# This route is the explicit "the condition that parked it may have
# changed" signal (the person just granted consent or fixed the model),
# which is when `readmit_parked` is meant to run.
return await schedule(notes, record, readmit=True)
return router
@@ -98,23 +98,6 @@ class UnifiedWriteIntake:
enqueued=durable.enqueued,
)
def readmit_cancelled_evidence(
self,
record: EvidenceRecord,
*,
job_id: str,
first_round: int,
last_round: int,
priority: WritePriority = "normal",
) -> int:
request = self._evidence_request(
record,
first_round=first_round,
last_round=last_round,
priority=priority,
)
return self.queue.readmit_cancelled_permission_confirmed(job_id, request)
@staticmethod
def _evidence_request(
record: EvidenceRecord,
@@ -0,0 +1,504 @@
"""Put a home back exactly as it was when a failed `sdk-migrate` started.
A migration that fails before SDK authority is committed has already changed
the home: schema migrations on `one-memory.sqlite3`, legacy work moved into the
durable queue, renamed Chroma collections under `hy-memory/governed-v2`, ledger
rows. The fixed release that follows must start from the original data, not
from that half-way state, so the runner restores the verified full-home bundle
it took before the first change (`restore point`).
The restore is staged and then swapped in with renames:
1. the bundle is verified (every file hashed against its manifest);
2. its database, config and SDK root are copied next to their live places and
verified again;
3. a journal lists every rename, then the live items (database with its
`-wal`/`-shm`, `config.json`, `hy-memory/governed-v2`) are renamed into
`backups/sdk-migration-failed/` and the staged copies are renamed in.
A rename either happened or did not, and each step is idempotent, so a process
killed during step 3 is finished by the next run (`finish_interrupted_restore`)
before anything else reads the home. Core refuses to serve and the status
probe reports `required` while a journal exists.
After SDK authority is committed nothing is restored: the home already belongs
to the SDK and the legacy tables may be gone.
"""
from __future__ import annotations
import errno
import hashlib
import json
import os
import shutil
import sys
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from one_memory.database_access import close_main_databases
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE
from one_memory.sdk_data_plane_migration import DataPlaneMigrationError
RESTORE_POINT = ".sdk-migration-restore-point.json"
RESTORE_STAGING = ".sdk-migration-restore"
JOURNAL = "journal.json"
FAILED_STATE = "sdk-migration-failed"
RESTORED_MARK = "restored.json"
DATABASE = "one-memory.sqlite3"
CONFIG = "config.json"
_DATABASE_SIDECARS = ("-wal", "-shm", "-journal")
_WRITER = "core-sdk-migrate"
class MigrationRestoreFailed(DataPlaneMigrationError):
"""The pre-migration data could not be put back; the home stays blocked."""
@dataclass(frozen=True)
class RestoreResult:
bundle_id: str
files: int
bytes: int
duration_ms: float
def as_document(self) -> dict[str, object]:
return {
"bundle_id": self.bundle_id,
"files": self.files,
"bytes": self.bytes,
"duration_ms": self.duration_ms,
}
def _home(data_dir: Path) -> Path:
return data_dir.resolve()
def _staging(home: Path) -> Path:
return home / RESTORE_STAGING
def _journal(home: Path) -> Path:
return _staging(home) / JOURNAL
def _sdk_staging(home: Path) -> Path:
return home / "hy-memory" / f".{HY_MEMORY_NAMESPACE}.restore-staging"
def failed_state_path(data_dir: Path) -> Path:
return _home(data_dir) / "backups" / FAILED_STATE
def _write_json(path: Path, document: dict[str, object]) -> None:
temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
temporary.write_text(json.dumps(document, sort_keys=True) + "\n", encoding="utf-8")
os.chmod(temporary, 0o600)
_fsync(temporary)
temporary.replace(path)
_fsync_directory(path.parent)
def _fsync(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _fsync_directory(path: Path) -> None:
# Windows cannot open a directory; its renames are durable on return.
try:
_fsync(path)
except OSError:
return
def _log(
data_dir: Path,
event: str,
outcome: str,
*,
migration_id: str | None,
code: str | None = None,
error: BaseException | None = None,
details: dict[str, object] | None = None,
) -> None:
from one_memory.sdk_migration_log import write_migration_log
write_migration_log(
data_dir,
writer=_WRITER,
event=event,
outcome=outcome,
migration_id=migration_id,
code=code,
error=error,
details=details,
)
# --- restore point -----------------------------------------------------------
def record_restore_point(data_dir: Path, *, migration_id: str, bundle_id: str) -> None:
"""Name the verified bundle that holds this home as the migration found it.
Written before the first change, so a run killed part-way is rolled back
by the next one instead of resumed.
"""
_write_json(
_home(data_dir) / RESTORE_POINT,
{
"schema_version": 1,
"migration_id": migration_id,
"bundle_id": bundle_id,
"created_at": datetime.now(UTC).isoformat(),
},
)
def read_restore_point(data_dir: Path) -> dict[str, str] | None:
try:
document = json.loads((_home(data_dir) / RESTORE_POINT).read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except (OSError, ValueError) as error:
raise MigrationRestoreFailed("sdk_migration_restore_point_invalid") from error
if (
not isinstance(document, dict)
or document.get("schema_version") != 1
or not isinstance(document.get("migration_id"), str)
or not isinstance(document.get("bundle_id"), str)
):
raise MigrationRestoreFailed("sdk_migration_restore_point_invalid")
return {"migration_id": document["migration_id"], "bundle_id": document["bundle_id"]}
def clear_restore_point(data_dir: Path) -> None:
(_home(data_dir) / RESTORE_POINT).unlink(missing_ok=True)
def restore_pending(data_dir: Path) -> bool:
"""A restore was interrupted between its renames; nothing may read the home."""
return _journal(_home(data_dir)).is_file()
# --- restore -----------------------------------------------------------------
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _release_handles() -> None:
"""Close what this process holds on the home; Windows cannot rename open files."""
close_main_databases()
chromadb = sys.modules.get("chromadb")
if chromadb is None:
return
try:
from chromadb.api.shared_system_client import SharedSystemClient
SharedSystemClient.clear_system_cache()
except Exception: # noqa: BLE001 - best effort; a held file fails the rename instead
return
def _verify_copy(copy: Path, expected: list[dict[str, object]], prefix: str) -> None:
"""The staged tree holds exactly the bundle's files under `prefix`."""
wanted = {
str(item["path"])[len(prefix) :]: item
for item in expected
if str(item["path"]).startswith(prefix)
}
present = (
{path.relative_to(copy).as_posix() for path in copy.rglob("*") if path.is_file()}
if copy.is_dir()
else set()
)
if present != set(wanted):
raise MigrationRestoreFailed("sdk_migration_restore_verify_failed")
for relative, item in wanted.items():
path = copy / relative
if path.stat().st_size != int(str(item["bytes"])) or _sha256(path) != item["sha256"]:
raise MigrationRestoreFailed("sdk_migration_restore_verify_failed")
def _plan(home: Path, *, config_staged: bool) -> list[tuple[str, str]]:
"""Every rename of the swap, as paths relative to the home, in order."""
failed = Path("backups") / FAILED_STATE
staging = Path(RESTORE_STAGING)
sdk_live = Path("hy-memory") / HY_MEMORY_NAMESPACE
steps: list[tuple[Path, Path]] = [
(Path(f"{DATABASE}{suffix}"), failed / "management" / f"{DATABASE}{suffix}")
for suffix in _DATABASE_SIDECARS
]
steps += [
(Path(DATABASE), failed / "management" / DATABASE),
(Path(CONFIG), failed / "management" / CONFIG),
(sdk_live, failed / "sdk" / HY_MEMORY_NAMESPACE),
(staging / "management" / DATABASE, Path(DATABASE)),
(_sdk_staging(home).relative_to(home), sdk_live),
]
if config_staged:
steps.append((staging / "management" / CONFIG, Path(CONFIG)))
return [(source.as_posix(), target.as_posix()) for source, target in steps]
def _apply(
home: Path,
steps: list[list[str]] | list[tuple[str, str]],
done: list[tuple[Path, Path]],
) -> None:
"""Run each rename once; a step whose source is already gone is skipped.
`done` collects the renames this call made, so a caller can undo them.
"""
for source_name, target_name in steps:
source, target = home / source_name, home / target_name
source_exists = source.exists() or source.is_symlink()
target_exists = target.exists() or target.is_symlink()
if not source_exists:
continue
if target_exists:
raise MigrationRestoreFailed("sdk_migration_restore_conflict")
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
source.replace(target)
done.append((source, target))
for directory in (home, home / "hy-memory"):
_fsync_directory(directory)
def _finish(home: Path, bundle_id: str) -> None:
# The restore point goes first: a kill after it leaves only the journal,
# whose steps are all done, and the next run just tidies up.
clear_restore_point(home)
shutil.rmtree(_sdk_staging(home), ignore_errors=True)
_journal(home).unlink(missing_ok=True)
shutil.rmtree(_staging(home), ignore_errors=True)
bundle = home / "backups" / f"{bundle_id}.sdk-bundle"
try:
# A later verified bundle supersedes this one (`prune_restored_bundles`).
_write_json(
bundle / RESTORED_MARK,
{"schema_version": 1, "restored_at": datetime.now(UTC).isoformat()},
)
except OSError:
return
def restore_pre_migration_state(
data_dir: Path,
*,
bundle_id: str,
migration_id: str | None,
reason: str,
) -> RestoreResult:
"""Put the restore-point bundle back in place of the half-migrated home.
The caller holds the migration lock with Runtime stopped and host capture
fenced. Raises `MigrationRestoreFailed`; before the swap the home is left as
the failure left it, and the restore point stays for the next run.
"""
home = _home(data_dir)
started = time.monotonic()
_log(
home,
"restore.started",
"started",
migration_id=migration_id,
details={"bundle_id": bundle_id, "reason": reason},
)
try:
result = _restore(home, bundle_id=bundle_id, started=started)
except BaseException as error:
code = getattr(error, "code", None)
if not (isinstance(code, str) and code.startswith("sdk_migration_restore")):
code = (
"sdk_migration_restore_disk_space_insufficient"
if isinstance(error, OSError) and error.errno in {errno.ENOSPC, errno.EDQUOT}
else "sdk_migration_restore_failed"
)
_log(
home,
"restore.failed",
"failed",
migration_id=migration_id,
code=code,
error=error,
details={
"bundle_id": bundle_id,
"reason": reason,
"duration_ms": (time.monotonic() - started) * 1000,
"swap_pending": restore_pending(home),
},
)
if not isinstance(error, Exception) or (
isinstance(error, MigrationRestoreFailed) and error.code == code
):
raise
raise MigrationRestoreFailed(code) from error
_log(
home,
"restore.finished",
"succeeded",
migration_id=migration_id,
details={**result.as_document(), "reason": reason},
)
return result
def _restore(home: Path, *, bundle_id: str, started: float) -> RestoreResult:
from one_memory.sdk_migration_bundle import inspect_sdk_migration_bundle
if restore_pending(home):
raise MigrationRestoreFailed("sdk_migration_restore_pending")
manifest = inspect_sdk_migration_bundle(data_dir=home, bundle_id=bundle_id)
bundle = home / "backups" / f"{bundle_id}.sdk-bundle"
listed = manifest.get("files")
files = [item for item in listed if isinstance(item, dict)] if isinstance(listed, list) else []
total = sum(int(str(item["bytes"])) for item in files)
if shutil.disk_usage(home).free < total:
raise MigrationRestoreFailed("sdk_migration_restore_disk_space_insufficient")
_release_handles()
staging = _staging(home)
sdk_staging = _sdk_staging(home)
shutil.rmtree(staging, ignore_errors=True)
shutil.rmtree(sdk_staging, ignore_errors=True)
(staging / "management").mkdir(parents=True, mode=0o700)
try:
shutil.copy2(bundle / "management" / DATABASE, staging / "management" / DATABASE)
config_staged = manifest.get("config") is not None
if config_staged:
shutil.copy2(bundle / "management" / CONFIG, staging / "management" / CONFIG)
_verify_copy(staging / "management", files, "management/")
sdk_source = bundle / "sdk" / HY_MEMORY_NAMESPACE
if not sdk_source.is_dir() or sdk_source.is_symlink():
raise MigrationRestoreFailed("sdk_migration_restore_bundle_incomplete")
sdk_staging.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(sdk_source, sdk_staging, symlinks=True)
_verify_copy(sdk_staging, files, f"sdk/{HY_MEMORY_NAMESPACE}/")
for path in (staging / "management").iterdir():
_fsync(path)
except BaseException:
shutil.rmtree(staging, ignore_errors=True)
shutil.rmtree(sdk_staging, ignore_errors=True)
raise
failed = failed_state_path(home)
# One failed state is kept for diagnosis; the newest replaces the last.
shutil.rmtree(failed, ignore_errors=True)
if failed.exists():
raise MigrationRestoreFailed("sdk_migration_restore_failed_state_busy")
steps = _plan(home, config_staged=config_staged)
_write_json(
_journal(home),
{"schema_version": 1, "bundle_id": bundle_id, "steps": [list(step) for step in steps]},
)
done: list[tuple[Path, Path]] = []
try:
_apply(home, steps, done)
except BaseException as error:
if not isinstance(error, Exception):
# Interrupted (a signal tearing the process down): leave the
# journal; the next run finishes the swap forward.
raise
try:
for source, target in reversed(done):
target.replace(source)
except OSError:
# The journal stays: the next run finishes the swap forward.
raise MigrationRestoreFailed("sdk_migration_restore_swap_interrupted") from error
_journal(home).unlink(missing_ok=True)
shutil.rmtree(staging, ignore_errors=True)
shutil.rmtree(sdk_staging, ignore_errors=True)
raise
_finish(home, bundle_id)
return RestoreResult(
bundle_id=bundle_id,
files=len(files),
bytes=total,
duration_ms=(time.monotonic() - started) * 1000,
)
def finish_interrupted_restore(data_dir: Path, *, migration_id: str | None = None) -> bool:
"""Complete a swap a killed process left between its renames. True if it did."""
home = _home(data_dir)
journal = _journal(home)
if not journal.is_file():
return False
started = time.monotonic()
try:
document = json.loads(journal.read_text(encoding="utf-8"))
steps = document["steps"]
bundle_id = str(document["bundle_id"])
if not isinstance(steps, list) or not all(
isinstance(step, list) and len(step) == 2 for step in steps
):
raise ValueError("journal steps invalid")
_release_handles()
_apply(home, steps, [])
_finish(home, bundle_id)
except Exception as error:
code = (
error.code
if isinstance(error, MigrationRestoreFailed)
else "sdk_migration_restore_resume_failed"
)
_log(
home,
"restore.failed",
"failed",
migration_id=migration_id,
code=code,
error=error,
details={"reason": "interrupted_swap", "swap_pending": True},
)
if isinstance(error, MigrationRestoreFailed):
raise
raise MigrationRestoreFailed(code) from error
_log(
home,
"restore.finished",
"succeeded",
migration_id=migration_id,
details={
"bundle_id": bundle_id,
"reason": "interrupted_swap",
"duration_ms": (time.monotonic() - started) * 1000,
},
)
return True
def prune_restored_bundles(data_dir: Path, *, keep: set[str]) -> int:
"""Drop bundles already restored, once a newer verified bundle exists.
Each holds the same pre-migration home a newer bundle now holds (capture
stayed fenced since), so it is only a second copy of it.
"""
backups = _home(data_dir) / "backups"
removed = 0
for mark in backups.glob(f"*.sdk-bundle/{RESTORED_MARK}"):
bundle = mark.parent
if bundle.name.removesuffix(".sdk-bundle") in keep:
continue
shutil.rmtree(bundle, ignore_errors=True)
removed += 0 if bundle.exists() else 1
return removed
def discard_failed_state(data_dir: Path) -> None:
"""The failed half-migrated copy is only useful until a migration succeeds."""
shutil.rmtree(failed_state_path(data_dir), ignore_errors=True)
@@ -18,6 +18,7 @@ import time
from collections.abc import Callable, Iterator
from contextlib import AbstractContextManager, contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -483,30 +484,50 @@ def _resumable_identity(database_path: Path) -> tuple[str, str]:
return migration_id, f"{migration_id}-pre-cutover"
@contextmanager
def capture_fence(data_dir: Path) -> Iterator[None]:
"""Host capture hooks return silently while this process is alive."""
def _write_capture_fence(data_dir: Path, document: dict[str, object]) -> None:
marker = data_dir / CAPTURE_FENCE
temporary = marker.with_name(f"{marker.name}.{os.getpid()}.tmp")
temporary.write_text(
json.dumps({"schema_version": 1, "pid": os.getpid()}) + "\n",
encoding="utf-8",
)
temporary.write_text(json.dumps(document) + "\n", encoding="utf-8")
os.chmod(temporary, 0o600)
temporary.replace(marker)
@contextmanager
def capture_fence(data_dir: Path) -> Iterator[None]:
"""Host capture hooks return silently while this process is alive.
A failed run leaves the fence frozen instead of lifting it: the home was
put back (or is half-migrated) and must not take new writes until the next
migration attempt replaces the fence with its own and succeeds.
"""
marker = data_dir / CAPTURE_FENCE
_write_capture_fence(data_dir, {"schema_version": 1, "pid": os.getpid()})
try:
yield
finally:
except BaseException:
try:
_write_capture_fence(
data_dir, {"schema_version": 1, "frozen": True, "reason": "sdk_migration_failed"}
)
except OSError:
marker.unlink(missing_ok=True)
raise
else:
marker.unlink(missing_ok=True)
temporary.unlink(missing_ok=True)
finally:
marker.with_name(f"{marker.name}.{os.getpid()}.tmp").unlink(missing_ok=True)
def _ensure_bundle(*, data_dir: Path, bundle_id: str, runtime_build: str) -> dict[str, object]:
def _ensure_bundle(
*, data_dir: Path, bundle_id: str, runtime_build: str
) -> tuple[dict[str, object], bool]:
"""The verified bundle, and whether this call created it."""
from one_memory.sdk_migration_bundle import (
MigrationBundleRejected,
create_sdk_migration_bundle,
)
created = True
try:
create_sdk_migration_bundle(
data_dir=data_dir,
@@ -517,7 +538,67 @@ def _ensure_bundle(*, data_dir: Path, bundle_id: str, runtime_build: str) -> dic
except MigrationBundleRejected as error:
if error.code != "sdk_migration_bundle_exists":
raise
return inspect_sdk_migration_bundle(data_dir=data_dir, bundle_id=bundle_id)
created = False
return inspect_sdk_migration_bundle(data_dir=data_dir, bundle_id=bundle_id), created
def _restore_point_bundle(
*, data_dir: Path, migration_id: str, bundle_id: str, created: bool, runtime_build: str
) -> str:
"""The bundle that holds this home exactly as this run found it.
The cutover bundle qualifies when this run created it. One left by an
earlier run (an older build's blocked ledger row) may predate later writes,
so restoring it could drop them; a fresh restore-point bundle is taken.
"""
if created:
return bundle_id
from one_memory.sdk_migration_bundle import create_sdk_migration_bundle
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
restore_id = f"{migration_id}-restore-point-{stamp}"
create_sdk_migration_bundle(
data_dir=data_dir,
bundle_id=restore_id,
runtime_build=runtime_build,
runtime_stopped=True,
)
inspect_sdk_migration_bundle(data_dir=data_dir, bundle_id=restore_id)
return restore_id
def _recover_interrupted_attempt(data_dir: Path, database_path: Path, log: _StepLog) -> None:
"""Roll back what a killed earlier run left, before anything reads the home."""
from one_memory.sdk_migration_restore import (
MigrationRestoreFailed,
clear_restore_point,
finish_interrupted_restore,
read_restore_point,
restore_pre_migration_state,
)
finish_interrupted_restore(data_dir)
point = read_restore_point(data_dir)
if point is None:
return
with log.step("preflight.interrupted_attempt") as step:
try:
authority = SdkDataPlaneMigrationStore(database_path).authority()
except Exception as error:
# Never restore over a home that may already be on SDK authority.
raise MigrationRestoreFailed("sdk_migration_restore_authority_unknown") from error
step["authority"] = authority.value
if authority is MemoryAuthority.SDK:
clear_restore_point(data_dir)
step["restored"] = False
return
restore_pre_migration_state(
data_dir,
bundle_id=point["bundle_id"],
migration_id=point["migration_id"],
reason="interrupted_attempt",
)
step["restored"] = True
def _vacuum(database_path: Path) -> None:
@@ -537,14 +618,28 @@ def run_startup_migration(
namespace: str,
progress: Callable[[str], None] = lambda _step: None,
open_client: Callable[[ModelProfileSnapshot], AbstractContextManager[Any]] | None = None,
restore_report: dict[str, object] | None = None,
) -> StartupMigrationResult:
"""Bring one stopped home to SDK authority. Safe to repeat after any failure.
The caller must stop Runtime and keep its service from restarting.
`open_client` yields a public SDK client configured as the Runtime will be;
tests replace it.
A failure before SDK authority is committed puts the home back exactly as
this run found it (`sdk_migration_restore`), so the next run, possibly of a
fixed release, starts from the original data. The original exception is
re-raised either way; `restore_report` learns what the restore did
(`state`: `restored`, `failed` with its own `code`, or `skipped`).
"""
from one_memory.sdk_migration_restore import read_restore_point, restore_pending
database_path = data_dir / "one-memory.sqlite3"
log = _StepLog(data_dir)
# Before anything reads the home: a run killed part-way is rolled back.
if restore_pending(data_dir) or read_restore_point(data_dir) is not None:
with capture_fence(data_dir):
_recover_interrupted_attempt(data_dir, database_path, log)
if not database_path.is_file():
return StartupMigrationResult(state="not_required")
@@ -553,7 +648,6 @@ def run_startup_migration(
return open_client(profile)
return runtime_sdk_client(database_path, data_dir, profile)
log = _StepLog(data_dir)
store = SdkDataPlaneMigrationStore(database_path)
with log.step("preflight.authority_read") as step:
authority, authority_migration = store.authority_state()
@@ -613,6 +707,12 @@ def run_startup_migration(
from one_memory.database_access import close_main_databases
from one_memory.memory_server.migration_runner import SqliteMigrationStore, migrate
from one_memory.memory_server.migrations import MIGRATIONS
from one_memory.sdk_migration_restore import (
clear_restore_point,
discard_failed_state,
prune_restored_bundles,
record_restore_point,
)
with log.step("preflight.resumable_identity") as step:
migration_id, bundle_id = _resumable_identity(database_path)
@@ -630,66 +730,105 @@ def run_startup_migration(
raise DataPlaneMigrationError("sdk_migration_model_profile_required")
log.check("preflight.model_profile_check", None)
with capture_fence(data_dir):
progress("bundle")
with log.step("bundle.ensure") as step:
manifest = _ensure_bundle(
data_dir=data_dir,
bundle_id=bundle_id,
runtime_build=runtime_build,
)
step["bundle_id"] = bundle_id
progress("prepare")
with log.step("prepare.schema_migrate") as step:
asyncio.run(migrate(SqliteMigrationStore(database_path), MIGRATIONS))
close_main_databases()
step["schema_version"] = MIGRATIONS[-1].version
# Builds through 19 left a staged SDK copy beside the original store.
# Settling it before the bundle means the bundle holds the original
# store, and a restore never brings the staged copy back.
with log.step("prepare.recover_abandoned_staging"):
recover_abandoned_staging(
data_dir=data_dir,
database_path=database_path,
namespace=namespace,
)
with log.step("prepare.ledger_start"):
store.start(
migration_id=migration_id,
source_runtime_build=str(manifest.get("runtime_build") or ""),
target_sdk_version=sdk_version,
source_schema_version=MIGRATIONS[-1].version,
namespace_fingerprint=hashlib.sha256(namespace.encode()).hexdigest(),
backend_fingerprint=hashlib.sha256(b"chroma:kuzu:sqlite").hexdigest(),
backup_bundle_id=bundle_id,
progress("bundle")
with log.step("bundle.ensure") as step:
manifest, created = _ensure_bundle(
data_dir=data_dir,
bundle_id=bundle_id,
runtime_build=runtime_build,
)
with log.step("prepare.legacy_work") as step:
preparation = prepare_legacy_work_for_cutover(database_path, step)
step.update(preparation.as_document())
reattached = ()
readback = None
if profile is not None:
progress("vectors")
with log.step("vectors.reattach") as step:
reattached = reattach_legacy_vectors(data_dir, profile)
step["collections"] = len(reattached)
if not legacy.empty:
assert profile is not None
progress("verify")
with log.step("verify.readback") as step:
with client_for(profile) as client:
readback = verify_readback(client, legacy)
step.update(readback.as_document())
if not readback.passed:
store.set_phase(migration_id, phase="readback_failed", state="blocked")
raise MemoryReadbackFailed(readback)
with log.step("prepare.mark_prepared"):
store.set_phase(migration_id, phase=PREPARED_PHASE, state="ready")
progress("cutover")
with log.step("cutover.commit"):
commit_sdk_cutover(
step["bundle_id"] = bundle_id
step["created"] = created
with log.step("bundle.restore_point") as step:
restore_bundle = _restore_point_bundle(
data_dir=data_dir,
migration_id=migration_id,
bundle_id=bundle_id,
created=created,
runtime_build=runtime_build,
)
record_restore_point(data_dir, migration_id=migration_id, bundle_id=restore_bundle)
step["bundle_id"] = restore_bundle
step["pruned"] = prune_restored_bundles(data_dir, keep={bundle_id, restore_bundle})
# From here until SDK authority is committed, a failure puts the
# restore point back. Only `cutover.commit` can fail after its own
# commit; the database decides then.
cutover_started = False
try:
progress("prepare")
with log.step("prepare.schema_migrate") as step:
asyncio.run(migrate(SqliteMigrationStore(database_path), MIGRATIONS))
close_main_databases()
step["schema_version"] = MIGRATIONS[-1].version
with log.step("prepare.ledger_start"):
store.start(
migration_id=migration_id,
source_runtime_build=str(manifest.get("runtime_build") or ""),
target_sdk_version=sdk_version,
source_schema_version=MIGRATIONS[-1].version,
namespace_fingerprint=hashlib.sha256(namespace.encode()).hexdigest(),
backend_fingerprint=hashlib.sha256(b"chroma:kuzu:sqlite").hexdigest(),
backup_bundle_id=bundle_id,
)
with log.step("prepare.legacy_work") as step:
preparation = prepare_legacy_work_for_cutover(database_path, step)
step.update(preparation.as_document())
reattached = ()
readback = None
if profile is not None:
progress("vectors")
with log.step("vectors.reattach") as step:
reattached = reattach_legacy_vectors(data_dir, profile)
step["collections"] = len(reattached)
if not legacy.empty:
assert profile is not None
progress("verify")
with log.step("verify.readback") as step:
with client_for(profile) as client:
readback = verify_readback(client, legacy)
step.update(readback.as_document())
if not readback.passed:
store.set_phase(migration_id, phase="readback_failed", state="blocked")
raise MemoryReadbackFailed(readback)
with log.step("prepare.mark_prepared"):
store.set_phase(migration_id, phase=PREPARED_PHASE, state="ready")
progress("cutover")
cutover_started = True
with log.step("cutover.commit"):
commit_sdk_cutover(
data_dir=data_dir,
database_path=database_path,
migration_id=migration_id,
bundle_id=bundle_id,
sdk_version=sdk_version,
)
except Exception:
_restore_after_failure(
data_dir=data_dir,
database_path=database_path,
migration_id=migration_id,
bundle_id=bundle_id,
sdk_version=sdk_version,
bundle_id=restore_bundle,
cutover_started=cutover_started,
report=restore_report,
)
raise
# SDK authority is committed: from here on the home is never restored.
clear_restore_point(data_dir)
if restore_bundle != bundle_id:
shutil.rmtree(
data_dir.resolve() / "backups" / f"{restore_bundle}.sdk-bundle",
ignore_errors=True,
)
discard_failed_state(data_dir)
progress("cleanup")
with log.step("cleanup.drop_legacy_tables") as step:
dropped = resume_legacy_cleanup(data_dir=data_dir, database_path=database_path)
@@ -706,3 +845,52 @@ def run_startup_migration(
reattached=reattached,
readback=readback,
)
def _restore_after_failure(
*,
data_dir: Path,
database_path: Path,
migration_id: str,
bundle_id: str,
cutover_started: bool,
report: dict[str, object] | None,
) -> None:
"""Put the restore point back after a pre-cutover failure. Never raises.
The caller re-raises the migration's own error: the restore is secondary,
so its outcome only goes to the log and to `report`.
"""
from one_memory.sdk_migration_log import write_migration_log
from one_memory.sdk_migration_restore import restore_pre_migration_state
report = {} if report is None else report
if cutover_started:
try:
committed = SdkDataPlaneMigrationStore(database_path).authority() is MemoryAuthority.SDK
except Exception: # noqa: BLE001 - never restore over an unknown authority
committed = True
if committed:
write_migration_log(
data_dir,
writer="core-sdk-migrate",
event="restore.skipped",
outcome="succeeded",
migration_id=migration_id,
details={"reason": "authority_committed_or_unknown"},
)
report["state"] = "skipped"
return
try:
restore_pre_migration_state(
data_dir,
bundle_id=bundle_id,
migration_id=migration_id,
reason="migration_failed",
)
except Exception as error: # noqa: BLE001 - the migration's own failure is raised
code = getattr(error, "code", None)
report["state"] = "failed"
report["code"] = code if isinstance(code, str) else "sdk_migration_restore_failed"
return
report["state"] = "restored"
+3 -2
View File
@@ -152,13 +152,13 @@
| `test_measure_c1_import_scale.py` | `contract` | The import measurement must not report fabricated or conflated counters. |
| `test_measure_c1_loop_blocking_contract.py` | `integration` | Contract guards for the C1 event-loop blocking measurement. |
| `test_memhub_display_identity.py` | `contract` | Display name MemHub must not fork the technical identity. |
| `test_memory_additions.py` | `integration` | Add one memory directly: `POST /api/v2/memories` and `GET /api/v2/memories/additions/{id}`. |
| `test_memory_batch_extraction.py` | `contract` | 覆盖 batch extractor keeps only unique known evidence ids、batch extractor maps provider failure to machine error 场景。 |
| `test_memory_citation_query_plan.py` | `contract` | Correlated citation checks must look links up by memory, not scan by 'active'. |
| `test_memory_content_import.py` | `integration` | 覆盖 existing memory preview keeps unknown author and exact original、invalid existing memory records never leave a partial upload、memory content requires explicit mode and rejects vendor guessing 等 5 个场景。 |
| `test_memory_evidence_freshness.py` | `contract` | 新鲜度拆除后:读路径恒为 current;证据修订 / 畸形 proof / segment ref 不再藏记忆或 503。 |
| `test_memory_freshness_integration_review.py` | `integration` | Independent synthetic read-path review; no lifespan, model or user database. |
| `test_memory_library_all_scenes.py` | `integration` | The v2 memory library across chat, working and coding memories. |
| `test_memory_notes.py` | `integration` | 覆盖 empty library can save a note without a matter or configured model、note requires authentication explicit scope and nonempty content、saved note evidence returns raw and consent reference from same record 等 6 个场景。 |
| `test_memory_qa.py` | `contract` | 覆盖 search terms keep meaningful single chinese character、streamed answer emits recall then incremental grounded text、governance change during generation stops the old answer 等 14 个场景。 |
| `test_memory_qa_api.py` | `integration` | 覆盖 ask stream endpoint emits recall deltas and final、ask stream endpoint requires the local token 场景。 |
| `test_memory_qa_permissions.py` | `contract` | 覆盖 initial refusal precedes all secret access and network、invalid authorization result fails before secret access、authorization receives exact question and only outbound grounding 等 16 个场景。 |
@@ -249,6 +249,7 @@
| `test_sdk_memory_qa.py` | `contract` | 覆盖 sdk ask propagates sdk search failure without legacy fallback、sdk ask revalidates memory before returning extract、sdk ask uses native sdk memory without governance fields 场景。 |
| `test_sdk_migration_bundle.py` | `contract` | SDK-authority cutover requires a verified management + SDK data-plane bundle. |
| `test_sdk_migration_log.py` | `integration` | `sdk-migration.log` explains an unfinished upgrade, and a legacy home is never served. |
| `test_sdk_migration_restore.py` | `contract` | A failed upgrade migration puts the home back exactly as it found it. |
| `test_sdk_verbatim_fallback.py` | `contract` | D98: the SDK's verbatim fallback must stay off. |
| `test_search_without_profile.py` | `contract` | What 'semantic_search' should do once the profile that filled the store is gone. |
| `test_secondary_source_adapters.py` | `contract` | 覆盖 pi and qwenwork keep tool and sidechain content out of the persons words、qwen code reads only the transcript beside its bookkeeping、qwen native parts preserve visible text not reasoning or tools 等 22 个场景。 |
@@ -306,4 +307,4 @@
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
共 301 个测试文件。
共 302 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 301
EXPECTED_TEST_FILES_SHA256 = "1e5f3a85f124a26ed67861e07fba7c670512a82139db64c1b7ba767ca37ed563"
EXPECTED_TEST_FILE_COUNT = 302
EXPECTED_TEST_FILES_SHA256 = "07707528025ac134e46c93ebef12335e852df1ed0f52f8beb62abb3b4d1b0e97"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -78,8 +78,8 @@ INTEGRATION_FILES = frozenset(
"test_measure_c1_loop_blocking_contract.py",
"test_memory_content_import.py",
"test_memory_freshness_integration_review.py",
"test_memory_additions.py",
"test_memory_library_all_scenes.py",
"test_memory_notes.py",
"test_memory_qa_api.py",
"test_memory_server_durable_queue.py",
"test_memory_supersede.py",
@@ -34,6 +34,7 @@ import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
@@ -201,10 +202,10 @@ def main() -> int:
receipt["reason"] = "runtime_not_ready"
return 2
_run(cli, ["init", "--source", "workbuddy", "--json"], env)
plan = _json(_run(cli, ["setup", "plan", "--source", "workbuddy", "--json"], env).stdout)
plan_file = work / "plan-inner.json"
plan_file.write_text(json.dumps(plan.get("plan", {})), encoding="utf-8")
_run(cli, ["sources", "connect", "workbuddy", "--json"], env)
# No plan files: the connect request id is the idempotency key, minted
# here so the receipt can be read while the import runs.
plan = {"plan": {"request_id": str(uuid.uuid4())}}
idle_latencies = []
idle_errors: list[int] = []
@@ -217,7 +218,15 @@ def main() -> int:
started = time.perf_counter()
imported = _run(
cli,
["sources", "import", "--plan-file", str(plan_file), "--confirm"],
[
"sources",
"connect",
"workbuddy",
"--import",
"--request-id",
plan["plan"]["request_id"],
"--json",
],
env,
)
during_latencies = []
@@ -36,6 +36,7 @@ import sqlite3
import subprocess
import tempfile
import time
import uuid
from pathlib import Path
from subprocess import Popen
@@ -266,14 +267,22 @@ def _receipt(
def _import_plan(cli: Path, env: dict[str, str], work: Path, *sources: str) -> dict:
args = ["init"]
for source in sources:
args.extend(["--source", source])
args.append("--json")
plan = _body(_run(cli, args, env).stdout)
# Grant only (processing stays frozen); the imports below connect again
# with --import.
plan = _body(_run(cli, ["sources", "connect", *sources, "--json"], env).stdout)
return plan
def _connect_plan(source: str) -> dict:
"""The request a `sources connect --import` will carry.
There are no plan files any more: the CLI connects directly and the
request id is the idempotency key, so the probe mints it up front to read
the receipt afterwards.
"""
return {"plan": {"request_id": str(uuid.uuid4()), "source_ids": [source]}}
def _submit_plan(
cli: Path,
env: dict[str, str],
@@ -282,9 +291,20 @@ def _submit_plan(
name: str,
wait_timeout: float = CONVERGENCE_TIMEOUT_SECONDS,
) -> dict:
path = work / f"{name}-inner.json"
path.write_text(json.dumps(plan.get("plan", {})), encoding="utf-8")
result = _run(cli, ["sources", "import", "--plan-file", str(path), "--confirm"], env)
body = plan.get("plan", {})
result = _run(
cli,
[
"sources",
"connect",
*body.get("source_ids", []),
"--import",
"--request-id",
str(body.get("request_id")),
"--json",
],
env,
)
return {
"exit": result.returncode,
"receipt": _receipt(cli, env, plan, wait_timeout=wait_timeout),
@@ -394,10 +414,21 @@ def _preserve_run_state(dest: Path, data: Path, receipt: dict, args) -> dict:
def _busy_negative_arm(cli: Path, env: dict[str, str], work: Path, source: str) -> dict:
"""Prove a concurrent history request is refused without a second job."""
plan = _body(_run(cli, ["setup", "plan", "--source", source, "--json"], env).stdout)
path = work / "busy-inner.json"
path.write_text(json.dumps(plan.get("plan", {})), encoding="utf-8")
result = _run(cli, ["sources", "import", "--plan-file", str(path), "--confirm"], env)
plan = _connect_plan(source)
body = plan["plan"]
result = _run(
cli,
[
"sources",
"connect",
source,
"--import",
"--request-id",
body["request_id"],
"--json",
],
env,
)
return {
"exit": result.returncode,
"receipt": _receipt(cli, env, plan, wait_terminal=False),
@@ -408,7 +439,7 @@ def _run_busy_negative(
cli: Path, env: dict[str, str], work: Path, source: str, timeout: float
) -> int:
"""A separate arm: make a real long-running job, then require busy/no-job."""
first_plan = _body(_run(cli, ["setup", "plan", "--source", source, "--json"], env).stdout)
first_plan = _connect_plan(source)
first = _submit_plan(cli, env, work, first_plan, "busy-first", timeout)
first_job = _nested(first, "receipt").get("jobs", [])
if not first_job or first_job[0].get("status") not in {"running", "pending"}:
@@ -583,22 +614,16 @@ def main() -> int:
if args.busy_negative:
return _run_busy_negative(cli, env, work, args.source, args.wait_timeout)
# Authorize first, then use the same headless plan shape for every
# import. `init --json` is a discovery/authorization receipt and its
# nested shape differs from `setup plan`.
# Authorize first, then connect with --import for every import.
_import_plan(cli, env, work, args.source)
first_plan = _body(
_run(cli, ["setup", "plan", "--source", args.source, "--json"], env).stdout
)
first_plan = _connect_plan(args.source)
receipt["first"] = _submit_plan(cli, env, work, first_plan, "first", args.wait_timeout)
if _nested(receipt, "first", "receipt").get("_probe_timeout"):
receipt["state"] = "failed"
receipt["reason"] = "convergence_timeout"
return 1
second_plan = _body(
_run(cli, ["setup", "plan", "--source", args.source, "--json"], env).stdout
)
second_plan = _connect_plan(args.source)
receipt["second"] = _submit_plan(cli, env, work, second_plan, "second", args.wait_timeout)
if _nested(receipt, "second", "receipt").get("_probe_timeout"):
receipt["state"] = "failed"
@@ -607,9 +632,7 @@ def main() -> int:
with copied[0].open("a", encoding="utf-8") as stream:
stream.write(json.dumps(_append_record(args.source)) + "\n")
append_plan = _body(
_run(cli, ["setup", "plan", "--source", args.source, "--json"], env).stdout
)
append_plan = _connect_plan(args.source)
receipt["append"] = _submit_plan(cli, env, work, append_plan, "append", args.wait_timeout)
if _nested(receipt, "append", "receipt").get("_probe_timeout"):
receipt["state"] = "failed"
@@ -631,9 +654,7 @@ def main() -> int:
text=True,
)
receipt["restart"] = _wait_ready(cli, env, process)
restart_plan = _body(
_run(cli, ["setup", "plan", "--source", args.source, "--json"], env).stdout
)
restart_plan = _connect_plan(args.source)
receipt["restart_import"] = _submit_plan(
cli, env, work, restart_plan, "restart", args.wait_timeout
)
@@ -642,18 +663,10 @@ def main() -> int:
receipt["reason"] = "convergence_timeout"
return 1
freeze_plan = _body(
_run(
cli,
["sync", "pause", "--source", args.source, "--json"],
env,
).stdout
)
freeze_file = work / "freeze-inner.json"
freeze_file.write_text(json.dumps(freeze_plan.get("plan", {})), encoding="utf-8")
# `sync pause` applies directly (there is no plan/apply pair).
applied = _run(
cli,
["sync", "apply", "--plan-file", str(freeze_file), "--confirm"],
["sync", "pause", "--source", args.source, "--json"],
env,
)
listed = _body(_run(cli, ["sources", "list", "--json"], env).stdout)
@@ -200,6 +200,7 @@ def test_sdk_migrate_failure_is_one_named_code(tmp_path, monkeypatch) -> None:
"schema_version": 1,
"state": "failed",
"code": "sdk_migration_disk_space_insufficient",
"data_restored": False,
}
@@ -1,3 +1,5 @@
"""Add one memory directly: `POST /api/v2/memories` and `GET /api/v2/memories/additions/{id}`."""
import asyncio
import json
import sqlite3
@@ -20,7 +22,7 @@ def application(tmp_path):
Settings(
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr("note-test"),
api_token=SecretStr("memory-add-test"),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
@@ -39,56 +41,59 @@ def client_for(app):
return AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer note-test"},
headers={"Authorization": "Bearer memory-add-test"},
)
@pytest.mark.fresh_sdk
def test_empty_library_can_save_a_note_without_a_matter_or_configured_model(tmp_path):
def test_empty_library_can_add_a_memory_without_a_matter_or_configured_model(tmp_path):
async def scenario():
app = application(tmp_path)
async with app.router.lifespan_context(app), client_for(app) as client:
assert legacy_tables_present(app.state.settings.data_dir / "one-memory.sqlite3") == ()
note = {
addition = {
"request_id": str(uuid4()),
"text": "请记住:项目周会不排在周五。",
"scope": "all-apps",
}
response = await client.post("/api/v1/memory-notes", json=note)
response = await client.post("/api/v2/memories", json=addition)
assert response.status_code == 201
saved = response.json()
assert saved["state"] == "pending"
assert saved["memory_ids"] == []
record = app.state.memory_note_store.require(saved["evidence_id"])
record = app.state.memory_addition_store.require(saved["evidence_id"])
assert record.source_id == "one-memory-manual"
assert record.role == "user" and record.written_by == WrittenBy.PERSON
assert record.verbatim == note["text"] and record.created_at is not None
again = await client.post("/api/v1/memory-notes", json=note)
assert record.verbatim == addition["text"] and record.created_at is not None
again = await client.post("/api/v2/memories", json=addition)
assert again.json()["evidence_id"] == saved["evidence_id"]
operation = await client.get(f"/api/v1/memory-notes/operations/{note['request_id']}")
assert operation.status_code == 200
assert operation.json()["evidence_id"] == saved["evidence_id"]
assert (
await client.get(f"/api/v1/memory-notes/operations/{uuid4()}")
).status_code == 404
status = await client.get(f"/api/v2/memories/additions/{addition['request_id']}")
assert status.status_code == 200
assert status.json()["evidence_id"] == saved["evidence_id"]
missing = await client.get(f"/api/v2/memories/additions/{uuid4()}")
assert missing.status_code == 404
assert missing.json()["detail"] == "memory_add_not_found"
conflict = await client.post(
"/api/v1/memory-notes", json={**note, "text": "Changed content"}
"/api/v2/memories", json={**addition, "text": "Changed content"}
)
assert conflict.status_code == 409
assert conflict.json()["detail"] == "memory_add_conflict"
assert (
app.state.memory_note_store.require(saved["evidence_id"]).verbatim == note["text"]
app.state.memory_addition_store.require(saved["evidence_id"]).verbatim
== addition["text"]
)
assert legacy_tables_present(app.state.settings.data_dir / "one-memory.sqlite3") == ()
restarted = application(tmp_path)
async with restarted.router.lifespan_context(restarted), client_for(restarted) as client:
notes = (await client.get("/api/v1/memory-notes")).json()
assert len(notes) == 1 and notes[0]["evidence_id"] == saved["evidence_id"]
status = await client.get(f"/api/v2/memories/additions/{addition['request_id']}")
assert status.status_code == 200
assert status.json()["evidence_id"] == saved["evidence_id"]
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_note_requires_authentication_explicit_scope_and_nonempty_content(tmp_path):
def test_memory_add_requires_authentication_explicit_scope_and_nonempty_content(tmp_path):
async def scenario():
app = application(tmp_path)
async with app.router.lifespan_context(app), client_for(app) as client:
@@ -100,23 +105,26 @@ def test_note_requires_authentication_explicit_scope_and_nonempty_content(tmp_pa
{"user_id": "other"},
):
assert (
await client.post("/api/v1/memory-notes", json={**base, **change})
await client.post("/api/v2/memories", json={**base, **change})
).status_code == 422
assert (
await client.post(
"/api/v1/memory-notes",
"/api/v2/memories",
json={"request_id": base["request_id"], "text": "No scope"},
)
).status_code == 422
client.headers.pop("Authorization")
assert (await client.post("/api/v1/memory-notes", json=base)).status_code == 401
assert app.state.memory_note_store.list() == []
assert (await client.post("/api/v2/memories", json=base)).status_code == 401
status_path = f"/api/v2/memories/additions/{base['request_id']}"
assert (await client.get(status_path)).status_code == 401
client.headers["Authorization"] = "Bearer memory-add-test"
assert (await client.get(status_path)).status_code == 404
asyncio.run(scenario())
def test_note_and_source_grant_are_atomic(tmp_path):
from one_memory.memory_notes import MemoryNoteInput
def test_memory_add_and_source_grant_are_atomic(tmp_path):
from one_memory.memory_additions import MemoryAddInput
async def scenario():
app = legacy_application(tmp_path)
@@ -127,8 +135,8 @@ def test_note_and_source_grant_are_atomic(tmp_path):
"BEGIN SELECT RAISE(ABORT, 'grant failed'); END"
)
with pytest.raises(sqlite3.IntegrityError, match="grant failed"):
app.state.memory_note_store.save(
MemoryNoteInput(
app.state.memory_addition_store.save(
MemoryAddInput(
request_id=uuid4(), text="Do not leave half a save.", scope="all-apps"
)
)
@@ -137,22 +145,23 @@ def test_note_and_source_grant_are_atomic(tmp_path):
asyncio.run(scenario())
def test_revoked_note_source_is_not_silently_reauthorized(tmp_path):
def test_revoked_manual_source_is_not_silently_reauthorized(tmp_path):
async def scenario():
app = legacy_application(tmp_path)
async with app.router.lifespan_context(app), client_for(app) as client:
note = {"request_id": str(uuid4()), "text": "Remember this.", "scope": "all-apps"}
saved = (await client.post("/api/v1/memory-notes", json=note)).json()
addition = {"request_id": str(uuid4()), "text": "Remember this.", "scope": "all-apps"}
assert (await client.post("/api/v2/memories", json=addition)).status_code == 201
with sqlite3.connect(app.state.evidence_store.database_path) as db:
db.execute(
"UPDATE source_grants SET revoked_at='2026-09-12' "
"WHERE source_id='one-memory-manual'"
)
assert (await client.post("/api/v1/memory-notes", json=note)).status_code == 409
assert (
await client.post(f"/api/v1/memory-notes/{saved['evidence_id']}/process", json={})
).status_code == 409
assert (await client.get("/api/v1/memory-notes")).json() == []
again = await client.post("/api/v2/memories", json=addition)
assert again.status_code == 409
assert again.json()["detail"] == "memory_add_source_revoked"
status = await client.get(f"/api/v2/memories/additions/{addition['request_id']}")
assert status.status_code == 409
assert status.json()["detail"] == "memory_add_source_revoked"
asyncio.run(scenario())
@@ -473,3 +482,157 @@ def test_sdk_context_search_sets_each_recall_lane_apart(tmp_path):
] == [(10, 3, 1), (5, 0, 2)]
asyncio.run(scenario())
def _envelope(tools=("om_recall", "om_ask")) -> dict[str, str]:
return {
"x-one-memory-read-context": "external",
"x-one-memory-installation-id": "00000000-0000-0000-0000-000000000001",
"x-one-memory-installation-revision": "1",
"x-one-memory-allowed-scopes": json.dumps(["all-apps"]),
"x-one-memory-allowed-tools": json.dumps(list(tools)),
}
def _bare_request(method: str, path: str, headers: dict[str, str]):
from starlette.requests import Request
return Request(
{
"type": "http",
"method": method,
"path": path,
"headers": [(k.encode(), v.encode()) for k, v in headers.items()],
}
)
@pytest.mark.parametrize(
("method", "path"),
[
("POST", "/api/v2/memories"),
("GET", f"/api/v2/memories/additions/{uuid4()}"),
],
)
def test_memory_add_routes_are_denied_to_enveloped_requests(method, path):
"""The broker writes as a trusted local caller; an external envelope never
reaches the add or status route, whatever tools it carries."""
from fastapi import HTTPException
from one_memory.external_memory_access import TOOLS, require_external_endpoint
with pytest.raises(HTTPException) as error:
require_external_endpoint(_bare_request(method, path, _envelope(sorted(TOOLS))))
assert (error.value.status_code, error.value.detail) == (403, "external_endpoint_denied")
# A trusted local caller (no envelope) is not refused by this gate.
require_external_endpoint(_bare_request(method, path, {}))
def test_existing_memories_broker_routes_keep_their_access_rules():
"""Adding `POST /api/v2/memories` neither opens nor closes the ask routes."""
from fastapi import HTTPException
from one_memory.external_memory_access import require_external_endpoint
for path in ("/api/v2/memories/ask", "/api/v2/memories/ask/stream"):
require_external_endpoint(_bare_request("POST", path, _envelope(("om_ask",))))
with pytest.raises(HTTPException) as denied:
require_external_endpoint(_bare_request("POST", path, _envelope(("om_recall",))))
assert denied.value.detail == "external_recall_scope_denied"
for method, path in (
("GET", "/api/v2/memories/some-memory"),
("GET", "/api/v2/memories/graph"),
("PUT", "/api/v2/memories/some-memory"),
("DELETE", "/api/v2/memories/some-memory"),
):
with pytest.raises(HTTPException) as denied:
require_external_endpoint(_bare_request(method, path, _envelope(("om_ask",))))
assert denied.value.detail == "external_endpoint_denied"
@pytest.mark.fresh_sdk
def test_enveloped_memory_add_saves_nothing(tmp_path):
async def scenario():
app = application(tmp_path)
async with app.router.lifespan_context(app), client_for(app) as client:
addition = {"request_id": str(uuid4()), "text": "Remember this.", "scope": "all-apps"}
refused = await client.post("/api/v2/memories", json=addition, headers=_envelope())
assert refused.status_code == 403
assert refused.json()["detail"] == "external_endpoint_denied"
status = f"/api/v2/memories/additions/{addition['request_id']}"
assert (await client.get(status, headers=_envelope())).status_code == 403
assert (await client.get(status)).status_code == 404
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_memory_add_routes_do_not_collide_with_single_memory_routes(tmp_path):
"""`/additions/{request_id}` is two segments and the add is a POST on the
collection: neither may be served by `/api/v2/memories/{memory_id}`."""
sdk_calls: list[tuple[str, object]] = []
class Client:
def __getattr__(self, name):
def record(*args, **kwargs):
sdk_calls.append((name, args))
return None
return record
class Handle:
async def client(self):
return Client()
async def close(self):
return None
async def scenario():
app = application(tmp_path)
request_id = str(uuid4())
async with app.router.lifespan_context(app), client_for(app) as client:
app.state.sdk_client_handle = Handle()
missing = await client.get(f"/api/v2/memories/additions/{request_id}")
assert missing.status_code == 404
assert missing.json()["detail"] == "memory_add_not_found"
created = await client.post(
"/api/v2/memories",
json={"request_id": request_id, "text": "Remember this.", "scope": "all-apps"},
)
assert created.status_code == 201
assert set(created.json()) == {
"evidence_id",
"text",
"scope",
"created_at",
"state",
"task_id",
"memory_ids",
"error",
}
status = await client.get(f"/api/v2/memories/additions/{request_id}")
assert status.json()["evidence_id"] == created.json()["evidence_id"]
# Neither route reached the single-memory SDK reads ...
assert not [call for call in sdk_calls if call[0] == "get"]
# ... while a single segment still does, even one named `additions`.
await client.get("/api/v2/memories/additions")
await client.get("/api/v2/memories/some-memory")
assert [call[1][0] for call in sdk_calls if call[0] == "get"] == [
"additions",
"some-memory",
]
# The old note surface is gone.
assert (await client.get("/api/v1/memory-notes")).status_code == 404
assert (
await client.post(
"/api/v1/memory-notes",
json={"request_id": str(uuid4()), "text": "x", "scope": "all-apps"},
)
).status_code == 404
schema = (await client.get("/openapi.json")).json()
assert "MemoryAddReceipt" in schema["components"]["schemas"]
assert "MemoryAddInput" in schema["components"]["schemas"]
assert not [name for name in schema["components"]["schemas"] if "Note" in name]
assert not [path for path in schema["paths"] if "note" in path]
asyncio.run(scenario())
@@ -165,8 +165,40 @@ class FakeSdk:
def list_memories(self, **kwargs):
self.list_calls.append(kwargs)
assert kwargs["scene"] == "all"
include = kwargs.get("include_sources") or []
scene = kwargs["scene"]
start, stop = kwargs["offset"], kwargs["offset"] + kwargs["limit"]
if scene == "chat":
rows = [row for row in self.chat.values() if not include or row["source"] in include]
return {
"vdb": {
"memories": rows[start:stop],
"total": len(rows),
"limit": kwargs["limit"],
"offset": kwargs["offset"],
},
"elapsed_ms": 1.0,
}
if scene in ("working", "coding"):
rows = [
{key: value for key, value in row.items() if not key.startswith("_")}
| {"status": "active"}
for (kind, variant, _id), row in self.productivity.items()
if kind == "experience"
and variant == scene
and (not include or row["source"] in include)
]
return {
"request_id": "list",
"scene": "productivity",
"variant": scene,
"experiences": rows[start:stop],
"total": len(rows),
"limit": kwargs["limit"],
"offset": kwargs["offset"],
"elapsed_ms": 1.0,
}
assert scene == "all"
chat = [
{**row, "scene": "chat", "kind": "chat", "created_at": 10, "updated_at": 10}
for row in self.chat.values()
@@ -549,3 +581,69 @@ def test_ambiguous_memory_id_is_a_conflict(tmp_path, monkeypatch):
assert resolved.json()["content"] == "chat text"
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_scene_filter_lists_one_scene(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
sdk.add_chat(chat_row("chat-1", "chat text"))
sdk.add_productivity("experience", experience_row("work-1", "w task", "w fix"))
sdk.add_productivity(
"experience", experience_row("code-1", "c task", "c fix", variant="coding")
)
working = await client.get("/api/v2/memory-library", params={"scene": "working"})
assert working.status_code == 200
body = working.json()
assert body["total"] == 1
assert [item["memory_id"] for item in body["memories"]] == ["work-1"]
assert body["memories"][0]["kind"] == "experience"
assert body["memories"][0]["variant"] == "working"
assert body["memories"][0]["scene"] == "working"
assert sdk.list_calls[-1]["scene"] == "working"
chat = await client.get("/api/v2/memory-library", params={"scene": "chat"})
assert chat.status_code == 200
assert [item["memory_id"] for item in chat.json()["memories"]] == ["chat-1"]
assert sdk.list_calls[-1]["scene"] == "chat"
default = await client.get("/api/v2/memory-library")
assert default.status_code == 200
assert sdk.list_calls[-1]["scene"] == "all"
bad = await client.get("/api/v2/memory-library", params={"scene": "normal"})
assert bad.status_code == 422
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_scene_filter_narrows_search_and_binds_the_window(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
sdk.add_chat(chat_row("chat-1", "deploy chat"))
sdk.add_productivity("experience", experience_row("work-1", "deploy task", "run make"))
sdk.add_productivity(
"experience",
experience_row("code-1", "deploy code", "run tests", variant="coding"),
)
coding = await client.get(
"/api/v2/memory-library", params={"query": "deploy", "scene": "coding"}
)
assert coding.status_code == 200
body = coding.json()
assert body["total"] == 1
assert [item["memory_id"] for item in body["memories"]] == ["code-1"]
assert sdk.search_calls[-1][1]["scene"] == "all"
# The same window under another scene is a different search.
other = await client.get(
"/api/v2/memory-library",
params={"query": "deploy", "scene": "chat", "window_id": body["window_id"]},
)
assert other.status_code == 200
assert other.json()["window_reset"] is True
assert [item["memory_id"] for item in other.json()["memories"]] == ["chat-1"]
assert len(sdk.search_calls) == 2
run_with_sdk(tmp_path, monkeypatch, scenario)
@@ -963,7 +963,7 @@ class _SdkPublicClient:
def add_extracted(self, contents, **kwargs):
self.events.append("add_extracted")
self.calls.append(("add_extracted", contents, kwargs))
return {"success": True, "memory_ids": ["memory-note"]}
return {"success": True, "memory_ids": ["memory-added"]}
def get_write_outcome(self, request_id):
self.calls.append(("get_write_outcome", request_id, {}))
@@ -988,7 +988,7 @@ def _sdk_evidence(
text: str = "hello",
memory_content: bool = False,
source: str = "cursor",
manual_note: bool = False,
manual_addition: bool = False,
created_at: datetime | None = None,
):
from one_memory.imports.models import (
@@ -1002,7 +1002,7 @@ def _sdk_evidence(
origin = (
OriginMetadata(raw_meta={"input_mode": "direct_memory_note"})
if manual_note
if manual_addition
else OriginMetadata()
)
return materialize_write_input(
@@ -1089,14 +1089,14 @@ def test_sdk_write_processor_calls_add_once_with_public_scope_and_receipt() -> N
(
_sdk_evidence("memory", text="remember this", memory_content=True),
_sdk_evidence(
"note",
text="save this note",
"addition",
text="save this memory",
source="one-memory-manual",
manual_note=True,
manual_addition=True,
),
),
)
def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_notes(record) -> None:
def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_additions(record) -> None:
from one_memory.sdk_write_processor import SdkWriteProcessor, SdkWriteReceipt
client = _SdkPublicClient()
@@ -1112,7 +1112,7 @@ def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_notes(rec
before_hy_invocation=before,
)
assert asyncio.run(run()) == SdkWriteReceipt(memory_ids=("memory-note",))
assert asyncio.run(run()) == SdkWriteReceipt(memory_ids=("memory-added",))
assert client.events == ["before", "add_extracted"]
assert client.calls[0][1] == [record.verbatim]
assert len(client.calls) == 1
@@ -301,16 +301,20 @@ def test_the_runner_names_the_step_and_sub_step_that_failed(tmp_path, monkeypatc
for line in written
if "step" in line.get("details", {})
]
assert steps[:6] == [
assert steps[:8] == [
("step.finished", "preflight.authority_read"),
("step.finished", "preflight.profile_read"),
("step.finished", "preflight.resumable_identity"),
("step.finished", "preflight.legacy_ids_scan"),
("step.finished", "preflight.model_profile_check"),
("step.finished", "prepare.recover_abandoned_staging"),
("step.finished", "bundle.ensure"),
("step.finished", "bundle.restore_point"),
]
assert steps[-1] == ("step.failed", "prepare.legacy_work")
failed = written[-1]
# The failure put the home back; the restore says so after the failed step.
assert [line["event"] for line in written[-2:]] == ["restore.started", "restore.finished"]
failed = next(line for line in reversed(written) if line["event"] == "step.failed")
assert failed["migration_id"].startswith("sdk-authority-v2-")
assert failed["details"]["sub_step"] == "write_queue"
assert failed["error"]["chain"][0]["type"] == "ValueError"
@@ -0,0 +1,483 @@
"""A failed upgrade migration puts the home back exactly as it found it.
The fixed release that follows must migrate from the original 1.0.4 data, not
from a half-migrated home: every pre-cutover failure restores the database,
the SDK store and `config.json`, and a later run succeeds from there.
"""
from __future__ import annotations
import asyncio
import hashlib
import io
import json
import sqlite3
from contextlib import closing, contextmanager
from pathlib import Path
import pytest
from one_memory import cli
from one_memory.config import Settings
from one_memory.database_access import close_main_databases, main_database
from one_memory.imports.storage import EvidenceStore
from one_memory.live_capture import sdk_migration_capture_fenced
from one_memory.memory_server.migration_runner import SqliteMigrationStore, migrate
from one_memory.memory_server.migrations import MIGRATIONS
from one_memory.runtime_client import RuntimeStatus
from one_memory.sdk_data_plane_migration import (
DataPlaneMigrationError,
MemoryAuthority,
SdkDataPlaneMigrationStore,
)
from one_memory.sdk_migration_log import log_path
from one_memory.sdk_migration_restore import (
RESTORE_POINT,
MigrationRestoreFailed,
failed_state_path,
finish_interrupted_restore,
restore_pending,
)
from one_memory.sdk_migration_runner import run_startup_migration
from one_memory.sdk_migration_verify import MemoryReadbackFailed
from one_memory.target_schema import legacy_tables_present
NAMESPACE = "governed-v2"
CONFIG = {"schema_version": 1, "sources": {"cursor": {"sync_enabled": True}}}
@pytest.fixture
def profile(monkeypatch):
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot
snapshot = builtin_retrieval_snapshot()
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: snapshot)
monkeypatch.setattr("one_memory.sdk_migration_verify.read_active_profile", lambda _db: snapshot)
return snapshot
def _chroma(home: Path):
from one_memory.sdk_vector_reattach import open_offline_chroma
return open_offline_chroma(home / "hy-memory" / NAMESPACE / "vector")
@pytest.fixture
def home(tmp_path, profile) -> Path:
"""A 1.0.4-shaped home: legacy memories, queued legacy work, legacy vectors."""
from one_memory.hy_sdk_runtime import vector_collection_bases
database = tmp_path / "one-memory.sqlite3"
asyncio.run(migrate(SqliteMigrationStore(database), MIGRATIONS))
with main_database(database).write() as connection, connection:
for memory_id in ("m1", "m2"):
connection.execute(
"INSERT INTO governed_memories (memory_id,user_id,kind,status,content,"
"scopes_json,revision,created_at,updated_at) "
"VALUES (?,'local','chat','confirmed','x','[]',1,'now','now')",
(memory_id,),
)
verbatim = "text of e1"
connection.execute(
"INSERT INTO evidence (evidence_id,idempotency_key,user_id,source_id,"
"conversation_id,content_kind,role,written_by,verbatim,content_hash,"
"revision,imported_at,updated_at,ingest_seq) "
"VALUES ('e1','e1','local','cursor','session-1','raw_conversation','user',"
"'person',?,?,1,'2026-10-22T00:00:00+00:00','2026-10-22T00:00:00+00:00',1)",
(verbatim, hashlib.sha256(verbatim.encode()).hexdigest()),
)
connection.execute(
"INSERT INTO memory_tasks "
"(task_id,request_id,user_id,kind,input_ref,status,profile_id,"
"profile_revision,profile_snapshot_json,runner_token,attempts,"
"last_error_code,created_at,updated_at,duration_seconds,hy_invocation_count) "
"VALUES ('t1','request-t1','local','extract','e1','pending','profile',1,'{}',"
"NULL,0,NULL,'now','now',NULL,0)"
)
close_main_databases()
assert EvidenceStore(database).get("e1") is not None
close_main_databases()
current, legacy = vector_collection_bases(profile)
client = _chroma(tmp_path)
for name, ids in ((f"{legacy[0]}_4", ["m1", "m2"]), (f"{current}_4", ["after"])):
collection = client.get_or_create_collection(name, metadata={"hnsw:space": "cosine"})
collection.upsert(
ids=ids,
embeddings=[[float(index + 1)] * 4 for index, _ in enumerate(ids)],
documents=[f"doc {identifier}" for identifier in ids],
)
client.close()
(tmp_path / "config.json").write_text(json.dumps(CONFIG), encoding="utf-8")
return tmp_path
def _collections(home: Path) -> dict[str, int]:
client = _chroma(home)
try:
return {item.name: item.count() for item in client.list_collections()}
finally:
client.close()
def _state(home: Path) -> dict[str, object]:
"""Everything a migration may change, compared byte for byte or row for row."""
close_main_databases()
with closing(sqlite3.connect(home / "one-memory.sqlite3")) as connection:
dump = list(connection.iterdump())
sdk = home / "hy-memory" / NAMESPACE
files = {
path.relative_to(sdk).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest()
for path in sorted(sdk.rglob("*"))
if path.is_file()
}
from one_memory.sdk_vector_reattach import _stored_names
return {
"database": dump,
"sdk_files": files,
"collection_names": _stored_names(home),
"config": (home / "config.json").read_bytes(),
}
def _reading(readable: set[str]):
class Client:
def list_memories(self, *, user_id, statuses, limit, offset, order):
del user_id, statuses, order
ids = sorted(readable)
rows = [{"memory_id": item} for item in ids[offset : offset + limit]]
return {"vdb": {"memories": rows, "total": len(ids)}}
def get(self, memory_id):
return {"memory_id": memory_id} if memory_id in readable else None
@contextmanager
def open_client(_profile):
yield Client()
return open_client
def _run(home: Path, *, readable=frozenset({"m1", "m2"}), report=None):
return run_startup_migration(
data_dir=home,
runtime_build="22",
sdk_version="2.0.5",
namespace=NAMESPACE,
open_client=_reading(set(readable)),
restore_report=report,
)
def _events(home: Path) -> list[dict[str, object]]:
return [json.loads(line) for line in log_path(home).read_text().splitlines()]
def _fail_schema_migrate(monkeypatch, home):
import one_memory.memory_server.migration_runner as runner_module
async def half_migrated(store, migrations):
del store, migrations
with main_database(home / "one-memory.sqlite3").write() as connection, connection:
connection.execute("CREATE TABLE half_applied_migration (x INTEGER)")
raise DataPlaneMigrationError("sdk_migration_schema_failed")
monkeypatch.setattr(runner_module, "migrate", half_migrated)
def _fail_legacy_work(monkeypatch, home):
import one_memory.sdk_migration_runner as runner
def broken(_database_path):
raise DataPlaneMigrationError("sdk_data_plane_dispatch_failed")
monkeypatch.setattr(runner, "move_pending_dispatches", broken)
def _fail_vectors(monkeypatch, home):
import one_memory.sdk_migration_runner as runner
real = runner.reattach_legacy_vectors
def renamed_then_failed(data_dir, profile):
real(data_dir, profile)
raise DataPlaneMigrationError("sdk_migration_vector_reattach_incomplete")
monkeypatch.setattr(runner, "reattach_legacy_vectors", renamed_then_failed)
FAILURES = {
"prepare.schema_migrate": (_fail_schema_migrate, "sdk_migration_schema_failed"),
"prepare.legacy_work": (_fail_legacy_work, "sdk_data_plane_dispatch_failed"),
"vectors.reattach": (_fail_vectors, "sdk_migration_vector_reattach_incomplete"),
}
@pytest.mark.parametrize("step", sorted(FAILURES))
def test_a_failure_before_cutover_restores_the_home_exactly(home, monkeypatch, step):
counts = _collections(home)
before = _state(home)
inject, code = FAILURES[step]
inject(monkeypatch, home)
report: dict[str, object] = {}
with pytest.raises(DataPlaneMigrationError) as caught:
_run(home, report=report)
# The migration's own failure is what the caller sees.
assert caught.value.code == code
assert report == {"state": "restored"}
assert _state(home) == before
assert _collections(home) == counts
store = SdkDataPlaneMigrationStore(home / "one-memory.sqlite3")
assert store.authority() is MemoryAuthority.LEGACY
# Nothing left to resume: the next run starts from the original data.
assert not (home / RESTORE_POINT).exists()
assert not restore_pending(home)
# The failed state is kept aside for diagnosis; capture stays frozen.
assert (failed_state_path(home) / "management" / "one-memory.sqlite3").is_file()
assert sdk_migration_capture_fenced(home)
events = _events(home)
failed_at = next(
index
for index, line in enumerate(events)
if line["event"] == "step.failed" and line["details"]["step"] == step
)
finished = next(line for line in events if line["event"] == "restore.finished")
assert events.index(finished) > failed_at
assert finished["details"]["files"] > 0 and finished["details"]["bytes"] > 0
assert finished["details"]["reason"] == "migration_failed"
assert "duration_ms" in finished["details"]
monkeypatch.undo()
_retry_succeeds(home, monkeypatch)
def test_a_readback_failure_restores_and_a_fixed_build_succeeds(home, monkeypatch):
before = _state(home)
report: dict[str, object] = {}
with pytest.raises(MemoryReadbackFailed):
_run(home, readable={"m1"}, report=report)
assert report == {"state": "restored"}
assert _state(home) == before
# The ledger row the failure wrote went with the rest.
with closing(sqlite3.connect(home / "one-memory.sqlite3")) as connection:
assert not connection.execute(
"SELECT 1 FROM sdk_data_plane_migrations WHERE state='blocked'"
).fetchall()
_retry_succeeds(home, monkeypatch)
def _retry_succeeds(home: Path, monkeypatch) -> None:
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot, vector_collection_bases
snapshot = builtin_retrieval_snapshot()
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: snapshot)
restored_bundles = sorted((home / "backups").glob("*.sdk-bundle/restored.json"))
assert restored_bundles
result = _run(home)
assert result.state == "cutover"
store = SdkDataPlaneMigrationStore(home / "one-memory.sqlite3")
assert store.authority() is MemoryAuthority.SDK
current, _legacy = vector_collection_bases(snapshot)
assert _collections(home) == {f"{current}_4": 3}
assert not sdk_migration_capture_fenced(home)
assert not (home / RESTORE_POINT).exists()
assert not failed_state_path(home).exists()
# The restored bundle was superseded by this run's verified one.
assert not any(path.exists() for path in restored_bundles)
assert len(list((home / "backups").glob("*-pre-cutover.sdk-bundle"))) == 1
def test_a_failure_after_the_cutover_commit_is_not_restored(home, monkeypatch):
import one_memory.sdk_migration_runner as runner
def broken_cleanup(**_options):
raise DataPlaneMigrationError("sdk_data_plane_cleanup_failed")
monkeypatch.setattr(runner, "resume_legacy_cleanup", broken_cleanup)
report: dict[str, object] = {}
with pytest.raises(DataPlaneMigrationError):
_run(home, report=report)
assert report == {}
store = SdkDataPlaneMigrationStore(home / "one-memory.sqlite3")
assert store.authority() is MemoryAuthority.SDK
assert legacy_tables_present(home / "one-memory.sqlite3")
assert not (home / RESTORE_POINT).exists()
assert not any(line["event"].startswith("restore.") for line in _events(home))
monkeypatch.undo()
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot
snapshot = builtin_retrieval_snapshot()
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: snapshot)
assert _run(home).state == "complete"
def test_a_commit_that_landed_before_its_step_failed_is_not_restored(home, monkeypatch):
import one_memory.sdk_migration_runner as runner
real = runner.commit_sdk_cutover
def committed_then_failed(**options):
real(**options)
raise DataPlaneMigrationError("sdk_data_plane_cutover_missing")
monkeypatch.setattr(runner, "commit_sdk_cutover", committed_then_failed)
report: dict[str, object] = {}
with pytest.raises(DataPlaneMigrationError):
_run(home, report=report)
assert report == {"state": "skipped"}
assert (
SdkDataPlaneMigrationStore(home / "one-memory.sqlite3").authority() is MemoryAuthority.SDK
)
skipped = [line for line in _events(home) if line["event"] == "restore.skipped"]
assert len(skipped) == 1
def test_a_failed_restore_is_logged_and_the_original_code_is_kept(home, monkeypatch):
import one_memory.sdk_migration_runner as runner
def corrupt_bundle_then_fail(_database_path):
bundle = next((home / "backups").glob("*-pre-cutover.sdk-bundle"))
(bundle / "management" / "one-memory.sqlite3").write_bytes(b"not the backup")
raise DataPlaneMigrationError("sdk_data_plane_dispatch_failed")
monkeypatch.setattr(runner, "move_pending_dispatches", corrupt_bundle_then_fail)
monkeypatch.setattr(cli, "load_settings", lambda: Settings(_env_file=None, data_dir=home))
monkeypatch.setattr(
cli,
"inspect_runtime",
lambda _data_dir: RuntimeStatus("stopped", "runtime_receipt_missing"),
)
output = io.StringIO()
code = cli.run(["sdk-migrate", "--runtime-build", "22", "--yes", "--json"], output=output)
document = json.loads(output.getvalue().splitlines()[-1])
assert code == 1
assert document["code"] == "sdk_data_plane_dispatch_failed"
assert document["data_restored"] is False
assert document["restore_code"] == "sdk_migration_restore_failed"
events = _events(home)
restore_failed = next(line for line in events if line["event"] == "restore.failed")
assert restore_failed["code"] == "sdk_migration_restore_failed"
assert restore_failed["error"]["chain"]
migrate_failed = next(line for line in events if line["event"] == "migrate.failed")
assert migrate_failed["code"] == "sdk_data_plane_dispatch_failed"
assert migrate_failed["details"]["restore"] == "failed"
# Nothing was swapped; the restore point stays and the next run refuses
# to migrate over the half-migrated home until a restore succeeds.
assert (home / RESTORE_POINT).exists()
assert not restore_pending(home)
monkeypatch.undo()
with pytest.raises(MigrationRestoreFailed):
_run(home)
def test_a_restore_killed_between_renames_is_finished_before_anything_reads(home, monkeypatch):
import one_memory.sdk_migration_restore as restore_module
before = _state(home)
real_apply = restore_module._apply
def killed_part_way(target, steps, done):
real_apply(target, steps[:5], done)
raise KeyboardInterrupt
_fail_vectors(monkeypatch, home)
monkeypatch.setattr(restore_module, "_apply", killed_part_way)
with pytest.raises(KeyboardInterrupt):
_run(home)
assert restore_pending(home)
# The database was renamed aside: Core must not serve, and the probe
# must not call this a fresh home.
assert not (home / "one-memory.sqlite3").exists()
monkeypatch.setattr(cli, "load_settings", lambda: Settings(_env_file=None, data_dir=home))
status = io.StringIO()
assert cli.run(["sdk-migration-status", "--json"], output=status) == 0
assert json.loads(status.getvalue())["state"] == "required"
with pytest.raises(SystemExit) as refused:
cli._refuse_unmigrated_home(home)
assert refused.value.code == cli.SDK_MIGRATION_REQUIRED_EXIT
monkeypatch.undo()
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot
snapshot = builtin_retrieval_snapshot()
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: snapshot)
assert finish_interrupted_restore(home)
assert not restore_pending(home)
assert _state(home) == before
def test_a_run_killed_after_its_restore_point_is_rolled_back_on_the_next_start(home, monkeypatch):
import one_memory.sdk_migration_runner as runner
before = _state(home)
real = runner.reattach_legacy_vectors
def renamed_then_killed(data_dir, profile):
real(data_dir, profile)
raise KeyboardInterrupt
monkeypatch.setattr(runner, "reattach_legacy_vectors", renamed_then_killed)
with pytest.raises(KeyboardInterrupt):
_run(home)
assert (home / RESTORE_POINT).exists()
monkeypatch.undo()
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot
snapshot = builtin_retrieval_snapshot()
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: snapshot)
# The next start first puts the home back, then migrates it from there.
def failing_readback(_profile):
raise DataPlaneMigrationError("sdk_migration_secure_storage_unavailable")
with pytest.raises(DataPlaneMigrationError):
run_startup_migration(
data_dir=home,
runtime_build="22",
sdk_version="2.0.5",
namespace=NAMESPACE,
open_client=failing_readback,
)
reasons = [
line["details"]["reason"] for line in _events(home) if line["event"] == "restore.finished"
]
assert reasons == ["interrupted_attempt", "migration_failed"]
assert _state(home) == before
def test_a_preflight_failure_changes_nothing_and_needs_no_restore(home, monkeypatch):
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: None)
before = _state(home)
report: dict[str, object] = {}
with pytest.raises(DataPlaneMigrationError, match="sdk_migration_model_profile_required"):
_run(home, report=report)
assert report == {}
assert _state(home) == before
assert not (home / "backups").exists() or not list((home / "backups").iterdir())
def test_a_frozen_capture_fence_holds_until_a_migration_lifts_it(tmp_path):
marker = tmp_path / ".sdk-migration-capture-fence.json"
marker.write_text(json.dumps({"schema_version": 1, "frozen": True}), encoding="utf-8")
assert sdk_migration_capture_fenced(tmp_path)
assert marker.exists()
@@ -2,12 +2,12 @@
Companion to `test_memory_server_unified_entry.py` (C2's adapter). That file
proves the adapter works in isolation; this one pins the *wiring* in `api.py` /
`memory_notes.py`, which is the C0-owned half:
`memory_additions.py`, which is the C0-owned half:
red state - before the wiring, `on_stable_evidence` was the supervisor's
`enqueue_evidence` and a committed record had no
`memory_write_jobs` row;
positive - after it, both the import callback and a manual note submit to
positive - after it, both the import callback and a hand-added memory submit to
the durable queue under `entry:<evidence_id>`;
idempotent - replaying the same evidence returns the original job.
"""
@@ -173,8 +173,8 @@ def test_import_callback_goes_through_the_durable_intake(tmp_path) -> None:
assert callback.__module__ == "one_memory.api"
# The shared intake exists and is what the callback closes over.
assert app.state.memory_write_intake.queue is not None
# Same object for the note path, so both entries share one queue.
assert app.state.memory_note_store.intake is app.state.memory_write_intake
# Same object for the memory-add path, so both entries share one queue.
assert app.state.memory_addition_store.intake is app.state.memory_write_intake
asyncio.run(scenario())
@@ -225,7 +225,7 @@ def test_live_import_callback_submits_realtime_priority(tmp_path) -> None:
asyncio.run(scenario())
def test_manual_note_creates_one_durable_row_and_replays_idempotently(
def test_memory_add_creates_one_durable_row_and_replays_idempotently(
tmp_path,
) -> None:
async def scenario():
@@ -237,10 +237,8 @@ def test_manual_note_creates_one_durable_row_and_replays_idempotently(
headers={"Authorization": "Bearer wire-test"},
)
request_id = str(uuid4())
created = await client.post(
"/api/v1/memory-notes",
json={"request_id": request_id, "text": "记住这条", "scope": "all-apps"},
)
body = {"request_id": request_id, "text": "记住这条", "scope": "all-apps"}
created = await client.post("/api/v2/memories", json=body)
assert created.status_code == 201
evidence_id = created.json()["evidence_id"]
key = f"entry:{evidence_id}"
@@ -257,10 +255,12 @@ def test_manual_note_creates_one_durable_row_and_replays_idempotently(
break
assert rows == [(key,)]
# Idempotent replay: a second /process must not add a second row.
assert (
await client.post(f"/api/v1/memory-notes/{evidence_id}/process")
).status_code == 200
# Idempotent replay: the same request submitted again must not add
# a second row.
replay = await client.post("/api/v2/memories", json=body)
assert replay.status_code == 201
assert replay.json()["evidence_id"] == evidence_id
await asyncio.sleep(0.2)
with sqlite3.connect(app.state.evidence_store.database_path) as db:
count = db.execute(
"SELECT COUNT(*) FROM memory_write_jobs WHERE idempotency_key=?",
+36
View File
@@ -25,11 +25,14 @@ import {
installUserService,
RecallBrokerSupervisor,
RuntimeSupervisor,
readInstalledRuntimeSource,
readPublishedCorePort,
readRuntimeServiceOwner,
readSdkMigrationBlock,
removeRuntimeServiceOwner,
runtimeInstallLayout,
runtimeServiceEnableMarkerPath,
type SdkMigrationSource,
stopRuntimeProcess,
waitForRuntimeProcess,
writeRuntimeServiceOwner,
@@ -1075,6 +1078,9 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
);
installPackagedWebProtocol();
// The Runtime this launch replaced, when it moved `current`: the migration
// log (and so the issue a person files) names the version upgraded from.
let upgradeSource: SdkMigrationSource | null = null;
const migrateAtStartup = async (core: {
command: string;
args: string[];
@@ -1084,6 +1090,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
dataDir,
core,
caller: "desktop",
sourceRuntime: upgradeSource,
runtimeBuild:
packagedBuildNumber === undefined
? typeof app.getVersion === "function"
@@ -1200,6 +1207,12 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
}
if (installed !== null) {
runtimeChanged = !installed.reused_current;
if (runtimeChanged) {
upgradeSource = await readInstalledRuntimeSource(
installRoot,
installed.previous_current_target,
);
}
await installBundledCliIfNeeded({
home: app.getPath("home"),
installRoot,
@@ -1217,6 +1230,12 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
command: stable?.captureCommand ?? layout.coreCommand,
args: [],
});
} else if (desktopBackgroundLaunch) {
// A login launch does not migrate, and the service exits on a home a
// failed migration froze: say so now instead of waiting for a
// Runtime that will not come. Opening MemHub retries the migration.
const frozen = await readSdkMigrationBlock(dataDir);
if (frozen?.frozen !== undefined) throw new Error(frozen.code);
}
const alreadyRunning = runtimeChanged
? false
@@ -1707,6 +1726,23 @@ if (desktopVersion !== null) {
showItemInFolder: (target) => shell.showItemInFolder(target),
openExternal: (url) => shell.openExternal(url),
},
environment: {
appVersion:
typeof app.getVersion === "function" ? app.getVersion() : null,
buildNumber: packagedBuildNumber ?? null,
osName:
process.platform === "darwin"
? "macOS"
: process.platform === "win32"
? "Windows"
: "Linux",
osVersion:
typeof process.getSystemVersion === "function"
? process.getSystemVersion()
: null,
arch: process.arch,
home: app.getPath("home"),
},
})
)
return;
@@ -1,13 +1,17 @@
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
displayPath,
MAX_ISSUE_URL_LENGTH,
MIGRATION_ISSUE_URL,
type MigrationDialogDependencies,
migrationFailureCode,
migrationIssueBody,
migrationIssueUrl,
migrationLogPath,
readMigrationLogFacts,
showMigrationFailureDialog,
} from "./migration-failure-dialog";
@@ -57,8 +61,15 @@ describe("migration failure dialog", () => {
expect(deps.showItemInFolder).toHaveBeenCalledWith(
migrationLogPath(dataDir),
);
expect(deps.openExternal).toHaveBeenCalledWith(
migrationIssueUrl("sdk_migration_preflight_failed"),
const opened = new URL(
String((deps.openExternal.mock.calls[0] as unknown[] | undefined)?.[0]),
);
expect(`${opened.origin}${opened.pathname}`).toBe(MIGRATION_ISSUE_URL);
expect(opened.searchParams.get("title")).toBe(
"[升级迁移失败 / migration failed] sdk_migration_preflight_failed",
);
expect(opened.searchParams.get("body")).toContain(
"- 错误码:sdk_migration_preflight_failed",
);
expect(migrationIssueUrl("sdk_migration_preflight_failed")).toMatch(
new RegExp(
@@ -115,6 +126,170 @@ describe("migration failure dialog", () => {
).toBeNull();
});
it("fills the issue body from the migration log, never naming the user", async () => {
const accountHome = await home();
const dataDir = path.join(accountHome, ".one-memory");
await mkdir(path.join(dataDir, "logs"), { recursive: true });
const log = [
{
event: "migration.invoked",
details: { source_version: "unknown", source_build: "unknown" },
},
{
event: "migrate.invoked",
details: { source_version: "1.0.4", source_build: "104" },
},
{
event: "step.failed",
code: "sdk_data_plane_dispatch_failed",
details: {
step: "prepare.legacy_work",
sub_step: "pending_dispatches",
},
},
{ event: "restore.started", details: {} },
{ event: "restore.finished", details: { files: 9 } },
{
event: "migrate.failed",
code: "sdk_data_plane_dispatch_failed",
details: {},
},
]
.map((line) => JSON.stringify(line))
.join("\n");
await writeFile(migrationLogPath(dataDir), `${log}\nnot json\n`);
await writeFile(
path.join(dataDir, "sdk-migration-blocked.json"),
JSON.stringify({
schema_version: 1,
code: "sdk_data_plane_dispatch_failed",
caller: "desktop",
at: "2026-10-27T00:00:00.000Z",
frozen: { service_was_enabled: true },
data_restored: true,
}),
);
const deps = dependencies(0);
expect(
showMigrationFailureDialog({
// Desktop's own migration throws the code the note records.
error: new Error("sdk_data_plane_dispatch_failed"),
dataDir,
locale: "zh-CN",
dependencies: deps,
environment: {
appVersion: "2.0.6",
buildNumber: 206,
osName: "macOS",
osVersion: "15.6",
arch: "arm64",
home: accountHome,
},
}),
).toBe(true);
expect(deps.showMessageBoxSync.mock.calls[0]?.[0]?.detail).toContain(
"记忆数据已恢复到升级前的状态。",
);
expect(deps.showItemInFolder).toHaveBeenCalledWith(
migrationLogPath(dataDir),
);
const url = String(
(deps.openExternal.mock.calls[0] as unknown[] | undefined)?.[0],
);
expect(url.length).toBeLessThanOrEqual(MAX_ISSUE_URL_LENGTH);
const body = new URL(url).searchParams.get("body");
expect(body).toBe(
[
"## 升级迁移失败",
"- 从 MemHub 1.0.4(build 104)升级到 2.0.6(build 206),迁移数据失败。",
"- 错误码:sdk_data_plane_dispatch_failed",
"- 失败步骤:prepare.legacy_work / pending_dispatches",
"- 数据已恢复到升级前状态:是",
"- 系统:macOS 15.6(arm64)",
`- 迁移日志(本地):~${path.sep}${path.join(".one-memory", "logs", "sdk-migration.log")}`,
"",
"请把上面这个日志文件拖到这里作为附件,再提交。",
"",
].join("\n"),
);
expect(body).not.toContain(accountHome);
});
it("says 未知 for what the log does not know instead of guessing", () => {
const body = migrationIssueBody({
code: "sdk_migration_preflight_failed",
fromVersion: "../../etc",
logPath: "~/.one-memory/logs/sdk-migration.log",
});
expect(body).toContain(
"- 从 MemHub 未知(build 未知)升级到 未知(build 未知)",
);
expect(body).toContain("- 失败步骤:未知");
expect(body).toContain("- 数据已恢复到升级前状态:未知");
expect(body).toContain("- 系统:未知");
});
it("keeps the issue URL within GitHub's limit and encodes it", () => {
const body = `${"迁移".repeat(4000)}\n& = # ?`;
const url = migrationIssueUrl("sdk_migration_preflight_failed", body);
expect(url.length).toBeLessThanOrEqual(MAX_ISSUE_URL_LENGTH);
const parsed = new URL(url);
expect(parsed.searchParams.get("title")).toContain(
"sdk_migration_preflight_failed",
);
expect(body.startsWith(parsed.searchParams.get("body") ?? "x")).toBe(true);
const small = migrationIssueUrl("sdk_migration_x", "a & b = c #d");
expect(new URL(small).searchParams.get("body")).toBe("a & b = c #d");
});
it("shows a custom data dir as it is and the default one under ~", () => {
expect(
displayPath("/Users/alice/.one-memory/logs/x.log", "/Users/alice"),
).toBe(`~${path.sep}${path.join(".one-memory", "logs", "x.log")}`);
expect(displayPath("/Volumes/data/memhub/logs/x.log", "/Users/alice")).toBe(
"/Volumes/data/memhub/logs/x.log",
);
expect(displayPath("/Users/alice2/x.log", "/Users/alice")).toBe(
"/Users/alice2/x.log",
);
});
it("reads only the latest attempt's step and restore from the log", async () => {
const dataDir = await home();
await mkdir(path.join(dataDir, "logs"), { recursive: true });
await writeFile(
migrationLogPath(dataDir),
[
{
event: "migrate.invoked",
details: { source_version: "1.0.4", source_build: 104 },
},
{ event: "step.failed", details: { step: "vectors.reattach" } },
{ event: "restore.finished", details: {} },
{
event: "migrate.invoked",
details: { source_version: "unknown", source_build: "unknown" },
},
{
event: "step.failed",
details: { step: "preflight.model_profile_check" },
},
]
.map((line) => JSON.stringify(line))
.join("\n"),
);
expect(readMigrationLogFacts(dataDir)).toEqual({
fromVersion: "1.0.4",
fromBuild: 104,
step: "preflight.model_profile_check",
subStep: null,
code: null,
dataRestored: null,
});
});
it("leaves other startup failures to the generic error box", async () => {
const deps = dependencies(0);
expect(
@@ -1,4 +1,12 @@
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
import {
appendFileSync,
closeSync,
fstatSync,
mkdirSync,
openSync,
readFileSync,
readSync,
} from "node:fs";
import path from "node:path";
/**
@@ -7,30 +15,48 @@ import path from "node:path";
* MemHub does not run on a home whose migration did not finish: one data shape
* per release, no legacy fallback. The app therefore stops, and this dialog is
* the only thing between the failure and a useful report — it names the code
* and hands the person the one file support needs (`sdk-migration.log`).
* and hands the person the one file support needs (`sdk-migration.log`), with
* a GitHub issue already filled in from that log.
*/
export const MIGRATION_ISSUE_URL =
"https://github.com/Tencent-Hunyuan/Hy-Memory/issues/new";
const CODE = /^[a-z][a-z0-9_]{0,95}$/;
const STEP = /^[a-z][a-z0-9_.]{0,79}$/;
const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/;
/** Generic startup codes that hide a migration block recorded by the service. */
const MASKING_CODES = new Set([
"desktop_start_failed",
"runtime_service_not_ready",
]);
/** Only the tail of the log is read: the last attempt is at its end. */
const LOG_TAIL_BYTES = 256 * 1024;
/** GitHub rejects very long `issues/new` URLs; stay well below its limit. */
export const MAX_ISSUE_URL_LENGTH = 6000;
export function migrationLogPath(dataDir: string): string {
return path.join(dataDir, "logs", "sdk-migration.log");
}
function readBlockCode(dataDir: string): string | null {
interface BlockNote {
code: string;
dataRestored: boolean | null;
}
function readBlock(dataDir: string): BlockNote | null {
try {
const value = JSON.parse(
readFileSync(path.join(dataDir, "sdk-migration-blocked.json"), "utf8"),
) as { code?: unknown };
) as { code?: unknown; data_restored?: unknown };
return typeof value.code === "string" && CODE.test(value.code)
? value.code
? {
code: value.code,
dataRestored:
typeof value.data_restored === "boolean"
? value.data_restored
: null,
}
: null;
} catch {
return null;
@@ -53,8 +79,11 @@ export function migrationFailureCode(
return message;
}
if (dataDir === null) return null;
const blocked = readBlockCode(dataDir);
const blocked = readBlock(dataDir)?.code ?? null;
if (blocked === null) return null;
// Desktop's own migration threw the code the note records (for example
// `sdk_data_plane_write_job_input_missing`).
if (message === blocked) return blocked;
return MASKING_CODES.has(message) ||
!CODE.test(message) ||
message.startsWith("core_")
@@ -62,9 +91,201 @@ export function migrationFailureCode(
: null;
}
export function migrationIssueUrl(code: string): string {
/** What the issue says about the failed upgrade; every field is optional. */
export interface MigrationIssueFacts {
code: string;
fromVersion?: string | null;
fromBuild?: number | null;
toVersion?: string | null;
toBuild?: number | null;
step?: string | null;
subStep?: string | null;
dataRestored?: boolean | null;
osName?: string | null;
osVersion?: string | null;
arch?: string | null;
/** The local log path, already shown with the home directory as `~`. */
logPath: string;
}
const UNKNOWN = "未知";
function versionText(value: string | null | undefined): string {
return typeof value === "string" && VERSION.test(value) ? value : UNKNOWN;
}
function buildText(value: number | null | undefined): string {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
? String(value)
: UNKNOWN;
}
function stepText(
step: string | null | undefined,
sub: string | null | undefined,
) {
if (typeof step !== "string" || !STEP.test(step)) return UNKNOWN;
return typeof sub === "string" && STEP.test(sub) ? `${step} / ${sub}` : step;
}
/** The issue body: short, content-free, and honest about what is unknown. */
export function migrationIssueBody(facts: MigrationIssueFacts): string {
const restored =
facts.dataRestored === true
? "是"
: facts.dataRestored === false
? "否"
: UNKNOWN;
const system = [facts.osName, facts.osVersion]
.map((part) => versionText(part))
.filter((part) => part !== UNKNOWN)
.join(" ");
const arch = versionText(facts.arch);
return [
"## 升级迁移失败",
`- 从 MemHub ${versionText(facts.fromVersion)}(build ${buildText(facts.fromBuild)})升级到 ${versionText(facts.toVersion)}(build ${buildText(facts.toBuild)}),迁移数据失败。`,
`- 错误码:${CODE.test(facts.code) ? facts.code : UNKNOWN}`,
`- 失败步骤:${stepText(facts.step, facts.subStep)}`,
`- 数据已恢复到升级前状态:${restored}`,
`- 系统:${system || UNKNOWN}${arch === UNKNOWN ? "" : `(${arch})`}`,
`- 迁移日志(本地):${facts.logPath}`,
"",
"请把上面这个日志文件拖到这里作为附件,再提交。",
"",
].join("\n");
}
export function migrationIssueUrl(code: string, body?: string): string {
const title = `[升级迁移失败 / migration failed] ${code}`;
return `${MIGRATION_ISSUE_URL}?${new URLSearchParams({ title }).toString()}`;
const plain = `${MIGRATION_ISSUE_URL}?${new URLSearchParams({ title }).toString()}`;
if (body === undefined) return plain;
let text = body;
for (;;) {
const url = `${MIGRATION_ISSUE_URL}?${new URLSearchParams({ title, body: text }).toString()}`;
if (url.length <= MAX_ISSUE_URL_LENGTH) return url;
if (text.length === 0) return plain;
// Cut whole characters until it fits; the log attachment carries the rest.
text = Array.from(text)
.slice(0, Math.floor(Array.from(text).length * 0.8))
.join("");
}
}
/** `/Users/name/.one-memory/...` as `~/.one-memory/...`: never the user name. */
export function displayPath(target: string, home: string | null): string {
if (home) {
const normalized = path.resolve(home);
const resolved = path.resolve(target);
if (resolved === normalized) return "~";
if (resolved.startsWith(`${normalized}${path.sep}`)) {
return `~${path.sep}${path.relative(normalized, resolved)}`;
}
}
return target;
}
export interface MigrationLogFacts {
fromVersion: string | null;
fromBuild: number | null;
step: string | null;
subStep: string | null;
code: string | null;
dataRestored: boolean | null;
}
function readTail(file: string): string {
const descriptor = openSync(file, "r");
try {
const size = fstatSync(descriptor).size;
const length = Math.min(size, LOG_TAIL_BYTES);
const buffer = Buffer.alloc(length);
readSync(descriptor, buffer, 0, length, size - length);
return buffer.toString("utf8");
} finally {
closeSync(descriptor);
}
}
function sourceBuild(value: unknown): number | null {
const number = typeof value === "string" ? Number(value) : value;
return typeof number === "number" &&
Number.isSafeInteger(number) &&
number > 0
? number
: null;
}
/**
* What the migration log says about the last failed attempt.
*
* The version upgraded from is the latest one an invocation recorded; the
* failing step is the last `step.failed` Core wrote; the restore outcome is
* the last `restore.finished`/`restore.failed`. Anything absent stays null.
*/
export function readMigrationLogFacts(dataDir: string): MigrationLogFacts {
const facts: MigrationLogFacts = {
fromVersion: null,
fromBuild: null,
step: null,
subStep: null,
code: null,
dataRestored: null,
};
let text: string;
try {
text = readTail(migrationLogPath(dataDir));
} catch {
return facts;
}
for (const line of text.split("\n")) {
let entry: {
event?: unknown;
code?: unknown;
details?: Record<string, unknown>;
};
try {
entry = JSON.parse(line) as typeof entry;
} catch {
continue;
}
const details =
entry.details && typeof entry.details === "object" ? entry.details : {};
if (
entry.event === "migrate.invoked" ||
entry.event === "migration.invoked"
) {
// Each invocation is a new attempt; the version upgraded from stays.
facts.step = null;
facts.subStep = null;
facts.dataRestored = null;
const version = details.source_version;
const build = sourceBuild(details.source_build);
if (
typeof version === "string" &&
VERSION.test(version) &&
version !== "unknown"
) {
facts.fromVersion = version;
}
if (build !== null) facts.fromBuild = build;
} else if (entry.event === "step.failed") {
facts.step = typeof details.step === "string" ? details.step : null;
facts.subStep =
typeof details.sub_step === "string" ? details.sub_step : null;
if (typeof entry.code === "string" && CODE.test(entry.code)) {
facts.code = entry.code;
}
} else if (entry.event === "migrate.failed") {
if (typeof entry.code === "string" && CODE.test(entry.code)) {
facts.code = entry.code;
}
} else if (entry.event === "restore.finished") {
facts.dataRestored = true;
} else if (entry.event === "restore.failed") {
facts.dataRestored = false;
}
}
return facts;
}
export interface MigrationDialogText {
@@ -79,6 +300,7 @@ export function migrationDialogText(
code: string,
logPath: string,
locale: string,
dataRestored = false,
): MigrationDialogText {
if (locale.toLowerCase().startsWith("en")) {
return {
@@ -86,7 +308,11 @@ export function migrationDialogText(
message: "The data migration for this MemHub upgrade did not finish.",
detail:
"To protect your data, MemHub will not start. Please attach the migration log to a GitHub issue and send it to us. " +
`(Error code: ${code})\n\nLog: ${logPath}`,
`(Error code: ${code})` +
(dataRestored
? "\n\nYour data was restored to its state before the upgrade."
: "") +
`\n\nLog: ${logPath}`,
openLog: "Open log",
quit: "Quit",
};
@@ -96,7 +322,9 @@ export function migrationDialogText(
message: "MemHub 升级时数据迁移没有完成。",
detail:
"为保护你的数据,MemHub 不会启动。请把迁移日志附在 GitHub 反馈里发给我们。" +
`(错误码:${code})\n\n日志:${logPath}`,
`(错误码:${code})` +
(dataRestored ? "\n\n记忆数据已恢复到升级前的状态。" : "") +
`\n\n日志:${logPath}`,
openLog: "打开日志",
quit: "退出",
};
@@ -117,6 +345,17 @@ export interface MigrationDialogDependencies {
openExternal(url: string): Promise<void> | void;
}
/** The running app and system, for the issue body. */
export interface MigrationDialogEnvironment {
appVersion?: string | null;
buildNumber?: number | null;
osName?: string | null;
osVersion?: string | null;
arch?: string | null;
/** The account home, shown as `~` in the issue body. */
home?: string | null;
}
/** Make sure the file the dialog points at exists and ends with this failure. */
function appendFinalLine(dataDir: string, code: string): void {
try {
@@ -150,12 +389,25 @@ export function showMigrationFailureDialog(options: {
dataDir: string | null;
locale: string;
dependencies: MigrationDialogDependencies;
environment?: MigrationDialogEnvironment;
}): boolean {
const code = migrationFailureCode(options.error, options.dataDir);
if (code === null || options.dataDir === null) return false;
const block = readBlock(options.dataDir);
const logFacts = readMigrationLogFacts(options.dataDir);
// The log says whether this attempt restored (是) or failed to (否); a
// failure before anything changed has no restore line and stays 未知
// rather than a misleading 否.
const dataRestored =
logFacts.dataRestored ?? (block?.dataRestored === true ? true : null);
appendFinalLine(options.dataDir, code);
const logPath = migrationLogPath(options.dataDir);
const text = migrationDialogText(code, logPath, options.locale);
const text = migrationDialogText(
code,
logPath,
options.locale,
dataRestored === true,
);
const choice = options.dependencies.showMessageBoxSync({
type: "error",
title: text.title,
@@ -168,8 +420,23 @@ export function showMigrationFailureDialog(options: {
});
if (choice === 0) {
options.dependencies.showItemInFolder(logPath);
const environment = options.environment ?? {};
const body = migrationIssueBody({
code,
fromVersion: logFacts.fromVersion,
fromBuild: logFacts.fromBuild,
toVersion: environment.appVersion ?? null,
toBuild: environment.buildNumber ?? null,
step: logFacts.step,
subStep: logFacts.subStep,
dataRestored,
osName: environment.osName ?? null,
osVersion: environment.osVersion ?? null,
arch: environment.arch ?? null,
logPath: displayPath(logPath, environment.home ?? null),
});
void Promise.resolve(
options.dependencies.openExternal(migrationIssueUrl(code)),
options.dependencies.openExternal(migrationIssueUrl(code, body)),
).catch(() => undefined);
}
return true;
+1 -1
View File
@@ -536,7 +536,7 @@ export function HistoryImport({
}
}}
>
<header className="memory-note-heading">
<header className="memory-add-heading">
<h2 id="history-import-title">导入历史包</h2>
<button
type="button"
@@ -1,17 +1,17 @@
// @vitest-environment jsdom
import type { MemoryNoteReceipt } from "@one-memory/contracts";
import type { MemoryAddReceipt } from "@one-memory/contracts";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { MemoryNoteComposer } from "./MemoryNoteComposer";
import { MemoryAddComposer, RECENT_ADDITIONS_KEY } from "./MemoryAddComposer";
it("opens a Spanish composer without saving, processing or enabling anything", async () => {
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<MemoryNoteComposer
<MemoryAddComposer
onSaved={vi.fn()}
onOpenMemory={vi.fn()}
onConfigureModel={vi.fn()}
@@ -32,24 +32,22 @@ it("opens a Spanish composer without saving, processing or enabling anything", a
await userEvent.click(
screen.getByRole("button", { name: "Cerrar nueva memoria" }),
);
expect(api.saveMemoryNote).not.toHaveBeenCalled();
expect(api.processMemoryNote).not.toHaveBeenCalled();
expect(api.addMemory).not.toHaveBeenCalled();
localStorage.clear();
});
const api = vi.hoisted(() => ({
getMemoryNotes: vi.fn(),
getMemoryNoteOperation: vi.fn(),
getMemoryAddStatus: vi.fn(),
getModelProfiles: vi.fn(),
processMemoryNote: vi.fn(),
saveMemoryNote: vi.fn(),
addMemory: vi.fn(),
apiErrorCode: vi.fn(),
getSources: vi.fn(),
}));
vi.mock("./api", () => api);
const note: MemoryNoteReceipt = {
evidence_id: "note-1",
const REQUEST_ID = "11111111-2222-4333-8444-555555555555";
const addition: MemoryAddReceipt = {
evidence_id: "evidence-1",
text: "项目周会不排在周五。",
scope: "all-apps",
created_at: "2026-09-12T00:00:00Z",
@@ -58,17 +56,29 @@ const note: MemoryNoteReceipt = {
memory_ids: [],
error: null,
};
/** This device remembers one earlier addition whose status reads as `receipt`. */
function remembered(...receipts: MemoryAddReceipt[]) {
localStorage.setItem(RECENT_ADDITIONS_KEY, JSON.stringify([REQUEST_ID]));
for (const receipt of receipts)
api.getMemoryAddStatus.mockResolvedValueOnce(receipt);
if (receipts.length)
api.getMemoryAddStatus.mockResolvedValue(receipts.at(-1));
}
beforeEach(() => {
vi.resetAllMocks();
api.getMemoryNotes.mockResolvedValue([]);
api.getMemoryNoteOperation.mockRejectedValue(new Error("not found"));
localStorage.removeItem(RECENT_ADDITIONS_KEY);
api.getMemoryAddStatus.mockRejectedValue(new Error("not found"));
api.getModelProfiles.mockResolvedValue([]);
api.saveMemoryNote.mockResolvedValue(note);
api.addMemory.mockResolvedValue(addition);
api.apiErrorCode.mockImplementation(
(reason) => reason?.response?.data?.detail ?? null,
);
});
afterEach(cleanup);
afterEach(() => {
cleanup();
localStorage.removeItem(RECENT_ADDITIONS_KEY);
});
it("OBS-18: reads the configured model before saying it is not configured", async () => {
let release: (value: unknown) => void = () => {};
@@ -95,13 +105,11 @@ it("OBS-18: reads the configured model before saying it is not configured", asyn
});
it("explains source verification failure without retrying or claiming success", async () => {
api.getMemoryNotes.mockResolvedValue([
{
...note,
state: "failed",
error: "hy_memory_write_verification_historical_support_only",
},
]);
remembered({
...addition,
state: "failed",
error: "hy_memory_write_verification_historical_support_only",
});
page();
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
expect(
@@ -110,7 +118,8 @@ it("explains source verification failure without retrying or claiming success",
),
).toBeTruthy();
expect(screen.queryByText("整理已完成")).toBeNull();
expect(api.processMemoryNote).not.toHaveBeenCalled();
expect(api.getMemoryAddStatus).toHaveBeenCalledWith(REQUEST_ID);
expect(screen.queryByRole("button", { name: "重新整理" })).toBeNull();
});
function page() {
@@ -119,7 +128,7 @@ function page() {
onConfigureModel = vi.fn(),
onPendingChange = vi.fn();
const view = render(
<MemoryNoteComposer
<MemoryAddComposer
{...{ onSaved, onOpenMemory, onConfigureModel, onPendingChange }}
/>,
);
@@ -132,28 +141,28 @@ function page() {
};
}
it("reports a saved note that still needs processing when the panel is closed", async () => {
api.getMemoryNotes.mockResolvedValue([note]);
it("reports a saved addition that still needs processing when the panel is closed", async () => {
remembered(addition);
const callbacks = page();
await waitFor(() =>
expect(callbacks.onPendingChange).toHaveBeenCalledWith({
evidenceId: note.evidence_id,
text: note.text,
evidenceId: addition.evidence_id,
text: addition.text,
state: "waiting_for_model",
}),
);
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
await waitFor(() =>
expect(callbacks.onPendingChange).toHaveBeenCalledWith({
evidenceId: note.evidence_id,
text: note.text,
evidenceId: addition.evidence_id,
text: addition.text,
state: "waiting_for_model",
}),
);
await userEvent.keyboard("{Escape}");
expect(callbacks.onPendingChange).toHaveBeenLastCalledWith({
evidenceId: note.evidence_id,
text: note.text,
evidenceId: addition.evidence_id,
text: addition.text,
state: "waiting_for_model",
});
});
@@ -163,7 +172,7 @@ it("saves from an empty library with the linked-app scope by default", async ()
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
const input = screen.getByRole("textbox", { name: "记下这件事" });
expect(document.activeElement).toBe(input);
await userEvent.type(input, note.text);
await userEvent.type(input, addition.text);
expect(
screen.getByRole("button", { name: "保存" }).hasAttribute("disabled"),
).toBe(false);
@@ -172,9 +181,9 @@ it("saves from an empty library with the linked-app scope by default", async ()
).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "保存" }));
await waitFor(() =>
expect(api.saveMemoryNote).toHaveBeenCalledWith(
expect(api.addMemory).toHaveBeenCalledWith(
{
text: note.text,
text: addition.text,
scope: "all-apps",
request_id: expect.any(String),
},
@@ -190,9 +199,9 @@ it("saves from an empty library with the linked-app scope by default", async ()
});
it("preserves draft and request identity after an uncertain save", async () => {
api.saveMemoryNote
api.addMemory
.mockRejectedValueOnce(new Error("timeout"))
.mockResolvedValueOnce(note);
.mockResolvedValueOnce(addition);
const emitted: Array<{ phase: string; status?: string; reason?: string }> =
[];
window.oneMemory = {
@@ -207,11 +216,11 @@ it("preserves draft and request identity after an uncertain save", async () => {
} as unknown as NonNullable<typeof window.oneMemory>;
page();
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
await userEvent.type(screen.getByRole("textbox"), note.text);
await userEvent.type(screen.getByRole("textbox"), addition.text);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(await screen.findByRole("alert")).toBeTruthy();
expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe(
note.text,
addition.text,
);
// A plain timeout error is not proof that Core refused the write: the copy
// says the result is unconfirmed, so the log must not say "failed".
@@ -220,36 +229,37 @@ it("preserves draft and request identity after an uncertain save", async () => {
expect(result?.reason).toBe("save_result_unconfirmed");
expect(result?.status).not.toBe("failed");
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(api.saveMemoryNote.mock.calls[0][0].request_id).toBe(
api.saveMemoryNote.mock.calls[1][0].request_id,
expect(api.addMemory.mock.calls[0][0].request_id).toBe(
api.addMemory.mock.calls[1][0].request_id,
);
delete (window as unknown as { oneMemory?: unknown }).oneMemory;
});
it("closes as saved when the POST response is lost but the operation exists", async () => {
api.saveMemoryNote.mockRejectedValueOnce(new Error("timeout"));
api.getMemoryNoteOperation.mockResolvedValueOnce({
...note,
it("closes as saved when the POST response is lost but the addition exists", async () => {
api.addMemory.mockRejectedValueOnce(new Error("timeout"));
api.getMemoryAddStatus.mockResolvedValueOnce({
...addition,
state: "pending",
});
const callbacks = page();
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
await userEvent.type(screen.getByRole("textbox"), note.text);
await userEvent.type(screen.getByRole("textbox"), addition.text);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
await waitFor(() =>
expect(api.getMemoryNoteOperation).toHaveBeenCalledOnce(),
);
const requestId = api.saveMemoryNote.mock.calls[0][0].request_id;
expect(api.getMemoryNoteOperation).toHaveBeenCalledWith(requestId);
await waitFor(() => expect(api.getMemoryAddStatus).toHaveBeenCalled());
const requestId = api.addMemory.mock.calls[0][0].request_id;
// The recovery read asks for exactly this request's addition.
expect(api.getMemoryAddStatus.mock.calls[0]).toEqual([requestId]);
// The recovered addition is remembered for the recent list.
expect(
JSON.parse(localStorage.getItem(RECENT_ADDITIONS_KEY) ?? "[]"),
).toEqual([requestId]);
expect(screen.getByText("已保存,正在后台沉淀记忆")).toBeTruthy();
expect(screen.queryByRole("dialog", { name: "添加记忆" })).toBeNull();
expect(callbacks.onSaved).toHaveBeenCalledOnce();
});
it("opens a persisted result in the existing memory editor", async () => {
api.getMemoryNotes.mockResolvedValue([
{ ...note, state: "ready", memory_ids: ["memory-7"] },
]);
remembered({ ...addition, state: "ready", memory_ids: ["memory-7"] });
const callbacks = page();
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
await userEvent.click(
@@ -269,12 +279,11 @@ it("closes with Escape and restores the trigger focus", async () => {
});
it("refreshes the workbench once asynchronous extraction becomes ready", async () => {
api.getMemoryNotes
.mockResolvedValueOnce([{ ...note, state: "running" }])
.mockResolvedValueOnce([{ ...note, state: "running" }]);
api.getMemoryNotes.mockResolvedValue([
{ ...note, state: "ready", memory_ids: ["new-memory"] },
]);
remembered(
{ ...addition, state: "running" },
{ ...addition, state: "running" },
{ ...addition, state: "ready", memory_ids: ["new-memory"] },
);
const callbacks = page();
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
expect(callbacks.onSaved).not.toHaveBeenCalled();
@@ -284,35 +293,37 @@ it("refreshes the workbench once asynchronous extraction becomes ready", async (
expect(await screen.findByRole("button", { name: "查看记忆" })).toBeTruthy();
});
it("does not ask for separate consent when an old note is waiting for permission", async () => {
api.getMemoryNotes.mockResolvedValue([
{ ...note, state: "waiting_for_permission" },
]);
api.processMemoryNote.mockResolvedValue({ ...note, state: "pending" });
it("shows an old addition waiting for permission without consent or a manual retry", async () => {
remembered({ ...addition, state: "waiting_for_permission" });
page();
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
expect(await screen.findByText("正在等待整理")).toBeTruthy();
expect(screen.queryByRole("button", { name: "模型授权" })).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "重新整理" }));
expect(api.processMemoryNote).toHaveBeenCalledExactlyOnceWith(
note.evidence_id,
expect.any(String),
);
// Processing is not a separate write: activating a model readmits parked
// work, so there is no "process again" control on an addition.
expect(screen.queryByRole("button", { name: "重新整理" })).toBeNull();
});
it("preserves ordinary processing-failure retry without opening model consent", async () => {
api.getMemoryNotes.mockResolvedValue([
{ ...note, state: "failed", error: "hy_memory_model_payment_required" },
]);
api.processMemoryNote.mockResolvedValue({ ...note, state: "pending" });
it("forgets a remembered addition Core no longer knows", async () => {
localStorage.setItem(RECENT_ADDITIONS_KEY, JSON.stringify([REQUEST_ID]));
api.getMemoryAddStatus.mockRejectedValue({ response: { status: 404 } });
page();
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
await userEvent.click(
await screen.findByRole("button", { name: "重新整理" }),
);
expect(api.processMemoryNote).toHaveBeenCalledExactlyOnceWith(
note.evidence_id,
expect.any(String),
await waitFor(() =>
expect(localStorage.getItem(RECENT_ADDITIONS_KEY)).toBe("[]"),
);
expect(screen.queryByRole("region", { name: "最近添加" })).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
});
it("keeps remembered additions when a status read fails for another reason", async () => {
localStorage.setItem(RECENT_ADDITIONS_KEY, JSON.stringify([REQUEST_ID]));
api.getMemoryAddStatus.mockRejectedValue({ response: { status: 503 } });
page();
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
expect(await screen.findByRole("alert")).toBeTruthy();
expect(
JSON.parse(localStorage.getItem(RECENT_ADDITIONS_KEY) ?? "[]"),
).toEqual([REQUEST_ID]);
});
@@ -1,20 +1,13 @@
import type { MemoryNoteReceipt, ModelProfile } from "@one-memory/contracts";
import { FilePlus2, RefreshCw, X } from "lucide-react";
import type { MemoryAddReceipt, ModelProfile } from "@one-memory/contracts";
import { FilePlus2, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
getMemoryNoteOperation,
getMemoryNotes,
getModelProfiles,
processMemoryNote,
saveMemoryNote,
} from "./api";
import { addMemory, getMemoryAddStatus, getModelProfiles } from "./api";
import { useMemoryLanguage } from "./memory-language";
import { memoryWriteErrorText } from "./model-error-text";
import { subscribePoll } from "./poll-scheduler";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal } from "./write-outcome";
const states: Record<MemoryNoteReceipt["state"], string> = {
const states: Record<MemoryAddReceipt["state"], string> = {
waiting_for_model: "原话已保存,等待可用模型",
waiting_for_permission: "正在等待整理",
pending: "正在等待整理",
@@ -24,13 +17,87 @@ const states: Record<MemoryNoteReceipt["state"], string> = {
no_memories: "原话已保存,未提取出可用记忆",
};
export interface PendingMemoryNote {
export interface PendingMemoryAdd {
evidenceId: string;
text: string;
state: Exclude<MemoryNoteReceipt["state"], "ready" | "no_memories">;
state: Exclude<MemoryAddReceipt["state"], "ready" | "no_memories">;
}
export function MemoryNoteComposer({
/** One addition this device made, with the request id its status is read by. */
interface Addition {
requestId: string;
receipt: MemoryAddReceipt;
}
/** Core has no list of additions: this device remembers the request ids it
* submitted and reads each one's status from
* `GET /api/v2/memories/additions/{request_id}`. */
export const RECENT_ADDITIONS_KEY = "memhub.memory-additions.recent";
const RECENT_ADDITIONS_MAX = 20;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
function readRecentAdditions(): string[] {
try {
const value: unknown = JSON.parse(
window.localStorage.getItem(RECENT_ADDITIONS_KEY) ?? "[]",
);
if (!Array.isArray(value)) return [];
return value
.filter((id): id is string => typeof id === "string" && UUID.test(id))
.slice(0, RECENT_ADDITIONS_MAX);
} catch {
return [];
}
}
function writeRecentAdditions(ids: string[]) {
try {
window.localStorage.setItem(
RECENT_ADDITIONS_KEY,
JSON.stringify(ids.slice(0, RECENT_ADDITIONS_MAX)),
);
} catch {
// Storage is a convenience for the recent list; the memory is saved.
}
}
function trackRecentAddition(requestId: string) {
writeRecentAdditions([
requestId,
...readRecentAdditions().filter((id) => id !== requestId),
]);
}
function isNotFound(reason: unknown): boolean {
return (
!!reason &&
typeof reason === "object" &&
(reason as { response?: { status?: unknown } }).response?.status === 404
);
}
/** Read the status of every remembered addition. Unknown ids (404) are
* forgotten; any other failure throws so the caller keeps its last state. */
async function loadRecentAdditions(): Promise<Addition[]> {
const ids = readRecentAdditions();
const results = await Promise.allSettled(
ids.map((id) => getMemoryAddStatus(id)),
);
const additions: Addition[] = [];
const gone = new Set<string>();
results.forEach((result, index) => {
const requestId = ids[index] as string;
if (result.status === "fulfilled")
additions.push({ requestId, receipt: result.value });
else if (isNotFound(result.reason)) gone.add(requestId);
else throw result.reason;
});
if (gone.size)
writeRecentAdditions(readRecentAdditions().filter((id) => !gone.has(id)));
return additions;
}
export function MemoryAddComposer({
onSaved,
onOpenMemory,
onConfigureModel,
@@ -40,13 +107,13 @@ export function MemoryNoteComposer({
onSaved(): void;
onOpenMemory(id: string): void;
onConfigureModel(): void;
onPendingChange?(note: PendingMemoryNote | null): void;
onPendingChange?(addition: PendingMemoryAdd | null): void;
suspended?: boolean;
}) {
const { text: copy } = useMemoryLanguage();
const [open, setOpen] = useState(false);
const [text, setText] = useState("");
const [notes, setNotes] = useState<MemoryNoteReceipt[]>([]);
const [additions, setAdditions] = useState<Addition[]>([]);
const [model, setModel] = useState<ModelProfile | null>(null);
// Before the read settles, `model` is null for the same reason it is null
// when nothing is configured. Showing the configured-model copy anyway made
@@ -58,7 +125,7 @@ export function MemoryNoteComposer({
const [savedToast, setSavedToast] = useState("");
const panel = useRef<HTMLElement>(null);
const isOpen = useRef(false);
const notesRequest = useRef(0);
const additionsRequest = useRef(0);
const intent = useRef<{ text: string; requestId: string } | null>(null);
const completed = useRef(new Set<string>());
const lastSaved = useRef<string | null>(null);
@@ -67,49 +134,52 @@ export function MemoryNoteComposer({
const onPendingChangeRef = useRef(onPendingChange);
onPendingChangeRef.current = onPendingChange;
const reportPending = useCallback((value: MemoryNoteReceipt[]) => {
const note = value.find(
(
item,
): item is MemoryNoteReceipt & {
state: PendingMemoryNote["state"];
} => item.state !== "ready" && item.state !== "no_memories",
);
const reportPending = useCallback((value: Addition[]) => {
const pending = value
.map((item) => item.receipt)
.find(
(
item,
): item is MemoryAddReceipt & {
state: PendingMemoryAdd["state"];
} => item.state !== "ready" && item.state !== "no_memories",
);
onPendingChangeRef.current?.(
note
pending
? {
evidenceId: note.evidence_id,
text: note.text,
state: note.state,
evidenceId: pending.evidence_id,
text: pending.text,
state: pending.state,
}
: null,
);
}, []);
const refreshNotes = useCallback(
const refreshAdditions = useCallback(
async (failure: string, notifyResults = false) => {
const request = ++notesRequest.current;
const request = ++additionsRequest.current;
try {
const value = await getMemoryNotes();
if (!isOpen.current || request !== notesRequest.current) return;
setNotes(value);
const value = await loadRecentAdditions();
if (!isOpen.current || request !== additionsRequest.current) return;
setAdditions(value);
reportPending(value);
if (!notifyResults) return;
const latest = value.find(
(note) => note.evidence_id === lastSaved.current,
(item) => item.receipt.evidence_id === lastSaved.current,
);
if (latest) setNotice(states[latest.state]);
if (latest) setNotice(states[latest.receipt.state]);
const newResults = value.filter(
(note) =>
note.state === "ready" && !completed.current.has(note.evidence_id),
({ receipt }) =>
receipt.state === "ready" &&
!completed.current.has(receipt.evidence_id),
);
if (newResults.length) {
for (const note of newResults)
completed.current.add(note.evidence_id);
for (const { receipt } of newResults)
completed.current.add(receipt.evidence_id);
onSavedRef.current();
}
} catch {
if (isOpen.current && request === notesRequest.current)
if (isOpen.current && request === additionsRequest.current)
setError(failure);
}
},
@@ -122,7 +192,7 @@ export function MemoryNoteComposer({
let cancelled = false;
const before = document.activeElement;
panel.current?.querySelector("textarea")?.focus();
void refreshNotes("最近添加的内容读取失败。");
void refreshAdditions("最近添加的内容读取失败。");
setModelRead(false);
getModelProfiles()
.then((value) => {
@@ -138,14 +208,14 @@ export function MemoryNoteComposer({
return () => {
cancelled = true;
isOpen.current = false;
notesRequest.current += 1;
additionsRequest.current += 1;
if (before instanceof HTMLElement && before.isConnected) before.focus();
};
}, [open, refreshNotes, suspended]);
}, [open, refreshAdditions, suspended]);
useEffect(() => {
if (open || suspended || !onPendingChange) return;
void getMemoryNotes()
void loadRecentAdditions()
.then((value) => reportPending(value))
.catch(() => {
// The page already keeps the last known state when a background read
@@ -153,20 +223,20 @@ export function MemoryNoteComposer({
});
}, [open, onPendingChange, reportPending, suspended]);
const working = notes.some(
(note) => note.state === "pending" || note.state === "running",
const working = additions.some(
({ receipt }) => receipt.state === "pending" || receipt.state === "running",
);
useEffect(() => {
if (!open || suspended || !working) return;
const stop = subscribePoll({
// The note list and the processing status are two surfaces; sharing one
// key would make one tick read the wrong endpoint for the other caller.
key: "note-processing",
// A key of its own: sharing one with another surface would make one
// tick read the wrong endpoint for the other caller.
key: "memory-add-processing",
intervalMs: 1_500,
run: () => refreshNotes("整理状态暂时不可用,原话已保存。", true),
run: () => refreshAdditions("整理状态暂时不可用,原话已保存。", true),
});
return stop;
}, [open, working, refreshNotes, suspended]);
}, [open, working, refreshAdditions, suspended]);
useEffect(() => {
if (!savedToast) return;
@@ -177,15 +247,16 @@ export function MemoryNoteComposer({
function close() {
if (busy) return;
isOpen.current = false;
notesRequest.current += 1;
additionsRequest.current += 1;
setOpen(false);
}
function remember(receipt: MemoryNoteReceipt) {
setNotes((current) => {
function remember(addition: Addition) {
trackRecentAddition(addition.requestId);
setAdditions((current) => {
const next = [
receipt,
...current.filter((note) => note.evidence_id !== receipt.evidence_id),
addition,
...current.filter((item) => item.requestId !== addition.requestId),
];
reportPending(next);
return next;
@@ -196,28 +267,29 @@ export function MemoryNoteComposer({
if (busy || !text.trim()) return;
if (intent.current?.text !== text)
intent.current = { text, requestId: crypto.randomUUID() };
const requestId = intent.current.requestId;
setBusy(true);
setError("");
setNotice("");
const trace = traceUiAction("memory", "submit");
const completeSave = (receipt: MemoryNoteReceipt) => {
remember(receipt);
const completeSave = (receipt: MemoryAddReceipt) => {
remember({ requestId, receipt });
lastSaved.current = receipt.evidence_id;
setText("");
intent.current = null;
setNotice("");
setSavedToast("已保存,正在后台沉淀记忆");
isOpen.current = false;
notesRequest.current += 1;
additionsRequest.current += 1;
setOpen(false);
onSaved();
};
try {
const receipt = await saveMemoryNote(
const receipt = await addMemory(
{
text,
scope: "all-apps",
request_id: intent.current.requestId,
request_id: requestId,
},
trace.traceId,
);
@@ -225,14 +297,14 @@ export function MemoryNoteComposer({
trace.done("succeeded");
} catch {
try {
completeSave(await getMemoryNoteOperation(intent.current.requestId));
// The save landed but its response was lost; the note is still saved,
// so the interaction succeeded even though this attempt did not.
completeSave(await getMemoryAddStatus(requestId));
// The save landed but its response was lost; the memory is still
// saved, so the interaction succeeded even though this attempt did not.
trace.done("succeeded", { reason: "recovered_from_operation" });
} catch {
setError("未能确认保存结果,输入已保留。重试不会重复添加。");
// The copy already says the result is *unconfirmed*: neither the POST
// nor the receipt re-read came back. Only a definite refusal is a
// nor the status re-read came back. Only a definite refusal is a
// failure, and here there is none, so the log must not claim one.
trace.done("unknown", { reason: "save_result_unconfirmed" });
}
@@ -241,45 +313,18 @@ export function MemoryNoteComposer({
}
}
async function retry(note: MemoryNoteReceipt) {
if (busy) return;
setBusy(true);
setError("");
// Starting processing is a real write, so it needs its own chain.
const trace = traceUiAction("memory", "retry");
try {
remember(await processMemoryNote(note.evidence_id, trace.traceId));
trace.done("succeeded");
} catch (reason) {
const refused = isDefiniteRefusal(reason);
trace.done(refused ? "failed" : "unknown", {
reason: refused ? "note_process_failed" : "note_process_result_unknown",
});
// Only a definite refusal may be phrased as "did not start"; a timeout or
// 5xx means the outcome is unknown, and saying it failed would be a claim
// the UI cannot support.
setError(
refused
? "整理未能开始,原话仍已保存。"
: "整理结果尚未确认,原话仍已保存。请刷新后核对。",
);
} finally {
setBusy(false);
}
}
return (
<>
<button
type="button"
className="secondary memory-note-trigger"
data-tour-id="memory-note-trigger"
className="secondary memory-add-trigger"
data-tour-id="memory-add-trigger"
onClick={() => setOpen(true)}
>
<FilePlus2 size={16} aria-hidden="true" /> {copy("添加记忆")}
</button>
{savedToast && (
<p className="memory-note-toast" role="status">
<p className="memory-add-toast" role="status">
{copy(savedToast)}
</p>
)}
@@ -287,9 +332,9 @@ export function MemoryNoteComposer({
<div className="consent-backdrop" hidden={suspended}>
<section
ref={panel}
className="consent-sheet memory-note-sheet"
className="consent-sheet memory-add-sheet"
{...(suspended ? {} : { role: "dialog", "aria-modal": true })}
aria-labelledby="memory-note-title"
aria-labelledby="memory-add-title"
onKeyDown={(event) => {
if (suspended) return;
if (event.key === "Escape") {
@@ -314,8 +359,8 @@ export function MemoryNoteComposer({
}
}}
>
<header className="memory-note-heading">
<h2 id="memory-note-title">{copy("添加记忆")}</h2>
<header className="memory-add-heading">
<h2 id="memory-add-title">{copy("添加记忆")}</h2>
<button
className="quiet"
type="button"
@@ -333,11 +378,11 @@ export function MemoryNoteComposer({
void save();
}}
>
<label className="memory-note-label" htmlFor="memory-note-text">
<label className="memory-add-label" htmlFor="memory-add-text">
{copy("记下这件事")}
</label>
<textarea
id="memory-note-text"
id="memory-add-text"
rows={5}
maxLength={12000}
value={text}
@@ -345,7 +390,7 @@ export function MemoryNoteComposer({
placeholder={copy("例如:项目周会不排在周五。")}
onChange={(event) => setText(event.target.value)}
/>
<p className="memory-note-model">
<p className="memory-add-model">
{copy("整理模型:")}
{model?.name ??
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
@@ -375,58 +420,49 @@ export function MemoryNoteComposer({
</button>
</div>
</form>
{notes.length > 0 && (
{additions.length > 0 && (
<section
className="memory-note-recent"
className="memory-add-recent"
aria-label={copy("最近添加")}
>
<h3>{copy("最近添加")}</h3>
<ul>
{notes.map((note) => (
<li key={note.evidence_id}>
<p>{note.text}</p>
<small>{copy(states[note.state])}</small>
{memoryWriteErrorText(note.error) && (
{additions.map(({ requestId, receipt }) => (
<li key={requestId}>
<p>{receipt.text}</p>
<small>{copy(states[receipt.state])}</small>
{memoryWriteErrorText(receipt.error) && (
<p className="error-banner">
{copy(memoryWriteErrorText(note.error) ?? "")}
{copy(memoryWriteErrorText(receipt.error) ?? "")}
</p>
)}
{note.error === "hy_memory_model_payment_required" && (
{receipt.error === "hy_memory_model_payment_required" && (
<p className="error-banner">
{copy(
"模型服务要求检查余额或付款状态,原话仍已保存。",
)}
</p>
)}
<div className="memory-note-actions">
{note.memory_ids.map((id, index) => (
<button
key={id}
type="button"
className="quiet"
onClick={() => {
close();
onOpenMemory(id);
}}
>
{copy("查看记忆")}
{note.memory_ids.length > 1 ? ` ${index + 1}` : ""}
</button>
))}
{(note.state === "failed" ||
note.state === "waiting_for_model" ||
note.state === "waiting_for_permission") && (
<button
type="button"
className="quiet"
disabled={busy}
onClick={() => void retry(note)}
>
<RefreshCw size={14} aria-hidden="true" />
{copy("重新整理")}
</button>
)}
</div>
{receipt.memory_ids.length > 0 && (
<div className="memory-add-actions">
{receipt.memory_ids.map((id, index) => (
<button
key={id}
type="button"
className="quiet"
onClick={() => {
close();
onOpenMemory(id);
}}
>
{copy("查看记忆")}
{receipt.memory_ids.length > 1
? ` ${index + 1}`
: ""}
</button>
))}
</div>
)}
</li>
))}
</ul>
+5 -5
View File
@@ -84,11 +84,11 @@ vi.mock("./api", async (importOriginal) => ({
getModelProfiles: reads.profiles,
}));
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
vi.mock("./MemoryNoteComposer", () => ({
MemoryNoteComposer: ({
vi.mock("./MemoryAddComposer", () => ({
MemoryAddComposer: ({
onPendingChange,
}: {
onPendingChange?(note: {
onPendingChange?(addition: {
evidenceId: string;
text: string;
state: string;
@@ -98,7 +98,7 @@ vi.mock("./MemoryNoteComposer", () => ({
type="button"
onClick={() =>
onPendingChange?.({
evidenceId: "note-1",
evidenceId: "addition-1",
text: "项目周会统一安排在周三上午。",
state: "waiting_for_model",
})
@@ -196,7 +196,7 @@ function setup() {
reads.profiles.mockResolvedValue([]);
}
it("keeps a saved note visible while it waits for model processing", async () => {
it("keeps an added memory visible while it waits for model processing", async () => {
setup();
render(<MemorySpace simplified onConfigureModel={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "补充记忆" }));
@@ -35,7 +35,7 @@ vi.mock("./api", () => ({
apiErrorCode: () => null,
}));
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
vi.mock("./MemoryNoteComposer", () => ({ MemoryNoteComposer: () => null }));
vi.mock("./MemoryAddComposer", () => ({ MemoryAddComposer: () => null }));
vi.mock("./MemoryMap", () => ({
MemoryMap: ({
hidden,
+12 -16
View File
@@ -79,9 +79,8 @@ const {
getGraphWidth,
decideGraphWidth,
getModelProfiles,
getMemoryNotes,
saveMemoryNote,
processMemoryNote,
getMemoryAddStatus,
addMemory,
} = vi.hoisted(() => ({
apiErrorCode: vi.fn(),
getSdkRaw: vi.fn(),
@@ -94,9 +93,8 @@ const {
getGraphWidth: vi.fn(),
decideGraphWidth: vi.fn(),
getModelProfiles: vi.fn(),
getMemoryNotes: vi.fn(),
saveMemoryNote: vi.fn(),
processMemoryNote: vi.fn(),
getMemoryAddStatus: vi.fn(),
addMemory: vi.fn(),
}));
const _LIBRARY_REFRESH_MS = 10_000;
@@ -113,9 +111,8 @@ vi.mock("./api", () => ({
getGraphWidth,
decideGraphWidth,
getModelProfiles,
getMemoryNotes,
saveMemoryNote,
processMemoryNote,
getMemoryAddStatus,
addMemory,
}));
function deferred<T>() {
@@ -171,7 +168,7 @@ describe("MemorySpace", () => {
getModelProfiles.mockResolvedValue([
{ profile_id: "profile-1", is_active: true },
]);
getMemoryNotes.mockResolvedValue([]);
getMemoryAddStatus.mockRejectedValue(new Error("not found"));
setExtractionWindow.mockResolvedValue({ window_preset: "1y" });
manualExtract.mockResolvedValue({
promoted: 25,
@@ -197,8 +194,7 @@ describe("MemorySpace", () => {
setExtractionWindow,
manualExtract,
decideGraphWidth,
saveMemoryNote,
processMemoryNote,
addMemory,
])
expect(write).not.toHaveBeenCalled();
}
@@ -281,7 +277,7 @@ describe("MemorySpace", () => {
const view = render(<MemorySpace />);
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
const draft = screen.getByLabelText("记下这件事") as HTMLTextAreaElement;
await userEvent.type(draft, "Unsent note");
await userEvent.type(draft, "Unsent draft");
view.rerender(<MemorySpace active={false} />);
expect(screen.queryByRole("dialog")).toBeNull();
const event = new KeyboardEvent("keydown", {
@@ -293,7 +289,7 @@ describe("MemorySpace", () => {
expect(event.defaultPrevented).toBe(false);
view.rerender(<MemorySpace />);
expect(screen.getByLabelText("记下这件事")).toBe(draft);
expect(draft.value).toBe("Unsent note");
expect(draft.value).toBe("Unsent draft");
expectNoTeachingWrites();
});
});
@@ -801,7 +797,7 @@ describe("MemorySpace", () => {
expect(screen.queryByText(/revision|evidence_ids/)).toBeNull();
});
it("hands a saved answer to HY as a note instead of writing the memory itself", async () => {
it("hands a saved answer to HY as an added memory instead of writing the memory itself", async () => {
askMemoriesStream.mockImplementation(async function* () {
yield {
type: "final",
@@ -821,7 +817,7 @@ describe("MemorySpace", () => {
);
await waitFor(() =>
expect(saveMemoryNote).toHaveBeenCalledWith(
expect(addMemory).toHaveBeenCalledWith(
{
request_id: expect.any(String),
text: "Grounded answer",
+15 -20
View File
@@ -27,6 +27,7 @@ import {
useState,
} from "react";
import {
addMemory,
apiErrorCode,
askMemoriesStream,
deleteSdkMemory,
@@ -37,7 +38,6 @@ import {
getSdkMemoryLibrary,
manualExtract,
retryUnknownMemoryProcessing,
saveMemoryNote,
setExtractionWindow,
updateSdkMemory,
} from "./api";
@@ -60,10 +60,7 @@ import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
import "./r6-memory.css";
import {
MemoryNoteComposer,
type PendingMemoryNote,
} from "./MemoryNoteComposer";
import { MemoryAddComposer, type PendingMemoryAdd } from "./MemoryAddComposer";
import type { MemoryArrival } from "./memory-arrival-state";
import {
blockReasonLabel,
@@ -326,9 +323,7 @@ export function MemorySpace({
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
);
const [pendingNote, setPendingNote] = useState<PendingMemoryNote | null>(
null,
);
const [pendingAdd, setPendingAdd] = useState<PendingMemoryAdd | null>(null);
const [checkingSetup, setCheckingSetup] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: Source authority changes invalidate an in-flight setup snapshot.
useEffect(() => {
@@ -553,11 +548,11 @@ export function MemorySpace({
async function saveAnswer(draft: { content: string }) {
// HY decides what becomes a memory; the reader choosing to keep the
// answer is what makes it their words for HY to read.
// Saving an answer writes a memory note, so it needs the same ui.action /
// ui.result pair as the composer's note save.
// Saving an answer adds a memory, so it needs the same ui.action /
// ui.result pair as the composer's save.
const trace = traceUiAction("memory", "submit");
try {
await saveMemoryNote(
await addMemory(
{
request_id: crypto.randomUUID(),
text: draft.content,
@@ -569,8 +564,8 @@ export function MemorySpace({
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: isDefiniteRefusal(reason)
? "answer_note_save_failed"
: "answer_note_save_result_unknown",
? "answer_memory_add_failed"
: "answer_memory_add_result_unknown",
});
throw reason;
}
@@ -770,11 +765,11 @@ export function MemorySpace({
: null}
</span>
</div>
<MemoryNoteComposer
<MemoryAddComposer
suspended={!active}
onPendingChange={setPendingNote}
onPendingChange={setPendingAdd}
onSaved={() => {
// A finished note is a new memory: re-read the page now rather
// A finished addition is a new memory: re-read the page now rather
// than leave it for the next poll.
bumpSdkMap();
}}
@@ -955,13 +950,13 @@ export function MemorySpace({
hasMemories={(sdkLibrary.page?.total ?? 0) > 0}
onConfigureModel={onConfigureModel}
/>
{simplified && pendingNote ? (
<p className="memory-pending-note" role="status">
{simplified && pendingAdd ? (
<p className="memory-pending-add" role="status">
<span>
{t("memory:原话已保存,正在等待整理:")}
{pendingNote.text}
{pendingAdd.text}
</span>
{pendingNote.state === "waiting_for_model" ? (
{pendingAdd.state === "waiting_for_model" ? (
<button type="button" onClick={onConfigureModel}>
{t("memory:配置整理模型")}
</button>
@@ -22,8 +22,7 @@ import {
getImportJob,
getImportJobs,
getLiveSyncHealth,
getMemoryNoteOperation,
getMemoryNotes,
getMemoryAddStatus,
getModelCreationOperation,
getModelProfiles,
getSourceImportReceipt,
@@ -258,11 +257,13 @@ describe("source import receipt transport validation", () => {
);
});
});
describe("memory-note list transport validation", () => {
it("accepts a list of valid note receipts", async () => {
const note = {
describe("memory-add status transport validation", () => {
const requestId = "22222222-2222-4222-8222-222222222222";
it("reads a valid receipt from the additions status route", async () => {
const receipt = {
evidence_id: "11111111-1111-4111-8111-111111111111",
text: "user note",
text: "user memory",
scope: "all-apps",
created_at: "2026-09-13T00:00:00Z",
state: "pending",
@@ -270,23 +271,27 @@ describe("memory-note list transport validation", () => {
memory_ids: [],
error: null,
};
fake.get.mockResolvedValue({ data: [note] });
await expect(getMemoryNotes()).resolves.toEqual([note]);
});
it("rejects a malformed note before the composer can crash on it", async () => {
fake.get.mockResolvedValue({ data: [null] });
await expect(getMemoryNotes()).rejects.toThrow(
"memory_note_receipt_invalid",
fake.get.mockResolvedValue({ data: receipt });
await expect(getMemoryAddStatus(requestId)).resolves.toEqual(receipt);
expect(fake.get).toHaveBeenLastCalledWith(
`/api/v2/memories/additions/${requestId}`,
);
});
it("rejects a malformed operation receipt before it reaches the composer", async () => {
it("rejects a malformed status receipt before it reaches the composer", async () => {
fake.get.mockResolvedValue({ data: { state: "pending" } });
await expect(getMemoryNoteOperation("operation-1")).rejects.toThrow(
"memory_note_receipt_invalid",
await expect(getMemoryAddStatus(requestId)).rejects.toThrow(
"memory_add_receipt_invalid",
);
});
it("refuses a non-UUID request id without a request", async () => {
fake.get.mockClear();
await expect(getMemoryAddStatus("operation-1")).rejects.toThrow(
"request_id_invalid",
);
expect(fake.get).not.toHaveBeenCalled();
});
});
describe("history file recovery transport validation", () => {
+21 -47
View File
@@ -14,9 +14,9 @@ import {
type ImportJob,
type LiveSyncHealth,
type ManualExtractReceipt,
type MemoryAddInput,
type MemoryAddReceipt,
type MemoryGraphResponse,
type MemoryNoteInput,
type MemoryNoteReceipt,
type MemoryProcessingSummary,
type ModelCreationOperation,
type ModelProbeOperationReceipt,
@@ -42,18 +42,19 @@ import {
// stays here is the transport adapter: axios wiring, recovery clients, and the
// UI-facing error mapping.
import {
buildMemoryNoteSaveRequest,
buildMemoryAddRequest,
buildModelProfileCreateRequest,
buildModelProfileUpdateRequest,
buildSourceImportRequest,
buildSourceSyncRequest,
classifyModelRemove,
hasSourceSyncTarget,
memoryAddStatusPath,
normalizeManualExtractEvidence,
parseExtractionSettingsReceipt,
parseGraphWidthDecisionReceipt,
parseManualExtractReceipt,
parseMemoryNoteReceipt,
parseMemoryAddReceipt,
parseModelProfileReceipt,
SourceSyncInputError,
} from "@one-memory/operations";
@@ -149,55 +150,29 @@ export async function getCallReferencesRequest(
export type { HealthResponse };
export async function saveMemoryNote(
note: MemoryNoteInput,
/** Add one memory directly (`POST /api/v2/memories`). */
export async function addMemory(
input: MemoryAddInput,
traceId?: string,
): Promise<MemoryNoteReceipt> {
const request = buildMemoryNoteSaveRequest(note);
const response = await client.post<MemoryNoteReceipt>(
): Promise<MemoryAddReceipt> {
const request = buildMemoryAddRequest(input);
const response = await client.post<MemoryAddReceipt>(
request.path,
request.body,
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
);
const receipt = parseMemoryNoteReceipt(response.data);
if (receipt === null) throw new Error("memory_note_receipt_invalid");
const receipt = parseMemoryAddReceipt(response.data);
if (receipt === null) throw new Error("memory_add_receipt_invalid");
return receipt;
}
export async function getMemoryNotes(): Promise<MemoryNoteReceipt[]> {
const response = await client.get<unknown>("/api/v1/memory-notes");
if (!Array.isArray(response.data))
throw new Error("memory_note_receipt_invalid");
const notes = response.data.map(parseMemoryNoteReceipt);
if (notes.some((note) => note === null))
throw new Error("memory_note_receipt_invalid");
return notes as MemoryNoteReceipt[];
}
export async function getMemoryNoteOperation(
/** Status of one addition, keyed by the request id it was submitted with. */
export async function getMemoryAddStatus(
requestId: string,
): Promise<MemoryNoteReceipt> {
const response = await client.get<unknown>(
`/api/v1/memory-notes/operations/${encodeURIComponent(requestId)}`,
);
const receipt = parseMemoryNoteReceipt(response.data);
if (receipt === null) throw new Error("memory_note_receipt_invalid");
return receipt;
}
export async function processMemoryNote(
id: string,
traceId?: string,
): Promise<MemoryNoteReceipt> {
const response = await client.post<MemoryNoteReceipt>(
`/api/v1/memory-notes/${encodeURIComponent(id)}/process`,
{},
// Starting processing is a write; carry the trace id so the ui.action /
// ui.result pair joins the kernel legs in one chain.
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
);
const receipt = parseMemoryNoteReceipt(response.data);
if (receipt === null) throw new Error("memory_note_receipt_invalid");
): Promise<MemoryAddReceipt> {
const response = await client.get<unknown>(memoryAddStatusPath(requestId));
const receipt = parseMemoryAddReceipt(response.data);
if (receipt === null) throw new Error("memory_add_receipt_invalid");
return receipt;
}
@@ -1037,9 +1012,8 @@ export async function setExtractionWindow(
{ window_preset: windowPreset },
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
);
// The CLI validates this receipt (`ui-write-flows-extended.ts`
// `executeExtractionWindowSet` -> `ui_write_receipt_invalid`); the Web stored
// the raw body, so a malformed 200 became a saved preset.
// The receipt is validated before it is trusted; the Web used to store the
// raw body, so a malformed 200 became a saved preset.
const receipt = parseExtractionSettingsReceipt(response.data, windowPreset);
if (receipt === null) throw new Error("extraction_settings_receipt_invalid");
return receipt;
+5 -5
View File
@@ -523,7 +523,7 @@ button.rail-button.active::before {
font-size: 12px;
color: var(--tx3);
}
.memory-note-trigger {
.memory-add-trigger {
height: 38px;
border-radius: 10px;
grid-column: 3;
@@ -1430,12 +1430,12 @@ button.map-related {
color: var(--dg);
border: 1px solid var(--dg-bd);
}
.memory-note-sheet .memory-note-recent {
.memory-add-sheet .memory-add-recent {
background: transparent;
padding: 16px 0 0;
border-radius: 0;
}
.memory-note-recent li {
.memory-add-recent li {
background: transparent;
}
.memory-query-bar {
@@ -1935,7 +1935,7 @@ button.map-related {
grid-row: 1;
justify-self: center;
}
.memory-note-trigger {
.memory-add-trigger {
grid-column: 3;
}
.desktop-shell .core-memory-map {
@@ -2053,7 +2053,7 @@ button.map-related {
.memory-instrument-header h1 {
font-size: 20px;
}
.memory-note-trigger {
.memory-add-trigger {
font-size: 12px;
padding: 7px;
}
+1 -2
View File
@@ -28,8 +28,7 @@ export type PollKey =
| "applications"
| "calls"
| "model-profiles"
| "note-evidence"
| "note-processing"
| "memory-add-processing"
| "health";
export interface PollTask {
+6 -6
View File
@@ -22,7 +22,7 @@
font-size: 13px;
color: #637786;
}
.r6-memory .memory-pending-note {
.r6-memory .memory-pending-add {
display: flex;
align-items: center;
justify-content: space-between;
@@ -36,11 +36,11 @@
font-size: 13px;
line-height: 22px;
}
.r6-memory .memory-pending-note span {
.r6-memory .memory-pending-add span {
min-width: 0;
overflow-wrap: anywhere;
}
.r6-memory .memory-pending-note button {
.r6-memory .memory-pending-add button {
flex: none;
min-height: 36px;
padding: 0 14px;
@@ -51,7 +51,7 @@
font: inherit;
cursor: pointer;
}
.r6-memory .memory-note-trigger {
.r6-memory .memory-add-trigger {
margin-left: auto;
min-height: 44px;
border-radius: 8px;
@@ -59,7 +59,7 @@
justify-content: center;
gap: 0;
}
.r6-memory .memory-note-trigger > svg {
.r6-memory .memory-add-trigger > svg {
display: none;
}
.r6-memory .core-memory-map {
@@ -267,7 +267,7 @@
.r6-memory .map-library {
padding: 16px 10px;
}
.r6-memory .memory-note-trigger {
.r6-memory .memory-add-trigger {
margin-left: 0;
}
}
+22 -22
View File
@@ -3270,22 +3270,22 @@ button.onboarding-skip {
border-style: dashed;
color: var(--tx2);
}
.memory-note-trigger,
.memory-note-heading,
.memory-note-actions,
.memory-note-consent {
.memory-add-trigger,
.memory-add-heading,
.memory-add-actions,
.memory-add-consent {
display: flex;
align-items: center;
gap: 8px;
}
.memory-note-heading {
.memory-add-heading {
justify-content: space-between;
}
.memory-note-sheet {
.memory-add-sheet {
max-height: calc(100dvh - 48px);
overflow-y: auto;
}
.memory-note-sheet textarea {
.memory-add-sheet textarea {
width: 100%;
min-height: 128px;
resize: vertical;
@@ -3296,19 +3296,19 @@ button.onboarding-skip {
border: 1px solid var(--bd2);
border-radius: 6px;
}
.memory-note-label {
.memory-add-label {
display: block;
margin: 12px 0 6px;
}
.memory-note-model {
.memory-add-model {
color: var(--tx2);
font-size: 12px;
}
.memory-note-consent {
.memory-add-consent {
align-items: flex-start;
font-size: 13px;
}
.memory-note-consent input {
.memory-add-consent input {
width: 16px;
height: 16px;
min-height: 16px;
@@ -3317,18 +3317,18 @@ button.onboarding-skip {
margin-top: 4px;
accent-color: var(--ac);
}
.memory-note-sheet {
.memory-add-sheet {
color: var(--tx);
background: rgba(248, 250, 252, 0.96);
}
.memory-note-consent {
.memory-add-consent {
line-height: 1.7;
}
.memory-note-trigger {
.memory-add-trigger {
justify-self: end;
white-space: nowrap;
}
.memory-note-toast {
.memory-add-toast {
position: fixed;
z-index: 40;
left: 50%;
@@ -3357,33 +3357,33 @@ button.onboarding-skip {
justify-self: start;
}
}
.memory-note-recent {
.memory-add-recent {
border-top: 1px solid var(--bd);
margin-top: 20px;
padding-top: 12px;
}
.memory-note-recent h3 {
.memory-add-recent h3 {
font-size: 14px;
}
.memory-note-recent ul {
.memory-add-recent ul {
list-style: none;
margin: 0;
padding: 0;
}
.memory-note-recent li {
.memory-add-recent li {
padding: 10px 0;
border-bottom: 1px solid var(--bd);
}
.memory-note-recent p {
.memory-add-recent p {
overflow-wrap: anywhere;
white-space: pre-wrap;
margin: 0 0 6px;
font-size: 13px;
}
.memory-note-recent small {
.memory-add-recent small {
color: var(--tx2);
}
.memory-note-actions {
.memory-add-actions {
flex-wrap: wrap;
}
+12 -17
View File
@@ -43,8 +43,8 @@ describe("ui trace write coverage", () => {
expect(memory).toMatch(/updateSdkMemory\([\s\S]{0,160}trace\.traceId/);
expect(memory).toMatch(/deleteSdkMemory\([\s\S]{0,160}trace\.traceId/);
// The composer's save is the native four-level sample.
expect(source("MemoryNoteComposer.tsx")).toMatch(
/saveMemoryNote\([\s\S]{0,200}trace\.traceId/,
expect(source("MemoryAddComposer.tsx")).toMatch(
/addMemory\([\s\S]{0,200}trace\.traceId/,
);
});
@@ -225,11 +225,11 @@ describe("ui trace write coverage", () => {
);
});
it("traces the answer note save", () => {
// `saveAnswer` writes a memory note, so it must form a four-line chain.
it("traces the answer memory add", () => {
// `saveAnswer` adds a memory, so it must form a four-line chain.
const view = source("MemorySpace.tsx");
expect(view).toMatch(
/saveMemoryNote\(\s*\{[\s\S]{0,240}?scope: "all-apps",\s*\},?\s*trace\.traceId,?\s*\)/,
/addMemory\(\s*\{[\s\S]{0,240}?scope: "all-apps",\s*\},?\s*trace\.traceId,?\s*\)/,
);
});
@@ -271,19 +271,14 @@ describe("ui trace write coverage", () => {
expect(body).toContain("X-One-Memory-Trace-Id");
});
it("traces the memory-note process write", () => {
// "重新整理" starts note processing: a non-GET write that had no trace.
const composer = source("MemoryNoteComposer.tsx");
expect(composer).toMatch(/traceUiAction\("memory", "retry"\)/);
expect(composer).toMatch(
/processMemoryNote\(note\.evidence_id, trace\.traceId\)/,
);
it("has no separate memory-add process write", () => {
// Processing an addition is not a client write: activating a model
// readmits parked work. The composer must not grow a retry route again.
const composer = source("MemoryAddComposer.tsx");
expect(composer).not.toMatch(/traceUiAction\("memory", "retry"\)/);
const api = source("api.ts");
const start = api.indexOf("export async function processMemoryNote(");
expect(start).toBeGreaterThan(-1);
const body = api.slice(start, start + 600);
expect(body).toContain("traceId?: string");
expect(body).toContain("X-One-Memory-Trace-Id");
expect(api).not.toContain("/process`");
expect(api).not.toContain("memory-notes");
});
it("traces every model-profile write through the hook's single choke point", () => {
+2 -1
View File
@@ -39,7 +39,8 @@ memhub status
4. 校验 HTTPS URL、重定向域名、文件大小与 SHA-256;
5. 安全解压并交叉验证内部 manifest 的版本、build、commit、协议和逐文件哈希;
6. 调用现有安装事务,注册用户级服务和受管 CLI 链接;
7. 失败时回滚,并保留当前 Runtime 和 Memory 数据。
7. 记忆数据迁移开始前失败会回滚,并保留当前 Runtime 和 Memory 数据;迁移开始后失败
保留新版本、拒绝启动(不会用旧版本打开数据),并输出迁移日志与反馈地址。
不带 `--yes` 的 `memhub install` 只下载较小的 Release manifest 并展示计划;显式确认后才
下载完整 Runtime。npm `postinstall` 不下载 Runtime、不注册服务、不修改 shell profile。
+51 -37
View File
@@ -32,87 +32,94 @@ node apps/cli/dist/one-memory.cjs doctor
`status` 显示 Runtime 是否运行;`doctor` 按诊断口径做只读检查(默认不修复)。
## 2. 首次接入(init)—— 没有独立的「授权」步骤
## 2. 首次接入(init / sources connect)—— 没有独立的「授权」步骤
```bash
# 先看本机有哪些宿主可接入
# 在终端里:交互向导(选来源 → 是否开启处理 → 配置模型)
# 非终端或加 --json:只列出可接入的来源和下一步命令
node apps/cli/dist/one-memory.cjs init
# 指定来源,生成接入计划(默认只出计划)
node apps/cli/dist/one-memory.cjs init --source codex
# 只授权(处理保持冻结:不导入历史、不扫描新会话)
node apps/cli/dist/one-memory.cjs sources connect codex
# 接入并同时导入历史
node apps/cli/dist/one-memory.cjs init --source codex --import
# 接入并同时导入历史、开启持续同步;输出 request id
node apps/cli/dist/one-memory.cjs sources connect codex --import
# 只看会做什么,不真的做
node apps/cli/dist/one-memory.cjs init --source codex --import --dry-run
# 一次接入所有已检测到的来源
node apps/cli/dist/one-memory.cjs sources connect --all-detected --import
```
**接入即授权**:没有单独的 authorize 步骤。
**接入即授权**:没有单独的 authorize 步骤,也没有计划文件。目录来自环境变量覆盖
(如 `DSH_HOME`)的来源必须加 `--confirm-overrides`,否则被拒绝(或在
`--all-detected` 时跳过并列出)。用同一个 `--request-id <uuid>` 重试不会重复导入。
## 3. 导入历史与看进度
## 3. 看导入进度
```bash
node apps/cli/dist/one-memory.cjs setup plan --source codex # 生成导入计划
node apps/cli/dist/one-memory.cjs sources import --plan-file <计划文件> --confirm
node apps/cli/dist/one-memory.cjs progress # 看导入进度
node apps/cli/dist/one-memory.cjs sources receipt <request-id> # 接入回执
node apps/cli/dist/one-memory.cjs progress # 导入与生成进度
```
真机观察:授权 codex 后任务运行中并持续落库(stored 从 14,437 涨到 62,634)。
## 4. 扫描(两阶段)
## 4. 立即扫描一个来源
```bash
node apps/cli/dist/one-memory.cjs scan # 预览
node apps/cli/dist/one-memory.cjs scan --yes --source codex # 执行(必须带 --source)
node apps/cli/dist/one-memory.cjs scan --source codex # 直接执行,返回导入任务
```
不带 `--yes` 只出计划;执行时给裸路径会明确拒绝(Runtime 按来源寻址)。
来源必须已接入并开启持续同步,否则返回 `live_sync_unavailable`(exit 1)。
## 5. 登记扫描目录(path)
## 5. 登记扫描目录(scan-roots,旧名 path 仍可用)
```bash
mkdir -p /tmp/scanme
node apps/cli/dist/one-memory.cjs path list
node apps/cli/dist/one-memory.cjs path add --root /tmp/scanme --source codex --yes
node apps/cli/dist/one-memory.cjs path list # 显示刚登记的,revision 0→1
node apps/cli/dist/one-memory.cjs path remove --root /tmp/scanme --yes
node apps/cli/dist/one-memory.cjs scan-roots list
node apps/cli/dist/one-memory.cjs scan-roots add --root /tmp/scanme --source codex
node apps/cli/dist/one-memory.cjs scan-roots list # 显示刚登记的,revision 0→1
node apps/cli/dist/one-memory.cjs scan-roots remove --root /tmp/scanme
```
行为已验证:重复登记返回 **409**;删除不存在的返回 **404**。
行为已验证:重复登记返回 **409**;删除不存在的返回 **404**(都是 exit 1,带 code)。
## 6. 看宿主适配器(adapter)
```bash
node apps/cli/dist/one-memory.cjs adapter list
node apps/cli/dist/one-memory.cjs adapter probe --source codex
```
输出每个 harness 的 available / authorized / live_sync / recall 能力。
`adapter probe` **目前会拒绝**(Runtime 未提供探测端点),不是参数问题。
## 7. 写入记忆与查任务(write → task)
## 7. 添加记忆与写入对话
```bash
node apps/cli/dist/one-memory.cjs write --session <会话ID> --from 1 --to 2 --evidence <证据ID> --json
# 返回 request_id(state=accepted)
# 新增一条结论式记忆(与 App 的「添加记忆」相同)
node apps/cli/dist/one-memory.cjs memories add "我更喜欢用 pnpm" --json
node apps/cli/dist/one-memory.cjs memories add-status <request_id> --json
node apps/cli/dist/one-memory.cjs task --id <request_id> --json
# 提交一段 OpenAI messages 对话,异步加工成记忆
node apps/cli/dist/one-memory.cjs write submit --session <会话ID> --messages-file <文件> --json
node apps/cli/dist/one-memory.cjs write status <request_id> --json
```
写入异步完成;`unknown` 表示结果待定,**不可据此重发**。
两条都返回 request id;它是幂等键,结果未知时用**同一个** `--request-id` 重试不会重复写入。
## 8. 召回(recall)
`recall` 两条路径(raw / memory)此前已真机验证,命令可继续使用。
`recall` 两条路径(raw / memory)此前已真机验证。文本输出会标明每条是「原始对话摘录」
还是「记忆」。
## 9. 备份
```bash
node apps/cli/dist/one-memory.cjs backup create --target <data目录内的路径> --yes --json
node apps/cli/dist/one-memory.cjs backup create --target <数据目录>/backups/<文件名> --json
node apps/cli/dist/one-memory.cjs backup restore --source <数据目录>/backups/<文件名> # 预览
node apps/cli/dist/one-memory.cjs backup restore --source <数据目录>/backups/<文件名> --yes --confirm <数据目录>/backups/<文件名> # 执行
```
目标必须在数据目录内(否则拒绝);产物已验证 540,672 字节、权限 owner-only。
备份只包含主库(来源、写入队列、配置),**不包含记忆本身**(`hy-memory/`)。目标必须在
数据目录内(否则拒绝),放在 `backups/` 下才能被 restore 使用;restore 需 Runtime 已停止。
## 10. 宿主接入(host)
@@ -129,11 +136,19 @@ node apps/cli/dist/one-memory.cjs host uninstall --host codex --approve <fingerp
## 11. 持续同步控制
```bash
node apps/cli/dist/one-memory.cjs sync status --host codex
node apps/cli/dist/one-memory.cjs sync pause --host codex
node apps/cli/dist/one-memory.cjs sync resume --host codex
node apps/cli/dist/one-memory.cjs sync status
node apps/cli/dist/one-memory.cjs sync pause --source codex
node apps/cli/dist/one-memory.cjs sync resume --source codex
```
## 确认约定
只有不可逆操作需要 `--yes`:`memories delete`、`models remove`、`delete-data`、
`backup restore`、`graph-width --decision rebuild_with_new`。不加时只说明将要做什么,不做任何
改动(exit 0)。`delete-data` 和 `backup restore` 还要用 `--confirm <路径>` 再写一遍目标(数据目录 /
备份文件),只加 `--yes` 会被拒绝。install / upgrade / uninstall / service-install / service-uninstall 不加 `--yes`
时只出计划。其它写命令直接执行。
## 12. 卸载(保留数据)
```bash
@@ -144,7 +159,6 @@ node apps/cli/dist/one-memory.cjs uninstall --install-root <root> --home <home>
## 已知会明确拒绝的(不是 bug)
- `adapter probe`:Runtime 无探测端点。
- `service-install/service-uninstall --yes`(在隔离环境):需真实用户域(launchctl),失败时会正确回滚、不留半成品。
- 某些命令不接受 `--json`(如 `start`、`install`、`uninstall`)——契约里该命令没有这个选项,带了会退 2。
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.5",
"release": {
"build_number": 31,
"build_number": 32,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+5 -3
View File
@@ -1042,13 +1042,16 @@ export interface ManualExtractReceipt {
batches_current: number;
}
export interface MemoryNoteInput {
/** Body of `POST /api/v2/memories`: add one memory directly. */
export interface MemoryAddInput {
request_id: string;
text: string;
scope: "all-apps";
}
export interface MemoryNoteReceipt {
/** Receipt of `POST /api/v2/memories` and
* `GET /api/v2/memories/additions/{request_id}`. */
export interface MemoryAddReceipt {
evidence_id: string;
text: string;
scope: "all-apps";
@@ -1066,7 +1069,6 @@ export interface MemoryNoteReceipt {
error: string | null;
}
export type SavedMemoryNoteEvidence = MemoryEvidenceExcerpt;
export type {
BackupRestoreFailureCode,
BackupRestoreRequest,
@@ -3,7 +3,7 @@ import { lstat, open } from "node:fs/promises";
import path from "node:path";
import type {
ContextSearchRequest,
MemoryNoteReceipt,
MemoryAddReceipt,
RecallReceipt,
RecallRequest,
SdkContextSearchResponse,
@@ -67,8 +67,8 @@ export type BrokerClientErrorCode =
| "context_unavailable"
| "context_cancelled"
| "context_timeout"
| "memory_note_conflict"
| "memory_note_invalid"
| "memory_add_conflict"
| "memory_add_invalid"
| "broker_request_failed"
| "broker_unavailable";
@@ -83,9 +83,9 @@ const CLIENT_MESSAGES: Record<BrokerClientErrorCode, string> = {
"MemHub context response is invalid or incompatible.",
context_unavailable:
"MemHub is running, but context search is temporarily unavailable.",
memory_note_conflict:
memory_add_conflict:
"MemHub already holds a different memory under this request_id. Use a new request_id for new content.",
memory_note_invalid: "MemHub rejected this memory as invalid.",
memory_add_invalid: "MemHub rejected this memory as invalid.",
host_credential_unavailable:
"MemHub host credential is unavailable. Re-link this installation in MemHub.",
host_authorization_required:
@@ -226,33 +226,33 @@ export async function grantedToolsThroughBroker(
}
/** Core caps a hand-added memory at 12,000 characters. */
export const MEMORY_NOTE_TEXT_MAX = 12_000;
export const MEMORY_ADD_TEXT_MAX = 12_000;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** What a host sends to write one hand-added memory. Scope is not a field:
* the broker always files the note as cross-app, as the app's 添加记忆 does. */
export interface MemoryNoteRequest {
* the broker always files the memory as cross-app, as the app's 添加记忆 does. */
export interface MemoryAddBrokerRequest {
request_id: string;
text: string;
}
export function isMemoryNoteRequestId(value: unknown): value is string {
export function isMemoryAddRequestId(value: unknown): value is string {
return typeof value === "string" && UUID.test(value);
}
export function isMemoryNoteText(value: unknown): value is string {
export function isMemoryAddText(value: unknown): value is string {
return (
typeof value === "string" &&
value.length >= 1 &&
value.length <= MEMORY_NOTE_TEXT_MAX &&
value.length <= MEMORY_ADD_TEXT_MAX &&
value.trim() !== ""
);
}
/** Exactly `{request_id, text}`; anything else is refused, not dropped. */
export function isMemoryNoteRequest(
export function isMemoryAddBrokerRequest(
value: unknown,
): value is MemoryNoteRequest {
): value is MemoryAddBrokerRequest {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const body = value as Record<string, unknown>;
const keys = Object.keys(body);
@@ -260,27 +260,21 @@ export function isMemoryNoteRequest(
keys.length === 2 &&
keys.includes("request_id") &&
keys.includes("text") &&
isMemoryNoteRequestId(body.request_id) &&
isMemoryNoteText(body.text)
isMemoryAddRequestId(body.request_id) &&
isMemoryAddText(body.text)
);
}
/** Write one hand-added memory through the broker to Core's memory notes.
/** Write one hand-added memory through the broker to Core's `POST /api/v2/memories`.
*
* The same entry the app's 添加记忆 uses. Reusing a request_id returns the
* original note rather than saving a second one. */
export async function memoryNoteThroughBroker(
* original addition rather than saving a second one. */
export async function memoryAddThroughBroker(
discoveryPath: string,
request: MemoryNoteRequest,
request: MemoryAddBrokerRequest,
options?: BrokerClientOptions,
): Promise<MemoryNoteReceipt> {
return postThroughBroker(
discoveryPath,
"memory-notes",
request,
15_000,
options,
);
): Promise<MemoryAddReceipt> {
return postThroughBroker(discoveryPath, "memories", request, 15_000, options);
}
async function brokerBase(
@@ -411,7 +405,7 @@ async function postThroughBroker<T>(
| "recall"
| "context"
| "call-events/delivery"
| "memory-notes"
| "memories"
| "diagnostics/trace",
request: unknown,
timeout: number,
@@ -447,13 +441,13 @@ async function postThroughBroker<T>(
throw new BrokerClientError("context_unavailable");
// A reused request_id with different content is a conflict, not an
// outage: the caller must pick a new id for new content.
if (endpoint === "memory-notes" && error.response?.status === 409)
throw new BrokerClientError("memory_note_conflict");
if (endpoint === "memories" && error.response?.status === 409)
throw new BrokerClientError("memory_add_conflict");
if (
endpoint === "memory-notes" &&
endpoint === "memories" &&
[400, 422].includes(error.response?.status ?? 0)
)
throw new BrokerClientError("memory_note_invalid");
throw new BrokerClientError("memory_add_invalid");
if (error.response?.status === 401)
throw new BrokerClientError("host_authorization_required");
if (error.response?.status === 403) {
+17 -20
View File
@@ -1,12 +1,12 @@
import { randomUUID } from "node:crypto";
import type { MemoryNoteReceipt, RecallRequest } from "@one-memory/contracts";
import type { MemoryAddReceipt, RecallRequest } from "@one-memory/contracts";
import {
diagnosticTraceThroughBroker,
isMemoryNoteRequestId,
isMemoryNoteText,
MEMORY_NOTE_TEXT_MAX,
type MemoryNoteRequest,
memoryNoteThroughBroker,
isMemoryAddRequestId,
isMemoryAddText,
MEMORY_ADD_TEXT_MAX,
type MemoryAddBrokerRequest,
memoryAddThroughBroker,
recallThroughBroker,
} from "./broker-client.js";
import { HOST_CONTEXT_TIMEOUT_MS } from "./context-protocol.js";
@@ -37,8 +37,8 @@ type Recall = (
type Write = (
discoveryPath: string,
request: MemoryNoteRequest,
) => Promise<MemoryNoteReceipt>;
request: MemoryAddBrokerRequest,
) => Promise<MemoryAddReceipt>;
type DiagnosticTrace = (
discoveryPath: string,
@@ -77,9 +77,9 @@ export class McpSession {
* which is exactly the cross-project boundary the workspace design
* draws. The host's own answer cannot be talked into being wrong. */
private readonly workspacePath?: string,
/** One hand-added memory, through the broker to Core's memory notes —
/** One hand-added memory, through the broker to Core's `POST /api/v2/memories` —
* the same entry the app's 添加记忆 uses. */
private readonly write: Write = memoryNoteThroughBroker,
private readonly write: Write = memoryAddThroughBroker,
private readonly diagnosticTrace: DiagnosticTrace = diagnosticTraceThroughBroker,
/** What the grant allows this installation to read.
*
@@ -295,9 +295,9 @@ function recallToolDefinition() {
/** One hand-added memory, the same entry as the app's 添加记忆.
*
* A conclusion, not a transcript: Core files it as a manual note and turns it
* A conclusion, not a transcript: Core files it as a hand-added memory and turns it
* into memories asynchronously, in a lane ahead of history import. Neither
* source nor scope is an argument; the note is always cross-app. */
* source nor scope is an argument; the memory is always cross-app. */
function writeToolDefinition() {
const title = "MemHub write";
return {
@@ -319,7 +319,7 @@ function writeToolDefinition() {
text: {
type: "string",
minLength: 1,
maxLength: MEMORY_NOTE_TEXT_MAX,
maxLength: MEMORY_ADD_TEXT_MAX,
description: "The memory itself, in plain words. Must not be blank.",
},
request_id: {
@@ -334,7 +334,7 @@ function writeToolDefinition() {
title,
readOnlyHint: false,
destructiveHint: false,
// Idempotent for a reused request_id: Core returns the original note.
// Idempotent for a reused request_id: Core returns the original addition.
idempotentHint: true,
openWorldHint: false,
},
@@ -451,15 +451,12 @@ function parseWriteArgs(args: unknown):
message:
"MemHub_write accepts only text and request_id; source and scope are not arguments",
};
if (!isMemoryNoteText(input.text))
if (!isMemoryAddText(input.text))
return {
ok: false,
message: `text must contain 1 to ${MEMORY_NOTE_TEXT_MAX} characters and not be blank`,
message: `text must contain 1 to ${MEMORY_ADD_TEXT_MAX} characters and not be blank`,
};
if (
input.request_id !== undefined &&
!isMemoryNoteRequestId(input.request_id)
)
if (input.request_id !== undefined && !isMemoryAddRequestId(input.request_id))
return { ok: false, message: "request_id must be a UUID" };
return {
ok: true,
+2 -2
View File
@@ -5,7 +5,7 @@ import {
type BrokerClientOptions,
diagnosticTraceThroughBroker,
grantedToolsThroughBroker,
memoryNoteThroughBroker,
memoryAddThroughBroker,
readHostCredentialFile,
recallThroughBroker,
} from "./broker-client.js";
@@ -35,7 +35,7 @@ const session = new McpSession(
}),
// The broker refuses a write without an authenticated installation.
(discovery, request) =>
memoryNoteThroughBroker(discovery, request, clientOptions),
memoryAddThroughBroker(discovery, request, clientOptions),
(discovery, request) =>
diagnosticTraceThroughBroker(discovery, request, clientOptions),
// What the grant allows, so the list a host sees is the list it may use.
@@ -1,4 +1,4 @@
// MemHub_write end to end: MCP session -> broker -> a fake Core. The note is
// MemHub_write end to end: MCP session -> broker -> a fake Core. The memory is
// the same hand-added memory the app's 添加记忆 writes: authenticated by the
// host credential, not grant-governed, sent to Core as a trusted local caller
// (no external read envelope) and always filed cross-app.
@@ -13,7 +13,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
type BrokerClientOptions,
memoryNoteThroughBroker,
memoryAddThroughBroker,
readHostCredentialFile,
} from "./broker-client.js";
import { HostGrantStore } from "./host-grants.js";
@@ -54,8 +54,8 @@ function receiptFor(text: string) {
};
}
async function fixture(options: { noteStatus?: number } = {}) {
const root = await mkdtemp(path.join(os.tmpdir(), "om-note-broker-"));
async function fixture(options: { coreStatus?: number } = {}) {
const root = await mkdtemp(path.join(os.tmpdir(), "om-memory-add-broker-"));
roots.push(root);
const store = await HostGrantStore.initialize(path.join(root, "grants"));
const issued = await store.createInstallation({ hostId: "cursor" });
@@ -82,8 +82,8 @@ async function fixture(options: { noteStatus?: number } = {}) {
body,
});
response.setHeader("Content-Type", "application/json");
if (request.url === "/api/v1/memory-notes") {
response.statusCode = options.noteStatus ?? 201;
if (request.url === "/api/v2/memories") {
response.statusCode = options.coreStatus ?? 201;
response.end(
JSON.stringify(
response.statusCode === 201
@@ -107,7 +107,7 @@ async function fixture(options: { noteStatus?: number } = {}) {
coreBaseUrl: `http://127.0.0.1:${(core.address() as AddressInfo).port}`,
coreApiToken: coreToken,
discoveryPath,
instanceId: "isolated-note-broker",
instanceId: "isolated-memory-add-broker",
preferredPort: 0,
hostGrantStore: store,
});
@@ -120,7 +120,7 @@ async function fixture(options: { noteStatus?: number } = {}) {
discoveryPath,
undefined,
undefined,
(discovery, request) => memoryNoteThroughBroker(discovery, request, client),
(discovery, request) => memoryAddThroughBroker(discovery, request, client),
undefined,
async () => [],
);
@@ -168,14 +168,14 @@ describe.skipIf(process.platform === "win32")("MemHub_write", () => {
expect(f.calls).toHaveLength(1);
const [call] = f.calls;
expect(call.method).toBe("POST");
expect(call.url).toBe("/api/v1/memory-notes");
expect(call.url).toBe("/api/v2/memories");
expect(call.body).toEqual({
request_id: REQUEST_ID,
text: "Prefers tabs over spaces",
scope: "all-apps",
});
expect(call.headers.authorization).toBe(`Bearer ${coreToken}`);
// Core denies memory notes to enveloped requests: none of it may leak.
// Core denies POST /api/v2/memories to enveloped requests: none of it may leak.
for (const header of Object.keys(call.headers)) {
expect(header).not.toMatch(
/^x-one-memory-(read-context|host-id|installation-|allowed-)/,
@@ -194,34 +194,34 @@ describe.skipIf(process.platform === "win32")("MemHub_write", () => {
});
it("maps a 409 conflict and a 422 refusal to their own errors", async () => {
const conflict = await fixture({ noteStatus: 409 });
const conflict = await fixture({ coreStatus: 409 });
await expect(
memoryNoteThroughBroker(
memoryAddThroughBroker(
conflict.discoveryPath,
{ request_id: REQUEST_ID, text: "a" },
conflict.client,
),
).rejects.toMatchObject({ code: "memory_note_conflict" });
).rejects.toMatchObject({ code: "memory_add_conflict" });
const conflictCall = await callWrite(conflict.session, {
text: "a",
request_id: REQUEST_ID,
});
expect(conflictCall.result?.isError).toBe(true);
const invalid = await fixture({ noteStatus: 422 });
const invalid = await fixture({ coreStatus: 422 });
await expect(
memoryNoteThroughBroker(
memoryAddThroughBroker(
invalid.discoveryPath,
{ request_id: REQUEST_ID, text: "a" },
invalid.client,
),
).rejects.toMatchObject({ code: "memory_note_invalid" });
).rejects.toMatchObject({ code: "memory_add_invalid" });
});
it("refuses an unauthenticated write before reaching Core", async () => {
const f = await fixture();
const response = await axios.post(
`${f.baseUrl}/memory-notes`,
`${f.baseUrl}/memories`,
{ request_id: REQUEST_ID, text: "anything" },
{
headers: { "Content-Type": "application/json" },
@@ -243,7 +243,7 @@ describe.skipIf(process.platform === "win32")("MemHub_write", () => {
{ request_id: REQUEST_ID, text: "x".repeat(12_001) },
{ text: "x" },
]) {
const response = await axios.post(`${f.baseUrl}/memory-notes`, body, {
const response = await axios.post(`${f.baseUrl}/memories`, body, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${f.secret}`,
@@ -14,7 +14,7 @@ import type {
import axios from "axios";
import {
type CallEventDeliveryRequest,
isMemoryNoteRequest,
isMemoryAddBrokerRequest,
} from "./broker-client.js";
import {
authorizeContext,
@@ -41,7 +41,7 @@ const LOOPBACK_HOST = "127.0.0.1";
const DEFAULT_PORT = 18_765;
const MAX_BODY_BYTES = 64 * 1024;
/** 12,000 characters, each of which JSON may escape to six bytes. */
const MAX_MEMORY_NOTE_BODY_BYTES = 128 * 1024;
const MAX_MEMORY_ADD_BODY_BYTES = 128 * 1024;
const CONNECTION_DIAGNOSTIC_SUMMARY_MS = 5 * 60 * 1000;
const DELIVERY_BINDING_TTL_MS = 60_000;
const CALL_EVENT_ID =
@@ -319,8 +319,8 @@ export class RecallBroker {
await this.proxyContext(request, response);
return;
}
if (request.url === "/memory-notes") {
await this.proxyMemoryNote(request, response);
if (request.url === "/memories") {
await this.proxyMemoryAdd(request, response);
return;
}
if (request.url === "/diagnostics/trace") {
@@ -976,7 +976,7 @@ export class RecallBroker {
/** The installation behind a request: authenticated, never tool-authorized.
*
* Memory notes are not grant-governed, but only an installation MemHub
* Hand-added memories are not grant-governed, but only an installation MemHub
* paired may write one. Without a grant store there is no identity, so no
* write. */
private async authenticateHost(
@@ -1005,9 +1005,9 @@ export class RecallBroker {
}
}
/** Core's memory-note entry, as a trusted local caller.
/** Core's memory-add entry, as a trusted local caller.
*
* Core refuses `/api/v1/memory-notes` to requests that carry the external
* Core refuses `POST /api/v2/memories` to requests that carry the external
* read envelope, and writes are not grant-governed. */
private coreWriteHeaders(request: IncomingMessage): Record<string, string> {
return {
@@ -1020,8 +1020,8 @@ export class RecallBroker {
/** One hand-added memory, the same entry as the app's 添加记忆.
*
* The body is exactly `{request_id, text}`: a host cannot choose a scope or
* a source. The note is filed cross-app, as a hand-added memory. */
private async proxyMemoryNote(
* a source. The memory is filed cross-app, as a hand-added memory. */
private async proxyMemoryAdd(
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
@@ -1030,14 +1030,14 @@ export class RecallBroker {
const body = await readJsonBody(
request,
undefined,
MAX_MEMORY_NOTE_BODY_BYTES,
MAX_MEMORY_ADD_BODY_BYTES,
);
if (!isMemoryNoteRequest(body)) {
sendJson(response, 400, { error: "memory_note_invalid" });
if (!isMemoryAddBrokerRequest(body)) {
sendJson(response, 400, { error: "memory_add_invalid" });
return;
}
const result = await axios.post<unknown>(
`${this.options.coreBaseUrl}/api/v1/memory-notes`,
`${this.options.coreBaseUrl}/api/v2/memories`,
{ request_id: body.request_id, text: body.text, scope: "all-apps" },
{
headers: this.coreWriteHeaders(request),
@@ -1052,11 +1052,11 @@ export class RecallBroker {
return;
}
if (result.status === 409) {
sendJson(response, 409, { error: "memory_note_conflict" });
sendJson(response, 409, { error: "memory_add_conflict" });
return;
}
if (result.status === 422) {
sendJson(response, 422, { error: "memory_note_invalid" });
sendJson(response, 422, { error: "memory_add_invalid" });
return;
}
sendJson(response, 502, { error: "MemHub memory write is unavailable" });
@@ -1246,7 +1246,7 @@ function brokerOperation(url: string | undefined): string | null {
"/recall": "recall",
"/context": "context",
"/call-events/delivery": "call_event_delivery",
"/memory-notes": "memory_note",
"/memories": "memory_add",
"/source-changed": "source_notification",
"/control/connections": "connection_control",
}[url ?? ""] ?? null
+1 -5
View File
@@ -1730,17 +1730,13 @@ const baseEnglishMessages = {
"memory:整理已完成": "Processing complete",
"memory:原话已保存,未提取出可用记忆":
"Original saved; no usable memory extracted",
"memory:最近添加的内容读取失败。": "Could not read recently added notes.",
"memory:最近添加的内容读取失败。": "Could not read recently added memories.",
"memory:整理状态暂时不可用,原话已保存。":
"Processing status is temporarily unavailable; the original is saved.",
"memory:已保存,正在后台沉淀记忆":
"Saved; creating memories in the background",
"memory:未能确认保存结果,输入已保留。重试不会重复添加。":
"Could not confirm the save result. Your text is retained; retrying will not add a duplicate.",
"memory:整理未能开始,原话仍已保存。":
"Processing could not start; the original is still saved.",
"memory:整理结果尚未确认,原话仍已保存。请刷新后核对。":
"Processing is not yet confirmed; the original is saved. Refresh to check.",
"memory:模型服务要求检查余额或付款状态,原话仍已保存。":
"The model service requires a balance or payment check; the original remains saved.",
"memory:这台设备打不开 3D 视图,已显示关联列表。":
+2 -8
View File
@@ -300,9 +300,6 @@ export const memoryChineseMessages = {
"memory:已保存,正在后台沉淀记忆": "已保存,正在后台沉淀记忆",
"memory:未能确认保存结果,输入已保留。重试不会重复添加。":
"未能确认保存结果,输入已保留。重试不会重复添加。",
"memory:整理未能开始,原话仍已保存。": "整理未能开始,原话仍已保存。",
"memory:整理结果尚未确认,原话仍已保存。请刷新后核对。":
"整理结果尚未确认,原话仍已保存。请刷新后核对。",
"memory:模型服务要求检查余额或付款状态,原话仍已保存。":
"模型服务要求检查余额或付款状态,原话仍已保存。",
"memory:这台设备打不开 3D 视图,已显示关联列表。":
@@ -707,17 +704,14 @@ export const memorySpanishMessages: Record<
"memory:整理已完成": "Procesamiento completado",
"memory:原话已保存,未提取出可用记忆":
"Texto guardado; no se extrajeron memorias utilizables",
"memory:最近添加的内容读取失败。": "No se pudieron leer las notas recientes.",
"memory:最近添加的内容读取失败。":
"No se pudieron leer las memorias añadidas recientemente.",
"memory:整理状态暂时不可用,原话已保存。":
"Estado del procesamiento no disponible. El texto está guardado.",
"memory:已保存,正在后台沉淀记忆":
"Guardado; generando memorias en segundo plano",
"memory:未能确认保存结果,输入已保留。重试不会重复添加。":
"No se pudo confirmar el guardado. Tu texto se conserva; reintentar no lo duplicará.",
"memory:整理未能开始,原话仍已保存。":
"No se pudo iniciar el procesamiento. El texto sigue guardado.",
"memory:整理结果尚未确认,原话仍已保存。请刷新后核对。":
"El resultado no está confirmado. El texto sigue guardado; actualiza para verificarlo.",
"memory:模型服务要求检查余额或付款状态,原话仍已保存。":
"Revisa el saldo o los pagos del proveedor. El texto sigue guardado.",
"memory:这台设备打不开 3D 视图,已显示关联列表。":
+1 -1
View File
@@ -8,7 +8,7 @@
* copies drifting apart.
*/
export * from "./memory-note-operation.js";
export * from "./memory-add-operation.js";
export * from "./memory-processing-operation.js";
export * from "./model-operation.js";
export * from "./model-profile-operation.js";
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
buildMemoryAddRequest,
MemoryAddInputError,
memoryAddStatusPath,
parseMemoryAddReceipt,
} from "./memory-add-operation";
const ID = "11111111-2222-4333-8444-555555555555";
describe("memory add request", () => {
it("sends exactly request_id, text and the fixed scope", () => {
expect(
buildMemoryAddRequest({
request_id: ID,
text: "raw memory",
scope: "all-apps",
}),
).toEqual({
path: "/api/v2/memories",
body: { request_id: ID, text: "raw memory", scope: "all-apps" },
});
});
it("refuses a bad request id or empty text", () => {
expect(() =>
buildMemoryAddRequest({
request_id: "nope",
text: "x",
scope: "all-apps",
}),
).toThrow("request_id_invalid");
expect(() =>
buildMemoryAddRequest({
request_id: ID,
text: " ",
scope: "all-apps",
}),
).toThrow(MemoryAddInputError);
});
});
describe("memory add status path", () => {
it("addresses the addition by its request id", () => {
expect(memoryAddStatusPath(ID)).toBe(`/api/v2/memories/additions/${ID}`);
});
it("refuses a non-UUID request id", () => {
for (const value of ["nope", "", `${ID}/../x`]) {
expect(() => memoryAddStatusPath(value)).toThrow(MemoryAddInputError);
expect(() => memoryAddStatusPath(value)).toThrow("request_id_invalid");
}
});
});
describe("memory add receipt validation", () => {
function receipt(patch: Record<string, unknown> = {}) {
return {
evidence_id: ID,
text: "raw memory",
scope: "all-apps",
created_at: "2026-09-13T00:00:00+00:00",
state: "pending",
task_id: null,
memory_ids: [],
...patch,
};
}
it("accepts a well-formed receipt", () => {
expect(parseMemoryAddReceipt(receipt())?.state).toBe("pending");
});
it("refuses a non-UUID evidence id or an unknown state", () => {
expect(parseMemoryAddReceipt(receipt({ evidence_id: "nope" }))).toBeNull();
expect(parseMemoryAddReceipt(receipt({ state: "invented" }))).toBeNull();
});
it("refuses a wrong scope or malformed memory_ids", () => {
expect(parseMemoryAddReceipt(receipt({ scope: "private" }))).toBeNull();
expect(parseMemoryAddReceipt(receipt({ memory_ids: [1] }))).toBeNull();
expect(
parseMemoryAddReceipt(receipt({ memory_ids: undefined })),
).toBeNull();
});
it("refuses a non-object body", () => {
for (const value of [null, [], "ok", 7]) {
expect(parseMemoryAddReceipt(value)).toBeNull();
}
});
});
@@ -0,0 +1,74 @@
/** Memory-add contract: the `POST /api/v2/memories` body, its receipt check and
* the addition status path.
*
* Core takes exactly `request_id`, `text`, `scope`; the CLI and the UI both
* hard-code `scope: "all-apps"`, so keeping the body here means both surfaces
* send the same three fields. A receipt is validated before it becomes state —
* the evidence id must be a UUID and the state must be one of the known
* states — so a malformed 200 is never rendered as a real addition.
*
* Dependency-free (contracts types only) so it can move into a shared package.
*/
import type { MemoryAddInput, MemoryAddReceipt } from "@one-memory/contracts";
export class MemoryAddInputError extends Error {}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const ADD_STATES = [
"waiting_for_model",
"waiting_for_permission",
"pending",
"running",
"failed",
"ready",
"no_memories",
] as const;
export interface MemoryAddRequest {
path: "/api/v2/memories";
body: { request_id: string; text: string; scope: "all-apps" };
}
/** The exact three-field body Core accepts; scope is fixed, not caller-supplied. */
export function buildMemoryAddRequest(input: MemoryAddInput): MemoryAddRequest {
if (typeof input.request_id !== "string" || !UUID.test(input.request_id))
throw new MemoryAddInputError("request_id_invalid");
if (typeof input.text !== "string" || input.text.trim().length === 0)
throw new MemoryAddInputError("text_required");
return {
path: "/api/v2/memories",
body: { request_id: input.request_id, text: input.text, scope: "all-apps" },
};
}
/** Where the status of one addition is read, keyed by its request id. */
export function memoryAddStatusPath(requestId: string): string {
if (typeof requestId !== "string" || !UUID.test(requestId))
throw new MemoryAddInputError("request_id_invalid");
return `/api/v2/memories/additions/${requestId}`;
}
/**
* Validate a memory-add receipt before it becomes UI state.
*
* Returns null for a malformed body; callers must treat that as an unknown
* result rather than a saved memory.
*/
export function parseMemoryAddReceipt(value: unknown): MemoryAddReceipt | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const receipt = value as Record<string, unknown>;
if (
typeof receipt.evidence_id !== "string" ||
!UUID.test(receipt.evidence_id) ||
typeof receipt.state !== "string" ||
!(ADD_STATES as readonly string[]).includes(receipt.state) ||
receipt.scope !== "all-apps"
)
return null;
if (
!Array.isArray(receipt.memory_ids) ||
receipt.memory_ids.some((id) => typeof id !== "string")
)
return null;
return value as MemoryAddReceipt;
}
@@ -1,78 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildMemoryNoteSaveRequest,
MemoryNoteInputError,
parseMemoryNoteReceipt,
} from "./memory-note-operation";
const ID = "11111111-2222-4333-8444-555555555555";
describe("memory note save request", () => {
it("sends exactly request_id, text and the fixed scope", () => {
expect(
buildMemoryNoteSaveRequest({
request_id: ID,
text: "raw note",
scope: "all-apps",
}),
).toEqual({
path: "/api/v1/memory-notes",
body: { request_id: ID, text: "raw note", scope: "all-apps" },
});
});
it("refuses a bad request id or empty text", () => {
expect(() =>
buildMemoryNoteSaveRequest({
request_id: "nope",
text: "x",
scope: "all-apps",
}),
).toThrow("request_id_invalid");
expect(() =>
buildMemoryNoteSaveRequest({
request_id: ID,
text: " ",
scope: "all-apps",
}),
).toThrow(MemoryNoteInputError);
});
});
describe("memory note receipt validation", () => {
function receipt(patch: Record<string, unknown> = {}) {
return {
evidence_id: ID,
text: "raw note",
scope: "all-apps",
created_at: "2026-09-13T00:00:00+00:00",
state: "pending",
task_id: null,
memory_ids: [],
...patch,
};
}
it("accepts a well-formed receipt", () => {
expect(parseMemoryNoteReceipt(receipt())?.state).toBe("pending");
});
it("refuses a non-UUID evidence id or an unknown state", () => {
expect(parseMemoryNoteReceipt(receipt({ evidence_id: "nope" }))).toBeNull();
expect(parseMemoryNoteReceipt(receipt({ state: "invented" }))).toBeNull();
});
it("refuses a wrong scope or malformed memory_ids", () => {
expect(parseMemoryNoteReceipt(receipt({ scope: "private" }))).toBeNull();
expect(parseMemoryNoteReceipt(receipt({ memory_ids: [1] }))).toBeNull();
expect(
parseMemoryNoteReceipt(receipt({ memory_ids: undefined })),
).toBeNull();
});
it("refuses a non-object body", () => {
for (const value of [null, [], "ok", 7]) {
expect(parseMemoryNoteReceipt(value)).toBeNull();
}
});
});
@@ -1,74 +0,0 @@
/** Memory-note write contract: the body shape and the receipt check.
*
* The CLI validates a note receipt in `ui-write-flows-extended.ts:321-340`
* before accepting it — the evidence id must be a UUID and the state must be
* one of the known states. The renderer previously stored whatever Core
* returned straight into component state (`MemoryNoteComposer.remember`), so a
* malformed 200 would be rendered as a real note. This mirrors the CLI's check.
*
* The save body is fixed too: Core takes exactly `request_id`, `text`, `scope`;
* the CLI hard-codes `scope: "all-apps"` and so does the UI. Keeping it here
* means both surfaces send the same three fields.
*
* Dependency-free (contracts types only) so it can move into a shared package.
*/
import type { MemoryNoteInput, MemoryNoteReceipt } from "@one-memory/contracts";
export class MemoryNoteInputError extends Error {}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const NOTE_STATES = [
"waiting_for_model",
"waiting_for_permission",
"pending",
"running",
"failed",
"ready",
"no_memories",
] as const;
export interface MemoryNoteSaveRequest {
path: "/api/v1/memory-notes";
body: { request_id: string; text: string; scope: "all-apps" };
}
/** The exact three-field body Core accepts; scope is fixed, not caller-supplied. */
export function buildMemoryNoteSaveRequest(
note: MemoryNoteInput,
): MemoryNoteSaveRequest {
if (typeof note.request_id !== "string" || !UUID.test(note.request_id))
throw new MemoryNoteInputError("request_id_invalid");
if (typeof note.text !== "string" || note.text.trim().length === 0)
throw new MemoryNoteInputError("text_required");
return {
path: "/api/v1/memory-notes",
body: { request_id: note.request_id, text: note.text, scope: "all-apps" },
};
}
/**
* Validate a note receipt before it becomes UI state.
*
* Returns null for a malformed body; callers must treat that as an unknown
* result rather than a saved note.
*/
export function parseMemoryNoteReceipt(
value: unknown,
): MemoryNoteReceipt | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const receipt = value as Record<string, unknown>;
if (
typeof receipt.evidence_id !== "string" ||
!UUID.test(receipt.evidence_id) ||
typeof receipt.state !== "string" ||
!(NOTE_STATES as readonly string[]).includes(receipt.state) ||
receipt.scope !== "all-apps"
)
return null;
if (
!Array.isArray(receipt.memory_ids) ||
receipt.memory_ids.some((id) => typeof id !== "string")
)
return null;
return value as MemoryNoteReceipt;
}
@@ -16,9 +16,8 @@ describe("extraction window receipt", () => {
).toEqual({ window_preset: "6m" });
});
// The CLI rejects a receipt that does not match the plan
// (`ui-write-flows-extended.ts:executeExtractionWindowSet`); a receipt for a
// different preset means the response belongs to another write.
// A receipt for a different preset than the one requested means the
// response belongs to another write.
it("rejects a preset that is not the one requested", () => {
expect(
parseExtractionSettingsReceipt({ window_preset: "all" }, "6m"),
@@ -1,10 +1,9 @@
/** Memory-processing writes: extraction window, graph width, manual extract.
*
* These three are the same class of gap as the model-profile and
* continuous-policy receipts: the CLI refuses to trust a 2xx body
* (`ui-write-flows-extended.ts` `executeExtractionWindowSet` /
* `executeGraphWidthDecide` / `executeManualExtract`), reporting
* `ui_write_receipt_invalid` instead of a committed write. The Web returned
* continuous-policy receipts: a 2xx body is not trusted until it parses
* (the CLI's `graph-width-flow.ts` reports `graph_width_receipt_invalid`
* instead of a committed write). The Web returned
* `response.data` straight into component state.
*
* The graph-width case is the one that can lie: `GraphWidthPrompt` treats an
@@ -176,6 +176,7 @@ export {
} from "./runtime-supervisor.js";
export {
type CoreCommand,
readInstalledRuntimeSource,
runSdkMigration,
type SdkMigrationCommandResult,
type SdkMigrationOptions,
@@ -183,13 +184,18 @@ export {
} from "./sdk-migration.js";
export {
clearSdkMigrationBlock,
freezeCapture,
readSdkMigrationBlock,
recordSdkMigrationBlock,
SDK_MIGRATION_CAPTURE_FENCE,
SDK_MIGRATION_LOG_NAME,
type SdkMigrationBlock,
type SdkMigrationBlockDetails,
type SdkMigrationCaller,
type SdkMigrationLogEntry,
type SdkMigrationSource,
sdkMigrationLogPath,
thawCapture,
writeSdkMigrationLog,
} from "./sdk-migration-log.js";
export {
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more