Merge pull request #253 from Tencent-Hunyuan/feat/memhub-mode-evolve
feat(memhub): memory mode (lite/pro/ultra), evolve and nightly schedule (B, stacked on #252)
This commit is contained in:
31 files changed
+3430
-197
No files matched your search
+2
-2
@@ -13,8 +13,8 @@
|
||||
接口先补 SDK。Runtime writers 停止后的 App 启动迁移可直接操作底层存储,
|
||||
但不得把迁移专用方法加入公共 Client。
|
||||
3. MemHub **不存 memory 实体**;**可以存 `memory_id`** 做组织/回执。原文进入 **SDK raw**。
|
||||
4. SDK raw 只保留滚动三天;一条 raw 对应一个完整 turn,只含真人 user 与
|
||||
assistant,tool 不持久化。提取后的高层 memory 不受该窗口影响。
|
||||
4. SDK raw 全部保留,任何模式(lite / pro / ultra)都不删除;一条 raw 对应一个
|
||||
完整 turn,只含真人 user 与 assistant,tool 不持久化。
|
||||
5. `source_raw_ids` 是 SDK 内部溯源关系,不是产品 Evidence 合同;记忆详情不提供
|
||||
“原始依据”或按 raw ID 展开原文的入口。
|
||||
|
||||
|
||||
@@ -315,6 +315,8 @@ describe("catalog exit codes", () => {
|
||||
"models",
|
||||
"memories",
|
||||
"profile",
|
||||
"settings",
|
||||
"evolve",
|
||||
"graph-width",
|
||||
"host",
|
||||
"recall",
|
||||
|
||||
@@ -188,6 +188,18 @@ import {
|
||||
removeManagedCliLink,
|
||||
rollbackManagedCliLink,
|
||||
} from "./managed-cli-link.js";
|
||||
import {
|
||||
type EvolveDocument,
|
||||
evolveExitCode,
|
||||
type ModeDocument,
|
||||
modeExitCode,
|
||||
parseEvolveCommand,
|
||||
parseSettingsCommand,
|
||||
renderEvolveText,
|
||||
renderModeText,
|
||||
runEvolveCommand,
|
||||
runModeCommand,
|
||||
} from "./memory-mode-flow.js";
|
||||
import {
|
||||
addMemory,
|
||||
DEFAULT_USER_ID,
|
||||
@@ -325,6 +337,8 @@ export interface RunControls {
|
||||
watchSignal?: AbortSignal;
|
||||
watchWait?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
||||
watchNow?: () => string;
|
||||
/** `memhub evolve --wait` polls on this; tests inject an instant one. */
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
readStdin?: () => Promise<Buffer | null>;
|
||||
stdinIsTTY?: boolean;
|
||||
/** Injectable onboarding prompter so the wizard can be driven in tests
|
||||
@@ -2228,6 +2242,55 @@ async function runCommand(
|
||||
);
|
||||
return outcome.state === "committed" ? EXIT_OK : EXIT_FAILED;
|
||||
}
|
||||
if (named === "settings") {
|
||||
const parsed = parseSettingsCommand(arguments_.slice(1));
|
||||
if (!parsed) return usage();
|
||||
const connected = await connectRuntime(environment);
|
||||
if (!connected.ok) {
|
||||
const document: ModeDocument = {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "settings mode",
|
||||
state: "unavailable",
|
||||
code: connected.code,
|
||||
};
|
||||
emit(asJson ? `${JSON.stringify(document)}\n` : renderModeText(document));
|
||||
return connected.exitCode;
|
||||
}
|
||||
const document = await runModeCommand(
|
||||
parsed,
|
||||
connected.reader,
|
||||
connected.operations,
|
||||
);
|
||||
emit(asJson ? `${JSON.stringify(document)}\n` : renderModeText(document));
|
||||
return modeExitCode(document);
|
||||
}
|
||||
if (named === "evolve") {
|
||||
const parsed = parseEvolveCommand(arguments_.slice(1));
|
||||
if (!parsed) return usage();
|
||||
const connected = await connectRuntime(environment);
|
||||
if (!connected.ok) {
|
||||
const document: EvolveDocument = {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "evolve",
|
||||
state: "unavailable",
|
||||
code: connected.code,
|
||||
};
|
||||
emit(
|
||||
asJson ? `${JSON.stringify(document)}\n` : renderEvolveText(document),
|
||||
);
|
||||
return connected.exitCode;
|
||||
}
|
||||
const document = await runEvolveCommand(parsed, {
|
||||
userId: userIdParameters(userId).user_id,
|
||||
reader: connected.reader,
|
||||
operations: connected.operations,
|
||||
sleep: controls.sleep,
|
||||
});
|
||||
emit(asJson ? `${JSON.stringify(document)}\n` : renderEvolveText(document));
|
||||
return evolveExitCode(document);
|
||||
}
|
||||
if (named === "graph-width") {
|
||||
const parsed = parseGraphWidthCommand(arguments_.slice(1));
|
||||
if (!parsed) return usage();
|
||||
@@ -6814,7 +6877,13 @@ const OWN_DATA_DIR_COMMANDS = new Set([
|
||||
/** Remove one `--data-dir <absolute path>` from argv; null when it is
|
||||
* repeated, missing its value, or not an absolute path. */
|
||||
/** Memory commands that honour the global `--user-id`. */
|
||||
const USER_ID_COMMANDS = new Set(["memories", "recall", "profile", "write"]);
|
||||
const USER_ID_COMMANDS = new Set([
|
||||
"memories",
|
||||
"recall",
|
||||
"profile",
|
||||
"write",
|
||||
"evolve",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Take `--user-id <id>` out of the arguments. The default user (`local`) is
|
||||
|
||||
@@ -448,6 +448,43 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
summary:
|
||||
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。",
|
||||
usage: "memhub settings mode [lite|pro|ultra] [--yes] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "mode [lite|pro|ultra]",
|
||||
summary: "不带模式只显示当前模式;带模式时先显示切换计划。",
|
||||
},
|
||||
CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "evolve",
|
||||
summary:
|
||||
"立即为一个用户运行记忆进化(System 2,仅 ultra 模式)。在后台运行并返回运行 ID;--wait 等待结果。同一用户同时只运行一次;每晚的自动进化由 Runtime 按设置安排。",
|
||||
usage:
|
||||
"memhub evolve [--user-id <id>] [--agent-id <id>] [--max-batches <n>] [--wait] [--json]",
|
||||
options: [
|
||||
USER_ID_OPTION,
|
||||
{
|
||||
flag: "--agent-id <id>",
|
||||
summary:
|
||||
"进化哪个 agent 的记忆,默认 all-apps(MemHub 写入记忆用的 agent)。",
|
||||
},
|
||||
{
|
||||
flag: "--max-batches <n>",
|
||||
summary: "每轮最多处理的批次数(1–1000),默认由 SDK 决定。",
|
||||
},
|
||||
{ flag: "--wait", summary: "等待这次进化结束再返回。" },
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "graph-width",
|
||||
summary:
|
||||
|
||||
@@ -76,6 +76,8 @@ const COMMANDS = [
|
||||
"models",
|
||||
"memories",
|
||||
"profile",
|
||||
"settings",
|
||||
"evolve",
|
||||
"graph-width",
|
||||
"host",
|
||||
"recall",
|
||||
|
||||
@@ -18,7 +18,12 @@ const READ_ONLY_PATHS = [
|
||||
"/api/v2/memory-library",
|
||||
"/api/v2/user-profile",
|
||||
"/api/v2/user-profile/versions",
|
||||
"/api/v1/settings/memory-mode",
|
||||
"/api/v2/evolve",
|
||||
] as const;
|
||||
/** One evolve run's receipt (`memhub evolve --wait`). */
|
||||
const EVOLVE_RUN =
|
||||
/^\/api\/v2\/evolve\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const SDK_MEMORY_DETAIL = /^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}$/;
|
||||
/** Status of one `POST /api/v2/memories` addition, by its request id. */
|
||||
const MEMORY_ADDITION =
|
||||
@@ -221,6 +226,7 @@ export function isReadOnlyPath(path: string): boolean {
|
||||
if (IMPORT_JOB.test(path)) return true;
|
||||
if (OPERATION_RECEIPT.test(path)) return true;
|
||||
if (MEMORY_ADDITION.test(path)) return true;
|
||||
if (EVOLVE_RUN.test(path)) return true;
|
||||
if (!SDK_MEMORY_DETAIL.test(path)) return false;
|
||||
const segment = path.slice("/api/v2/memories/".length);
|
||||
return !(RESERVED_MEMORY_SEGMENTS as readonly string[]).includes(segment);
|
||||
|
||||
@@ -26,6 +26,9 @@ const SOURCE_IMPORT_JOB_PATH = "/api/v1/import-jobs";
|
||||
const IMPORT_JOB_ACTION_PATH =
|
||||
/^\/api\/v1\/import-jobs\/[A-Za-z0-9][\w.-]{0,127}\/(?:pause|resume|cancel)$/;
|
||||
const GRAPH_WIDTH_PATH = "/api/v1/graph-width";
|
||||
/** `memhub evolve` starts a run; `memhub settings mode <m> --yes` sets the mode. */
|
||||
const EVOLVE_PATH = "/api/v2/evolve";
|
||||
const MEMORY_MODE_PATH = "/api/v1/settings/memory-mode";
|
||||
const ONBOARDING_COMPLETE_PATH = "/api/v1/onboarding/complete";
|
||||
const MAX_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
@@ -97,6 +100,7 @@ export function createCoreOperations(attachment: {
|
||||
!SOURCE_AUTHORIZE_PATH.test(path) &&
|
||||
!SOURCE_REVOKE_PATH.test(path) &&
|
||||
path !== GRAPH_WIDTH_PATH &&
|
||||
path !== EVOLVE_PATH &&
|
||||
path !== ONBOARDING_COMPLETE_PATH &&
|
||||
!MODEL_PROFILE_ACTIVATE.test(path)
|
||||
) {
|
||||
@@ -132,7 +136,10 @@ export function createCoreOperations(attachment: {
|
||||
body: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
if (!SDK_MEMORY_PATH.test(path) || path.includes("?")) {
|
||||
if (
|
||||
path !== MEMORY_MODE_PATH &&
|
||||
(!SDK_MEMORY_PATH.test(path) || path.includes("?"))
|
||||
) {
|
||||
throw new Error("core_operation_path_not_allowed");
|
||||
}
|
||||
let response: { status: number; data: unknown };
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
import { modeEffects, parseSettingsCommand } from "./memory-mode-flow.js";
|
||||
import { composeStatusReport, renderStatusText } from "./status-report.js";
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
create: vi.fn(() => transport),
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
isAxiosError: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
inspectRuntimeStatus: vi.fn(),
|
||||
discoverRuntimeAttachment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
|
||||
...runtime,
|
||||
}));
|
||||
|
||||
const RUN_ID = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
function modeState(mode: string, configured = mode) {
|
||||
return {
|
||||
mode,
|
||||
configured_mode: configured,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: { state: "idle", users: {} },
|
||||
};
|
||||
}
|
||||
|
||||
function receipt(state: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
run_id: RUN_ID,
|
||||
user_id: "wife",
|
||||
agent_id: "all-apps",
|
||||
state,
|
||||
trigger: "manual",
|
||||
started_at: "2026-11-01T03:00:00Z",
|
||||
rounds: state === "running" ? 0 : 2,
|
||||
tasks_processed: state === "running" ? 0 : 5,
|
||||
batch_failures: 0,
|
||||
capped: false,
|
||||
elapsed_ms: 10,
|
||||
results: { schemas: ["never printed"] },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const method of Object.values(transport)) method.mockReset();
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "running",
|
||||
code: "runtime_verified",
|
||||
instanceId: "instance-1",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
version: "1.2.3",
|
||||
});
|
||||
runtime.discoverRuntimeAttachment.mockResolvedValue({
|
||||
instanceId: "instance-1",
|
||||
apiToken: "synthetic-runtime-token",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
servicePid: 4242,
|
||||
health: {
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
version: "1.2.3",
|
||||
instance_id: "instance-1",
|
||||
},
|
||||
});
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/settings/memory-mode"
|
||||
? { status: 200, data: modeState("pro") }
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
});
|
||||
|
||||
describe("memhub settings mode", () => {
|
||||
it("shows the current mode without changing it", async () => {
|
||||
const result = await capturedRun(["settings", "mode", "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout).mode.mode).toBe("pro");
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints the plan and changes nothing without --yes", async () => {
|
||||
const result = await capturedRun(["settings", "mode", "lite"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
expect(result.stdout).toContain("更高模式生成的记忆仍保存着");
|
||||
expect(result.stdout).toContain("memhub settings mode lite --yes");
|
||||
const json = JSON.parse(
|
||||
(await capturedRun(["settings", "mode", "ultra", "--json"])).stdout,
|
||||
);
|
||||
expect(json.state).toBe("preview");
|
||||
expect(json.applied).toBe(false);
|
||||
expect(json.effects).toEqual(["existing_memories_added_to_graph"]);
|
||||
});
|
||||
|
||||
it("changes the mode with --yes and reports what changed", async () => {
|
||||
transport.put.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
...modeState("lite"),
|
||||
previous_mode: "pro",
|
||||
effects: ["higher_mode_memories_hidden"],
|
||||
},
|
||||
});
|
||||
const result = await capturedRun(["settings", "mode", "lite", "--yes"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.put).toHaveBeenCalledWith(
|
||||
"/api/v1/settings/memory-mode",
|
||||
{ mode: "lite" },
|
||||
undefined,
|
||||
);
|
||||
expect(result.stdout).toContain("已切换");
|
||||
expect(result.stdout).toContain("原始对话:全部保留");
|
||||
});
|
||||
|
||||
it("does nothing for the mode already set, and refuses bad input", async () => {
|
||||
const same = await capturedRun(["settings", "mode", "pro", "--yes"]);
|
||||
expect(same.code).toBe(0);
|
||||
expect(same.stdout).toContain("已经是这个模式");
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
for (const bad of [
|
||||
["settings"],
|
||||
["settings", "mode", "max"],
|
||||
["settings", "mode", "--yes"],
|
||||
["settings", "mode", "lite", "pro"],
|
||||
["settings", "language", "en"],
|
||||
]) {
|
||||
expect((await capturedRun(bad)).code, bad.join(" ")).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("plans the same effects Core reports", () => {
|
||||
expect(modeEffects("lite", "pro")).toEqual([
|
||||
"earlier_raw_not_extracted",
|
||||
"lite_search_stops",
|
||||
]);
|
||||
expect(modeEffects("ultra", "lite")).toEqual([
|
||||
"higher_mode_memories_hidden",
|
||||
"evolve_stopped",
|
||||
]);
|
||||
expect(modeEffects("pro", "pro")).toEqual([]);
|
||||
expect(parseSettingsCommand(["mode", "ultra", "--yes"])).toEqual({
|
||||
kind: "set",
|
||||
mode: "ultra",
|
||||
confirmed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("memhub evolve", () => {
|
||||
it("starts a run for the named user and returns at once", async () => {
|
||||
transport.post.mockResolvedValue({ status: 202, data: receipt("running") });
|
||||
const result = await capturedRun([
|
||||
"evolve",
|
||||
"--user-id",
|
||||
"wife",
|
||||
"--agent-id",
|
||||
"all-apps",
|
||||
"--max-batches",
|
||||
"3",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v2/evolve",
|
||||
{ user_id: "wife", agent_id: "all-apps", max_batches: 3 },
|
||||
undefined,
|
||||
);
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.state).toBe("running");
|
||||
expect(document.run.results).toBeUndefined();
|
||||
});
|
||||
|
||||
it("follows the run with --wait until it ends", async () => {
|
||||
transport.post.mockResolvedValue({ status: 202, data: receipt("running") });
|
||||
const reads = [receipt("running"), receipt("succeeded")];
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === `/api/v2/evolve/${RUN_ID}`
|
||||
? { status: 200, data: reads.shift() }
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
const result = await capturedRun(["evolve", "--wait"], {
|
||||
sleep: async () => undefined,
|
||||
});
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v2/evolve",
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
expect(result.stdout).toContain("进化完成");
|
||||
expect(result.stdout).toContain("2 轮");
|
||||
});
|
||||
|
||||
it("names the mode when Core refuses outside ultra", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "memory_mode_not_ultra" },
|
||||
});
|
||||
const result = await capturedRun(["evolve"]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stdout).toContain("memhub settings mode ultra --yes");
|
||||
expect((await capturedRun(["evolve", "--max-batches", "0"])).code).toBe(2);
|
||||
expect((await capturedRun(["evolve", "--now"])).code).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("status pipeline", () => {
|
||||
it("reports pipeline.mode and the last evolve from memory-processing", async () => {
|
||||
const processing = {
|
||||
pending: 0,
|
||||
fresh_pending: 0,
|
||||
backlog_pending: 0,
|
||||
outside_window_pending: 0,
|
||||
running: 0,
|
||||
waiting_for_model: 0,
|
||||
completed: 3,
|
||||
failed: 0,
|
||||
blocked_stages: [],
|
||||
pipeline: {
|
||||
mode: "ultra",
|
||||
configured_mode: "ultra",
|
||||
forced_by_env: false,
|
||||
last_evolve_at: "2026-11-01T03:00:05+08:00",
|
||||
graph_build: { state: "succeeded", users: {} },
|
||||
evolve: {
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
running: [],
|
||||
users: {
|
||||
local: {
|
||||
last_run_at: "2026-11-01T03:00:05+08:00",
|
||||
last_result: { state: "succeeded", tasks_processed: 4 },
|
||||
last_error: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const report = await composeStatusReport({
|
||||
dataDir: "/tmp/isolated",
|
||||
runtime: { state: "running", code: "ok", host: "127.0.0.1", port: 1 },
|
||||
background: null,
|
||||
reader: (async (path: string) =>
|
||||
path === "/api/v1/memory-processing"
|
||||
? { state: "ok", data: processing }
|
||||
: { state: "unavailable", code: "x" }) as never,
|
||||
});
|
||||
expect(report.pipeline?.mode).toBe("ultra");
|
||||
expect(report.pipeline?.last_evolve_at).toBe("2026-11-01T03:00:05+08:00");
|
||||
expect(report.pipeline?.evolve_users.local?.state).toBe("succeeded");
|
||||
const text = renderStatusText(report, "MemHub");
|
||||
expect(text).toContain("记忆模式:ultra");
|
||||
expect(text).toContain("每日 03:00 自动进化");
|
||||
});
|
||||
});
|
||||
|
||||
async function capturedRun(
|
||||
arguments_: string[],
|
||||
controls: Parameters<typeof run>[2] = {},
|
||||
) {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const stdoutSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
return {
|
||||
code: await run(
|
||||
arguments_,
|
||||
{ ONE_MEMORY_DATA_DIR: "/synthetic/data" },
|
||||
controls,
|
||||
),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* `memhub settings mode` and `memhub evolve`.
|
||||
*
|
||||
* The mode (lite / pro / ultra) decides what the SDK does with every
|
||||
* conversation, and switching it migrates nothing: what one mode made is not
|
||||
* shown by a lower one, and raw kept by lite is not extracted until it is
|
||||
* imported again. So a change prints its plan first and needs `--yes`, like
|
||||
* every other command whose effect a person cannot simply take back.
|
||||
*
|
||||
* `memhub evolve` starts System 2 (ultra only) and, with `--wait`, follows
|
||||
* the run until it ends.
|
||||
*/
|
||||
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations, CorePutOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
export const MEMORY_MODES = ["lite", "pro", "ultra"] as const;
|
||||
export type MemoryMode = (typeof MEMORY_MODES)[number];
|
||||
|
||||
export type ModeEffect =
|
||||
| "earlier_raw_not_extracted"
|
||||
| "higher_mode_memories_hidden"
|
||||
| "existing_memories_added_to_graph"
|
||||
| "evolve_stopped"
|
||||
| "lite_search_stops"
|
||||
| "graph_backfill_already_running";
|
||||
|
||||
const MODE_SUMMARY: Record<MemoryMode, string> = {
|
||||
lite: "lite:不调用模型,保留全部原始对话,只在原始对话里检索",
|
||||
pro: "pro:模型从对话中提取记忆(System 1)",
|
||||
ultra: "ultra:pro + 每晚自动进化(System 2,使用知识图谱)",
|
||||
};
|
||||
|
||||
const EFFECT_TEXT: Record<ModeEffect, string> = {
|
||||
earlier_raw_not_extracted:
|
||||
"lite 下已处理完的对话不会再被提取;只有尚未处理的和之后的新对话会由模型整理成记忆,检索立即改为记忆检索",
|
||||
higher_mode_memories_hidden:
|
||||
"更高模式生成的记忆仍保存着,但在这个模式下不会显示",
|
||||
existing_memories_added_to_graph:
|
||||
"已有记忆会在后台加入知识图谱(每个用户一次)",
|
||||
evolve_stopped: "每晚的自动进化(System 2)停止",
|
||||
lite_search_stops: "lite 只在原始对话里的检索立即停止,改为检索记忆",
|
||||
graph_backfill_already_running: "知识图谱的补建已在进行中",
|
||||
};
|
||||
|
||||
/** The same rule Core applies (`memory_mode_effects`), for the plan. */
|
||||
export function modeEffects(
|
||||
previous: MemoryMode,
|
||||
next: MemoryMode,
|
||||
): ModeEffect[] {
|
||||
if (previous === next) return [];
|
||||
const rank: Record<MemoryMode, number> = { lite: 0, pro: 1, ultra: 2 };
|
||||
const effects: ModeEffect[] = [];
|
||||
if (previous === "lite") {
|
||||
effects.push("earlier_raw_not_extracted", "lite_search_stops");
|
||||
}
|
||||
if (rank[next] < rank[previous]) effects.push("higher_mode_memories_hidden");
|
||||
if (previous === "ultra") effects.push("evolve_stopped");
|
||||
if (next === "ultra") effects.push("existing_memories_added_to_graph");
|
||||
return effects;
|
||||
}
|
||||
|
||||
export type ModeCommand =
|
||||
| { kind: "show" }
|
||||
| { kind: "set"; mode: MemoryMode; confirmed: boolean };
|
||||
|
||||
/** `settings mode [lite|pro|ultra] [--yes]` after `settings`; null = usage. */
|
||||
export function parseSettingsCommand(
|
||||
values: readonly string[],
|
||||
): ModeCommand | null {
|
||||
const rest = values.filter((value) => value !== "--json");
|
||||
if (rest[0] !== "mode") return null;
|
||||
const tail = rest.slice(1);
|
||||
const confirmed = tail.includes("--yes");
|
||||
const positional = tail.filter((value) => value !== "--yes");
|
||||
if (tail.filter((value) => value === "--yes").length > 1) return null;
|
||||
if (positional.length === 0) return confirmed ? null : { kind: "show" };
|
||||
if (positional.length !== 1) return null;
|
||||
const mode = positional[0] as MemoryMode;
|
||||
if (!(MEMORY_MODES as readonly string[]).includes(mode)) return null;
|
||||
return { kind: "set", mode, confirmed };
|
||||
}
|
||||
|
||||
export interface ModeState {
|
||||
mode: MemoryMode;
|
||||
configured_mode: MemoryMode;
|
||||
forced_by_env: boolean;
|
||||
graph_build?: { state?: string } | null;
|
||||
previous_mode?: MemoryMode;
|
||||
effects?: ModeEffect[];
|
||||
}
|
||||
|
||||
function modeState(value: unknown): ModeState | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const item = value as Record<string, unknown>;
|
||||
if (
|
||||
!(MEMORY_MODES as readonly unknown[]).includes(item.mode) ||
|
||||
!(MEMORY_MODES as readonly unknown[]).includes(item.configured_mode) ||
|
||||
typeof item.forced_by_env !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return item as unknown as ModeState;
|
||||
}
|
||||
|
||||
export type ModeDocument = {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "settings mode";
|
||||
} & (
|
||||
| { state: "ok"; mode: ModeState }
|
||||
| {
|
||||
state: "preview";
|
||||
mode: ModeState;
|
||||
target: MemoryMode;
|
||||
effects: ModeEffect[];
|
||||
applied: false;
|
||||
next_step: string;
|
||||
}
|
||||
| { state: "unchanged"; mode: ModeState }
|
||||
| { state: "committed"; mode: ModeState; effects: ModeEffect[] }
|
||||
| { state: "rejected" | "unknown" | "unavailable"; code: string }
|
||||
);
|
||||
|
||||
const BASE = {
|
||||
schema_version: 1 as const,
|
||||
product: PRODUCT,
|
||||
command: "settings mode" as const,
|
||||
};
|
||||
|
||||
export async function runModeCommand(
|
||||
command: ModeCommand,
|
||||
reader: CoreReader,
|
||||
operations: CorePutOperations,
|
||||
): Promise<ModeDocument> {
|
||||
const read = await reader<unknown>("/api/v1/settings/memory-mode");
|
||||
if (read.state !== "ok") {
|
||||
return { ...BASE, state: "unavailable", code: read.code };
|
||||
}
|
||||
const current = modeState(read.data);
|
||||
if (current === null) {
|
||||
return { ...BASE, state: "unavailable", code: "response_invalid" };
|
||||
}
|
||||
if (command.kind === "show") return { ...BASE, state: "ok", mode: current };
|
||||
if (current.configured_mode === command.mode) {
|
||||
return { ...BASE, state: "unchanged", mode: current };
|
||||
}
|
||||
if (!command.confirmed) {
|
||||
return {
|
||||
...BASE,
|
||||
state: "preview",
|
||||
mode: current,
|
||||
target: command.mode,
|
||||
effects: modeEffects(current.mode, command.mode),
|
||||
applied: false,
|
||||
next_step: `${PRODUCT} settings mode ${command.mode} --yes`,
|
||||
};
|
||||
}
|
||||
const result = await operations.put<unknown>("/api/v1/settings/memory-mode", {
|
||||
mode: command.mode,
|
||||
});
|
||||
if (result.state !== "committed") {
|
||||
return { ...BASE, state: result.state, code: result.code };
|
||||
}
|
||||
const changed = modeState(result.data);
|
||||
if (changed === null) {
|
||||
return { ...BASE, state: "unknown", code: "response_invalid" };
|
||||
}
|
||||
return {
|
||||
...BASE,
|
||||
state: "committed",
|
||||
mode: changed,
|
||||
effects: changed.effects ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function modeExitCode(document: ModeDocument): number {
|
||||
return ["ok", "preview", "unchanged", "committed"].includes(document.state)
|
||||
? 0
|
||||
: 1;
|
||||
}
|
||||
|
||||
export function renderModeText(document: ModeDocument): string {
|
||||
if ("code" in document) {
|
||||
return `记忆模式未能${document.state === "unavailable" ? "读取" : "修改"}:${document.code}\n`;
|
||||
}
|
||||
const mode = document.mode;
|
||||
const lines = [`当前模式:${MODE_SUMMARY[mode.mode]}`];
|
||||
if (mode.forced_by_env) {
|
||||
lines.push(
|
||||
`设置里是 ${mode.configured_mode};环境变量 ONE_MEMORY_GRAPH_STORE 强制使用 ultra。`,
|
||||
);
|
||||
}
|
||||
lines.push("原始对话:全部保留(任何模式都不删除)");
|
||||
if (document.state === "unchanged") {
|
||||
lines.push("已经是这个模式,未做改动。");
|
||||
}
|
||||
if (document.state === "preview") {
|
||||
lines.push(`将切换到:${MODE_SUMMARY[document.target]}`);
|
||||
for (const effect of document.effects)
|
||||
lines.push(`- ${EFFECT_TEXT[effect]}`);
|
||||
lines.push(`未执行任何改动。确认后运行:${document.next_step}`);
|
||||
}
|
||||
if (document.state === "committed") {
|
||||
lines.push("已切换。");
|
||||
for (const effect of document.effects)
|
||||
lines.push(`- ${EFFECT_TEXT[effect]}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export type EvolveCommand = {
|
||||
agentId?: string;
|
||||
maxBatches?: number;
|
||||
wait: boolean;
|
||||
};
|
||||
|
||||
/** `evolve [--agent-id <id>] [--max-batches <n>] [--wait]`; null = usage. */
|
||||
export function parseEvolveCommand(
|
||||
values: readonly string[],
|
||||
): EvolveCommand | null {
|
||||
const parsed: EvolveCommand = { wait: false };
|
||||
const rest = values.filter((value) => value !== "--json");
|
||||
for (let index = 0; index < rest.length; index += 1) {
|
||||
const flag = rest[index];
|
||||
if (flag === "--wait" && !parsed.wait) {
|
||||
parsed.wait = true;
|
||||
} else if (flag === "--agent-id" && parsed.agentId === undefined) {
|
||||
const value = rest[index + 1];
|
||||
if (!value || !/^[A-Za-z0-9._:-]{1,128}$/.test(value)) return null;
|
||||
parsed.agentId = value;
|
||||
index += 1;
|
||||
} else if (flag === "--max-batches" && parsed.maxBatches === undefined) {
|
||||
const value = Number(rest[index + 1]);
|
||||
if (!Number.isInteger(value) || value < 1 || value > 1000) return null;
|
||||
parsed.maxBatches = value;
|
||||
index += 1;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export type EvolveDocument = {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "evolve";
|
||||
} & (
|
||||
| { state: "running" | "succeeded" | "failed"; run: Record<string, unknown> }
|
||||
| { state: "rejected" | "unknown" | "unavailable"; code: string }
|
||||
);
|
||||
|
||||
const RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
function evolveRun(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const run = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof run.run_id !== "string" ||
|
||||
!RUN_ID.test(run.run_id) ||
|
||||
!["running", "succeeded", "failed"].includes(String(run.state))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function runEvolveCommand(
|
||||
command: EvolveCommand,
|
||||
options: {
|
||||
userId?: string;
|
||||
reader: CoreReader;
|
||||
operations: CoreOperations;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
pollMs?: number;
|
||||
},
|
||||
): Promise<EvolveDocument> {
|
||||
const base = {
|
||||
schema_version: 1 as const,
|
||||
product: PRODUCT,
|
||||
command: "evolve" as const,
|
||||
};
|
||||
const body: Record<string, unknown> = {};
|
||||
if (options.userId !== undefined) body.user_id = options.userId;
|
||||
if (command.agentId !== undefined) body.agent_id = command.agentId;
|
||||
if (command.maxBatches !== undefined) body.max_batches = command.maxBatches;
|
||||
const started = await options.operations.post<unknown>(
|
||||
"/api/v2/evolve",
|
||||
body,
|
||||
);
|
||||
if (started.state !== "committed") {
|
||||
return { ...base, state: started.state, code: started.code };
|
||||
}
|
||||
let run = evolveRun(started.data);
|
||||
if (run === null)
|
||||
return { ...base, state: "unknown", code: "response_invalid" };
|
||||
const sleep =
|
||||
options.sleep ??
|
||||
((ms: number) => new Promise((done) => setTimeout(done, ms)));
|
||||
while (command.wait && run.state === "running") {
|
||||
await sleep(options.pollMs ?? 2_000);
|
||||
const read = await options.reader<unknown>(
|
||||
`/api/v2/evolve/${run.run_id}`,
|
||||
options.userId === undefined ? {} : { user_id: options.userId },
|
||||
);
|
||||
if (read.state !== "ok") {
|
||||
return { ...base, state: "unavailable", code: read.code };
|
||||
}
|
||||
const next = evolveRun(read.data);
|
||||
if (next === null)
|
||||
return { ...base, state: "unknown", code: "response_invalid" };
|
||||
run = next;
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: run.state as "running" | "succeeded" | "failed",
|
||||
run: projectRun(run),
|
||||
};
|
||||
}
|
||||
|
||||
/** Counts and codes; SDK result bodies stay in the Core receipt. */
|
||||
function projectRun(run: Record<string, unknown>): Record<string, unknown> {
|
||||
const keep = [
|
||||
"run_id",
|
||||
"user_id",
|
||||
"agent_id",
|
||||
"state",
|
||||
"trigger",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"rounds",
|
||||
"tasks_processed",
|
||||
"batch_failures",
|
||||
"cycle_status",
|
||||
"should_continue",
|
||||
"continuation_reason",
|
||||
"remaining_clusters",
|
||||
"remaining_actionable_clusters",
|
||||
"remaining_fresh_facts",
|
||||
"capped",
|
||||
"elapsed_ms",
|
||||
"error_code",
|
||||
"failure_kind",
|
||||
];
|
||||
return Object.fromEntries(
|
||||
keep.filter((key) => key in run).map((key) => [key, run[key]]),
|
||||
);
|
||||
}
|
||||
|
||||
export function evolveExitCode(document: EvolveDocument): number {
|
||||
return document.state === "running" || document.state === "succeeded" ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderEvolveText(document: EvolveDocument): string {
|
||||
if ("code" in document) {
|
||||
return document.code === "memory_mode_not_ultra"
|
||||
? "进化只在 ultra 模式下可用。切换:memhub settings mode ultra --yes\n"
|
||||
: `进化未能开始:${document.code}\n`;
|
||||
}
|
||||
const run = document.run;
|
||||
if (document.state === "running") {
|
||||
return `进化已开始(${run.run_id}),在后台运行;加 --wait 等待结果。\n`;
|
||||
}
|
||||
if (document.state === "failed") {
|
||||
return `进化失败(${run.run_id}):${String(run.error_code ?? "unknown")}\n`;
|
||||
}
|
||||
return `进化完成(${run.run_id}):${run.rounds} 轮,处理 ${run.tasks_processed} 项${
|
||||
run.cycle_status ? `,周期 ${run.cycle_status}` : ""
|
||||
}${run.capped ? ",已到轮数上限,可再次运行继续" : ""}\n`;
|
||||
}
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
validateProcessing,
|
||||
validateSources,
|
||||
} from "./core-responses.js";
|
||||
import {
|
||||
describePipeline,
|
||||
type PipelineSummary,
|
||||
pipelineLine,
|
||||
} from "./status-report.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
type ReadState =
|
||||
@@ -84,6 +89,8 @@ export interface ProgressDocument {
|
||||
summary_state: ReadState;
|
||||
summary: Record<string, unknown> | null;
|
||||
};
|
||||
/** The SDK mode and last evolve per user; absent on an older Core. */
|
||||
pipeline?: PipelineSummary | null;
|
||||
}
|
||||
|
||||
export async function readSources(
|
||||
@@ -283,6 +290,10 @@ export async function readProgress(
|
||||
summary:
|
||||
processing.state === "ok" ? projectProcessing(processing.data) : null,
|
||||
},
|
||||
pipeline:
|
||||
processingRead.state === "ok"
|
||||
? describePipeline(processingRead.data)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -353,6 +364,18 @@ export function renderProgressText(document: ProgressDocument): string {
|
||||
? `记忆整理:待处理 ${document.generation.summary.pending} · 进行中 ${document.generation.summary.running} · 等待模型 ${document.generation.summary.waiting_for_model} · 失败 ${document.generation.summary.failed}`
|
||||
: "记忆整理:暂时无法读取",
|
||||
);
|
||||
if (document.pipeline) {
|
||||
lines.push(`记忆模式:${pipelineLine(document.pipeline)}`);
|
||||
for (const [userId, user] of Object.entries(
|
||||
document.pipeline.evolve_users,
|
||||
)) {
|
||||
lines.push(
|
||||
` 进化 ${userId}:${user.last_run_at ?? "尚未进化"}${
|
||||
user.last_error ? `(失败:${user.last_error})` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (document.imports.output_truncated) {
|
||||
lines.push("这里只显示汇总;完整记录请使用 --json。");
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ export interface StatusDocument {
|
||||
broker?: BackgroundHealth["broker"];
|
||||
};
|
||||
sections: Record<string, Section>;
|
||||
/** R10: the SDK mode and System 2 state; null when the Runtime is not read
|
||||
* or is older than the setting. */
|
||||
pipeline?: PipelineSummary | null;
|
||||
summary: {
|
||||
state: "ok" | "partial";
|
||||
degraded: string[];
|
||||
@@ -64,6 +67,88 @@ export interface StatusDocument {
|
||||
};
|
||||
}
|
||||
|
||||
export interface PipelineSummary {
|
||||
mode: "lite" | "pro" | "ultra";
|
||||
configured_mode: "lite" | "pro" | "ultra";
|
||||
forced_by_env: boolean;
|
||||
last_evolve_at: string | null;
|
||||
evolve_schedule: { enabled: boolean; time: string } | null;
|
||||
evolve_running: number;
|
||||
evolve_users: Record<
|
||||
string,
|
||||
{
|
||||
last_run_at: string | null;
|
||||
last_error: string | null;
|
||||
state: string | null;
|
||||
}
|
||||
>;
|
||||
graph_build: string | null;
|
||||
}
|
||||
|
||||
const MODES = new Set(["lite", "pro", "ultra"]);
|
||||
|
||||
/** Projection of `pipeline` in `/api/v1/memory-processing`; null if absent or malformed. */
|
||||
export function describePipeline(value: unknown): PipelineSummary | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const pipeline = (value as { pipeline?: unknown }).pipeline;
|
||||
if (!pipeline || typeof pipeline !== "object") return null;
|
||||
const item = pipeline as Record<string, unknown>;
|
||||
if (
|
||||
!MODES.has(String(item.mode)) ||
|
||||
!MODES.has(String(item.configured_mode))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const evolve =
|
||||
item.evolve && typeof item.evolve === "object"
|
||||
? (item.evolve as Record<string, unknown>)
|
||||
: {};
|
||||
const schedule =
|
||||
evolve.schedule && typeof evolve.schedule === "object"
|
||||
? (evolve.schedule as Record<string, unknown>)
|
||||
: null;
|
||||
const users: PipelineSummary["evolve_users"] = {};
|
||||
if (evolve.users && typeof evolve.users === "object") {
|
||||
for (const [userId, raw] of Object.entries(
|
||||
evolve.users as Record<string, unknown>,
|
||||
)) {
|
||||
const user =
|
||||
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
||||
const result =
|
||||
user.last_result && typeof user.last_result === "object"
|
||||
? (user.last_result as Record<string, unknown>)
|
||||
: {};
|
||||
users[userId] = {
|
||||
last_run_at:
|
||||
typeof user.last_run_at === "string" ? user.last_run_at : null,
|
||||
last_error:
|
||||
typeof user.last_error === "string" ? user.last_error : null,
|
||||
state: typeof result.state === "string" ? result.state : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
const graph =
|
||||
item.graph_build && typeof item.graph_build === "object"
|
||||
? (item.graph_build as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
mode: item.mode as PipelineSummary["mode"],
|
||||
configured_mode: item.configured_mode as PipelineSummary["mode"],
|
||||
forced_by_env: item.forced_by_env === true,
|
||||
last_evolve_at:
|
||||
typeof item.last_evolve_at === "string" ? item.last_evolve_at : null,
|
||||
evolve_schedule:
|
||||
schedule &&
|
||||
typeof schedule.enabled === "boolean" &&
|
||||
typeof schedule.time === "string"
|
||||
? { enabled: schedule.enabled, time: schedule.time }
|
||||
: null,
|
||||
evolve_running: Array.isArray(evolve.running) ? evolve.running.length : 0,
|
||||
evolve_users: users,
|
||||
graph_build: typeof graph.state === "string" ? graph.state : null,
|
||||
};
|
||||
}
|
||||
|
||||
const SECTION_NAMES = [
|
||||
"sources",
|
||||
"sync",
|
||||
@@ -97,6 +182,7 @@ export async function composeStatusReport(options: {
|
||||
attachFailure?: string;
|
||||
}): Promise<StatusDocument> {
|
||||
const sections: Record<string, Section> = {};
|
||||
let pipeline: PipelineSummary | null = null;
|
||||
if (options.reader === null) {
|
||||
const state: SectionState =
|
||||
options.attachFailure === undefined ? "not_checked" : "unavailable";
|
||||
@@ -118,6 +204,8 @@ export async function composeStatusReport(options: {
|
||||
validateProcessing,
|
||||
describeProcessing,
|
||||
);
|
||||
pipeline =
|
||||
processing.state === "ok" ? describePipeline(processing.data) : null;
|
||||
sections.model = section(model, validateModelProfiles, describeModel);
|
||||
sections.memories = section(
|
||||
memories,
|
||||
@@ -169,6 +257,7 @@ export async function composeStatusReport(options: {
|
||||
},
|
||||
bridge,
|
||||
sections,
|
||||
pipeline,
|
||||
summary: {
|
||||
state:
|
||||
degraded.length === 0 && notChecked.length === 0 ? "ok" : "partial",
|
||||
@@ -205,6 +294,7 @@ export function renderStatusText(
|
||||
`${SECTION_LABELS[name]}:${sectionLine(report.sections[name])}`,
|
||||
);
|
||||
}
|
||||
if (report.pipeline) lines.push(`记忆模式:${pipelineLine(report.pipeline)}`);
|
||||
if (report.summary.state === "partial") {
|
||||
const notes = [
|
||||
report.summary.degraded.length > 0
|
||||
@@ -220,6 +310,28 @@ export function renderStatusText(
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function pipelineLine(pipeline: PipelineSummary): string {
|
||||
const parts: string[] = [pipeline.mode];
|
||||
if (pipeline.forced_by_env)
|
||||
parts.push("(ONE_MEMORY_GRAPH_STORE 强制 ultra)");
|
||||
if (pipeline.mode === "ultra") {
|
||||
const schedule = pipeline.evolve_schedule;
|
||||
parts.push(
|
||||
schedule?.enabled
|
||||
? `,每日 ${schedule.time} 自动进化`
|
||||
: ",自动进化已关闭",
|
||||
);
|
||||
parts.push(
|
||||
pipeline.last_evolve_at
|
||||
? `,上次进化 ${pipeline.last_evolve_at}`
|
||||
: ",尚未进化",
|
||||
);
|
||||
if (pipeline.evolve_running > 0)
|
||||
parts.push(`,进行中 ${pipeline.evolve_running}`);
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function runtimeLine(report: StatusDocument): string {
|
||||
if (report.runtime.state !== "running") {
|
||||
return report.runtime.state === "stopped" ? "未运行" : "暂时无法确认";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -83,7 +84,12 @@ from one_memory.external_memory_access import (
|
||||
)
|
||||
from one_memory.extraction_settings import ExtractionSettings, ExtractionSettingsStore
|
||||
from one_memory.file_imports import FileImportStore, file_import_router
|
||||
from one_memory.hy_graph_admin import GraphWidthDecision, GraphWidthMismatch
|
||||
from one_memory.hy_graph_admin import (
|
||||
GraphWidthDecision,
|
||||
GraphWidthMismatch,
|
||||
effective_memory_mode,
|
||||
graph_store_enabled,
|
||||
)
|
||||
from one_memory.hy_runtime_config import (
|
||||
HY_MEMORY_READER,
|
||||
DiagnosticLogLevel,
|
||||
@@ -176,6 +182,16 @@ from one_memory.memory_additions import (
|
||||
MemoryAdditionStore,
|
||||
memory_add_router,
|
||||
)
|
||||
from one_memory.memory_evolution import (
|
||||
EvolveModeError,
|
||||
EvolveRunReceipt,
|
||||
EvolveScheduler,
|
||||
EvolveService,
|
||||
EvolveStateStore,
|
||||
GraphBuildState,
|
||||
MemoryGraphBackfill,
|
||||
UserEvolveState,
|
||||
)
|
||||
from one_memory.memory_governance import (
|
||||
MemoryGovernanceStore,
|
||||
)
|
||||
@@ -183,7 +199,12 @@ from one_memory.memory_qa import MemoryAnswerError
|
||||
from one_memory.memory_queue import (
|
||||
ExtractionWindowPreset,
|
||||
)
|
||||
from one_memory.memory_server.config_state import ConfigStore
|
||||
from one_memory.memory_server.config_state import (
|
||||
MEMORY_MODES,
|
||||
ConfigStore,
|
||||
EvolveSchedule,
|
||||
MemoryMode,
|
||||
)
|
||||
from one_memory.memory_tasks import (
|
||||
NON_RETRYABLE_POST_WRITE_CODES,
|
||||
MemoryTaskStatus,
|
||||
@@ -629,6 +650,119 @@ class MemoryProcessingSummary(BaseModel):
|
||||
active_queue: int = 0
|
||||
in_flight_count: int = 0
|
||||
metrics: ProcessingMetrics = Field(default_factory=ProcessingMetrics)
|
||||
#: R10: the SDK mode and System 2 state. Optional so older readers and
|
||||
#: test doubles stay valid; Core always sends it.
|
||||
pipeline: "PipelineStatus | None" = None
|
||||
|
||||
|
||||
class MemoryModeState(BaseModel):
|
||||
"""`GET /api/v1/settings/memory-mode`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
#: What SDK clients are built with now.
|
||||
mode: MemoryMode
|
||||
#: The saved setting (default pro).
|
||||
configured_mode: MemoryMode
|
||||
#: `ONE_MEMORY_GRAPH_STORE` (dev) forces ultra over the setting.
|
||||
forced_by_env: bool
|
||||
modes: tuple[MemoryMode, ...] = MEMORY_MODES
|
||||
graph_build: GraphBuildState
|
||||
|
||||
|
||||
class MemoryModeUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: MemoryMode
|
||||
|
||||
|
||||
MemoryModeEffect = Literal[
|
||||
"earlier_raw_not_extracted",
|
||||
"higher_mode_memories_hidden",
|
||||
"existing_memories_added_to_graph",
|
||||
"evolve_stopped",
|
||||
"lite_search_stops",
|
||||
"graph_backfill_already_running",
|
||||
]
|
||||
|
||||
|
||||
class MemoryModeChange(MemoryModeState):
|
||||
"""`PUT /api/v1/settings/memory-mode`: the new state and what it changes."""
|
||||
|
||||
previous_mode: MemoryMode
|
||||
effects: tuple[MemoryModeEffect, ...] = ()
|
||||
|
||||
|
||||
class EvolveScheduleUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
enabled: StrictBool | None = None
|
||||
time: str | None = Field(default=None, pattern=r"^(?:[01][0-9]|2[0-3]):[0-5][0-9]$")
|
||||
|
||||
|
||||
class EvolveRequest(BaseModel):
|
||||
"""`POST /api/v2/evolve`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
user_id: UserId = DEFAULT_USER_ID
|
||||
#: Default `all-apps`, the agent MemHub writes every memory under.
|
||||
agent_id: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
max_batches: int | None = Field(default=None, ge=1, le=1_000)
|
||||
|
||||
|
||||
class EvolveOverview(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: MemoryMode
|
||||
schedule: EvolveSchedule
|
||||
users: dict[str, UserEvolveState]
|
||||
running: tuple[EvolveRunReceipt, ...] = ()
|
||||
|
||||
|
||||
class PipelineStatus(BaseModel):
|
||||
"""`pipeline` in `/api/v1/memory-processing` and `memhub status --json`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: MemoryMode
|
||||
configured_mode: MemoryMode
|
||||
forced_by_env: bool
|
||||
evolve: EvolveOverview
|
||||
#: Newest finished run across users, for a one-line status.
|
||||
last_evolve_at: datetime | None = None
|
||||
graph_build: GraphBuildState
|
||||
|
||||
|
||||
def memory_mode_effects(
|
||||
previous: str,
|
||||
new: str,
|
||||
*,
|
||||
graph_backfill: bool | None = None,
|
||||
) -> tuple[MemoryModeEffect, ...]:
|
||||
"""What a mode switch changes; the SDK migrates nothing on its own.
|
||||
|
||||
`graph_backfill`: True when this switch started the one-time backfill,
|
||||
False when one was already running, None when not asked (no switch).
|
||||
"""
|
||||
if previous == new:
|
||||
return ()
|
||||
rank = {"lite": 0, "pro": 1, "ultra": 2}
|
||||
effects: list[MemoryModeEffect] = []
|
||||
if previous == "lite":
|
||||
effects.extend(("earlier_raw_not_extracted", "lite_search_stops"))
|
||||
if rank[new] < rank[previous]:
|
||||
effects.append("higher_mode_memories_hidden")
|
||||
if previous == "ultra":
|
||||
effects.append("evolve_stopped")
|
||||
if new == "ultra" and graph_backfill is True:
|
||||
effects.append("existing_memories_added_to_graph")
|
||||
if new == "ultra" and graph_backfill is False:
|
||||
effects.append("graph_backfill_already_running")
|
||||
return tuple(effects)
|
||||
|
||||
|
||||
MemoryProcessingSummary.model_rebuild()
|
||||
|
||||
|
||||
class RetryUnknownMemoryTasksRequest(BaseModel):
|
||||
@@ -983,6 +1117,7 @@ def create_app(
|
||||
source_id: asyncio.Lock() for source_id in app.state.source_catalog
|
||||
}
|
||||
app.state.sedimentation_lock = asyncio.Lock()
|
||||
app.state.memory_mode_lock = asyncio.Lock()
|
||||
_follow_workbuddy_home(app, runtime_settings.source_home)
|
||||
app.state.source_detection = {}
|
||||
|
||||
@@ -1084,6 +1219,7 @@ def create_app(
|
||||
)
|
||||
),
|
||||
sedimentation_enabled=lambda: _config_store(app).sedimentation_enabled(),
|
||||
memory_mode=lambda: _memory_mode(app),
|
||||
)
|
||||
app.state.sdk_memory_runtime = SdkMemoryRuntime(
|
||||
coordinator=app.state.sdk_write_coordinator,
|
||||
@@ -1091,7 +1227,22 @@ def create_app(
|
||||
write_queue=memory_write_queue,
|
||||
extraction_settings=app.state.extraction_settings_store,
|
||||
data_dir=runtime_settings.data_dir,
|
||||
user_ids=lambda: _known_user_ids(app),
|
||||
memory_mode=lambda: _memory_mode(app),
|
||||
)
|
||||
app.state.evolve_state_store = EvolveStateStore(runtime_settings.data_dir)
|
||||
app.state.evolve_service = EvolveService(
|
||||
coordinator=app.state.sdk_write_coordinator,
|
||||
state=app.state.evolve_state_store,
|
||||
memory_mode=lambda: _memory_mode(app),
|
||||
)
|
||||
app.state.evolve_scheduler = EvolveScheduler(
|
||||
service=app.state.evolve_service,
|
||||
schedule=lambda: _config_store(app).evolve_schedule(),
|
||||
users_with_memories=lambda: _users_with_memories(app),
|
||||
)
|
||||
app.state.memory_graph_backfill = MemoryGraphBackfill(
|
||||
coordinator=app.state.sdk_write_coordinator,
|
||||
state=app.state.evolve_state_store,
|
||||
)
|
||||
# C2 owns the single production budget (handle 5s + reserve 250ms,
|
||||
# total 5.25s, lease 10s, renew 1s, claim 250ms). The composition
|
||||
@@ -1427,8 +1578,9 @@ def create_app(
|
||||
name="one-memory-durable-write-drain",
|
||||
)
|
||||
|
||||
async def start_raw_retention() -> None:
|
||||
app.state.sdk_memory_runtime.start_raw_retention()
|
||||
async def start_evolve_schedule() -> None:
|
||||
# Idle outside ultra: each tick asks the mode first.
|
||||
app.state.evolve_scheduler.start()
|
||||
|
||||
async def replay_pending_dispatch() -> None:
|
||||
# A file's Evidence and its pending-dispatch row commit atomically,
|
||||
@@ -1509,7 +1661,7 @@ def create_app(
|
||||
await step("sdk_metadata_repair", repair_sdk_metadata)
|
||||
await step("write_drain", start_write_drain)
|
||||
if sdk:
|
||||
await step("raw_retention", start_raw_retention)
|
||||
await step("evolve_schedule", start_evolve_schedule)
|
||||
else:
|
||||
await step("pending_dispatch_replay", replay_pending_dispatch)
|
||||
await step("import_resume", resume_imports)
|
||||
@@ -1606,6 +1758,9 @@ def create_app(
|
||||
if app.state.memory_write_drain_task is not None:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await app.state.memory_write_drain_task
|
||||
await app.state.evolve_scheduler.shutdown()
|
||||
await app.state.evolve_service.shutdown()
|
||||
await app.state.memory_graph_backfill.shutdown()
|
||||
await app.state.sdk_memory_runtime.shutdown()
|
||||
await app.state.sdk_write_coordinator.close()
|
||||
if app.state.sdk_client_handle is not None:
|
||||
@@ -1635,6 +1790,7 @@ def create_app(
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.state.memory_mode = lambda: _memory_mode(app)
|
||||
app.state.service_levels = ServiceLevelScheduler(
|
||||
{
|
||||
ServiceResource.IMPORT: ResourceLimit(
|
||||
@@ -4079,8 +4235,148 @@ def create_app(
|
||||
active_queue=active_queue,
|
||||
in_flight_count=in_flight_count,
|
||||
metrics=ProcessingMetrics.model_validate(store.processing_metrics()),
|
||||
pipeline=_pipeline_status(app),
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/settings/memory-mode",
|
||||
response_model=MemoryModeState,
|
||||
dependencies=strict,
|
||||
)
|
||||
def get_memory_mode() -> MemoryModeState:
|
||||
"""The SDK mode (lite / pro / ultra): configured, effective, and why."""
|
||||
return _memory_mode_state(app)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/settings/memory-mode",
|
||||
response_model=MemoryModeChange,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def put_memory_mode(request: MemoryModeUpdate) -> MemoryModeChange:
|
||||
"""Choose the SDK mode. Nothing is migrated; `effects` says what changes.
|
||||
|
||||
The mode is read when a queued job runs, not when it was queued: a
|
||||
job already done (in any mode) stays done and is never reprocessed;
|
||||
jobs still waiting, and new input, run in the new mode. A batch in
|
||||
flight finishes in the old mode. Search follows the new mode at once.
|
||||
lite → pro/ultra: what lite stored stays raw, and lite's raw-only
|
||||
search stops. Down to a lower mode: memories the higher mode made stay
|
||||
stored but are not shown. → ultra: every user's existing facts are
|
||||
added to the graph once, in the background (`graph_build`). No raw is
|
||||
ever deleted, in any mode. See docs/contracts/memory-mode-evolve-v1.md.
|
||||
"""
|
||||
if request.mode == "ultra" and importlib.util.find_spec("kuzu") is None:
|
||||
raise HTTPException(status_code=409, detail="graph_store_unavailable")
|
||||
store = _config_store(app)
|
||||
async with app.state.memory_mode_lock:
|
||||
previous_effective = _memory_mode(app)
|
||||
previous = store.memory_mode()
|
||||
if previous != request.mode or "memory_mode" not in store.document().model_fields_set:
|
||||
await asyncio.to_thread(store.set_memory_mode, request.mode)
|
||||
effective = _memory_mode(app)
|
||||
backfill: bool | None = None
|
||||
if effective != previous_effective:
|
||||
# Reads rebuild on their next request (`ActiveProfileReadHandle`).
|
||||
await app.state.sdk_write_coordinator.retire_stale_modes()
|
||||
if effective == "ultra":
|
||||
backfill = not app.state.memory_graph_backfill.running
|
||||
if backfill:
|
||||
users = await asyncio.to_thread(_known_user_ids, app)
|
||||
app.state.memory_graph_backfill.start(users)
|
||||
diagnostic_event(
|
||||
level="info",
|
||||
component="memory.mode",
|
||||
event="memory_mode.changed",
|
||||
outcome="succeeded",
|
||||
details={"mode": effective, "previous_mode": previous_effective},
|
||||
)
|
||||
return MemoryModeChange(
|
||||
**_memory_mode_state(app).model_dump(),
|
||||
previous_mode=previous_effective,
|
||||
effects=memory_mode_effects(previous_effective, effective, graph_backfill=backfill),
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/settings/evolve-schedule",
|
||||
response_model=EvolveSchedule,
|
||||
dependencies=strict,
|
||||
)
|
||||
def get_evolve_schedule() -> EvolveSchedule:
|
||||
return _config_store(app).evolve_schedule()
|
||||
|
||||
@app.put(
|
||||
"/api/v1/settings/evolve-schedule",
|
||||
response_model=EvolveSchedule,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def put_evolve_schedule(request: EvolveScheduleUpdate) -> EvolveSchedule:
|
||||
"""Turn the nightly evolve on or off, or move it (local `HH:MM`)."""
|
||||
store = _config_store(app)
|
||||
current = store.evolve_schedule()
|
||||
updated = EvolveSchedule(
|
||||
enabled=current.enabled if request.enabled is None else request.enabled,
|
||||
time=current.time if request.time is None else request.time,
|
||||
)
|
||||
await asyncio.to_thread(store.set_evolve_schedule, updated)
|
||||
return updated
|
||||
|
||||
@app.post(
|
||||
"/api/v2/evolve",
|
||||
response_model=EvolveRunReceipt,
|
||||
status_code=202,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def start_evolve(request: EvolveRequest) -> EvolveRunReceipt:
|
||||
"""Run System 2 for one user, in the background; read the run by its id.
|
||||
|
||||
Ultra only (409 `memory_mode_not_ultra`). One run per user: asking
|
||||
while one runs answers that run. The run waits for the user's write
|
||||
in flight and holds their next writes until it ends, and repeats
|
||||
while the SDK says there is more to do (up to 20 rounds).
|
||||
"""
|
||||
try:
|
||||
return _evolve_service(app).start(
|
||||
request.user_id,
|
||||
agent_id=request.agent_id,
|
||||
max_batches=request.max_batches,
|
||||
)
|
||||
except EvolveModeError:
|
||||
raise HTTPException(status_code=409, detail="memory_mode_not_ultra") from None
|
||||
|
||||
@app.get(
|
||||
"/api/v2/evolve",
|
||||
response_model=EvolveOverview,
|
||||
dependencies=strict,
|
||||
)
|
||||
def evolve_overview(
|
||||
user_id: str | None = Query(default=None, pattern=USER_ID_PATTERN),
|
||||
) -> EvolveOverview:
|
||||
"""Last run per user and the runs in progress; one user's with `user_id`."""
|
||||
overview = _evolve_overview(app)
|
||||
if user_id is None:
|
||||
return overview
|
||||
return overview.model_copy(
|
||||
update={
|
||||
"users": {key: value for key, value in overview.users.items() if key == user_id},
|
||||
"running": tuple(run for run in overview.running if run.user_id == user_id),
|
||||
}
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v2/evolve/{run_id}",
|
||||
response_model=EvolveRunReceipt,
|
||||
dependencies=strict,
|
||||
)
|
||||
def get_evolve_run(
|
||||
run_id: str,
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> EvolveRunReceipt:
|
||||
"""One run; another user's run answers exactly like a missing one."""
|
||||
receipt = _evolve_service(app).get(run_id)
|
||||
if receipt is None or receipt.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="evolve_run_not_found")
|
||||
return receipt
|
||||
|
||||
@app.post(
|
||||
"/api/v1/memory-processing/retry-unknown",
|
||||
response_model=RetryUnknownMemoryTasksReceipt,
|
||||
@@ -4413,14 +4709,50 @@ def _other_user_lanes(
|
||||
|
||||
|
||||
def _known_user_ids(app: FastAPI) -> tuple[str, ...]:
|
||||
"""`local` and every user holding a grant: the users whose data Core keeps."""
|
||||
"""Every user whose data Core keeps, from Core's own records.
|
||||
|
||||
The SDK has no "list users": `local`, every user holding a grant (a
|
||||
Hermes profile), and every user whose turns or notes passed the write
|
||||
queue (`POST /api/v2/memories` or `/api/v2/memory-writes` with a user).
|
||||
"""
|
||||
users = [DEFAULT_USER_ID]
|
||||
for receipt, _root in _grant_store(app).grants():
|
||||
if receipt.user_id not in users:
|
||||
users.append(receipt.user_id)
|
||||
queue = getattr(app.state, "memory_write_queue", None)
|
||||
if queue is not None:
|
||||
with main_database(queue.database_path).read() as database:
|
||||
for (source_key,) in database.execute(
|
||||
"SELECT DISTINCT source_key FROM memory_write_jobs"
|
||||
):
|
||||
user_id = split_lane(str(source_key))[0]
|
||||
if user_id not in users:
|
||||
users.append(user_id)
|
||||
return tuple(users)
|
||||
|
||||
|
||||
async def _users_with_memories(app: FastAPI) -> tuple[str, ...]:
|
||||
"""Known users the SDK holds any memory for: the nightly evolve's users."""
|
||||
users = await asyncio.to_thread(_known_user_ids, app)
|
||||
client = await _sdk_client_handle(app).client()
|
||||
found: list[str] = []
|
||||
for user_id in users:
|
||||
try:
|
||||
stats = await asyncio.to_thread(client.memory_stats, user_id=user_id)
|
||||
except Exception:
|
||||
continue
|
||||
scenes = stats.get("scenes") if isinstance(stats, dict) else None
|
||||
total = stats.get("total") if isinstance(stats, dict) else None
|
||||
facts = sum(
|
||||
int(scene.get("facts") or 0)
|
||||
for scene in (scenes or {}).values()
|
||||
if isinstance(scene, dict)
|
||||
)
|
||||
if (isinstance(total, int) and total > 0) or facts > 0:
|
||||
found.append(user_id)
|
||||
return tuple(found)
|
||||
|
||||
|
||||
def _granted_roots(
|
||||
source_ids: Iterable[str],
|
||||
grant_store: SourceGrantStore,
|
||||
@@ -5161,7 +5493,13 @@ def _sdk_memory_runtime(app: FastAPI) -> SdkMemoryRuntime:
|
||||
|
||||
|
||||
async def _recall_model_ready(app: FastAPI) -> bool:
|
||||
"""A generation model is configured and usable, so memories can exist."""
|
||||
"""A generation model is configured and usable, so memories can exist.
|
||||
|
||||
In lite nothing is extracted whatever model is configured: recall reads
|
||||
the raw conversations.
|
||||
"""
|
||||
if _memory_mode(app) == "lite":
|
||||
return False
|
||||
try:
|
||||
profile = await asyncio.to_thread(_model_profile_store(app).active, _secret_store(app))
|
||||
except Exception:
|
||||
@@ -5310,16 +5648,68 @@ def _sdk_client_handle(app: FastAPI) -> SdkClientHandle:
|
||||
handle = cast(SdkClientHandle | None, app.state.sdk_client_handle)
|
||||
if handle is None:
|
||||
# Reads open the embedding space writes fill: the active profile's,
|
||||
# the built-in one only when none is active (#242).
|
||||
# the built-in one only when none is active (#242). A profile or a
|
||||
# memory-mode change rebuilds the client.
|
||||
handle = ActiveProfileReadHandle(
|
||||
profiles=_model_profile_store(app),
|
||||
secrets=_secret_store(app),
|
||||
data_dir=cast(Settings, app.state.settings).data_dir,
|
||||
memory_mode=lambda: _memory_mode(app),
|
||||
)
|
||||
app.state.sdk_client_handle = handle
|
||||
return handle
|
||||
|
||||
|
||||
def _memory_mode(app: FastAPI) -> MemoryMode:
|
||||
"""The mode SDK clients are built with: the setting, or ultra forced by env."""
|
||||
store = getattr(app.state, "config_store", None)
|
||||
configured = store.memory_mode() if store is not None else "pro"
|
||||
return cast(MemoryMode, effective_memory_mode(configured))
|
||||
|
||||
|
||||
def _memory_mode_state(app: FastAPI) -> MemoryModeState:
|
||||
mode = _memory_mode(app)
|
||||
return MemoryModeState(
|
||||
mode=mode,
|
||||
configured_mode=_config_store(app).memory_mode(),
|
||||
forced_by_env=graph_store_enabled(),
|
||||
graph_build=app.state.evolve_state_store.graph_build(),
|
||||
)
|
||||
|
||||
|
||||
def _evolve_service(app: FastAPI) -> EvolveService:
|
||||
return cast(EvolveService, app.state.evolve_service)
|
||||
|
||||
|
||||
def _evolve_overview(app: FastAPI) -> EvolveOverview:
|
||||
document = app.state.evolve_state_store.document()
|
||||
return EvolveOverview(
|
||||
mode=_memory_mode(app),
|
||||
schedule=_config_store(app).evolve_schedule(),
|
||||
users=document.users,
|
||||
running=app.state.evolve_service.running(),
|
||||
)
|
||||
|
||||
|
||||
def _pipeline_status(app: FastAPI) -> PipelineStatus | None:
|
||||
"""R10's `pipeline`, or None before the evolve state exists (legacy tests)."""
|
||||
if getattr(app.state, "evolve_state_store", None) is None:
|
||||
return None
|
||||
state = _memory_mode_state(app)
|
||||
overview = _evolve_overview(app)
|
||||
finished = [
|
||||
user.last_run_at for user in overview.users.values() if user.last_run_at is not None
|
||||
]
|
||||
return PipelineStatus(
|
||||
mode=state.mode,
|
||||
configured_mode=state.configured_mode,
|
||||
forced_by_env=state.forced_by_env,
|
||||
evolve=overview,
|
||||
last_evolve_at=max(finished, default=None),
|
||||
graph_build=state.graph_build,
|
||||
)
|
||||
|
||||
|
||||
def _log_skipped_sdk_memories(count: int) -> None:
|
||||
if count:
|
||||
logger.warning("sdk_memory_invalid_rows_skipped count=%d", count)
|
||||
@@ -5566,16 +5956,29 @@ def _source_writes_on(app: FastAPI, source_id: str) -> bool:
|
||||
|
||||
|
||||
def _sedimentation_claim_enabled(app: FastAPI, source_id: str) -> bool:
|
||||
"""Claim queued model work only when policy, source and model all admit it."""
|
||||
"""Claim queued model work only when policy, source and model all admit it.
|
||||
|
||||
lite calls no model, so it needs no active generation profile: the
|
||||
built-in local embedder stores the raw turns. A user evolve is running
|
||||
for waits until it is done.
|
||||
"""
|
||||
|
||||
return (
|
||||
_config_store(app).sedimentation_enabled()
|
||||
and _model_profile_store(app).active_identity() is not None
|
||||
and (_memory_mode(app) == "lite" or _model_profile_store(app).active_identity() is not None)
|
||||
and _history_import_gate_released(app)
|
||||
and _source_writes_on(app, source_id)
|
||||
and not _user_evolving(app, split_lane(source_id)[0])
|
||||
)
|
||||
|
||||
|
||||
def _user_evolving(app: FastAPI, user_id: str) -> bool:
|
||||
"""Whether evolve holds this user's writes right now."""
|
||||
coordinator = getattr(app.state, "sdk_write_coordinator", None)
|
||||
evolving = getattr(coordinator, "evolving", None)
|
||||
return bool(callable(evolving) and evolving(user_id))
|
||||
|
||||
|
||||
def _history_import_gate_released(app: FastAPI) -> bool:
|
||||
"""Hold first sedimentation until every accepted history import is terminal."""
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
client = await client_handle.client()
|
||||
except Exception:
|
||||
raise HTTPException(503, "sdk_context_unavailable") from None
|
||||
memory_mode = getattr(request.app.state, "memory_mode", None)
|
||||
lite = callable(memory_mode) and memory_mode() == "lite"
|
||||
|
||||
async def raw_lane() -> tuple[SdkRawContextItem, ...]:
|
||||
if "raw" not in value.kinds or not authority.raw_allowed:
|
||||
@@ -155,7 +157,9 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
)
|
||||
|
||||
async def memory_lane() -> tuple[SdkMemoryContextItem, ...]:
|
||||
if "memory" not in value.kinds or not authority.memory_allowed:
|
||||
# lite extracts nothing: its search answers raw turns, which the
|
||||
# raw lane already reads under the raw authority.
|
||||
if "memory" not in value.kinds or not authority.memory_allowed or lite:
|
||||
return ()
|
||||
overrides = value.search_overrides()
|
||||
raw = await asyncio.to_thread(
|
||||
|
||||
@@ -71,6 +71,16 @@ def graph_store_enabled() -> bool:
|
||||
return raw != "none"
|
||||
|
||||
|
||||
def effective_memory_mode(configured: str = "pro") -> str:
|
||||
"""The SDK mode clients are built with: the setting, unless dev forces ultra.
|
||||
|
||||
`ONE_MEMORY_GRAPH_STORE` (any value but empty or `none`) predates the
|
||||
setting and keeps working for development: it forces ultra whatever the
|
||||
configured mode. Without it the configured mode (default pro) applies.
|
||||
"""
|
||||
return "ultra" if graph_store_enabled() else configured
|
||||
|
||||
|
||||
def inspect_graph_width_mismatch(config: Any) -> GraphWidthMismatch | None:
|
||||
"""Return a mismatch without renaming or deleting anything."""
|
||||
db_path = Path(config.graph_store.db_path)
|
||||
@@ -83,11 +93,12 @@ def inspect_profile_graph_width(
|
||||
*,
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
mode: str | None = None,
|
||||
) -> GraphWidthMismatch | None:
|
||||
"""Inspect the graph path and width selected by the active profile."""
|
||||
del api_key
|
||||
return _inspect_graph_width(
|
||||
_profile_graph_path(data_dir),
|
||||
_profile_graph_path(data_dir, mode),
|
||||
_profile_embedding_width(profile),
|
||||
)
|
||||
|
||||
@@ -98,11 +109,12 @@ def decide_profile_graph_width(
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
decision: GraphWidthDecision,
|
||||
mode: str | None = None,
|
||||
) -> GraphWidthOutcome:
|
||||
"""Apply the user's decision to the active profile's graph."""
|
||||
del api_key
|
||||
config = _GraphAdminConfig(
|
||||
graph_store=_GraphStoreConfig(str(_profile_graph_path(data_dir))),
|
||||
graph_store=_GraphStoreConfig(str(_profile_graph_path(data_dir, mode))),
|
||||
embedder=_EmbeddingWidthConfig(_profile_embedding_width(profile)),
|
||||
)
|
||||
return apply_graph_width_decision(
|
||||
@@ -240,8 +252,9 @@ def _inspect_graph_width(
|
||||
)
|
||||
|
||||
|
||||
def _profile_graph_path(data_dir: Path) -> Path:
|
||||
name = "graph" if graph_store_enabled() else "graph-disabled"
|
||||
def _profile_graph_path(data_dir: Path, mode: str | None = None) -> Path:
|
||||
ultra = mode == "ultra" if mode is not None else graph_store_enabled()
|
||||
name = "graph" if ultra else "graph-disabled"
|
||||
return data_dir / "hy-memory" / HY_MEMORY_NAMESPACE / name
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ from one_memory.embedding_assets import (
|
||||
)
|
||||
from one_memory.hy_graph_admin import (
|
||||
GraphWidthMismatchError,
|
||||
graph_store_enabled,
|
||||
effective_memory_mode,
|
||||
inspect_graph_width_mismatch,
|
||||
)
|
||||
from one_memory.hy_sdk_types import HySdkRuntimeError
|
||||
@@ -170,11 +170,14 @@ class SdkClientHandle:
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
http_transport_factory: Callable[[], Any] | None = None,
|
||||
mode: str | None = None,
|
||||
) -> None:
|
||||
self._profile = profile
|
||||
self._api_key = api_key
|
||||
self._data_dir = data_dir
|
||||
self._http_transport_factory = http_transport_factory
|
||||
#: The SDK mode this handle's client is built with, fixed for its life.
|
||||
self.mode = mode or effective_memory_mode()
|
||||
self._lease: ClientLease | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@@ -189,6 +192,7 @@ class SdkClientHandle:
|
||||
api_key=self._api_key,
|
||||
data_dir=self._data_dir,
|
||||
http_transport_factory=self._http_transport_factory,
|
||||
mode=self.mode,
|
||||
)
|
||||
return self._lease.client
|
||||
|
||||
@@ -352,9 +356,12 @@ def acquire_client(
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
http_transport_factory: Callable[[], Any] | None = None,
|
||||
mode: str | None = None,
|
||||
) -> ClientLease:
|
||||
"""Construct a public SDK client and return its explicit runtime lease.
|
||||
|
||||
`mode` is the SDK pipeline mode (lite / pro / ultra); None keeps the
|
||||
pre-setting default (ultra when `ONE_MEMORY_GRAPH_STORE` is set, else pro).
|
||||
Waits while `hold_stores_offline` holds this home's stores.
|
||||
"""
|
||||
key = data_dir.resolve()
|
||||
@@ -368,6 +375,7 @@ def acquire_client(
|
||||
api_key=api_key,
|
||||
data_dir=data_dir,
|
||||
http_transport_factory=http_transport_factory,
|
||||
mode=mode or effective_memory_mode(),
|
||||
)
|
||||
finally:
|
||||
with _STORE_GATE:
|
||||
@@ -406,6 +414,7 @@ def _acquire_client(
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
http_transport_factory: Callable[[], Any] | None,
|
||||
mode: str = "pro",
|
||||
) -> ClientLease:
|
||||
config = build_sdk_config(profile, api_key=api_key, data_dir=data_dir)
|
||||
config.http_transport_factory = http_transport_factory
|
||||
@@ -423,7 +432,7 @@ def _acquire_client(
|
||||
with constructing:
|
||||
client = HyMemoryClient(
|
||||
config=config,
|
||||
mode="ultra" if graph_store_enabled() else "pro",
|
||||
mode=mode,
|
||||
runtime=runtime,
|
||||
embedding_service=embedding_service,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
"""System 2 for ultra: evolve runs, their receipts, the nightly schedule, graph backfill.
|
||||
|
||||
hy-memory 2.0.9's `client.evolve(user_id, agent_id, max_batches)` is ultra
|
||||
only, holds no lock, and has no scheduler: MemHub owns all three.
|
||||
|
||||
- A run is asynchronous: `start` answers a receipt at once and the run goes
|
||||
on in the background (evolve makes model calls across the user's whole
|
||||
library and can take minutes, longer than any HTTP timeout). One run per
|
||||
user at a time: asking again while one runs returns that run. A run holds
|
||||
the write coordinator's per-user exclusivity, so it never overlaps that
|
||||
user's write batch, and loops while the SDK answers `should_continue`, up
|
||||
to `EVOLVE_MAX_ROUNDS`.
|
||||
- The schedule runs every user that has memories once a day at the
|
||||
configured local time, and catches up after the machine slept through it.
|
||||
- Switching to ultra backfills each user's existing facts into the graph
|
||||
once (`build_memory_graph`).
|
||||
|
||||
What is kept on disk is counts and stable codes only, never memory text:
|
||||
`<data_dir>/evolve-state.json`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Any, Literal, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from one_memory.diagnostic_log import diagnostic_event
|
||||
from one_memory.memory_server.config_state import EvolveSchedule
|
||||
from one_memory.memory_server.durable_queue import safe_error_code
|
||||
from one_memory.sdk_write_processor import PRODUCT_AGENT_ID
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Rounds one run makes while the SDK says there is more to do.
|
||||
EVOLVE_MAX_ROUNDS = 20
|
||||
#: How often the schedule looks at the clock; also bounds catch-up after wake.
|
||||
SCHEDULE_CHECK_SECONDS = 300.0
|
||||
#: Receipts kept in memory for `GET /api/v2/evolve/{run_id}`.
|
||||
_KEPT_RUNS = 64
|
||||
_STABLE_CODE = re.compile(r"[a-z][a-z0-9_]{0,95}")
|
||||
_COUNTS = (
|
||||
"remaining_clusters",
|
||||
"remaining_actionable_clusters",
|
||||
"remaining_fresh_facts",
|
||||
)
|
||||
|
||||
RunState = Literal["running", "succeeded", "failed"]
|
||||
Trigger = Literal["manual", "scheduled"]
|
||||
|
||||
|
||||
class EvolveModeError(RuntimeError):
|
||||
"""Evolve asked for outside ultra."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("memory_mode_not_ultra")
|
||||
|
||||
|
||||
class EvolveRunReceipt(BaseModel):
|
||||
"""One evolve run. Counts and codes; `results` only while in memory."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
run_id: str
|
||||
user_id: str
|
||||
agent_id: str
|
||||
max_batches: int | None = None
|
||||
trigger: Trigger = "manual"
|
||||
state: RunState = "running"
|
||||
started_at: datetime
|
||||
finished_at: datetime | None = None
|
||||
rounds: int = 0
|
||||
tasks_processed: int = 0
|
||||
batch_failures: int = 0
|
||||
cycle_status: str | None = None
|
||||
should_continue: bool | None = None
|
||||
continuation_reason: str | None = None
|
||||
remaining_clusters: int | None = None
|
||||
remaining_actionable_clusters: int | None = None
|
||||
remaining_fresh_facts: int | None = None
|
||||
#: True when the round cap stopped a run the SDK would have continued.
|
||||
capped: bool = False
|
||||
elapsed_ms: float = 0.0
|
||||
error_code: str | None = None
|
||||
failure_kind: str | None = None
|
||||
#: The last round's SDK `request_id`, for matching SDK logs.
|
||||
request_id: str | None = None
|
||||
#: The last round's SDK `results` and `cross_domain_sweeper`. Kept only
|
||||
#: in this receipt, never written to disk or diagnostics.
|
||||
results: Any = None
|
||||
cross_domain_sweeper: Any = None
|
||||
|
||||
def summary(self) -> dict[str, object]:
|
||||
"""What is persisted and shown in status: no SDK result bodies."""
|
||||
return self.model_dump(mode="json", exclude={"results", "cross_domain_sweeper"})
|
||||
|
||||
|
||||
class UserEvolveState(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
last_run_at: datetime | None = None
|
||||
last_result: dict[str, object] | None = None
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
class GraphBuildUser(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
state: Literal["pending", "running", "succeeded", "failed"] = "pending"
|
||||
memories_scanned: int | None = None
|
||||
nodes: int | None = None
|
||||
error_code: str | None = None
|
||||
|
||||
|
||||
class GraphBuildState(BaseModel):
|
||||
"""The one-time backfill of existing facts into the graph after → ultra."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
state: Literal["idle", "running", "succeeded", "failed"] = "idle"
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
users: dict[str, GraphBuildUser] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EvolveStateDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
schema_version: int = Field(default=1, ge=1, le=1)
|
||||
users: dict[str, UserEvolveState] = Field(default_factory=dict)
|
||||
#: When the schedule first ran in ultra: the "last run" of a user that
|
||||
#: never ran, so enabling ultra at noon does not evolve everyone at once.
|
||||
schedule_anchor: datetime | None = None
|
||||
graph_build: GraphBuildState = Field(default_factory=GraphBuildState)
|
||||
|
||||
|
||||
class EvolveStateStore:
|
||||
"""The only writer of `<data_dir>/evolve-state.json`."""
|
||||
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.path = data_dir / "evolve-state.json"
|
||||
self._lock = threading.RLock()
|
||||
self._document = self._read()
|
||||
|
||||
def document(self) -> EvolveStateDocument:
|
||||
with self._lock:
|
||||
return self._document.model_copy(deep=True)
|
||||
|
||||
def user(self, user_id: str) -> UserEvolveState:
|
||||
with self._lock:
|
||||
return self._document.users.get(user_id, UserEvolveState()).model_copy()
|
||||
|
||||
def record_run(self, receipt: EvolveRunReceipt) -> None:
|
||||
with self._lock:
|
||||
users = dict(self._document.users)
|
||||
users[receipt.user_id] = UserEvolveState(
|
||||
last_run_at=receipt.started_at,
|
||||
last_result=receipt.summary(),
|
||||
last_error=receipt.error_code,
|
||||
)
|
||||
self._save(self._document.model_copy(update={"users": users}))
|
||||
|
||||
def anchor(self, now: datetime) -> datetime:
|
||||
"""The schedule anchor, set to `now` the first time it is asked."""
|
||||
with self._lock:
|
||||
if self._document.schedule_anchor is None:
|
||||
self._save(self._document.model_copy(update={"schedule_anchor": now}))
|
||||
assert self._document.schedule_anchor is not None
|
||||
return self._document.schedule_anchor
|
||||
|
||||
def graph_build(self) -> GraphBuildState:
|
||||
with self._lock:
|
||||
return self._document.graph_build.model_copy(deep=True)
|
||||
|
||||
def set_graph_build(self, state: GraphBuildState) -> None:
|
||||
with self._lock:
|
||||
self._save(self._document.model_copy(update={"graph_build": state}))
|
||||
|
||||
def _save(self, document: EvolveStateDocument) -> None:
|
||||
payload = json.dumps(document.model_dump(mode="json"), indent=2, sort_keys=True) + "\n"
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary: Path | None = None
|
||||
try:
|
||||
with NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=self.path.parent,
|
||||
prefix=".evolve-state.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as stream:
|
||||
temporary = Path(stream.name)
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, self.path)
|
||||
except OSError:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
# Status is best-effort: a run must not fail because its record did.
|
||||
logger.warning("evolve_state_write_failed")
|
||||
self._document = document
|
||||
|
||||
def _read(self) -> EvolveStateDocument:
|
||||
try:
|
||||
return EvolveStateDocument.model_validate_json(self.path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return EvolveStateDocument()
|
||||
except (OSError, ValueError):
|
||||
# A damaged status file only loses history; start a fresh one.
|
||||
logger.warning("evolve_state_unreadable")
|
||||
return EvolveStateDocument()
|
||||
|
||||
|
||||
class EvolveCoordinator(Protocol):
|
||||
"""The write coordinator surface evolve and the graph backfill use."""
|
||||
|
||||
def exclusive_for_evolve(self, user_id: str) -> Any: ...
|
||||
|
||||
def use_client(self) -> Any: ...
|
||||
|
||||
|
||||
class EvolveService:
|
||||
"""Start, serialize and record evolve runs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
coordinator: EvolveCoordinator,
|
||||
state: EvolveStateStore,
|
||||
memory_mode: Callable[[], str],
|
||||
max_rounds: int = EVOLVE_MAX_ROUNDS,
|
||||
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.state = state
|
||||
self.memory_mode = memory_mode
|
||||
self.max_rounds = max_rounds
|
||||
self._now = now
|
||||
self._runs: dict[str, EvolveRunReceipt] = {}
|
||||
self._tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._active: dict[str, str] = {}
|
||||
|
||||
def start(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
agent_id: str | None = None,
|
||||
max_batches: int | None = None,
|
||||
trigger: Trigger = "manual",
|
||||
) -> EvolveRunReceipt:
|
||||
"""Start a run, or return the one already running for this user."""
|
||||
if self.memory_mode() != "ultra":
|
||||
raise EvolveModeError()
|
||||
running = self._active.get(user_id)
|
||||
if running is not None:
|
||||
return self._runs[running]
|
||||
receipt = EvolveRunReceipt(
|
||||
run_id=str(uuid4()),
|
||||
user_id=user_id,
|
||||
agent_id=agent_id or PRODUCT_AGENT_ID,
|
||||
max_batches=max_batches,
|
||||
trigger=trigger,
|
||||
started_at=self._now(),
|
||||
)
|
||||
self._remember(receipt)
|
||||
self._active[user_id] = receipt.run_id
|
||||
self._tasks[receipt.run_id] = asyncio.create_task(
|
||||
self._run(receipt.run_id), name="one-memory-evolve"
|
||||
)
|
||||
return receipt
|
||||
|
||||
def get(self, run_id: str) -> EvolveRunReceipt | None:
|
||||
return self._runs.get(run_id)
|
||||
|
||||
def running(self) -> tuple[EvolveRunReceipt, ...]:
|
||||
return tuple(self._runs[run_id] for run_id in self._active.values())
|
||||
|
||||
async def wait(self, run_id: str) -> EvolveRunReceipt | None:
|
||||
task = self._tasks.get(run_id)
|
||||
if task is not None:
|
||||
await asyncio.shield(task)
|
||||
return self._runs.get(run_id)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
tasks = tuple(self._tasks.values())
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
self._tasks.clear()
|
||||
|
||||
def _remember(self, receipt: EvolveRunReceipt) -> None:
|
||||
self._runs[receipt.run_id] = receipt
|
||||
while len(self._runs) > _KEPT_RUNS:
|
||||
oldest = next(iter(self._runs))
|
||||
if oldest in self._active.values():
|
||||
break
|
||||
del self._runs[oldest]
|
||||
|
||||
async def _run(self, run_id: str) -> None:
|
||||
receipt = self._runs[run_id]
|
||||
diagnostic_event(
|
||||
level="info",
|
||||
component="memory.evolve",
|
||||
event="memory_evolve.started",
|
||||
outcome="started",
|
||||
details={"trigger": receipt.trigger, "max_batches": receipt.max_batches},
|
||||
)
|
||||
try:
|
||||
async with (
|
||||
self.coordinator.exclusive_for_evolve(receipt.user_id),
|
||||
self.coordinator.use_client() as client,
|
||||
):
|
||||
while receipt.rounds < self.max_rounds:
|
||||
result = await asyncio.to_thread(
|
||||
client.evolve,
|
||||
receipt.user_id,
|
||||
agent_id=receipt.agent_id,
|
||||
max_batches=receipt.max_batches,
|
||||
)
|
||||
receipt = _folded(receipt, result)
|
||||
self._runs[run_id] = receipt
|
||||
if receipt.state == "failed" or not receipt.should_continue:
|
||||
break
|
||||
else:
|
||||
receipt = receipt.model_copy(update={"capped": True})
|
||||
if receipt.state == "running":
|
||||
receipt = receipt.model_copy(update={"state": "succeeded"})
|
||||
except asyncio.CancelledError:
|
||||
receipt = receipt.model_copy(
|
||||
update={"state": "failed", "error_code": "memory_evolve_cancelled"}
|
||||
)
|
||||
raise
|
||||
except Exception as error: # noqa: BLE001 - recorded as a stable code
|
||||
code = safe_error_code(error)
|
||||
receipt = receipt.model_copy(
|
||||
update={
|
||||
"state": "failed",
|
||||
"error_code": _stable(code, "hy_memory_evolve_failed"),
|
||||
"failure_kind": type(error).__name__,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
receipt = receipt.model_copy(update={"finished_at": self._now()})
|
||||
self._runs[run_id] = receipt
|
||||
self._active.pop(receipt.user_id, None)
|
||||
self._tasks.pop(run_id, None)
|
||||
await asyncio.to_thread(self.state.record_run, receipt)
|
||||
diagnostic_event(
|
||||
level="info" if receipt.state == "succeeded" else "warning",
|
||||
component="memory.evolve",
|
||||
event="memory_evolve.finished",
|
||||
outcome="succeeded" if receipt.state == "succeeded" else "failed",
|
||||
details={
|
||||
"trigger": receipt.trigger,
|
||||
"rounds": receipt.rounds,
|
||||
"tasks_processed": receipt.tasks_processed,
|
||||
"batch_failures": receipt.batch_failures,
|
||||
"capped": receipt.capped,
|
||||
"elapsed_ms": round(receipt.elapsed_ms, 1),
|
||||
"error_code": receipt.error_code,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _folded(receipt: EvolveRunReceipt, result: object) -> EvolveRunReceipt:
|
||||
"""One more evolve round's result added to the run."""
|
||||
if not isinstance(result, Mapping):
|
||||
return receipt.model_copy(
|
||||
update={"state": "failed", "error_code": "hy_memory_evolve_shape_unknown"}
|
||||
)
|
||||
failures = result.get("batch_failures")
|
||||
update: dict[str, object] = {
|
||||
"rounds": receipt.rounds + 1,
|
||||
"tasks_processed": receipt.tasks_processed + _int(result.get("tasks_processed")),
|
||||
"batch_failures": receipt.batch_failures
|
||||
+ (len(failures) if isinstance(failures, list | tuple) else _int(failures)),
|
||||
"elapsed_ms": receipt.elapsed_ms + _float(result.get("elapsed_ms")),
|
||||
"should_continue": result.get("should_continue") is True,
|
||||
"cycle_status": _text(result.get("cycle_status")),
|
||||
"continuation_reason": _text(result.get("continuation_reason")),
|
||||
"request_id": _text(result.get("request_id")),
|
||||
"results": result.get("results"),
|
||||
"cross_domain_sweeper": result.get("cross_domain_sweeper"),
|
||||
}
|
||||
for name in _COUNTS:
|
||||
value = result.get(name)
|
||||
update[name] = value if isinstance(value, int) and not isinstance(value, bool) else None
|
||||
if result.get("success") is not True:
|
||||
code = result.get("error_code")
|
||||
kind = result.get("failure_kind")
|
||||
update.update(
|
||||
state="failed",
|
||||
should_continue=False,
|
||||
error_code=(
|
||||
code
|
||||
if isinstance(code, str) and _STABLE_CODE.fullmatch(code)
|
||||
else "hy_memory_evolve_rejected"
|
||||
),
|
||||
failure_kind=kind if isinstance(kind, str) and _STABLE_CODE.fullmatch(kind) else None,
|
||||
)
|
||||
return receipt.model_copy(update=update)
|
||||
|
||||
|
||||
def _stable(code: str, fallback: str) -> str:
|
||||
return code if _STABLE_CODE.fullmatch(code) else fallback
|
||||
|
||||
|
||||
def _int(value: object) -> int:
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) else 0
|
||||
|
||||
|
||||
def _float(value: object) -> float:
|
||||
if isinstance(value, int | float) and not isinstance(value, bool):
|
||||
return float(value)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _text(value: object) -> str | None:
|
||||
return value[:128] if isinstance(value, str) else None
|
||||
|
||||
|
||||
def evolve_due(
|
||||
now: datetime,
|
||||
schedule: EvolveSchedule,
|
||||
last_run_at: datetime | None,
|
||||
anchor: datetime,
|
||||
) -> bool:
|
||||
"""Whether a user's nightly evolve is due at `now` (aware, local time).
|
||||
|
||||
Due once the day's slot (`schedule.time`, local) has passed and the last
|
||||
run (or, for a user who never ran, the anchor) is older than that slot.
|
||||
A last run more than 24 hours old is due at once as well: a machine asleep
|
||||
at the slot catches up when it wakes, even before the next day's slot.
|
||||
"""
|
||||
if not schedule.enabled:
|
||||
return False
|
||||
local = now.astimezone()
|
||||
hour, minute = (int(part) for part in schedule.time.split(":"))
|
||||
slot = local.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
reference = last_run_at or anchor
|
||||
if local >= slot and reference < slot:
|
||||
return True
|
||||
return last_run_at is not None and local - last_run_at > timedelta(hours=24)
|
||||
|
||||
|
||||
class EvolveScheduler:
|
||||
"""Run evolve nightly for every user that has memories (ultra only)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
service: EvolveService,
|
||||
schedule: Callable[[], EvolveSchedule],
|
||||
users_with_memories: Callable[[], Awaitable[Iterable[str]]],
|
||||
now: Callable[[], datetime] = lambda: datetime.now().astimezone(),
|
||||
check_seconds: float = SCHEDULE_CHECK_SECONDS,
|
||||
) -> None:
|
||||
self.service = service
|
||||
self.schedule = schedule
|
||||
self.users_with_memories = users_with_memories
|
||||
self._now = now
|
||||
self.check_seconds = check_seconds
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(self._loop(), name="one-memory-evolve-schedule")
|
||||
|
||||
async def tick(self) -> tuple[str, ...]:
|
||||
"""Run every due user once, one after another; the users run."""
|
||||
schedule = self.schedule()
|
||||
if not schedule.enabled or self.service.memory_mode() != "ultra":
|
||||
return ()
|
||||
now = self._now()
|
||||
anchor = await asyncio.to_thread(self.service.state.anchor, now)
|
||||
ran: list[str] = []
|
||||
for user_id in await self.users_with_memories():
|
||||
last = self.service.state.user(user_id).last_run_at
|
||||
if not evolve_due(now, schedule, last, anchor):
|
||||
continue
|
||||
try:
|
||||
receipt = self.service.start(user_id, trigger="scheduled")
|
||||
except EvolveModeError:
|
||||
break
|
||||
await self.service.wait(receipt.run_id)
|
||||
ran.append(user_id)
|
||||
if ran:
|
||||
diagnostic_event(
|
||||
level="info",
|
||||
component="memory.evolve",
|
||||
event="memory_evolve.schedule_ran",
|
||||
outcome="succeeded",
|
||||
details={"users": len(ran)},
|
||||
)
|
||||
return tuple(ran)
|
||||
|
||||
async def _loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self.tick()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error: # noqa: BLE001 - the loop must survive
|
||||
logger.warning("evolve_schedule_tick_failed error_type=%s", type(error).__name__)
|
||||
await asyncio.sleep(self.check_seconds)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._task is None:
|
||||
return
|
||||
self._task.cancel()
|
||||
await asyncio.gather(self._task, return_exceptions=True)
|
||||
self._task = None
|
||||
|
||||
|
||||
class MemoryGraphBackfill:
|
||||
"""After a switch to ultra, project each user's existing facts into the graph once."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
coordinator: EvolveCoordinator,
|
||||
state: EvolveStateStore,
|
||||
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.state = state
|
||||
self._now = now
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
def start(self, user_ids: Iterable[str]) -> GraphBuildState:
|
||||
if self.running:
|
||||
return self.state.graph_build()
|
||||
users = tuple(dict.fromkeys(user_ids))
|
||||
state = GraphBuildState(
|
||||
state="running",
|
||||
started_at=self._now(),
|
||||
users={user_id: GraphBuildUser() for user_id in users},
|
||||
)
|
||||
self.state.set_graph_build(state)
|
||||
self._task = asyncio.create_task(self._run(users), name="one-memory-graph-backfill")
|
||||
return state
|
||||
|
||||
async def wait(self) -> None:
|
||||
if self._task is not None:
|
||||
await asyncio.shield(self._task)
|
||||
|
||||
async def _run(self, users: tuple[str, ...]) -> None:
|
||||
state = self.state.graph_build()
|
||||
for user_id in users:
|
||||
state.users[user_id] = GraphBuildUser(state="running")
|
||||
await asyncio.to_thread(self.state.set_graph_build, state.model_copy(deep=True))
|
||||
try:
|
||||
async with (
|
||||
self.coordinator.exclusive_for_evolve(user_id),
|
||||
self.coordinator.use_client() as client,
|
||||
):
|
||||
result = await asyncio.to_thread(
|
||||
client.build_memory_graph,
|
||||
user_id,
|
||||
agent_id=None,
|
||||
limit=10000,
|
||||
clear_first=False,
|
||||
)
|
||||
ok = isinstance(result, Mapping) and result.get("success") is True
|
||||
state.users[user_id] = GraphBuildUser(
|
||||
state="succeeded" if ok else "failed",
|
||||
memories_scanned=_int(result.get("memories_scanned"))
|
||||
if isinstance(result, Mapping)
|
||||
else None,
|
||||
nodes=_int(result.get("nodes")) if isinstance(result, Mapping) else None,
|
||||
error_code=None if ok else "hy_memory_graph_build_rejected",
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error: # noqa: BLE001 - recorded as a stable code
|
||||
code = safe_error_code(error)
|
||||
state.users[user_id] = GraphBuildUser(
|
||||
state="failed",
|
||||
error_code=_stable(code, "hy_memory_graph_build_failed"),
|
||||
)
|
||||
failed = any(user.state == "failed" for user in state.users.values())
|
||||
state.state = "failed" if failed else "succeeded"
|
||||
state.finished_at = self._now()
|
||||
await asyncio.to_thread(self.state.set_graph_build, state)
|
||||
diagnostic_event(
|
||||
level="warning" if failed else "info",
|
||||
component="memory.evolve",
|
||||
event="memory_graph_backfill.finished",
|
||||
outcome="failed" if failed else "succeeded",
|
||||
details={
|
||||
"users": len(state.users),
|
||||
"failed_users": sum(user.state == "failed" for user in state.users.values()),
|
||||
},
|
||||
)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._task is None:
|
||||
return
|
||||
self._task.cancel()
|
||||
await asyncio.gather(self._task, return_exceptions=True)
|
||||
self._task = None
|
||||
@@ -13,6 +13,7 @@ import threading
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator
|
||||
|
||||
@@ -27,6 +28,31 @@ class SourceConfig(BaseModel):
|
||||
sync_enabled: StrictBool = False
|
||||
|
||||
|
||||
#: The SDK pipeline mode (hy-memory `HyMemoryClient(mode=...)`):
|
||||
#: lite keeps raw conversations only, with no model call; pro extracts
|
||||
#: memories (System 1); ultra adds the knowledge graph and evolve (System 2).
|
||||
MemoryMode = Literal["lite", "pro", "ultra"]
|
||||
MEMORY_MODES: tuple[MemoryMode, ...] = ("lite", "pro", "ultra")
|
||||
_SCHEDULE_TIME = re.compile(r"^(?:[01][0-9]|2[0-3]):[0-5][0-9]$")
|
||||
|
||||
|
||||
class EvolveSchedule(BaseModel):
|
||||
"""When ultra runs evolve for every user: once a day, local time."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
|
||||
|
||||
enabled: StrictBool = True
|
||||
#: `HH:MM`, 24-hour, the machine's local time.
|
||||
time: str = "03:00"
|
||||
|
||||
@field_validator("time")
|
||||
@classmethod
|
||||
def valid_time(cls, value: str) -> str:
|
||||
if _SCHEDULE_TIME.fullmatch(value) is None:
|
||||
raise ValueError("invalid_evolve_schedule_time")
|
||||
return value
|
||||
|
||||
|
||||
class ConfigDocument(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
|
||||
|
||||
@@ -39,6 +65,10 @@ class ConfigDocument(BaseModel):
|
||||
#: needs no re-entry of a key.
|
||||
sedimentation_enabled: StrictBool = True
|
||||
extraction_window_preset: ExtractionWindowPreset = ExtractionWindowPreset.THREE_MONTHS
|
||||
#: The SDK mode every client is built with. `ONE_MEMORY_GRAPH_STORE` (dev)
|
||||
#: still forces ultra over it; see `hy_graph_admin.effective_memory_mode`.
|
||||
memory_mode: MemoryMode = "pro"
|
||||
evolve_schedule: EvolveSchedule = Field(default_factory=EvolveSchedule)
|
||||
|
||||
@field_validator("sources")
|
||||
@classmethod
|
||||
@@ -61,6 +91,9 @@ class ConfigFileError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
_WRITTEN_WHEN_SET = frozenset({"memory_mode", "evolve_schedule"})
|
||||
|
||||
|
||||
class ConfigStore:
|
||||
"""The only writer of ``<data_dir>/config.json`` inside Core."""
|
||||
|
||||
@@ -107,6 +140,32 @@ class ConfigStore:
|
||||
self._document = updated
|
||||
return updated
|
||||
|
||||
def memory_mode(self) -> MemoryMode:
|
||||
with self._lock:
|
||||
return self._document.memory_mode
|
||||
|
||||
def set_memory_mode(self, mode: MemoryMode) -> ConfigDocument:
|
||||
if mode not in MEMORY_MODES:
|
||||
raise ValueError("invalid_memory_mode")
|
||||
with self._lock:
|
||||
updated = self._document.model_copy(update={"memory_mode": mode})
|
||||
self._write(updated)
|
||||
self._document = updated
|
||||
return updated
|
||||
|
||||
def evolve_schedule(self) -> EvolveSchedule:
|
||||
with self._lock:
|
||||
return self._document.evolve_schedule
|
||||
|
||||
def set_evolve_schedule(self, schedule: EvolveSchedule) -> ConfigDocument:
|
||||
if not isinstance(schedule, EvolveSchedule):
|
||||
raise ValueError("invalid_evolve_schedule")
|
||||
with self._lock:
|
||||
updated = self._document.model_copy(update={"evolve_schedule": schedule})
|
||||
self._write(updated)
|
||||
self._document = updated
|
||||
return updated
|
||||
|
||||
def migrate_extraction_window_preset(self, preset: ExtractionWindowPreset) -> ConfigDocument:
|
||||
"""Adopt the legacy SQL value only when config.json has no value."""
|
||||
if not isinstance(preset, ExtractionWindowPreset):
|
||||
@@ -152,9 +211,12 @@ class ConfigStore:
|
||||
raise ConfigFileError("config_invalid") from error
|
||||
|
||||
def _write(self, document: ConfigDocument) -> None:
|
||||
# Settings added after 1.0.5 are written only once a person chose
|
||||
# them, so an untouched install's file stays what older builds read.
|
||||
unset = {name for name in _WRITTEN_WHEN_SET if name not in document.model_fields_set}
|
||||
payload = (
|
||||
json.dumps(
|
||||
document.model_dump(mode="json"),
|
||||
document.model_dump(mode="json", exclude=unset),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
|
||||
@@ -2563,6 +2563,8 @@ async def execute_job(
|
||||
in {
|
||||
"model_content_source_disabled",
|
||||
"model_sedimentation_disabled",
|
||||
# Evolve holds this user; the job runs once it is done.
|
||||
"memory_evolve_in_progress",
|
||||
}
|
||||
and not hy_invoked
|
||||
):
|
||||
|
||||
@@ -5,8 +5,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -33,16 +33,6 @@ from one_memory.semantic_deadline import SemanticMemoryHit
|
||||
_STABLE_CODE = re.compile(r"[a-z][a-z0-9_]{0,95}")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
RAW_RETENTION_DAYS = 3
|
||||
RAW_RETENTION_CHECK_SECONDS = 6 * 60 * 60
|
||||
_RAW_STATUSES = (
|
||||
"active",
|
||||
"superseded",
|
||||
"shadow",
|
||||
"negated",
|
||||
"conflicted",
|
||||
"archived",
|
||||
)
|
||||
|
||||
|
||||
class SdkMemoryRuntime:
|
||||
@@ -56,84 +46,17 @@ class SdkMemoryRuntime:
|
||||
write_queue: DurableWriteQueue,
|
||||
extraction_settings: ExtractionSettingsStore,
|
||||
data_dir: Path,
|
||||
user_ids: Callable[[], Iterable[str]] = lambda: ("local",),
|
||||
memory_mode: Callable[[], str] = lambda: "pro",
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
#: Every user whose raw layer is kept to the retention window.
|
||||
self.user_ids = user_ids
|
||||
#: The SDK mode, for the graph store's path (`hy_graph_admin`).
|
||||
self.memory_mode = memory_mode
|
||||
self.read_handle = read_handle
|
||||
self.write_queue = write_queue
|
||||
self.extraction_settings = extraction_settings
|
||||
self.data_dir = data_dir
|
||||
self._raw_retention_task: asyncio.Task[None] | None = None
|
||||
|
||||
def start_raw_retention(self) -> None:
|
||||
if self._raw_retention_task is None or self._raw_retention_task.done():
|
||||
self._raw_retention_task = asyncio.create_task(
|
||||
self._raw_retention_loop(),
|
||||
name="one-memory-sdk-raw-retention",
|
||||
)
|
||||
|
||||
async def prune_expired_raw(self, *, now: datetime | None = None) -> int:
|
||||
deleted = 0
|
||||
for user_id in dict.fromkeys(await asyncio.to_thread(lambda: tuple(self.user_ids()))):
|
||||
deleted += await self._prune_expired_raw_for(user_id, now=now)
|
||||
return deleted
|
||||
|
||||
async def _prune_expired_raw_for(self, user_id: str, *, now: datetime | None) -> int:
|
||||
cutoff = (now or datetime.now(UTC)) - timedelta(days=RAW_RETENTION_DAYS)
|
||||
cutoff_timestamp = int(cutoff.timestamp())
|
||||
client = await self.read_handle.client()
|
||||
deleted = 0
|
||||
while True:
|
||||
page = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id=user_id,
|
||||
layers=["l1_raw"],
|
||||
statuses=list(_RAW_STATUSES),
|
||||
order="asc",
|
||||
limit=500,
|
||||
offset=0,
|
||||
)
|
||||
bucket = page.get("vdb") if isinstance(page, dict) else None
|
||||
rows = bucket.get("memories") if isinstance(bucket, dict) else None
|
||||
if not isinstance(rows, list) or not rows:
|
||||
return deleted
|
||||
expired = [
|
||||
row
|
||||
for row in rows
|
||||
if isinstance(row, dict)
|
||||
and isinstance(row.get("memory_id"), str)
|
||||
and int(row.get("memory_at") or row.get("gmt_created") or 0) < cutoff_timestamp
|
||||
]
|
||||
if not expired:
|
||||
return deleted
|
||||
removed_page = 0
|
||||
for row in expired:
|
||||
result = await asyncio.to_thread(client.delete, row["memory_id"])
|
||||
if isinstance(result, dict) and result.get("success") is True:
|
||||
count = int(result.get("deleted_count") or 0)
|
||||
deleted += count
|
||||
removed_page += count
|
||||
if removed_page == 0:
|
||||
return deleted
|
||||
|
||||
async def _raw_retention_loop(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(RAW_RETENTION_CHECK_SECONDS)
|
||||
try:
|
||||
# Its own task, so this pass's use of the read client ends
|
||||
# with it instead of with this loop (see `sdk_read_handle`).
|
||||
removed = await asyncio.create_task(self.prune_expired_raw())
|
||||
if removed:
|
||||
logger.info("sdk_raw_retention_deleted count=%d", removed)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"sdk_raw_retention_failed error_type=%s",
|
||||
type(error).__name__,
|
||||
)
|
||||
# MemHub keeps every raw the SDK stores, in every mode: there is no
|
||||
# retention window (owner decision; the SDK itself never deletes raw).
|
||||
|
||||
async def semantic_recall(
|
||||
self,
|
||||
@@ -233,6 +156,7 @@ class SdkMemoryRuntime:
|
||||
profile,
|
||||
api_key=secret,
|
||||
data_dir=self.data_dir,
|
||||
mode=self.memory_mode(),
|
||||
)
|
||||
|
||||
async def decide_graph_width(
|
||||
@@ -249,6 +173,7 @@ class SdkMemoryRuntime:
|
||||
api_key=secret,
|
||||
data_dir=self.data_dir,
|
||||
decision=decision,
|
||||
mode=self.memory_mode(),
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -387,12 +312,7 @@ class SdkMemoryRuntime:
|
||||
return None
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._raw_retention_task is None:
|
||||
return
|
||||
self._raw_retention_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._raw_retention_task
|
||||
self._raw_retention_task = None
|
||||
return None
|
||||
|
||||
|
||||
def _agent_ids(raw: object) -> tuple[str, ...]:
|
||||
|
||||
@@ -10,14 +10,20 @@ is closed once no task still uses it, as `SdkWriteCoordinator` does for writes
|
||||
(#218). A use is the task that asked for the client: callers keep their
|
||||
`client = await handle.client()` shape, and the use ends with that task (one
|
||||
HTTP request, one retention pass).
|
||||
|
||||
The memory mode (lite / pro / ultra) is fixed per SDK client, so a mode change
|
||||
rebuilds and retires the same way: handles are keyed by (profile, mode). The
|
||||
mode never names a collection; it is a separate client parameter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from one_memory.hy_graph_admin import effective_memory_mode
|
||||
from one_memory.hy_sdk_runtime import (
|
||||
BUILTIN_RETRIEVAL_PROFILE_ID,
|
||||
SdkClientHandle,
|
||||
@@ -40,12 +46,14 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
profiles: ModelProfileStore,
|
||||
secrets: SecretStore,
|
||||
data_dir: Path,
|
||||
memory_mode: Callable[[], str] = effective_memory_mode,
|
||||
) -> None:
|
||||
super().__init__(builtin_retrieval_snapshot(), api_key=None, data_dir=data_dir)
|
||||
self.profiles = profiles
|
||||
self.secrets = secrets
|
||||
self.data_dir = data_dir
|
||||
self._handles: dict[tuple[str, int], SdkClientHandle] = {}
|
||||
self.memory_mode = memory_mode
|
||||
self._handles: dict[tuple[str, int, str], SdkClientHandle] = {}
|
||||
#: Tasks in flight per handle; a retired handle closes at zero.
|
||||
self._uses: dict[SdkClientHandle, int] = {}
|
||||
self._retired: set[SdkClientHandle] = set()
|
||||
@@ -53,7 +61,8 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
self._handle_lock = asyncio.Lock()
|
||||
|
||||
async def client(self) -> Any:
|
||||
identity = await asyncio.to_thread(self.profiles.active_identity) or _BUILTIN
|
||||
profile = await asyncio.to_thread(self.profiles.active_identity) or _BUILTIN
|
||||
identity = (*profile, self.memory_mode())
|
||||
async with self._handle_lock:
|
||||
handle = self._handles.get(identity)
|
||||
if handle is None:
|
||||
@@ -95,10 +104,12 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
self._closing.add(closing)
|
||||
closing.add_done_callback(self._closing.discard)
|
||||
|
||||
def _build(self, identity: tuple[str, int]) -> SdkClientHandle:
|
||||
def _build(self, key: tuple[str, int, str]) -> SdkClientHandle:
|
||||
profile_id, revision, mode = key
|
||||
identity = (profile_id, revision)
|
||||
if identity == _BUILTIN:
|
||||
return SdkClientHandle(
|
||||
builtin_retrieval_snapshot(), api_key=None, data_dir=self.data_dir
|
||||
builtin_retrieval_snapshot(), api_key=None, data_dir=self.data_dir, mode=mode
|
||||
)
|
||||
profile = self.profiles.snapshot(*identity)
|
||||
view = retrieval_view(profile)
|
||||
@@ -111,4 +122,4 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
raise HySdkRuntimeError("secure_storage_unavailable") from None
|
||||
if secret is None:
|
||||
raise HySdkRuntimeError("model_secret_unavailable")
|
||||
return SdkClientHandle(view, api_key=secret, data_dir=self.data_dir)
|
||||
return SdkClientHandle(view, api_key=secret, data_dir=self.data_dir, mode=mode)
|
||||
@@ -9,7 +9,8 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from one_memory.hy_sdk_runtime import SdkClientHandle
|
||||
from one_memory.hy_graph_admin import effective_memory_mode
|
||||
from one_memory.hy_sdk_runtime import SdkClientHandle, builtin_retrieval_snapshot
|
||||
from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord
|
||||
from one_memory.input_sanitization import sanitize_model_input
|
||||
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore
|
||||
@@ -31,8 +32,11 @@ class SdkWriteReceipt:
|
||||
class SdkWriteProcessor:
|
||||
"""Turn one durable job into exactly one public SDK write call."""
|
||||
|
||||
def __init__(self, client_handle: SdkClientHandle) -> None:
|
||||
def __init__(self, client_handle: SdkClientHandle, *, lite: bool = False) -> None:
|
||||
self._client_handle = client_handle
|
||||
#: lite makes no model call: `add_extracted` would still call one, so a
|
||||
#: direct memory note is stored as one raw user turn through `add`.
|
||||
self._lite = lite
|
||||
|
||||
async def process_sdk_job(
|
||||
self,
|
||||
@@ -64,7 +68,7 @@ class SdkWriteProcessor:
|
||||
# waits and retries instead of being left `unknown`.
|
||||
raise RuntimeError("hy_memory_client_unavailable") from error
|
||||
|
||||
if extracted:
|
||||
if extracted and not self._lite:
|
||||
contents = [sanitize_model_input(record.verbatim) for record in records]
|
||||
await before_hy_invocation()
|
||||
result = await asyncio.to_thread(
|
||||
@@ -81,12 +85,15 @@ class SdkWriteProcessor:
|
||||
else:
|
||||
messages = [
|
||||
{
|
||||
"role": record.role,
|
||||
"role": "user" if extracted else record.role,
|
||||
"content": sanitize_model_input(record.verbatim),
|
||||
}
|
||||
for record in records
|
||||
if record.content_kind is EvidenceContentKind.RAW_CONVERSATION
|
||||
and record.role in _MESSAGE_ROLES
|
||||
if extracted
|
||||
or (
|
||||
record.content_kind is EvidenceContentKind.RAW_CONVERSATION
|
||||
and record.role in _MESSAGE_ROLES
|
||||
)
|
||||
]
|
||||
if not messages:
|
||||
raise RuntimeError("hy_memory_task_without_inputs")
|
||||
@@ -138,6 +145,15 @@ class SdkWriteCoordinator:
|
||||
from under a write still running on it: the SDK cleared its stores and
|
||||
the SharedRuntime closed mid-`add`, the job failed with AttributeError
|
||||
after HY was invoked, and it was left `unknown`.
|
||||
|
||||
Handles are keyed by profile and memory mode, so a mode change retires
|
||||
handles the same way. In lite no generation model is needed: without an
|
||||
active profile the built-in local embedder writes raw turns.
|
||||
|
||||
`exclusive_for_evolve(user_id)` keeps evolve and that user's writes
|
||||
apart: it waits for the user's write in flight, and while it runs that
|
||||
user's jobs are refused before the SDK is called
|
||||
(`memory_evolve_in_progress`), so the drain puts them back.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -148,13 +164,22 @@ class SdkWriteCoordinator:
|
||||
data_dir: Any,
|
||||
transport_factory: Callable[[ModelProfileSnapshot], Callable[[], Any] | None],
|
||||
sedimentation_enabled: Callable[[], bool],
|
||||
memory_mode: Callable[[], str] = effective_memory_mode,
|
||||
) -> None:
|
||||
self.profiles = profiles
|
||||
self.secrets = secrets
|
||||
self.data_dir = data_dir
|
||||
self.transport_factory = transport_factory
|
||||
self.sedimentation_enabled = sedimentation_enabled
|
||||
self._handles: dict[tuple[str, int], SdkClientHandle] = {}
|
||||
self.memory_mode = memory_mode
|
||||
self._handles: dict[tuple[str, int, str], SdkClientHandle] = {}
|
||||
#: Users evolve (or the graph backfill) is running for, the writes in
|
||||
#: flight per user, and one exclusive run per user at a time. Writes
|
||||
#: of one user still run side by side; only evolve waits for them.
|
||||
self._evolving: set[str] = set()
|
||||
self._writing: dict[str, int] = {}
|
||||
self._writes_idle = asyncio.Condition()
|
||||
self._exclusive: dict[str, asyncio.Lock] = {}
|
||||
#: Uses in flight per handle; a retired handle closes at zero.
|
||||
self._uses: dict[SdkClientHandle, int] = {}
|
||||
self._retired: set[SdkClientHandle] = set()
|
||||
@@ -169,16 +194,66 @@ class SdkWriteCoordinator:
|
||||
) -> SdkWriteReceipt:
|
||||
if not self.sedimentation_enabled():
|
||||
raise RuntimeError("model_sedimentation_disabled")
|
||||
async with self._active_handle() as handle:
|
||||
return await SdkWriteProcessor(handle).process_sdk_job(
|
||||
records,
|
||||
request_id=request_id,
|
||||
before_hy_invocation=before_hy_invocation,
|
||||
)
|
||||
user_id = records[0].user_id if records else "local"
|
||||
if user_id in self._evolving:
|
||||
# Before any SDK call: the drain returns the job to pending.
|
||||
raise RuntimeError("memory_evolve_in_progress")
|
||||
self._writing[user_id] = self._writing.get(user_id, 0) + 1
|
||||
try:
|
||||
async with self._active_handle() as handle:
|
||||
return await SdkWriteProcessor(
|
||||
handle, lite=getattr(handle, "mode", None) == "lite"
|
||||
).process_sdk_job(
|
||||
records,
|
||||
request_id=request_id,
|
||||
before_hy_invocation=before_hy_invocation,
|
||||
)
|
||||
finally:
|
||||
remaining = self._writing.get(user_id, 1) - 1
|
||||
if remaining > 0:
|
||||
self._writing[user_id] = remaining
|
||||
else:
|
||||
self._writing.pop(user_id, None)
|
||||
async with self._writes_idle:
|
||||
self._writes_idle.notify_all()
|
||||
|
||||
async def inquire_write_outcome(
|
||||
self, request_id: str
|
||||
) -> SdkWriteReceipt | None:
|
||||
async def retire_stale_modes(self) -> None:
|
||||
"""After a mode change: retire handles of other modes, closing the idle ones.
|
||||
|
||||
A handle still writing is closed when its last use ends (#218), so a
|
||||
write that started in the old mode finishes in it.
|
||||
"""
|
||||
mode = self.memory_mode()
|
||||
async with self._lock:
|
||||
stale = {key: handle for key, handle in self._handles.items() if key[2] != mode}
|
||||
for key in stale:
|
||||
del self._handles[key]
|
||||
self._retired.update(stale.values())
|
||||
unused = {value for value in self._retired if not self._uses.get(value)}
|
||||
self._retired -= unused
|
||||
await asyncio.gather(*(value.close() for value in unused))
|
||||
|
||||
def evolving(self, user_id: str) -> bool:
|
||||
"""Whether evolve holds this user, so their queued writes should wait."""
|
||||
return user_id in self._evolving
|
||||
|
||||
@asynccontextmanager
|
||||
async def exclusive_for_evolve(self, user_id: str) -> AsyncIterator[None]:
|
||||
"""Hold one user's writes off while evolve runs for them.
|
||||
|
||||
New writes of the user are refused from here on; the ones already in
|
||||
flight are waited for before the body runs.
|
||||
"""
|
||||
async with self._exclusive.setdefault(user_id, asyncio.Lock()):
|
||||
self._evolving.add(user_id)
|
||||
try:
|
||||
async with self._writes_idle:
|
||||
await self._writes_idle.wait_for(lambda: not self._writing.get(user_id))
|
||||
yield
|
||||
finally:
|
||||
self._evolving.discard(user_id)
|
||||
|
||||
async def inquire_write_outcome(self, request_id: str) -> SdkWriteReceipt | None:
|
||||
async with self._active_handle() as handle:
|
||||
return await SdkWriteProcessor(handle).inquire_write_outcome(request_id)
|
||||
|
||||
@@ -204,9 +279,11 @@ class SdkWriteCoordinator:
|
||||
@asynccontextmanager
|
||||
async def _active_handle(self) -> AsyncIterator[SdkClientHandle]:
|
||||
"""Use the active profile's handle; retire the others, closing the unused."""
|
||||
identity, profile, secret = await asyncio.to_thread(
|
||||
self._read_active_credentials
|
||||
mode = self.memory_mode()
|
||||
profile_identity, profile, secret = await asyncio.to_thread(
|
||||
self._read_active_credentials, mode
|
||||
)
|
||||
identity = (*profile_identity, mode)
|
||||
async with self._lock:
|
||||
handle = self._handles.get(identity)
|
||||
if handle is None:
|
||||
@@ -215,6 +292,7 @@ class SdkWriteCoordinator:
|
||||
api_key=secret,
|
||||
data_dir=self.data_dir,
|
||||
http_transport_factory=self.transport_factory(profile),
|
||||
mode=mode,
|
||||
)
|
||||
self._handles[identity] = handle
|
||||
self._retired.update(value for key, value in self._handles.items() if key != identity)
|
||||
@@ -239,9 +317,14 @@ class SdkWriteCoordinator:
|
||||
|
||||
def _read_active_credentials(
|
||||
self,
|
||||
mode: str = "pro",
|
||||
) -> tuple[tuple[str, int], ModelProfileSnapshot, str | None]:
|
||||
identity = self.profiles.active_identity()
|
||||
if identity is None:
|
||||
if mode == "lite":
|
||||
# lite stores raw turns with one embedding and calls no model.
|
||||
builtin = builtin_retrieval_snapshot()
|
||||
return (builtin.profile_id, builtin.revision), builtin, None
|
||||
raise RuntimeError("model_profile_not_active")
|
||||
profile = self.profiles.snapshot(*identity)
|
||||
try:
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
| `test_memory_evidence_freshness.py` | `contract` | 新鲜度拆除后:读路径恒为 current;证据修订 / 畸形 proof / segment ref 不再藏记忆或 503。 |
|
||||
| `test_memory_freshness_integration_review.py` | `integration` | Independent synthetic read-path review; no lifespan, model or user database. |
|
||||
| `test_memory_library_all_scenes.py` | `integration` | The v2 memory library across chat, working and coding memories. |
|
||||
| `test_memory_mode_and_evolve.py` | `contract` | The memory mode (lite / pro / ultra), evolve and its nightly schedule. |
|
||||
| `test_memory_qa.py` | `contract` | 覆盖 search terms keep meaningful single chinese character、streamed answer emits recall then incremental grounded text、governance change during generation stops the old answer 等 14 个场景。 |
|
||||
| `test_memory_qa_api.py` | `integration` | 覆盖 ask stream endpoint emits recall deltas and final、ask stream endpoint requires the local token 场景。 |
|
||||
| `test_memory_qa_permissions.py` | `contract` | 覆盖 initial refusal precedes all secret access and network、invalid authorization result fails before secret access、authorization receives exact question and only outbound grounding 等 16 个场景。 |
|
||||
@@ -312,4 +313,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. |
|
||||
|
||||
共 307 个测试文件。
|
||||
共 308 个测试文件。
|
||||
@@ -14,8 +14,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
||||
EXPECTED_TEST_FILE_COUNT = 307
|
||||
EXPECTED_TEST_FILES_SHA256 = "96a9ed11e358791723fb5ad827f72da2e9caeb673841119a5d9856a2d76fd5b1"
|
||||
EXPECTED_TEST_FILE_COUNT = 308
|
||||
EXPECTED_TEST_FILES_SHA256 = "3aadf5953e41c01d4b60469f47242c00d2acef3c42550aa017255e31d216b589"
|
||||
|
||||
# Pure transformations, validation models, parity checks and state machines.
|
||||
# This list is intentionally small: uncertain files default to contract.
|
||||
|
||||
@@ -164,7 +164,7 @@ def test_metadata_repair_never_overlaps_an_sdk_client(tmp_path, monkeypatch):
|
||||
events.append("repair_finished")
|
||||
return MetadataRepairResult(None, 0, 0, 0)
|
||||
|
||||
def open_client(profile, *, api_key, data_dir, http_transport_factory):
|
||||
def open_client(profile, *, api_key, data_dir, http_transport_factory, mode="pro"):
|
||||
events.append("client_opened")
|
||||
return ClientLease(object(), data_dir.resolve())
|
||||
|
||||
|
||||
@@ -0,0 +1,861 @@
|
||||
"""The memory mode (lite / pro / ultra), evolve and its nightly schedule.
|
||||
|
||||
Item 5: the mode is a saved setting (default pro, `ONE_MEMORY_GRAPH_STORE`
|
||||
still forces ultra), it decides the mode every SDK client is built with, and
|
||||
a change rebuilds the handles. lite needs no generation model, never calls
|
||||
`add_extracted`, and keeps every raw. Item 6 (P1-3 / R7): `POST
|
||||
/api/v2/evolve` is ultra only, one run per user at a time, never beside that
|
||||
user's write, and loops while the SDK says `should_continue`; the schedule
|
||||
runs nightly and catches up after sleep. R10: the status carries
|
||||
`pipeline.mode` and the last evolve.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from one_memory import hy_sdk_runtime, sdk_write_processor
|
||||
from one_memory.api import _sdk_client_handle, create_app, memory_mode_effects
|
||||
from one_memory.config import Settings
|
||||
from one_memory.hy_graph_admin import effective_memory_mode
|
||||
from one_memory.imports.models import (
|
||||
EvidenceContentKind,
|
||||
EvidenceInput,
|
||||
EvidenceOrigin,
|
||||
OriginMetadata,
|
||||
WrittenBy,
|
||||
materialize_write_input,
|
||||
)
|
||||
from one_memory.memory_evolution import (
|
||||
EvolveModeError,
|
||||
EvolveScheduler,
|
||||
EvolveService,
|
||||
EvolveStateStore,
|
||||
MemoryGraphBackfill,
|
||||
evolve_due,
|
||||
)
|
||||
from one_memory.memory_server.config_state import ConfigStore, EvolveSchedule
|
||||
from one_memory.sdk_memory_runtime import SdkMemoryRuntime
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
|
||||
TOKEN = "memory-mode-test"
|
||||
|
||||
|
||||
# --- the setting and the client mode -----------------------------------------
|
||||
|
||||
|
||||
def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
|
||||
store = ConfigStore(tmp_path)
|
||||
assert store.memory_mode() == "pro"
|
||||
assert effective_memory_mode(store.memory_mode()) == "pro"
|
||||
store.set_memory_mode("lite")
|
||||
assert ConfigStore(tmp_path).memory_mode() == "lite"
|
||||
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "lite"
|
||||
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
|
||||
assert effective_memory_mode("lite") == "ultra"
|
||||
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "none")
|
||||
assert effective_memory_mode("lite") == "lite"
|
||||
with pytest.raises(ValueError):
|
||||
store.set_memory_mode("max") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_an_untouched_install_writes_no_new_settings(tmp_path) -> None:
|
||||
store = ConfigStore(tmp_path)
|
||||
store.set_sync("cursor", True)
|
||||
saved = json.loads((tmp_path / "config.json").read_text())
|
||||
assert "memory_mode" not in saved and "evolve_schedule" not in saved
|
||||
|
||||
|
||||
def test_acquire_client_builds_the_sdk_client_in_the_given_mode(tmp_path, monkeypatch) -> None:
|
||||
built: list[str] = []
|
||||
|
||||
class Client:
|
||||
def __init__(self, *, config, mode, runtime, embedding_service):
|
||||
built.append(mode)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(hy_sdk_runtime, "HyMemoryClient", Client)
|
||||
monkeypatch.setattr(hy_sdk_runtime, "_acquire_runtime", lambda config, key: object())
|
||||
monkeypatch.setattr(hy_sdk_runtime, "_release_runtime", lambda key: None)
|
||||
monkeypatch.setattr(hy_sdk_runtime, "inspect_graph_width_mismatch", lambda config: None)
|
||||
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
|
||||
profile = hy_sdk_runtime.builtin_retrieval_snapshot()
|
||||
for mode in ("lite", "pro", "ultra"):
|
||||
hy_sdk_runtime.acquire_client(profile, api_key=None, data_dir=tmp_path, mode=mode).close()
|
||||
hy_sdk_runtime.acquire_client(profile, api_key=None, data_dir=tmp_path).close()
|
||||
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
|
||||
hy_sdk_runtime.acquire_client(profile, api_key=None, data_dir=tmp_path).close()
|
||||
assert built == ["lite", "pro", "ultra", "pro", "ultra"]
|
||||
|
||||
|
||||
def test_the_read_handle_rebuilds_on_a_mode_or_profile_change(monkeypatch, tmp_path) -> None:
|
||||
"""Reads follow the active profile's embedding (#255) and the memory mode."""
|
||||
from one_memory import sdk_read_handle
|
||||
|
||||
modes = ["pro"]
|
||||
made: list[_FakeHandle] = []
|
||||
|
||||
def handle_for(profile, *, api_key, data_dir, mode=None):
|
||||
handle = _FakeHandle(mode or "pro", profile.profile_id)
|
||||
made.append(handle)
|
||||
return handle
|
||||
|
||||
monkeypatch.setattr(sdk_read_handle, "SdkClientHandle", handle_for)
|
||||
profiles = _Profiles(identity=None)
|
||||
reads = sdk_read_handle.ActiveProfileReadHandle(
|
||||
profiles=profiles, # type: ignore[arg-type]
|
||||
secrets=None, # type: ignore[arg-type]
|
||||
data_dir=tmp_path,
|
||||
memory_mode=lambda: modes[0],
|
||||
)
|
||||
|
||||
async def read():
|
||||
return await reads.client()
|
||||
|
||||
async def scenario():
|
||||
first = await asyncio.create_task(read())
|
||||
assert (first.mode, first.profile_id) == ("pro", "builtin-retrieval")
|
||||
modes[0] = "lite"
|
||||
# A read still in flight on the old client keeps it open.
|
||||
hold = asyncio.Event()
|
||||
holding_client = asyncio.Event()
|
||||
|
||||
async def holding():
|
||||
await reads.client()
|
||||
holding_client.set()
|
||||
await hold.wait()
|
||||
|
||||
modes[0] = "pro"
|
||||
holder = asyncio.create_task(holding())
|
||||
await asyncio.wait_for(holding_client.wait(), 5)
|
||||
modes[0] = "lite"
|
||||
try:
|
||||
assert (await asyncio.create_task(read())).mode == "lite"
|
||||
assert not made[0].closed
|
||||
finally:
|
||||
hold.set()
|
||||
await holder
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
assert made[0].closed
|
||||
assert [item.mode for item in made] == ["pro", "lite"]
|
||||
await reads.close()
|
||||
assert made[1].closed
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
class _FakeHandle:
|
||||
def __init__(self, mode: str, profile_id: str = "p") -> None:
|
||||
self.mode = mode
|
||||
self.profile_id = profile_id
|
||||
self.closed = False
|
||||
|
||||
async def client(self):
|
||||
return self
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _Profiles:
|
||||
def __init__(self, identity=("profile-a", 1)) -> None:
|
||||
self.identity = identity
|
||||
|
||||
def active_identity(self):
|
||||
return self.identity
|
||||
|
||||
def snapshot(self, profile_id, revision):
|
||||
return SimpleNamespace(profile_id=profile_id, has_api_key=False)
|
||||
|
||||
def resolve_snapshot_secret(self, profile_id, revision, secrets):
|
||||
return None
|
||||
|
||||
|
||||
def _coordinator(monkeypatch, modes, profiles=None):
|
||||
opened: list[_FakeHandle] = []
|
||||
|
||||
def handle(profile, **kwargs):
|
||||
value = _FakeHandle(kwargs.get("mode") or "pro", profile.profile_id)
|
||||
opened.append(value)
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(sdk_write_processor, "SdkClientHandle", handle)
|
||||
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
||||
profiles=profiles or _Profiles(), # type: ignore[arg-type]
|
||||
secrets=None, # type: ignore[arg-type]
|
||||
data_dir=None,
|
||||
transport_factory=lambda _profile: None,
|
||||
sedimentation_enabled=lambda: True,
|
||||
memory_mode=lambda: modes[0],
|
||||
)
|
||||
return coordinator, opened
|
||||
|
||||
|
||||
def test_a_mode_change_retires_idle_write_handles_and_keeps_a_busy_one(monkeypatch) -> None:
|
||||
modes = ["pro"]
|
||||
coordinator, opened = _coordinator(monkeypatch, modes)
|
||||
|
||||
async def scenario():
|
||||
async with coordinator.use_client() as client:
|
||||
assert client.mode == "pro"
|
||||
modes[0] = "ultra"
|
||||
await coordinator.retire_stale_modes()
|
||||
# Still in use: closed only when this use ends (#218).
|
||||
assert not opened[0].closed
|
||||
assert opened[0].closed
|
||||
async with coordinator.use_client() as client:
|
||||
assert client.mode == "ultra"
|
||||
modes[0] = "lite"
|
||||
await coordinator.retire_stale_modes()
|
||||
assert opened[1].closed
|
||||
await coordinator.close()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_lite_needs_no_generation_model(monkeypatch) -> None:
|
||||
modes = ["lite"]
|
||||
coordinator, opened = _coordinator(monkeypatch, modes, _Profiles(identity=None))
|
||||
|
||||
async def scenario():
|
||||
async with coordinator.use_client() as client:
|
||||
assert client.mode == "lite"
|
||||
assert client.profile_id == hy_sdk_runtime.BUILTIN_RETRIEVAL_PROFILE_ID
|
||||
modes[0] = "pro"
|
||||
with pytest.raises(RuntimeError, match="model_profile_not_active"):
|
||||
async with coordinator.use_client():
|
||||
pass
|
||||
await coordinator.close()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def _record(text: str, *, extracted: bool, user_id: str = "local", role: str = "user"):
|
||||
record = materialize_write_input(
|
||||
EvidenceInput(
|
||||
user_id=user_id,
|
||||
source_id="one-memory-manual" if extracted else "hermes",
|
||||
conversation_id="session-1",
|
||||
message_id=str(uuid4()),
|
||||
role=role,
|
||||
written_by=WrittenBy.PERSON,
|
||||
created_at=datetime(2026, 10, 1, tzinfo=UTC),
|
||||
verbatim=text,
|
||||
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
||||
)
|
||||
)
|
||||
if extracted:
|
||||
record = record.model_copy(update={"content_kind": EvidenceContentKind.MEMORY_CONTENT})
|
||||
return record
|
||||
|
||||
|
||||
class _WritingSdk:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, object]] = []
|
||||
|
||||
def add(self, messages, **kwargs):
|
||||
self.calls.append(("add", messages))
|
||||
return {"success": True, "raw_ids": ["raw-1"]}
|
||||
|
||||
def add_extracted(self, contents, **kwargs):
|
||||
self.calls.append(("add_extracted", contents))
|
||||
return {"success": True, "memory_ids": ["m-1"]}
|
||||
|
||||
|
||||
class _SdkHandle:
|
||||
def __init__(self, sdk, mode="pro") -> None:
|
||||
self.sdk = sdk
|
||||
self.mode = mode
|
||||
|
||||
async def client(self):
|
||||
return self.sdk
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
|
||||
async def _noop() -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_a_note_in_lite_is_stored_as_raw_and_never_calls_add_extracted() -> None:
|
||||
sdk = _WritingSdk()
|
||||
note = (_record("I prefer green tea", extracted=True),)
|
||||
|
||||
async def scenario():
|
||||
await sdk_write_processor.SdkWriteProcessor(_SdkHandle(sdk), lite=True).process_sdk_job(
|
||||
note, request_id="r-lite", before_hy_invocation=_noop
|
||||
)
|
||||
await sdk_write_processor.SdkWriteProcessor(_SdkHandle(sdk)).process_sdk_job(
|
||||
note, request_id="r-pro", before_hy_invocation=_noop
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert sdk.calls == [
|
||||
("add", [{"role": "user", "content": "I prefer green tea"}]),
|
||||
("add_extracted", ["I prefer green tea"]),
|
||||
]
|
||||
|
||||
|
||||
def test_a_lite_write_runs_through_the_coordinator_without_a_model(monkeypatch) -> None:
|
||||
sdk = _WritingSdk()
|
||||
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
||||
profiles=_Profiles(identity=None), # type: ignore[arg-type]
|
||||
secrets=None, # type: ignore[arg-type]
|
||||
data_dir=None,
|
||||
transport_factory=lambda _profile: None,
|
||||
sedimentation_enabled=lambda: True,
|
||||
memory_mode=lambda: "lite",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sdk_write_processor, "SdkClientHandle", lambda profile, **kwargs: _SdkHandle(sdk, "lite")
|
||||
)
|
||||
turn = (
|
||||
_record("hello", extracted=False),
|
||||
_record("hi there", extracted=False, role="assistant"),
|
||||
)
|
||||
receipt = asyncio.run(
|
||||
coordinator.process_sdk_job(turn, request_id="r-1", before_hy_invocation=_noop)
|
||||
)
|
||||
assert receipt.raw_ids == ("raw-1",)
|
||||
assert [name for name, _ in sdk.calls] == ["add"]
|
||||
|
||||
|
||||
def test_no_raw_is_ever_deleted_in_any_mode() -> None:
|
||||
"""Owner decision: MemHub keeps every raw; there is no retention window."""
|
||||
from one_memory import sdk_memory_runtime
|
||||
|
||||
for name in ("prune_expired_raw", "start_raw_retention", "_raw_retention_loop"):
|
||||
assert not hasattr(SdkMemoryRuntime, name), name
|
||||
assert not hasattr(sdk_memory_runtime, "RAW_RETENTION_DAYS")
|
||||
|
||||
|
||||
def test_mode_switch_effects() -> None:
|
||||
assert memory_mode_effects("lite", "pro") == (
|
||||
"earlier_raw_not_extracted",
|
||||
"lite_search_stops",
|
||||
)
|
||||
assert memory_mode_effects("ultra", "pro") == ("higher_mode_memories_hidden", "evolve_stopped")
|
||||
assert memory_mode_effects("pro", "ultra", graph_backfill=True) == (
|
||||
"existing_memories_added_to_graph",
|
||||
)
|
||||
assert memory_mode_effects("pro", "ultra", graph_backfill=False) == (
|
||||
"graph_backfill_already_running",
|
||||
)
|
||||
assert memory_mode_effects("pro", "lite") == ("higher_mode_memories_hidden",)
|
||||
assert memory_mode_effects("pro", "pro") == ()
|
||||
|
||||
|
||||
# --- evolve ---------------------------------------------------------------------
|
||||
|
||||
|
||||
class _EvolveSdk:
|
||||
"""`evolve` answers `should_continue` for the first rounds, then stops."""
|
||||
|
||||
def __init__(self, continuing: int = 2, *, gate: asyncio.Event | None = None) -> None:
|
||||
self.calls: list[dict] = []
|
||||
self.continuing = continuing
|
||||
self.gate = gate
|
||||
self.graph_builds: list[str] = []
|
||||
self.active = 0
|
||||
self.peak = 0
|
||||
|
||||
def evolve(self, user_id, agent_id="default_agent", max_batches=None):
|
||||
self.active += 1
|
||||
self.peak = max(self.peak, self.active)
|
||||
try:
|
||||
self.calls.append(
|
||||
{"user_id": user_id, "agent_id": agent_id, "max_batches": max_batches}
|
||||
)
|
||||
more = len([call for call in self.calls if call["user_id"] == user_id]) <= (
|
||||
self.continuing
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"request_id": f"evolve-{len(self.calls)}",
|
||||
"tasks_processed": 2,
|
||||
"results": {"schemas": ["private text"]},
|
||||
"cross_domain_sweeper": {"merged": 1},
|
||||
"should_continue": more,
|
||||
"continuation_reason": "remaining_clusters" if more else None,
|
||||
"remaining_clusters": 3 if more else 0,
|
||||
"remaining_actionable_clusters": 1 if more else 0,
|
||||
"remaining_fresh_facts": 0,
|
||||
"cycle_status": "partial" if more else "complete",
|
||||
"batch_failures": [],
|
||||
"elapsed_ms": 5.0,
|
||||
}
|
||||
finally:
|
||||
self.active -= 1
|
||||
|
||||
def build_memory_graph(self, user_id, *, agent_id=None, limit=10000, clear_first=False):
|
||||
self.graph_builds.append(user_id)
|
||||
return {"success": True, "memories_scanned": 4, "nodes": 4}
|
||||
|
||||
|
||||
class _Coordinator:
|
||||
"""The real per-user exclusivity over a stand-in client."""
|
||||
|
||||
def __init__(self, sdk, monkeypatch) -> None:
|
||||
self.inner, _ = _coordinator(monkeypatch, ["ultra"])
|
||||
self.sdk = sdk
|
||||
|
||||
def exclusive_for_evolve(self, user_id):
|
||||
return self.inner.exclusive_for_evolve(user_id)
|
||||
|
||||
@asynccontextmanager
|
||||
async def use_client(self):
|
||||
yield self.sdk
|
||||
|
||||
|
||||
def test_evolve_refuses_outside_ultra(tmp_path, monkeypatch) -> None:
|
||||
service = EvolveService(
|
||||
coordinator=_Coordinator(_EvolveSdk(), monkeypatch),
|
||||
state=EvolveStateStore(tmp_path),
|
||||
memory_mode=lambda: "pro",
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
with pytest.raises(EvolveModeError):
|
||||
service.start("local")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_evolve_loops_while_should_continue_and_records_counts_only(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk(continuing=2)
|
||||
state = EvolveStateStore(tmp_path)
|
||||
service = EvolveService(
|
||||
coordinator=_Coordinator(sdk, monkeypatch), state=state, memory_mode=lambda: "ultra"
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
started = service.start("wife", max_batches=2)
|
||||
assert started.state == "running"
|
||||
done = await service.wait(started.run_id)
|
||||
assert done is not None
|
||||
return done
|
||||
|
||||
done = asyncio.run(scenario())
|
||||
assert done.state == "succeeded" and done.rounds == 3 and done.tasks_processed == 6
|
||||
assert done.cycle_status == "complete" and done.should_continue is False
|
||||
assert {call["agent_id"] for call in sdk.calls} == {"all-apps"}
|
||||
assert {call["max_batches"] for call in sdk.calls} == {2}
|
||||
saved = (tmp_path / "evolve-state.json").read_text()
|
||||
assert "private text" not in saved
|
||||
assert json.loads(saved)["users"]["wife"]["last_result"]["rounds"] == 3
|
||||
|
||||
|
||||
def test_evolve_stops_at_the_round_cap(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk(continuing=100)
|
||||
service = EvolveService(
|
||||
coordinator=_Coordinator(sdk, monkeypatch),
|
||||
state=EvolveStateStore(tmp_path),
|
||||
memory_mode=lambda: "ultra",
|
||||
max_rounds=4,
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
return await service.wait(service.start("local").run_id)
|
||||
|
||||
done = asyncio.run(scenario())
|
||||
assert done is not None and done.rounds == 4 and done.capped is True
|
||||
assert done.state == "succeeded"
|
||||
|
||||
|
||||
def test_one_run_per_user_and_never_beside_that_users_write(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk(continuing=0)
|
||||
coordinator = _Coordinator(sdk, monkeypatch)
|
||||
service = EvolveService(
|
||||
coordinator=coordinator, state=EvolveStateStore(tmp_path), memory_mode=lambda: "ultra"
|
||||
)
|
||||
writing = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def fake_write(self, records, *, request_id, before_hy_invocation):
|
||||
writing.set()
|
||||
await release.wait()
|
||||
return sdk_write_processor.SdkWriteReceipt()
|
||||
|
||||
monkeypatch.setattr(sdk_write_processor.SdkWriteProcessor, "process_sdk_job", fake_write)
|
||||
record = (_record("hello", extracted=False, user_id="wife"),)
|
||||
|
||||
async def scenario():
|
||||
inner = coordinator.inner
|
||||
write = asyncio.create_task(
|
||||
inner.process_sdk_job(record, request_id="w", before_hy_invocation=_noop)
|
||||
)
|
||||
await writing.wait()
|
||||
first = service.start("wife")
|
||||
again = service.start("wife")
|
||||
assert again.run_id == first.run_id
|
||||
other = service.start("local")
|
||||
await service.wait(other.run_id)
|
||||
# The other user is not held; wife's run waits for wife's write.
|
||||
assert sdk.calls and {call["user_id"] for call in sdk.calls} == {"local"}
|
||||
assert inner.evolving("wife")
|
||||
with pytest.raises(RuntimeError, match="memory_evolve_in_progress"):
|
||||
await inner.process_sdk_job(record, request_id="w2", before_hy_invocation=_noop)
|
||||
release.set()
|
||||
await write
|
||||
done = await service.wait(first.run_id)
|
||||
assert done is not None and done.state == "succeeded"
|
||||
assert not inner.evolving("wife")
|
||||
assert sdk.peak == 1
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_a_failed_round_records_its_code(tmp_path, monkeypatch) -> None:
|
||||
class Failing(_EvolveSdk):
|
||||
def evolve(self, user_id, agent_id="default_agent", max_batches=None):
|
||||
return {
|
||||
"success": False,
|
||||
"error_code": "schema_llm_failed",
|
||||
"failure_kind": "llm_error",
|
||||
"error_message": "the provider said something private",
|
||||
}
|
||||
|
||||
state = EvolveStateStore(tmp_path)
|
||||
service = EvolveService(
|
||||
coordinator=_Coordinator(Failing(), monkeypatch), state=state, memory_mode=lambda: "ultra"
|
||||
)
|
||||
done = asyncio.run(service.wait(_start(service, "local")))
|
||||
assert done is not None and done.state == "failed"
|
||||
assert done.error_code == "schema_llm_failed" and done.failure_kind == "llm_error"
|
||||
assert state.user("local").last_error == "schema_llm_failed"
|
||||
assert "private" not in (tmp_path / "evolve-state.json").read_text()
|
||||
|
||||
|
||||
def _start(service: EvolveService, user_id: str) -> str:
|
||||
async def begin() -> str:
|
||||
return service.start(user_id).run_id
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
run_id = loop.run_until_complete(begin())
|
||||
loop.run_until_complete(service.wait(run_id))
|
||||
finally:
|
||||
loop.close()
|
||||
return run_id
|
||||
|
||||
|
||||
# --- the nightly schedule -------------------------------------------------------
|
||||
|
||||
LOCAL = datetime.now().astimezone().tzinfo
|
||||
|
||||
|
||||
def _at(day: int, hour: int, minute: int = 0) -> datetime:
|
||||
return datetime(2026, 11, day, hour, minute, tzinfo=LOCAL)
|
||||
|
||||
|
||||
def test_evolve_is_due_after_the_slot_once_a_day_and_catches_up_after_sleep() -> None:
|
||||
schedule = EvolveSchedule()
|
||||
anchor = _at(1, 12)
|
||||
# Never ran: not at noon on the day ultra was enabled, then at 03:00 next day.
|
||||
assert not evolve_due(_at(1, 13), schedule, None, anchor)
|
||||
assert not evolve_due(_at(2, 2, 59), schedule, None, anchor)
|
||||
assert evolve_due(_at(2, 3, 0), schedule, None, anchor)
|
||||
# Ran at 03:00: not again that day, due again the next night.
|
||||
assert not evolve_due(_at(2, 22), schedule, _at(2, 3, 1), anchor)
|
||||
assert evolve_due(_at(3, 3, 5), schedule, _at(2, 3, 1), anchor)
|
||||
# Asleep at 03:00: a run older than the slot is due on wake, the same day.
|
||||
assert evolve_due(_at(3, 9), schedule, _at(2, 3, 1), anchor)
|
||||
# Last run more than 24h ago is due even before today's slot.
|
||||
assert evolve_due(_at(4, 1), schedule, _at(2, 3, 1), anchor)
|
||||
assert not evolve_due(_at(3, 1), schedule, _at(2, 3, 1), anchor)
|
||||
# Moved and turned off.
|
||||
assert evolve_due(_at(2, 23, 30), EvolveSchedule(time="23:30"), _at(2, 3), anchor)
|
||||
assert not evolve_due(_at(3, 9), EvolveSchedule(enabled=False), _at(1, 3), anchor)
|
||||
|
||||
|
||||
def test_the_scheduler_runs_each_due_user_once_in_ultra_only(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk(continuing=0)
|
||||
state = EvolveStateStore(tmp_path)
|
||||
modes = ["pro"]
|
||||
clock = [_at(1, 12)]
|
||||
service = EvolveService(
|
||||
coordinator=_Coordinator(sdk, monkeypatch),
|
||||
state=state,
|
||||
memory_mode=lambda: modes[0],
|
||||
now=lambda: clock[0],
|
||||
)
|
||||
schedule = [EvolveSchedule()]
|
||||
|
||||
async def users():
|
||||
return ("local", "wife")
|
||||
|
||||
scheduler = EvolveScheduler(
|
||||
service=service,
|
||||
schedule=lambda: schedule[0],
|
||||
users_with_memories=users,
|
||||
now=lambda: clock[0],
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
assert await scheduler.tick() == () # pro: nothing
|
||||
modes[0] = "ultra"
|
||||
assert await scheduler.tick() == () # anchored at noon
|
||||
clock[0] = _at(2, 3, 2)
|
||||
assert await scheduler.tick() == ("local", "wife")
|
||||
assert await scheduler.tick() == () # once a night
|
||||
clock[0] = _at(3, 8) # slept through 03:00
|
||||
assert await scheduler.tick() == ("local", "wife")
|
||||
schedule[0] = EvolveSchedule(enabled=False)
|
||||
clock[0] = _at(5, 8)
|
||||
assert await scheduler.tick() == ()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert [call["user_id"] for call in sdk.calls] == ["local", "wife", "local", "wife"]
|
||||
assert state.user("wife").last_result["trigger"] == "scheduled"
|
||||
|
||||
|
||||
def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk()
|
||||
state = EvolveStateStore(tmp_path)
|
||||
backfill = MemoryGraphBackfill(coordinator=_Coordinator(sdk, monkeypatch), state=state)
|
||||
|
||||
async def scenario():
|
||||
backfill.start(["local", "wife", "local"])
|
||||
await backfill.wait()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert sdk.graph_builds == ["local", "wife"]
|
||||
built = state.graph_build()
|
||||
assert built.state == "succeeded"
|
||||
assert {user: value.state for user, value in built.users.items()} == {
|
||||
"local": "succeeded",
|
||||
"wife": "succeeded",
|
||||
}
|
||||
|
||||
|
||||
# --- the HTTP surface -----------------------------------------------------------
|
||||
|
||||
|
||||
def application(tmp_path: Path):
|
||||
settings = Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=tmp_path,
|
||||
api_token=SecretStr(TOKEN),
|
||||
imports_enabled=False,
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
)
|
||||
return create_app(settings, secret_store=InMemorySecretStore())
|
||||
|
||||
|
||||
def client_for(app) -> AsyncClient:
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {TOKEN}"},
|
||||
)
|
||||
|
||||
|
||||
class _ReadSdk(_EvolveSdk):
|
||||
def memory_stats(self, *, user_id, statuses=None):
|
||||
return {"total": 1, "scenes": {}}
|
||||
|
||||
|
||||
def run_app(tmp_path, monkeypatch, scenario) -> None:
|
||||
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
|
||||
|
||||
async def main() -> None:
|
||||
app = application(tmp_path)
|
||||
sdk = _ReadSdk(continuing=1)
|
||||
|
||||
@asynccontextmanager
|
||||
async def write_client():
|
||||
yield sdk
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
monkeypatch.setattr(app.state.sdk_write_coordinator, "use_client", write_client)
|
||||
async with client_for(app) as client:
|
||||
await scenario(app, sdk, client)
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
current = (await client.get("/api/v1/settings/memory-mode")).json()
|
||||
assert current["mode"] == current["configured_mode"] == "pro"
|
||||
assert current["forced_by_env"] is False
|
||||
assert app.state.sdk_write_coordinator.memory_mode() == "pro"
|
||||
|
||||
lite = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
|
||||
assert lite.status_code == 200, lite.text
|
||||
body = lite.json()
|
||||
assert body["mode"] == "lite" and body["previous_mode"] == "pro"
|
||||
assert body["effects"] == ["higher_mode_memories_hidden"]
|
||||
assert app.state.sdk_write_coordinator.memory_mode() == "lite"
|
||||
assert _sdk_client_handle(app).memory_mode() == "lite"
|
||||
# lite claims queued work without a generation model.
|
||||
from one_memory.api import _sedimentation_claim_enabled
|
||||
|
||||
assert app.state.model_profile_store.active_identity() is None
|
||||
app.state.history_import_gate_released = True
|
||||
assert _sedimentation_claim_enabled(app, "one-memory-manual")
|
||||
|
||||
ultra = await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
|
||||
assert ultra.status_code == 200, ultra.text
|
||||
assert "existing_memories_added_to_graph" in ultra.json()["effects"]
|
||||
await app.state.memory_graph_backfill.wait()
|
||||
assert sdk.graph_builds == ["local"]
|
||||
reread = (await client.get("/api/v1/settings/memory-mode")).json()
|
||||
assert reread["graph_build"]["state"] == "succeeded"
|
||||
|
||||
for bad in ({"mode": "max"}, {"mode": "pro", "x": 1}):
|
||||
assert (await client.put("/api/v1/settings/memory-mode", json=bad)).status_code == 422
|
||||
assert (
|
||||
await client.get("/api/v1/settings/memory-mode", params={"user_id": "x"})
|
||||
).status_code == 422
|
||||
|
||||
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"})
|
||||
forced = (await client.get("/api/v1/settings/memory-mode")).json()
|
||||
assert forced["mode"] == "ultra" and forced["configured_mode"] == "pro"
|
||||
assert forced["forced_by_env"] is True
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
refused = await client.post("/api/v2/evolve", json={})
|
||||
assert refused.status_code == 409
|
||||
assert refused.json()["detail"] == "memory_mode_not_ultra"
|
||||
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
|
||||
await app.state.memory_graph_backfill.wait()
|
||||
started = await client.post(
|
||||
"/api/v2/evolve", json={"user_id": "wife", "agent_id": "all-apps", "max_batches": 2}
|
||||
)
|
||||
assert started.status_code == 202, started.text
|
||||
run = started.json()
|
||||
assert run["state"] == "running" and run["user_id"] == "wife"
|
||||
await app.state.evolve_service.wait(run["run_id"])
|
||||
path = f"/api/v2/evolve/{run['run_id']}"
|
||||
assert (await client.get(path)).status_code == 404
|
||||
done = (await client.get(path, params={"user_id": "wife"})).json()
|
||||
assert done["state"] == "succeeded" and done["rounds"] == 2
|
||||
assert sdk.calls[0] == {"user_id": "wife", "agent_id": "all-apps", "max_batches": 2}
|
||||
|
||||
overview = (await client.get("/api/v2/evolve", params={"user_id": "wife"})).json()
|
||||
assert set(overview["users"]) == {"wife"}
|
||||
assert overview["schedule"] == {"enabled": True, "time": "03:00"}
|
||||
|
||||
processing = (await client.get("/api/v1/memory-processing")).json()
|
||||
pipeline = processing["pipeline"]
|
||||
assert pipeline["mode"] == "ultra"
|
||||
assert pipeline["last_evolve_at"] is not None
|
||||
assert pipeline["evolve"]["users"]["wife"]["last_result"]["state"] == "succeeded"
|
||||
|
||||
for bad in (
|
||||
{"user_id": "a:b"},
|
||||
{"max_batches": 0},
|
||||
{"from_beginning": True},
|
||||
):
|
||||
assert (await client.post("/api/v2/evolve", json=bad)).status_code == 422
|
||||
|
||||
moved = await client.put(
|
||||
"/api/v1/settings/evolve-schedule", json={"time": "04:30", "enabled": False}
|
||||
)
|
||||
assert moved.json() == {"enabled": False, "time": "04:30"}
|
||||
assert (
|
||||
await client.put("/api/v1/settings/evolve-schedule", json={"time": "25:00"})
|
||||
).status_code == 422
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_a_job_written_in_lite_stays_done_and_a_pending_job_takes_the_new_mode(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""lite to pro reprocesses nothing: the mode is read when a job runs."""
|
||||
from one_memory.memory_server.durable_queue import DurableWriteDrain, execute_job
|
||||
|
||||
modes = ["lite"]
|
||||
seen: list[str] = []
|
||||
|
||||
class Sdk:
|
||||
def __init__(self, mode):
|
||||
self.mode = mode
|
||||
|
||||
def add(self, messages, **kwargs):
|
||||
seen.append(self.mode)
|
||||
return {"success": True, "raw_ids": [f"raw-{len(seen)}"]}
|
||||
|
||||
monkeypatch.setattr(
|
||||
sdk_write_processor,
|
||||
"SdkClientHandle",
|
||||
lambda profile, **kwargs: _SdkHandle(Sdk(kwargs["mode"]), kwargs["mode"]),
|
||||
)
|
||||
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
||||
profiles=_Profiles(), # type: ignore[arg-type]
|
||||
secrets=None, # type: ignore[arg-type]
|
||||
data_dir=None,
|
||||
transport_factory=lambda _profile: None,
|
||||
sedimentation_enabled=lambda: True,
|
||||
memory_mode=lambda: modes[0],
|
||||
)
|
||||
|
||||
async def never(_job_id):
|
||||
return None
|
||||
|
||||
async def drain_once(queue) -> int:
|
||||
drain = DurableWriteDrain(queue, execute=never, lease=timedelta(seconds=30))
|
||||
claimed = drain.claim(limit=8)
|
||||
for job_id in claimed:
|
||||
await execute_job(
|
||||
job_id,
|
||||
queue=queue,
|
||||
supervisor=coordinator,
|
||||
evidence=None, # type: ignore[arg-type]
|
||||
)
|
||||
return len(claimed)
|
||||
|
||||
async def scenario(app, _sdk, client):
|
||||
queue = app.state.memory_write_queue
|
||||
for request_id in ("turn-lite", "turn-pro"):
|
||||
written = await client.post(
|
||||
"/api/v2/memory-writes",
|
||||
json={
|
||||
"request_id": request_id,
|
||||
"session_id": f"s-{request_id}",
|
||||
"source_id": "one-memory-manual",
|
||||
"messages": [
|
||||
{"role": "user", "content": request_id},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert written.status_code == 202, written.text
|
||||
if request_id == "turn-lite":
|
||||
assert await drain_once(queue) == 1
|
||||
modes[0] = "pro"
|
||||
await coordinator.retire_stale_modes()
|
||||
assert await drain_once(queue) == 1
|
||||
assert await drain_once(queue) == 0
|
||||
assert queue.get("turn-lite").state == queue.get("turn-pro").state == "completed"
|
||||
assert seen == ["lite", "pro"]
|
||||
await coordinator.close()
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
@@ -6,7 +6,7 @@ import io
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -30,7 +30,6 @@ from one_memory.sdk_data_plane_migration import (
|
||||
MemoryAuthority,
|
||||
SdkDataPlaneMigrationStore,
|
||||
)
|
||||
from one_memory.sdk_memory_runtime import SdkMemoryRuntime
|
||||
from one_memory.sdk_migration_bundle import create_sdk_migration_bundle
|
||||
from one_memory.sdk_migration_runner import (
|
||||
PREPARED_PHASE,
|
||||
@@ -1350,57 +1349,6 @@ def test_raw_http_dto_uses_the_unified_memory_list_shape():
|
||||
assert page.raw[0].custom == {"role": "user"}
|
||||
|
||||
|
||||
def test_sdk_raw_retention_keeps_only_the_latest_three_days():
|
||||
now = datetime(2026, 10, 23, tzinfo=UTC)
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.rows = [
|
||||
{
|
||||
"memory_id": "old",
|
||||
"memory_at": int((now - timedelta(days=4)).timestamp()),
|
||||
},
|
||||
{
|
||||
"memory_id": "recent",
|
||||
"memory_at": int((now - timedelta(days=2)).timestamp()),
|
||||
},
|
||||
]
|
||||
|
||||
def list_memories(self, **_kwargs):
|
||||
return {
|
||||
"vdb": {
|
||||
"memories": list(self.rows),
|
||||
"total": len(self.rows),
|
||||
"limit": 500,
|
||||
"offset": 0,
|
||||
}
|
||||
}
|
||||
|
||||
def delete(self, memory_id):
|
||||
before = len(self.rows)
|
||||
self.rows = [row for row in self.rows if row["memory_id"] != memory_id]
|
||||
return {
|
||||
"success": True,
|
||||
"deleted_count": before - len(self.rows),
|
||||
}
|
||||
|
||||
client = Client()
|
||||
|
||||
class Handle:
|
||||
async def client(self):
|
||||
return client
|
||||
|
||||
runtime = SdkMemoryRuntime(
|
||||
coordinator=None,
|
||||
read_handle=Handle(),
|
||||
write_queue=None,
|
||||
extraction_settings=None,
|
||||
data_dir=Path("."),
|
||||
)
|
||||
assert asyncio.run(runtime.prune_expired_raw(now=now)) == 1
|
||||
assert [row["memory_id"] for row in client.rows] == ["recent"]
|
||||
|
||||
|
||||
# The UI asks which memory data plane is authoritative and must get the
|
||||
# answer the API gates use.
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ class _Recorder:
|
||||
self.opened: list[tuple[str, int | None, str | None]] = []
|
||||
self.closed: list[str] = []
|
||||
|
||||
def __call__(self, profile, *, api_key, data_dir, http_transport_factory=None):
|
||||
def __call__(self, profile, *, api_key, data_dir, http_transport_factory=None, mode="pro"):
|
||||
from one_memory.hy_sdk_runtime import build_sdk_config
|
||||
|
||||
config = build_sdk_config(profile, api_key=api_key, data_dir=data_dir)
|
||||
|
||||
@@ -130,7 +130,7 @@ This exception does not extend to VDB, Coding, prompt, reconcile, or lifecycle.
|
||||
|
||||
- Storage backends:仅 SDK config/env(Chroma/FAISS/Qdrant、Kuzu/Neo4j、cache/history sqlite|mysql、coding sqlite 等)。
|
||||
- Queue:pending `memory_write_jobs` 只表示尚未调用 SDK 的待处理请求,因此没有 SDK raw/memory ID。worker 单次调用 SDK `add()`;SDK 内部完成 raw 写入与 extraction/reconciliation,成功后 MemHub 仅将返回的 raw ID / memory IDs 记作 job outcome。
|
||||
- Raw retention:SDK L1 raw 只保留最近三天,由 Runtime 的 `prune_expired_raw` 执行;每个完整 turn 只保存真人 user 与 assistant,tool 不进入 raw。高层 Chat/Coding/Working memory 不受该窗口影响。
|
||||
- Raw retention:MemHub 不删除任何 SDK L1 raw,任何模式都全部保留(SDK 自身也不删 raw);每个完整 turn 只保存真人 user 与 assistant,tool 不进入 raw。记忆模式(lite / pro / ultra)与 evolve 见 `docs/contracts/memory-mode-evolve-v1.md`。
|
||||
- Recall:Host Bridge / Core 先做宿主安装凭据与 tool grant,再调 **SDK search/get**。当前产品没有逐 memory 的 A-harness→B-harness 可见性开关,默认允许跨 harness 搜索;未来若增加来源限制,直接透传 SDK `source` / `include_sources` / `exclude_sources`,不在 MemHub 建 memory ACL。
|
||||
- Context wire:cutover marker 提交前只使用 legacy Context v1;提交后 Host Bridge 只使用 SDK-native Context v2。两种 schema 原样校验、原样返回,不把 SDK item 补造成 v1 的 `revision`、`evidence_ids`、`paths` 或 `adoption`,已选择的 v2 路径失败时也不回退 legacy 权威。
|
||||
- Ask wire:marker 前保留 Governance Ask v1;marker 后 Web / Host Bridge 只调用 SDK Ask v2。v2 的引用只含 SDK 原生 `memory_id/content/layer/status/source/gmt_created`,生成过程中重复 `get` 校验同一 SDK 快照,不补造 revision、Evidence 或 scopes,SDK 失败不回退 v1。
|
||||
|
||||
@@ -152,6 +152,20 @@ node apps/cli/dist/one-memory.cjs profile --versions --user-id wife --json
|
||||
用于找出覆盖了正确值的错误版本(例如 `用户` / `The user`),再用
|
||||
`memories delete <memory-id> --yes` 删除;对应 `GET /api/v2/user-profile/versions`。
|
||||
|
||||
### 记忆模式与进化(`settings mode` / `evolve`)
|
||||
|
||||
```bash
|
||||
node apps/cli/dist/one-memory.cjs settings mode # 当前模式
|
||||
node apps/cli/dist/one-memory.cjs settings mode ultra # 只显示切换计划,不改动
|
||||
node apps/cli/dist/one-memory.cjs settings mode ultra --yes # 切换
|
||||
node apps/cli/dist/one-memory.cjs evolve --user-id wife --wait # 立即进化(仅 ultra)
|
||||
```
|
||||
|
||||
- 切换不迁移数据:已处理的对话不会重做,排队中的和新的按新模式处理;正在写的一批按旧模式写完。
|
||||
- 任何模式都保留全部原始对话。
|
||||
- `status --json` 与 `progress --json` 输出 `pipeline.mode`、每晚进化时间和每个用户的上次进化。
|
||||
- 契约见 `docs/contracts/memory-mode-evolve-v1.md`。
|
||||
|
||||
## 8. 召回(recall)
|
||||
|
||||
`recall` 两条路径(raw / memory)此前已真机验证。文本输出会标明每条是「原始对话摘录」
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# Memory mode, evolve and the nightly schedule v1
|
||||
|
||||
Status: Core + CLI implemented (PR B of the SDK-surface batch). The App
|
||||
controls are built separately against this contract.
|
||||
|
||||
All routes need the Runtime bearer token. Unknown query parameters and body
|
||||
fields answer `422 {"detail": "request_validation_failed"}`, like every
|
||||
memory route. `user_id` is optional (default `local`) and must match
|
||||
`^[A-Za-z0-9._-]{1,64}$`. The App never sends it.
|
||||
|
||||
## 1. What the modes are
|
||||
|
||||
| Mode | SDK client | What happens to a conversation | What search reads |
|
||||
|---|---|---|---|
|
||||
| `lite` | `mode="lite"` | Stored as one raw turn with one embedding. No model call; no generation model needs to be configured. | Raw turns only (the SDK forces `L1_RAW`). |
|
||||
| `pro` (default) | `mode="pro"` | Stored as raw, then a model extracts memories (System 1). The raw becomes SHADOW once extraction succeeds. | Extracted memories (facts, profile, intentions). |
|
||||
| `ultra` | `mode="ultra"` | Same as pro, and new facts are also projected into the knowledge graph. Every night, evolve runs System 2 for every user. | Same as pro, plus the graph-backed lanes below. |
|
||||
|
||||
The product difference between pro and ultra is the nightly evolve. MemHub ultra
|
||||
uses SDK `mode="ultra"` because `client.evolve` refuses any other mode. Compared
|
||||
with pro, SDK ultra (hy-memory 2.0.9) also turns on the following, so the owner
|
||||
can judge their cost:
|
||||
|
||||
- **The graph store.** Kuzu is always opened in ultra. Client init fails if the
|
||||
graph cannot open, so `PUT ultra` answers `409 graph_store_unavailable` when
|
||||
`kuzu` is missing. In pro it opens only when `MEMORY_ENABLE_GRAPH` is set, and
|
||||
MemHub does not set it. Writes project new L2 facts into the graph.
|
||||
- **The schema lane (L6_SCHEMA).** The search's schema channel runs only when
|
||||
a graph store exists, so in MemHub only in ultra. It returns the schemas that
|
||||
evolve wrote (`schema_limit`, `schema_min_score`).
|
||||
- **Graph-aware ranking on the normal lane.** With a graph store, search adds
|
||||
graph-entity (MENTIONS) boosts when the query names known entities and no
|
||||
source filter is set. The heavier graph stages need the graph *and* their
|
||||
own env flag, and every flag defaults off, so none of them run in MemHub:
|
||||
graph rerank and episode expansion (`MEMORY_GRAPH_RERANK_ENABLED`), topic
|
||||
graph entry, same-series bundle, hierarchy bundle.
|
||||
- **The profile.** `get_user_profile` appends the balanced catalog and the
|
||||
specific supplement from the graph, when the SDK's balanced-catalog flag is
|
||||
on.
|
||||
|
||||
`ONE_MEMORY_GRAPH_STORE` (development only) still forces ultra whatever the
|
||||
setting says. Any value other than empty or `none` does this. The API reports
|
||||
it as `forced_by_env: true`, with `configured_mode` showing the saved choice.
|
||||
|
||||
**Raw is never deleted.** In every mode MemHub keeps every raw turn the SDK
|
||||
stored, and the SDK never deletes raw itself. There is no retention window.
|
||||
|
||||
## 2. Switching mode: what changes and what does not
|
||||
|
||||
The SDK migrates nothing when the mode changes. MemHub applies these rules,
|
||||
and they are the owner's:
|
||||
|
||||
- The mode is read **when a queued job runs**, not when it was queued.
|
||||
- A job that already completed, in any mode, stays completed. It is never
|
||||
reclaimed or reprocessed.
|
||||
- Jobs still waiting (pending, assembling, waiting) and new input run in
|
||||
the new mode.
|
||||
- **A batch in flight finishes in the mode it started in.** Its SDK client is
|
||||
retired only when its last use ends (the #218 use count). The next claim
|
||||
uses the new mode.
|
||||
- **Search follows the new mode at once.** The read client is rebuilt on the
|
||||
next request (the active-profile read handle is keyed by profile and mode); the client it replaces closes once no request still uses it.
|
||||
- **lite → pro/ultra:**
|
||||
- what lite stored stays raw and is not extracted;
|
||||
- lite's raw-only search stops at once;
|
||||
- nothing is re-ingested or backfilled.
|
||||
- **pro/ultra → lite:** memories made by the higher mode stay on disk, but lite
|
||||
search does not serve them. Nothing is deleted.
|
||||
- **ultra → pro/lite:** the nightly evolve stops.
|
||||
- **→ ultra:**
|
||||
- every known user's existing facts are added to the graph once
|
||||
(`build_memory_graph(user_id, limit=10000, clear_first=False)`);
|
||||
- this runs in the background, one user at a time, never beside that
|
||||
user's write;
|
||||
- progress is reported in `graph_build`.
|
||||
- Vector collection names never depend on the mode. The mode is a separate
|
||||
client parameter, not part of the embedding-space identity.
|
||||
|
||||
## 3. `GET /api/v1/settings/memory-mode`
|
||||
|
||||
No parameters. `200` response:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "pro",
|
||||
"configured_mode": "pro",
|
||||
"forced_by_env": false,
|
||||
"modes": ["lite", "pro", "ultra"],
|
||||
"graph_build": {
|
||||
"state": "idle",
|
||||
"started_at": null,
|
||||
"finished_at": null,
|
||||
"users": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `mode` is what SDK clients are built with now; `configured_mode` is the
|
||||
saved setting.
|
||||
- `graph_build.state` is `idle | running | succeeded | failed`. Each entry in
|
||||
`graph_build.users[<user_id>]` is
|
||||
`{state: pending|running|succeeded|failed, memories_scanned, nodes, error_code}`.
|
||||
|
||||
## 4. `PUT /api/v1/settings/memory-mode`
|
||||
|
||||
Body: `{"mode": "lite" | "pro" | "ultra"}`. `200` response: the GET shape,
|
||||
plus these fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"previous_mode": "pro",
|
||||
"effects": ["existing_memories_added_to_graph"]
|
||||
}
|
||||
```
|
||||
|
||||
`effects` is empty when the effective mode did not change. That happens when
|
||||
the mode was already set, or when `ONE_MEMORY_GRAPH_STORE` forces ultra; the
|
||||
saved setting still changes in that case. Otherwise `effects` lists these, in
|
||||
this order:
|
||||
|
||||
| Effect | When | Meaning |
|
||||
|---|---|---|
|
||||
| `earlier_raw_not_extracted` | from lite | What lite stored stays raw; it is not extracted. |
|
||||
| `lite_search_stops` | from lite | lite's raw-only search ends now; search reads memories. |
|
||||
| `higher_mode_memories_hidden` | to a lower mode | Memories of the higher mode stay stored but are not shown. |
|
||||
| `evolve_stopped` | from ultra | The nightly evolve stops. |
|
||||
| `existing_memories_added_to_graph` | to ultra | The one-time graph backfill started (see `graph_build`). |
|
||||
| `graph_backfill_already_running` | to ultra | A backfill from an earlier switch is still running; no second one started. |
|
||||
|
||||
Errors:
|
||||
|
||||
- `409 {"detail": "graph_store_unavailable"}`: `ultra` was asked for, but the
|
||||
graph store package is not installed. Nothing changed.
|
||||
- `422 request_validation_failed`: an unknown mode or an extra field.
|
||||
|
||||
Show the planned effects before calling PUT. Clients can compute them with the
|
||||
table above: the CLI (`memhub settings mode <m>` without `--yes`) and Core
|
||||
(`memory_mode_effects`) use the same rule.
|
||||
|
||||
## 5. Evolve
|
||||
|
||||
### `POST /api/v2/evolve` → `202`
|
||||
|
||||
Body (every field optional):
|
||||
|
||||
```json
|
||||
{"user_id": "local", "agent_id": "all-apps", "max_batches": 3}
|
||||
```
|
||||
|
||||
- `agent_id` is 1..128 characters. The default is `all-apps`, the agent
|
||||
MemHub writes every memory under.
|
||||
- `max_batches` is 1..1000. The default is the SDK's own.
|
||||
|
||||
The response is the run receipt below, with `state: "running"`. The run goes
|
||||
on in the background.
|
||||
|
||||
- **One run per user.** Asking while that user's run is going answers the same
|
||||
receipt, again `202`.
|
||||
- **Never beside that user's writes.** The run waits for the user's write in
|
||||
flight. While it runs, that user's queued jobs wait: they are returned to
|
||||
pending before any SDK call. Other users are not held.
|
||||
- **Loops while the SDK answers `should_continue: true`**, up to 20 rounds. A
|
||||
run stopped by that cap has `capped: true`; start it again to continue.
|
||||
|
||||
Errors:
|
||||
|
||||
- `409 {"detail": "memory_mode_not_ultra"}`: the effective mode is not ultra.
|
||||
- `422`: a malformed `user_id`, `max_batches` outside its range, or an extra
|
||||
field.
|
||||
|
||||
### Run receipt
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "uuid",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"max_batches": null,
|
||||
"trigger": "manual",
|
||||
"state": "succeeded",
|
||||
"started_at": "2026-11-01T03:00:00Z",
|
||||
"finished_at": "2026-11-01T03:00:42Z",
|
||||
"rounds": 2,
|
||||
"tasks_processed": 5,
|
||||
"batch_failures": 0,
|
||||
"cycle_status": "complete",
|
||||
"should_continue": false,
|
||||
"continuation_reason": null,
|
||||
"remaining_clusters": 0,
|
||||
"remaining_actionable_clusters": 0,
|
||||
"remaining_fresh_facts": 0,
|
||||
"capped": false,
|
||||
"elapsed_ms": 41873.2,
|
||||
"error_code": null,
|
||||
"failure_kind": null,
|
||||
"request_id": "sdk-request-id",
|
||||
"results": {},
|
||||
"cross_domain_sweeper": {}
|
||||
}
|
||||
```
|
||||
|
||||
- `trigger` is `manual` or `scheduled`.
|
||||
- `state` is `running`, `succeeded` or `failed`.
|
||||
- `cycle_status` is the SDK's `partial` or `complete`.
|
||||
- `error_code` and `failure_kind` are stable codes on failure. Examples:
|
||||
`memory_evolve_cancelled` (the Runtime stopped mid-run),
|
||||
`hy_memory_evolve_rejected` (the SDK answered `success: false` without a
|
||||
code), or the SDK's own `error_code`. Provider messages never appear.
|
||||
- `results` and `cross_domain_sweeper` are the last round's SDK bodies. They
|
||||
live in memory only and are never written to disk or diagnostics.
|
||||
|
||||
### `GET /api/v2/evolve/{run_id}?user_id=` → receipt
|
||||
|
||||
Returns `404 {"detail": "evolve_run_not_found"}` in two cases:
|
||||
|
||||
- the run belongs to another user (answered exactly like a missing run);
|
||||
- the Runtime restarted since the run. Receipts live in memory, and the last
|
||||
64 are kept.
|
||||
|
||||
### `GET /api/v2/evolve?user_id=` → overview
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "ultra",
|
||||
"schedule": {"enabled": true, "time": "03:00"},
|
||||
"users": {
|
||||
"local": {
|
||||
"last_run_at": "2026-11-01T03:00:00Z",
|
||||
"last_result": {"state": "succeeded", "rounds": 2, "...": "receipt without results"},
|
||||
"last_error": null
|
||||
}
|
||||
},
|
||||
"running": []
|
||||
}
|
||||
```
|
||||
|
||||
- Without `user_id` the overview covers every user; with it, that one user.
|
||||
- `users` survives restarts. It is kept in `<data_dir>/evolve-state.json`,
|
||||
counts and codes only.
|
||||
|
||||
## 6. Nightly schedule
|
||||
|
||||
### `GET /api/v1/settings/evolve-schedule`
|
||||
|
||||
Returns `{"enabled": true, "time": "03:00"}`. `time` is `HH:MM`, 24-hour, in
|
||||
the machine's local time.
|
||||
|
||||
### `PUT /api/v1/settings/evolve-schedule`
|
||||
|
||||
Body: `{"enabled"?: bool, "time"?: "HH:MM"}`. An absent field keeps its
|
||||
value. The response is the saved schedule. A bad time answers `422`.
|
||||
|
||||
How the schedule runs:
|
||||
|
||||
- It runs only in ultra.
|
||||
- It checks every 5 minutes. A user's run is due when either holds:
|
||||
- today's slot has passed, and the user's last run is older than that slot;
|
||||
- the user's last run is more than 24 hours old.
|
||||
|
||||
The second rule is how a machine that slept through 03:00 catches up when
|
||||
it wakes.
|
||||
- A user who never ran is measured from the first time the schedule ran in
|
||||
ultra, not from the epoch. So switching to ultra at noon does not evolve
|
||||
everyone at once; their first run is at the next slot.
|
||||
- Users run one after another.
|
||||
|
||||
The schedule runs every user that has memories. The SDK cannot list users, so
|
||||
Core takes them from its own records:
|
||||
|
||||
- `local`;
|
||||
- every user holding a source grant (a Hermes profile);
|
||||
- every user whose turns or notes passed the write queue.
|
||||
|
||||
A user is kept when `memory_stats` reports any memory or fact for them.
|
||||
|
||||
## 7. Status fields (R10)
|
||||
|
||||
`GET /api/v1/memory-processing` has a new optional field, `pipeline`:
|
||||
|
||||
```json
|
||||
{
|
||||
"pipeline": {
|
||||
"mode": "ultra",
|
||||
"configured_mode": "ultra",
|
||||
"forced_by_env": false,
|
||||
"evolve": {"...": "the overview of section 5"},
|
||||
"last_evolve_at": "2026-11-01T03:00:00Z",
|
||||
"graph_build": {"...": "as in section 3"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`memhub status --json` adds a top-level projection of it:
|
||||
|
||||
```json
|
||||
{
|
||||
"pipeline": {
|
||||
"mode": "ultra",
|
||||
"configured_mode": "ultra",
|
||||
"forced_by_env": false,
|
||||
"last_evolve_at": "...",
|
||||
"evolve_schedule": {"enabled": true, "time": "03:00"},
|
||||
"evolve_running": 0,
|
||||
"evolve_users": {
|
||||
"local": {"last_run_at": "...", "last_error": null, "state": "succeeded"}
|
||||
},
|
||||
"graph_build": "succeeded"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`memhub progress --json` carries the same `pipeline`. Both are `null` against
|
||||
an older Core.
|
||||
|
||||
## 8. CLI
|
||||
|
||||
```bash
|
||||
memhub settings mode # show the current mode
|
||||
memhub settings mode lite # print the plan, change nothing
|
||||
memhub settings mode lite --yes # switch
|
||||
memhub evolve [--user-id <id>] [--agent-id <id>] [--max-batches <n>] [--wait] [--json]
|
||||
```
|
||||
|
||||
## 9. Copy the UI can reuse
|
||||
|
||||
| Key | zh-CN | en | es |
|
||||
|---|---|---|---|
|
||||
| title | 记忆模式 | Memory mode | Modo de memoria |
|
||||
| lite | lite:不调用模型,保留全部原始对话,只在原始对话里检索。 | lite: no model calls. Every original conversation is kept, and search reads only those conversations. | lite: sin llamadas al modelo. Se conservan todas las conversaciones originales y la búsqueda solo las consulta a ellas. |
|
||||
| pro | pro:由模型从对话中提取记忆(System 1)。 | pro: a model extracts memories from your conversations (System 1). | pro: un modelo extrae memorias de tus conversaciones (System 1). |
|
||||
| ultra | ultra:在 pro 之上每晚自动整合记忆(System 2,evolve),并使用知识图谱。 | ultra: pro plus nightly memory consolidation (System 2, evolve), using a knowledge graph. | ultra: pro más una consolidación nocturna de las memorias (System 2, evolve), con un grafo de conocimiento. |
|
||||
| confirm | 切换到 {mode} 前请确认: | Before switching to {mode}, please confirm: | Antes de cambiar a {mode}, confirma lo siguiente: |
|
||||
| earlier_raw_not_extracted | lite 下已处理完的对话不会再被提取;只有尚未处理的和之后的新对话会由模型整理成记忆。 | Conversations already handled in lite are not extracted again; only conversations still waiting, and new ones, become memories. | Las conversaciones ya procesadas en lite no se vuelven a extraer; solo las pendientes y las nuevas se convierten en memorias. |
|
||||
| lite_search_stops | 只在原始对话里的检索会立即停止,改为检索记忆。 | Search over original conversations stops now; search reads memories instead. | La búsqueda en las conversaciones originales se detiene ahora; se buscará en las memorias. |
|
||||
| higher_mode_memories_hidden | 更高模式生成的记忆仍然保存,但在这个模式下不会显示。 | Memories created by a higher mode stay stored but are not shown in this mode. | Las memorias creadas por un modo superior se conservan, pero no se muestran en este modo. |
|
||||
| evolve_stopped | 每晚的自动整合(System 2)会停止。 | Nightly consolidation (System 2) stops. | La consolidación nocturna (System 2) se detiene. |
|
||||
| existing_memories_added_to_graph | 已有记忆会在后台加入知识图谱。 | Your existing memories are added to the knowledge graph in the background. | Tus memorias actuales se añaden al grafo de conocimiento en segundo plano. |
|
||||
| graph_backfill_already_running | 知识图谱正在补建中。 | The knowledge graph is still being built. | El grafo de conocimiento todavía se está construyendo. |
|
||||
| raw_kept | 原始对话全部保留,任何模式都不删除。 | Every original conversation is kept, in every mode. | Se conservan todas las conversaciones originales, en cualquier modo. |
|
||||
| graph_build_running | 正在把已有记忆加入知识图谱… | Adding existing memories to the knowledge graph… | Añadiendo las memorias actuales al grafo de conocimiento… |
|
||||
| forced_by_env | 开发环境变量 ONE_MEMORY_GRAPH_STORE 正在强制使用 ultra。 | The development variable ONE_MEMORY_GRAPH_STORE is forcing ultra. | La variable de desarrollo ONE_MEMORY_GRAPH_STORE está forzando ultra. |
|
||||
| graph_store_unavailable | 这个安装缺少知识图谱组件,无法切换到 ultra。 | This installation lacks the knowledge-graph component, so it cannot switch to ultra. | A esta instalación le falta el componente del grafo de conocimiento; no puede cambiar a ultra. |
|
||||
| schedule | 每天 {time} 自动整合 | Consolidates daily at {time} | Consolida cada día a las {time} |
|
||||
| last_evolve | 上次整合:{time} | Last consolidation: {time} | Última consolidación: {time} |
|
||||
| never_evolved | 尚未整合 | Not consolidated yet | Aún no consolidado |
|
||||
| evolve_not_ultra | 记忆整合只在 ultra 模式下可用。 | Consolidation is available in ultra mode only. | La consolidación solo está disponible en el modo ultra. |
|
||||
@@ -26,8 +26,7 @@ and no raw turn, and does not compare the mirror with the SDK:
|
||||
cannot be trusted to decide what the SDK should contain;
|
||||
- the product has no user editing of memories in practice, so the mirror holds
|
||||
no user intent the SDK lacks;
|
||||
- SDK L1 raw is retained for three days by the Runtime's own
|
||||
`prune_expired_raw`, not by the migration.
|
||||
- SDK L1 raw is kept: neither the migration nor the Runtime deletes raw.
|
||||
|
||||
"Already complete" holds only if the new Runtime opens the same stores. Builds
|
||||
before #145 named a profile's vector collections `one_memory_<12 hex>…`; #145
|
||||
|
||||
Reference in new issue
Block a user