Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc2e51a8cc | ||
|
|
d4fca0639e |
No files matched your search
@@ -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
|
||||
|
||||
+8
-1
@@ -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,
|
||||
|
||||
@@ -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 [])}
|
||||
|
||||
@@ -577,6 +577,7 @@ class CodingMemoryReconciler:
|
||||
confidence=d.confidence,
|
||||
source=d.source,
|
||||
extraction_source=d.extraction_source,
|
||||
memory_at=d.memory_at,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
MemoryGraphNode,
|
||||
MemoryGraphResponse,
|
||||
MemoryProcessingSummary,
|
||||
SourceImportReceipt as Receipt,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import {
|
||||
@@ -39,6 +40,12 @@ 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,
|
||||
@@ -975,6 +982,35 @@ 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(<Host items={[]} sources={importedSources} />);
|
||||
const canvas = screen.getByTestId("canvas");
|
||||
@@ -989,8 +1025,7 @@ it("REV09: selecting an imported source retains the canvas and only its authoriz
|
||||
expect(
|
||||
screen.getByRole("list", { name: "地图记忆列表" }).children,
|
||||
).toHaveLength(0);
|
||||
expect(screen.getByRole("heading", { name: "记忆详情" })).toBeTruthy();
|
||||
expect(screen.getByText("还没有已生成的记忆")).toBeTruthy();
|
||||
expect(sourceReceipt).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: /全部记忆/ }));
|
||||
expect(within(canvas).getAllByRole("button")).toHaveLength(2);
|
||||
expect(canvas.dataset.selected ?? "").toBe("");
|
||||
@@ -1012,247 +1047,83 @@ 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();
|
||||
});
|
||||
|
||||
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(
|
||||
<Host
|
||||
simplified
|
||||
sources={importedSources}
|
||||
libraryView={{
|
||||
...pagedView(oldPage),
|
||||
query: "旧查询",
|
||||
settledQuery: "旧查询",
|
||||
onQuery,
|
||||
onSource,
|
||||
resetPage,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||
expect(onQuery).toHaveBeenCalledWith("");
|
||||
expect(onSource).toHaveBeenCalledWith("codex");
|
||||
expect(resetPage).toHaveBeenCalledOnce();
|
||||
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
sources={importedSources}
|
||||
libraryView={{
|
||||
...pagedView(oldPage),
|
||||
source: "codex",
|
||||
settledSource: "",
|
||||
loading: true,
|
||||
onQuery,
|
||||
onSource,
|
||||
resetPage,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("status")
|
||||
.some((node) => node.textContent === "正在读取记忆详情…"),
|
||||
).toBe(true);
|
||||
|
||||
const settledItems = [memories[1], memories[0]];
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
items={settledItems}
|
||||
sources={importedSources}
|
||||
libraryView={{
|
||||
...pagedView({
|
||||
...oldPage,
|
||||
items: settledItems,
|
||||
source_ids_by_memory: { m1: ["codex"], m0: ["codex"] },
|
||||
}),
|
||||
source: "codex",
|
||||
settledSource: "codex",
|
||||
settledQuery: "",
|
||||
onQuery,
|
||||
onSource,
|
||||
resetPage,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
|
||||
expect(screen.queryByText("查看已读内容")).toBeNull();
|
||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not replace a newer memory choice when a source page settles", async () => {
|
||||
const page = {
|
||||
items: memories,
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const view = render(
|
||||
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
|
||||
it("REV09: source selection uses the actual permission-checked receipt API", async () => {
|
||||
sourceReceipt.mockResolvedValue(importedReceipt);
|
||||
render(<Host items={[]} sources={importedSources} />);
|
||||
await openSourceReceipt();
|
||||
expect(
|
||||
await screen.findByText(importedReceipt.examples[0].excerpt),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("用户 · 时间未知")).toBeTruthy();
|
||||
expect(screen.getByText("原始消息").nextElementSibling?.textContent).toBe(
|
||||
"5",
|
||||
);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "图中选择 第二条" }),
|
||||
);
|
||||
expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
|
||||
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
sources={importedSources}
|
||||
libraryView={{
|
||||
...pagedView(page),
|
||||
source: "codex",
|
||||
settledSource: "codex",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "第二条" })).toBeTruthy();
|
||||
// No raw conversation is read by id on the SDK data plane.
|
||||
expect(screen.queryByRole("button", { name: "查看完整对话" })).toBeNull();
|
||||
});
|
||||
|
||||
it("closing the compact source inspector cancels its pending auto-open", async () => {
|
||||
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<Receipt>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const view = render(<Host items={[]} sources={[importedSources[0]]} />);
|
||||
await openSourceReceipt();
|
||||
view.rerender(
|
||||
<Host
|
||||
items={[]}
|
||||
hidden={state === "revoked-hidden"}
|
||||
sources={
|
||||
state === "removed"
|
||||
? []
|
||||
: [{ ...importedSources[0], authorized: false }]
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
||||
await act(async () => finish(importedReceipt));
|
||||
view.rerender(<Host items={[]} sources={[importedSources[0]]} />);
|
||||
expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe("");
|
||||
expect(screen.queryByText(importedReceipt.examples[0].excerpt)).toBeNull();
|
||||
expect(screen.queryByText("查看已读内容")).toBeNull();
|
||||
expect(sourceReceipt).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("REV09: compact preview close keeps source selection for reopening", async () => {
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: query.includes("max-width"),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
const page = {
|
||||
items: memories,
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const view = render(
|
||||
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
|
||||
);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||
sourceReceipt.mockResolvedValue(importedReceipt);
|
||||
render(<Host items={[]} sources={importedSources} />);
|
||||
await openSourceReceipt();
|
||||
await screen.findByText(importedReceipt.examples[0].excerpt);
|
||||
await userEvent.click(screen.getByRole("button", { name: "关闭记忆详情" }));
|
||||
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
sources={importedSources}
|
||||
libraryView={{
|
||||
...pagedView(page),
|
||||
source: "codex",
|
||||
settledSource: "codex",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog", { name: "记忆与原文" })).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the source selection and offers retry when its page fails", async () => {
|
||||
const onRetry = vi.fn();
|
||||
const page = {
|
||||
items: memories,
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const view = render(
|
||||
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
|
||||
);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
sources={importedSources}
|
||||
libraryView={{
|
||||
...pagedView(page),
|
||||
source: "codex",
|
||||
settledSource: "codex",
|
||||
failed: true,
|
||||
onRetry,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("alert")
|
||||
.some((node) => node.textContent === "记忆暂时无法读取"),
|
||||
).toBe(true);
|
||||
await userEvent.click(screen.getByRole("button", { name: "重试" }));
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("drops a pending source selection when that source is revoked", async () => {
|
||||
const page = {
|
||||
items: memories,
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const view = render(
|
||||
<Host
|
||||
simplified
|
||||
sources={[importedSources[0]]}
|
||||
libraryView={pagedView(page)}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
||||
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
|
||||
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
sources={[{ ...importedSources[0], authorized: false }]}
|
||||
libraryView={{
|
||||
...pagedView(page),
|
||||
source: "codex",
|
||||
settledSource: "codex",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "图中选择 Codex" })).toBeNull();
|
||||
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
|
||||
"source:codex",
|
||||
);
|
||||
expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
|
||||
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
sources={[importedSources[0]]}
|
||||
libraryView={{
|
||||
...pagedView(page),
|
||||
source: "codex",
|
||||
settledSource: "codex",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
|
||||
"source:codex",
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "查看选中来源原文" }),
|
||||
);
|
||||
expect(screen.getByRole("dialog", { name: "记忆与原文" })).toBeTruthy();
|
||||
expect(screen.getByText("查看已读内容")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("REV09: first governed memories replace fallback without remounting the graph or breaking memory IDs", async () => {
|
||||
@@ -1291,4 +1162,5 @@ it.each([
|
||||
render(<Host items={[]} sources={sources} />);
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
||||
expect(sourceReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
|
||||
import { SourceImportReceipt } from "./SourceImportReceipt";
|
||||
import { sourceNameOr } from "./source-label";
|
||||
import { useMediaPreference } from "./use-media-preference";
|
||||
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
|
||||
@@ -96,8 +97,6 @@ type Props = {
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
stale: boolean;
|
||||
settledSource?: string;
|
||||
settledQuery?: string;
|
||||
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
||||
unattributedSource?: string;
|
||||
onQuery(value: string): void;
|
||||
@@ -214,9 +213,6 @@ export function MemoryMap(props: Props) {
|
||||
}, [submittedQuery]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
|
||||
const [pendingSourceSelection, setPendingSourceSelection] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [question, setQuestion] = useState("");
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||
const [filterNotice, setFilterNotice] = useState("");
|
||||
@@ -367,7 +363,6 @@ export function MemoryMap(props: Props) {
|
||||
// Source revocation also clears a suspended preview, without closing shared memory detail.
|
||||
if (groupNode && !visibleGroupNode) {
|
||||
setGroupNode(null);
|
||||
setPendingSourceSelection(null);
|
||||
setInspectorOpen(false);
|
||||
}
|
||||
}, [groupNode, visibleGroupNode]);
|
||||
@@ -394,7 +389,6 @@ export function MemoryMap(props: Props) {
|
||||
const pick = useCallback(
|
||||
(memoryId: string, scroll = true) => {
|
||||
if (!active.some((memory) => memory.memory_id === memoryId)) return;
|
||||
setPendingSourceSelection(null);
|
||||
scrollSelection.current = scroll;
|
||||
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
|
||||
setGroupKey("");
|
||||
@@ -408,32 +402,6 @@ export function MemoryMap(props: Props) {
|
||||
},
|
||||
[active, filtered, onOpenMemory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!library ||
|
||||
!pendingSourceSelection ||
|
||||
overlaysSuspended ||
|
||||
library.loading ||
|
||||
library.failed ||
|
||||
!selectedSource ||
|
||||
selectedSource.source_id !== pendingSourceSelection ||
|
||||
library.source !== pendingSourceSelection ||
|
||||
(library.settledSource ?? library.source) !== pendingSourceSelection ||
|
||||
(library.settledQuery ?? library.query).trim() ||
|
||||
library.page?.offset !== 0
|
||||
)
|
||||
return;
|
||||
setPendingSourceSelection(null);
|
||||
const first = active[0];
|
||||
if (first) pick(first.memory_id, false);
|
||||
}, [
|
||||
active,
|
||||
library,
|
||||
overlaysSuspended,
|
||||
pendingSourceSelection,
|
||||
pick,
|
||||
selectedSource,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (!props.simplified || openedDefault.current || overlaysSuspended) return;
|
||||
if (library?.loading && filtered.length === 0) return;
|
||||
@@ -484,7 +452,6 @@ export function MemoryMap(props: Props) {
|
||||
const clear = useCallback(() => {
|
||||
setSelectedId(null);
|
||||
setGroupNode(null);
|
||||
setPendingSourceSelection(null);
|
||||
setInspectorOpen(false);
|
||||
onCloseDetail();
|
||||
}, [onCloseDetail]);
|
||||
@@ -516,7 +483,6 @@ export function MemoryMap(props: Props) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
setPendingSourceSelection(null);
|
||||
setInspectorOpen(false);
|
||||
}
|
||||
function chooseGroup(key: string) {
|
||||
@@ -543,17 +509,8 @@ export function MemoryMap(props: Props) {
|
||||
setAxis("source");
|
||||
setGroupKey(`source:${node.source_id}`);
|
||||
if (library) {
|
||||
setPendingSourceSelection(node.source_id);
|
||||
library.onQuery("");
|
||||
library.onSource(node.source_id);
|
||||
library.resetPage();
|
||||
} else {
|
||||
const first = sourceGroups
|
||||
.find((entry) => entry.key === `source:${node.source_id}`)
|
||||
?.memoryIds.find((memoryId) =>
|
||||
active.some((memory) => memory.memory_id === memoryId),
|
||||
);
|
||||
if (first) pick(first, false);
|
||||
}
|
||||
}
|
||||
if (node.node_type === "topic") {
|
||||
@@ -1030,13 +987,11 @@ export function MemoryMap(props: Props) {
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("记忆详情")}
|
||||
aria-label={text(
|
||||
selectedSource ? "查看选中来源原文" : "查看选中记忆原文",
|
||||
)}
|
||||
title={text("查看原文")}
|
||||
onClick={() => {
|
||||
setInspectorOpen(true);
|
||||
if (selectedSource && library)
|
||||
setPendingSourceSelection(selectedSource.source_id);
|
||||
}}
|
||||
onClick={() => setInspectorOpen(true)}
|
||||
>
|
||||
<FileText size={17} />
|
||||
</button>
|
||||
@@ -1171,7 +1126,7 @@ export function MemoryMap(props: Props) {
|
||||
{inspectorDetail
|
||||
? text("记忆详情")
|
||||
: selectedSource
|
||||
? text("记忆详情")
|
||||
? text("来源原文")
|
||||
: visibleGroupNode
|
||||
? text("关联节点")
|
||||
: text("记忆详情")}
|
||||
@@ -1190,33 +1145,13 @@ export function MemoryMap(props: Props) {
|
||||
</header>
|
||||
<div className="map-inspector-body" ref={inspectorBody}>
|
||||
{selectedSource ? (
|
||||
<div className="map-inspector-empty map-inspector-loading">
|
||||
{library?.failed &&
|
||||
library.source === selectedSource.source_id ? (
|
||||
<>
|
||||
<p role="alert">{text("记忆暂时无法读取")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={library.onRetry}
|
||||
>
|
||||
{text("重试")}
|
||||
</button>
|
||||
</>
|
||||
) : pendingSourceSelection || library?.loading ? (
|
||||
<>
|
||||
<div className="map-detail-skeleton" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<p role="status">{text("正在读取记忆详情…")}</p>
|
||||
</>
|
||||
) : (
|
||||
<p>{text("还没有已生成的记忆")}</p>
|
||||
)}
|
||||
</div>
|
||||
inspectorOpen &&
|
||||
!overlaysSuspended && (
|
||||
<SourceImportReceipt
|
||||
key={selectedSource.source_id}
|
||||
source={selectedSource}
|
||||
/>
|
||||
)
|
||||
) : props.simplified && inspectorDetail ? (
|
||||
<div
|
||||
aria-busy={
|
||||
|
||||
@@ -1027,8 +1027,6 @@ export function MemorySpace({
|
||||
loading: sdkLibrary.loading,
|
||||
failed: sdkLibrary.failed,
|
||||
stale: false,
|
||||
settledSource: sdkLibrary.settledSource,
|
||||
settledQuery: sdkLibrary.settledQuery,
|
||||
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
||||
onQuery: setSdkLibraryQuery,
|
||||
onSource: setSdkLibrarySource,
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user