Compare commits

...
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 32539e7a82 chore(release): bump MemHub build number to 34
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:44:42 +08:00
alvinfeiandClaude Opus 5.5 cfae020a31 test(memhub): install docs name hy-memory 2.0.7
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:44:42 +08:00
alvinfeiandClaude Opus 5.5 5d51513ec8 docs: record hy-memory 2.0.7 as published
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:37:30 +08:00
alvinfeiandClaude Opus 5.5 9db5b49ccb build(memhub): lock hy-memory 2.0.7 from PyPI
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:37:23 +08:00
alvinfeiandClaude Opus 5.5 cb7df2a022 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) <noreply@anthropic.com>
2026-10-28 11:37:13 +08:00
alvinfeiandClaude Opus 5.5 812d1c3846 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) <noreply@anthropic.com>
2026-10-28 11:36:56 +08:00
alvinfeiandClaude Opus 5.5 c8b660651e fix(memhub): experiences show their conversation time
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) <noreply@anthropic.com>
2026-10-28 11:36:56 +08:00
feitianxiang bdd2d9cf84 Merge pull request #207 from Tencent-Hunyuan/feat/sdk-coding-memory-at
feat(sdk): coding and working experiences keep their conversation time (2.0.7)
2026-10-28 11:33:29 +08:00
27 changed files with 252 additions and 64 deletions

No files matched your search

+10
View File
@@ -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
@@ -70,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
+2 -2
View File
@@ -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`。
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
@@ -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"]
@@ -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,10 +438,14 @@ def _repair_coding(
):
if table not in tables:
continue
# _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 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 +454,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)
@@ -470,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])
@@ -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),
@@ -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)
@@ -137,19 +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:
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)"
# 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','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(
@@ -168,10 +187,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(
@@ -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",
+4 -4
View File
@@ -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" },
+7 -2
View File
@@ -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(
+6 -2
View File
@@ -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);
@@ -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(
<SelectedMemoryDetail
detail={{
...detail,
evidence: [],
sdk_status: "active",
source_id: "codebuddy",
sdk_memory_at: today,
sdk_created_at: today,
sdk_ref: { scene: "coding", kind: "experience", variant: "coding" },
}}
sources={[]}
/>,
);
expect(screen.getByText(/对话时间 今天 /)).toBeTruthy();
expect(screen.queryByText(/时间未知/)).toBeNull();
});
it("labels an experience without a conversation time by its write time", () => {
render(
<SelectedMemoryDetail
detail={{
...detail,
evidence: [],
sdk_status: "active",
source_id: "codebuddy",
sdk_memory_at: null,
sdk_created_at: new Date().toISOString(),
sdk_ref: { scene: "working", kind: "experience", variant: "working" },
}}
sources={[]}
/>,
);
expect(screen.getByText(/来源 codebuddy · 写入时间 今天 /)).toBeTruthy();
expect(screen.queryByText(/对话时间/)).toBeNull();
expect(screen.queryByText(/时间未知/)).toBeNull();
});
+34 -17
View File
@@ -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({
<p className="selected-memory-source">
{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,
),
})}
</p>
) : null}
<p className="selected-memory-times">
{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,
),
})}
</p>
{writtenOnly ? null : (
<p className="selected-memory-times">
{t("selectedMemory.timestamps", {
conversation: clockStamp(
conversationAt?.at ??
detail.sdk_memory_at ??
evidence?.created_at,
locale,
),
created,
})}
</p>
)}
</article>
);
}
+32 -2
View File
@@ -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(),
);
});
+8 -6
View File
@@ -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<string, string[]> = {};
const refs: Record<string, SdkMemoryRef> = {};
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 {
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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,
+4 -2
View File
@@ -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<string, string[]>;
/** 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"
@@ -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}:",
@@ -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:模型连接已通过。首批记忆生成后,会逐步出现在记忆列表里。":
@@ -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:模型连接已通过。首批记忆生成后,会逐步出现在记忆列表里。":
+1 -1
View File
@@ -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):
+1 -1
View File
@@ -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);
+3 -3
View File
@@ -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"]