From 6718ba8bdfd54764c00149bf60518a25bb0e4770 Mon Sep 17 00:00:00 2001 From: u7-u7 <116776533+u7-u7@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:41:13 +0800 Subject: [PATCH 01/25] fix(async): preserve context in guarded thread calls --- hy_memory/utils/async_executor.py | 5 ++- tests/test_async_executor_context.py | 51 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/test_async_executor_context.py diff --git a/hy_memory/utils/async_executor.py b/hy_memory/utils/async_executor.py index 0ab0c7e..28afa74 100644 --- a/hy_memory/utils/async_executor.py +++ b/hy_memory/utils/async_executor.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import concurrent.futures +import contextvars import functools from concurrent.futures import Executor from typing import Any, Callable, Optional @@ -39,7 +40,9 @@ async def run_in_executor_guarded( async def to_thread_guarded(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: """Guarded equivalent of ``asyncio.to_thread`` using the default pool.""" - return await run_in_executor_guarded(None, func, *args, **kwargs) + context = contextvars.copy_context() + call = functools.partial(func, *args, **kwargs) + return await run_in_executor_guarded(None, context.run, call) def concurrent_result_guarded( diff --git a/tests/test_async_executor_context.py b/tests/test_async_executor_context.py new file mode 100644 index 0000000..5fb6a47 --- /dev/null +++ b/tests/test_async_executor_context.py @@ -0,0 +1,51 @@ +"""The guarded to_thread adapter preserves asyncio.to_thread context semantics.""" +import asyncio +import contextvars +import importlib.util +from pathlib import Path +import unittest + + +# This helper is dependency-free; exercise it without initializing the SDK. +_spec = importlib.util.spec_from_file_location( + "async_executor_under_test", + Path(__file__).resolve().parents[1] / "hy_memory/utils/async_executor.py", +) +_executor = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_executor) + + +class ThreadContextTests(unittest.IsolatedAsyncioTestCase): + async def test_copies_context_and_does_not_propagate_worker_changes(self): + request_id = contextvars.ContextVar("request_id", default="missing") + request_id.set("caller") + + def work(prefix, *, suffix): + value = request_id.get() + request_id.set("worker") + return prefix + value + suffix + + result = await _executor.to_thread_guarded(work, "[", suffix="]") + self.assertEqual(result, "[caller]") + self.assertEqual(request_id.get(), "caller") + + async def test_concurrent_callers_keep_separate_contexts(self): + request_id = contextvars.ContextVar("request_id", default="missing") + + async def call(value): + request_id.set(value) + await asyncio.sleep(0) + return await _executor.to_thread_guarded(request_id.get) + + self.assertEqual(await asyncio.gather(call("a"), call("b")), ["a", "b"]) + + async def test_context_is_copied_when_coroutine_runs(self): + request_id = contextvars.ContextVar("request_id", default="missing") + request_id.set("created") + pending = _executor.to_thread_guarded(request_id.get) + request_id.set("awaited") + self.assertEqual(await pending, "awaited") + + +if __name__ == "__main__": + unittest.main() From e9698a293cedded3f33e2193239a50d5949eab38 Mon Sep 17 00:00:00 2001 From: u7-u7 <116776533+u7-u7@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:28:14 +0800 Subject: [PATCH 02/25] fix(tokens): count literal special-token spellings as ordinary text --- hy_memory/core/embed_service.py | 5 ++-- hy_memory/utils/token_count.py | 3 +- tests/test_literal_special_token_counting.py | 29 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 tests/test_literal_special_token_counting.py diff --git a/hy_memory/core/embed_service.py b/hy_memory/core/embed_service.py index efe7955..0f9de32 100644 --- a/hy_memory/core/embed_service.py +++ b/hy_memory/core/embed_service.py @@ -80,7 +80,7 @@ def _count_tokens(text: str) -> int: try: import tiktoken - return len(tiktoken.get_encoding("cl100k_base").encode(text)) + return len(tiktoken.get_encoding("cl100k_base").encode(text, disallowed_special=())) except Exception: return len(text) // _CHARS_PER_TOKEN_FALLBACK + 1 @@ -98,7 +98,8 @@ def _truncate_for_embedding(text: str, limit: int = _EMBED_MAX_TOKENS) -> str: import tiktoken enc = tiktoken.get_encoding("cl100k_base") - ids = enc.encode(text) + # Input is ordinary user text, not a tokenizer control-token stream. + ids = enc.encode(text, disallowed_special=()) if len(ids) <= limit: return text dropped = len(ids) - limit diff --git a/hy_memory/utils/token_count.py b/hy_memory/utils/token_count.py index 10e53ad..afbf09d 100644 --- a/hy_memory/utils/token_count.py +++ b/hy_memory/utils/token_count.py @@ -36,7 +36,8 @@ def count_tokens(text: str) -> int: enc = _get_encoder() if enc: try: - return len(enc.encode(text)) + # Quoted control-token spellings are ordinary input text. + return len(enc.encode(text, disallowed_special=())) except Exception as e: logger.debug(f"[token_count] encode failed, char fallback: {e}") return max(1, len(text) // 4) diff --git a/tests/test_literal_special_token_counting.py b/tests/test_literal_special_token_counting.py new file mode 100644 index 0000000..a2eb461 --- /dev/null +++ b/tests/test_literal_special_token_counting.py @@ -0,0 +1,29 @@ +"""User text may quote tokenizer control spellings without using control tokens.""" + +import pytest +import tiktoken + +from hy_memory.core.embed_service import _count_tokens, _truncate_for_embedding +from hy_memory.utils.token_count import count_tokens + + +@pytest.mark.parametrize('counter', [count_tokens, _count_tokens]) +@pytest.mark.parametrize('marker', ['<|endoftext|>', '<|fim_prefix|>']) +def test_literal_special_tokens_are_counted_as_ordinary_text(marker, counter): + text = marker + '你好世界' * 20 + expected = len(tiktoken.get_encoding('cl100k_base').encode_ordinary(text)) + assert counter(text) == expected + + +def test_literal_special_token_does_not_bypass_embedding_limit(): + text = '<|endoftext|>' + '你好世界' * 3000 + encoder = tiktoken.get_encoding('cl100k_base') + truncated = _truncate_for_embedding(text) + assert truncated != text + assert len(encoder.encode_ordinary(truncated)) <= 8192 - 64 + assert truncated.startswith('<|endoftext|>') + + +def test_short_literal_special_token_text_is_preserved(): + text = 'Explain <|endoftext|> as a literal string.' + assert _truncate_for_embedding(text) == text From d76bfa6474ad08b5367d1aa62204f9db24057495 Mon Sep 17 00:00:00 2001 From: u7-u7 <116776533+u7-u7@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:49:54 +0800 Subject: [PATCH 03/25] fix(memhub): preserve migration log write order --- .../src/sdk-migration-log-order.test.ts | 101 ++++++++++++++++++ .../runtime-control/src/sdk-migration-log.ts | 36 +++++-- 2 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 memhub/packages/runtime-control/src/sdk-migration-log-order.test.ts diff --git a/memhub/packages/runtime-control/src/sdk-migration-log-order.test.ts b/memhub/packages/runtime-control/src/sdk-migration-log-order.test.ts new file mode 100644 index 0000000..5e3a891 --- /dev/null +++ b/memhub/packages/runtime-control/src/sdk-migration-log-order.test.ts @@ -0,0 +1,101 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + sdkMigrationLogPath, + writeSdkMigrationLog, +} from "./sdk-migration-log.js"; + +async function events(dataDir: string): Promise { + return (await readFile(sdkMigrationLogPath(dataDir), "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line).event); +} + +describe("SDK migration log ordering", () => { + it("preserves invocation order for concurrent progress and completion writes", async () => { + const dataDir = await mkdtemp( + path.join(os.tmpdir(), "migration-log-order-"), + ); + try { + const expected = Array.from( + { length: 100 }, + (_, i) => `migration.progress.${i}`, + ); + expected.push("migration.finished"); + await Promise.all( + expected.map((event) => + writeSdkMigrationLog(dataDir, { + writer: "desktop", + event, + outcome: "started", + }), + ), + ); + expect(await events(dataDir)).toEqual(expected); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it("awaiting the terminal event also drains preceding progress writes", async () => { + const dataDir = await mkdtemp( + path.join(os.tmpdir(), "migration-log-drain-"), + ); + const pending: Promise[] = []; + try { + const expected = Array.from( + { length: 50 }, + (_, i) => `migration.progress.${i}`, + ); + for (const event of expected) { + pending.push( + writeSdkMigrationLog(dataDir, { + writer: "desktop", + event, + outcome: "started", + }), + ); + } + await writeSdkMigrationLog(dataDir, { + writer: "desktop", + event: "migration.finished", + outcome: "succeeded", + }); + expect(await events(dataDir)).toEqual([ + ...expected, + "migration.finished", + ]); + } finally { + await Promise.all(pending); + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it("does not let a failed append block subsequent writes to the same home", async () => { + const dataDir = await mkdtemp( + path.join(os.tmpdir(), "migration-log-recovery-"), + ); + try { + await writeFile(path.join(dataDir, "logs"), "not a directory"); + await expect( + writeSdkMigrationLog(dataDir, { + writer: "desktop", + event: "migration.progress", + outcome: "started", + }), + ).resolves.toBeUndefined(); + await rm(path.join(dataDir, "logs")); + await writeSdkMigrationLog(dataDir, { + writer: "desktop", + event: "migration.finished", + outcome: "succeeded", + }); + expect(await events(dataDir)).toEqual(["migration.finished"]); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/memhub/packages/runtime-control/src/sdk-migration-log.ts b/memhub/packages/runtime-control/src/sdk-migration-log.ts index 71bc831..ba4936e 100644 --- a/memhub/packages/runtime-control/src/sdk-migration-log.ts +++ b/memhub/packages/runtime-control/src/sdk-migration-log.ts @@ -33,6 +33,9 @@ export const SDK_MIGRATION_LOG_NAME = "sdk-migration.log"; const MAX_BYTES = 4 * 1024 * 1024; const SAFE_TEXT = /^[A-Za-z0-9_.:/@+-]{0,160}$/; const MAX_ITEMS = 32; +// Preserve invocation order within this process, including fire-and-forget +// progress followed by an awaited terminal event. Homes remain independent. +const pendingWrites = new Map>(); export function sdkMigrationLogPath(dataDir: string): string { return path.join(dataDir, "logs", SDK_MIGRATION_LOG_NAME); @@ -103,17 +106,28 @@ export async function writeSdkMigrationLog( platform: process.platform, ...(entry.details === undefined ? {} : { details: entry.details }), }); - const target = sdkMigrationLogPath(dataDir); - await mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); - const size = await stat(target).then( - (info) => info.size, - () => 0, - ); - if (size > MAX_BYTES) await rename(target, `${target}.1`); - await appendFile(target, `${JSON.stringify(line)}\n`, { - encoding: "utf8", - mode: 0o600, - }); + const target = path.resolve(sdkMigrationLogPath(dataDir)); + const previous = pendingWrites.get(target) ?? Promise.resolve(); + const pending = previous + .catch(() => undefined) + .then(async () => { + await mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); + const size = await stat(target).then( + (info) => info.size, + () => 0, + ); + if (size > MAX_BYTES) await rename(target, `${target}.1`); + await appendFile(target, `${JSON.stringify(line)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + }); + pendingWrites.set(target, pending); + try { + await pending; + } finally { + if (pendingWrites.get(target) === pending) pendingWrites.delete(target); + } } catch { // Never let the log decide whether a migration or a startup happens. } From a17230dadb5d940f8cb49b784f8a9c05dfa7a3e1 Mon Sep 17 00:00:00 2001 From: Rahul Ranjan Date: Wed, 28 Oct 2026 00:38:08 +0530 Subject: [PATCH 04/25] fix(runtime): clear leases that predate the current boot A forced power-off leaves runtime-service.lock behind with no chance to release it (#184). After the reboot the liveness probe cannot tell the dead owner from the process that reused its PID, so launchd's KeepAlive restarts the wrapper into runtime_service_already_running without end; and a power cut inside the mkdir -> privateWrite window leaves the lock without owner.json at all, which fails as runtime_service_lock_invalid forever. A lease created before the current boot can never have a living owner: every process from that boot is gone. removeStaleLease now compares the lock directory's mtime with the boot time (os.uptime(), injectable via bootTime for tests) and clears such leases before it ever trusts owner or PID. A two-minute slack absorbs forward clock steps taken since boot, so a lease taken right after boot is not mistaken for stale. Same-boot leases keep the old behavior exactly: live owner still refused, dead owner still reclaimed, corrupt same-boot lease still refused. Fixes #184 Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- .../src/runtime-service.test.ts | 77 +++++++++++++++++++ .../runtime-control/src/runtime-service.ts | 35 +++++++++ 2 files changed, 112 insertions(+) diff --git a/memhub/packages/runtime-control/src/runtime-service.test.ts b/memhub/packages/runtime-control/src/runtime-service.test.ts index 382c206..6629888 100644 --- a/memhub/packages/runtime-control/src/runtime-service.test.ts +++ b/memhub/packages/runtime-control/src/runtime-service.test.ts @@ -94,6 +94,83 @@ describe("RuntimeService", () => { ); }); + it("reclaims a lease older than the current boot even when a recycled PID answers the liveness probe", async () => { + // Issue #184: a forced power-off leaves runtime-service.lock behind; + // after the reboot the owner's PID is another process, so the old + // liveness-only check reports "already running" forever and launchd's + // KeepAlive loops the wrapper. A lease from a previous boot can never + // have a living owner, regardless of what the PID table says. + const dataDir = await directory("runtime-service-prevboot-"); + const lock = path.join(dataDir, "runtime-service.lock"); + await import("node:fs/promises").then(({ mkdir }) => + mkdir(lock, { mode: 0o700 }), + ); + await writeFile( + path.join(lock, "owner.json"), + JSON.stringify({ pid: 777 }), + { + mode: 0o600, + }, + ); + await chmod(lock, 0o700); + const leaseTime = Date.now(); + const service = new RuntimeService({ + dataDir, + createSupervisor: supervisor, + // The recycled PID answers the probe: the liveness check alone + // would refuse forever. + processIsAlive: () => true, + // This boot began minutes after the lease was taken. + bootTime: () => leaseTime + 10 * 60_000, + }); + + await service.start(); + await service.stop(); + }); + + it("reclaims a lease whose owner.json was never written once the machine has rebooted", async () => { + // Issue #184's hard case: a power cut inside the mkdir → privateWrite + // window leaves the lock directory with no owner.json at all. That used + // to be runtime_service_lock_invalid forever; after a reboot it is + // provably from a previous boot and is cleared. + const dataDir = await directory("runtime-service-prevboot- corrupt-"); + const lock = path.join(dataDir, "runtime-service.lock"); + await import("node:fs/promises").then(({ mkdir }) => + mkdir(lock, { mode: 0o700 }), + ); + await chmod(lock, 0o700); + const leaseTime = Date.now(); + const service = new RuntimeService({ + dataDir, + createSupervisor: supervisor, + processIsAlive: () => true, + bootTime: () => leaseTime + 10 * 60_000, + }); + + await service.start(); + await service.stop(); + }); + + it("still refuses a corrupt lease from the current boot", async () => { + // Same-boot corruption stays refused: the boot-time shortcut only + // applies when the lease provably predates the boot. + const dataDir = await directory("runtime-service-corrupt-sameboot-"); + const lock = path.join(dataDir, "runtime-service.lock"); + await import("node:fs/promises").then(({ mkdir }) => + mkdir(lock, { mode: 0o700 }), + ); + await chmod(lock, 0o700); + const service = new RuntimeService({ + dataDir, + createSupervisor: supervisor, + bootTime: () => Date.now() - 60 * 60_000, // booted an hour ago + }); + + await expect(service.start()).rejects.toThrow( + "runtime_service_lock_invalid", + ); + }); + it("reclaims a stale validated lease", async () => { const dataDir = await directory("runtime-service-stale-"); const lock = path.join(dataDir, "runtime-service.lock"); diff --git a/memhub/packages/runtime-control/src/runtime-service.ts b/memhub/packages/runtime-control/src/runtime-service.ts index d3bdc1d..ee6afdc 100644 --- a/memhub/packages/runtime-control/src/runtime-service.ts +++ b/memhub/packages/runtime-control/src/runtime-service.ts @@ -9,6 +9,7 @@ import { rmdir, writeFile, } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { processIsAlive } from "./core-supervisor.js"; @@ -35,6 +36,10 @@ export interface RuntimeServiceOptions { ): ManagedRuntime | Promise; pid?: number; processIsAlive?: (pid: number) => boolean; + /** Wall-clock epoch (ms) of the current boot. The default is derived from + * `os.uptime()`; injectable so tests can put a lease before or after the + * boot without moving the machine's clock. */ + bootTime?: () => number; diagnostics?: DiagnosticSink; } @@ -45,6 +50,9 @@ interface LeaseIdentity { /** Publishes one outer Runtime identity around the Core+sidecar supervisor. */ export class RuntimeService { + /** See removeStaleLease: absorbs a forward clock step since boot. */ + private static readonly BOOT_SLACK_MS = 2 * 60_000; + private readonly options: RuntimeServiceOptions; private lease: LeaseIdentity | null = null; private supervisor: ManagedRuntime | null = null; @@ -223,6 +231,23 @@ export class RuntimeService { ) { throw new Error("runtime_service_lock_unsafe"); } + // A lease created before the current boot cannot have a living owner: + // every process from that boot is gone, and a PID that answers the + // liveness probe now is a different process that merely reused the + // number. Before this check, a forced power-off (issue #184) left + // runtime-service.lock behind, the reboot recycled the owner's PID, and + // launchd's KeepAlive restarted the wrapper into + // runtime_service_already_running without end. + // + // The slack absorbs a forward clock step since boot: an NTP correction + // would otherwise make a lease taken moments after boot look older than + // the boot itself. With the slack, only a jump larger than two minutes + // within that window could mistake a live lease for a stale one. + const bootTime = this.options.bootTime ?? defaultBootTime; + if (info.mtimeMs < bootTime() - RuntimeService.BOOT_SLACK_MS) { + await this.clearLeaseDir(lock); + return; + } let owner: unknown; try { owner = JSON.parse(await readFile(path.join(lock, "owner.json"), "utf8")); @@ -239,6 +264,10 @@ export class RuntimeService { if ((this.options.processIsAlive ?? processIsAlive)(pid)) { throw new Error("runtime_service_already_running"); } + await this.clearLeaseDir(lock); + } + + private async clearLeaseDir(lock: string): Promise { const stale = `${lock}.stale.${randomUUID()}`; await rename(lock, stale); await rm(stale, { recursive: true }); @@ -302,6 +331,12 @@ export class RuntimeService { } } +function defaultBootTime(): number { + // Wall-clock epoch of the current boot, as far as the clock admits today. + // os.uptime() is boot-based (includes suspend) on every supported platform. + return Date.now() - os.uptime() * 1000; +} + async function privateWrite(target: string, value: string): Promise { const temporary = `${target}.${randomUUID()}.tmp`; try { From d4fca0639e24142ccf87626588587cf6cc94c961 Mon Sep 17 00:00:00 2001 From: alvinfei Date: Wed, 28 Oct 2026 11:09:54 +0800 Subject: [PATCH 05/25] feat(sdk): coding and working experiences keep their conversation time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client.add passed memory_at only to the chat path; the productivity writer dropped it and coding_memory_meta / working_fact had no column for it, so every experience had only its write time and MemHub showed it as 时间未知. Both tables gain a nullable memory_at column (epoch seconds), added to existing coding/working databases on open. The writer stamps it on drafts and new facts, defaulting to the request time as chat does; an UPDATE keeps the later of the stored and incoming times. list_memories (coding, working, all), get and search return memory_at in chat's format. Co-Authored-By: Claude Opus 5.5 (1M context) --- hy_memory/client.py | 9 +- hy_memory/coding/fact_reconciler.py | 6 +- hy_memory/coding/reconciler.py | 1 + hy_memory/coding/store.py | 46 +++++- hy_memory/coding/types.py | 14 ++ hy_memory/coding/writer.py | 9 +- tests/coding/test_memory_at.py | 229 ++++++++++++++++++++++++++++ 7 files changed, 306 insertions(+), 8 deletions(-) create mode 100644 tests/coding/test_memory_at.py diff --git a/hy_memory/client.py b/hy_memory/client.py index dd37ed3..895a862 100644 --- a/hy_memory/client.py +++ b/hy_memory/client.py @@ -1544,6 +1544,7 @@ class HyMemoryClient: # LLM 写的"这个结论一路怎么改成现在这样",直接进提示词; # 没被覆盖过就是空串。不参与检索——召回认的是 statement 的当前值。 "fact_chain_summary": fact.fact_chain_summary, + "memory_at": _epoch_seconds(fact.memory_at), "created_at": fact.created_at.isoformat() if fact.created_at else None, "score": round(float(score), 4), "source": fact.source, @@ -1652,6 +1653,8 @@ class HyMemoryClient: "score": (round(float(score_by_id[d.experience_id]), 4) if d.experience_id in score_by_id else None), "confidence": d.confidence, + "memory_at": _epoch_seconds(d.memory_at), + "created_at": d.created_at.isoformat() if d.created_at else None, "updated_at": d.updated_at.isoformat() if d.updated_at else None, "source": d.source, "extraction_source": d.extraction_source, @@ -1953,6 +1956,7 @@ class HyMemoryClient: "content" chat 正文;经验为 "task\nsolution";事实为 statement "score" RRF 融合分;"lane_score" 为该路原始分;"lane_rank" 为该路名次 "source", "created_at", "updated_at"(秒级 epoch,取不到为 None) + "memory_at" 对话时间(秒级 epoch),各路原有字段,三路同名同格式 chat 路内部名次:normal → schema → proactive 依次排;working/coding 路 内部名次:事实与经验各按原顺序交替(事实在前),两层谁也不压谁。 """ @@ -2144,7 +2148,8 @@ class HyMemoryClient: "limit", "offset", "request_id", "elapsed_ms"} 每条带 "scene"、"kind"(chat | experience)、"memory_id"、"content" (经验为 "task\nsolution")、"source"、"status"、"created_at"、 - "updated_at"(秒级 epoch),以及该路原有字段。 + "updated_at"(秒级 epoch),以及该路原有字段(含对话时间 + "memory_at",秒级 epoch,三路同名同格式)。 """ return self._loop_thread.run( self.async_list_memories( @@ -2967,6 +2972,7 @@ class HyMemoryClient: session_id=session_id, request_id=request_id, source=source, + memory_at=memory_at, ) # Public receipt always identifies the productivity namespace. resp["variant"] = scene_variant @@ -5920,6 +5926,7 @@ class HyMemoryClient: "source_fact_ids": list(d.source_fact_ids), "confidence": d.confidence, "status": MemoryStatus.ACTIVE.value, + "memory_at": _epoch_seconds(d.memory_at), "created_at": d.created_at.isoformat() if d.created_at else None, "updated_at": d.updated_at.isoformat() if d.updated_at else None, "source": d.source, diff --git a/hy_memory/coding/fact_reconciler.py b/hy_memory/coding/fact_reconciler.py index 91da943..41ea32e 100644 --- a/hy_memory/coding/fact_reconciler.py +++ b/hy_memory/coding/fact_reconciler.py @@ -454,9 +454,10 @@ class WorkingFactReconciler: workspace_id: Optional[str] = None, branch: Optional[str] = None, source: str = "default", + memory_at: Optional[datetime] = None, ) -> Tuple[List[WorkingFact], List[FactReconcileOp]]: """ - 执行 reconcile + 持久化。 + 执行 reconcile + 持久化。memory_at 是这段对话的时间,记到新事实上。 Returns: (真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖, @@ -493,6 +494,7 @@ class WorkingFactReconciler: workspace_id=workspace_id, branch=branch, source=source, + memory_at=memory_at, ) logger.info( f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} " @@ -781,6 +783,7 @@ class WorkingFactReconciler: workspace_id: Optional[str], branch: Optional[str] = None, source: str = "default", + memory_at: Optional[datetime] = None, ) -> Tuple[List[WorkingFact], List[FactReconcileOp]]: inserted: List[WorkingFact] = [] ops: List[FactReconcileOp] = [] @@ -801,6 +804,7 @@ class WorkingFactReconciler: subject=draft.subject, details=draft.details, status="active", + memory_at=memory_at, ) allowed = {c.fact_id for c in (candidates[idx] if idx < len(candidates) else [])} diff --git a/hy_memory/coding/reconciler.py b/hy_memory/coding/reconciler.py index ed36dd6..56aacc7 100644 --- a/hy_memory/coding/reconciler.py +++ b/hy_memory/coding/reconciler.py @@ -577,6 +577,7 @@ class CodingMemoryReconciler: confidence=d.confidence, source=d.source, extraction_source=d.extraction_source, + memory_at=d.memory_at, ) diff --git a/hy_memory/coding/store.py b/hy_memory/coding/store.py index 5503934..e4e6033 100644 --- a/hy_memory/coding/store.py +++ b/hy_memory/coding/store.py @@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS coding_memory_meta ( confidence REAL NOT NULL DEFAULT 0.7, source TEXT NOT NULL DEFAULT 'default', extraction_source TEXT NOT NULL DEFAULT 'auto_extract', + memory_at INTEGER, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -109,6 +110,7 @@ CREATE TABLE IF NOT EXISTS working_fact ( derived_experience_ids TEXT NOT NULL DEFAULT '[]', status TEXT NOT NULL DEFAULT 'active', fact_chain_summary TEXT NOT NULL DEFAULT '', + memory_at INTEGER, created_at TEXT NOT NULL ); -- 反查前驱(谁的 fact_link 指向我)走这个索引,写演变总结时要顺着链往回走 @@ -164,6 +166,8 @@ _ADDED_COLUMNS = { "coding_memory_meta": { "source_fact_ids": "TEXT NOT NULL DEFAULT '[]'", "extraction_source": "TEXT NOT NULL DEFAULT 'auto_extract'", + # 对话时间,秒级 epoch(同 chat)。老库里的行是 NULL:那时没存过。 + "memory_at": "INTEGER", }, # 早期版本存过一列 fact_chain(机械累积的历代说法),已废弃:同样的信息顺着 # fact_link 反查就有。老库里那一列留着不管,有默认值不影响插入。 @@ -177,6 +181,7 @@ _ADDED_COLUMNS = { "subject": "TEXT NOT NULL DEFAULT ''", "details": "TEXT NOT NULL DEFAULT ''", "source": "TEXT NOT NULL DEFAULT 'default'", + "memory_at": "INTEGER", }, } @@ -290,6 +295,29 @@ def _parse_dt(s: Optional[str]) -> Optional[datetime]: return None +def _from_epoch(value: Any) -> Optional[datetime]: + """memory_at 列(秒级 epoch)→ datetime;NULL 或坏值返回 None。""" + if value is None: + return None + try: + return datetime.fromtimestamp(int(value)) + except Exception: + return None + + +def _to_epoch(value: Optional[datetime]) -> Optional[int]: + return int(value.timestamp()) if isinstance(value, datetime) else None + + +def _later(a: Optional[datetime], b: Optional[datetime]) -> Optional[datetime]: + """两个对话时间取较晚的;一边缺就取另一边。""" + if a is None: + return b + if b is None: + return a + return a if _to_epoch(a) >= _to_epoch(b) else b + + def _row_to_memory(row: sqlite3.Row) -> CodingMemory: """SQLite row → CodingMemory""" return CodingMemory( @@ -308,6 +336,7 @@ def _row_to_memory(row: sqlite3.Row) -> CodingMemory: confidence=row["confidence"], source=row["source"], extraction_source=row["extraction_source"], + memory_at=_from_epoch(row["memory_at"]), created_at=_parse_dt(row["created_at"]), updated_at=_parse_dt(row["updated_at"]), ) @@ -334,6 +363,7 @@ def _row_to_fact(row: sqlite3.Row) -> WorkingFact: # 老库刚 ADD COLUMN 时可能取不到这一列,用 keys() 兜一下 fact_chain_summary=(row["fact_chain_summary"] or "" if "fact_chain_summary" in row.keys() else ""), + memory_at=_from_epoch(row["memory_at"]), created_at=_parse_dt(row["created_at"]), ) @@ -597,6 +627,8 @@ class CodingMemoryStore: memory.updated_at = datetime.utcnow() if memory.created_at is None: memory.created_at = prev.created_at + # 合并后的经验讲的是两段对话,对话时间取较晚的那段 + memory.memory_at = _later(prev.memory_at, memory.memory_at) await _run_in_pool(self._upsert_sql_sync, memory) # 删旧 VDB await self._delete_vec_keys_by_count(memory.experience_id, prev_key_count) @@ -1199,8 +1231,8 @@ class CodingMemoryStore: experience_id, user_id, agent_id, task, search_keys, solution, boundary_envs, workspace_id, branch, session_id, files, source_fact_ids, confidence, source, extraction_source, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + memory_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(experience_id) DO UPDATE SET user_id = excluded.user_id, agent_id = excluded.agent_id, @@ -1216,6 +1248,7 @@ class CodingMemoryStore: confidence = excluded.confidence, source = excluded.source, extraction_source = excluded.extraction_source, + memory_at = excluded.memory_at, updated_at = excluded.updated_at """, ( @@ -1226,6 +1259,7 @@ class CodingMemoryStore: json.dumps(memory.files, ensure_ascii=False), json.dumps(memory.source_fact_ids, ensure_ascii=False), memory.confidence, memory.source, memory.extraction_source, + _to_epoch(memory.memory_at), (memory.created_at or datetime.utcnow()).isoformat(), (memory.updated_at or datetime.utcnow()).isoformat(), ), @@ -1366,8 +1400,8 @@ class CodingMemoryStore: fact_id, user_id, agent_id, session_id, workspace_id, branch, source, statement, files, subject, details, fact_link, derived_experience_ids, - status, fact_chain_summary, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + status, fact_chain_summary, memory_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(fact_id) DO UPDATE SET user_id = excluded.user_id, agent_id = excluded.agent_id, @@ -1382,7 +1416,8 @@ class CodingMemoryStore: fact_link = excluded.fact_link, derived_experience_ids = excluded.derived_experience_ids, status = excluded.status, - fact_chain_summary = excluded.fact_chain_summary + fact_chain_summary = excluded.fact_chain_summary, + memory_at = excluded.memory_at """, ( fact.fact_id, fact.user_id, fact.agent_id, @@ -1395,6 +1430,7 @@ class CodingMemoryStore: json.dumps(fact.derived_experience_ids, ensure_ascii=False), fact.status, fact.fact_chain_summary or "", + _to_epoch(fact.memory_at), (fact.created_at or datetime.utcnow()).isoformat(), ), ) diff --git a/hy_memory/coding/types.py b/hy_memory/coding/types.py index 295be7a..147ddb6 100644 --- a/hy_memory/coding/types.py +++ b/hy_memory/coding/types.py @@ -25,6 +25,11 @@ FactStatus = Literal["active", "superseded"] MAX_FACT_CHAIN_ENTRIES = 8 +def _epoch(value: Optional[datetime]) -> Optional[int]: + """memory_at 对外是秒级 epoch,与 chat 一致。""" + return int(value.timestamp()) if isinstance(value, datetime) else None + + # ================================================================ # 草稿(LLM extract 阶段输出) # ================================================================ @@ -53,6 +58,8 @@ class CodingMemoryDraft: files: List[str] = field(default_factory=list) source: str = "default" extraction_source: str = "auto_extract" + # 对话时间(SDK 注入,同 chat 的 memory_at) + memory_at: Optional[datetime] = None def __post_init__(self) -> None: from ..utils.source_filter import normalize_source @@ -73,6 +80,7 @@ class CodingMemoryDraft: "files": list(self.files), "source": self.source, "extraction_source": self.extraction_source, + "memory_at": _epoch(self.memory_at), } @@ -111,6 +119,9 @@ class CodingMemory: extraction_source: str = "auto_extract" # 时间 + # memory_at 是对话时间(同 chat),created_at / updated_at 是写库时间。 + # UPDATE 合并时取两者中较晚的那个对话时间,见 store.update。 + memory_at: Optional[datetime] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None @@ -135,6 +146,7 @@ class CodingMemory: "confidence": self.confidence, "source": self.source, "extraction_source": self.extraction_source, + "memory_at": _epoch(self.memory_at), "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } @@ -249,6 +261,7 @@ class WorkingFact: fact_chain_summary: str = "" # 时间 + memory_at: Optional[datetime] = None # 对话时间(同 chat),没传时是收到请求的时间 created_at: Optional[datetime] = None # 产生时间,新的优先 def __post_init__(self) -> None: @@ -272,6 +285,7 @@ class WorkingFact: "derived_experience_ids": list(self.derived_experience_ids), "status": self.status, "fact_chain_summary": self.fact_chain_summary, + "memory_at": _epoch(self.memory_at), "created_at": self.created_at.isoformat() if self.created_at else None, } diff --git a/hy_memory/coding/writer.py b/hy_memory/coding/writer.py index c6a8b1e..f4afcd3 100644 --- a/hy_memory/coding/writer.py +++ b/hy_memory/coding/writer.py @@ -18,6 +18,7 @@ CodingWriter - 写入端协调器 import logging import time import uuid +from datetime import datetime from typing import Any, Dict, List, Optional from ..agent.llm_provider import LLMProvider @@ -79,9 +80,11 @@ class CodingWriter: session_id: Optional[str] = None, request_id: Optional[str] = None, source: str = "default", + memory_at: Optional[datetime] = None, ) -> Dict[str, Any]: """ - working/coding 段写入端到端。返回响应 dict(兼容 client.add 返回结构): + working/coding 段写入端到端。memory_at 是对话时间,记到这次写出的经验和 + 事实上;不传则用收到请求的时间(与 chat 链路一致)。返回响应 dict(兼容 client.add 返回结构): { "success": True, "scene": "productivity", @@ -101,6 +104,8 @@ class CodingWriter: """ request_id = request_id or str(uuid.uuid4()) t0 = time.perf_counter() + if memory_at is None: + memory_at = datetime.now() if not messages: return self._empty_response(request_id, t0) @@ -172,6 +177,7 @@ class CodingWriter: # extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。 for draft in drafts: draft.source = source + draft.memory_at = memory_at # 3. 经验层 reconcile + 持久化 ops: List[ReconcileOp] = ( @@ -197,6 +203,7 @@ class CodingWriter: workspace_id=workspace_id, branch=branch, source=source, + memory_at=memory_at, ) experience_ids = [op.target_experience_id for op in ops diff --git a/tests/coding/test_memory_at.py b/tests/coding/test_memory_at.py new file mode 100644 index 0000000..20731b7 --- /dev/null +++ b/tests/coding/test_memory_at.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +"""经验与事实记下对话时间 memory_at,并以秒级 epoch 对外返回(与 chat 一致)。 + +之前生产力链路丢了 add() 传进来的 memory_at,coding_memory_meta / working_fact +也没有这一列,MemHub 里所有经验都显示"时间未知"。 +""" + +import sqlite3 +import time +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest + +from hy_memory.client import HyMemoryClient +from hy_memory.coding.extractor import CodingMemoryExtractor +from hy_memory.coding.reconciler import CodingMemoryReconciler +from hy_memory.coding.store import CodingMemoryStore, _CREATE_TABLE_SQL, _CREATE_FACT_TABLE_SQL +from hy_memory.coding.types import CodingMemory, WorkingFact +from hy_memory.coding.writer import CodingWriter + +from tests.coding._helpers import MockLLMProvider +from tests.coding.test_list_and_stats_by_scene import _client, _unwrapped +from tests.coding.test_write_scene_routing import _messages +from tests.coding.test_writer_e2e import _coding_messages, _extract_resp, _reconcile_resp + +T1 = datetime(2026, 10, 1, 9, 30, tzinfo=timezone.utc) +T2 = T1 + timedelta(days=3) + + +def _epoch(value: datetime) -> int: + return int(value.timestamp()) + + +def _writer(store, embed, llm): + return CodingWriter( + store=store, + extractor=CodingMemoryExtractor(llm), + reconciler=CodingMemoryReconciler(store, embed, llm), + llm_provider=llm, embed_service=embed, + ) + + +@pytest.mark.asyncio +class TestStore: + async def test_round_trip_as_epoch(self, coding_store): + await coding_store.insert(CodingMemory( + experience_id="e1", user_id="u", task="T", solution="S", memory_at=T1, + )) + await coding_store.insert_fact(WorkingFact( + fact_id="f1", user_id="u", statement="x", memory_at=T1, + )) + exp = await coding_store.get_by_id("e1", user_id="u") + fact = await coding_store.get_fact("f1", user_id="u") + assert _epoch(exp.memory_at) == _epoch(T1) + assert exp.to_dict()["memory_at"] == _epoch(T1) + assert fact.to_dict()["memory_at"] == _epoch(T1) + + async def test_missing_time_stays_unknown(self, coding_store): + await coding_store.insert(CodingMemory( + experience_id="e1", user_id="u", task="T", solution="S", + )) + exp = await coding_store.get_by_id("e1", user_id="u") + assert exp.memory_at is None and exp.to_dict()["memory_at"] is None + + @pytest.mark.parametrize("old, new", [(T1, T2), (T2, T1), (T2, None)]) + async def test_update_keeps_the_later_time(self, coding_store, old, new): + await coding_store.insert(CodingMemory( + experience_id="e1", user_id="u", task="T", solution="S", + session_id="s", memory_at=old, + )) + await coding_store.update(CodingMemory( + experience_id="e1", user_id="u", task="T", solution="S2", + session_id="s", memory_at=new, + )) + exp = await coding_store.get_by_id("e1", user_id="u") + assert exp.solution == "S2" + assert _epoch(exp.memory_at) == _epoch(T2) + + async def test_edit_keeps_the_time(self, coding_store): + await coding_store.insert(CodingMemory( + experience_id="e1", user_id="u", task="T", solution="S", memory_at=T1, + )) + await coding_store.edit_experience("e1", user_id="u", solution="S2") + exp = await coding_store.get_by_id("e1", user_id="u") + assert _epoch(exp.memory_at) == _epoch(T1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("variant", ["working", "coding"]) +async def test_existing_database_gains_the_column_on_open(coding_config, mock_embed, variant): + store = CodingMemoryStore(coding_config, embed_service=mock_embed, variant=variant) + # 先按老 schema(没有 memory_at)建库并放一行 + conn = sqlite3.connect(store._db_path) + conn.executescript(_CREATE_TABLE_SQL.replace(" memory_at INTEGER,\n", "")) + conn.executescript(_CREATE_FACT_TABLE_SQL.replace(" memory_at INTEGER,\n", "")) + conn.execute( + "INSERT INTO coding_memory_meta (experience_id, user_id, task, solution, " + "created_at, updated_at) VALUES ('old', 'u', 'T', 'S', '2026-01-01T00:00:00', " + "'2026-01-01T00:00:00')" + ) + conn.commit() + conn.close() + + await store.initialize() + try: + old = await store.get_by_id("old", user_id="u") + assert old is not None and old.memory_at is None + await store.insert_fact(WorkingFact( + fact_id="f1", user_id="u", statement="x", memory_at=T1, + )) + assert _epoch((await store.get_fact("f1")).memory_at) == _epoch(T1) + finally: + await store.close() + # 再开一次不出错(ADD COLUMN 是幂等的) + again = CodingMemoryStore(coding_config, embed_service=mock_embed, variant=variant) + await again.initialize() + await again.close() + + +@pytest.mark.asyncio +class TestWriter: + def _llm(self): + return MockLLMProvider([ + _extract_resp( + [{"task": "打包发布", "solution": "S", "confidence": 0.9}], + [{"statement": "发布脚本在 scripts/publish_package.sh"}], + ), + _reconcile_resp([{"draft_idx": 0, "action": "ADD"}]), + ]) + + async def test_conversation_time_reaches_both_layers(self, coding_store, mock_embed): + resp = await _writer(coding_store, mock_embed, self._llm()).write( + _coding_messages(), user_id="u", session_id="s", memory_at=T1, + ) + exp = await coding_store.get_by_id(resp["experience_ids"][0], user_id="u") + fact = await coding_store.get_fact(resp["fact_ids"][0], user_id="u") + assert _epoch(exp.memory_at) == _epoch(T1) + assert _epoch(fact.memory_at) == _epoch(T1) + + async def test_defaults_to_request_time_like_chat(self, coding_store, mock_embed): + before = int(time.time()) + resp = await _writer(coding_store, mock_embed, self._llm()).write( + _coding_messages(), user_id="u", session_id="s", + ) + exp = await coding_store.get_by_id(resp["experience_ids"][0], user_id="u") + fact = await coding_store.get_fact(resp["fact_ids"][0], user_id="u") + for stamp in (_epoch(exp.memory_at), _epoch(fact.memory_at)): + assert before <= stamp <= int(time.time()) + 1 + + +@pytest.mark.asyncio +async def test_add_passes_the_time_to_the_productivity_writer(monkeypatch): + seen = {} + + async def _judge(*args, **kwargs): + return "working" + + class _Writer: + async def write(self, *args, **kwargs): + seen.update(kwargs) + return {"success": True, "experience_ids": [], "fact_ids": []} + + async def _get_writer(*, variant): + return _Writer() + + monkeypatch.setattr("hy_memory.client.classify_messages_scene", _judge) + ns = SimpleNamespace( + _config=SimpleNamespace(coding=SimpleNamespace(enable=True)), + _coding_llm_provider=object(), + _cache=None, + _get_coding_writer=_get_writer, + _normalize_scene=HyMemoryClient._normalize_scene, + _scene_variant=HyMemoryClient._scene_variant, + _parse_input=HyMemoryClient._parse_input, + ) + await HyMemoryClient._async_add_impl( + ns, _messages(), user_id="u1", agent_id="a1", memory_at=T1, + ) + assert seen["memory_at"] == T1 + + +@pytest.mark.asyncio +async def test_list_get_and_search_return_epoch_seconds(coding_config, mock_embed): + coding = CodingMemoryStore(coding_config, embed_service=mock_embed, variant="coding") + await coding.initialize() + try: + await coding.insert(CodingMemory( + experience_id="e1", user_id="u", task="打包发布", solution="S", memory_at=T1, + )) + await coding.insert_fact(WorkingFact( + fact_id="f1", user_id="u", statement="打包发布", memory_at=T2, + derived_experience_ids=["e1"], + )) + client = _client([], {"coding": coding}) + list_memories = _unwrapped("async_list_memories") + + page = await list_memories(client, user_id="u", scene="coding") + assert page["experiences"][0]["memory_at"] == _epoch(T1) + + merged = await list_memories(client, user_id="u", scene="all") + assert merged["memories"][0]["memory_at"] == _epoch(T1) + + exp = await coding.get_by_id("e1", user_id="u") + got = HyMemoryClient._format_productivity_get( + exp, kind="experience", variant="coding", request_id="r", elapsed_ms=0, + ) + assert got["memory_at"] == _epoch(T1) + + client._embed_service = mock_embed + client._coding_store = coding + + async def _ready(variant="working"): + return None + + client._get_coding_writer = _ready + client._cache = None + resp = await HyMemoryClient._search_coding( + client, target_query="打包发布", user_ids=["u"], workspace_id=None, + branch=None, limit=5, working_fact_min_score=0.0, + working_experience_min_score=0.0, request_id="r", + t0=time.perf_counter(), variant="coding", + ) + assert resp["facts"][0]["memory_at"] == _epoch(T2) + assert resp["experiences"][0]["memory_at"] == _epoch(T1) + unified = HyMemoryClient._search_lane_items("coding", resp) + assert {item["memory_at"] for item in unified} == {_epoch(T1), _epoch(T2)} + finally: + await coding.close() From fc2e51a8cce2a910683e7137f89b0898a414a175 Mon Sep 17 00:00:00 2001 From: alvinfei Date: Wed, 28 Oct 2026 11:09:54 +0800 Subject: [PATCH 06/25] release: hy-memory 2.0.7 Coding and working experiences and facts keep their conversation time (memory_at) and return it from list, get and search. Not yet published to PyPI. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 16 ++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ccecb6..1e29f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,22 @@ Hy Memory and MemHub are versioned and released independently. `hy_node_permissions` proof table. Evidence, governed memories, model profiles, SDK stores, and source/host grants are preserved. +## Hy Memory 2.0.7 — 2026-10-28 + +- Coding and working experiences and facts now keep their conversation time. + `add(..., memory_at=...)` reached only the chat path before; the + productivity path dropped it, so every experience had only its write time + and MemHub showed them as "时间未知" (time unknown). `coding_memory_meta` + and `working_fact` gain a nullable `memory_at` column (epoch seconds) in + both the working and coding stores; existing databases get it on open, and + their old rows stay `None`. Without `memory_at` a write uses the time the + request arrived, as chat does. When an experience is updated by a later + conversation it keeps the later of the two times. +- `list_memories` (`scene="coding"`, `"working"` and `"all"`), `get` and + `search` return `memory_at` for experiences and facts in the same field and + format as chat (epoch seconds, `None` when unknown). Experience search + results now also carry `created_at`. + ## Hy Memory 2.0.6 — 2026-10-27 - Published diff --git a/pyproject.toml b/pyproject.toml index 83a4bf0..c2afd09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hy-memory" -version = "2.0.6" +version = "2.0.7" description = "HY Memory - Industrial-grade dual-system cognitive memory framework" readme = "README.md" requires-python = ">=3.9" From c8b660651e70baa598a45e51b840fae57d5ad1d1 Mon Sep 17 00:00:00 2001 From: alvinfei Date: Wed, 28 Oct 2026 11:23:36 +0800 Subject: [PATCH 07/25] fix(memhub): experiences show their conversation time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the SDK upgrade 124 working/coding experiences read 时间未知: the SDK never stored their conversation time. hy-memory 2.0.7 stores memory_at for experiences and facts; Core now maps it from list, get and search, so the memory library, detail, map list and home summary carry it. An experience stored before 2.0.7 has none. The list labels its write time 写入于 and the detail 写入时间, instead of 时间未知 or calling it an import. Metadata repair also writes the conversation time recovered from the migration bundle into coding_memory_meta / working_fact when the store has the memory_at column. The repair version is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 8 +++ .../core/src/one_memory/hy_sdk_migration.py | 14 ++++- .../apps/core/src/one_memory/hy_sdk_types.py | 4 ++ .../tests/test_memory_library_all_scenes.py | 12 +++++ .../tests/test_sdk_data_plane_migration.py | 11 ++-- memhub/apps/web/src/MemoryMap.test.tsx | 9 +++- memhub/apps/web/src/MemoryMap.tsx | 8 ++- .../web/src/SelectedMemoryDetail.test.tsx | 38 ++++++++++++++ memhub/apps/web/src/SelectedMemoryDetail.tsx | 51 ++++++++++++------- memhub/apps/web/src/sdk-memory-map.test.ts | 34 ++++++++++++- memhub/apps/web/src/sdk-memory-map.ts | 14 ++--- memhub/packages/contracts/src/index.ts | 6 ++- memhub/packages/i18n/src/gui-i18n-current.ts | 3 ++ memhub/packages/i18n/src/gui-i18n-english.ts | 1 + memhub/packages/i18n/src/gui-i18n-memory.ts | 2 + 15 files changed, 178 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e29f07..055f081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ Hy Memory and MemHub are versioned and released independently. ## Unreleased +- MemHub shows when working and coding experiences were talked about. After + the SDK upgrade every one of them read "时间未知" (time unknown) because the + SDK never stored their conversation time; hy-memory 2.0.7 stores it and + Core now passes it through the memory list, detail, search and home + summary. An experience stored before 2.0.7 shows its write time, labelled + "写入于" / "写入时间", instead of "时间未知". Metadata repair also writes + the conversation time it recovers from the migration bundle into the SDK's + coding/working stores when they already have the column. - The SDK migration no longer fails its first run on a home with legacy vectors: reattaching them left a Chroma instance open with its own settings, and the readback that followed could not open the same store diff --git a/memhub/apps/core/src/one_memory/hy_sdk_migration.py b/memhub/apps/core/src/one_memory/hy_sdk_migration.py index cf43a29..b4bd56b 100644 --- a/memhub/apps/core/src/one_memory/hy_sdk_migration.py +++ b/memhub/apps/core/src/one_memory/hy_sdk_migration.py @@ -437,10 +437,13 @@ def _repair_coding( ): if table not in tables: continue + # hy-memory 2.0.7 added the conversation time; older stores lack it. + has_memory_at = "memory_at" in _columns(database, table) rows = database.execute( - f"SELECT {identifier},source,created_at FROM {table}" + f"SELECT {identifier},source,created_at," + f"{'memory_at' if has_memory_at else 'NULL'} FROM {table}" ).fetchall() - for memory_id, source, created_at in rows: + for memory_id, source, created_at, memory_at in rows: desired = mapping.get(str(memory_id)) if desired is None: continue @@ -449,6 +452,13 @@ def _repair_coding( if desired.source and source in {None, "", "default"}: updates.append("source=?") values.append(desired.source) + if ( + has_memory_at + and desired.memory_at is not None + and memory_at != desired.memory_at + ): + updates.append("memory_at=?") + values.append(desired.memory_at) if desired.created_at is not None: desired_created_at = ( datetime.fromtimestamp(desired.created_at, tz=UTC) diff --git a/memhub/apps/core/src/one_memory/hy_sdk_types.py b/memhub/apps/core/src/one_memory/hy_sdk_types.py index 52fd381..d510a79 100644 --- a/memhub/apps/core/src/one_memory/hy_sdk_types.py +++ b/memhub/apps/core/src/one_memory/hy_sdk_types.py @@ -151,6 +151,7 @@ class HySdkMemoryRecord(BaseModel): if not content.strip(): raise HySdkReadError("hy_memory_record_shape_unknown") status = str(raw.get("status") or "active") + memory_at = raw.get("memory_at") created_at = _epoch(raw.get("created_at")) updated_at = _epoch(raw.get("updated_at")) try: @@ -167,6 +168,9 @@ class HySdkMemoryRecord(BaseModel): str(raw["session_id"]) if isinstance(raw.get("session_id"), str) else None ), source=str(raw["source"]) if isinstance(raw.get("source"), str) else None, + # Conversation time; hy-memory 2.0.7 stores it for experiences + # and facts. Older stores leave it unknown. + memory_at=memory_at if isinstance(memory_at, int) else None, gmt_created=created_at, scene=cast(MemoryScene, variant), kind=cast(MemoryKindName, kind), diff --git a/memhub/apps/core/tests/test_memory_library_all_scenes.py b/memhub/apps/core/tests/test_memory_library_all_scenes.py index 439aa18..1d07cb4 100644 --- a/memhub/apps/core/tests/test_memory_library_all_scenes.py +++ b/memhub/apps/core/tests/test_memory_library_all_scenes.py @@ -55,6 +55,9 @@ def chat_row(memory_id, content, *, source="cursor"): } +EXPERIENCE_MEMORY_AT = 1_789_990_000 + + def experience_row(memory_id, task, solution, *, variant="working", source="codebuddy"): return { "experience_id": memory_id, @@ -63,6 +66,8 @@ def experience_row(memory_id, task, solution, *, variant="working", source="code "task": task, "solution": solution, "source": source, + # Conversation time, epoch seconds like chat (hy-memory 2.0.7). + "memory_at": EXPERIENCE_MEMORY_AT, "created_at": "2026-10-01T08:00:00", "updated_at": "2026-10-02T08:00:00", "_variant": variant, @@ -446,6 +451,7 @@ def test_keyword_filter_reads_experience_and_fact_text(tmp_path, monkeypatch): assert experience["content"] == "Fix flaky tests\nPin the pytest seed" assert experience["status"] == "active" assert experience["updated_at"] > experience["created_at"] + assert experience["memory_at"] == EXPERIENCE_MEMORY_AT fact = by_id["fact-1"] assert (fact["scene"], fact["kind"], fact["variant"]) == ("coding", "fact", "coding") assert fact["content"] == "The pytest suite runs in CI" @@ -488,6 +494,10 @@ def test_empty_query_lists_all_scenes_with_source_filter_and_totals(tmp_path, mo "chat", "chat", ] + assert [item["memory_at"] for item in body["memories"][:2]] == [ + EXPERIENCE_MEMORY_AT, + EXPERIENCE_MEMORY_AT, + ] call = sdk.list_calls[0] assert call["scene"] == "all" assert call["include_sources"] is None @@ -527,6 +537,7 @@ def test_detail_edit_delete_pass_kind_and_variant(tmp_path, monkeypatch): assert record["task"] == "Old task" assert record["solution"] == "Old solution" assert record["content"] == "Old task\nOld solution" + assert record["memory_at"] == EXPERIENCE_MEMORY_AT assert sdk.get_calls[-1] == ("exp-1", "experience", "working") # Chat detail with no namespace keeps the plain SDK call. @@ -692,5 +703,6 @@ def test_home_summary_counts_what_the_memory_page_lists(tmp_path, monkeypatch): assert (home_list["offset"], home_list["limit"], home_list["order"]) == (0, 1, "desc") assert body["latest"]["memory_id"] == "exp-w" assert body["latest"]["scene"] == "working" + assert body["latest"]["memory_at"] == EXPERIENCE_MEMORY_AT run_with_sdk(tmp_path, monkeypatch, scenario) diff --git a/memhub/apps/core/tests/test_sdk_data_plane_migration.py b/memhub/apps/core/tests/test_sdk_data_plane_migration.py index 31d41f7..bdf9cf8 100644 --- a/memhub/apps/core/tests/test_sdk_data_plane_migration.py +++ b/memhub/apps/core/tests/test_sdk_data_plane_migration.py @@ -137,10 +137,12 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store): ) coding = data_dir / "hy-memory" / "governed-v2" / "coding_cv.sqlite3" with sqlite3.connect(coding) as database: + # hy-memory 2.0.7 stores the conversation time; working_fact below is + # left without it, as a store not yet opened by 2.0.7. database.execute( "CREATE TABLE coding_memory_meta (" "experience_id TEXT,user_id TEXT,agent_id TEXT,solution TEXT," - "source TEXT,created_at TEXT,updated_at TEXT)" + "source TEXT,memory_at INTEGER,created_at TEXT,updated_at TEXT)" ) database.execute( "CREATE TABLE working_fact (" @@ -149,7 +151,7 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store): ) database.execute( "INSERT INTO coding_memory_meta VALUES " - "('sdk-1','local','scope','remember','default','now','now')" + "('sdk-1','local','scope','remember','default',NULL,'now','now')" ) result = repair_sdk_metadata_from_bundle( @@ -168,10 +170,11 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store): 2026, 10, 18, tzinfo=UTC ) with sqlite3.connect(coding) as database: - source, created_at = database.execute( - "SELECT source,created_at FROM coding_memory_meta WHERE experience_id='sdk-1'" + source, memory_at, created_at = database.execute( + "SELECT source,memory_at,created_at FROM coding_memory_meta WHERE experience_id='sdk-1'" ).fetchone() assert source == "workbuddy" + assert datetime.fromtimestamp(memory_at, tz=UTC) == datetime(2026, 10, 17, tzinfo=UTC) assert created_at.startswith("2026-10-18T00:00:00") repeated = repair_sdk_metadata_from_bundle( diff --git a/memhub/apps/web/src/MemoryMap.test.tsx b/memhub/apps/web/src/MemoryMap.test.tsx index beec1a5..dfbafd2 100644 --- a/memhub/apps/web/src/MemoryMap.test.tsx +++ b/memhub/apps/web/src/MemoryMap.test.tsx @@ -517,7 +517,7 @@ it("D96: the memory page has no raw-dialogue tab and never loads the dialogue li it("D96: lists a memory by when its conversation happened, not when it was generated", () => { const today = new Date().toISOString(); const thisYear = new Date().getFullYear(); - const items = ["第一条", "第二条", "第三条"].map((content, i) => ({ + const items = ["第一条", "第二条", "第三条", "第四条"].map((content, i) => ({ ...memories[0], memory_id: `t${i}`, content, @@ -532,10 +532,11 @@ it("D96: lists a memory by when its conversation happened, not when it was gener total: 519, offset: 100, limit: 100, - source_ids_by_memory: { t0: [], t1: [], t2: [] }, + source_ids_by_memory: { t0: [], t1: [], t2: [], t3: [] }, source_time_by_memory: { t0: { at: `${thisYear}-10-02T10:54:00`, basis: "conversation" }, t1: { at: `${thisYear}-09-30T08:00:00`, basis: "import" }, + t3: { at: `${thisYear}-09-29T08:00:00`, basis: "write" }, }, })} />, @@ -547,6 +548,10 @@ it("D96: lists a memory by when its conversation happened, not when it was gener expect( within(list).getByRole("button", { name: /第二条/ }).textContent, ).toContain("导入于 9月30日"); + // An experience stored without a conversation time names its write time. + expect( + within(list).getByRole("button", { name: /第四条/ }).textContent, + ).toContain("写入于 9月29日"); // D99: the generated day keeps its own line, so a memory made today from an // old conversation still reads as new rather than looking misplaced. expect( diff --git a/memhub/apps/web/src/MemoryMap.tsx b/memhub/apps/web/src/MemoryMap.tsx index b1fc273..7852c4c 100644 --- a/memhub/apps/web/src/MemoryMap.tsx +++ b/memhub/apps/web/src/MemoryMap.tsx @@ -139,7 +139,7 @@ function monthDay(value: string, locale: GuiLocale): string | null { /** D96: when the cited conversation happened, not when the memory was generated. */ function sourceStamp( - time: { at: string; basis: "conversation" | "import" } | undefined, + time: { at: string; basis: "conversation" | "import" | "write" } | undefined, fallback: string | null | undefined, locale: GuiLocale = "zh-CN", ): string { @@ -147,7 +147,11 @@ function sourceStamp( if (time && day) return memoryText( locale, - time.basis === "conversation" ? "对话于 {day}" : "导入于 {day}", + time.basis === "conversation" + ? "对话于 {day}" + : time.basis === "write" + ? "写入于 {day}" + : "导入于 {day}", { day }, ); return dayStamp(fallback, locale); diff --git a/memhub/apps/web/src/SelectedMemoryDetail.test.tsx b/memhub/apps/web/src/SelectedMemoryDetail.test.tsx index 3964c72..4409ff5 100644 --- a/memhub/apps/web/src/SelectedMemoryDetail.test.tsx +++ b/memhub/apps/web/src/SelectedMemoryDetail.test.tsx @@ -176,3 +176,41 @@ it("stamps today's source and conversation time without an evidence page", () => expect(screen.getByText(/对话时间 今天 /)).toBeTruthy(); expect(screen.queryByRole("button", { name: "查看原文" })).toBeNull(); }); +it("shows an experience's conversation time when the SDK stored one", () => { + const today = new Date().toISOString(); + render( + , + ); + expect(screen.getByText(/对话时间 今天 /)).toBeTruthy(); + expect(screen.queryByText(/时间未知/)).toBeNull(); +}); +it("labels an experience without a conversation time by its write time", () => { + render( + , + ); + expect(screen.getByText(/来源 codebuddy · 写入时间 今天 /)).toBeTruthy(); + expect(screen.queryByText(/对话时间/)).toBeNull(); + expect(screen.queryByText(/时间未知/)).toBeNull(); +}); diff --git a/memhub/apps/web/src/SelectedMemoryDetail.tsx b/memhub/apps/web/src/SelectedMemoryDetail.tsx index 6b32b32..10d2ace 100644 --- a/memhub/apps/web/src/SelectedMemoryDetail.tsx +++ b/memhub/apps/web/src/SelectedMemoryDetail.tsx @@ -19,7 +19,10 @@ export function SelectedMemoryDetail({ detail: MemoryDetail; sources: SourceResponse[]; /** D99: when the cited conversation happened, in the list's own terms. */ - conversationAt?: { at: string; basis: "conversation" | "import" } | null; + conversationAt?: { + at: string; + basis: "conversation" | "import" | "write"; + } | null; }) { const { locale, t } = useGuiLanguage(); const memory = detail.memory; @@ -42,6 +45,17 @@ export function SelectedMemoryDetail({ ? t("selectedMemory.status.superseded") : t("selectedMemory.status.unknown"); const sourceId = detail.source_id ?? evidence?.source_id; + const created = clockStamp( + detail.sdk_created_at ?? memory.updated_at, + locale, + ); + // An SDK experience or fact stored before hy-memory 2.0.7 has no + // conversation time. It shows when it was written, named as such, instead + // of "时间未知". + const writtenOnly = + detail.sdk_ref !== undefined && + detail.sdk_ref.kind !== "chat" && + !detail.sdk_memory_at; const source = detail.sdk_status && sourceId === UNATTRIBUTED_SOURCE_ID ? t("selectedMemory.sourceUnattributed") @@ -77,25 +91,28 @@ export function SelectedMemoryDetail({

{t("selectedMemory.sourceLine", { source, - time: clockStamp( - detail.sdk_memory_at ?? evidence?.created_at, - locale, - ), + time: writtenOnly + ? t("selectedMemory.writeTime", { created }) + : clockStamp( + detail.sdk_memory_at ?? evidence?.created_at, + locale, + ), })}

) : null} -

- {t("selectedMemory.timestamps", { - conversation: clockStamp( - conversationAt?.at ?? detail.sdk_memory_at ?? evidence?.created_at, - locale, - ), - created: clockStamp( - detail.sdk_created_at ?? memory.updated_at, - locale, - ), - })} -

+ {writtenOnly ? null : ( +

+ {t("selectedMemory.timestamps", { + conversation: clockStamp( + conversationAt?.at ?? + detail.sdk_memory_at ?? + evidence?.created_at, + locale, + ), + created, + })} +

+ )} ); } diff --git a/memhub/apps/web/src/sdk-memory-map.test.ts b/memhub/apps/web/src/sdk-memory-map.test.ts index 55d8667..0b18c92 100644 --- a/memhub/apps/web/src/sdk-memory-map.test.ts +++ b/memhub/apps/web/src/sdk-memory-map.test.ts @@ -119,8 +119,12 @@ it("carries each memory's scene and namespace, and edits an experience's solutio }); expect(page.window_id).toBe("w1"); expect(page.window_reset).toBe(true); - // An experience has no conversation time to show as "imported". - expect(page.source_time_by_memory?.e).toBeUndefined(); + // Stored without a conversation time: it shows when it was written, not + // an "import". + expect(page.source_time_by_memory?.e).toEqual({ + at: new Date((NOW - 86_400) * 1_000).toISOString(), + basis: "write", + }); expect(page.items[1]).toMatchObject({ kind: "working", updated_at: new Date((NOW - 3_600) * 1_000).toISOString(), @@ -140,3 +144,29 @@ it("carries each memory's scene and namespace, and edits an experience's solutio expect(sdkEditableText(experience)).toBe("Pin seed"); expect(sdkEditableText(record({}))).toBe("memory"); }); + +it("shows an experience's conversation time when the SDK stored one", () => { + const at = NOW - 2 * 86_400; + const experience = record({ + memory_id: "e", + layer: "experience", + scene: "coding", + kind: "experience", + variant: "coding", + memory_at: at, + created_at: NOW - 3_600, + }); + const page = sdkLibraryPage({ + memories: [experience], + total: 1, + limit: 20, + offset: 0, + }); + expect(page.source_time_by_memory?.e).toEqual({ + at: new Date(at * 1_000).toISOString(), + basis: "conversation", + }); + expect(sdkDetail(experience).sdk_memory_at).toBe( + new Date(at * 1_000).toISOString(), + ); +}); diff --git a/memhub/apps/web/src/sdk-memory-map.ts b/memhub/apps/web/src/sdk-memory-map.ts index 8e48829..ba13781 100644 --- a/memhub/apps/web/src/sdk-memory-map.ts +++ b/memhub/apps/web/src/sdk-memory-map.ts @@ -154,22 +154,24 @@ export function sdkDetail(record: SdkMemoryRecord): MemoryDetail { export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage { const sourceTime: Record< string, - { at: string; basis: "conversation" | "import" } + { at: string; basis: "conversation" | "import" | "write" } > = {}; const sourceIds: Record = {}; const refs: Record = {}; for (const record of page.memories) { sourceIds[record.memory_id] = [sourceOf(record)]; refs[record.memory_id] = sdkMemoryRef(record); - // An experience or fact has no conversation time; the list shows when it - // was last updated instead of calling its creation an import. - if (refs[record.memory_id].kind !== "chat") continue; + // An experience or fact stored before hy-memory 2.0.7 has no + // conversation time. It shows when it was written, labelled as such, + // rather than calling its creation an import. + const chat = refs[record.memory_id].kind === "chat"; const conversation = plausible(record.memory_at); - const at = conversation ?? plausible(record.gmt_created); + const at = + conversation ?? plausible(chat ? record.gmt_created : record.created_at); if (at === null) continue; sourceTime[record.memory_id] = { at: new Date(at * 1_000).toISOString(), - basis: conversation !== null ? "conversation" : "import", + basis: conversation !== null ? "conversation" : chat ? "import" : "write", }; } return { diff --git a/memhub/packages/contracts/src/index.ts b/memhub/packages/contracts/src/index.ts index 2ca034a..1d443ba 100644 --- a/memhub/packages/contracts/src/index.ts +++ b/memhub/packages/contracts/src/index.ts @@ -468,10 +468,12 @@ export interface MemoryLibraryPage { limit: number; /** Active evidence sources for each returned memory, from the same authorized page snapshot. */ source_ids_by_memory?: Record; - /** When each returned memory's cited conversation happened (D96). `import` when only the import time is known. */ + /** When each returned memory's cited conversation happened (D96). `import` + * when only the import time is known; `write` for an SDK experience or fact + * stored without a conversation time, which shows when it was written. */ source_time_by_memory?: Record< string, - { at: string; basis: "conversation" | "import" } + { at: string; basis: "conversation" | "import" | "write" } >; /** True when the read budget ran out mid-page (typically during a history * import): a partial snapshot, not an authoritative view. Show "importing" diff --git a/memhub/packages/i18n/src/gui-i18n-current.ts b/memhub/packages/i18n/src/gui-i18n-current.ts index 489a093..6eea397 100644 --- a/memhub/packages/i18n/src/gui-i18n-current.ts +++ b/memhub/packages/i18n/src/gui-i18n-current.ts @@ -108,6 +108,7 @@ export const currentChineseMessages = { "selectedMemory.version": "第 {revision} 版", "selectedMemory.sourceLine": "来源 {source} · {time}", "selectedMemory.timestamps": "对话时间 {conversation} · 沉淀时间 {created}", + "selectedMemory.writeTime": "写入时间 {created}", "selectedMemory.edit": "编辑", "selectedMemory.evidence": "原始依据", "selectedMemory.evidenceLine": "{author} · 原对话 {time}:", @@ -258,6 +259,7 @@ export const currentEnglishMessages: Record< "selectedMemory.sourceLine": "Source {source} · {time}", "selectedMemory.timestamps": "Conversation time {conversation} · Created {created}", + "selectedMemory.writeTime": "Written {created}", "selectedMemory.edit": "Edit", "selectedMemory.evidence": "Original evidence", "selectedMemory.evidenceLine": "{author} · Original conversation {time}:", @@ -418,6 +420,7 @@ export const currentSpanishMessages: Record< "selectedMemory.sourceLine": "Origen {source} · {time}", "selectedMemory.timestamps": "Conversación {conversation} · Guardada {created}", + "selectedMemory.writeTime": "Escrita {created}", "selectedMemory.edit": "Editar", "selectedMemory.evidence": "Evidencia original", "selectedMemory.evidenceLine": "{author} · Conversación original {time}:", diff --git a/memhub/packages/i18n/src/gui-i18n-english.ts b/memhub/packages/i18n/src/gui-i18n-english.ts index 8cd10e6..e5952ef 100644 --- a/memhub/packages/i18n/src/gui-i18n-english.ts +++ b/memhub/packages/i18n/src/gui-i18n-english.ts @@ -1774,6 +1774,7 @@ const baseEnglishMessages = { " · Import count is still updating; refresh to check", "memory:对话于 {day}": "Conversation on {day}", "memory:导入于 {day}": "Imported on {day}", + "memory:写入于 {day}": "Written on {day}", "memory:已开始处理所选对话": "Processing selected conversations", "memory:处理请求已受理": "Processing request received", "memory:模型连接已通过。首批记忆生成后,会逐步出现在记忆列表里。": diff --git a/memhub/packages/i18n/src/gui-i18n-memory.ts b/memhub/packages/i18n/src/gui-i18n-memory.ts index d27ee09..9db1fb3 100644 --- a/memhub/packages/i18n/src/gui-i18n-memory.ts +++ b/memhub/packages/i18n/src/gui-i18n-memory.ts @@ -336,6 +336,7 @@ export const memoryChineseMessages = { "memory: · 导入数量仍在更新,可刷新核对": " · 导入数量仍在更新,可刷新核对", "memory:对话于 {day}": "对话于 {day}", "memory:导入于 {day}": "导入于 {day}", + "memory:写入于 {day}": "写入于 {day}", "memory:已开始处理所选对话": "已开始处理所选对话", "memory:处理请求已受理": "处理请求已受理", "memory:模型连接已通过。首批记忆生成后,会逐步出现在记忆列表里。": @@ -750,6 +751,7 @@ export const memorySpanishMessages: Record< " · La importación continúa; actualiza para verificar", "memory:对话于 {day}": "Conversación del {day}", "memory:导入于 {day}": "Importada el {day}", + "memory:写入于 {day}": "Escrita el {day}", "memory:已开始处理所选对话": "Procesamiento de conversaciones iniciado", "memory:处理请求已受理": "Solicitud de procesamiento aceptada", "memory:模型连接已通过。首批记忆生成后,会逐步出现在记忆列表里。": From 812d1c38467557e01c3d80ea7db580df086ddf7d Mon Sep 17 00:00:00 2001 From: alvinfei Date: Wed, 28 Oct 2026 11:24:20 +0800 Subject: [PATCH 08/25] build(memhub): pin hy-memory 2.0.7 2.0.7 stores and returns the conversation time of working/coding experiences. apps/core/uv.lock is not updated: 2.0.7 is not on PyPI yet, so the lock is regenerated (uv lock --directory apps/core) once it is published. Co-Authored-By: Claude Opus 5.5 (1M context) --- memhub/README.md | 4 ++-- memhub/SBOM.spdx.json | 2 +- memhub/THIRD_PARTY_NOTICES.md | 2 +- memhub/apps/core/pyproject.toml | 2 +- memhub/apps/core/src/one_memory/hy_runtime_config.py | 2 +- memhub/apps/core/tests/test_sdk_migration_bundle.py | 2 +- memhub/docs/ARCHITECTURE.md | 2 +- memhub/scripts/generate_sbom.py | 2 +- memhub/scripts/test_generate_sbom.py | 6 +++--- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/memhub/README.md b/memhub/README.md index 0c06ec2..df3bd58 100644 --- a/memhub/README.md +++ b/memhub/README.md @@ -18,7 +18,7 @@ If you run into a problem, please open an Issue with the version, platform,repro ## 发行与平台合同 -源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.6`。MemHub 源码版本为 `1.0.5` +源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.7`。MemHub 源码版本为 `1.0.5` (build 14),GitHub 仓库为 [`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为 `memhub-cli`。当前代码架构见 @@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime ## Hy Memory -SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.6`。SDK 配置、权限边界及 search +SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.7`。SDK 配置、权限边界及 search 返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的 逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。 diff --git a/memhub/SBOM.spdx.json b/memhub/SBOM.spdx.json index f5f91d3..10d6b9f 100644 --- a/memhub/SBOM.spdx.json +++ b/memhub/SBOM.spdx.json @@ -2352,7 +2352,7 @@ { "SPDXID": "SPDXRef-Package-hy-memory", "name": "hy-memory", - "versionInfo": "2.0.6", + "versionInfo": "2.0.7", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "Apache-2.0", diff --git a/memhub/THIRD_PARTY_NOTICES.md b/memhub/THIRD_PARTY_NOTICES.md index 58c3ab6..a8895bb 100644 --- a/memhub/THIRD_PARTY_NOTICES.md +++ b/memhub/THIRD_PARTY_NOTICES.md @@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license column is filled in as part of release review, and the SBOM records `NOASSERTION` until then rather than guessing. -## hy-memory 2.0.6 +## hy-memory 2.0.7 License: Apache-2.0 diff --git a/memhub/apps/core/pyproject.toml b/memhub/apps/core/pyproject.toml index ea2dce7..316bdcc 100644 --- a/memhub/apps/core/pyproject.toml +++ b/memhub/apps/core/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "fastapi>=0.141.1", "filelock>=3.32.4", # Public SDK version validated against MemHub's permission and recall contracts. - "hy-memory==2.0.6", + "hy-memory==2.0.7", # chromadb arrives through the HY SDK; locked here so an unrelated upgrade # cannot move the SDK's store. Built-in embedding consumes numpy directly. "chromadb==1.5.9", diff --git a/memhub/apps/core/src/one_memory/hy_runtime_config.py b/memhub/apps/core/src/one_memory/hy_runtime_config.py index 097e450..0012252 100644 --- a/memhub/apps/core/src/one_memory/hy_runtime_config.py +++ b/memhub/apps/core/src/one_memory/hy_runtime_config.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import Literal, cast HY_MEMORY_DISTRIBUTION = "hy-memory" -HY_MEMORY_VERSION = "2.0.6" +HY_MEMORY_VERSION = "2.0.7" HY_MEMORY_NAMESPACE = "governed-v2" DiagnosticLogLevel = Literal["error", "warning", "info", "debug"] diff --git a/memhub/apps/core/tests/test_sdk_migration_bundle.py b/memhub/apps/core/tests/test_sdk_migration_bundle.py index 19381b4..ebb4e3d 100644 --- a/memhub/apps/core/tests/test_sdk_migration_bundle.py +++ b/memhub/apps/core/tests/test_sdk_migration_bundle.py @@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path): assert manifest["bundle_id"] == "migration-1" assert manifest["sdk_namespace"] == "governed-v2" assert manifest["runtime_build"] == "build-13" - assert manifest["snapshot_sdk_version"] == "2.0.6" + assert manifest["snapshot_sdk_version"] == "2.0.7" paths = {item["path"]: item for item in manifest["files"]} assert { "management/one-memory.sqlite3", diff --git a/memhub/docs/ARCHITECTURE.md b/memhub/docs/ARCHITECTURE.md index c6af59e..7e7adfc 100644 --- a/memhub/docs/ARCHITECTURE.md +++ b/memhub/docs/ARCHITECTURE.md @@ -282,7 +282,7 @@ Support is declared per immutable GitHub Release manifest: - Linux x64/arm64 Runtime on native GHA runners. - macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance. - Windows is not supported. -- `hy-memory==2.0.6` is the current stable SDK dependency; the release +- `hy-memory==2.0.7` is the current stable SDK dependency; the release lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes. See [`INSTALLATION.md`](INSTALLATION.md). diff --git a/memhub/scripts/generate_sbom.py b/memhub/scripts/generate_sbom.py index 60212ba..bd20d27 100644 --- a/memhub/scripts/generate_sbom.py +++ b/memhub/scripts/generate_sbom.py @@ -36,7 +36,7 @@ MEMMY_LICENSE = ( MEMMY_PROVENANCE = MEMMY_LICENSE.with_name("PROVENANCE.md") MEMMY_PIN = "25acd5f29fc7df73b943713abe61f1b2f9839887" HY_MEMORY_NAME = "hy-memory" -HY_MEMORY_VERSION = "2.0.6" +HY_MEMORY_VERSION = "2.0.7" class SbomError(RuntimeError): diff --git a/memhub/scripts/test_generate_sbom.py b/memhub/scripts/test_generate_sbom.py index bda070d..ca69a7f 100644 --- a/memhub/scripts/test_generate_sbom.py +++ b/memhub/scripts/test_generate_sbom.py @@ -13,7 +13,7 @@ spec.loader.exec_module(module) def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> None: - dist_info = tmp_path / "hy_memory-2.0.6.dist-info" + dist_info = tmp_path / "hy_memory-2.0.7.dist-info" licenses = dist_info / "licenses" licenses.mkdir(parents=True) (dist_info / "METADATA").write_text( @@ -21,7 +21,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> ( "Metadata-Version: 2.4", "Name: hy-memory", - "Version: 2.0.6", + "Version: 2.0.7", "License-Expression: Apache-2.0", "", ) @@ -38,7 +38,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> notice = module.hy_memory_notice(dist_info) assert notice["name"] == "hy-memory" - assert notice["version"] == "2.0.6" + assert notice["version"] == "2.0.7" assert notice["license"] == "Apache-2.0" assert notice["copyright"] == "Copyright (C) 2026 Tencent. All rights reserved." assert "Licensed under the Apache License" in notice["text"] From cb7df2a022175aea87685d7a498c0f2f4f0ed844 Mon Sep 17 00:00:00 2001 From: alvinfei Date: Wed, 28 Oct 2026 11:32:26 +0800 Subject: [PATCH 09/25] fix(memhub): metadata repair adds the SDK's memory_at column itself The repair wrote memory_at only when the coding store already had the column, which depended on the cutover readback having opened the store through a 2.0.7 client first. On a migrated 1.0.4 home it now brings each coding.sqlite3 / coding_cv.sqlite3 to the installed SDK's schema before repairing, by running CodingMemoryStore's own SQLite schema step (its initialize() would also open Chroma, which must stay closed offline). The SDK's DDL is not repeated here. The test now starts from a 2.0.6-shaped store without the column and expects the bundle's conversation time in it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../core/src/one_memory/hy_sdk_migration.py | 30 ++++++++++++- .../tests/test_sdk_data_plane_migration.py | 43 +++++++++++++------ 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/memhub/apps/core/src/one_memory/hy_sdk_migration.py b/memhub/apps/core/src/one_memory/hy_sdk_migration.py index b4bd56b..df3d6c0 100644 --- a/memhub/apps/core/src/one_memory/hy_sdk_migration.py +++ b/memhub/apps/core/src/one_memory/hy_sdk_migration.py @@ -426,6 +426,7 @@ def _repair_coding( path = root / name if not path.is_file(): continue + _ensure_coding_schema(path) with closing(sqlite3.connect(path)) as database: tables = { str(row[0]) @@ -437,7 +438,8 @@ def _repair_coding( ): if table not in tables: continue - # hy-memory 2.0.7 added the conversation time; older stores lack it. + # _ensure_coding_schema added it; an SDK without the column + # (before 2.0.7) leaves the time to the vector repair alone. has_memory_at = "memory_at" in _columns(database, table) rows = database.execute( f"SELECT {identifier},source,created_at," @@ -480,6 +482,32 @@ def _repair_coding( return repaired +def _ensure_coding_schema(path: Path) -> None: + """Bring a Coding/Working store to the installed SDK's schema. + + A migrated home's stores were written by an older SDK and may lack columns + the repair writes (``memory_at`` since hy-memory 2.0.7). The repair runs + before any SDK client opens them, so the SDK's own schema step is run here + rather than repeating its DDL. ``CodingMemoryStore`` has no public + schema-only entry point: ``initialize()`` also opens its Chroma + collections, which must stay closed during the offline repair. So only its + SQLite step runs, on a store object that carries nothing but that path. + """ + from hy_memory.coding.store import ( # type: ignore[import-untyped] + CodingMemoryStore, + ) + + # Relies on the pinned SDK's internals (hy-memory==2.0.7 is pinned exactly). + store = CodingMemoryStore.__new__(CodingMemoryStore) + store._db_path = str(path) + store._conn = None + try: + store._init_sqlite_sync() + finally: + if store._conn is not None: + store._conn.close() + + def _batches[T](values: list[T] | tuple[T, ...]) -> Iterator[tuple[T, ...]]: for start in range(0, len(values), _BATCH): yield tuple(values[start : start + _BATCH]) diff --git a/memhub/apps/core/tests/test_sdk_data_plane_migration.py b/memhub/apps/core/tests/test_sdk_data_plane_migration.py index bdf9cf8..e628e2a 100644 --- a/memhub/apps/core/tests/test_sdk_data_plane_migration.py +++ b/memhub/apps/core/tests/test_sdk_data_plane_migration.py @@ -137,21 +137,38 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store): ) coding = data_dir / "hy-memory" / "governed-v2" / "coding_cv.sqlite3" with sqlite3.connect(coding) as database: - # hy-memory 2.0.7 stores the conversation time; working_fact below is - # left without it, as a store not yet opened by 2.0.7. - database.execute( - "CREATE TABLE coding_memory_meta (" - "experience_id TEXT,user_id TEXT,agent_id TEXT,solution TEXT," - "source TEXT,memory_at INTEGER,created_at TEXT,updated_at TEXT)" + # A store written by hy-memory 2.0.6, before the conversation time + # column: the repair must add it through the SDK and then fill it. + database.executescript( + """ + CREATE TABLE coding_memory_meta ( + experience_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, + agent_id TEXT NOT NULL DEFAULT 'default_agent', task TEXT NOT NULL, + search_keys TEXT NOT NULL DEFAULT '[]', solution TEXT NOT NULL, + boundary_envs TEXT NOT NULL DEFAULT '', workspace_id TEXT, + branch TEXT, session_id TEXT, files TEXT NOT NULL DEFAULT '[]', + source_fact_ids TEXT NOT NULL DEFAULT '[]', + confidence REAL NOT NULL DEFAULT 0.7, + source TEXT NOT NULL DEFAULT 'default', + extraction_source TEXT NOT NULL DEFAULT 'auto_extract', + created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + CREATE TABLE working_fact ( + fact_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, + agent_id TEXT NOT NULL DEFAULT 'default_agent', session_id TEXT, + workspace_id TEXT, branch TEXT, source TEXT NOT NULL DEFAULT 'default', + statement TEXT NOT NULL, files TEXT NOT NULL DEFAULT '[]', + subject TEXT NOT NULL DEFAULT '', details TEXT NOT NULL DEFAULT '', + fact_link TEXT, derived_experience_ids TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'active', + fact_chain_summary TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL + ); + """ ) database.execute( - "CREATE TABLE working_fact (" - "fact_id TEXT,user_id TEXT,agent_id TEXT,statement TEXT," - "source TEXT,created_at TEXT)" - ) - database.execute( - "INSERT INTO coding_memory_meta VALUES " - "('sdk-1','local','scope','remember','default',NULL,'now','now')" + "INSERT INTO coding_memory_meta " + "(experience_id,user_id,agent_id,task,solution,source,created_at,updated_at) " + "VALUES ('sdk-1','local','scope','remember','remember','default','now','now')" ) result = repair_sdk_metadata_from_bundle( From 9db5b49ccb0816f0d0210eb4b8b200353d459c93 Mon Sep 17 00:00:00 2001 From: alvinfei Date: Wed, 28 Oct 2026 11:37:23 +0800 Subject: [PATCH 10/25] build(memhub): lock hy-memory 2.0.7 from PyPI Co-Authored-By: Claude Opus 5.5 (1M context) --- memhub/apps/core/uv.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/memhub/apps/core/uv.lock b/memhub/apps/core/uv.lock index 83e11dd..bd68691 100644 --- a/memhub/apps/core/uv.lock +++ b/memhub/apps/core/uv.lock @@ -991,7 +991,7 @@ wheels = [ [[package]] name = "hy-memory" -version = "2.0.6" +version = "2.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chromadb" }, @@ -1008,9 +1008,9 @@ dependencies = [ { name = "scikit-learn" }, { name = "tiktoken" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/98/312ac8bcbe41e63200a93d296300b2e6748e1a532dad01f009dad57ba408/hy_memory-2.0.6.tar.gz", hash = "sha256:d0f54d54867ee7e722a8dd1a88a00c0576904435f15ae7899fe640840bff74bc", size = 855200, upload-time = "2026-10-27T07:53:10.022Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/b5/87a43c8d8b77fdfbbe7974d1e93af50ddf82317c5d56e180b865fb19e181/hy_memory-2.0.7.tar.gz", hash = "sha256:85636487e5bd4b6595a8c15301499efb365b6010c236a45c51c144f6986a4222", size = 854913, upload-time = "2026-10-28T03:35:38.127Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/f1/d4866cffe426d77b857168cc694b15df866f3d9f45152c17c148e1595db4/hy_memory-2.0.6-py3-none-any.whl", hash = "sha256:b0e666f8e89d1763bb31c0e67502df74434134a638ff6fd1912de9e936be2483", size = 918955, upload-time = "2026-10-27T07:53:07.686Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b8/a6e0bfd17aeb432b0470f4dc707271e6476f5ac34f3b301ab3ad2e1c0d83/hy_memory-2.0.7-py3-none-any.whl", hash = "sha256:80a501e17987202ee1150a91b071313e890300620f353f9e89c967286dc1a645", size = 919841, upload-time = "2026-10-28T03:35:35.399Z" }, ] [[package]] @@ -1837,7 +1837,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.141.1" }, { name = "filelock", specifier = ">=3.32.4" }, { name = "httpx", specifier = ">=0.28.1" }, - { name = "hy-memory", specifier = "==2.0.6" }, + { name = "hy-memory", specifier = "==2.0.7" }, { name = "keyring", specifier = ">=25.7.0" }, { name = "numpy", specifier = "==2.5.2" }, { name = "onnxruntime", specifier = "==1.23.2" }, From 5d51513ec81563c33ce7d40af72971ca9be23194 Mon Sep 17 00:00:00 2001 From: alvinfei Date: Wed, 28 Oct 2026 11:37:30 +0800 Subject: [PATCH 11/25] docs: record hy-memory 2.0.7 as published Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 055f081..0a44dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,8 @@ Hy Memory and MemHub are versioned and released independently. ## Hy Memory 2.0.7 — 2026-10-28 +- Published + [`hy-memory==2.0.7`](https://pypi.org/project/hy-memory/2.0.7/). - Coding and working experiences and facts now keep their conversation time. `add(..., memory_at=...)` reached only the chat path before; the productivity path dropped it, so every experience had only its write time From cfae020a31ef60ac892e8601ad075cfb3eb0b666 Mon Sep 17 00:00:00 2001 From: alvinfei Date: Wed, 28 Oct 2026 11:44:42 +0800 Subject: [PATCH 12/25] test(memhub): install docs name hy-memory 2.0.7 Co-Authored-By: Claude Opus 5.5 (1M context) --- memhub/scripts/install-docs.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/memhub/scripts/install-docs.test.mjs b/memhub/scripts/install-docs.test.mjs index 00147d8..3ff91bf 100644 --- a/memhub/scripts/install-docs.test.mjs +++ b/memhub/scripts/install-docs.test.mjs @@ -12,7 +12,7 @@ test("installation docs disclose publication and lifecycle boundaries", async () "utf8", ); - assert.match(readme, /`hy-memory==2\.0\.6`/); + assert.match(readme, /`hy-memory==2\.0\.7`/); assert.match(readme, /`v1\.0\.5` 的发行目标是 Linux x64\/arm64 Runtime/); assert.match(readme, /Linux 制品由 GitHub Actions/); assert.match(readme, /macOS.*Developer ID 签名、公证/s); From 32539e7a823d49bf22ea9ab815b11967d122b0ae Mon Sep 17 00:00:00 2001 From: alvinfei Date: Wed, 28 Oct 2026 11:24:28 +0800 Subject: [PATCH 13/25] chore(release): bump MemHub build number to 34 Co-Authored-By: Claude Opus 5.5 (1M context) --- memhub/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/memhub/package.json b/memhub/package.json index 2bd53b6..20b0ecb 100644 --- a/memhub/package.json +++ b/memhub/package.json @@ -2,7 +2,7 @@ "name": "one-memory", "version": "1.0.5", "release": { - "build_number": 33, + "build_number": 34, "macos_min_version": "13.0", "app_update": "manual", "runtime_manifest_schema": 1, From 30c0b1d5ffd8c86edebaaa9493d27b01ce337b7c Mon Sep 17 00:00:00 2001 From: alvinfei Date: Wed, 28 Oct 2026 11:58:19 +0800 Subject: [PATCH 14/25] fix(memhub): refresh harness skills and MCP entry after an upgrade After an upgrade, hosts kept the call-rule skill and MCP entry an older release wrote, so models were still told to call MemHub_ask and the other removed tools, and WorkBuddy kept listing them from its tool-list cache. - Stamp every managed MCP entry with ONE_MEMORY_MCP_TOOLSET, a tool contract version kept beside the tool list (mcp-toolset.ts). WorkBuddy fingerprints the stdio transport, env included, so a new stamp makes it restart the server and drop the cached list. An entry without the current stamp reads as stale in skill drift; verify still passes it. - HostPermissions.refreshInstalledHosts runs the existing repoint and stale-sync planners for every host this data dir installed, leaves user-edited skills alone, emits allowlisted host_refresh.* diagnostics (host ids and counts only), and records the build it completed in host-permissions/host-refresh.json so it runs once per build. - The Runtime calls it as it starts (covering App and `memhub upgrade`), and Desktop calls it after an embedded Runtime is up. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/host-control-ownership.test.ts | 9 + .../apps/desktop/src/host-permissions.test.ts | 197 ++++++++++++++++++ memhub/apps/desktop/src/main.ts | 18 ++ .../contracts/one-memory-recall-install-v1.md | 7 +- .../host-bridge/src/install-contract.test.ts | 6 +- .../host-bridge/src/mcp-granted-tools.test.ts | 12 ++ .../packages/host-bridge/src/mcp-session.ts | 2 + .../packages/host-bridge/src/mcp-toolset.ts | 29 +++ .../src/opencode-installer.test.ts | 2 + .../host-bridge/src/recall-installer.ts | 12 +- .../host-bridge/src/skill-drift.test.ts | 55 +++++ .../packages/host-bridge/src/skill-drift.ts | 51 ++++- .../runtime-control/src/host-permissions.ts | 157 +++++++++++++- memhub/packages/runtime-control/src/index.ts | 1 + .../runtime-control/src/runtime-cli.test.ts | 1 + .../runtime-control/src/runtime-cli.ts | 12 +- 16 files changed, 559 insertions(+), 12 deletions(-) create mode 100644 memhub/packages/host-bridge/src/mcp-toolset.ts diff --git a/memhub/apps/desktop/src/host-control-ownership.test.ts b/memhub/apps/desktop/src/host-control-ownership.test.ts index 67b6be5..993fd5f 100644 --- a/memhub/apps/desktop/src/host-control-ownership.test.ts +++ b/memhub/apps/desktop/src/host-control-ownership.test.ts @@ -15,6 +15,7 @@ const fake = vi.hoisted(() => ({ missingBroker: false, hostCapability: true, open: vi.fn(), + refreshHosts: vi.fn(async (_layouts: unknown, _options: unknown) => ({})), host: vi.fn< (input: { hostId: string; @@ -274,7 +275,9 @@ beforeEach(async () => { fake.open.mockResolvedValue({ hostGrantStore: {}, repairInstalledCommand: vi.fn(async () => []), + refreshInstalledHosts: fake.refreshHosts, }); + fake.refreshHosts.mockClear(); fake.host.mockResolvedValue({ state: "synthetic-remote-result" }); Object.defineProperty(process, "resourcesPath", { configurable: true, @@ -590,5 +593,11 @@ describe("packaged Desktop host-control ownership", () => { expect(fake.ensureRuntime).not.toHaveBeenCalled(); expect(fake.createClient).not.toHaveBeenCalled(); expect(fake.windows).toHaveLength(1); + // The embedded Runtime is this process, so Desktop refreshes the hosts + // it installed once the Runtime is up, as a standalone one would itself. + expect(fake.refreshHosts).toHaveBeenCalledTimes(1); + expect(fake.refreshHosts.mock.calls[0]?.[1]).toMatchObject({ + diagnostics: expect.anything(), + }); }); }); diff --git a/memhub/apps/desktop/src/host-permissions.test.ts b/memhub/apps/desktop/src/host-permissions.test.ts index 6593032..f2be754 100644 --- a/memhub/apps/desktop/src/host-permissions.test.ts +++ b/memhub/apps/desktop/src/host-permissions.test.ts @@ -21,6 +21,8 @@ import { claudeCodeLayout, codexLayout, cursorLayout, + MCP_TOOLSET_KEY, + MCP_TOOLSET_VERSION, mcpConfigPath, piLayout, planRecallInstall, @@ -29,6 +31,7 @@ import { workbuddyLayout, workbuddyScatteredLayout, } from "@one-memory/host-bridge/installer"; +import type { DiagnosticInput } from "@one-memory/runtime-control"; import { afterEach, describe, expect, it, vi } from "vitest"; import { probeRecallInstall } from "../../../packages/host-bridge/src/recall-probe.js"; import { skillFilePath } from "../../../packages/host-bridge/src/skill-drift.js"; @@ -1384,3 +1387,197 @@ describe("upgrading installs left by an earlier release", () => { ); }); }); + +describe("refreshing installed hosts after an upgrade", () => { + function recorder() { + const events: DiagnosticInput[] = []; + return { + events, + sink: { + log: async (event: DiagnosticInput) => { + events.push(event); + }, + }, + }; + } + + async function linkedWorkbuddy(context: Awaited>) { + const layout = workbuddyLayout(path.join(context.root, "workbuddy")); + const preview = await context.permissions.previewInstall(layout); + const result = await context.permissions.install( + layout, + preview.fingerprint, + ); + await context.permissions.setPermission( + layout, + approval(required(result.permission.receipt)), + ); + return layout; + } + + /** Put back the skill an earlier release wrote, journaled as ours. */ + async function ageSkill(layout: ReturnType) { + const skillPath = required(skillFilePath(layout)); + const aged = (await readFile(skillPath, "utf8")) + .replace(/skill-version=\d+/, "skill-version=15") + .replace("`MemHub_recall`", "`MemHub_ask`"); + await writeFile(skillPath, aged); + const journal = JSON.parse(await readFile(layout.journalPath, "utf8")); + journal.installed_skill_hash = createHash("sha256") + .update(aged) + .digest("hex"); + await writeFile(layout.journalPath, JSON.stringify(journal)); + return aged; + } + + it("rewrites an old skill on the first start of a build, and only once per build", async () => { + const context = await fixture(); + const layout = await linkedWorkbuddy(context); + const current = await readFile(required(skillFilePath(layout)), "utf8"); + await ageSkill(layout); + const { events, sink } = recorder(); + + const first = await context.permissions.refreshInstalledHosts([layout], { + build: "34", + diagnostics: sink, + }); + expect(first).toMatchObject({ + state: "refreshed", + synced: ["workbuddy"], + failed: [], + }); + expect(await readFile(required(skillFilePath(layout)), "utf8")).toBe( + current, + ); + + // Same build again: one file read, nothing rewritten, even if stale. + const aged = await ageSkill(layout); + expect( + await context.permissions.refreshInstalledHosts([layout], { + build: "34", + diagnostics: sink, + }), + ).toMatchObject({ state: "already_refreshed", synced: [] }); + expect(await readFile(required(skillFilePath(layout)), "utf8")).toBe(aged); + + // The next build refreshes again. + expect( + await context.permissions.refreshInstalledHosts([layout], { + build: "35", + }), + ).toMatchObject({ state: "refreshed", synced: ["workbuddy"] }); + expect(await readFile(required(skillFilePath(layout)), "utf8")).toBe( + current, + ); + + // Diagnostics carry host ids and counts only, never file content. + expect(events.map((event) => event.event)).toEqual([ + "host_refresh.host_updated", + "host_refresh.completed", + "host_refresh.skipped", + ]); + expect(events[0].details).toEqual({ source_id: "workbuddy" }); + expect(events[1].details).toEqual({ + count: 1, + updated_count: 1, + error_count: 0, + }); + expect(JSON.stringify(events)).not.toContain("MemHub_"); + expect(JSON.stringify(events)).not.toContain(context.root); + }); + + it("leaves a skill the user edited untouched", async () => { + const context = await fixture(); + const layout = await linkedWorkbuddy(context); + const edited = `${await ageSkill(layout)}\nUser addition\n`; + await writeFile(required(skillFilePath(layout)), edited); + + expect( + await context.permissions.refreshInstalledHosts([layout], { + build: "34", + }), + ).toMatchObject({ state: "refreshed", synced: [], failed: [] }); + expect(await readFile(required(skillFilePath(layout)), "utf8")).toBe( + edited, + ); + }); + + it("rewrites an MCP entry without the tool set stamp, in the plugin manifest too", async () => { + const context = await fixture(); + const layout = await linkedWorkbuddy(context); + const plugin = required(layout.plugin); + const manifests = [ + path.join(plugin.installRoot, ".codebuddy-plugin", "plugin.json"), + path.join( + plugin.marketplaceRoot, + "plugins", + plugin.pluginName, + ".codebuddy-plugin", + "plugin.json", + ), + ]; + // What an earlier release wrote: the same entry with no stamp, journaled. + const journal = JSON.parse(await readFile(layout.journalPath, "utf8")); + for (const manifestPath of manifests) { + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + expect(manifest.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe( + MCP_TOOLSET_VERSION, + ); + delete manifest.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]; + const text = `${JSON.stringify(manifest, null, 2)}\n`; + await writeFile(manifestPath, text); + const entry = journal.plugin_files.find( + (file: { path: string }) => file.path === manifestPath, + ); + entry.hash = createHash("sha256").update(text).digest("hex"); + journal.installed_server_hash = createHash("sha256") + .update(JSON.stringify(manifest.mcpServers["one-memory"])) + .digest("hex"); + } + await writeFile(layout.journalPath, JSON.stringify(journal)); + const granted = await context.permissions.getPermission(layout); + expect(granted.state).toBe("approved"); + expect( + (await readInstalledRecallCommand(layout))?.env[MCP_TOOLSET_KEY], + ).toBeUndefined(); + + expect( + await context.permissions.refreshInstalledHosts([layout], { + build: "34", + }), + ).toMatchObject({ synced: ["workbuddy"], failed: [] }); + for (const manifestPath of manifests) { + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + expect(manifest.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe( + MCP_TOOLSET_VERSION, + ); + } + // Rewriting our own entry keeps the user's authorization. + expect(await context.permissions.getPermission(layout)).toEqual(granted); + }); + + it("rewrites a config-file MCP entry without the stamp", async () => { + const context = await fixture(); + await install(context); + const configPath = mcpConfigPath(context.layout); + const config = JSON.parse(await readFile(configPath, "utf8")); + delete config.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]; + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); + const journal = JSON.parse( + await readFile(context.layout.journalPath, "utf8"), + ); + journal.installed_server_hash = createHash("sha256") + .update(JSON.stringify(config.mcpServers["one-memory"])) + .digest("hex"); + await writeFile(context.layout.journalPath, JSON.stringify(journal)); + + expect( + await context.permissions.refreshInstalledHosts([context.layout]), + ).toMatchObject({ state: "refreshed", synced: ["cursor"], failed: [] }); + const refreshed = JSON.parse(await readFile(configPath, "utf8")); + expect(refreshed.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe( + MCP_TOOLSET_VERSION, + ); + expect(refreshed.mcpServers.neighbor).toEqual({ command: "keep" }); + }); +}); diff --git a/memhub/apps/desktop/src/main.ts b/memhub/apps/desktop/src/main.ts index 48b3dfe..791a82a 100644 --- a/memhub/apps/desktop/src/main.ts +++ b/memhub/apps/desktop/src/main.ts @@ -1369,6 +1369,24 @@ async function initialize(isQuitting: () => boolean): Promise { }); await runtimeSupervisor.start(); if (isQuitting()) return; + // A standalone Runtime refreshes the hosts it installed as it starts, so + // an upgrade through the App or `memhub upgrade` is covered there. An + // embedded one is this process: do the same here, now it is up. Only a + // packaged build, so a source checkout never repoints real hosts at itself. + if (localPermissions && app.isPackaged) { + void localPermissions + .refreshInstalledHosts( + hosts.map((host) => host.layout), + { + build: + packagedBuildNumber === undefined + ? undefined + : String(packagedBuildNumber), + diagnostics: hostDiagnostics, + }, + ) + .catch(() => undefined); + } if ( app.isPackaged && process.platform === "darwin" && diff --git a/memhub/docs/contracts/one-memory-recall-install-v1.md b/memhub/docs/contracts/one-memory-recall-install-v1.md index 9fa6c8c..69d0c35 100644 --- a/memhub/docs/contracts/one-memory-recall-install-v1.md +++ b/memhub/docs/contracts/one-memory-recall-install-v1.md @@ -39,10 +39,12 @@ which talks to a broker on the same machine. | `ONE_MEMORY_BROKER_DISCOVERY` | absolute path to the discovery file | How the server finds the running app. Wrong value means recall returns nothing, with no error. | | `ONE_MEMORY_MANAGED_BY` | `one-memory` | Marks the entry as ours, so our installer will update or remove it later instead of treating it as a stranger's and leaving it alone. | | `ELECTRON_RUN_AS_NODE` | `1` | The MCP entry ships inside the app bundle and is started through the app's own binary. Without this the binary opens the window instead of running the script. | +| `ONE_MEMORY_MCP_TOOLSET` | tool set version, e.g. `3` | Names the MemHub tool set the entry was written for. MemHub changes it only when tools are added, removed or renamed, so hosts that cache a server's tool list by its launch config restart the server and drop removed tools. The server itself ignores it. | `ONE_MEMORY_MANAGED_BY` is optional for a hand-made entry. Set it if you want MemHub to manage the entry for you afterwards; leave it out if you would -rather own the entry yourself. +rather own the entry yourself. `ONE_MEMORY_MCP_TOOLSET` is optional too; a +managed entry without the current value is rewritten on the next upgrade. ## 3. MCP hosts @@ -57,7 +59,8 @@ Register a stdio server named `one-memory`. The product name is MemHub; the conf "env": { "ELECTRON_RUN_AS_NODE": "1", "ONE_MEMORY_BROKER_DISCOVERY": "", - "ONE_MEMORY_MANAGED_BY": "one-memory" + "ONE_MEMORY_MANAGED_BY": "one-memory", + "ONE_MEMORY_MCP_TOOLSET": "3" } } } diff --git a/memhub/packages/host-bridge/src/install-contract.test.ts b/memhub/packages/host-bridge/src/install-contract.test.ts index 7d67eae..f67e778 100644 --- a/memhub/packages/host-bridge/src/install-contract.test.ts +++ b/memhub/packages/host-bridge/src/install-contract.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest"; import type { RecallRuntime } from "./recall-installer.js"; import { DISCOVERY_KEY, + MCP_TOOLSET_KEY, + MCP_TOOLSET_VERSION, OWNER_KEY, OWNER_VALUE, renderConnectionMaterial, @@ -60,6 +62,7 @@ describe("mcp install snippet", () => { const env = parsed.mcpServers[SERVER_NAME].env; expect(env[DISCOVERY_KEY]).toBe(runtime().discoveryPath); expect(env[OWNER_KEY]).toBe(OWNER_VALUE); + expect(env[MCP_TOOLSET_KEY]).toBe(MCP_TOOLSET_VERSION); expect(env.ELECTRON_RUN_AS_NODE).toBe("1"); }); @@ -174,9 +177,10 @@ describe("public contract document", () => { // reports it to us. Same class of problem R-33 treated for skill text, // and treated the same way: one source, checked by a test. const doc = await fs.readFile(CONTRACT_DOC, "utf8"); - for (const key of [DISCOVERY_KEY, OWNER_KEY]) { + for (const key of [DISCOVERY_KEY, OWNER_KEY, MCP_TOOLSET_KEY]) { expect(doc).toContain(key); } + expect(doc).toContain(`"${MCP_TOOLSET_KEY}": "${MCP_TOOLSET_VERSION}"`); expect(doc).toContain(OWNER_VALUE); expect(doc).toContain(SERVER_NAME); }); diff --git a/memhub/packages/host-bridge/src/mcp-granted-tools.test.ts b/memhub/packages/host-bridge/src/mcp-granted-tools.test.ts index 3ac0305..cc625a2 100644 --- a/memhub/packages/host-bridge/src/mcp-granted-tools.test.ts +++ b/memhub/packages/host-bridge/src/mcp-granted-tools.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { type GrantedTools, McpSession } from "./mcp-session.js"; +import { MCP_TOOL_NAMES, MCP_TOOLSET_VERSION } from "./mcp-toolset.js"; function sessionWith(granted: GrantedTools): McpSession { return new McpSession( @@ -71,3 +72,14 @@ describe("BUG-15: the listed tools follow what the grant allows", () => { expect(names).toHaveLength(3); }); }); + +describe("the tool set stamped on installed entries", () => { + it("names exactly the tools the server lists", async () => { + // Change a tool without moving MCP_TOOLSET_VERSION and hosts that cache + // tool lists by config fingerprint keep showing the old set. Update both, + // and this pinned version, together. + const names = await listedNames(sessionWith(async () => null)); + expect(names).toEqual([...MCP_TOOL_NAMES]); + expect(MCP_TOOLSET_VERSION).toBe("3"); + }); +}); diff --git a/memhub/packages/host-bridge/src/mcp-session.ts b/memhub/packages/host-bridge/src/mcp-session.ts index 5626593..2c81095 100644 --- a/memhub/packages/host-bridge/src/mcp-session.ts +++ b/memhub/packages/host-bridge/src/mcp-session.ts @@ -127,6 +127,8 @@ export class McpSession { } private async listedTools() { + // Any change to this set moves MCP_TOOL_NAMES and MCP_TOOLSET_VERSION in + // `mcp-toolset.ts`, so installed entries change and hosts relist. const definitions = [ recallToolDefinition(), writeToolDefinition(), diff --git a/memhub/packages/host-bridge/src/mcp-toolset.ts b/memhub/packages/host-bridge/src/mcp-toolset.ts new file mode 100644 index 0000000..050ab9c --- /dev/null +++ b/memhub/packages/host-bridge/src/mcp-toolset.ts @@ -0,0 +1,29 @@ +/** The tool contract of MemHub's MCP server, and the stamp that names it. + * + * Kept apart from `mcp-session.ts` so the installer can stamp an entry without + * pulling in the broker client, and listed here rather than inferred so a test + * can hold `tools/list` to it: change a tool there and that test fails until + * this list, and with it the version, moves too. + */ + +/** The tools `tools/list` returns, in order. */ +export const MCP_TOOL_NAMES = [ + "MemHub_recall", + "MemHub_write", + "MemHub_diagnostic_trace", +] as const; + +/** Bumped whenever a tool is added, removed or renamed. + * + * Written into every managed MCP entry under `MCP_TOOLSET_KEY`. Hosts such as + * WorkBuddy cache a server's tool list under a fingerprint of its launch + * config, and our entry is otherwise byte-identical across upgrades, since it + * runs through the `current` symlink. Moving this value is what changes that + * fingerprint, so those hosts restart the server and drop the tools it no + * longer has. Deliberately not the build number: that would rewrite every + * host's config on every upgrade, for nothing. + */ +export const MCP_TOOLSET_VERSION = "3"; + +/** The env key the version is written under. */ +export const MCP_TOOLSET_KEY = "ONE_MEMORY_MCP_TOOLSET"; diff --git a/memhub/packages/host-bridge/src/opencode-installer.test.ts b/memhub/packages/host-bridge/src/opencode-installer.test.ts index 5d83d9a..931a37a 100644 --- a/memhub/packages/host-bridge/src/opencode-installer.test.ts +++ b/memhub/packages/host-bridge/src/opencode-installer.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { applyRecallInstall, + MCP_TOOLSET_VERSION, mcpConfigPath, opencodeLayout, planRecallInstall, @@ -46,6 +47,7 @@ describe("OpenCode recall installer", () => { ELECTRON_RUN_AS_NODE: "1", ONE_MEMORY_MANAGED_BY: "one-memory", ONE_MEMORY_BROKER_DISCOVERY: "/opt/one-memory/runtime.json", + ONE_MEMORY_MCP_TOOLSET: MCP_TOOLSET_VERSION, }, enabled: true, }); diff --git a/memhub/packages/host-bridge/src/recall-installer.ts b/memhub/packages/host-bridge/src/recall-installer.ts index e4627ec..1f0cd23 100644 --- a/memhub/packages/host-bridge/src/recall-installer.ts +++ b/memhub/packages/host-bridge/src/recall-installer.ts @@ -58,6 +58,7 @@ import { readDshServer, removeDshServer, } from "./dsh-patch.js"; +import { MCP_TOOLSET_KEY, MCP_TOOLSET_VERSION } from "./mcp-toolset.js"; import { RECALL_PRESENTATION_GUIDANCE, RECALL_PRESENTATION_GUIDANCE_ZH, @@ -88,8 +89,12 @@ export { type RecallLinkState, type RecallProbe, } from "./recall-probe.js"; - -export { planRecallRepoint, type RecallRepoint }; +export { + MCP_TOOLSET_KEY, + MCP_TOOLSET_VERSION, + planRecallRepoint, + type RecallRepoint, +}; /** Rewrite hosts whose installed command is not the one we would write now. * @@ -2844,6 +2849,9 @@ function managedServer(runtime: RecallRuntime): ManagedServer { ELECTRON_RUN_AS_NODE: "1", [DISCOVERY_KEY]: runtime.discoveryPath, [OWNER_KEY]: OWNER_VALUE, + // Moves the host's fingerprint of this entry when the tool set changes; + // see `MCP_TOOLSET_VERSION`. An entry without it reads as stale. + [MCP_TOOLSET_KEY]: MCP_TOOLSET_VERSION, ...(runtime.credentialFile === undefined ? {} : { ONE_MEMORY_HOST_CREDENTIAL_FILE: runtime.credentialFile }), diff --git a/memhub/packages/host-bridge/src/skill-drift.test.ts b/memhub/packages/host-bridge/src/skill-drift.test.ts index 7d18fb3..cd681a1 100644 --- a/memhub/packages/host-bridge/src/skill-drift.test.ts +++ b/memhub/packages/host-bridge/src/skill-drift.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { applyCursorInstall, planCursorInstall } from "./cursor-installer.js"; +import { MCP_TOOLSET_KEY, MCP_TOOLSET_VERSION } from "./mcp-toolset.js"; import { applyRecallInstall, applyRecallUninstall, @@ -15,6 +16,7 @@ import { planRecallUninstall, SKILL_MARKER, SKILL_VERSION, + verifyRecallInstall, } from "./recall-installer.js"; import { checkSkillDrift, @@ -387,6 +389,59 @@ describe("skill drift (R-33)", () => { }); }, 30_000); + it("marks stale an MCP entry written before the tool set was stamped, and sync stamps it", async () => { + // Our entry is otherwise identical across upgrades, so hosts that cache a + // tool list by config fingerprint keep the removed tools until it changes. + const cursorDir = await fixtureDir(); + const discovery = path.join(cursorDir, "broker.json"); + await writeFile(discovery, "{}\n"); + await applyCursorInstall( + await planCursorInstall(options(cursorDir, discovery)), + ); + const layout = cursorLayout(cursorDir); + const configPath = layout.configPath as string; + const config = JSON.parse(await readFile(configPath, "utf8")) as { + mcpServers: Record }>; + }; + expect(config.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe( + MCP_TOOLSET_VERSION, + ); + // What an earlier release wrote and journaled: the same entry, no stamp. + delete config.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]; + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); + const journal = JSON.parse(await readFile(layout.journalPath, "utf8")); + journal.installed_server_hash = createHash("sha256") + .update(JSON.stringify(config.mcpServers["one-memory"])) + .digest("hex"); + await writeFile(layout.journalPath, `${JSON.stringify(journal)}\n`); + + // Still intact as installed, so the link is not reported broken... + await expect(verifyRecallInstall(layout)).resolves.toMatchObject({ + hostId: "cursor", + }); + // ...but stale against this release, and safe to sync without asking. + await expect( + checkSkillDrift(layout, runtime(discovery)), + ).resolves.toMatchObject({ + state: "stale", + detail: "the MCP entry predates the tool set this MemHub serves", + }); + const sync = await planSkillSync(layout, runtime(discovery)); + expect(sync?.requiresConfirmation).toBe(false); + if (sync === null) throw new Error("expected a stale entry sync"); + await applyRecallInstall(sync.plan); + + const synced = JSON.parse(await readFile(configPath, "utf8")) as { + mcpServers: Record }>; + }; + expect(synced.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe( + MCP_TOOLSET_VERSION, + ); + await expect( + checkSkillDrift(layout, runtime(discovery)), + ).resolves.toMatchObject({ state: "in_sync" }); + }, 30_000); + it("requires recall for dependent answers, not on every unrelated turn", async () => { const cursorDir = await fixtureDir(); const discovery = path.join(cursorDir, "broker.json"); diff --git a/memhub/packages/host-bridge/src/skill-drift.ts b/memhub/packages/host-bridge/src/skill-drift.ts index 9ea7758..9501d6c 100644 --- a/memhub/packages/host-bridge/src/skill-drift.ts +++ b/memhub/packages/host-bridge/src/skill-drift.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import path from "node:path"; import { captureFilePath, namesCapture } from "./capture-hooks.js"; import { PLUGIN_HOOKS } from "./codebuddy-plugin.js"; +import { MCP_TOOLSET_KEY, MCP_TOOLSET_VERSION } from "./mcp-toolset.js"; import { asInstalled, expectedPluginHooks, @@ -9,6 +10,7 @@ import { type RecallHostLayout, type RecallInstallPlan, type RecallRuntime, + readInstalledRecallCommand, renderSkillContent, SKILL_VERSION, } from "./recall-installer.js"; @@ -99,7 +101,23 @@ export async function checkSkillDrift( runtime: RecallRuntime, ): Promise { const skill = await checkSkillFileDrift(layout, runtime); - if (skill === null || skill.state === "modified") return skill; + if (skill === null) { + // A host with no skill mechanism still has an entry whose tool set can + // age. One whose skill is merely missing is left alone: syncing would put + // back a file somebody removed. + if (skillFilePath(layout) !== null) return null; + return (await serverToolsetDrift(layout)) === "stale" + ? { + hostId: layout.hostId, + state: "stale", + installedVersion: null, + currentVersion: SKILL_VERSION, + skillPath: null, + detail: TOOLSET_STALE_DETAIL, + } + : null; + } + if (skill.state === "modified") return skill; const hooks = await pluginHooksDrift(layout, runtime); if (hooks === "modified") { return { @@ -108,15 +126,19 @@ export async function checkSkillDrift( detail: "the plugin hook file was edited after install", }; } - if (hooks === "stale" && skill.state === "in_sync") { + if (skill.state === "stale") return skill; + if (hooks === "stale") { return { ...skill, state: "stale", detail: "plugin hooks are still ours, but have moved since install", }; } + if ((await serverToolsetDrift(layout)) === "stale") { + return { ...skill, state: "stale", detail: TOOLSET_STALE_DETAIL }; + } const capture = await captureHooksDrift(layout); - if (capture === "stale" && skill.state === "in_sync") { + if (capture === "stale") { return { ...skill, state: "stale", @@ -126,6 +148,29 @@ export async function checkSkillDrift( return skill; } +const TOOLSET_STALE_DETAIL = + "the MCP entry predates the tool set this MemHub serves"; + +/** Whether our MCP entry names the tool set this release serves. + * + * The entry is otherwise identical across upgrades, so a host that caches a + * server's tool list under a fingerprint of its config (WorkBuddy does) keeps + * listing tools that are gone until the entry itself changes. Only an entry + * marked as ours is read; a CLI-channel host has no entry at all. + */ +async function serverToolsetDrift( + layout: RecallHostLayout, +): Promise<"in_sync" | "stale" | null> { + if (layout.channel !== "mcp") return null; + // A config the host cannot parse either is not ours to judge here; the + // install and link checks report it. + const installed = await readInstalledRecallCommand(layout).catch(() => null); + if (installed === null) return null; + return installed.env[MCP_TOOLSET_KEY] === MCP_TOOLSET_VERSION + ? "in_sync" + : "stale"; +} + /** Detect duplicated command hooks left by earlier installs. * * `checkSkillDrift` historically compared only the Skill and plugin hooks; diff --git a/memhub/packages/runtime-control/src/host-permissions.ts b/memhub/packages/runtime-control/src/host-permissions.ts index 67d2a54..9f42f93 100644 --- a/memhub/packages/runtime-control/src/host-permissions.ts +++ b/memhub/packages/runtime-control/src/host-permissions.ts @@ -41,6 +41,7 @@ import { repairInstalledRecallCommands, verifyRecallInstall, } from "@one-memory/host-bridge/installer"; +import type { DiagnosticInput, DiagnosticSink } from "./diagnostic-log.js"; import { hasSyncHookArtifacts, loadSyncHookAssets, @@ -96,8 +97,22 @@ interface InstallationBinding { } const CREDENTIAL_ENV = "ONE_MEMORY_HOST_CREDENTIAL_FILE"; +/** The build whose host refresh last completed, beside the grants. */ +const HOST_REFRESH_FILE = "host-refresh.json"; const locks = new Map>(); +/** What one pass of `refreshInstalledHosts` did, by host id. */ +export interface HostRefreshReport { + /** `already_refreshed` when this build had already completed a pass. */ + state: "refreshed" | "already_refreshed"; + checked: number; + /** Hosts whose installed command or discovery path was moved. */ + repointed: string[]; + /** Hosts whose stale skill, hooks, plugin files or MCP entry were rewritten. */ + synced: string[]; + failed: string[]; +} + export const LOCAL_EXPERIENCE_UNAVAILABLE = "体验版暂不支持"; /** Trusted local Runtime control plane. Never exposed through MCP or the Core API. @@ -201,11 +216,20 @@ export class HostPermissions { * touching authorization. A skill the user edited is left for the detail page. */ async syncStaleInstalls(layouts: RecallHostLayout[]): Promise { + return (await this.syncStale(layouts)).synced; + } + + private async syncStale( + layouts: RecallHostLayout[], + ): Promise<{ synced: string[]; failed: string[] }> { const synced: string[] = []; + const failed: string[] = []; for (const layout of layouts) { try { await this.withHostLock(layout, async () => { - await this.requireLinked(layout); + // Not linked from this data directory is not ours to sync, and not + // a failure either. + if (!(await this.isLinked(layout))) return; const sync = await planSkillSync(layout, this.runtimeFor(layout)); if (sync === null || sync.requiresConfirmation) return; const preview = await previewRecallInstall(sync.plan); @@ -215,9 +239,128 @@ export class HostPermissions { }); } catch { // One host that cannot be synced must not stop the runtime starting. + failed.push(layout.hostId); } } - return synced; + return { synced, failed }; + } + + /** Refresh every host this directory installed, once per build. + * + * An upgrade changes the call rules, hooks and MCP entry we would write, but + * nothing rewrote the copies hosts already had, so they kept telling the + * model to call tools that were gone. This runs the same repoint and stale + * sync the detail page offers, without asking, for files that are still + * exactly ours; an edited skill stays for the user to confirm. + * + * The build it last completed for is recorded beside the grants, so later + * launches of the same build cost one file read. A run in which any host + * failed is not recorded, so the next launch tries again. With no build to + * name (a source checkout) it runs every time and records nothing. + */ + async refreshInstalledHosts( + layouts: RecallHostLayout[], + options: { build?: string; diagnostics?: DiagnosticSink } = {}, + ): Promise { + const log = (input: DiagnosticInput) => + options.diagnostics?.log(input).catch(() => undefined); + const build = options.build?.trim() || undefined; + if (build !== undefined && (await this.readRefreshedBuild()) === build) { + await log({ + level: "info", + component: "host.refresh", + event: "host_refresh.skipped", + outcome: "skipped", + details: { reason: "already_refreshed", count: layouts.length }, + }); + return { + state: "already_refreshed", + checked: layouts.length, + repointed: [], + synced: [], + failed: [], + }; + } + const started = Date.now(); + const repointed: string[] = []; + const failed: string[] = []; + for (const layout of layouts) { + try { + repointed.push(...(await this.repairInstalledCommands([layout]))); + } catch { + failed.push(layout.hostId); + } + } + const stale = await this.syncStale(layouts); + failed.push(...stale.failed.filter((hostId) => !failed.includes(hostId))); + for (const hostId of new Set([...repointed, ...stale.synced])) { + await log({ + level: "info", + component: "host.refresh", + event: "host_refresh.host_updated", + outcome: "succeeded", + details: { source_id: hostId }, + }); + } + for (const hostId of failed) { + await log({ + level: "warn", + component: "host.refresh", + event: "host_refresh.host_failed", + outcome: "failed", + error_code: "host_refresh_failed", + details: { source_id: hostId }, + }); + } + if (build !== undefined && failed.length === 0) { + await this.writeRefreshedBuild(build).catch(() => undefined); + } + await log({ + level: failed.length === 0 ? "info" : "warn", + component: "host.refresh", + event: "host_refresh.completed", + outcome: failed.length === 0 ? "succeeded" : "degraded", + duration_ms: Date.now() - started, + details: { + count: layouts.length, + updated_count: new Set([...repointed, ...stale.synced]).size, + error_count: failed.length, + }, + }); + return { + state: "refreshed", + checked: layouts.length, + repointed, + synced: stale.synced, + failed, + }; + } + + private async readRefreshedBuild(): Promise { + try { + return await storageGuard(async () => { + const file = path.join(this.directory, HOST_REFRESH_FILE); + if ((await optionalStat(file)) === null) return null; + const value: unknown = JSON.parse(await privateRead(file)); + return isObject(value) && + value.schemaVersion === 1 && + typeof value.build === "string" + ? value.build + : null; + }); + } catch { + // Unreadable reads as never refreshed: the refresh is idempotent. + return null; + } + } + + private async writeRefreshedBuild(build: string): Promise { + await storageGuard(() => + privateWrite( + path.join(this.directory, HOST_REFRESH_FILE), + `${JSON.stringify({ schemaVersion: 1, build })}\n`, + ), + ); } /** Same repair for one host. Does not revoke or reissue its authorization. */ @@ -942,6 +1085,16 @@ export class HostPermissions { throw new Error("host_permission_installation_incomplete"); } + /** `requireLinked` as a question, for sweeps that skip hosts not ours. */ + private async isLinked(layout: RecallHostLayout): Promise { + if (!supported(layout)) return false; + const current = await this.inspect(layout, false); + return ( + (current.state === "approved" || current.state === "install-only") && + current.reason === null + ); + } + /** Turning off a host with nothing of ours on it is an empty removal, not a * failure; one whose state cannot be settled is an empty removal carrying * what to look at, which uninstall refuses. Only what is ours for certain diff --git a/memhub/packages/runtime-control/src/index.ts b/memhub/packages/runtime-control/src/index.ts index 285875a..7f7e2e1 100644 --- a/memhub/packages/runtime-control/src/index.ts +++ b/memhub/packages/runtime-control/src/index.ts @@ -79,6 +79,7 @@ export { } from "./host-control-client.js"; export { HostPermissions, + type HostRefreshReport, LOCAL_EXPERIENCE_UNAVAILABLE, type RecallPermissionStatus, type RevokeRecallPermissionInput, diff --git a/memhub/packages/runtime-control/src/runtime-cli.test.ts b/memhub/packages/runtime-control/src/runtime-cli.test.ts index 175b111..8f724b6 100644 --- a/memhub/packages/runtime-control/src/runtime-cli.test.ts +++ b/memhub/packages/runtime-control/src/runtime-cli.test.ts @@ -226,6 +226,7 @@ describe("host runtime attestation", () => { "repairInstalledCommand", "repairInstalledCommands", "syncStaleInstalls", + "refreshInstalledHosts", "previewInstall", "install", "setPermission", diff --git a/memhub/packages/runtime-control/src/runtime-cli.ts b/memhub/packages/runtime-control/src/runtime-cli.ts index ad3d207..70e906b 100644 --- a/memhub/packages/runtime-control/src/runtime-cli.ts +++ b/memhub/packages/runtime-control/src/runtime-cli.ts @@ -600,6 +600,7 @@ export function guardedPermissions( "previewSyncHook", "applySyncHook", "syncStaleInstalls", + "refreshInstalledHosts", ]); return new Proxy(owner, { get(target, property, receiver) { @@ -739,10 +740,17 @@ export async function createRuntimeService( ).filter((host) => host.hostId !== "openclaw"); await verifyHostRuntime().then( async () => { - await permissions.repairInstalledCommands( + // Every Runtime start after an upgrade, the App's or `memhub + // upgrade`'s, comes through here first; once per build after that. + await permissions.refreshInstalledHosts( hosts.map((host) => host.layout), + { + build: + config.buildIdentity?.buildNumber ?? + config.buildIdentity?.version, + diagnostics: hostDiagnostics, + }, ); - await permissions.syncStaleInstalls(hosts.map((host) => host.layout)); }, () => undefined, ); From 81ed1d816faad1fbceba0fe9fe1a3e15f7b49deb Mon Sep 17 00:00:00 2001 From: kristinhe Date: Wed, 28 Oct 2026 12:00:26 +0800 Subject: [PATCH 15/25] fix(memhub): open source memories from map nodes Co-authored-by: Cursor --- memhub/apps/web/src/MemoryMap.test.tsx | 320 +++++++++++++++++-------- memhub/apps/web/src/MemoryMap.tsx | 91 ++++++- memhub/apps/web/src/MemorySpace.tsx | 2 + 3 files changed, 304 insertions(+), 109 deletions(-) diff --git a/memhub/apps/web/src/MemoryMap.test.tsx b/memhub/apps/web/src/MemoryMap.test.tsx index beec1a5..319aa9e 100644 --- a/memhub/apps/web/src/MemoryMap.test.tsx +++ b/memhub/apps/web/src/MemoryMap.test.tsx @@ -5,7 +5,6 @@ import type { MemoryGraphNode, MemoryGraphResponse, MemoryProcessingSummary, - SourceImportReceipt as Receipt, SourceResponse, } from "@one-memory/contracts"; import { @@ -40,12 +39,6 @@ import { afterEach, expect, it, vi } from "vitest"; import { MemoryMap } from "./MemoryMap"; import type { MemoryArrival } from "./memory-arrival-state"; -const { sourceReceipt } = vi.hoisted(() => ({ - sourceReceipt: vi.fn(), -})); -vi.mock("./api", () => ({ - getSourceImportReceipt: sourceReceipt, -})); vi.mock("./MemoryGraph3D", () => ({ MemoryGraph3D: ({ graph, @@ -982,35 +975,6 @@ const importedSources = [ imported_conversations: 1, }, ] as SourceResponse[]; -const importedReceipt: Receipt = { - source_id: "codex", - measured_at: "2026-09-14T00:00:00Z", - messages: 5, - conversations: 2, - person_messages: 2, - agent_messages: 2, - tool_messages: 0, - unknown_author_messages: 1, - unknown_time_messages: 1, - examples: [ - { - evidence_id: "raw-codex", - role: "user", - written_by: "person", - created_at: null, - excerpt: "合成来源原文,不是治理记忆。", - truncated: false, - }, - ], -}; -async function openSourceReceipt() { - await userEvent.click( - screen.getByRole("button", { name: "图中选择 Codex · 2 段" }), - ); - await userEvent.click(screen.getByText("查看已读内容")); - await waitFor(() => expect(sourceReceipt).toHaveBeenCalledWith("codex")); -} - it("REV09: selecting an imported source retains the canvas and only its authorized node", async () => { render(); const canvas = screen.getByTestId("canvas"); @@ -1025,7 +989,8 @@ it("REV09: selecting an imported source retains the canvas and only its authoriz expect( screen.getByRole("list", { name: "地图记忆列表" }).children, ).toHaveLength(0); - expect(sourceReceipt).not.toHaveBeenCalled(); + expect(screen.getByRole("heading", { name: "记忆详情" })).toBeTruthy(); + expect(screen.getByText("还没有已生成的记忆")).toBeTruthy(); await userEvent.click(screen.getByRole("button", { name: /全部记忆/ })); expect(within(canvas).getAllByRole("button")).toHaveLength(2); expect(canvas.dataset.selected ?? "").toBe(""); @@ -1047,83 +1012,247 @@ it("REV09: fallback search matches source names and IDs without bypassing the se ); await userEvent.type(search, "cOdEx"); expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex"); - expect(screen.getByText("查看已读内容")).toBeTruthy(); + expect(screen.getByText("还没有已生成的记忆")).toBeTruthy(); await userEvent.clear(search); await userEvent.type(search, "Cursor"); expect(screen.queryByTestId("canvas")).toBeNull(); - expect(screen.queryByText("查看已读内容")).toBeNull(); + expect(screen.queryByText("正在读取记忆详情…")).toBeNull(); await userEvent.clear(search); expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe(""); - expect(screen.queryByText("查看已读内容")).toBeNull(); + expect(screen.queryByText("正在读取记忆详情…")).toBeNull(); }); -it("REV09: source selection uses the actual permission-checked receipt API", async () => { - sourceReceipt.mockResolvedValue(importedReceipt); - render(); - await openSourceReceipt(); - expect( - await screen.findByText(importedReceipt.examples[0].excerpt), - ).toBeTruthy(); - expect(screen.getByText("用户 · 时间未知")).toBeTruthy(); - expect(screen.getByText("原始消息").nextElementSibling?.textContent).toBe( - "5", +it("selecting a source node waits for its settled page, then opens the first memory", async () => { + const onQuery = vi.fn(); + const onSource = vi.fn(); + const resetPage = vi.fn(); + const oldPage = { + items: memories, + total: 2, + offset: 0, + limit: 20, + source_ids_by_memory: { m0: ["codex"], m1: ["codex"] }, + }; + const view = render( + , ); - // No raw conversation is read by id on the SDK data plane. - expect(screen.queryByRole("button", { name: "查看完整对话" })).toBeNull(); + await act(async () => {}); + await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" })); + expect(onQuery).toHaveBeenCalledWith(""); + expect(onSource).toHaveBeenCalledWith("codex"); + expect(resetPage).toHaveBeenCalledOnce(); + + view.rerender( + , + ); + expect( + screen + .getAllByRole("status") + .some((node) => node.textContent === "正在读取记忆详情…"), + ).toBe(true); + + const settledItems = [memories[1], memories[0]]; + view.rerender( + , + ); + expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy(); + expect(screen.queryByText("查看已读内容")).toBeNull(); + expect(document.querySelector(".source-import-receipt")).toBeNull(); }); -it.each(["revoked", "removed", "revoked-hidden"])( - "REV09: %s source drops fallback, preview and a late receipt without reopening on reauthorization", - async (state) => { - let finish: (value: Receipt) => void = () => {}; - sourceReceipt.mockImplementation( - () => - new Promise((resolve) => { - finish = resolve; - }), - ); - const view = render(); - await openSourceReceipt(); - view.rerender( -