Compare commits

...
Author SHA1 Message Date
alvinfeiandCursor 748588dc6e fix: restore public MemHub CI tooling
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 22:49:26 +08:00
214 changed files with 2829 additions and 1633 deletions

No files matched your search

+27 -1
View File
@@ -59,4 +59,30 @@ jobs:
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: uv sync --directory apps/core --all-groups --locked
- run: pnpm check
- name: Verify public source structure and privacy boundaries
run: |
python3 scripts/verify_structure.py
python3 scripts/generate_core_test_index.py --check
python3 scripts/verify_zero_collection.py
python3 scripts/verify_zero_collection.py --self-test
- name: Lint Node workspaces
run: pnpm lint
- name: Typecheck Node workspaces
run: pnpm typecheck
- name: Test Node workspaces
run: pnpm test
- name: Test release tooling
run: pnpm test:release-scripts
- name: Fetch and verify the pinned embedding model
run: |
pnpm fetch:embedding-model
uv run --directory apps/core python -c \
"from one_memory.embedding_assets import assets_present, verify_assets; assert assets_present(); verify_assets()"
- name: Test public Core smoke suite
run: >-
bash scripts/run-tests-isolated.sh core-pr
tests/test_core_test_reporting.py
tests/test_import_api.py
tests/test_recent_first_import.py
tests/test_source_session_count_api.py
tests/test_schema_migrations.py
@@ -13,8 +13,8 @@
* the real package.json and only strips what is provably not needed at runtime.
*/
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -27,10 +27,7 @@ const releaseVersion = resolveBuildVersion(process.env, repositoryRoot);
const pkgPath = resolve(root, "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
const publishName = process.env.MEMHUB_NPM_PACKAGE?.trim();
if (
!publishName ||
!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(publishName)
) {
if (!publishName || !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(publishName)) {
throw new Error(
"MEMHUB_NPM_PACKAGE must name the explicit npm publish identity",
);
@@ -38,8 +35,12 @@ if (
const bundle = resolve(root, "dist/one-memory.cjs");
const source = readFileSync(bundle, "utf8");
const runtimeRequires = [...source.matchAll(/require\(["']([^"']+)["']\)/g)].map((m) => m[1]);
const workspaceLeaked = runtimeRequires.filter((name) => name.startsWith("@one-memory/"));
const runtimeRequires = [
...source.matchAll(/require\(["']([^"']+)["']\)/g),
].map((m) => m[1]);
const workspaceLeaked = runtimeRequires.filter((name) =>
name.startsWith("@one-memory/"),
);
if (workspaceLeaked.length > 0) {
throw new Error(
`bundle still requires workspace packages at runtime: ${workspaceLeaked.join(", ")}. ` +
@@ -61,9 +62,7 @@ for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
// else is build-time only. The bundle is inlined (tsup noExternal), so
// in practice only Node builtins remain — but compute it rather than
// assume, so a future non-inlined dep is not silently dropped.
runtimeRequires.some(
(req) => req === name || req.startsWith(`${name}/`),
),
runtimeRequires.some((req) => req === name || req.startsWith(`${name}/`)),
),
);
if (Object.keys(published[section]).length === 0) delete published[section];
@@ -77,8 +76,15 @@ published.files = ["dist", "scripts", "package.json"];
// in the staging dir overrides that and makes the manifest authoritative.
writeFileSync(
resolve(outDir, ".npmignore"),
["# Ship the built bundle: the repo root .gitignore excludes dist/.", "!dist/", ""].join("\n"),
[
"# Ship the built bundle: the repo root .gitignore excludes dist/.",
"!dist/",
"",
].join("\n"),
);
writeFileSync(resolve(outDir, "package.json"), `${JSON.stringify(published, null, 2)}\n`);
writeFileSync(
resolve(outDir, "package.json"),
`${JSON.stringify(published, null, 2)}\n`,
);
console.log("wrote", resolve(outDir, "package.json"));
@@ -20,11 +20,7 @@ import { pathToFileURL } from "node:url";
const REQUIRED_ALIASES = {
daemon: ["start", "stop", "restart", "status"],
"startup-service": [
"service-plan",
"service-install",
"service-uninstall",
],
"startup-service": ["service-plan", "service-install", "service-uninstall"],
};
const REQUIRED_COMMAND_SURFACE = [
@@ -112,19 +108,85 @@ const REQUIRED_SUBCOMMANDS = [
* These run without `--lifecycle`, so they are safe against any package.
*/
const STOPPED_DESTRUCTIVE_PROBES = [
{ 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: "import_file_commit_apply", args: ["import-file", "commit", "22222222-2222-4222-8222-222222222222", "--confirm"] },
{ name: "import_file_discard_apply", args: ["import-file", "discard", "22222222-2222-4222-8222-222222222222", "--confirm"] },
{ name: "notes_process_apply", args: ["notes", "process", "33333333-3333-4333-8333-333333333333", "--confirm"] },
{
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: "import_file_commit_apply",
args: [
"import-file",
"commit",
"22222222-2222-4222-8222-222222222222",
"--confirm",
],
},
{
name: "import_file_discard_apply",
args: [
"import-file",
"discard",
"22222222-2222-4222-8222-222222222222",
"--confirm",
],
},
{
name: "notes_process_apply",
args: [
"notes",
"process",
"33333333-3333-4333-8333-333333333333",
"--confirm",
],
},
{ name: "permission_allow", args: ["permission", "allow"] },
{ name: "permission_deny", args: ["permission", "deny"] },
{ name: "extraction_set_apply", args: ["extraction", "set", "--window", "6m", "--confirm"] },
{ name: "graph_width_apply", args: ["graph", "width", "--decision", "keep_existing", "--confirm"] },
{ name: "processing_extract_apply", args: ["processing", "extract", "--confirm"] },
{ name: "models_activate_apply", args: ["models", "activate", "44444444-4444-4444-8444-444444444444", "--confirm"] },
{ name: "models_edit_apply", args: ["models", "edit", "44444444-4444-4444-8444-444444444444", "--config-file", "/nonexistent/patch.json", "--confirm"] },
{
name: "extraction_set_apply",
args: ["extraction", "set", "--window", "6m", "--confirm"],
},
{
name: "graph_width_apply",
args: ["graph", "width", "--decision", "keep_existing", "--confirm"],
},
{
name: "processing_extract_apply",
args: ["processing", "extract", "--confirm"],
},
{
name: "models_activate_apply",
args: [
"models",
"activate",
"44444444-4444-4444-8444-444444444444",
"--confirm",
],
},
{
name: "models_edit_apply",
args: [
"models",
"edit",
"44444444-4444-4444-8444-444444444444",
"--config-file",
"/nonexistent/patch.json",
"--confirm",
],
},
];
export function validateCatalog(document) {
@@ -201,7 +263,9 @@ export async function runPackageMatrix(runtimeDist, options = {}) {
const markers = REQUIRED_MARKERS.filter(
(marker) => !bundle.toString("utf8").includes(marker),
);
const isolatedRoot = await mkdtemp(path.join(tmpdir(), "memhub-package-matrix-"));
const isolatedRoot = await mkdtemp(
path.join(tmpdir(), "memhub-package-matrix-"),
);
const home = path.join(isolatedRoot, "home");
const dataDir = path.join(isolatedRoot, "data");
const hostHome = path.join(isolatedRoot, "host-home");
@@ -343,7 +407,10 @@ async function runStoppedDestructive(node, cli, environment) {
`${probe.name} exit ${result.code}, expected 3 while Runtime is stopped`,
);
}
if (document?.state !== "unknown" || document?.code !== "runtime_not_running") {
if (
document?.state !== "unknown" ||
document?.code !== "runtime_not_running"
) {
failures.push(
`${probe.name} did not fail closed with unknown/runtime_not_running`,
);
@@ -451,8 +518,7 @@ async function main() {
}
const asJson = rest.includes("--json");
const expectShaIndex = rest.indexOf("--expect-sha");
const expectSha =
expectShaIndex >= 0 ? rest[expectShaIndex + 1] : undefined;
const expectSha = expectShaIndex >= 0 ? rest[expectShaIndex + 1] : undefined;
if (expectSha !== undefined && !/^[0-9a-fA-F]{64}$/.test(expectSha)) {
process.stderr.write("--expect-sha must be a 64-character SHA-256\n");
process.exitCode = 2;
+23 -6
View File
@@ -30,12 +30,16 @@ describe("managed restore over HTTP", { timeout: 15_000 }, () => {
}
if (request.headers.authorization === "Bearer r409") {
response.statusCode = 409;
response.end(JSON.stringify({ detail: "backup_restore_runtime_running" }));
response.end(
JSON.stringify({ detail: "backup_restore_runtime_running" }),
);
return;
}
if (request.headers.authorization === "Bearer r422") {
response.statusCode = 422;
response.end(JSON.stringify({ detail: "backup_restore_source_outside_home" }));
response.end(
JSON.stringify({ detail: "backup_restore_source_outside_home" }),
);
return;
}
response.end(
@@ -48,9 +52,12 @@ describe("managed restore over HTTP", { timeout: 15_000 }, () => {
}),
);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
const address = server.address();
if (address === null || typeof address === "string") throw new Error("no port");
if (address === null || typeof address === "string")
throw new Error("no port");
port = address.port;
});
@@ -137,8 +144,18 @@ describe("maintenance Core identity", { timeout: 15_000 }, () => {
it("rejects a squatter, wrong service, wrong instance, and non-200", async () => {
const cases = [
{ status: 200, data: { status: "ok", service: "other", instance_id: "x" } },
{ status: 200, data: { status: "ok", service: "one-memory-core", instance_id: "wrong" } },
{
status: 200,
data: { status: "ok", service: "other", instance_id: "x" },
},
{
status: 200,
data: {
status: "ok",
service: "one-memory-core",
instance_id: "wrong",
},
},
{ status: 503, data: {} },
{ status: 200, data: null },
];
+2 -1
View File
@@ -91,7 +91,8 @@ export async function callRestore(
source: string,
request: typeof defaultPost = defaultPost,
): Promise<
{ state: "restored"; result: RestoreResult } | { state: "rejected"; code: string }
| { state: "restored"; result: RestoreResult }
| { state: "rejected"; code: string }
> {
const response = await request(
`http://${options.host}:${options.port}/api/v1/backup/restore`,
+54 -18
View File
@@ -1,19 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import type { CoreReader, ReadOutcome } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import {
batchScanExitCode,
executeBatchImport,
planBatchImport,
renderBatchScanText,
} from "./batch-import-flow.js";
import type { CoreReader, ReadOutcome } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
function readerFor(catalog: unknown): CoreReader {
return (async (path: string) =>
path === "/api/v1/sources"
? ({ state: "ok", data: catalog } as ReadOutcome<unknown>)
: ({ state: "not_found", code: "http_404" } as ReadOutcome<unknown>)) as CoreReader;
: ({
state: "not_found",
code: "http_404",
} as ReadOutcome<unknown>)) as CoreReader;
}
function found(source_id: string, extra: Record<string, unknown> = {}) {
@@ -81,18 +83,36 @@ describe("A19 batch execution", () => {
readerFor([found("codex"), found("cursor")]),
);
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: ["codex", "cursor"],
failed_sources: {},
jobs: [
{ job_id: "j1", source_id: "codex", status: "pending", discovered: 3, stored: 3, errors: 0, operation: "history" },
{ job_id: "j2", source_id: "cursor", status: "pending", discovered: 5, stored: 5, errors: 0, operation: "history" },
],
},
}));
const post = vi.fn(
async (_path: string, body: Record<string, unknown>) => ({
state: "committed" as const,
data: {
request_id: body.request_id,
accepted_source_ids: ["codex", "cursor"],
failed_sources: {},
jobs: [
{
job_id: "j1",
source_id: "codex",
status: "pending",
discovered: 3,
stored: 3,
errors: 0,
operation: "history",
},
{
job_id: "j2",
source_id: "cursor",
status: "pending",
discovered: 5,
stored: 5,
errors: 0,
operation: "history",
},
],
},
}),
);
const operations = { post } as unknown as CoreOperations;
const document = await executeBatchImport(
planned.candidates,
@@ -127,7 +147,15 @@ describe("A19 batch execution", () => {
accepted_source_ids: ["codex"],
failed_sources: { cursor: "source_identity_changed" },
jobs: [
{ job_id: "j1", source_id: "codex", status: "pending", discovered: 1, stored: 1, errors: 0, operation: "history" },
{
job_id: "j1",
source_id: "codex",
status: "pending",
discovered: 1,
stored: 1,
errors: 0,
operation: "history",
},
],
},
})),
@@ -242,7 +270,15 @@ describe("A19 batch execution", () => {
accepted_source_ids: ["codex"],
failed_sources: {},
jobs: [
{ job_id: "j1", source_id: "codex", status: "pending", discovered: 1, stored: 1, errors: 0, operation: "history" },
{
job_id: "j1",
source_id: "codex",
status: "pending",
discovered: 1,
stored: 1,
errors: 0,
operation: "history",
},
],
},
})),
+28 -15
View File
@@ -14,15 +14,14 @@
*/
import { randomUUID } from "node:crypto";
import { buildSourceImportRequest } from "@one-memory/operations";
import type { ImportJob } from "@one-memory/contracts";
import { buildSourceImportRequest } from "@one-memory/operations";
import type { CoreReader } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import {
type SourceConnectionCandidate,
validateSourceConnectionCandidates,
} from "./core-responses.js";
import type { CoreReader } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import { PRODUCT } from "./version.js";
export interface BatchSourceRow {
@@ -57,19 +56,34 @@ export function detectedSourceIds(
return candidates.map((candidate) => candidate.source_id);
}
export async function planBatchImport(
reader: CoreReader,
): Promise<
| { state: "preview"; candidates: SourceConnectionCandidate[]; rows: BatchSourceRow[]; notFound: string[] }
| { state: "rejected" | "unknown" | "no_sources"; code: string; notFound: string[] }
export async function planBatchImport(reader: CoreReader): Promise<
| {
state: "preview";
candidates: SourceConnectionCandidate[];
rows: BatchSourceRow[];
notFound: string[];
}
| {
state: "rejected" | "unknown" | "no_sources";
code: string;
notFound: string[];
}
> {
const outcome = await reader<unknown>("/api/v1/sources");
if (outcome.state !== "ok") {
return { state: outcome.state === "unavailable" ? "unknown" : "rejected", code: outcome.code, notFound: [] };
return {
state: outcome.state === "unavailable" ? "unknown" : "rejected",
code: outcome.code,
notFound: [],
};
}
const catalog = outcome.data;
if (!Array.isArray(catalog) || catalog.length > SOURCE_LIST_LIMIT) {
return { state: "rejected", code: "sources_response_invalid", notFound: [] };
return {
state: "rejected",
code: "sources_response_invalid",
notFound: [],
};
}
const candidates = validateSourceConnectionCandidates(catalog);
if (!candidates) {
@@ -165,9 +179,7 @@ export async function executeBatchImport(
receipt.failed_sources && typeof receipt.failed_sources === "object"
? (receipt.failed_sources as Record<string, string>)
: {};
const jobs = Array.isArray(receipt.jobs)
? (receipt.jobs as ImportJob[])
: [];
const jobs = Array.isArray(receipt.jobs) ? (receipt.jobs as ImportJob[]) : [];
const firstSeen = jobs.map((job) => ({
source_id: job.source_id,
discovered: job.discovered,
@@ -262,7 +274,8 @@ export async function executeBatchImport(
}
export function batchScanExitCode(document: BatchScanDocument): number {
if (document.state === "committed" || document.state === "no_sources") return 0;
if (document.state === "committed" || document.state === "no_sources")
return 0;
if (document.state === "partial") return 1;
return 1;
}
+1 -5
View File
@@ -282,11 +282,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
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,
],
options: [PLAN_FILE_OPTION, EXPLICIT_CONFIRM_OPTION, JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
},
{
+2 -11
View File
@@ -300,11 +300,7 @@ describe("CLI-22 shared output contract", () => {
`${PRODUCT} sync`,
],
[
[
"sync",
"receipt",
"123e4567-e89b-12d3-a456-426614174000",
],
["sync", "receipt", "123e4567-e89b-12d3-a456-426614174000"],
`${PRODUCT} sync`,
],
[["sync", "pause", "--source"], `${PRODUCT} sync`],
@@ -555,12 +551,7 @@ describe("documented commands", () => {
// 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"],
jobs: [
"list",
"show",
"start",
"<pause|resume|cancel>",
],
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>"],
+14 -11
View File
@@ -342,17 +342,20 @@ describe("unified context search", () => {
["--limit", "1", "2"],
["--character-budget", "200", "300"],
["--timeout-ms", "100", "200"],
])("rejects a repeated %s before Runtime attach", async (flag, first, second) => {
const streams = captureStreams();
try {
expect(
await run(["recall", "q", flag, first, flag, second, "--json"], {}),
).toBe(2);
} finally {
streams.restore();
}
expect(streams.stdout()).toBe("");
});
])(
"rejects a repeated %s before Runtime attach",
async (flag, first, second) => {
const streams = captureStreams();
try {
expect(
await run(["recall", "q", flag, first, flag, second, "--json"], {}),
).toBe(2);
} finally {
streams.restore();
}
expect(streams.stdout()).toBe("");
},
);
it("refuses malformed success bodies instead of leaking nested values", async () => {
transport.post.mockResolvedValue({
+53 -38
View File
@@ -1,5 +1,5 @@
import axios from "axios";
import { classifyModelRemove } from "@one-memory/operations";
import axios from "axios";
/**
* Only paths proven side-effect free by reading the Core handlers belong here.
@@ -56,7 +56,10 @@ export function createUiSessionRequest(attachment: {
return { state: "ok" as const, data: response.data };
}
if (response.status === 503) {
return { state: "unavailable" as const, code: "ui_web_assets_unavailable" };
return {
state: "unavailable" as const,
code: "ui_web_assets_unavailable",
};
}
return { state: "unavailable" as const, code: `http_${response.status}` };
};
@@ -397,18 +400,23 @@ export async function readMemoryWrite(
export async function readScanRoots(
attachment: RuntimeAttachmentLike,
): Promise<{ schema_version: number; revision: number; roots: unknown[] } | null> {
): Promise<{
schema_version: number;
revision: number;
roots: unknown[];
} | null> {
try {
const response = await axios.get<{ schema_version: number; revision: number; roots: unknown[] }>(
`${baseUrlOf(attachment)}/api/v1/scan-roots`,
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 10_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
},
);
const response = await axios.get<{
schema_version: number;
revision: number;
roots: unknown[];
}>(`${baseUrlOf(attachment)}/api/v1/scan-roots`, {
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 10_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
});
return response.data;
} catch {
return null;
@@ -420,13 +428,17 @@ export async function addScanRoot(
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,
});
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) };
@@ -438,14 +450,17 @@ export async function removeScanRoot(
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,
});
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) };
@@ -456,16 +471,16 @@ export async function readAdapters(
attachment: RuntimeAttachmentLike,
): Promise<{ schema_version: number; adapters: unknown[] } | null> {
try {
const response = await axios.get<{ schema_version: number; adapters: unknown[] }>(
`${baseUrlOf(attachment)}/api/v1/adapters`,
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 10_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
},
);
const response = await axios.get<{
schema_version: number;
adapters: unknown[];
}>(`${baseUrlOf(attachment)}/api/v1/adapters`, {
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 10_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
});
return response.data;
} catch {
return null;
+28 -9
View File
@@ -1,4 +1,4 @@
import { mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
@@ -23,14 +23,22 @@ describe("explicit data deletion", () => {
"one-memory.sqlite3": "db",
"runtime-service.json": "{}",
});
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: false });
const plan = await planDataDeletion({
dataDir,
home,
runtimeRunning: false,
});
expect(plan.state).toBe("preview");
expect(plan.entries).toContain(path.join(dataDir, "one-memory.sqlite3"));
});
it("refuses while the Runtime is still running", async () => {
const { home, dataDir } = await homeWith();
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: true });
const plan = await planDataDeletion({
dataDir,
home,
runtimeRunning: true,
});
expect(plan).toMatchObject({
state: "blocked",
code: "runtime_still_running",
@@ -66,16 +74,23 @@ describe("explicit data deletion", () => {
home,
runtimeRunning: false,
});
expect(plan).toMatchObject({ state: "blocked", code: "data_dir_refused" });
expect(plan).toMatchObject({
state: "blocked",
code: "data_dir_refused",
});
}
});
it("requires the second confirmation to name the exact directory", async () => {
const { home, dataDir } = await homeWith();
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: false });
await expect(applyDataDeletion(plan, path.join(home, "other"))).rejects.toThrow(
"data_deletion_confirmation_mismatch",
);
const plan = await planDataDeletion({
dataDir,
home,
runtimeRunning: false,
});
await expect(
applyDataDeletion(plan, path.join(home, "other")),
).rejects.toThrow("data_deletion_confirmation_mismatch");
// The data survived a mismatched confirmation.
await expect(
stat(path.join(dataDir, "one-memory.sqlite3")),
@@ -84,7 +99,11 @@ describe("explicit data deletion", () => {
it("deletes an owned Home only when both confirmations match", async () => {
const { home, dataDir } = await homeWith({ "one-memory.sqlite3": "db" });
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: false });
const plan = await planDataDeletion({
dataDir,
home,
runtimeRunning: false,
});
const receipt = await applyDataDeletion(plan, dataDir);
expect(receipt.state).toBe("deleted");
await expect(stat(dataDir)).rejects.toMatchObject({ code: "ENOENT" });
+2 -1
View File
@@ -68,7 +68,8 @@ export function renderDoctorText(document: DoctorDocument): string {
return [
`MemHub 检查:${doctorState(document.state)}`,
...document.checks.map(
(check) => `${symbol(check.state)} ${labels[check.id]}:${checkState(check.state)}`,
(check) =>
`${symbol(check.state)} ${labels[check.id]}:${checkState(check.state)}`,
),
...(document.state === "healthy"
? []
+1 -5
View File
@@ -28,11 +28,7 @@ export function resolveEntryDiagnostics(input: {
// comparison would misread that as isolated and silently drop diagnostics.
const home = resolve(input.home);
const accountHome = resolve(input.accountHome);
if (
home === undefined ||
accountHome === undefined ||
home === accountHome
) {
if (home === undefined || accountHome === undefined || home === accountHome) {
return { kind: "log" };
}
// Isolated HOME with no explicit data dir: the fallback path would write
+1 -1
View File
@@ -9,8 +9,8 @@ import {
resolveRuntimeCliConfig,
} from "@one-memory/runtime-control";
import { run } from "./cli.js";
import { resolveDiagnosticsDataDir } from "./install-receipt.js";
import { resolveEntryDiagnostics } from "./entry-diagnostics.js";
import { resolveDiagnosticsDataDir } from "./install-receipt.js";
function accountHomeDirectory(): string | undefined {
try {
+4 -6
View File
@@ -1,12 +1,12 @@
import { createHash, randomUUID } from "node:crypto";
import type { ImportJob, ModelProfile } from "@one-memory/contracts";
import {
ModelProfileInputError,
SourceImportInputError,
buildModelProfileCreateRequest,
buildSourceImportRequest,
ModelProfileInputError,
parseModelProfileReceipt,
SourceImportInputError,
} from "@one-memory/operations";
import type { ImportJob, ModelProfile } from "@one-memory/contracts";
import type { CoreReader, ReadOutcome } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import {
@@ -117,9 +117,7 @@ function classifySourceImport(
product: PRODUCT,
state: "rejected",
operation_id: operationId,
code: onlyBusy
? "source_import_all_busy"
: "source_import_all_rejected",
code: onlyBusy ? "source_import_all_busy" : "source_import_all_rejected",
receipt,
};
}
+3 -1
View File
@@ -266,7 +266,9 @@ export function renderHostText(document: HostFlowDocument): string {
}
if (review.length > 12) lines.push(` … 另有 ${review.length - 12} 处`);
lines.push("");
lines.push("这些不是我们安装的内容,卸载不会替你删除;需自行处理后才能卸载。");
lines.push(
"这些不是我们安装的内容,卸载不会替你删除;需自行处理后才能卸载。",
);
}
}
return `${lines.join("\n")}\n`;
+3 -1
View File
@@ -7,7 +7,9 @@ import {
type WizardPrompter,
} from "./init-wizard.js";
function scripted(lines: string[]): WizardPrompter & { asked: string[]; wrote: string[] } {
function scripted(
lines: string[],
): WizardPrompter & { asked: string[]; wrote: string[] } {
let index = 0;
const asked: string[] = [];
const wrote: string[] = [];
+1 -2
View File
@@ -99,8 +99,7 @@ export async function runInitWizard(
"开启处理?开启后会处理历史未完成会话并持续接收新会话;输入 n 保持冻结。[Y/n]: ",
);
const processing = parseYesNo(processingAnswer, true);
if (processing === null)
return { state: "invalid", code: "invalid_answer" };
if (processing === null) return { state: "invalid", code: "invalid_answer" };
return {
state: "answered",
+5 -6
View File
@@ -1,15 +1,15 @@
import {
mkdir,
mkdtemp,
readFile,
readdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveEntryDiagnostics } from "./entry-diagnostics.js";
import {
defaultInstallReceiptPath,
readInstallReceipt,
@@ -18,7 +18,6 @@ import {
resolveInstalledDataDir,
writeInstallReceipt,
} from "./install-receipt.js";
import { resolveEntryDiagnostics } from "./entry-diagnostics.js";
async function root(): Promise<string> {
return mkdtemp(path.join(tmpdir(), "memhub-install-receipt-"));
@@ -230,9 +229,9 @@ describe("managed install receipt", () => {
}),
).toBe("/tmp/env data");
// Receipt beats default.
expect(
await resolveDiagnosticsDataDir(["install"], { HOME: home }),
).toBe(path.join(home, "receipt data"));
expect(await resolveDiagnosticsDataDir(["install"], { HOME: home })).toBe(
path.join(home, "receipt data"),
);
// Invalid explicit flag must not silently fall back to the receipt.
expect(
await resolveDiagnosticsDataDir(
@@ -184,10 +184,9 @@ describe("interactive model setup", () => {
embedding_provider_kind: "local_onnx",
}),
);
expect(api.post).toHaveBeenCalledWith(
"/api/v1/model-content-permission",
{ allowed: true },
);
expect(api.post).toHaveBeenCalledWith("/api/v1/model-content-permission", {
allowed: true,
});
expect(prompt.written.join("\n")).not.toContain("sk-do-not-print");
});
@@ -11,12 +11,12 @@ import {
} from "./headless-flows.js";
import { approvalFingerprint, listHosts, readHost } from "./host-flow.js";
import { parseSelection, type WizardPrompter } from "./init-wizard.js";
import { setModelContentPermission } from "./model-content-permission-flow.js";
import { executeModelProbe, planModelProbe } from "./model-probe-flow.js";
import {
executeModelProfileActivate,
planModelProfileActivate,
} from "./model-write-flows.js";
import { setModelContentPermission } from "./model-content-permission-flow.js";
type ProviderPreset = {
id: string;
+1 -1
View File
@@ -17,6 +17,7 @@ vi.mock("axios", () => ({
default: { create: vi.fn(() => transport), get: vi.fn() },
}));
import type { CoreOperations } from "./core-operations.js";
import { createCoreOperations } from "./core-operations.js";
import {
executeJobAction,
@@ -25,7 +26,6 @@ import {
planJobAction,
planJobStart,
} from "./job-flow.js";
import type { CoreOperations } from "./core-operations.js";
function operations(
post: (path: string, body: Record<string, unknown>) => Promise<unknown>,
+1 -3
View File
@@ -145,9 +145,7 @@ function jobMutationResult(
export function jobExitCode(document: JobFlowDocument): number {
if (document.state === "preview" || document.state === "committed") return 0;
if (document.state === "recovered") return 0;
return document.state === "rejected" || document.state === "unknown"
? 1
: 2;
return document.state === "rejected" || document.state === "unknown" ? 1 : 2;
}
function invalid(code: string): JobFlowDocument {
+2 -8
View File
@@ -105,10 +105,7 @@ describe("memhub jobs", () => {
const output = captureStdout();
let code: number;
try {
code = await run(
["jobs", "start", "--source", "codex", "--json"],
{},
);
code = await run(["jobs", "start", "--source", "codex", "--json"], {});
} finally {
output.restore();
}
@@ -154,10 +151,7 @@ describe("memhub jobs", () => {
const output = captureStdout();
let code: number;
try {
code = await run(
["jobs", action, "--job", "job-1", "--json"],
{},
);
code = await run(["jobs", action, "--job", "job-1", "--json"], {});
} finally {
output.restore();
}
+37 -11
View File
@@ -1,7 +1,10 @@
import type { DiagnosticEventV1 } from "@one-memory/runtime-control";
import { diagnosticLogFiles, readDiagnosticEvents } from "@one-memory/runtime-control";
import { lstat } from "node:fs/promises";
import path from "node:path";
import type { DiagnosticEventV1 } from "@one-memory/runtime-control";
import {
diagnosticLogFiles,
readDiagnosticEvents,
} from "@one-memory/runtime-control";
export interface LogQuery {
since?: Date;
@@ -160,10 +163,21 @@ export async function readLogReport(
const logsDirectory = path.join(path.resolve(dataDir), "logs");
const info = await lstat(logsDirectory).catch(() => null);
if (info !== null && (!info.isDirectory() || info.isSymbolicLink())) {
return { events: [], invalid_lines: 0, failure: "log_directory_unreadable" };
return {
events: [],
invalid_lines: 0,
failure: "log_directory_unreadable",
};
}
if (info !== null && (await diagnosticLogFiles(dataDir).catch(() => null)) === null) {
return { events: [], invalid_lines: 0, failure: "log_directory_unreadable" };
if (
info !== null &&
(await diagnosticLogFiles(dataDir).catch(() => null)) === null
) {
return {
events: [],
invalid_lines: 0,
failure: "log_directory_unreadable",
};
}
const explicit =
query.since ||
@@ -239,15 +253,27 @@ export function redactDiagnosticEvent(
outcome: event.outcome,
session_id: event.session_id,
...(event.trace_id === undefined ? {} : { trace_id: event.trace_id }),
...(event.incident_id === undefined ? {} : { incident_id: event.incident_id }),
...(event.instance_id === undefined ? {} : { instance_id: event.instance_id }),
...(event.operation_id === undefined ? {} : { operation_id: event.operation_id }),
...(event.incident_id === undefined
? {}
: { incident_id: event.incident_id }),
...(event.instance_id === undefined
? {}
: { instance_id: event.instance_id }),
...(event.operation_id === undefined
? {}
: { operation_id: event.operation_id }),
...(event.request_id === undefined ? {} : { request_id: event.request_id }),
...(event.job_id === undefined ? {} : { job_id: event.job_id }),
...(event.task_id === undefined ? {} : { task_id: event.task_id }),
...(event.duration_ms === undefined ? {} : { duration_ms: event.duration_ms }),
...(event.state_before === undefined ? {} : { state_before: event.state_before }),
...(event.state_after === undefined ? {} : { state_after: event.state_after }),
...(event.duration_ms === undefined
? {}
: { duration_ms: event.duration_ms }),
...(event.state_before === undefined
? {}
: { state_before: event.state_before }),
...(event.state_after === undefined
? {}
: { state_after: event.state_after }),
...(event.error_code === undefined ? {} : { error_code: event.error_code }),
...(event.error_type === undefined ? {} : { error_type: event.error_type }),
...(event.safe_stack === undefined
+12 -5
View File
@@ -11,9 +11,8 @@ import {
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { CliLinkPlan } from "@one-memory/runtime-control";
import { describe, expect, it } from "vitest";
import {
installManagedCliAlias,
installManagedCliLink,
@@ -107,7 +106,9 @@ describe("managed memhub CLI alias", () => {
await installManagedCliAlias(plan);
await expect(removeManagedCliAlias(plan)).resolves.toBe("removed");
await expect(lstat(plan.link_path)).rejects.toMatchObject({ code: "ENOENT" });
await expect(lstat(plan.link_path)).rejects.toMatchObject({
code: "ENOENT",
});
});
// C8 P1 (2026-09-29): `runtime-control.applyLocalUninstall` deletes
@@ -156,7 +157,10 @@ describe("managed memhub CLI alias", () => {
it("manages the one-memory companion when memhub is already present", async () => {
if (process.platform === "win32") return;
const f = await fixture();
const primary = { ...f.base, link_path: path.join(f.base.bin_directory, "memhub") };
const primary = {
...f.base,
link_path: path.join(f.base.bin_directory, "memhub"),
};
await mkdir(f.base.bin_directory, { recursive: true, mode: 0o700 });
await (await import("node:fs/promises")).chmod(f.base.bin_directory, 0o700);
await (await import("node:fs/promises")).symlink(
@@ -168,7 +172,10 @@ describe("managed memhub CLI alias", () => {
const plan = await planManagedCliLink(f.base, "one-memory");
// The companion does not exist yet, so it is ours to create; crucially it
// targets the compat name, not the package's `memhub`.
expect(plan).toMatchObject({ status: "create", link_path: f.base.link_path });
expect(plan).toMatchObject({
status: "create",
link_path: f.base.link_path,
});
// The package's memhub link is untouched.
expect(await readlink(primary.link_path)).toBe(f.target);
+9 -1
View File
@@ -1,4 +1,12 @@
import { access, chmod, lstat, mkdir, readlink, rm, symlink } from "node:fs/promises";
import {
access,
chmod,
lstat,
mkdir,
readlink,
rm,
symlink,
} from "node:fs/promises";
import path from "node:path";
import type { CliLinkPlan, CliLinkReceipt } from "@one-memory/runtime-control";
@@ -53,7 +53,9 @@ describe("model content permission CLI", () => {
expect(permission.code).toBe(0);
expect(permission.stdout).toContain("memhub permission allow");
expect(permission.stdout).toContain("memhub permission deny");
expect(permission.stdout).not.toMatch(/\b(preview|confirm|process|revoke)\b/);
expect(permission.stdout).not.toMatch(
/\b(preview|confirm|process|revoke)\b/,
);
expect(permission.stdout).not.toContain("revision");
expect(permission.stdout).not.toContain("receipt");
@@ -23,19 +23,22 @@ describe("model content permission flow", () => {
it.each([
["allow", true],
["deny", false],
] as const)("sets %s directly without revision or receipt fields", async (_, allowed) => {
const post = vi.fn().mockResolvedValue({
state: "committed",
data: { allowed },
});
] as const)(
"sets %s directly without revision or receipt fields",
async (_, allowed) => {
const post = vi.fn().mockResolvedValue({
state: "committed",
data: { allowed },
});
await expect(
setModelContentPermission({ post } as CoreOperations, allowed),
).resolves.toMatchObject({ state: "ok", allowed });
expect(post).toHaveBeenCalledWith("/api/v1/model-content-permission", {
allowed,
});
});
await expect(
setModelContentPermission({ post } as CoreOperations, allowed),
).resolves.toMatchObject({ state: "ok", allowed });
expect(post).toHaveBeenCalledWith("/api/v1/model-content-permission", {
allowed,
});
},
);
it("does not accept a malformed or mismatched success response", async () => {
const malformed = vi.fn().mockResolvedValue({
@@ -23,7 +23,10 @@ export async function readModelContentPermission(
if (outcome.state !== "ok") {
return failure("unknown", outcome.code);
}
return permissionState(outcome.data, "model_content_permission_response_invalid");
return permissionState(
outcome.data,
"model_content_permission_response_invalid",
);
}
export async function setModelContentPermission(
+8 -5
View File
@@ -13,8 +13,8 @@
import { createHash, randomUUID } from "node:crypto";
import type { ModelProfile } from "@one-memory/contracts";
import {
ModelProfileInputError,
buildModelProfileUpdateRequest,
ModelProfileInputError,
parseModelProfileReceipt,
} from "@one-memory/operations";
import type { CoreOperations, CorePatchOperations } from "./core-operations.js";
@@ -78,7 +78,10 @@ function digestOf(value: unknown): string {
function preview(
plan:
| Omit<Extract<ModelWritePlan, { kind: "model_profile_activate" }>, "digest">
| Omit<
Extract<ModelWritePlan, { kind: "model_profile_activate" }>,
"digest"
>
| Omit<Extract<ModelWritePlan, { kind: "model_profile_edit" }>, "digest">,
): ModelWriteDocument {
return {
@@ -198,9 +201,9 @@ export async function executeModelProfileEdit(
let request: ReturnType<typeof buildModelProfileUpdateRequest>;
try {
request = buildModelProfileUpdateRequest(plan.profile_id, {
...plan.patch,
expected_revision: plan.expected_revision,
...(apiKey === undefined ? {} : { api_key: apiKey }),
...plan.patch,
expected_revision: plan.expected_revision,
...(apiKey === undefined ? {} : { api_key: apiKey }),
});
} catch (error) {
if (error instanceof ModelProfileInputError) {
+1 -1
View File
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir, homedir } from "node:os";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
@@ -1,6 +1,6 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
+50 -18
View File
@@ -103,26 +103,45 @@ describe("unknown flags are rejected", () => {
});
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",
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", ["remove", "--root", "/tmp/x", "--source", "codex"]),
parseProductCommand("path", [
"remove",
"--root",
"/tmp/x",
"--source",
"codex",
]),
).toBeNull();
expect(
parseProductCommand("path", ["remove", "--root", "/tmp/x", "--no-recursive"]),
parseProductCommand("path", [
"remove",
"--root",
"/tmp/x",
"--no-recursive",
]),
).toBeNull();
expect(
parseProductCommand("path", ["add", "--root", "/tmp/x", "--full"]),
).toBeNull();
expect(
parseProductCommand("backup", ["create", "--target", "/tmp/b", "--source", "/tmp/s"]),
parseProductCommand("backup", [
"create",
"--target",
"/tmp/b",
"--source",
"/tmp/s",
]),
).toBeNull();
expect(
parseProductCommand("backup", [
@@ -163,9 +182,7 @@ describe("unknown flags are rejected", () => {
});
it("rejects repeated boolean flags instead of treating them as idempotent", () => {
expect(
parseProductCommand("scan", ["--all", "--all", "--yes"]),
).toBeNull();
expect(parseProductCommand("scan", ["--all", "--all", "--yes"])).toBeNull();
expect(
parseProductCommand("scan", ["--full", "--full", "--yes"]),
).toBeNull();
@@ -179,7 +196,13 @@ describe("unknown flags are rejected", () => {
]),
).toBeNull();
expect(
parseProductCommand("backup", ["create", "--target", "/tmp/a", "--yes", "--yes"]),
parseProductCommand("backup", [
"create",
"--target",
"/tmp/a",
"--yes",
"--yes",
]),
).toBeNull();
});
@@ -215,12 +238,12 @@ describe("unknown flags are rejected", () => {
]);
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", parsed?.confirmPath),
).toBe(true);
expect(
restoreConfirmationMatches("/tmp/backups/a.sqlite3", undefined),
).toBe(false);
expect(
restoreConfirmationMatches(
"/tmp/backups/a.sqlite3",
@@ -365,7 +388,11 @@ describe("path add and remove carry what the endpoint needs", () => {
});
it("distinguishes remove from add", () => {
const parsed = parseProductCommand("path", ["remove", "--root", "/tmp/scanme"]);
const parsed = parseProductCommand("path", [
"remove",
"--root",
"/tmp/scanme",
]);
if (parsed === null) {
throw new Error("path remove must parse");
}
@@ -375,7 +402,12 @@ describe("path add and remove carry what the endpoint needs", () => {
});
it("does not silently drop --yes", () => {
const parsed = parseProductCommand("path", ["add", "--root", "/tmp/a", "--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.
+10 -13
View File
@@ -6,10 +6,7 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/** The app root (one level above this test's `src/`). */
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const bundle = path.join(root, "dist", "one-memory.cjs");
// The published npm package must be self-contained. The bundle inlines
@@ -20,9 +17,10 @@ const bundle = path.join(root, "dist", "one-memory.cjs");
describe("publish manifest is production-safe", () => {
it("declares no npm runtime dependency once dependencies are inlined", () => {
// `test` builds with tsup first, so a missing bundle is a real failure.
expect(existsSync(bundle), "run `pnpm --filter @one-memory/cli build`").toBe(
true,
);
expect(
existsSync(bundle),
"run `pnpm --filter @one-memory/cli build`",
).toBe(true);
const source = readFileSync(bundle, "utf8");
const external = [
...new Set(
@@ -37,16 +35,15 @@ describe("publish manifest is production-safe", () => {
),
];
// Every one of these must be a Node builtin, i.e. no npm package.
const npmDeps = external.filter(
(name) => !builtinModules.includes(name),
);
const npmDeps = external.filter((name) => !builtinModules.includes(name));
expect(npmDeps).toEqual([]);
});
it("strip-out rule keeps only what the bundle requires", () => {
expect(existsSync(bundle), "run `pnpm --filter @one-memory/cli build`").toBe(
true,
);
expect(
existsSync(bundle),
"run `pnpm --filter @one-memory/cli build`",
).toBe(true);
// Run the real generator, then inspect its output manifest.
execFileSync("node", ["scripts/prepare-publish-manifest.mjs"], {
cwd: root,
@@ -49,9 +49,9 @@ describe("runtime service lock guard", () => {
JSON.stringify({ pid: 4321 }),
{ mode: 0o600 },
);
await expect(
inspectRuntimeServiceLock(dataDir, () => false),
).resolves.toBe("clear");
await expect(inspectRuntimeServiceLock(dataDir, () => false)).resolves.toBe(
"clear",
);
});
it("fails closed on a malformed lease", async () => {
+46 -12
View File
@@ -1,15 +1,15 @@
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
type CommandResult,
inspectLaunchdService,
restartManagedService,
type ServiceCommandRunner,
startManagedService,
stopManagedService,
type CommandResult,
type ServiceCommandRunner,
} from "./service-session.js";
const SERVICE_ID = "com.tencent.onememory.runtime";
@@ -35,7 +35,11 @@ describe("service-aware launchd session", () => {
it("reports running with the launchd pid and plist", async () => {
const run = vi.fn<ServiceCommandRunner>(async () => running(84237));
await expect(
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
inspectLaunchdService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toEqual({
state: "running",
pid: 84237,
@@ -46,7 +50,11 @@ describe("service-aware launchd session", () => {
it("treats 'could not find service' as absent", async () => {
const run = vi.fn<ServiceCommandRunner>(async () => absent());
await expect(
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
inspectLaunchdService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toEqual({ state: "absent" });
});
@@ -57,7 +65,11 @@ describe("service-aware launchd session", () => {
stderr: "Input/output error",
}));
await expect(
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
inspectLaunchdService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toEqual({ state: "unknown", code: "launchd_inspection_failed" });
});
@@ -146,13 +158,21 @@ describe("service-aware launchd session", () => {
if (command[1] === "print") {
printed += 1;
return printed === 1
? { code: 0, stdout: `path = /tmp/x.plist\nstate = not running\n`, stderr: "" }
? {
code: 0,
stdout: `path = /tmp/x.plist\nstate = not running\n`,
stderr: "",
}
: running(84237);
}
return running(84237);
});
await expect(
startManagedService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
startManagedService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toBe("started");
expect(calls).toContainEqual([
"launchctl",
@@ -169,7 +189,11 @@ describe("service-aware launchd session", () => {
return running(84237);
});
await expect(
startManagedService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
startManagedService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toBe("started");
// Already running -> no kickstart needed.
expect(calls.some((c) => c[1] === "kickstart")).toBe(false);
@@ -214,7 +238,11 @@ describe("service-aware launchd session", () => {
stderr: "",
}));
await expect(
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
inspectLaunchdService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toEqual({ state: "loaded", plistPath: "/tmp/x.plist" });
});
@@ -225,7 +253,11 @@ describe("service-aware launchd session", () => {
return running(101);
});
await expect(
restartManagedService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
restartManagedService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toBe("restarted");
expect(calls).toContainEqual([
"launchctl",
@@ -286,7 +318,9 @@ describe("service-aware launchd session", () => {
stdout: JSON.stringify({
Label: SERVICE_ID,
ProgramArguments: ["/tmp/node", "/tmp/runtime.cjs", "serve"],
EnvironmentVariables: { ONE_MEMORY_DATA_DIR: "/tmp/this-install/data" },
EnvironmentVariables: {
ONE_MEMORY_DATA_DIR: "/tmp/this-install/data",
},
}),
stderr: "",
};
+9 -5
View File
@@ -49,7 +49,10 @@ function launchdDomain(uid: number): string {
/** Deterministic location of the managed LaunchAgent plist. Needed because
* after `bootout` launchd no longer reports a path, yet `start` must be able to
* bootstrap the very same service back. */
export function defaultLaunchAgentPath(home: string, serviceId: string): string {
export function defaultLaunchAgentPath(
home: string,
serviceId: string,
): string {
return path.posix.join(home, "Library", "LaunchAgents", `${serviceId}.plist`);
}
@@ -162,7 +165,10 @@ export async function stopManagedService(options: {
? (["launchctl", "bootout", domain, before.plistPath] as const)
: (["launchctl", "bootout", `${domain}/${options.serviceId}`] as const);
const result = await options.runCaptured([...target]);
if (result.code !== 0 && !/could not find service|service not found/i.test(result.stderr)) {
if (
result.code !== 0 &&
!/could not find service|service not found/i.test(result.stderr)
) {
throw new Error("launchd_bootout_failed");
}
// "Stopped" must mean the label stays gone, not that we saw it gone once.
@@ -295,9 +301,7 @@ export async function runLaunchctlCaptured(
stderr += String(chunk);
});
child.once("error", () => reject(new Error("launchd_command_failed")));
child.once("close", (code) =>
resolve({ code: code ?? 1, stdout, stderr }),
);
child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
});
}
+5 -1
View File
@@ -3,7 +3,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { applyShellPath, planShellPath, removeShellPath } from "./shell-path.js";
import {
applyShellPath,
planShellPath,
removeShellPath,
} from "./shell-path.js";
async function home(): Promise<string> {
return mkdtemp(path.join(tmpdir(), "memhub-shell-path-"));
+8 -12
View File
@@ -72,9 +72,7 @@ function blockFor(binDirectory: string): string {
* original file byte-for-byte regardless of the original's trailing newline. */
function ownedBlock(existing: string | null, binDirectory: string): string {
const separator =
existing === null || existing === "" || existing.endsWith("\n")
? ""
: "\n";
existing === null || existing === "" || existing.endsWith("\n") ? "" : "\n";
return `${separator}${blockFor(binDirectory)}`;
}
@@ -104,10 +102,7 @@ export async function planShellPath(options: {
disabled: true,
};
}
const blocked = (
reason: string,
target: string,
): ShellPathPlan => ({
const blocked = (reason: string, target: string): ShellPathPlan => ({
schema_version: 1,
status: "blocked",
blocked_reason: reason,
@@ -229,11 +224,12 @@ async function writeProfileAtomically(
// A fresh `--home` may not exist yet; create it (owner-only) before writing
// the temp profile, otherwise the write fails with a raw ENOENT. Surface a
// structured code for any other failure.
await mkdir(path.dirname(profilePath), { recursive: true, mode: 0o700 }).catch(
() => {
throw new Error("shell_path_home_unwritable");
},
);
await mkdir(path.dirname(profilePath), {
recursive: true,
mode: 0o700,
}).catch(() => {
throw new Error("shell_path_home_unwritable");
});
const temporary = `${profilePath}.memhub.${process.pid}.tmp`;
try {
await writeFile(temporary, content, { encoding: "utf8", mode: 0o644 });
+3 -1
View File
@@ -468,7 +468,9 @@ describe("CLI-19 progress and issue reads", () => {
);
expect(output).toContain("[15:04:05.067] progress #1");
expect(output).toContain("[15:04:05.067] progress #2");
expect(output.match(/\x1b\[2J\x1b\[H/g)).toHaveLength(2);
expect(output.match(new RegExp("\\x1b\\[2J\\x1b\\[H", "g"))).toHaveLength(
2,
);
});
it("keeps non-terminal watch output append-only", async () => {
+1 -5
View File
@@ -1,9 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { CoreReader, ReadOutcome } from "./core-client.js";
import type {
CoreOperations,
OperationOutcome,
} from "./core-operations.js";
import type { CoreOperations, OperationOutcome } from "./core-operations.js";
import {
renderSourceSyncText,
setSourceSync,
@@ -320,4 +317,3 @@ describe("setSourceSync", () => {
expect(renderSourceSyncText(invalid)).toBe("未变更:invalid_source_id\n");
});
});
+5 -2
View File
@@ -1,7 +1,7 @@
import {
SourceSyncInputError,
buildSourceSyncRequest,
hasSourceSyncTarget,
SourceSyncInputError,
} from "@one-memory/operations";
import type { CoreReader } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
@@ -36,7 +36,10 @@ async function current(
reader: CoreReader,
sourceId: string,
): Promise<
| { state: "ok"; source: NonNullable<ReturnType<typeof validateSources>>[number] }
| {
state: "ok";
source: NonNullable<ReturnType<typeof validateSources>>[number];
}
| { state: "invalid" | "unknown"; code: string }
> {
const outcome = await reader<unknown>("/api/v1/sources");
+3 -1
View File
@@ -30,6 +30,8 @@ describe("status default output", () => {
expect(text).not.toContain("[object Object]");
expect(text).toContain("卡住 类型 整理对话/原因 等待模型/数量 2");
expect(text).not.toMatch(/background_verified|additions_only|error_codes=/);
expect(text).not.toMatch(/provider_kind|has_api_key|credential_state|observed_at/);
expect(text).not.toMatch(
/provider_kind|has_api_key|credential_state|observed_at/,
);
});
});
+22 -8
View File
@@ -85,12 +85,16 @@ describe("memhub ui command wiring", () => {
const output = capture();
let code: number;
try {
code = await run(["ui", "--json"], {}, {
openBrowser: async (url) => {
opened.push(url);
return true;
code = await run(
["ui", "--json"],
{},
{
openBrowser: async (url) => {
opened.push(url);
return true;
},
},
});
);
} finally {
output.restore();
}
@@ -102,7 +106,9 @@ describe("memhub ui command wiring", () => {
state: "opened",
url: "http://127.0.0.1:8765/ui/login?code=one-time-code",
});
expect(opened).toEqual(["http://127.0.0.1:8765/ui/login?code=one-time-code"]);
expect(opened).toEqual([
"http://127.0.0.1:8765/ui/login?code=one-time-code",
]);
// The bearer token was sent to Core in the request header, never in output.
expect(output.value()).not.toMatch(/synthetic-runtime-token/);
});
@@ -159,7 +165,11 @@ describe("memhub ui command wiring", () => {
const output = capture();
let code: number;
try {
code = await run(["ui", "--json"], {}, { openBrowser: async () => false });
code = await run(
["ui", "--json"],
{},
{ openBrowser: async () => false },
);
} finally {
output.restore();
}
@@ -185,7 +195,11 @@ describe("memhub ui command wiring", () => {
const output = capture();
let code: number;
try {
code = await run(["ui", "--json"], {}, { openBrowser: async () => true });
code = await run(
["ui", "--json"],
{},
{ openBrowser: async () => true },
);
} finally {
output.restore();
}
+2 -1
View File
@@ -9,7 +9,8 @@ import { parseUiSessionExchange } from "./ui-session.js";
describe("ui session exchange over HTTP", () => {
let server: Server;
let port: number;
const seen: Array<{ authorization?: string; method?: string; url?: string }> = [];
const seen: Array<{ authorization?: string; method?: string; url?: string }> =
[];
let status = 200;
beforeAll(async () => {
+36 -7
View File
@@ -45,11 +45,31 @@ describe("memhub ui session wiring", () => {
for (const doc of [
null,
{},
{ schema_version: 2, exchange_code: "x", expires_in_seconds: 60, url: "http://127.0.0.1:1/ui/login?code=x" },
{
schema_version: 2,
exchange_code: "x",
expires_in_seconds: 60,
url: "http://127.0.0.1:1/ui/login?code=x",
},
{ schema_version: 1, exchange_code: "x", expires_in_seconds: 60 },
{ schema_version: 1, exchange_code: "", expires_in_seconds: 60, url: "http://127.0.0.1:1/ui/login?code=x" },
{ schema_version: 1, exchange_code: "x", expires_in_seconds: 0, url: "http://127.0.0.1:1/ui/login?code=x" },
{ schema_version: 1, exchange_code: "x", expires_in_seconds: 60, url: "http://127.0.0.1:1/" },
{
schema_version: 1,
exchange_code: "",
expires_in_seconds: 60,
url: "http://127.0.0.1:1/ui/login?code=x",
},
{
schema_version: 1,
exchange_code: "x",
expires_in_seconds: 0,
url: "http://127.0.0.1:1/ui/login?code=x",
},
{
schema_version: 1,
exchange_code: "x",
expires_in_seconds: 60,
url: "http://127.0.0.1:1/",
},
]) {
expect(parseUiSessionExchange(doc)).toBeNull();
}
@@ -59,9 +79,18 @@ describe("memhub ui session wiring", () => {
expect(uiSessionFailure("ui_web_assets_unavailable")).toMatchObject({
code: "ui_web_assets_unavailable",
});
expect(uiExitCode({ state: "opened", url: "http://127.0.0.1:1/ui/login?code=x" })).toBe(0);
expect(uiExitCode({ state: "blocked", code: "ui_session_contract_unavailable" })).toBe(4);
expect(uiExitCode({ state: "failed", code: "ui_session_code_invalid" })).toBe(1);
expect(
uiExitCode({
state: "opened",
url: "http://127.0.0.1:1/ui/login?code=x",
}),
).toBe(0);
expect(
uiExitCode({ state: "blocked", code: "ui_session_contract_unavailable" }),
).toBe(4);
expect(
uiExitCode({ state: "failed", code: "ui_session_code_invalid" }),
).toBe(1);
});
it("uses a platform-appropriate opener and reports missing openers", async () => {
+6 -1
View File
@@ -42,7 +42,12 @@ function isLoopbackHttpUrl(value: string): boolean {
}
if (parsed.protocol !== "http:") return false;
const host = parsed.hostname.toLowerCase();
return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
return (
host === "localhost" ||
host === "127.0.0.1" ||
host === "[::1]" ||
host === "::1"
);
}
/** Validate the Core exchange document; return null when it does not match the
+11 -2
View File
@@ -80,7 +80,13 @@ describe("calls clear", () => {
});
it("previews without contacting Core", async () => {
const result = await capturedRun(["calls", "clear", "--event", EVENT, "--json"]);
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");
@@ -341,7 +347,10 @@ describe("notes / extraction / graph / manual extract", () => {
"--json",
]);
expect(applied.code).toBe(0);
const body = transport.post.mock.calls.at(-1)?.[1] as Record<string, unknown>;
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");
+23 -14
View File
@@ -10,18 +10,17 @@
import { createHash, randomUUID } from "node:crypto";
import {
MemoryNoteInputError,
buildMemoryNoteSaveRequest,
parseMemoryNoteReceipt,
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 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;
@@ -117,7 +116,10 @@ function digestOf(value: unknown): string {
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: "extraction_window_set" }>,
"digest"
>
| Omit<Extract<ExtendedWritePlan, { kind: "graph_width_decide" }>, "digest">
| Omit<Extract<ExtendedWritePlan, { kind: "manual_extract" }>, "digest">;
@@ -148,7 +150,10 @@ function committed(
};
}
function rejected(operationId: string | undefined, code: string): ExtendedWriteOutcome {
function rejected(
operationId: string | undefined,
code: string,
): ExtendedWriteOutcome {
return {
schema_version: 1,
product: PRODUCT,
@@ -158,7 +163,10 @@ function rejected(operationId: string | undefined, code: string): ExtendedWriteO
};
}
function unknown(operationId: string | undefined, code: string): ExtendedWriteOutcome {
function unknown(
operationId: string | undefined,
code: string,
): ExtendedWriteOutcome {
return {
schema_version: 1,
product: PRODUCT,
@@ -197,7 +205,9 @@ export function validNoteText(text: string): boolean {
);
}
export function planMemoryNoteProcess(evidenceId: string): ExtendedWriteOutcome {
export function planMemoryNoteProcess(
evidenceId: string,
): ExtendedWriteOutcome {
return UUID.test(evidenceId)
? preview({
schema_version: 1,
@@ -313,8 +323,7 @@ export function planGraphWidthDecide(decision: string): ExtendedWriteOutcome {
export function planManualExtract(evidenceIds: string[]): ExtendedWriteOutcome {
const ids = [...new Set(evidenceIds)];
return ids.length === evidenceIds.length &&
ids.every((id) => UUID.test(id))
return ids.length === evidenceIds.length && ids.every((id) => UUID.test(id))
? preview({
schema_version: 1,
kind: "manual_extract",
@@ -455,12 +464,12 @@ export function validateExtendedWritePlan(
}
export function extendedWriteExitCode(document: ExtendedWriteOutcome): number {
return ["preview", "committed", "recovered"].includes(document.state)
? 0
: 1;
return ["preview", "committed", "recovered"].includes(document.state) ? 0 : 1;
}
export function renderExtendedWriteText(document: ExtendedWriteOutcome): string {
export function renderExtendedWriteText(
document: ExtendedWriteOutcome,
): string {
if (document.state === "preview") {
const lines = ["以下操作尚未执行;加 --confirm 后才会写入:"];
const plan = document.plan;
+16 -22
View File
@@ -61,10 +61,9 @@ describe("UI write execution", () => {
}));
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,
);
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],
@@ -79,10 +78,9 @@ describe("UI write execution", () => {
}));
const plan = planOf(planCallEventsClearAll());
if (plan.kind !== "call_events_clear_all") throw new Error("kind");
await executeCallEventsClearAll(
plan,
{ post } as unknown as CoreOperations,
);
await executeCallEventsClearAll(plan, {
post,
} as unknown as CoreOperations);
expect(post).toHaveBeenCalledWith("/api/v1/call-events/clear-all", {
user_confirmed: true,
});
@@ -107,7 +105,7 @@ describe("UI write execution", () => {
committed: true,
},
}));
const del = vi.fn(async <T,>() => ({
const del = vi.fn(async <T>() => ({
state: "committed" as const,
data: {} as T,
}));
@@ -118,10 +116,9 @@ describe("UI write execution", () => {
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 },
);
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(
@@ -133,15 +130,12 @@ describe("UI write execution", () => {
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,
);
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",
+13 -17
View File
@@ -14,8 +14,7 @@ import type {
} 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 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;
@@ -139,7 +138,11 @@ export function planCallEventsClear(
) {
return invalid("call_event_ids_invalid");
}
return preview({ schema_version: 1, kind: "call_events_clear", event_ids: [...eventIds] });
return preview({
schema_version: 1,
kind: "call_events_clear",
event_ids: [...eventIds],
});
}
export function planCallEventsClearAll(): UiWriteOutcome {
@@ -224,11 +227,7 @@ export async function readImportFileUpload(
}
try {
const stats = await handle.stat();
if (
!stats.isFile() ||
stats.nlink !== 1 ||
stats.size > MAX_UPLOAD_BYTES
) {
if (!stats.isFile() || stats.nlink !== 1 || stats.size > MAX_UPLOAD_BYTES) {
return null;
}
const buffer = Buffer.alloc(Number(stats.size));
@@ -273,9 +272,7 @@ function fileUploadOperationId(
checksum: string,
): string {
const hex = createHash("sha256")
.update(
JSON.stringify([filename, inputKind, checksum]),
)
.update(JSON.stringify([filename, inputKind, checksum]))
.digest("hex");
return [
hex.slice(0, 8),
@@ -325,8 +322,7 @@ export function validateUiWritePlan(value: unknown): UiWritePlan | null {
UUID.test(candidate.operation_id) &&
typeof candidate.path === "string" &&
typeof candidate.filename === "string" &&
candidate.filename ===
candidate.path.split(/[/\\]/).pop() &&
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],
@@ -364,9 +360,7 @@ export async function executeCallEventsClear(
event_ids: plan.event_ids,
user_confirmed: true,
});
return outcome(result, plan.event_ids.join(","), [
"removed",
]);
return outcome(result, plan.event_ids.join(","), ["removed"]);
}
export async function executeCallEventsClearAll(
@@ -543,7 +537,9 @@ function outcome(
return committed(operationId, {});
}
const data =
result.data !== null && typeof result.data === "object" && !Array.isArray(result.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))) {
+8 -5
View File
@@ -1,10 +1,11 @@
import { defineConfig } from "tsup";
import { createRequire } from "node:module";
import { defineConfig } from "tsup";
const require = createRequire(import.meta.url);
const { resolveBuildVersion } = require("../../scripts/release-version.cjs") as {
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
};
const { resolveBuildVersion } =
require("../../scripts/release-version.cjs") as {
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
};
const releaseVersion = resolveBuildVersion(process.env);
export default defineConfig({
@@ -18,7 +19,9 @@ export default defineConfig({
__ONE_MEMORY_BUILD_NUMBER__: JSON.stringify(
process.env.ONE_MEMORY_BUILD_NUMBER ?? "unknown",
),
__ONE_MEMORY_COMMIT__: JSON.stringify(process.env.ONE_MEMORY_COMMIT ?? "unknown"),
__ONE_MEMORY_COMMIT__: JSON.stringify(
process.env.ONE_MEMORY_COMMIT ?? "unknown",
),
__MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: JSON.stringify(
process.env.MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE ?? "",
),
+4 -3
View File
@@ -1,8 +1,9 @@
import { defineConfig } from "vitest/config";
import { readFileSync } from "node:fs";
import { defineConfig } from "vitest/config";
const version = JSON.parse(readFileSync(new URL("package.json", import.meta.url), "utf8"))
.version as string;
const version = JSON.parse(
readFileSync(new URL("package.json", import.meta.url), "utf8"),
).version as string;
export default defineConfig({
define: {
+30 -45
View File
@@ -13,9 +13,9 @@
| `test_archive_extract.py` | `contract` | Unpacking an archive a stranger sent, onto this user's disk. |
| `test_ask_workspace_path.py` | `contract` | Ask must honour the same workspace_path expansion recall uses. |
| `test_async_bridge.py` | `contract` | 覆盖 reader rejects parallel workers that could close during next、cancelling a read stops the worker before the batch finishes、starting a read does not stop the app answering 等 10 个场景。 |
| `test_async_dispatch_checks.py` | `contract` | 覆盖 quiet arrival guard is off loop and 5000 events coalesce、live preparation cancel joins thread without late dispatch、quiet preparation rechecks permission before credentials or durable work 等 6 个场景。 |
| `test_async_dispatch_checks.py` | `contract` | 覆盖 cancelling quiet timer does not start any permission reader、dispatch check does not queue work after shutdown、dispatch permission check denies before queue after classification 等 6 个场景。 |
| `test_auth.py` | `integration` | 覆盖 local api requires bearer token、browser origin is checked in addition to token 场景。 |
| `test_backfill_acceptance_lifecycle.py` | `integration` | A processing acknowledgement belongs to one consent revision and live worker. |
| `test_backfill_acceptance_lifecycle.py` | `contract` | A processing acknowledgement belongs to one consent revision and live worker. |
| `test_backfill_conversation_isolation.py` | `contract` | One stale conversation cannot invalidate an entire backfill scan. |
| `test_background_work.py` | `contract` | 覆盖 a request does not wait behind a catch up、bulk work is kept to a few threads at a time、the pool outlives one hand off 等 4 个场景。 |
| `test_backup_destroy_restore_reconciliation.py` | `contract` | Backup -> destroy -> restore, reconciled row for row. |
@@ -25,7 +25,6 @@
| `test_bug01_workbuddy_root.py` | `contract` | 3925a kernel item 2 (BUG-01, backend half): WorkBuddy import root. |
| `test_bug03_identity_key.py` | `contract` | BUG-03: the hook lane and the file scan must share one identity for one message. |
| `test_bug04_thinking_strip.py` | `contract` | BUG-04: Cursor thinking text must not be stored as the assistant's answer. |
| `test_bulk_import_experience.py` | `contract` | A-6:大数据量导入的自动检查。 |
| `test_c1_append_per_source.py` | `contract` | Append, per source, including the SQLite-backed ones. |
| `test_c1_attribution_per_source.py` | `contract` | Attribution per source, asserted on stored Evidence, not just on parsed events. |
| `test_c1_conversation_identity_scope.py` | `contract` | Same-named source sessions must stay separate through the read path. |
@@ -66,33 +65,23 @@
| `test_cli_restore.py` | `contract` | 覆盖 restore requires a source、restore refuses while the runtime is running、restore previews without confirmation 等 4 个场景。 |
| `test_codex_header_provenance.py` | `contract` | 覆盖 header workspace versions and native ids reach file origin、new adapter rereads header after a real checkpoint、machine and unknown session sources do not establish human or agent sender 等 14 个场景。 |
| `test_compact_model_content.py` | `contract` | Server-held previews preserve exact grants without round-tripping all refs. |
| `test_concurrent_import_processing.py` | `integration` | Four real source formats/Core scheduling, with a blocked failing model boundary. |
| `test_connection_lifetime.py` | `integration` | Every SQLite connection a request opens must be closed when the request ends. |
| `test_context_access.py` | `contract` | 覆盖 memory only never reads raw authority、raw permission does not grant memory and removes revoked sources、raw requires all apps and source 等 13 个场景。 |
| `test_context_api.py` | `integration` | 覆盖 no model local query returns real raw and governed items、existing single lane authorization never runs denied reader、authentication and origin remain required 等 14 个场景。 |
| `test_context_auto_recall.py` | `contract` | 覆盖 needed history queries trigger without excluding recent turns、ordinary messages do not trigger、auto recall respects disable permission and satisfied state 等 7 个场景。 |
| `test_context_endpoint_guard.py` | `contract` | 覆盖 composite route accepts existing read capabilities for later body validation、other capabilities do not enable composite read、composite read does not open other routes 等 5 个场景。 |
| `test_context_expand.py` | `contract` | 覆盖 signed expansion returns only bounded same conversation、expansion uses source order not database insertion order、reference is bound to runtime host scope and revision 等 11 个场景。 |
| `test_context_feedback.py` | `contract` | 覆盖 explicit feedback contains only offline label and versioned refs、click copy injection and assistant claims are not feedback、feedback cannot be unversioned duplicated or retained forever by omission 等 6 个场景。 |
| `test_context_host_artifact.py` | `unit` | 覆盖 each priority host gets an independent artifact、host deadline drops content but preserves source coverage receipt、host character budget omits whole group and never truncates a citation 等 12 个场景。 |
| `test_context_hy_primary.py` | `contract` | MemHub_context memory lane: HY decides the order, keywords only when HY does not answer. |
| `test_context_memory.py` | `contract` | 覆盖 permissions precede candidate limit and do not leak counts、scope user status and active source isolation、denied scope does not open database 等 60 个场景。 |
| `test_context_memory_scale_benchmark.py` | `contract` | 覆盖 memory scale probe covers the required layers and target、memory scale rows preserve governed memory schema shape、memory scale builds the candidate index before measuring 等 9 个场景。 |
| `test_context_mutations.py` | `contract` | 覆盖 raw candidate never survives update delete or revoke、memory candidate never survives update delete revoke or lifecycle、denied memory never consumes candidate limit before return 等 5 个场景。 |
| `test_context_query_planner.py` | `contract` | 覆盖 local mode performs zero authorization and network calls、authorization precedes exactly one audited transport call、denied or expired budget never calls transport 等 6 个场景。 |
| `test_context_raw.py` | `contract` | 覆盖 empty authority and unrequested path do not open database、permissions precede candidate limit and no model needed、explicit source filter never broadens authority 等 37 个场景。 |
| `test_context_raw_scale_benchmark.py` | `performance` | 覆盖 raw scale probe uses fts and has a nonzero positive control、object scale probe has exact positive control and plan 场景。 |
| `test_context_resume.py` | `contract` | 覆盖 resume packet keeps literal sources and missing fields、agent report and tool output are not system acceptance、only trusted receipt identity marks system acceptance 等 9 个场景。 |
| `test_context_raw_scale_benchmark.py` | `contract` | 覆盖 object scale probe has exact positive control and plan、raw scale probe uses fts and has a nonzero positive control 场景。 |
| `test_context_search.py` | `contract` | 覆盖 authorization and requested paths never invoke other lane、empty partial zero unavailable and safe failures、parallel common deadline and finalizers wait for both reads 等 24 个场景。 |
| `test_context_search_both_paths.py` | `integration` | The objective names 'recall' and requires BOTH paths: raw and memory. |
| `test_context_v2_contract_parity.py` | `unit` | Mechanical parity checks for the shared v2 coverage receipt contract. |
| `test_context_v2_coordinator.py` | `contract` | 覆盖 no model path returns raw and memory with exact evidence、semantic failure is not recast as empty、empty lexical with incomplete semantic index is not complete no hit 等 52 个场景。 |
| `test_continuous_completed_input_review.py` | `contract` | Independent review of completed-input reuse; real stores and controlled HY only. |
| `test_continuous_coordinator.py` | `contract` | 覆盖 restart replays lost callback into same durable task queue、new journal event and source regrant wake replay without legacy request、activation hook and startup recovery start single monitor then stop 等 6 个场景。 |
| `test_continuous_outcome_unknown.py` | `contract` | 覆盖 crashed continuous send is not replayed but new event progresses、legacy running recovery remains pending and no continuous table、a timed out non continuous task also becomes unknown 等 8 个场景。 |
| `test_continuous_policy.py` | `contract` | 覆盖 explicit policy never upgrades legacy or sends or creates tasks、backlog snapshot over one hundred thousand is compact、legacy backlog receipt remains readable 等 19 个场景。 |
| `test_continuous_processing_bridge.py` | `contract` | 覆盖 continuous prepare lease ingest has no legacy receipt or old context、continuous inactive never falls back to legacy、revocation rechecked at runner and publication boundaries 等 17 个场景。 |
| `test_continuous_processing_integration.py` | `integration` | Real temporary Core/stores with controlled probe and HY boundaries, never real data. |
| `test_continuous_coordinator.py` | `contract` | 覆盖 activation hook and startup recovery start single monitor then stop、failed preparation does not advance observed watermark、new journal event and source regrant wake replay without legacy request 等 6 个场景。 |
| `test_continuous_outcome_unknown.py` | `contract` | 覆盖 a timed out continuous task becomes unknown not pending、a timed out non continuous task also becomes unknown、an unsettled continuous send becomes unknown not failed 等 8 个场景。 |
| `test_continuous_policy.py` | `contract` | 覆盖 backlog snapshot over one hundred thousand is compact、caller rollback includes policy and backlog、enable idempotence rejects changed payload and superseded operation 等 19 个场景。 |
| `test_continuous_processing_bridge.py` | `contract` | 覆盖 bound source regrant refuses old task、continuous inactive never falls back to legacy、continuous prepare lease ingest has no legacy receipt or old context 等 17 个场景。 |
| `test_continuous_processing_integration.py` | `contract` | Real temporary Core/stores with controlled probe and HY boundaries, never real data. |
| `test_contract_parity.py` | `contract` | Python 的枚举和 TypeScript 里那份手写的联合类型必须一致。 |
| `test_conversation_search.py` | `integration` | Searching imported conversations before any generation model is configured. |
| `test_conversation_seed_ids.py` | `contract` | 覆盖 seed ids read metadata only without hydrating、seed eligibility keeps person and memory content only、latest activity includes nonseed turns and import time fallback 等 13 个场景。 |
@@ -105,9 +94,9 @@
| `test_daemon_contract.py` | `unit` | A5-01/A5-03: process ownership, and the three grants kept apart. |
| `test_daemon_scheduler.py` | `contract` | A5-02/A5-04: the daemon's scan scheduler and composed health. |
| `test_data_safety.py` | `contract` | A5-05: backup usability/rotation, crash recovery, log retention. |
| `test_database_contention.py` | `integration` | Writers meet in this one file, and the loser used to lose data. |
| `test_database_contention.py` | `contract` | Writers meet in this one file, and the loser used to lose data. |
| `test_database_file_permissions.py` | `contract` | 覆盖 a new main database is readable by its owner alone、the wal sidecars are not left world readable、a database from an older build is tightened on open 场景。 |
| `test_db_session_utc.py` | `live` | The MySQL session must be pinned to UTC. |
| `test_db_session_utc.py` | `contract` | The MySQL session must be pinned to UTC. |
| `test_delete_no_conversation_freeze.py` | `contract` | 删掉一条记忆,不得让同一对话之后的新 turn 停止沉淀(RC10)。 |
| `test_delivery_board.py` | `contract` | 覆盖 parses only the marked structured record、verified requires evidence with a positive control、rejects unknown dependency 等 5 个场景。 |
| `test_derived_echo.py` | `contract` | A host that saves our injection back must not mint a new user decision. |
@@ -147,7 +136,7 @@
| `test_graph_width_export.py` | `unit` | EXPORT_FIRST has to actually export. |
| `test_graph_width_guard.py` | `unit` | Graph width mismatch must ask, never silently archive. |
| `test_harness_contract_suite.py` | `contract` | A4-09: one contract, ten harnesses. |
| `test_health.py` | `integration` | 覆盖 health reports the single core、core refuses to start a database written by a newer build、core stays ready when legacy policy upgrade cannot read keychain 等 15 个场景。 |
| `test_health.py` | `contract` | 覆盖 a backfill that fails says so rather than disappearing、a failed catch up leaves a traceback behind、configured model without content consent does not start backfill 等 15 个场景。 |
| `test_history_listing_rotation.py` | `contract` | 覆盖 history listing alternates started and untouched files、history listing drains whichever cohort is longer 场景。 |
| `test_home_summary.py` | `integration` | 覆盖 summary counts beyond page limit and excludes ineligible rows、summary revocation excludes whole mixed memory not only citation、latest is bounded and other users revocations do not hide local data 等 6 个场景。 |
| `test_home_summary_permissions.py` | `contract` | 覆盖 bad active link excludes whole latest memory、bad inactive link does not hide memory or enter source set、other user memory and grant are isolated 等 10 个场景。 |
@@ -158,7 +147,7 @@
| `test_hy_coding_lease.py` | `contract` | HY's own coding/working route under the permission lease, on the pinned SDK. |
| `test_hy_coding_reply.py` | `contract` | R-07: HY's coding-shaped replies read as "no memories at all". |
| `test_hy_lease_responsiveness.py` | `contract` | Blocked lease checks, local failing doubles only; no network or credentials. |
| `test_hy_lifecycle_sync.py` | `contract` | HY decides deletions; the ledger mirrors them. |
| `test_hy_lifecycle_sync.py` | `contract` | HY decides deletions; the ledger mirrors them. Pinned SDK, scripted HTTP. |
| `test_hy_log_privacy.py` | `contract` | HY SDK records are not rewritten by One Memory's logging setup. |
| `test_hy_memory_bridge.py` | `contract` | 覆盖 hy client initialization is serial across bridges、native hy clients share one runtime per data directory、ollama profile ignores ambient remote storage and models 等 41 个场景。 |
| `test_hy_metrics_lifecycle.py` | `contract` | What the pinned public SDK's MetricsCollector does, and what that costs us. |
@@ -178,7 +167,7 @@
| `test_hy_state_source.py` | `contract` | RC4:记忆生命周期(有效 / 被取代 / 去重隐藏 / 过期)直接读 HY,产品账本不再逐类镜像。 |
| `test_hy_step_timing.py` | `contract` | Where an HY write spends the time that is not a model call. |
| `test_hy_step_trace_switch.py` | `contract` | Rule 15: the per-step HY trace is off unless the debug tier asks for it. |
| `test_hy_supersede_sync.py` | `contract` | HY decides supersessions; the ledger mirrors them. |
| `test_hy_supersede_sync.py` | `contract` | HY decides supersessions; the ledger mirrors them. Pinned SDK, scripted HTTP. |
| `test_hy_trial_budget.py` | `contract` | Offline proofs for the real-model acceptance budget (no network, no Keychain). |
| `test_hy_turn_input.py` | `contract` | HY gets one turn at a time, exactly as it happened, and picks its own route. |
| `test_hy_writeback.py` | `integration` | HY 的结果只有一扇门进治理表,而且每次进门都带着调用开始时的证据快照。 |
@@ -190,9 +179,9 @@
| `test_import_snapshots.py` | `contract` | Atomic snapshot publication plus frozen manifest, path, mode, and per-file checksum guards. |
| `test_import_supervisor.py` | `contract` | 覆盖 supervisor starts all granted sources together、supervisor runs enqueued job in background、harness freeze keeps pending job and thaw runs the same row 等 11 个场景。 |
| `test_import_three_scenarios.py` | `contract` | The three import scenarios a user actually meets. |
| `test_import_three_scenarios_real.py` | `performance` | The three import scenarios, on a real Core at a real volume. |
| `test_import_three_scenarios_real.py` | `contract` | The three import scenarios, on a real Core at a real volume. |
| `test_imported_index_background.py` | `contract` | The ordering index is built off the startup path. |
| `test_incremental_memory_backfill.py` | `contract` | 覆盖 accept backfill returns after first batch while later history is still read、first task is dispatched before later conversation is prepared、cancellation joins the preparation thread before return 等 18 个场景。 |
| `test_incremental_memory_backfill.py` | `contract` | 覆盖 accept backfill returns after first batch while later history is still read、backfill budget yields between rounds and resumes same cursor、backfill holds an unanswered raw turn until its reply arrives 等 18 个场景。 |
| `test_ingest_journal.py` | `contract` | 覆盖 baseline and empty initialize are explicit transactional and idempotent、old date new import and revise noop and rollbacks、import metadata changes invalidate without authorizing content 等 12 个场景。 |
| `test_input_sanitization.py` | `unit` | 覆盖 redacts cli and assignment secrets without removing context、redacts bearer known tokens urls and private keys、redacts quoted json assignments and basic authorization 等 8 个场景。 |
| `test_installer.py` | `contract` | 覆盖 installer create verify and rollback is transactional、installer updates owned file and restores backup、installer refuses unowned or changed target 场景。 |
@@ -220,7 +209,7 @@
| `test_main_database_fairness.py` | `contract` | Waiting for the one database has to be a queue, not a scramble. |
| `test_main_database_isolation.py` | `contract` | What one checkout of the main database can do, and what it leaves for the next. |
| `test_managed_restore.py` | `contract` | 覆盖 restore rejects a source outside the managed backup directory、restore rejects a symlinked source、restore rejects a non one memory database 等 9 个场景。 |
| `test_manual_memory_via_hy.py` | `integration` | T04: what the person explicitly asks to remember is written through HY's import path. |
| `test_manual_memory_via_hy.py` | `contract` | T04: what the person explicitly asks to remember is written through HY's import path. |
| `test_manual_transcript_import.py` | `integration` | 覆盖 manual json keeps authors unknown time and exact text、marked text keeps code fences and never invents timestamps、ambiguous partial or invalid manual data never leaves partial import 等 4 个场景。 |
| `test_matter_collection.py` | `integration` | 覆盖 hy windows cover every turn including leading and boundary assistants、oversize turn is split without silently dropping content、partial timestamps preserve source sequence 等 8 个场景。 |
| `test_matter_discovery.py` | `contract` | 覆盖 synthesis materializes only known verbatim citations、duplicate claims keep all governed refs and distinct states、synthesis cannot echo the original sixty claim list 等 27 个场景。 |
@@ -247,7 +236,7 @@
| `test_memory_governance_api.py` | `integration` | 覆盖 memory governance api preserves evidence and reports cleanup、memory validation does not echo content 场景。 |
| `test_memory_graph_api.py` | `integration` | 覆盖 graph labels where every edge came from、a created memory is on the map with its source links、drawing the map does not stop the app answering 等 12 个场景。 |
| `test_memory_language.py` | `contract` | R-03: memory language follows the message, so Kristin's gets written in English. |
| `test_memory_library.py` | `performance` | 覆盖 full library is reachable in stable pages、permission filter precedes count search source and page、query filters before expensive freshness 等 26 个场景。 |
| `test_memory_library.py` | `contract` | 覆盖 full library is reachable in stable pages、permission filter precedes count search source and page、query filters before expensive freshness 等 26 个场景。 |
| `test_memory_library_api.py` | `integration` | 覆盖 auth and external policy reject before reading、invalid parameters are 422 before projection、empty unknown source and query boundary are success not failure 等 12 个场景。 |
| `test_memory_mutation_receipts.py` | `integration` | Read-only mutation receipts from real governance history, without model calls. |
| `test_memory_no_candidate.py` | `integration` | 新记忆一经创建即有效;「待确认」「已拒绝」只作为旧库迁移的输入存在。 |
@@ -288,8 +277,8 @@
| `test_model_creation_api.py` | `integration` | Normal protected model creation routes with temporary data and in-memory secrets. |
| `test_model_creation_operations.py` | `contract` | 覆盖 lookup without table is read only and legacy create does not initialize、operation commits with profile and version without autoactivation、any replay conflicts before secret put 等 21 个场景。 |
| `test_model_creation_validation_boundary.py` | `integration` | Real ASGI validation must precede creation reservation and secret writes. |
| `test_model_policy_activation.py` | `contract` | 覆盖 current is read only without policy tables、switch and policy commit together without old activate、policy append failure rolls back active and initialization 等 21 个场景。 |
| `test_model_policy_routes.py` | `contract` | 覆盖 get does not initialize and capability defaults false、all routes require auth、factory refuses no auth dependency 等 10 个场景。 |
| `test_model_policy_activation.py` | `contract` | 覆盖 activation helper is transactional and restores row factory、backlog scan does not hold the single writer、cloud credential is checked without keychain write 等 21 个场景。 |
| `test_model_policy_routes.py` | `contract` | 覆盖 all routes require auth、backlog is required not defaulted、enable and transition safe shape and callback once after commit 等 10 个场景。 |
| `test_model_probe.py` | `contract` | 覆盖 probe still measures embedding when structured output fails、probe records the real embedding width on the profile、openai compatible probe checks model and structured output 等 23 个场景。 |
| `test_model_probe_input_revision.py` | `contract` | 覆盖 input revision precedes measured width update、concurrent update still rejects width or capability cas、early returns record the actual starting revision 等 6 个场景。 |
| `test_model_probe_operations.py` | `integration` | Persisted probe identity, real SQLite/API and synthetic transport only. |
@@ -300,7 +289,7 @@
| `test_model_profile_queue_handover.py` | `contract` | Queued work when the model changes or its configuration is deleted (D75). |
| `test_model_profiles.py` | `contract` | 覆盖 profile uses one model and keeps key out of database、api key updates reuse one stable secret reference、startup cleanup removes only secrets not referenced by current profiles 等 15 个场景。 |
| `test_model_request_budget.py` | `contract` | 覆盖 persists failed request reservations without content、parallel leases cannot exceed request limit、unknown or excessive output and body are denied 等 6 个场景。 |
| `test_model_request_budget_integration.py` | `integration` | Real HY leaf retries plus durable budget accounting; no network or provider. |
| `test_model_request_budget_integration.py` | `contract` | Real HY leaf retries plus durable budget accounting; no network or provider. |
| `test_model_scope_lifecycle.py` | `contract` | Bound derived/temporary data without changing consent or original history. |
| `test_mysql_lifecycle.py` | `contract` | A1-07: the decisions Runtime makes around the bundled MySQL. |
| `test_mysql_platform.py` | `contract` | A1-07: the machine-facing half of managing the bundled MySQL. |
@@ -310,7 +299,7 @@
| `test_oversized_file_guard.py` | `contract` | One pass over a source file is bounded by a byte budget, not skipped. |
| `test_parent_watchdog.py` | `integration` | A Core that outlives its launcher makes the next launch impossible. |
| `test_policy_api_wiring.py` | `integration` | 覆盖 global model permission off does not backfill、backfill puts history on write jobs without scheduling memory tasks、source pause skips history until the source is reenabled 场景。 |
| `test_policy_operation_lookup.py` | `contract` | 覆盖 missing table returns not found without initializing or reading secrets、current operation uses safe receipt without callback or writes、superseded operation preserves historical receipt not current activation 等 12 个场景。 |
| `test_policy_operation_lookup.py` | `contract` | 覆盖 corrupt current row cannot make history or missing operation look safe、current operation uses safe receipt without callback or writes、lookup connection rejects writes and preserves database 等 12 个场景。 |
| `test_policy_production_entry.py` | `contract` | 覆盖 production serve passes only supported app options、maintenance serve does not own the runtime 场景。 |
| `test_precipitation_concurrency_setting.py` | `contract` | Rule 2/9: precipitation concurrency is configurable, not hardcoded to 1. |
| `test_primary_source_adapters.py` | `contract` | 覆盖 pipeline json is host generated even when truncated、workbuddy record parsing never blocks the event loop、cursor store user info envelope is not attributed to the person 等 34 个场景。 |
@@ -318,20 +307,18 @@
| `test_probe_payment_required.py` | `contract` | 覆盖 http 402 without a useful body is still understood as unpaid、a wrong address is still a missing model not an unpaid one 场景。 |
| `test_processing_coordinator.py` | `contract` | 覆盖 monitor is retained and observes config plus tasks 场景。 |
| `test_processing_merged_context.py` | `contract` | 覆盖 new chunk omits known memory with changed support but keeps original history 场景。 |
| `test_processing_requests.py` | `contract` | 覆盖 receipt is small frozen and persistent、grant deduplication never retriggers or changes profile、concurrent create same grant has one id and profile 等 14 个场景。 |
| `test_product_contract_coverage.py` | `contract` | 覆盖 full app contract is part of the existing acceptance manifest、core visual phase has its own contract without erasing full product scope 场景。 |
| `test_processing_requests.py` | `contract` | 覆盖 concurrent claim only one snapshot wins、concurrent create same grant has one id and profile、concurrent different profile never reuses another scope 等 14 个场景。 |
| `test_quality_acceptance.py` | `contract` | 覆盖 acceptance pack is deterministic and scores personal boundary、acceptance score fails missing personal memory 场景。 |
| `test_raw_search_without_model.py` | `integration` | Search imported conversations with no generative model configured at all. |
| `test_raw_vector_scale_benchmark.py` | `performance` | 覆盖 vector scale probe uses real 512 dimension filtered search 场景。 |
| `test_raw_vector_scale_benchmark.py` | `contract` | 覆盖 vector scale probe uses real 512 dimension filtered search 场景。 |
| `test_read_path_freshness_batch.py` | `contract` | The row-by-row read check must not scale its query count with the page. |
| `test_reader_app.py` | `integration` | The Reader process stays read-only and never blocks a read on its side effects. |
| `test_reader_conversation_hit.py` | `contract` | A search hit carries who wrote the row it matched. |
| `test_real_load_metrics.py` | `performance` | Run the real access layer under a production-like combined load. |
| `test_real_model_prereq_guards.py` | `contract` | Real-model first-run preconditions must fail closed before any egress. |
| `test_real_load_metrics.py` | `contract` | Run the real access layer under a production-like combined load. |
| `test_recall.py` | `contract` | 覆盖 recall is confirmed only grounded and scope bound、wildcard scope means every scope just like it does in qa、recall returns empty instead of unrelated fallback 等 10 个场景。 |
| `test_recall_during_import_probe_contract.py` | `contract` | Guard the source-level recall-during-import probe's sampling semantics. |
| `test_recall_excluded_sources.py` | `contract` | D94: an empty result names the sources this host may not read. |
| `test_recall_fusion_live.py` | `live` | RCL-04 against a real HY instance instead of a stand-in. |
| `test_recall_fusion_live.py` | `integration` | RCL-04 against a real HY instance instead of a stand-in. |
| `test_recall_hy_primary.py` | `integration` | Host recall: HY search decides the order, the ledger only filters. |
| `test_recall_library_scale.py` | `contract` | 覆盖 recall reaches a memory that is not among the newest 500、semantic recall reaches a memory outside the newest 500、recall still works just below the boundary 等 5 个场景。 |
| `test_recall_log_probe_exclusion.py` | `contract` | A-21:R-10 一旦引入召回日志,「探针不污染」必须自动变红而不是静默失效。 |
@@ -343,14 +330,13 @@
| `test_recall_scope_off_loop.py` | `contract` | The workspace lookup behind an external recall must not run on the loop. |
| `test_recent_first_import.py` | `contract` | Recent-file priority survives restart through a selection-bound frozen copy. |
| `test_retrieval_chunking.py` | `contract` | 覆盖 default 384 body 64 overlap and exact source slices、all prefix and special tokens count toward strict 512 budget、real tokenizer covers long chinese middle and tail object locations 等 20 个场景。 |
| `test_retrieval_eligibility_benchmark.py` | `performance` | 覆盖 scale probe proves composite authority index changes the plan 场景。 |
| `test_retrieval_eligibility_benchmark.py` | `contract` | 覆盖 scale probe proves composite authority index changes the plan 场景。 |
| `test_retrieval_index_store.py` | `contract` | 覆盖 constructor and reads do not create database or schema、explicit initialization rolls back and never grants model policy、consumer and jobs commit together 等 19 个场景。 |
| `test_retrieval_index_supervisor.py` | `contract` | 覆盖 stop waits for native exit and does not close resources、async cancellation repeatedly waits for native thread、idle no trigger no spin and duplicate start rejected 等 14 个场景。 |
| `test_retrieval_index_worker.py` | `contract` | 覆盖 tick indexes complete original and reopen can search、change during native encoding never publishes、cancel after encode does not publish and is reclaimable 等 10 个场景。 |
| `test_retrieval_lifespan.py` | `integration` | 覆盖 retrieval defaults off and does not create index、enabling retrieval requires runtime ownership、index enabled serve imports without building an unmanaged app 等 6 个场景。 |
| `test_retrieval_mapped_content.py` | `contract` | 覆盖 raw authors keep exact body and do not mutate evidence、raw and author qualification rejects without tokenizing、ambiguous generated and body hygiene are not admitted 等 22 个场景。 |
| `test_retrieval_mapped_integration.py` | `contract` | 覆盖 empty materialization rechecks excluded map、completed excluded missing active binding is not empty success、old empty completion is invalidated when a new indexed record arrives 等 24 个场景。 |
| `test_retrieval_models.py` | `unit` | RCL v2 wire contracts only; no service, model or database is initialized. |
| `test_retrieval_provisioning.py` | `contract` | The index has to come up through the product, not through a test helper. |
| `test_retrieval_query_plan.py` | `contract` | Local QueryPlan: extract URL/path objects. |
| `test_retrieval_raw_candidates.py` | `contract` | 覆盖 positive only current authorized published chunks are searched、empty authority never reads database or vector、unapproved source never enters vector query 等 15 个场景。 |
@@ -384,7 +370,7 @@
| `test_secret_store_error_codes.py` | `integration` | 3925a kernel item 3: keychain failure classification and backoff that heals. |
| `test_secure_storage.py` | `integration` | 覆盖 isolated keychain service namespace、custom keychain service is refused outside isolated tests、packaged keychain budget covers loaded process startup 等 20 个场景。 |
| `test_sedimentation_concurrency.py` | `contract` | Sedimentation runs several harnesses at once, but one harness at a time. |
| `test_service_level_pressure_acceptance.py` | `performance` | 覆盖 workbuddy recall remains available during 100k import 场景。 |
| `test_service_level_pressure_acceptance.py` | `contract` | 覆盖 workbuddy recall remains available during 100k import 场景。 |
| `test_service_levels.py` | `contract` | 覆盖 background never consumes the realtime reservation、realtime is next for the same source after background yields、import history gets one slice after nine realtime admissions 等 8 个场景。 |
| `test_session_inbox.py` | `contract` | A session inbox is not a shared mailbox and a pull is not adoption. |
| `test_session_lease.py` | `contract` | A session file on disk is not proof that the session is online. |
@@ -393,7 +379,7 @@
| `test_simplified_model_permission_migrations.py` | `contract` | 覆盖 fresh schema omits removed permission state machines 场景。 |
| `test_snapshot_adapter.py` | `contract` | Selection-only binding reopens checksum-verified frozen copies. |
| `test_snapshot_pool.py` | `contract` | A whole-directory snapshot must not hold a thread everything else is waiting for. |
| `test_soak_probe_effectiveness.py` | `contract` | The soak probe must observe the load it reports on. |
| `test_soak_probe_effectiveness.py` | `integration` | The soak probe must observe the load it reports on. |
| `test_source_access.py` | `contract` | 覆盖 source root requires explicit user grant、sensitive home directory is rejected without requesting os access、authorized file cannot escape root through a symlink 等 24 个场景。 |
| `test_source_candidate_api.py` | `integration` | Candidate location changes must not implicitly grant or import anything. |
| `test_source_candidate_connections.py` | `contract` | 覆盖 empty candidate revisions preserve default fingerprint、persisted candidate requires exact revision、connection resolves candidate in transaction and prior replay never regrants 等 7 个场景。 |
@@ -420,7 +406,7 @@
| `test_source_write_switch.py` | `contract` | A frozen Harness produces no extraction tasks. |
| `test_sql_compat.py` | `contract` | The shared compatibility layer, especially 'rowcount'. |
| `test_sqlite_semantics_gate.py` | `contract` | D56: prevent SQLite implementation details spreading outside the data layer. |
| `test_sqlite_write_contention.py` | `performance` | Measure whether SQLite's single writer actually blocks us. |
| `test_sqlite_write_contention.py` | `contract` | Measure whether SQLite's single writer actually blocks us. |
| `test_storage_ingest_journal.py` | `contract` | 覆盖 legacy put and reopen never initialize journal、initialized put records final origins create revision and noop、attribution propagation journals old rows as maintenance only 等 11 个场景。 |
| `test_store_binding.py` | `contract` | A2: which store is in force, and honest reporting of it. |
| `test_store_binding_ratchet.py` | `contract` | D56: ratchet the retired dual-engine 'store_for' seam out of the Core. |
@@ -432,7 +418,6 @@
| `test_tail_metadata_lane.py` | `contract` | Post-response metadata retries must be bounded and never block the route. |
| `test_terminal_payload_reaping.py` | `contract` | Rule 14: dead-end payloads are actually reaped, not just reapable. |
| `test_tool_evidence_hy_turns.py` | `contract` | Every adapter's tool records reach HY inside the turn they happened in. |
| `test_training_extraction_and_baseline.py` | `contract` | R-17:抽取结果落盘成训练数据,评测基线冻结成可复跑产物。 |
| `test_two_level_discovery.py` | `contract` | 3925a kernel item 1: two-level discovery. |
| `test_ui_session.py` | `contract` | 覆盖 exchange is single use and creates a session、expired exchange cannot create a session、exchange is bound to the core instance 等 5 个场景。 |
| `test_ui_session_api.py` | `integration` | 覆盖 ui session exchange sets httponly strict cookie、ui session requires bearer for exchange、ui session reports missing web assets 场景。 |
@@ -448,4 +433,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. |
共 443 个测试文件。
共 428 个测试文件。
@@ -14,14 +14,13 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 443
EXPECTED_TEST_FILES_SHA256 = "6d4815ac6a76cfa3f6208672052b785c7562083cf9d122cc9ddc796b8e624d43"
EXPECTED_TEST_FILE_COUNT = 428
EXPECTED_TEST_FILES_SHA256 = "bd3bf4fc9cda2a8e4427d106283c40d22dad026fd452db954046b6f28d21b896"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
UNIT_FILES = frozenset(
{
"test_context_host_artifact.py",
"test_context_v2_contract_parity.py",
"test_daemon_contract.py",
"test_diagnostic_payload_keys.py",
@@ -34,7 +33,6 @@ UNIT_FILES = frozenset(
"test_memory_server_config_state.py",
"test_memory_server_contracts.py",
"test_memory_server_job_state.py",
"test_retrieval_models.py",
"test_scan_roots.py",
"test_scanner_classifier.py",
"test_scanner_sessionizer.py",
@@ -50,7 +48,6 @@ UNIT_FILES = frozenset(
INTEGRATION_FILES = frozenset(
{
"test_auth.py",
"test_backfill_acceptance_lifecycle.py",
"test_backup_endpoint.py",
"test_backup_restore_api.py",
"test_c1_import_via_api_path.py",
@@ -58,11 +55,9 @@ INTEGRATION_FILES = frozenset(
"test_capture_deadline.py",
"test_chat_exports.py",
"test_cli_lifecycle.py",
"test_concurrent_import_processing.py",
"test_connection_lifetime.py",
"test_context_api.py",
"test_context_search_both_paths.py",
"test_continuous_processing_integration.py",
"test_conversation_search.py",
"test_credential_responsiveness.py",
"test_d93_same_process_load.py",
@@ -75,7 +70,6 @@ INTEGRATION_FILES = frozenset(
"test_file_import_operations.py",
"test_four_app_live_ingest.py",
"test_graph_width_endpoint.py",
"test_health.py",
"test_home_summary.py",
"test_hy_writeback.py",
"test_import_api.py",
@@ -85,7 +79,6 @@ INTEGRATION_FILES = frozenset(
"test_log_privacy.py",
"test_loopback_origin_write.py",
"test_main_database_entry.py",
"test_manual_memory_via_hy.py",
"test_manual_transcript_import.py",
"test_matter_collection.py",
"test_matter_hy_integration.py",
@@ -113,7 +106,6 @@ INTEGRATION_FILES = frozenset(
"test_model_probe_operations.py",
"test_model_profile_api.py",
"test_model_profile_context_reset.py",
"test_model_request_budget_integration.py",
"test_onboarding_profile.py",
"test_parent_watchdog.py",
"test_policy_api_wiring.py",
@@ -1 +1 @@
<?xml version="1.0" encoding="utf-8" ?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="172"><defs><filter id="filter_0" color-interpolation-filters="sRGB" x="0" y="8" width="164" height="164" filterUnits="userSpaceOnUse"><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset dx="0" dy="8"/><feGaussianBlur stdDeviation="9"/><feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.28 0 0 0 0 0.34 0 0 0 0.12 0" result="shadow"/></filter></defs><g filter="url(#filter_0)"><rect fill="#FFF" transform="matrix(1 0 0 1 18 18)" width="128" height="128" rx="28" ry="28"/><rect fill="none" stroke="#FFF" stroke-width="12" transform="matrix(1 0 0 1 46 41)" width="56" height="68" rx="23" ry="23"/><rect fill="none" stroke="#FFF" stroke-width="12" transform="matrix(1 0 0 1 62 55)" width="56" height="68" rx="23" ry="23"/><clipPath id="clip_0"><rect x="18" y="18" width="128" height="128" rx="28" ry="28"/></clipPath><g clip-path="url(#clip_0)"><path fill="#FFF" transform="matrix(1 0 0 1 18 18)" d="M129 28L129 100Q129 112.0121 120.5061 120.5061Q112.0121 129.0001 100 129L28 129Q15.9878 129 7.4939 120.5061Q-1 112.0121 -1 100L-1 28Q-1 15.9878 7.4939 7.4939Q15.9878 -1 28 -1L100 -1Q112.0122 -1 120.5061 7.4939Q129.0001 15.9878 129 28ZM127 28Q127 16.8162 119.0919 8.9081Q111.1838 1 100 1L28 1Q16.8162 1 8.9081 8.9081Q1 16.8162 1 28L1 100Q1 111.1838 8.9081 119.0919Q16.8162 127.0001 28 127L100 127Q111.1837 127 119.0919 119.0919Q127 111.1838 127 100L127 28Z"/></g></g><rect fill="#FFF" transform="matrix(1 0 0 1 18 18)" width="128" height="128" rx="28" ry="28"/><rect fill="none" stroke="#829CAD" stroke-width="12" transform="matrix(1 0 0 1 46 41)" width="56" height="68" rx="23" ry="23"/><rect fill="none" stroke="#4F6373" stroke-width="12" transform="matrix(1 0 0 1 62 55)" width="56" height="68" rx="23" ry="23"/><path fill="#5E7C8C" transform="matrix(1 0 0 1 79 64)" d="M23 0L23 22Q23 26.6757 21.1904 30.9547Q19.4442 35.0835 16.2639 38.2639Q13.0835 41.4442 8.9547 43.1904Q4.6757 45 0 45Q-2.5153 45.0724 -4.2426 43.2426Q-6.0724 41.5153 -6 39Q-6.0724 36.4847 -4.2426 34.7574Q-2.5153 32.9276 0 33Q2.2425 33 4.2806 32.1381Q6.2535 31.3037 7.7786 29.7786Q9.3037 28.2535 10.1381 26.2806Q11 24.2425 11 22L11 0Q10.9276 -2.5153 12.7574 -4.2426Q14.4847 -6.0724 17 -6Q19.5153 -6.0724 21.2426 -4.2426Q23.0724 -2.5153 23 0Z"/><clipPath id="clip_1"><rect x="18" y="18" width="128" height="128" rx="28" ry="28"/></clipPath><g clip-path="url(#clip_1)"><path fill="#FFF" transform="matrix(1 0 0 1 18 18)" d="M129 28L129 100Q129 112.0121 120.5061 120.5061Q112.0121 129.0001 100 129L28 129Q15.9878 129 7.4939 120.5061Q-1 112.0121 -1 100L-1 28Q-1 15.9878 7.4939 7.4939Q15.9878 -1 28 -1L100 -1Q112.0122 -1 120.5061 7.4939Q129.0001 15.9878 129 28ZM127 28Q127 16.8162 119.0919 8.9081Q111.1838 1 100 1L28 1Q16.8162 1 8.9081 8.9081Q1 16.8162 1 28L1 100Q1 111.1838 8.9081 119.0919Q16.8162 127.0001 28 127L100 127Q111.1837 127 119.0919 119.0919Q127 111.1838 127 100L127 28Z"/></g></svg>
<?xml version="1.0" encoding="utf-8" ?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="172"><title>MemHub</title><defs><filter id="filter_0" color-interpolation-filters="sRGB" x="0" y="8" width="164" height="164" filterUnits="userSpaceOnUse"><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset dx="0" dy="8"/><feGaussianBlur stdDeviation="9"/><feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.28 0 0 0 0 0.34 0 0 0 0.12 0" result="shadow"/></filter></defs><g filter="url(#filter_0)"><rect fill="#FFF" transform="matrix(1 0 0 1 18 18)" width="128" height="128" rx="28" ry="28"/><rect fill="none" stroke="#FFF" stroke-width="12" transform="matrix(1 0 0 1 46 41)" width="56" height="68" rx="23" ry="23"/><rect fill="none" stroke="#FFF" stroke-width="12" transform="matrix(1 0 0 1 62 55)" width="56" height="68" rx="23" ry="23"/><clipPath id="clip_0"><rect x="18" y="18" width="128" height="128" rx="28" ry="28"/></clipPath><g clip-path="url(#clip_0)"><path fill="#FFF" transform="matrix(1 0 0 1 18 18)" d="M129 28L129 100Q129 112.0121 120.5061 120.5061Q112.0121 129.0001 100 129L28 129Q15.9878 129 7.4939 120.5061Q-1 112.0121 -1 100L-1 28Q-1 15.9878 7.4939 7.4939Q15.9878 -1 28 -1L100 -1Q112.0122 -1 120.5061 7.4939Q129.0001 15.9878 129 28ZM127 28Q127 16.8162 119.0919 8.9081Q111.1838 1 100 1L28 1Q16.8162 1 8.9081 8.9081Q1 16.8162 1 28L1 100Q1 111.1838 8.9081 119.0919Q16.8162 127.0001 28 127L100 127Q111.1837 127 119.0919 119.0919Q127 111.1838 127 100L127 28Z"/></g></g><rect fill="#FFF" transform="matrix(1 0 0 1 18 18)" width="128" height="128" rx="28" ry="28"/><rect fill="none" stroke="#829CAD" stroke-width="12" transform="matrix(1 0 0 1 46 41)" width="56" height="68" rx="23" ry="23"/><rect fill="none" stroke="#4F6373" stroke-width="12" transform="matrix(1 0 0 1 62 55)" width="56" height="68" rx="23" ry="23"/><path fill="#5E7C8C" transform="matrix(1 0 0 1 79 64)" d="M23 0L23 22Q23 26.6757 21.1904 30.9547Q19.4442 35.0835 16.2639 38.2639Q13.0835 41.4442 8.9547 43.1904Q4.6757 45 0 45Q-2.5153 45.0724 -4.2426 43.2426Q-6.0724 41.5153 -6 39Q-6.0724 36.4847 -4.2426 34.7574Q-2.5153 32.9276 0 33Q2.2425 33 4.2806 32.1381Q6.2535 31.3037 7.7786 29.7786Q9.3037 28.2535 10.1381 26.2806Q11 24.2425 11 22L11 0Q10.9276 -2.5153 12.7574 -4.2426Q14.4847 -6.0724 17 -6Q19.5153 -6.0724 21.2426 -4.2426Q23.0724 -2.5153 23 0Z"/><clipPath id="clip_1"><rect x="18" y="18" width="128" height="128" rx="28" ry="28"/></clipPath><g clip-path="url(#clip_1)"><path fill="#FFF" transform="matrix(1 0 0 1 18 18)" d="M129 28L129 100Q129 112.0121 120.5061 120.5061Q112.0121 129.0001 100 129L28 129Q15.9878 129 7.4939 120.5061Q-1 112.0121 -1 100L-1 28Q-1 15.9878 7.4939 7.4939Q15.9878 -1 28 -1L100 -1Q112.0122 -1 120.5061 7.4939Q129.0001 15.9878 129 28ZM127 28Q127 16.8162 119.0919 8.9081Q111.1838 1 100 1L28 1Q16.8162 1 8.9081 8.9081Q1 16.8162 1 28L1 100Q1 111.1838 8.9081 119.0919Q16.8162 127.0001 28 127L100 127Q111.1837 127 119.0919 119.0919Q127 111.1838 127 100L127 28Z"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

+3 -1
View File
@@ -77,7 +77,9 @@ function releaseVersion(context) {
const packaged = context.packager?.appInfo?.version?.trim();
const version = supplied || packaged;
return typeof version === "string" &&
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(version)
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(
version,
)
? version
: undefined;
}
+11 -11
View File
@@ -493,17 +493,17 @@ function publicItem(
receipt: !receipt
? null
: {
kind: receipt.kind,
hostId: receipt.host.hostId,
installationId: receipt.host.installationId as string,
permissionRevision: receipt.host.permissionRevision,
...(receipt.kind === "readGrant"
? {
allowedTools: [...receipt.allowedTools],
allowedScopes: [...receipt.allowedScopes],
}
: {}),
},
kind: receipt.kind,
hostId: receipt.host.hostId,
installationId: receipt.host.installationId as string,
permissionRevision: receipt.host.permissionRevision,
...(receipt.kind === "readGrant"
? {
allowedTools: [...receipt.allowedTools],
allowedScopes: [...receipt.allowedScopes],
}
: {}),
},
};
}
function selectionInput(input: unknown): ConnectionSetupSelection {
@@ -4,10 +4,7 @@ import { desktopVersionOutput } from "./desktop-version";
describe("Desktop version probe", () => {
it("returns packaged SemVer for the product-private probe", () => {
expect(
desktopVersionOutput(
["MemHub", "--memhub-version"],
"0.1.0-internal.2",
),
desktopVersionOutput(["MemHub", "--memhub-version"], "0.1.0-internal.2"),
).toBe("MemHub 0.1.0-internal.2\n");
});
@@ -436,7 +436,9 @@ describe("display preference preload", () => {
it("preload exposes the explicit background restart action", async () => {
const api = await preload();
fake.invoke.mockResolvedValue({ state: "restarted" });
await expect(api.restartBackground()).resolves.toEqual({ state: "restarted" });
await expect(api.restartBackground()).resolves.toEqual({
state: "restarted",
});
expect(fake.invoke).toHaveBeenCalledExactlyOnceWith(
"one-memory:restart-background",
);
@@ -8,7 +8,9 @@ import { installEarlyStartupErrorHandler } from "./early-startup-errors";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true })));
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true })),
);
});
describe("early desktop startup errors", () => {
@@ -31,7 +33,9 @@ describe("early desktop startup errors", () => {
});
it("writes stderr and diagnostics before showing the fatal dialog", async () => {
const dataDir = await mkdtemp(path.join(os.tmpdir(), "desktop-early-error-"));
const dataDir = await mkdtemp(
path.join(os.tmpdir(), "desktop-early-error-"),
);
roots.push(dataDir);
const processEvents = new EventEmitter();
const order: string[] = [];
@@ -47,7 +51,8 @@ describe("early desktop startup errors", () => {
processEvents.emit(
"uncaughtException",
Object.assign(new Error("desktop_test_real_home_mismatch"), {
stack: "Error: desktop_test_real_home_mismatch\n at boot (apps/desktop/src/main.ts:80:1)",
stack:
"Error: desktop_test_real_home_mismatch\n at boot (apps/desktop/src/main.ts:80:1)",
}),
);
@@ -29,7 +29,8 @@ export function installEarlyStartupErrorHandler(
if (handled) return;
handled = true;
const fields = diagnosticError(error);
const message = error instanceof Error ? error.message : "desktop_startup_failed";
const message =
error instanceof Error ? error.message : "desktop_startup_failed";
const errorCode = SAFE_ERROR_CODE.test(message)
? message
: "desktop_startup_failed";
@@ -65,29 +65,29 @@ describe("formal package test isolation", () => {
it.skipIf(process.platform !== "darwin")(
"accepts the macOS /tmp alias for an existing /private/tmp test home",
() => {
const root = mkdtempSync("/private/tmp/one-memory-desktop-alias.");
const home = path.join(root, "home");
const dataDir = path.join(root, "data");
const hostHome = path.join(root, "host-home");
mkdirSync(home, { recursive: true });
mkdirSync(dataDir, { recursive: true });
mkdirSync(hostHome, { recursive: true });
const root = mkdtempSync("/private/tmp/one-memory-desktop-alias.");
const home = path.join(root, "home");
const dataDir = path.join(root, "data");
const hostHome = path.join(root, "host-home");
mkdirSync(home, { recursive: true });
mkdirSync(dataDir, { recursive: true });
mkdirSync(hostHome, { recursive: true });
expect(
resolveDesktopTestIsolation(
{
ONE_MEMORY_TEST_ISOLATED: "1",
ONE_MEMORY_TEST_REAL_HOME: "/Users/example",
ONE_MEMORY_DESKTOP_TEST_HOME: home,
ONE_MEMORY_DATA_DIR: dataDir,
ONE_MEMORY_HOST_HOME: hostHome,
ONE_MEMORY_CORE_PORT: "27791",
ONE_MEMORY_RUNTIME_PORT: "27791",
ONE_MEMORY_BROKER_PORT: "37791",
},
home.replace(/^\/private\/tmp\//, "/tmp/"),
),
).toMatchObject({ home });
expect(
resolveDesktopTestIsolation(
{
ONE_MEMORY_TEST_ISOLATED: "1",
ONE_MEMORY_TEST_REAL_HOME: "/Users/example",
ONE_MEMORY_DESKTOP_TEST_HOME: home,
ONE_MEMORY_DATA_DIR: dataDir,
ONE_MEMORY_HOST_HOME: hostHome,
ONE_MEMORY_CORE_PORT: "27791",
ONE_MEMORY_RUNTIME_PORT: "27791",
ONE_MEMORY_BROKER_PORT: "37791",
},
home.replace(/^\/private\/tmp\//, "/tmp/"),
),
).toMatchObject({ home });
},
);
@@ -53,7 +53,9 @@ it.skipIf(process.platform !== "darwin")(
process: { env: {} },
require(name: string) {
if (name === "node:crypto")
return { randomUUID: () => "00000000-0000-4000-8000-000000000000" };
return {
randomUUID: () => "00000000-0000-4000-8000-000000000000",
};
if (name === "node:path") return path;
if (name === "node:child_process")
return {
@@ -191,7 +191,9 @@ describe("packaged macOS permission metadata", () => {
expect(fixture.plist.get("CFBundleShortVersionString")).toBe("1.2.3");
expect(fixture.plist.get("CFBundleVersion")).toBe("3903");
expect(fixture.plist.get("LSEnvironment:ONE_MEMORY_VERSION")).toBe("1.2.3");
expect(fixture.plist.get("LSEnvironment:ONE_MEMORY_BUILD_NUMBER")).toBe("3903");
expect(fixture.plist.get("LSEnvironment:ONE_MEMORY_BUILD_NUMBER")).toBe(
"3903",
);
});
it("removes inherited audio capture and existing unused keys before signing", async () => {
@@ -209,10 +211,7 @@ describe("packaged macOS permission metadata", () => {
// Production clears any stale build marker before removing unused usage
// strings and signing. No LaunchAgent plist is written.
expect(fixture.execFileSync.mock.calls).toEqual([
[
"/usr/libexec/PlistBuddy",
["-c", "Add :LSEnvironment dict", plistPath],
],
["/usr/libexec/PlistBuddy", ["-c", "Add :LSEnvironment dict", plistPath]],
[
"/usr/libexec/PlistBuddy",
["-c", "Delete :LSEnvironment:ONE_MEMORY_VERSION", plistPath],
+1 -2
View File
@@ -229,6 +229,5 @@ contextBridge.exposeInMainWorld("oneMemory", {
hostId: string;
installed: boolean;
permission: RecallPermissionStatus;
}> =>
ipcRenderer.invoke("one-memory:uninstall-recall", hostId, approved),
}> => ipcRenderer.invoke("one-memory:uninstall-recall", hostId, approved),
});
+20 -5
View File
@@ -1,4 +1,10 @@
import { mkdir, mkdtemp, readFile, readlink, writeFile } from "node:fs/promises";
import {
mkdir,
mkdtemp,
readFile,
readlink,
writeFile,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
@@ -7,8 +13,8 @@ import {
ensureStableRuntimeThenRepair,
installBundledCliIfNeeded,
isInstalledApplicationsApp,
startStableRuntimeRepair,
stableRuntimeErrorCode,
startStableRuntimeRepair,
} from "./stable-runtime";
it("only treats a packaged macOS executable under Applications as installed", () => {
@@ -35,7 +41,12 @@ it("only treats a packaged macOS executable under Applications as installed", ()
it("installs only memhub and adds its directory to a new shell", async () => {
if (process.platform === "win32") return;
const home = await mkdtemp(path.join(os.tmpdir(), "memhub-auto-cli-"));
const installRoot = path.join(home, "Library", "Application Support", "One Memory");
const installRoot = path.join(
home,
"Library",
"Application Support",
"One Memory",
);
const target = path.join(installRoot, "current", "bin", "one-memory");
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, "#!/bin/sh\n", { mode: 0o700 });
@@ -43,10 +54,14 @@ it("installs only memhub and adds its directory to a new shell", async () => {
await installBundledCliIfNeeded({ home, installRoot, platform: "darwin" });
await installBundledCliIfNeeded({ home, installRoot, platform: "darwin" });
expect(await readlink(path.join(home, ".local", "bin", "memhub"))).toBe(target);
expect(await readlink(path.join(home, ".local", "bin", "memhub"))).toBe(
target,
);
const profile = await readFile(path.join(home, ".zprofile"), "utf8");
expect(profile.match(/# >>> memhub PATH >>>/g)).toHaveLength(1);
await expect(readFile(path.join(home, ".local", "bin", "one-memory"))).rejects.toThrow();
await expect(
readFile(path.join(home, ".local", "bin", "one-memory")),
).rejects.toThrow();
});
describe("ensureStableRuntimeThenRepair", () => {
+25 -5
View File
@@ -1,4 +1,12 @@
import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import {
chmod,
lstat,
mkdir,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { installCliLink, planCliLink } from "@one-memory/runtime-control";
@@ -36,13 +44,22 @@ async function ensureCliPath(
binDirectory: string,
platform: NodeJS.Platform,
): Promise<void> {
const profile = path.join(home, platform === "darwin" ? ".zprofile" : ".profile");
const profile = path.join(
home,
platform === "darwin" ? ".zprofile" : ".profile",
);
const existingInfo = await lstat(profile).catch(() => null);
if (existingInfo?.isSymbolicLink() || (existingInfo && !existingInfo.isFile())) {
if (
existingInfo?.isSymbolicLink() ||
(existingInfo && !existingInfo.isFile())
) {
throw new Error("shell_profile_unmanaged");
}
const existing = existingInfo ? await readFile(profile, "utf8") : "";
if (existing.includes(CLI_PATH_BEGIN) || existing.split("\n").some((line) => line.includes(binDirectory))) {
if (
existing.includes(CLI_PATH_BEGIN) ||
existing.split("\n").some((line) => line.includes(binDirectory))
) {
return;
}
await mkdir(home, { recursive: true, mode: 0o700 });
@@ -50,7 +67,10 @@ async function ensureCliPath(
const block = `${CLI_PATH_BEGIN}\nexport PATH="$HOME/.local/bin:$PATH"\n${CLI_PATH_END}\n`;
const temporary = `${profile}.memhub-${process.pid}`;
try {
await writeFile(temporary, `${existing}${separator}${block}`, { encoding: "utf8", mode: 0o600 });
await writeFile(temporary, `${existing}${separator}${block}`, {
encoding: "utf8",
mode: 0o600,
});
await chmod(temporary, existingInfo ? existingInfo.mode & 0o777 : 0o600);
await rename(temporary, profile);
} finally {
+2 -2
View File
@@ -3,8 +3,8 @@ export {
hasSyncHookArtifacts,
loadSyncHookAssets,
orphanSyncHookIdentity,
syncHookInput,
syncHookPreview,
type SyncHookPreview,
type SyncHookStatus,
syncHookInput,
syncHookPreview,
} from "@one-memory/runtime-control";
+5 -4
View File
@@ -1,10 +1,11 @@
import { defineConfig } from "tsup";
import { createRequire } from "node:module";
import { defineConfig } from "tsup";
const require = createRequire(import.meta.url);
const { resolveBuildVersion } = require("../../scripts/release-version.cjs") as {
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
};
const { resolveBuildVersion } =
require("../../scripts/release-version.cjs") as {
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
};
const releaseVersion = resolveBuildVersion(process.env);
export default defineConfig({
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#4d6bfe" d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.5 5.5 0 0 1-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11 11 0 0 0-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428s-1.67.295-2.687.684a3 3 0 0 1-.465.137 9.6 9.6 0 0 0-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.2 4.2 0 0 0 1.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.7 4.7 0 0 1 1.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614m1-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .2.288.306.306 0 0 1-.31.307.303.303 0 0 1-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.552-.758a1.7 1.7 0 0 1 .016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.56.56 0 0 1-.254-.078.253.253 0 0 1-.114-.358c.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>DeepSeek</title><path fill="#4d6bfe" d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.5 5.5 0 0 1-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11 11 0 0 0-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428s-1.67.295-2.687.684a3 3 0 0 1-.465.137 9.6 9.6 0 0 0-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.2 4.2 0 0 0 1.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.7 4.7 0 0 1 1.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614m1-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .2.288.306.306 0 0 1-.31.307.303.303 0 0 1-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.552-.758a1.7 1.7 0 0 1 .016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.56.56 0 0 1-.254-.078.253.253 0 0 1-.114-.358c.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452"/></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#1c1b1a" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M5.938 12.835c.127-.039.285.02.373.143.028.038.036.092.046.14.003.014-.02.033-.04.05q-.184-.146-.354-.291c-.011-.01-.016-.027-.025-.042m2.458-3.423c.195-.032.39-.06.588-.05a.5.5 0 0 1 .148.026q.303.107.601.224c.028.01.05.036.075.055l-.013.027a9 9 0 0 1-.26-.089c-.115-.038-.213-.077-.315-.098-.25-.05-.25-.046-.292-.014l.574.144q.413.207.823.417c.042.022.09.057.107.098.026.06.063.076.117.072.066-.006.132-.017.213-.027l-.04.086c.051.08.142.02.216.064-.074.13-.247.09-.334.199l.061.074-.12.087c0 .106-.038.168-.306.243l.026.085-.196.042.07.124h-.25l-.007.137-.244-.027-.053.123c-.027-.008-.052-.011-.073-.023-.067-.038-.128-.056-.195.006-.019.017-.063.014-.093.008-.026-.006-.05-.029-.07-.042-.11.095-.11.095-.208.003-.057.046-.12.074-.186.011-.063.027-.123-.02-.178-.014-.07.007-.097-.035-.133-.07l-.13.033c-.013-.236-.194-.19-.34-.203.005-.072.05-.092.095-.094a.5.5 0 0 1 .159.022c.164.05.32.12.496.138.203.021.405.029.601-.015.265-.059.52-.149.707-.365.049-.056.083-.127.117-.195.019-.038.02-.084-.02-.116a1.4 1.4 0 0 0-.382-.217c.024.12-.031.182-.115.221 0 .014-.004.025 0 .03.08.115.084.16-.007.267a1.4 1.4 0 0 1-.218.211.477.477 0 0 1-.641-.05 1.4 1.4 0 0 1-.133-.152c-.078-.107-.076-.108-.033-.236-.165-.08-.128-.226-.104-.364.008-.05.028-.096.049-.163-.04.014-.067.017-.087.032a.9.9 0 0 0-.316.357c-.007.016-.01.034-.02.047-.012.015-.034.038-.045.035-.02-.006-.037-.027-.05-.045-.008-.012-.007-.032-.012-.057h-.126l.053-.172-.039-.049.11-.284c-.06.026-.091.044-.124.051-.03.007-.064 0-.095 0 0-.031-.01-.07.004-.092.149-.22.305-.428.593-.476"/><path d="M8.06 10.788c-.003-.038-.004-.075.037-.062.016.006.034.048.028.067-.01.04-.038.032-.064-.005z"/><path d="M11.981.009q.34-.017.679 0c.247.01.495.024.74.062q.602.097 1.19.273c.463.138.92.299 1.356.511a7.3 7.3 0 0 1 2.948 2.642q.436.704.739 1.479c.219.556.446 1.11.623 1.683.204.654.329 1.326.458 1.997.097.504.182 1.01.29 1.511.156.722.329 1.44.494 2.16.186.812.4 1.615.63 2.415.102.355.193.713.282 1.072.11.436.202.876.254 1.323.031.278.066.557.073.837a8 8 0 0 1-.017.88c-.037.413-.1.818-.226 1.212a5 5 0 0 1-.915 1.649l-.13.156.018.023c.043-.023.088-.041.127-.068.2-.138.373-.307.531-.49.4-.46.721-.973.975-1.529a3.6 3.6 0 0 0 .325-1.72c-.024-.424-.097-.834-.3-1.213-.013-.027-.015-.06-.03-.121.05.035.082.048.101.072.107.13.22.258.315.398.33.494.46 1.052.486 1.64a3.75 3.75 0 0 1-.47 1.97c-.36.655-.887 1.14-1.526 1.506-.193.111-.394.21-.595.308-.157.078-.248.211-.318.365a.52.52 0 0 0-.033.406.4.4 0 0 1 .013.139c-.005.077-.077.155-.14.162-.054.006-.125-.043-.15-.116a1.2 1.2 0 0 1-.06-.233c-.04-.314-.155-.6-.308-.87a3.9 3.9 0 0 0-.73-.91 2.1 2.1 0 0 0-.897-.524 4 4 0 0 0-.692-.131c-.075-.008-.15-.04-.22.01.18.06.363.11.538.18.434.173.82.43 1.18.728.308.255.58.543.794.884.098.155.186.315.227.496.027.123.042.25.067.375q.021.092-.053.144c-.047.033-.122.034-.163-.01a.5.5 0 0 1-.08-.14c-.03-.073-.038-.159-.078-.225a7.3 7.3 0 0 0-1.423-1.664c-.16-.137-.329-.26-.537-.323-.376-.114-.753-.203-1.15-.154-.213.025-.427.032-.64.053a1.6 1.6 0 0 0-.736.278 5 5 0 0 0-.834.72c-.329.342-.642.699-.955 1.055-.136.155-.264.319-.314.531l-.012.051a.096.096 0 0 1-.09.076h-.31c-.046 0-.082-.048-.072-.094q.033-.162.07-.324c.075-.325.19-.635.368-.917.024-.039.04-.088.104-.08l.01.049.027.077c.28-.435.571-.834.996-1.135.283-.204.584-.378.89-.55a.2.2 0 0 0-.098-.002c-.162.043-.325.084-.485.134-.402.124-.764.33-1.11.566-.147.1-.298.193-.414.333a7.3 7.3 0 0 0-1.07 1.767 1 1 0 0 0-.04.12.075.075 0 0 1-.072.056h-.494c-.04 0-.062-.051-.036-.082.123-.14.246-.282.377-.415.275-.281.58-.532.777-.884.027-.048.063-.09.095-.135.238-.333.54-.607.818-.902.082-.086.175-.16.26-.24q.042-.042.079-.085l-.018-.025-.135.041q-.053.023-.102.05c-.248.144-.494.292-.743.433-.408.23-.825.439-1.209.711-.281.2-.591.358-.889.533-.02.012-.044.015-.08.028-.015-.135.143-.201.108-.336-.033.014-.064.02-.085.038-.111.096-.227.19-.328.296-.148.157-.284.325-.425.488q-.188.214-.373.431A.15.15 0 0 1 9.89 24H8.762l.016-.042c.028-.09.085-.172.083-.28-.091-.018-.162.001-.212.077a5 5 0 0 0-.136.215c-.01.016-.024.03-.042.03h-.093c-.019 0-.029-.022-.017-.037q.106-.133.209-.268c.001-.002-.006-.012-.012-.024q-.022.004-.045.013-.264.135-.527.274a.4.4 0 0 1-.168.042H5.202c-.026 0-.039-.036-.019-.053a3 3 0 0 0 .558-.605c.335-.496.538-1.047.667-1.629.004-.02-.003-.043-.006-.091-.037.048-.059.072-.076.1a2 2 0 0 1-.334.415c-.28.258-.59.448-.983.464-.297.012-.588 0-.865-.127-.46-.21-.722-.57-.794-1.072-.025-.17-.017-.171-.182-.219A3.5 3.5 0 0 1 1.97 20.6a2.3 2.3 0 0 1-.808-1.13 3.6 3.6 0 0 1-.16-1.245c.002-.034.016-.067.024-.1.032.023.046.043.05.066.033.153.059.308.096.46q.128.534.516.92c.258.256.571.419.91.532.358.118.717.138 1.07-.016a1.9 1.9 0 0 0 .621-.452c.328-.348.533-.76.648-1.223.009-.034.005-.071.007-.11-.015.006-.026.006-.03.011q-.048.075-.093.152A2.7 2.7 0 0 1 3.625 19.6c-.351.17-.718.255-1.11.159a1.6 1.6 0 0 1-.971-.64 2 2 0 0 1-.368-.924 2.9 2.9 0 0 1 .02-Line truncated
<svg xmlns="http://www.w3.org/2000/svg" fill="#1c1b1a" fill-rule="evenodd" viewBox="0 0 24 24"><title>Hermes</title><path d="M5.938 12.835c.127-.039.285.02.373.143.028.038.036.092.046.14.003.014-.02.033-.04.05q-.184-.146-.354-.291c-.011-.01-.016-.027-.025-.042m2.458-3.423c.195-.032.39-.06.588-.05a.5.5 0 0 1 .148.026q.303.107.601.224c.028.01.05.036.075.055l-.013.027a9 9 0 0 1-.26-.089c-.115-.038-.213-.077-.315-.098-.25-.05-.25-.046-.292-.014l.574.144q.413.207.823.417c.042.022.09.057.107.098.026.06.063.076.117.072.066-.006.132-.017.213-.027l-.04.086c.051.08.142.02.216.064-.074.13-.247.09-.334.199l.061.074-.12.087c0 .106-.038.168-.306.243l.026.085-.196.042.07.124h-.25l-.007.137-.244-.027-.053.123c-.027-.008-.052-.011-.073-.023-.067-.038-.128-.056-.195.006-.019.017-.063.014-.093.008-.026-.006-.05-.029-.07-.042-.11.095-.11.095-.208.003-.057.046-.12.074-.186.011-.063.027-.123-.02-.178-.014-.07.007-.097-.035-.133-.07l-.13.033c-.013-.236-.194-.19-.34-.203.005-.072.05-.092.095-.094a.5.5 0 0 1 .159.022c.164.05.32.12.496.138.203.021.405.029.601-.015.265-.059.52-.149.707-.365.049-.056.083-.127.117-.195.019-.038.02-.084-.02-.116a1.4 1.4 0 0 0-.382-.217c.024.12-.031.182-.115.221 0 .014-.004.025 0 .03.08.115.084.16-.007.267a1.4 1.4 0 0 1-.218.211.477.477 0 0 1-.641-.05 1.4 1.4 0 0 1-.133-.152c-.078-.107-.076-.108-.033-.236-.165-.08-.128-.226-.104-.364.008-.05.028-.096.049-.163-.04.014-.067.017-.087.032a.9.9 0 0 0-.316.357c-.007.016-.01.034-.02.047-.012.015-.034.038-.045.035-.02-.006-.037-.027-.05-.045-.008-.012-.007-.032-.012-.057h-.126l.053-.172-.039-.049.11-.284c-.06.026-.091.044-.124.051-.03.007-.064 0-.095 0 0-.031-.01-.07.004-.092.149-.22.305-.428.593-.476"/><path d="M8.06 10.788c-.003-.038-.004-.075.037-.062.016.006.034.048.028.067-.01.04-.038.032-.064-.005z"/><path d="M11.981.009q.34-.017.679 0c.247.01.495.024.74.062q.602.097 1.19.273c.463.138.92.299 1.356.511a7.3 7.3 0 0 1 2.948 2.642q.436.704.739 1.479c.219.556.446 1.11.623 1.683.204.654.329 1.326.458 1.997.097.504.182 1.01.29 1.511.156.722.329 1.44.494 2.16.186.812.4 1.615.63 2.415.102.355.193.713.282 1.072.11.436.202.876.254 1.323.031.278.066.557.073.837a8 8 0 0 1-.017.88c-.037.413-.1.818-.226 1.212a5 5 0 0 1-.915 1.649l-.13.156.018.023c.043-.023.088-.041.127-.068.2-.138.373-.307.531-.49.4-.46.721-.973.975-1.529a3.6 3.6 0 0 0 .325-1.72c-.024-.424-.097-.834-.3-1.213-.013-.027-.015-.06-.03-.121.05.035.082.048.101.072.107.13.22.258.315.398.33.494.46 1.052.486 1.64a3.75 3.75 0 0 1-.47 1.97c-.36.655-.887 1.14-1.526 1.506-.193.111-.394.21-.595.308-.157.078-.248.211-.318.365a.52.52 0 0 0-.033.406.4.4 0 0 1 .013.139c-.005.077-.077.155-.14.162-.054.006-.125-.043-.15-.116a1.2 1.2 0 0 1-.06-.233c-.04-.314-.155-.6-.308-.87a3.9 3.9 0 0 0-.73-.91 2.1 2.1 0 0 0-.897-.524 4 4 0 0 0-.692-.131c-.075-.008-.15-.04-.22.01.18.06.363.11.538.18.434.173.82.43 1.18.728.308.255.58.543.794.884.098.155.186.315.227.496.027.123.042.25.067.375q.021.092-.053.144c-.047.033-.122.034-.163-.01a.5.5 0 0 1-.08-.14c-.03-.073-.038-.159-.078-.225a7.3 7.3 0 0 0-1.423-1.664c-.16-.137-.329-.26-.537-.323-.376-.114-.753-.203-1.15-.154-.213.025-.427.032-.64.053a1.6 1.6 0 0 0-.736.278 5 5 0 0 0-.834.72c-.329.342-.642.699-.955 1.055-.136.155-.264.319-.314.531l-.012.051a.096.096 0 0 1-.09.076h-.31c-.046 0-.082-.048-.072-.094q.033-.162.07-.324c.075-.325.19-.635.368-.917.024-.039.04-.088.104-.08l.01.049.027.077c.28-.435.571-.834.996-1.135.283-.204.584-.378.89-.55a.2.2 0 0 0-.098-.002c-.162.043-.325.084-.485.134-.402.124-.764.33-1.11.566-.147.1-.298.193-.414.333a7.3 7.3 0 0 0-1.07 1.767 1 1 0 0 0-.04.12.075.075 0 0 1-.072.056h-.494c-.04 0-.062-.051-.036-.082.123-.14.246-.282.377-.415.275-.281.58-.532.777-.884.027-.048.063-.09.095-.135.238-.333.54-.607.818-.902.082-.086.175-.16.26-.24q.042-.042.079-.085l-.018-.025-.135.041q-.053.023-.102.05c-.248.144-.494.292-.743.433-.408.23-.825.439-1.209.711-.281.2-.591.358-.889.533-.02.012-.044.015-.08.028-.015-.135.143-.201.108-.336-.033.014-.064.02-.085.038-.111.096-.227.19-.328.296-.148.157-.284.325-.425.488q-.188.214-.373.431A.15.15 0 0 1 9.89 24H8.762l.016-.042c.028-.09.085-.172.083-.28-.091-.018-.162.001-.212.077a5 5 0 0 0-.136.215c-.01.016-.024.03-.042.03h-.093c-.019 0-.029-.022-.017-.037q.106-.133.209-.268c.001-.002-.006-.012-.012-.024q-.022.004-.045.013-.264.135-.527.274a.4.4 0 0 1-.168.042H5.202c-.026 0-.039-.036-.019-.053a3 3 0 0 0 .558-.605c.335-.496.538-1.047.667-1.629.004-.02-.003-.043-.006-.091-.037.048-.059.072-.076.1a2 2 0 0 1-.334.415c-.28.258-.59.448-.983.464-.297.012-.588 0-.865-.127-.46-.21-.722-.57-.794-1.072-.025-.17-.017-.171-.182-.219A3.5 3.5 0 0 1 1.97 20.6a2.3 2.3 0 0 1-.808-1.13 3.6 3.6 0 0 1-.16-1.245c.002-.034.016-.067.024-.1.032.023.046.043.05.066.033.153.059.308.096.46q.128.534.516.92c.258.256.571.419.91.532.358.118.717.138 1.07-.016a1.9 1.9 0 0 0 .621-.452c.328-.348.533-.76.648-1.223.009-.034.005-.071.007-.11-.015.006-.026.006-.03.011q-.048.075-.093.152A2.7 2.7 0 0 1 3.625 19.6c-.351.17-.718.255-1.11.159a1.6 1.6 0 0 1-.971-.64 2 2 0 0 1-.368-.9Line truncated

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg"><title>OpenClaw</title>
<defs>
<linearGradient id="ocLG" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff4d4d"/>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#1c1b1a" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M16 6H8v12h8zm4 16H4V2h16z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="#1c1b1a" fill-rule="evenodd" viewBox="0 0 24 24"><title>OpenCode</title><path d="M16 6H8v12h8zm4 16H4V2h16z"/></svg>

Before

Width:  |  Height:  |  Size: 139 B

After

Width:  |  Height:  |  Size: 162 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800"><rect width="800" height="800" rx="120" fill="#09090b"/><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/><path fill="#fff" d="M517.36 400H634.72V634.72H517.36Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800"><title>Pi</title><rect width="800" height="800" rx="120" fill="#09090b"/><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/><path fill="#fff" d="M517.36 400H634.72V634.72H517.36Z"/></svg>

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 331 B

+13 -21
View File
@@ -1028,20 +1028,20 @@ it("does not keep WorkBuddy looking synced when the server write fails", async (
screen.getByRole("switch", { name: "同步开关" }),
);
expect(
screen.getByRole("alert").compareDocumentPosition(
screen.getByRole("switch", { name: "同步开关" }),
) & Node.DOCUMENT_POSITION_FOLLOWING,
screen
.getByRole("alert")
.compareDocumentPosition(
screen.getByRole("switch", { name: "同步开关" }),
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("disables connection and explains why outside Applications, and stays enabled when in Applications", async () => {
window.oneMemory = {
getConnectionSetupCapability: vi
.fn()
.mockResolvedValue({
available: false,
reason: "app_not_in_applications",
}),
getConnectionSetupCapability: vi.fn().mockResolvedValue({
available: false,
reason: "app_not_in_applications",
}),
} as unknown as NonNullable<typeof window.oneMemory>;
render(<ApplicationDetail {...props} />);
await waitFor(() =>
@@ -1106,13 +1106,9 @@ it("promises a backfill when the switch is turned back on (D97)", async () => {
/>,
);
expect(screen.queryByText(/将补充冻结期间的新对话/)).toBeNull();
fireEvent.click(
await screen.findByRole("switch", { name: "同步开关" }),
);
fireEvent.click(await screen.findByRole("switch", { name: "同步开关" }));
await waitFor(() =>
expect(
screen.getByText(/将补充冻结期间的新对话/),
).toBeTruthy(),
expect(screen.getByText(/将补充冻结期间的新对话/)).toBeTruthy(),
);
});
@@ -1135,15 +1131,11 @@ it("says sync is being turned on while the write is in flight (D77)", async () =
);
const sync = await screen.findByRole("switch", { name: "同步开关" });
fireEvent.click(sync);
await waitFor(() =>
expect(screen.getByText("正在开启处理…")).toBeTruthy(),
);
await waitFor(() => expect(screen.getByText("正在开启处理…")).toBeTruthy());
expect(screen.getByRole("status").textContent).toContain("一两分钟");
expect(screen.queryByText("处理中")).toBeNull();
finish();
await waitFor(() =>
expect(screen.queryByText("正在开启处理…")).toBeNull(),
);
await waitFor(() => expect(screen.queryByText("正在开启处理…")).toBeNull());
});
it("sends a click made while the remembered sync is still writing (D77)", async () => {
+3 -1
View File
@@ -517,7 +517,9 @@ export function ApplicationDetail({
)}
{hostError && <p role="alert">{hostError}</p>}
{appLocationNote && <p role="status">{appLocationNote}</p>}
{hostRestartNotice ? <p role="status">{hostRestartNotice}</p> : null}
{hostRestartNotice ? (
<p role="status">{hostRestartNotice}</p>
) : null}
</div>
<R6Switch
label="一键插入应用"
+17 -13
View File
@@ -19,15 +19,21 @@ import { saveConnectionPreferences } from "./connection-preferences";
import { resetConnectionReconcile } from "./connection-reconcile";
import { queryClient } from "./query-client";
const { summary, processing, permission, history, liveSync, recentConversations } =
vi.hoisted(() => ({
summary: vi.fn(),
processing: vi.fn(),
permission: vi.fn(),
history: vi.fn(),
liveSync: vi.fn(),
recentConversations: vi.fn(),
}));
const {
summary,
processing,
permission,
history,
liveSync,
recentConversations,
} = vi.hoisted(() => ({
summary: vi.fn(),
processing: vi.fn(),
permission: vi.fn(),
history: vi.fn(),
liveSync: vi.fn(),
recentConversations: vi.fn(),
}));
vi.mock("./call-history-client", async (original) => ({
...(await original<typeof import("./call-history-client")>()),
getCallHistory: history,
@@ -116,7 +122,7 @@ it("hides a debug-frame dialogue excerpt from 最近更新 and falls back to mem
created_at: "2026-10-12T06:06:00Z",
imported_at: "2026-10-12T06:06:00Z",
time_basis: "conversation",
excerpt: '[write_stdout] {"chars":"{\\\"session_id\\":93099}',
excerpt: '[write_stdout] {"chars":"{\\"session_id\\":93099}',
truncated: false,
},
],
@@ -556,9 +562,7 @@ it("shows disabled model-content permission and opens settings", async () => {
0,
);
expect(screen.getByText("新的对话不会再整理成记忆")).toBeTruthy();
fireEvent.click(
screen.getByRole("button", { name: "打开模型内容权限" }),
);
fireEvent.click(screen.getByRole("button", { name: "打开模型内容权限" }));
expect(onSettings).toHaveBeenCalledOnce();
});
afterEach(() => {
+268 -268
View File
@@ -31,13 +31,13 @@ import {
retryAutoReconcile,
useAutoConnect,
} from "./connection-reconcile";
import { HomeCardBoundary } from "./HomeCardBoundary";
import {
hostBridgeAvailable,
hostConnectionApproved,
hostConnectionUnknown,
useRecallHosts,
} from "./host-state";
import { HomeCardBoundary } from "./HomeCardBoundary";
import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { prefetchDataRuntime } from "./runtime-state";
@@ -804,214 +804,214 @@ export function ApplicationsHome({
inert={expanded || phase !== "idle"}
>
<HomeCardBoundary label="记忆概览">
<section className="r6-home-overview" aria-label="记忆概览">
<header>
<h2>记忆概览</h2>
</header>
<div className="r6-home-metrics">
<div>
<p>已沉淀记忆</p>
{/* The figure slot used to hold the word 读取中 while the first
<section className="r6-home-overview" aria-label="记忆概览">
<header>
<h2>记忆概览</h2>
</header>
<div className="r6-home-metrics">
<div>
<p>已沉淀记忆</p>
{/* The figure slot used to hold the word 读取中 while the first
read was in flight, which is how "大数字不可见" (D51) shows up
on a slow link: the biggest type on the page spends the first
frame saying something that is not a number. A bar of the same
height reserves the digits instead, and the wording moves to a
live region so screen readers still hear it. 暂无法读取 stays a
word, because that is a settled state rather than progress. */}
{!visible && !summaryUnavailable ? (
<>
<p className="r6-home-figure">
<span
className="r6-home-figure-pending"
aria-hidden="true"
/>
</p>
<small role="status">正在读取已沉淀记忆数量…</small>
</>
{!visible && !summaryUnavailable ? (
<>
<p className="r6-home-figure">
<span
className="r6-home-figure-pending"
aria-hidden="true"
/>
</p>
<small role="status">正在读取已沉淀记忆数量…</small>
</>
) : (
<>
<p className="r6-home-figure">
<strong className={visible ? "" : "r6-home-word"}>
{visible
? new Intl.NumberFormat("zh-CN").format(
visible.memory_count,
)
: "暂无法读取"}
</strong>
{visible ? <i>条</i> : null}
</p>
<small>按应用授权范围使用</small>
</>
)}
</div>
<div>
<p>沉淀状态</p>
<strong className="r6-home-word">{stateLabel}</strong>
<small>
{modelContentAllowed === false
? "新的对话不会再整理成记忆"
: processing?.running
? "新的对话正在整理"
: processing?.pending
? "等待队列开始处理"
: "以当前处理状态为准"}
</small>
{modelContentAllowed === false ? (
<R6Button variant="quiet" onClick={() => onSettings()}>
打开模型内容权限
</R6Button>
) : null}
</div>
<div>
<p>最近更新</p>
<strong className={time ? "r6-home-time" : "r6-home-word"}>
{time ?? (visible ? "尚未生成" : "待核对")}
</strong>
<small>
{time
? `${latestClock?.today ? "今天 · " : ""}记忆已保存`
: "历史与记忆分别处理"}
</small>
</div>
</div>
<div className="r6-home-activity">
{availableActivities.length > 1 &&
(activity === "call" ||
activity === "memory" ||
activity === "dialogue") ? (
<div className="r6-home-activity-tabs" role="tablist">
{(["update", "call"] as const).map((item) => (
<button
type="button"
role="tab"
key={item}
aria-selected={
item === "call"
? activity === "call"
: activity === "memory" || activity === "dialogue"
}
onClick={() => setActivityView(item)}
>
{item === "call" ? "最近调用" : "最近更新"}
</button>
))}
</div>
) : (
<>
<p className="r6-home-figure">
<strong className={visible ? "" : "r6-home-word"}>
{visible
? new Intl.NumberFormat("zh-CN").format(
visible.memory_count,
)
: "暂无法读取"}
</strong>
{visible ? <i>条</i> : null}
</p>
<small>按应用授权范围使用</small>
</>
<p>
{activity === "unavailable"
? "动态暂不可用"
: activity === "call"
? "最近调用"
: activity === "dialogue" || activity === "memory"
? "最近更新"
: "首批沉淀"}
</p>
)}
</div>
<div>
<p>沉淀状态</p>
<strong className="r6-home-word">{stateLabel}</strong>
<small>
{modelContentAllowed === false
? "新的对话不会再整理成记忆"
: processing?.running
? "新的对话正在整理"
: processing?.pending
? "等待队列开始处理"
: "以当前处理状态为准"}
</small>
{modelContentAllowed === false ? (
<R6Button variant="quiet" onClick={() => onSettings()}>
打开模型内容权限
</R6Button>
) : null}
</div>
<div>
<p>最近更新</p>
<strong className={time ? "r6-home-time" : "r6-home-word"}>
{time ?? (visible ? "尚未生成" : "待核对")}
</strong>
<small>
{time
? `${latestClock?.today ? "今天 · " : ""}记忆已保存`
: "历史与记忆分别处理"}
</small>
</div>
</div>
<div className="r6-home-activity">
{availableActivities.length > 1 &&
(activity === "call" ||
activity === "memory" ||
activity === "dialogue") ? (
<div className="r6-home-activity-tabs" role="tablist">
{(["update", "call"] as const).map((item) => (
<div>
{activity === "call" && recentCall ? (
<div className="r6-home-call-direction" aria-hidden="true">
{recentCallSource ? (
<>
<AppMark id={recentCallSource.source_id} size={26} />
<ArrowRight size={22} />
</>
) : null}
<AppMark
id={
recentCallHost?.source_id ??
recentCall.host_id ??
"unknown"
}
size={26}
/>
</div>
) : activity === "memory" && latest?.source_ids[0] ? (
(() => {
const source = imports.sources.find(
(item) => item.source_id === latest.source_ids[0],
);
return source ? <SourceMark source={source} /> : null;
})()
) : activity === "dialogue" && latestDialogue ? (
(() => {
const source = imports.sources.find(
(item) => item.source_id === latestDialogue.source_id,
);
return source ? <SourceMark source={source} /> : null;
})()
) : null}
<section>
<h3>
{activity === "unavailable"
? callState === "unavailable"
? "暂时无法读取调用记录"
: "暂时无法读取记忆状态"
: activity === "call" && recentCall
? callDetailHeadline(
recentCall,
recentCallHost?.display_name ??
callHostName(recentCall.host_id),
)
: activity === "memory" && latest
? `${latest.content}${latest.truncated ? "…" : ""}`
: activity === "dialogue" && latestDialogue
? `${latestDialogue.excerpt}${latestDialogue.truncated ? "…" : ""}`
: activity === "importing"
? "对话持续导入中,即将沉淀你的记忆"
: activity === "first-running"
? "正在沉淀第一批记忆"
: activity === "first-queued"
? "准备沉淀第一批记忆"
: activity === "loading"
? "正在读取动态"
: "还没有已生成的记忆"}
</h3>
<small>
{activity === "unavailable"
? callState === "unavailable"
? "已有记忆仍可查看;重试不会发起新的调用。"
: "读取失败不会当作没有记忆。"
: activity === "call" && recentCall
? `${stamped(recentCall.occurred_at)} · ${callRowDetail(recentCall)}${callState === "incomplete" ? " · 有几次调用没被记下来,列表可能不全" : ""}`
: activity === "memory" && latest
? `${time} · ${memoryAction(latest.action)} · ${latest.source_ids.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join("、")} · 版本 ${latest.revision}`
: activity === "dialogue" && latestDialogue
? `${clockParts(latestDialogue.created_at ?? latestDialogue.imported_at)?.clock ?? "时间未知"} ${latestDialogue.time_basis === "conversation" ? "对话时间" : "导入时间"} · ${imports.sources.find((source) => source.source_id === latestDialogue.source_id)?.display_name ?? latestDialogue.source_id}`
: activity === "importing"
? "对话正在后台持续保存。"
: "历史已保存,记忆生成后会显示在这里。"}
</small>
</section>
{(activity === "unavailable" ||
(activity === "call" && CALL_HISTORY_ENTRY_VISIBLE) ||
activity === "memory") && (
<button
type="button"
role="tab"
key={item}
aria-selected={
item === "call"
? activity === "call"
: activity === "memory" || activity === "dialogue"
className="r6-home-activity-link"
onClick={
activity === "unavailable"
? () => {
setRevision((n) => n + 1);
setCallRevision((n) => n + 1);
}
: activity === "call"
? onCalls
: () => onMemories(latest?.memory_id)
}
onClick={() => setActivityView(item)}
>
{item === "call" ? "最近调用" : "最近更新"}
</button>
))}
</div>
) : (
<p>
{activity === "unavailable"
? "动态暂不可用"
: activity === "call"
? "最近调用"
: activity === "dialogue" || activity === "memory"
? "最近更新"
: "首批沉淀"}
</p>
)}
<div>
{activity === "call" && recentCall ? (
<div className="r6-home-call-direction" aria-hidden="true">
{recentCallSource ? (
<>
<AppMark id={recentCallSource.source_id} size={26} />
<ArrowRight size={22} />
</>
) : null}
<AppMark
id={
recentCallHost?.source_id ??
recentCall.host_id ??
"unknown"
}
size={26}
/>
</div>
) : activity === "memory" && latest?.source_ids[0] ? (
(() => {
const source = imports.sources.find(
(item) => item.source_id === latest.source_ids[0],
);
return source ? <SourceMark source={source} /> : null;
})()
) : activity === "dialogue" && latestDialogue ? (
(() => {
const source = imports.sources.find(
(item) => item.source_id === latestDialogue.source_id,
);
return source ? <SourceMark source={source} /> : null;
})()
) : null}
<section>
<h3>
{activity === "unavailable"
? callState === "unavailable"
? "暂时无法读取调用记录"
: "暂时无法读取记忆状态"
: activity === "call" && recentCall
? callDetailHeadline(
recentCall,
recentCallHost?.display_name ??
callHostName(recentCall.host_id),
)
: activity === "memory" && latest
? `${latest.content}${latest.truncated ? "…" : ""}`
: activity === "dialogue" && latestDialogue
? `${latestDialogue.excerpt}${latestDialogue.truncated ? "…" : ""}`
: activity === "importing"
? "对话持续导入中,即将沉淀你的记忆"
: activity === "first-running"
? "正在沉淀第一批记忆"
: activity === "first-queued"
? "准备沉淀第一批记忆"
: activity === "loading"
? "正在读取动态"
: "还没有已生成的记忆"}
</h3>
<small>
{activity === "unavailable"
? callState === "unavailable"
? "已有记忆仍可查看;重试不会发起新的调用。"
: "读取失败不会当作没有记忆。"
: activity === "call" && recentCall
? `${stamped(recentCall.occurred_at)} · ${callRowDetail(recentCall)}${callState === "incomplete" ? " · 有几次调用没被记下来,列表可能不全" : ""}`
: activity === "memory" && latest
? `${time} · ${memoryAction(latest.action)} · ${latest.source_ids.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join("、")} · 版本 ${latest.revision}`
: activity === "dialogue" && latestDialogue
? `${clockParts(latestDialogue.created_at ?? latestDialogue.imported_at)?.clock ?? "时间未知"} ${latestDialogue.time_basis === "conversation" ? "对话时间" : "导入时间"} · ${imports.sources.find((source) => source.source_id === latestDialogue.source_id)?.display_name ?? latestDialogue.source_id}`
: activity === "importing"
? "对话正在后台持续保存。"
: "历史已保存,记忆生成后会显示在这里。"}
</small>
</section>
{(activity === "unavailable" ||
(activity === "call" && CALL_HISTORY_ENTRY_VISIBLE) ||
activity === "memory") && (
<button
type="button"
className="r6-home-activity-link"
onClick={
activity === "unavailable"
? () => {
setRevision((n) => n + 1);
setCallRevision((n) => n + 1);
}
{activity === "unavailable"
? "重新读取"
: activity === "call"
? onCalls
: () => onMemories(latest?.memory_id)
}
>
{activity === "unavailable"
? "重新读取"
: activity === "call"
? "查看记录"
: "查看记忆"}
{activity === "unavailable" ? null : (
<ArrowRight size={14} aria-hidden="true" />
)}
</button>
)}
? "查看记录"
: "查看记忆"}
{activity === "unavailable" ? null : (
<ArrowRight size={14} aria-hidden="true" />
)}
</button>
)}
</div>
</div>
</div>
</section>
</section>
</HomeCardBoundary>
{attention ? (
<section className="r6-home-auto-failure" role="alert">
@@ -1038,81 +1038,81 @@ export function ApplicationsHome({
</section>
) : null}
<HomeCardBoundary label="运行状态">
<aside className="r6-home-runtime">
<h2>运行状态</h2>
<h3>
{connected
? "后台运行中"
: imports.healthStale
? "连接状态待核对"
: "服务未连接"}
</h3>
<p>仅处理已授权范围</p>
<dl>
<div>
<dt>对话同步</dt>
<dd>{syncState}</dd>
</div>
<div>
<dt>记忆整理</dt>
<dd>{stateLabel}</dd>
</div>
<div>
<dt>应用访问</dt>
<dd>{accessText}</dd>
</div>
</dl>
{accessUnknownText ? (
<div
role="status"
aria-label="应用接入核对"
className="r6-home-auto-failure"
>
<p>{accessUnknownText}</p>
<button
type="button"
className="r6-home-activity-link"
disabled={hostQuery.isFetching}
onClick={() => void hostQuery.refetch()}
<aside className="r6-home-runtime">
<h2>运行状态</h2>
<h3>
{connected
? "后台运行中"
: imports.healthStale
? "连接状态待核对"
: "服务未连接"}
</h3>
<p>仅处理已授权范围</p>
<dl>
<div>
<dt>对话同步</dt>
<dd>{syncState}</dd>
</div>
<div>
<dt>记忆整理</dt>
<dd>{stateLabel}</dd>
</div>
<div>
<dt>应用访问</dt>
<dd>{accessText}</dd>
</div>
</dl>
{accessUnknownText ? (
<div
role="status"
aria-label="应用接入核对"
className="r6-home-auto-failure"
>
{hostQuery.isFetching ? "核对中…" : "重新核对"}
</button>
</div>
) : null}
{hostStale ? (
<p
role="status"
aria-label="应用接入状态"
className="r6-home-auto-failure"
>
应用接入状态暂时读取失败,当前为上次状态。
<p>{accessUnknownText}</p>
<button
type="button"
className="r6-home-activity-link"
disabled={hostQuery.isFetching}
onClick={() => void hostQuery.refetch()}
>
{hostQuery.isFetching ? "核对中…" : "重新核对"}
</button>
</div>
) : null}
{hostStale ? (
<p
role="status"
aria-label="应用接入状态"
className="r6-home-auto-failure"
>
应用接入状态暂时读取失败,当前为上次状态。
</p>
) : null}
{autoFailureText ? (
<div role="alert" className="r6-home-auto-failure">
<p>{`${autoFailureText}不会自动重试。`}</p>
<button
type="button"
className="r6-home-activity-link"
disabled={auto.running.length > 0}
onClick={() =>
void retryAutoReconcile({
sources: imports.sources,
setSync: imports.setSync,
refresh: imports.refresh,
})
}
>
重新接入
</button>
</div>
) : null}
<p className="r6-home-checked">
{!checked
? "上次核对:待核对"
: `上次核对:${checked.today ? "今天 " : ""}${checked.clock}`}
</p>
) : null}
{autoFailureText ? (
<div role="alert" className="r6-home-auto-failure">
<p>{`${autoFailureText}不会自动重试。`}</p>
<button
type="button"
className="r6-home-activity-link"
disabled={auto.running.length > 0}
onClick={() =>
void retryAutoReconcile({
sources: imports.sources,
setSync: imports.setSync,
refresh: imports.refresh,
})
}
>
重新接入
</button>
</div>
) : null}
<p className="r6-home-checked">
{!checked
? "上次核对:待核对"
: `上次核对:${checked.today ? "今天 " : ""}${checked.clock}`}
</p>
</aside>
</aside>
</HomeCardBoundary>
</div>
<section
+1 -1
View File
@@ -439,7 +439,7 @@ it.each([
);
expect(
screen.getByRole("heading", {
name: "未记录 · 09/20 11:03",
name: /^未记录 · 09\/20 \d{2}:03$/,
}),
).toBeTruthy();
expect(screen.getByText("没有找到相关内容")).toBeTruthy();
+8 -7
View File
@@ -14,9 +14,9 @@ import {
type CallHistoryPage,
callDeliveryLabel,
callDetailHeadline,
callReturnDeliveryLabel,
callRowDetail,
callRowHeadline,
callReturnDeliveryLabel,
clearAllCallEvents,
clearCallEvents,
getCallHistory,
@@ -122,7 +122,9 @@ function CallReferences({
? "待核对"
: "不可用"}
</h4>
{reference.source_id && <p>{sourceLabel(reference.source_id)}</p>}
{reference.source_id && (
<p>{sourceLabel(reference.source_id)}</p>
)}
</div>
{current?.available && current.reason === null && (
<R6Button
@@ -511,8 +513,9 @@ export function CallHistory({
{stateLabels[selected.state]}
</p>
<p className="call-history-stage">
找到原文 {selected.raw_count} 段 · 记忆 {selected.memory_count} 条
· 用时 {(selected.elapsed_ms / 1000).toFixed(1)} 秒
找到原文 {selected.raw_count} 段 · 记忆{" "}
{selected.memory_count} 条 · 用时{" "}
{(selected.elapsed_ms / 1000).toFixed(1)} 秒
</p>
{callDeliveryLabel(selected) && (
<p className="call-history-stage">
@@ -592,9 +595,7 @@ export function CallHistory({
{callRowHeadline(item)}
</button>
</h2>
<p>
{callRowDetail(item)}
</p>
<p>{callRowDetail(item)}</p>
</div>
</li>
))}
+3 -1
View File
@@ -168,7 +168,9 @@ describe("HealthPanel", () => {
expect(toggle.getAttribute("aria-checked")).toBe("false");
fireEvent.click(toggle);
await waitFor(() => expect(setDetailedLogging).toHaveBeenCalledWith(true));
await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("true"));
await waitFor(() =>
expect(toggle.getAttribute("aria-checked")).toBe("true"),
);
});
it("records a problem marker, copies its id and exports diagnostics", async () => {
+1 -3
View File
@@ -353,9 +353,7 @@ export function HistoryImport({
await reviewSource(next, token);
settle("succeeded");
} catch (reason) {
settle(
saved ? "unknown" : apiErrorCode(reason) ? "failed" : "unknown",
);
settle(saved ? "unknown" : apiErrorCode(reason) ? "failed" : "unknown");
if (active(token))
setError(
saved
+3 -1
View File
@@ -36,7 +36,9 @@ export class HomeCardBoundary extends Component<Props, State> {
if (this.state.crashed) {
return (
<section className="r6-home-auto-failure" role="status">
<p>{this.props.label}暂时显示不了,其他功能不受影响,稍后会自动恢复。</p>
<p>
{this.props.label}暂时显示不了,其他功能不受影响,稍后会自动恢复。
</p>
</section>
);
}
+27 -7
View File
@@ -246,10 +246,19 @@ export function ImportProgressStrip({
const firstImport = !stale && runningHistory.length > 0;
const actionable =
currentArrival !== null &&
["model-required", "consent-required", "failed", "stopped", "unavailable"].includes(
currentArrival.phase,
);
if (!firstImport && !actionable && !failed.length && !completedWithErrors.length) {
[
"model-required",
"consent-required",
"failed",
"stopped",
"unavailable",
].includes(currentArrival.phase);
if (
!firstImport &&
!actionable &&
!failed.length &&
!completedWithErrors.length
) {
return null;
}
const imported = sources.reduce(
@@ -264,14 +273,25 @@ export function ImportProgressStrip({
? "模型授权已撤回,新的对话不会再整理成记忆。"
: "有些内容没有处理完成,请前往设置查看。";
return (
<div className="import-strip" role="status" aria-live="polite" data-feedback="compact">
<div
className="import-strip"
role="status"
aria-live="polite"
data-feedback="compact"
>
<div className="import-strip-headline">
<div className="import-strip-heading">
{firstImport && <span className="import-strip-pulse" aria-hidden="true" />}
{firstImport && (
<span className="import-strip-pulse" aria-hidden="true" />
)}
<strong>{message}</strong>
</div>
{!firstImport && onConfigureModel && (
<button type="button" className="secondary" onClick={onConfigureModel}>
<button
type="button"
className="secondary"
onClick={onConfigureModel}
>
<Settings2 size={15} aria-hidden="true" />
去设置
</button>
@@ -50,9 +50,7 @@ it("OBS-18: reads the configured model before saying it is not configured", asyn
page();
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
expect(screen.getByText(/整理模型:/).textContent).toContain("正在读取");
expect(
screen.queryByRole("button", { name: "配置模型" }),
).toBeNull();
expect(screen.queryByRole("button", { name: "配置模型" })).toBeNull();
release([
{
profile_id: "p1",
+1 -3
View File
@@ -706,9 +706,7 @@ describe("MemorySpace", () => {
// The arrival status belongs to the workbench surface, not the ask tab.
await userEvent.click(screen.getByRole("tab", { name: "工作台" }));
expect(screen.getByRole("region", { name: "记忆整理状态" })).toBeTruthy();
expect(
screen.getByText("等待允许模型读取本地会话内容"),
).toBeTruthy();
expect(screen.getByText("等待允许模型读取本地会话内容")).toBeTruthy();
expect(screen.queryByTestId("memory-graph")).toBeNull();
expectNoTeachingWrites();
});
+9 -8
View File
@@ -284,11 +284,7 @@ export function MemorySpace({
null,
);
const [arrivalSetup, setArrivalSetup] = useState<
| "checking"
| "model-required"
| "consent-required"
| "ready"
| "unavailable"
"checking" | "model-required" | "consent-required" | "ready" | "unavailable"
>("checking");
const [checkingSetup, setCheckingSetup] = useState(false);
const liveLoading = useRef(false);
@@ -318,7 +314,10 @@ export function MemorySpace({
]);
if (stopped || version !== request) return;
setCheckingSetup(false);
if (profiles.status !== "fulfilled" || permission.status !== "fulfilled") {
if (
profiles.status !== "fulfilled" ||
permission.status !== "fulfilled"
) {
setArrivalSetup("unavailable");
return;
}
@@ -351,6 +350,8 @@ export function MemorySpace({
// none"; not having read it yet means we do not know, and saying the first
// when we mean the second is how 483 memories came to look like zero.
const [loaded, setLoaded] = useState(false);
const loadedRef = useRef(loaded);
loadedRef.current = loaded;
/** Focus targets for the mode tablist's roving arrow-key navigation. */
const modeTabs = useRef<
Partial<
@@ -766,7 +767,7 @@ export function MemorySpace({
async function attemptLoad() {
if (cancelled || loading) return;
loading = true;
if (loaded) {
if (loadedRef.current) {
refreshingTimer = window.setTimeout(() => {
if (!cancelled) setRefreshing(true);
}, 2_000);
@@ -777,7 +778,7 @@ export function MemorySpace({
if (!cancelled) {
// A failed refresh keeps the list on screen, so it says why the list
// may be behind. A quick successful one never flashes the hint.
setRefreshing(!success && loaded);
setRefreshing(!success && loadedRef.current);
}
if (cancelled || success) return;
attempt += 1;
@@ -15,10 +15,14 @@ vi.mock("./ui-trace", () => ({
}));
beforeEach(() => {
vi.mocked(api.getModelContentPermission).mockResolvedValue({ allowed: false });
vi.mocked(api.setModelContentPermission).mockImplementation(async (allowed) => ({
allowed,
}));
vi.mocked(api.getModelContentPermission).mockResolvedValue({
allowed: false,
});
vi.mocked(api.setModelContentPermission).mockImplementation(
async (allowed) => ({
allowed,
}),
);
});
afterEach(() => {
@@ -46,12 +50,17 @@ it("writes the desired boolean and updates the switch", async () => {
await user.click(toggle);
await waitFor(() =>
expect(api.setModelContentPermission).toHaveBeenCalledWith(true, "trace-id"),
expect(api.setModelContentPermission).toHaveBeenCalledWith(
true,
"trace-id",
),
);
expect(toggle.getAttribute("aria-checked")).toBe("true");
});
it("states that generated-memory search is independent", async () => {
render(<ModelContentConsent onClose={vi.fn()} />);
expect(await screen.findByText(/已生成记忆的搜索和读取不受影响/)).toBeTruthy();
expect(
await screen.findByText(/已生成记忆的搜索和读取不受影响/),
).toBeTruthy();
});
+1 -3
View File
@@ -19,9 +19,7 @@ export function ModelContentConsent({
useEffect(() => {
const before = document.activeElement;
panel.current
?.querySelector<HTMLElement>("button:not(:disabled)")
?.focus();
panel.current?.querySelector<HTMLElement>("button:not(:disabled)")?.focus();
return () => {
if (before instanceof HTMLElement && before.isConnected) before.focus();
};
+2 -7
View File
@@ -800,9 +800,7 @@ describe("ModelSettings", () => {
profiles = [preset];
render(<ModelSettings />);
expect(
screen.getByRole("region", { name: "预配置模型服务" }),
).toBeTruthy();
expect(screen.getByRole("region", { name: "预配置模型服务" })).toBeTruthy();
// 贴一个 key 就能用的时候,没理由让人先面对协议、Base URL 和超时。
expect(screen.queryByLabelText("协议")).toBeNull();
expect(screen.getByRole("button", { name: "自定义一个模型" })).toBeTruthy();
@@ -862,10 +860,7 @@ describe("ModelSettings", () => {
const user = userEvent.setup();
render(<ModelSettings />);
await user.type(
screen.getByLabelText("粘贴 API Key"),
"preset-key",
);
await user.type(screen.getByLabelText("粘贴 API Key"), "preset-key");
await user.click(screen.getByRole("button", { name: "保存 key" }));
expect(update).toHaveBeenCalledWith("preset-1", {
+10 -20
View File
@@ -648,9 +648,7 @@ export function ModelSettings({
}
} catch {
if (alive.current) {
setSaveError(
"配置尚未保存成功,已填写内容保留,可核对后重试。",
);
setSaveError("配置尚未保存成功,已填写内容保留,可核对后重试。");
}
} finally {
flight.current = false;
@@ -826,14 +824,13 @@ export function ModelSettings({
<div className="model-saved-heading">
<Check size={18} aria-hidden="true" />
<strong>
{isReady(savedProfile) &&
savedProfile.is_active
{isReady(savedProfile) && savedProfile.is_active
? "已保存,已启用"
: isReady(savedProfile)
? "已保存,连接通过"
: models.probeFor(savedProfile)
? "已保存,测试未通过"
: "已保存,待测试"}
? "已保存,连接通过"
: models.probeFor(savedProfile)
? "已保存,测试未通过"
: "已保存,待测试"}
</strong>
</div>
<h3>{savedProfile.name}</h3>
@@ -917,7 +914,7 @@ export function ModelSettings({
刷新配置列表
</button>
)}
<label>
<div className="model-form-field">
服务商预设
<OptionMenu
ariaLabel="服务商预设"
@@ -954,7 +951,7 @@ export function ModelSettings({
只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。
请对照服务商的模型列表核对后再保存。
</small>
</label>
</div>
<label>
协议
<select
@@ -1245,11 +1242,7 @@ export function ModelSettings({
)}
<button
type="button"
disabled={
locked ||
editChanged ||
(!editing && unknownCreation)
}
disabled={locked || editChanged || (!editing && unknownCreation)}
onClick={() => void persist(true)}
>
{setupPhase === "testing"
@@ -1350,10 +1343,7 @@ export function ModelSettings({
<>
<button
type="button"
disabled={
locked ||
!isReady(profile)
}
disabled={locked || !isReady(profile)}
onClick={() => models.activate(profile.profile_id)}
>
设为当前模型
+4 -6
View File
@@ -332,9 +332,7 @@ describe("OnboardingFlow S1.3", () => {
connectForOnboarding: vi.fn(
() =>
new Promise<
Awaited<
ReturnType<ImportSourcesController["connectForOnboarding"]>
>
Awaited<ReturnType<ImportSourcesController["connectForOnboarding"]>>
>((_resolve, reject) => {
rejectImport = reject;
}),
@@ -363,9 +361,9 @@ describe("OnboardingFlow S1.3", () => {
});
await act(async () => rejectImport(new Error("response lost")));
expect(
screen.getByRole("region", { name: "模型步骤边界" }),
).toBe(modelBoundary);
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBe(
modelBoundary,
);
await user.click(screen.getByRole("button", { name: "返回导入" }));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
+2 -3
View File
@@ -188,6 +188,7 @@ export function OnboardingFlow({
accepted,
startingImport,
imports.sources,
modelSourceIds,
state.selected_source_ids,
]);
useEffect(() => {
@@ -431,7 +432,6 @@ export function OnboardingFlow({
await persistFinish(destination);
flight.current = false;
if (alive.current) setBusy(null);
},
[persistFinish],
);
@@ -616,8 +616,7 @@ export function OnboardingFlow({
: imports.sources;
const defaultDraft = defaultConnectionDraft(reviewSources, sourceIds);
const activeDraft =
connectionTouched ||
connectionDraft.hostSourceIds.length > 0
connectionTouched || connectionDraft.hostSourceIds.length > 0
? connectionDraft
: defaultDraft;
return (
@@ -1,7 +1,7 @@
import type {
ModelProbeResult,
ModelProfile,
ModelProfileInput,
ModelProbeResult,
} from "@one-memory/contracts";
import { useRef, useState } from "react";
import { getModelProfiles, setModelContentPermission } from "./api";
@@ -251,19 +251,12 @@ it("discloses that testing does not send historical conversations", () => {
expect(screen.getByText(/测试连接只发送测试内容/)).toBeTruthy();
expect(screen.getByText(/已导入 37 条对话/)).toBeTruthy();
expect(
screen.getByText(/启用时会打开全局模型内容权限/),
).toBeTruthy();
expect(screen.getByText(/启用时会打开全局模型内容权限/)).toBeTruthy();
});
it("does not expose an extra backlog selector in the first-run model page", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
/>,
);
render(<OnboardingModelStep {...p} capability={{ saveAndEnable: true }} />);
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
expect(screen.getByText(/先测试模型连通性/)).toBeTruthy();
@@ -70,9 +70,7 @@ it("OBS-21: names a manually added memory 手动添加, not the internal source
detail={{
...detail,
memory: { ...detail.memory, origin: "manual" },
evidence: [
{ ...detail.evidence[0], source_id: "one-memory-manual" },
],
evidence: [{ ...detail.evidence[0], source_id: "one-memory-manual" }],
}}
sources={[]}
onChanged={vi.fn()}
@@ -416,7 +414,9 @@ it("reports a definite undo refusal as not submitted, not as an unknown write",
fireEvent.click(screen.getByText("更多管理"));
fireEvent.click(screen.getByRole("button", { name: "撤销上次修改" }));
expect((await screen.findByRole("alert")).textContent).toContain("撤销未提交");
expect((await screen.findByRole("alert")).textContent).toContain(
"撤销未提交",
);
expect(screen.queryByText(/操作尚未确认/)).toBeNull();
expect(trace.done).toHaveBeenLastCalledWith("failed", {
reason: "memory_undo_failed",
+2 -5
View File
@@ -173,9 +173,7 @@ export function SelectedMemoryDetail({
} catch (reason) {
const refused = isDefiniteRefusal(reason);
trace.done(refused ? "failed" : "unknown", {
reason: refused
? "memory_delete_failed"
: "memory_delete_unconfirmed",
reason: refused ? "memory_delete_failed" : "memory_delete_unconfirmed",
});
if (alive.current)
setError(
@@ -445,8 +443,7 @@ export function SelectedMemoryDetail({
{detail.evidence.map((item) => (
<section key={item.evidence_id}>
<small>
{sourceNameOr(sources, item.source_id, "来源待核对")}{" "}
·{" "}
{sourceNameOr(sources, item.source_id, "来源待核对")} ·{" "}
{item.written_by === "person"
? "你"
: item.written_by === "agent"
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff. Show more