Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528d2c9fd7 | ||
|
|
8e56bf7a37 | ||
|
|
352fffb3da | ||
|
|
42c0ca1d14 | ||
|
|
0ca2e56ccc | ||
|
|
f328cdbf8e | ||
|
|
3f57a3c978 | ||
|
|
19a7af0e71 | ||
|
|
5791c0d1e7 | ||
|
|
bf92105210 | ||
|
|
3387247842 | ||
|
|
791285f4c9 | ||
|
|
dc762df78b | ||
|
|
8277a72aea | ||
|
|
5eed0f81fb | ||
|
|
59ca5afca6 | ||
|
|
2648012ad5 | ||
|
|
63a80092c2 | ||
|
|
6ba49bf112 | ||
|
|
cf410aa9cf | ||
|
|
81ed1d816f | ||
|
|
30c0b1d5ff | ||
|
|
1086f4d582 | ||
|
|
32539e7a82 | ||
|
|
cfae020a31 | ||
|
|
5d51513ec8 | ||
|
|
9db5b49ccb | ||
|
|
cb7df2a022 | ||
|
|
812d1c3846 | ||
|
|
c8b660651e | ||
|
|
bdd2d9cf84 | ||
|
|
fc2e51a8cc | ||
|
|
d4fca0639e | ||
|
|
a17230dadb | ||
|
|
f7961f340a | ||
|
|
cfebae118d | ||
|
|
d76bfa6474 | ||
|
|
b169906fcb | ||
|
|
f3b417ced4 | ||
|
|
e9698a293c | ||
|
|
aaebe64244 | ||
|
|
f3d13c4351 | ||
|
|
3e8a006f65 | ||
|
|
a70830970d | ||
|
|
556d95df84 | ||
|
|
ad05303d23 | ||
|
|
423cb00dbf | ||
|
|
e09fbd16f9 | ||
|
|
6718ba8bdf |
No files matched your search
@@ -4,6 +4,26 @@ Hy Memory and MemHub are versioned and released independently.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- MemHub turns off the HY pipeline log and trace at every diagnostic level
|
||||||
|
(hy-memory 2.0.8 `MEMORY_PIPELINE_LOG_ENABLED=false`,
|
||||||
|
`MEMORY_PIPELINE_TRACE_ENABLED=false`), so the per-step JSONL files under
|
||||||
|
`hy-memory/governed-v2/logs/` and the `pipeline_logs` rows stop growing.
|
||||||
|
The Debug panel no longer shows the HY pipeline section. Existing files are
|
||||||
|
left in place.
|
||||||
|
- HY SDK text logs are written once, to `hy_memory.log`. They are no longer
|
||||||
|
copied to `service.log` and `service-error.log` through the SDK's
|
||||||
|
stdout/stderr streams. SDK warnings and errors also go to `core.log`, so the
|
||||||
|
Debug panel still shows them. A live switch of the diagnostic level now also
|
||||||
|
changes the level of `hy_memory.log`.
|
||||||
|
|
||||||
|
- 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
|
- 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,
|
vectors: reattaching them left a Chroma instance open with its own settings,
|
||||||
and the readback that followed could not open the same store
|
and the readback that followed could not open the same store
|
||||||
@@ -68,6 +88,44 @@ Hy Memory and MemHub are versioned and released independently.
|
|||||||
`hy_node_permissions` proof table. Evidence, governed memories, model
|
`hy_node_permissions` proof table. Evidence, governed memories, model
|
||||||
profiles, SDK stores, and source/host grants are preserved.
|
profiles, SDK stores, and source/host grants are preserved.
|
||||||
|
|
||||||
|
## Hy Memory 2.0.8 — 2026-10-28
|
||||||
|
|
||||||
|
- Published
|
||||||
|
[`hy-memory==2.0.8`](https://pypi.org/project/hy-memory/2.0.8/).
|
||||||
|
- New `MEMORY_PIPELINE_LOG_ENABLED` (default `true`) turns the pipeline JSONL
|
||||||
|
log (`{MEMORY_LOG_DIR}/<subdir>/<date>.log`) off. With `false` no JSONL
|
||||||
|
file or directory is written. When `MEMORY_PIPELINE_TRACE_ENABLED` is also
|
||||||
|
`false`, `store_pipeline_log` becomes a no-op that returns `True` at once:
|
||||||
|
no timing, redaction, background task or database write. The init log line
|
||||||
|
now states whether the JSONL log is on or off.
|
||||||
|
- Conversation and memory text no longer appears in INFO or WARNING logs. The
|
||||||
|
`[add]` input, search queries and rewrites, coding experience tasks, fact
|
||||||
|
statements and subjects, reconcile op details, extracted info, embedding
|
||||||
|
inputs and unparsable model output moved to DEBUG. INFO lines keep ids,
|
||||||
|
counts and character lengths; parse-failure warnings remain but report only
|
||||||
|
the length of the output, e.g. `failed to parse output (812 chars)`. The
|
||||||
|
`[embed]` INFO line now carries `request.input_chars` instead of
|
||||||
|
`request.input`. The `JSON_PARSE_FAILED` error message from extraction no
|
||||||
|
longer embeds the model output either.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
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
|
## Hy Memory 2.0.6 — 2026-10-27
|
||||||
|
|
||||||
- Published
|
- Published
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ SDK 不设置 Rerank Search 并发上限。开启后模型在 `HyMemoryClient`
|
|||||||
| `MEMORY_LOG_FORMAT` | `text` | `text` / `json` |
|
| `MEMORY_LOG_FORMAT` | `text` | `text` / `json` |
|
||||||
| `MEMORY_LOG_DIR` | — | 统一日志根目录 |
|
| `MEMORY_LOG_DIR` | — | 统一日志根目录 |
|
||||||
| `MEMORY_TRACE_LOG_DIR` | — | JSONL 日志目录的兼容旧名;优先使用 `MEMORY_LOG_DIR` |
|
| `MEMORY_TRACE_LOG_DIR` | — | JSONL 日志目录的兼容旧名;优先使用 `MEMORY_LOG_DIR` |
|
||||||
|
| `MEMORY_PIPELINE_LOG_ENABLED` | `true` | 是否写 pipeline JSONL 日志(`{MEMORY_LOG_DIR}/<subdir>/<date>.log`);`false` 完全不写 |
|
||||||
| `MEMORY_PIPELINE_TRACE_ENABLED` | `true` | 是否写入 cache 的 pipeline trace 表 |
|
| `MEMORY_PIPELINE_TRACE_ENABLED` | `true` | 是否写入 cache 的 pipeline trace 表 |
|
||||||
| `MEMORY_MEMORY_OPERATIONS_ENABLED` | `false` | 是否写入 memory operation 审计记录 |
|
| `MEMORY_MEMORY_OPERATIONS_ENABLED` | `false` | 是否写入 memory operation 审计记录 |
|
||||||
| `MEMORY_HISTORY_ENABLE` | `true` | 是否启用历史记录 |
|
| `MEMORY_HISTORY_ENABLE` | `true` | 是否启用历史记录 |
|
||||||
|
|||||||
@@ -1177,7 +1177,7 @@ class Extractor:
|
|||||||
if not tc_id:
|
if not tc_id:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[extractor] tool_call_id is empty! "
|
f"[extractor] tool_call_id is empty! "
|
||||||
f"raw tc: {json.dumps(tc_raw, ensure_ascii=False, default=str)[:500]}"
|
f"raw_keys={list(tc_raw.keys())}"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
t_res = await tool_registry.dispatch(
|
t_res = await tool_registry.dispatch(
|
||||||
@@ -1311,11 +1311,12 @@ class Extractor:
|
|||||||
if parsed is None:
|
if parsed is None:
|
||||||
if not all_tool_calls:
|
if not all_tool_calls:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[extractor] JSON parse failed: {raw_text[:200]}"
|
f"[extractor] JSON parse failed ({len(raw_text)} chars)"
|
||||||
)
|
)
|
||||||
|
logger.debug(f"[extractor] unparsable output: {raw_text[:200]!r}")
|
||||||
return ExtractResult(
|
return ExtractResult(
|
||||||
success=False,
|
success=False,
|
||||||
error=f"JSON parse failed: {raw_text[:200]}",
|
error=f"JSON parse failed ({len(raw_text)} chars)",
|
||||||
error_code="JSON_PARSE_FAILED",
|
error_code="JSON_PARSE_FAILED",
|
||||||
raw_response=raw_text,
|
raw_response=raw_text,
|
||||||
tokens_used=total_tokens,
|
tokens_used=total_tokens,
|
||||||
|
|||||||
@@ -129,8 +129,7 @@ def _parse_tool_calls_list(raw_calls: list, _json) -> List[Dict[str, Any]]:
|
|||||||
tc_id = tc.get("id") or ""
|
tc_id = tc.get("id") or ""
|
||||||
if not tc_id:
|
if not tc_id:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[llm] tool_call missing 'id'! raw: "
|
f"[llm] tool_call missing 'id'! keys={list(tc.keys())}"
|
||||||
f"{_json.dumps(tc, ensure_ascii=False, default=str)[:500]}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 保留原始 tc 完整结构,只规范化 arguments
|
# 保留原始 tc 完整结构,只规范化 arguments
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ class MemAgent:
|
|||||||
f"identity={len(identity)} facts={len(facts)} "
|
f"identity={len(identity)} facts={len(facts)} "
|
||||||
f"basic_info={basic_field_count}fields"
|
f"basic_info={basic_field_count}fields"
|
||||||
)
|
)
|
||||||
logger.info(f"MemAgent: extracted_info={extract_result.info}")
|
logger.debug(f"MemAgent: extracted_info={extract_result.info}")
|
||||||
|
|
||||||
# 3. 冲突检测(如果有已有记忆)
|
# 3. 冲突检测(如果有已有记忆)
|
||||||
if mode == ProcessMode.FULL and existing_memories:
|
if mode == ProcessMode.FULL and existing_memories:
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def is_canonical_state_node(node: Any) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _log_ops_detail(ops: List["ReconcileOp"]) -> None:
|
def _log_ops_detail(ops: List["ReconcileOp"]) -> None:
|
||||||
"""以单条 INFO(JSON list)输出 reconcile 的逐条结果,便于线上 debug。"""
|
"""以单条 DEBUG(JSON list)输出 reconcile 的逐条结果;含记忆正文,不进 INFO。"""
|
||||||
if not ops:
|
if not ops:
|
||||||
return
|
return
|
||||||
view = [
|
view = [
|
||||||
@@ -68,7 +68,7 @@ def _log_ops_detail(ops: List["ReconcileOp"]) -> None:
|
|||||||
}
|
}
|
||||||
for o in ops
|
for o in ops
|
||||||
]
|
]
|
||||||
logger.info(
|
logger.debug(
|
||||||
f"[reconciler] ops detail: {json.dumps(view, ensure_ascii=False, default=str)}"
|
f"[reconciler] ops detail: {json.dumps(view, ensure_ascii=False, default=str)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1514,9 +1514,10 @@ class MemoryReconciler:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"[reconciler] RECONCILE JSON parse failed "
|
f"[reconciler] RECONCILE JSON parse failed "
|
||||||
f"(attempt {_attempt+1}/{_MAX_PARSE_ATTEMPTS}), "
|
f"(attempt {_attempt+1}/{_MAX_PARSE_ATTEMPTS}), "
|
||||||
f"retrying: raw="
|
f"retrying: raw({len(raw_response or '')} chars)"
|
||||||
f"{raw_response[:200] if _log_llm_content_enabled() else '<redacted>'}"
|
|
||||||
)
|
)
|
||||||
|
if _log_llm_content_enabled():
|
||||||
|
logger.debug(f"[reconciler] unparsable raw={raw_response[:200]!r}")
|
||||||
|
|
||||||
recon_elapsed = (_time.perf_counter() - t_recon) * 1000
|
recon_elapsed = (_time.perf_counter() - t_recon) * 1000
|
||||||
|
|
||||||
@@ -1528,8 +1529,8 @@ class MemoryReconciler:
|
|||||||
error_code = "LLM_UNAVAILABLE"
|
error_code = "LLM_UNAVAILABLE"
|
||||||
elif raw_response.strip():
|
elif raw_response.strip():
|
||||||
error_msg = (
|
error_msg = (
|
||||||
f"RECONCILE JSON parse failed after {_n_attempts} attempts: "
|
f"RECONCILE JSON parse failed after {_n_attempts} attempts "
|
||||||
f"{raw_response[:200] if _log_llm_content_enabled() else '<redacted>'}"
|
f"({len(raw_response)} chars)"
|
||||||
)
|
)
|
||||||
error_code = "JSON_PARSE_FAILED"
|
error_code = "JSON_PARSE_FAILED"
|
||||||
else:
|
else:
|
||||||
@@ -1806,7 +1807,8 @@ class MemoryReconciler:
|
|||||||
except Exception as _re:
|
except Exception as _re:
|
||||||
logger.debug(f"[reconciler] json_repair failed: {_re}")
|
logger.debug(f"[reconciler] json_repair failed: {_re}")
|
||||||
if data is None:
|
if data is None:
|
||||||
logger.warning(f"[reconciler] failed to parse ops JSON: {text[:200]}")
|
logger.warning(f"[reconciler] failed to parse ops JSON ({len(text)} chars)")
|
||||||
|
logger.debug(f"[reconciler] unparsable ops output: {text[:200]!r}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not isinstance(data, list):
|
if not isinstance(data, list):
|
||||||
@@ -2018,11 +2020,11 @@ class MemoryReconciler:
|
|||||||
if clash is not None:
|
if clash is not None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[reconciler] memory_id {clash} already touched by '{touched[clash]}', "
|
f"[reconciler] memory_id {clash} already touched by '{touched[clash]}', "
|
||||||
f"current op '{op.op} ({op.reason[:60]})' dropped"
|
f"current op '{op.op}' dropped"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
for t in target_ids:
|
for t in target_ids:
|
||||||
touched[t] = f"{op.op}({op.reason[:40]})"
|
touched[t] = op.op
|
||||||
validated.append(op)
|
validated.append(op)
|
||||||
|
|
||||||
return validated
|
return validated
|
||||||
@@ -211,7 +211,9 @@ def parse_tool_calls_from_json(raw: Any) -> List[ToolCall]:
|
|||||||
try:
|
try:
|
||||||
args = json.loads(args_raw) if args_raw.strip() else {}
|
args = json.loads(args_raw) if args_raw.strip() else {}
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.warning(f"[tools] failed to parse arguments as JSON: {args_raw[:120]}")
|
logger.warning(
|
||||||
|
f"[tools] failed to parse arguments as JSON ({len(args_raw)} chars)"
|
||||||
|
)
|
||||||
args = {}
|
args = {}
|
||||||
elif isinstance(args_raw, dict):
|
elif isinstance(args_raw, dict):
|
||||||
args = args_raw
|
args = args_raw
|
||||||
|
|||||||
+27
-14
@@ -744,7 +744,7 @@ class HyMemoryClient:
|
|||||||
def _install_pipeline_observability_hooks(self) -> None:
|
def _install_pipeline_observability_hooks(self) -> None:
|
||||||
"""
|
"""
|
||||||
包装 cache.store_pipeline_log:
|
包装 cache.store_pipeline_log:
|
||||||
- Log:JSONL 文件,始终写入(环节级,不依赖 Trace 开关)
|
- Log:JSONL 文件,由 MEMORY_PIPELINE_LOG_ENABLED 控制(默认开启)
|
||||||
- Trace:SQLite pipeline_logs,由 MEMORY_PIPELINE_TRACE_ENABLED 控制(Inspector)
|
- Trace:SQLite pipeline_logs,由 MEMORY_PIPELINE_TRACE_ENABLED 控制(Inspector)
|
||||||
- 落盘 fire-and-forget,不阻塞 search/write 热路径
|
- 落盘 fire-and-forget,不阻塞 search/write 热路径
|
||||||
"""
|
"""
|
||||||
@@ -958,7 +958,7 @@ class HyMemoryClient:
|
|||||||
parsed_tool_calls = json.loads(raw_tool_calls) if raw_tool_calls else []
|
parsed_tool_calls = json.loads(raw_tool_calls) if raw_tool_calls else []
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"tool_calls 是非法 JSON 字符串,已忽略: %r", raw_tool_calls[:200]
|
"tool_calls 是非法 JSON 字符串,已忽略 (%d chars)", len(raw_tool_calls)
|
||||||
)
|
)
|
||||||
parsed_tool_calls = []
|
parsed_tool_calls = []
|
||||||
raw_tool_calls = parsed_tool_calls if isinstance(parsed_tool_calls, list) else []
|
raw_tool_calls = parsed_tool_calls if isinstance(parsed_tool_calls, list) else []
|
||||||
@@ -1544,6 +1544,7 @@ class HyMemoryClient:
|
|||||||
# LLM 写的"这个结论一路怎么改成现在这样",直接进提示词;
|
# LLM 写的"这个结论一路怎么改成现在这样",直接进提示词;
|
||||||
# 没被覆盖过就是空串。不参与检索——召回认的是 statement 的当前值。
|
# 没被覆盖过就是空串。不参与检索——召回认的是 statement 的当前值。
|
||||||
"fact_chain_summary": fact.fact_chain_summary,
|
"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,
|
"created_at": fact.created_at.isoformat() if fact.created_at else None,
|
||||||
"score": round(float(score), 4),
|
"score": round(float(score), 4),
|
||||||
"source": fact.source,
|
"source": fact.source,
|
||||||
@@ -1652,6 +1653,8 @@ class HyMemoryClient:
|
|||||||
"score": (round(float(score_by_id[d.experience_id]), 4)
|
"score": (round(float(score_by_id[d.experience_id]), 4)
|
||||||
if d.experience_id in score_by_id else None),
|
if d.experience_id in score_by_id else None),
|
||||||
"confidence": d.confidence,
|
"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,
|
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
||||||
"source": d.source,
|
"source": d.source,
|
||||||
"extraction_source": d.extraction_source,
|
"extraction_source": d.extraction_source,
|
||||||
@@ -1953,6 +1956,7 @@ class HyMemoryClient:
|
|||||||
"content" chat 正文;经验为 "task\nsolution";事实为 statement
|
"content" chat 正文;经验为 "task\nsolution";事实为 statement
|
||||||
"score" RRF 融合分;"lane_score" 为该路原始分;"lane_rank" 为该路名次
|
"score" RRF 融合分;"lane_score" 为该路原始分;"lane_rank" 为该路名次
|
||||||
"source", "created_at", "updated_at"(秒级 epoch,取不到为 None)
|
"source", "created_at", "updated_at"(秒级 epoch,取不到为 None)
|
||||||
|
"memory_at" 对话时间(秒级 epoch),各路原有字段,三路同名同格式
|
||||||
chat 路内部名次:normal → schema → proactive 依次排;working/coding 路
|
chat 路内部名次:normal → schema → proactive 依次排;working/coding 路
|
||||||
内部名次:事实与经验各按原顺序交替(事实在前),两层谁也不压谁。
|
内部名次:事实与经验各按原顺序交替(事实在前),两层谁也不压谁。
|
||||||
"""
|
"""
|
||||||
@@ -2144,7 +2148,8 @@ class HyMemoryClient:
|
|||||||
"limit", "offset", "request_id", "elapsed_ms"}
|
"limit", "offset", "request_id", "elapsed_ms"}
|
||||||
每条带 "scene"、"kind"(chat | experience)、"memory_id"、"content"
|
每条带 "scene"、"kind"(chat | experience)、"memory_id"、"content"
|
||||||
(经验为 "task\nsolution")、"source"、"status"、"created_at"、
|
(经验为 "task\nsolution")、"source"、"status"、"created_at"、
|
||||||
"updated_at"(秒级 epoch),以及该路原有字段。
|
"updated_at"(秒级 epoch),以及该路原有字段(含对话时间
|
||||||
|
"memory_at",秒级 epoch,三路同名同格式)。
|
||||||
"""
|
"""
|
||||||
return self._loop_thread.run(
|
return self._loop_thread.run(
|
||||||
self.async_list_memories(
|
self.async_list_memories(
|
||||||
@@ -2967,6 +2972,7 @@ class HyMemoryClient:
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
source=source,
|
source=source,
|
||||||
|
memory_at=memory_at,
|
||||||
)
|
)
|
||||||
# Public receipt always identifies the productivity namespace.
|
# Public receipt always identifies the productivity namespace.
|
||||||
resp["variant"] = scene_variant
|
resp["variant"] = scene_variant
|
||||||
@@ -3002,13 +3008,13 @@ class HyMemoryClient:
|
|||||||
f"[add] writing {preview} user={uid} "
|
f"[add] writing {preview} user={uid} "
|
||||||
f"agent_id={agent_id} session_id={session_id} mode={self._mode}"
|
f"agent_id={agent_id} session_id={session_id} mode={self._mode}"
|
||||||
)
|
)
|
||||||
logger.info(f"[add] messages detail: {[m.to_dict() for m in chat_messages]}")
|
logger.debug(f"[add] messages detail: {[m.to_dict() for m in chat_messages]}")
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[add] writing text({len(content)} chars) user={uid} "
|
f"[add] writing text({len(content)} chars) user={uid} "
|
||||||
f"agent_id={agent_id} session_id={session_id} mode={self._mode} "
|
f"agent_id={agent_id} session_id={session_id} mode={self._mode}"
|
||||||
f"content={content}"
|
|
||||||
)
|
)
|
||||||
|
logger.debug(f"[add] text content={content!r}")
|
||||||
|
|
||||||
# mode → agent_mode 映射
|
# mode → agent_mode 映射
|
||||||
# lite: 不调 LLM agent
|
# lite: 不调 LLM agent
|
||||||
@@ -4028,14 +4034,14 @@ class HyMemoryClient:
|
|||||||
decided_variant = HyMemoryClient._scene_variant(scene)
|
decided_variant = HyMemoryClient._scene_variant(scene)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[search] scene explicit={decided_scene} (no LLM judge); "
|
f"[search] scene explicit={decided_scene} (no LLM judge); "
|
||||||
f"target={effective_target_query!r}"
|
f"target({len(effective_target_query or '')} chars)"
|
||||||
)
|
)
|
||||||
elif not agentic_enabled and not queries_supplied:
|
elif not agentic_enabled and not queries_supplied:
|
||||||
# 默认 light search 仅指 query="...":固定 normal,绝不调用 LLM。
|
# 默认 light search 仅指 query="...":固定 normal,绝不调用 LLM。
|
||||||
decided_scene = "normal"
|
decided_scene = "normal"
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[search] search_mode=default single-query scene=normal (no LLM); "
|
f"[search] search_mode=default single-query scene=normal (no LLM); "
|
||||||
f"target={target_query!r}"
|
f"target({len(target_query or '')} chars)"
|
||||||
)
|
)
|
||||||
elif agentic_enabled:
|
elif agentic_enabled:
|
||||||
# agentic:单/多 query 都强制一次 LLM 完成三分类 + rewrite + 时间解析。
|
# agentic:单/多 query 都强制一次 LLM 完成三分类 + rewrite + 时间解析。
|
||||||
@@ -4070,14 +4076,18 @@ class HyMemoryClient:
|
|||||||
rewrite_created_after = ar.get("created_after")
|
rewrite_created_after = ar.get("created_after")
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[search] agentic scene={decided_scene} via rewrite-LLM "
|
f"[search] agentic scene={decided_scene} via rewrite-LLM "
|
||||||
f"(classify={want_classify}); orig={target_query!r} "
|
f"(classify={want_classify}); rewrite_used={rewrite_used} "
|
||||||
f"effective={effective_target_query!r} rewrite_used={rewrite_used} "
|
|
||||||
f"created_after={rewrite_created_after}"
|
f"created_after={rewrite_created_after}"
|
||||||
)
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[search] agentic rewrite: orig={target_query!r} "
|
||||||
|
f"effective={effective_target_query!r}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
decided_scene = "normal"
|
decided_scene = "normal"
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[search] agentic rewrite-LLM fallback=normal; target={target_query!r}"
|
f"[search] agentic rewrite-LLM fallback=normal; "
|
||||||
|
f"target({len(target_query or '')} chars)"
|
||||||
)
|
)
|
||||||
elif rewrite_enabled and not _too_long:
|
elif rewrite_enabled and not _too_long:
|
||||||
# default + queries= 的原逻辑:配置开启时一次 LLM 做 rewrite/time,
|
# default + queries= 的原逻辑:配置开启时一次 LLM 做 rewrite/time,
|
||||||
@@ -4156,7 +4166,7 @@ class HyMemoryClient:
|
|||||||
decided_scene = "normal"
|
decided_scene = "normal"
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[search] default multi-query features disabled; no LLM; "
|
f"[search] default multi-query features disabled; no LLM; "
|
||||||
f"target={target_query!r}"
|
f"target({len(target_query or '')} chars)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────
|
||||||
@@ -4209,7 +4219,7 @@ class HyMemoryClient:
|
|||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[search] routing to PRODUCTIVITY path variant={decided_variant}: "
|
f"[search] routing to PRODUCTIVITY path variant={decided_variant}: "
|
||||||
f"target={effective_target_query!r} "
|
f"target({len(effective_target_query or '')} chars) "
|
||||||
f"workspace_id={workspace_id!r} branch={branch!r}"
|
f"workspace_id={workspace_id!r} branch={branch!r}"
|
||||||
)
|
)
|
||||||
return await self._search_coding(
|
return await self._search_coding(
|
||||||
@@ -4237,9 +4247,11 @@ class HyMemoryClient:
|
|||||||
# Chat 召回链路(原有逻辑)
|
# Chat 召回链路(原有逻辑)
|
||||||
# ────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[search] query='{effective_target_query}' user_ids={effective_user_ids} "
|
f"[search] query({len(effective_target_query or '')} chars) "
|
||||||
|
f"user_ids={effective_user_ids} "
|
||||||
f"agent_ids={effective_agent_ids} session_ids={effective_session_ids} limit={limit}"
|
f"agent_ids={effective_agent_ids} session_ids={effective_session_ids} limit={limit}"
|
||||||
)
|
)
|
||||||
|
logger.debug(f"[search] query={effective_target_query!r}")
|
||||||
|
|
||||||
# rewrite 抽出的时间下界与用户显式 created_after 取更晚者(更严格)
|
# rewrite 抽出的时间下界与用户显式 created_after 取更晚者(更严格)
|
||||||
effective_created_after = created_after
|
effective_created_after = created_after
|
||||||
@@ -5920,6 +5932,7 @@ class HyMemoryClient:
|
|||||||
"source_fact_ids": list(d.source_fact_ids),
|
"source_fact_ids": list(d.source_fact_ids),
|
||||||
"confidence": d.confidence,
|
"confidence": d.confidence,
|
||||||
"status": MemoryStatus.ACTIVE.value,
|
"status": MemoryStatus.ACTIVE.value,
|
||||||
|
"memory_at": _epoch_seconds(d.memory_at),
|
||||||
"created_at": d.created_at.isoformat() if d.created_at else None,
|
"created_at": d.created_at.isoformat() if d.created_at else None,
|
||||||
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
||||||
"source": d.source,
|
"source": d.source,
|
||||||
|
|||||||
@@ -1166,7 +1166,10 @@ class CodingMemoryExtractor:
|
|||||||
obj = None
|
obj = None
|
||||||
|
|
||||||
if not isinstance(obj, dict):
|
if not isinstance(obj, dict):
|
||||||
logger.warning(f"[coding-extract] failed to parse output: {text!r}")
|
logger.warning(
|
||||||
|
f"[coding-extract] failed to parse output ({len(text or '')} chars)"
|
||||||
|
)
|
||||||
|
logger.debug(f"[coding-extract] unparsable output: {text!r}")
|
||||||
return [], []
|
return [], []
|
||||||
|
|
||||||
# "memories" 是双层化之前的 key,老 prompt 缓存 / 旧模型可能还这么回
|
# "memories" 是双层化之前的 key,老 prompt 缓存 / 旧模型可能还这么回
|
||||||
|
|||||||
@@ -454,9 +454,10 @@ class WorkingFactReconciler:
|
|||||||
workspace_id: Optional[str] = None,
|
workspace_id: Optional[str] = None,
|
||||||
branch: Optional[str] = None,
|
branch: Optional[str] = None,
|
||||||
source: str = "default",
|
source: str = "default",
|
||||||
|
memory_at: Optional[datetime] = None,
|
||||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||||
"""
|
"""
|
||||||
执行 reconcile + 持久化。
|
执行 reconcile + 持久化。memory_at 是这段对话的时间,记到新事实上。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖,
|
(真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖,
|
||||||
@@ -493,6 +494,7 @@ class WorkingFactReconciler:
|
|||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
branch=branch,
|
branch=branch,
|
||||||
source=source,
|
source=source,
|
||||||
|
memory_at=memory_at,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} "
|
f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} "
|
||||||
@@ -568,9 +570,12 @@ class WorkingFactReconciler:
|
|||||||
kept.append(fact)
|
kept.append(fact)
|
||||||
if blocked:
|
if blocked:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[fact-reconcile] subject gate blocked {len(blocked)} candidate(s) "
|
f"[fact-reconcile] subject gate blocked {len(blocked)} candidate(s): "
|
||||||
f"for subject={draft.subject!r}: "
|
+ ", ".join(f.fact_id for f in blocked)
|
||||||
+ ", ".join(f"{f.fact_id}({f.subject!r})" for f in blocked)
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[fact-reconcile] subject gate: draft subject={draft.subject!r} "
|
||||||
|
f"blocked subjects={[f.subject for f in blocked]!r}"
|
||||||
)
|
)
|
||||||
result.append(kept)
|
result.append(kept)
|
||||||
return result
|
return result
|
||||||
@@ -743,7 +748,10 @@ class WorkingFactReconciler:
|
|||||||
obj = None
|
obj = None
|
||||||
|
|
||||||
if not isinstance(obj, dict):
|
if not isinstance(obj, dict):
|
||||||
logger.warning(f"[fact-reconcile] failed to parse ops output: {text!r}")
|
logger.warning(
|
||||||
|
f"[fact-reconcile] failed to parse ops output ({len(text or '')} chars)"
|
||||||
|
)
|
||||||
|
logger.debug(f"[fact-reconcile] unparsable ops output: {text!r}")
|
||||||
return {}
|
return {}
|
||||||
ops_raw = obj.get("ops")
|
ops_raw = obj.get("ops")
|
||||||
if not isinstance(ops_raw, list):
|
if not isinstance(ops_raw, list):
|
||||||
@@ -781,6 +789,7 @@ class WorkingFactReconciler:
|
|||||||
workspace_id: Optional[str],
|
workspace_id: Optional[str],
|
||||||
branch: Optional[str] = None,
|
branch: Optional[str] = None,
|
||||||
source: str = "default",
|
source: str = "default",
|
||||||
|
memory_at: Optional[datetime] = None,
|
||||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||||
inserted: List[WorkingFact] = []
|
inserted: List[WorkingFact] = []
|
||||||
ops: List[FactReconcileOp] = []
|
ops: List[FactReconcileOp] = []
|
||||||
@@ -801,6 +810,7 @@ class WorkingFactReconciler:
|
|||||||
subject=draft.subject,
|
subject=draft.subject,
|
||||||
details=draft.details,
|
details=draft.details,
|
||||||
status="active",
|
status="active",
|
||||||
|
memory_at=memory_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
allowed = {c.fact_id for c in (candidates[idx] if idx < len(candidates) else [])}
|
allowed = {c.fact_id for c in (candidates[idx] if idx < len(candidates) else [])}
|
||||||
@@ -831,8 +841,11 @@ class WorkingFactReconciler:
|
|||||||
# 别的主体的状态不是。闸门关掉时这道复查一并跳过,否则闸门就没关干净。
|
# 别的主体的状态不是。闸门关掉时这道复查一并跳过,否则闸门就没关干净。
|
||||||
if self.subject_gate and _subject_conflict(draft.subject, old.subject):
|
if self.subject_gate and _subject_conflict(draft.subject, old.subject):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[fact-reconcile] refused cross-subject SUPERSEDE: "
|
f"[fact-reconcile] refused cross-subject SUPERSEDE: {old.fact_id}"
|
||||||
f"{old.fact_id}({old.subject!r}) by draft subject {draft.subject!r}"
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[fact-reconcile] cross-subject: old subject={old.subject!r} "
|
||||||
|
f"draft subject={draft.subject!r}"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
|||||||
+21
-10
@@ -195,8 +195,10 @@ async def classify_messages_scene(
|
|||||||
result = _parse_scene_json(resp.content)
|
result = _parse_scene_json(resp.content)
|
||||||
if result is None:
|
if result is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[coding-judge] failed to parse classify_messages output: {resp.content!r}; defaulting to chat"
|
f"[coding-judge] failed to parse classify_messages output "
|
||||||
|
f"({len(resp.content or '')} chars); defaulting to chat"
|
||||||
)
|
)
|
||||||
|
logger.debug(f"[coding-judge] unparsable classify_messages output: {resp.content!r}")
|
||||||
await _store_coding_log(
|
await _store_coding_log(
|
||||||
cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||||||
session_id=session_id, step="CODING_JUDGE", prompt=prompt,
|
session_id=session_id, step="CODING_JUDGE", prompt=prompt,
|
||||||
@@ -207,9 +209,8 @@ async def classify_messages_scene(
|
|||||||
return "chat"
|
return "chat"
|
||||||
scene = result["scene"]
|
scene = result["scene"]
|
||||||
parsed = {**result, "is_coding": scene != "chat"}
|
parsed = {**result, "is_coding": scene != "chat"}
|
||||||
logger.info(
|
logger.info(f"[coding-judge] scene={scene}")
|
||||||
f"[coding-judge] scene={scene} reason={result.get('reason', '')!r}"
|
logger.debug(f"[coding-judge] scene={scene} reason={result.get('reason', '')!r}")
|
||||||
)
|
|
||||||
await _store_coding_log(
|
await _store_coding_log(
|
||||||
cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||||||
session_id=session_id, step="CODING_JUDGE", prompt=prompt,
|
session_id=session_id, step="CODING_JUDGE", prompt=prompt,
|
||||||
@@ -295,8 +296,10 @@ async def classify_queries_scene(
|
|||||||
result = _parse_scene_json(resp.content)
|
result = _parse_scene_json(resp.content)
|
||||||
if result is None:
|
if result is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[coding-judge] failed to parse classify_queries output: {resp.content!r}; defaulting to chat"
|
f"[coding-judge] failed to parse classify_queries output "
|
||||||
|
f"({len(resp.content or '')} chars); defaulting to chat"
|
||||||
)
|
)
|
||||||
|
logger.debug(f"[coding-judge] unparsable classify_queries output: {resp.content!r}")
|
||||||
return "chat"
|
return "chat"
|
||||||
scene = result["scene"]
|
scene = result["scene"]
|
||||||
logger.info(f"[coding-judge] queries scene={scene}")
|
logger.info(f"[coding-judge] queries scene={scene}")
|
||||||
@@ -422,15 +425,18 @@ async def classify_and_rewrite_queries(
|
|||||||
scene = _parse_scene_label(head)
|
scene = _parse_scene_label(head)
|
||||||
if scene is None:
|
if scene is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[coding-judge] classify_and_rewrite unrecognized head={head!r}; falling back to chat"
|
f"[coding-judge] classify_and_rewrite unrecognized head "
|
||||||
|
f"({len(head)} chars); falling back to chat"
|
||||||
)
|
)
|
||||||
|
logger.debug(f"[coding-judge] classify_and_rewrite unrecognized head={head!r}")
|
||||||
return {"scene": "chat", "is_coding": False, "rewrite_query": rewrite, "ok": False}
|
return {"scene": "chat", "is_coding": False, "rewrite_query": rewrite, "ok": False}
|
||||||
is_coding = scene != "chat"
|
is_coding = scene != "chat"
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[coding-judge] classify_and_rewrite scene={scene} "
|
f"[coding-judge] classify_and_rewrite scene={scene} "
|
||||||
f"rewrite={rewrite!r}"
|
f"rewrite({len(rewrite)} chars)"
|
||||||
)
|
)
|
||||||
|
logger.debug(f"[coding-judge] classify_and_rewrite rewrite={rewrite!r}")
|
||||||
return {"scene": scene, "is_coding": is_coding, "rewrite_query": rewrite, "ok": True}
|
return {"scene": scene, "is_coding": is_coding, "rewrite_query": rewrite, "ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -569,8 +575,9 @@ async def analyze_queries(
|
|||||||
parsed_scene = _parse_scene_label(head)
|
parsed_scene = _parse_scene_label(head)
|
||||||
if parsed_scene is None:
|
if parsed_scene is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[coding-judge] analyze_queries unrecognized head={head!r}; ok=False"
|
f"[coding-judge] analyze_queries unrecognized head ({len(head)} chars); ok=False"
|
||||||
)
|
)
|
||||||
|
logger.debug(f"[coding-judge] analyze_queries unrecognized head={head!r}")
|
||||||
return {**fallback, "since_raw": ""}
|
return {**fallback, "since_raw": ""}
|
||||||
scene = parsed_scene
|
scene = parsed_scene
|
||||||
idx = 1
|
idx = 1
|
||||||
@@ -589,9 +596,10 @@ async def analyze_queries(
|
|||||||
created_after = _since_to_created_after(since_raw, current_time)
|
created_after = _since_to_created_after(since_raw, current_time)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[coding-judge] analyze scene={scene} rewrite={rewrite!r} "
|
f"[coding-judge] analyze scene={scene} rewrite({len(rewrite)} chars) "
|
||||||
f"since={since_raw!r} created_after={created_after}"
|
f"since={since_raw!r} created_after={created_after}"
|
||||||
)
|
)
|
||||||
|
logger.debug(f"[coding-judge] analyze rewrite={rewrite!r}")
|
||||||
return {
|
return {
|
||||||
"scene": scene, "is_coding": scene != "chat", "rewrite_query": rewrite,
|
"scene": scene, "is_coding": scene != "chat", "rewrite_query": rewrite,
|
||||||
"created_after": created_after, "since_raw": since_raw, "ok": True,
|
"created_after": created_after, "since_raw": since_raw, "ok": True,
|
||||||
@@ -614,7 +622,10 @@ def _since_to_created_after(since_raw: str, current_time: str) -> Optional[float
|
|||||||
return dt.timestamp()
|
return dt.timestamp()
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
logger.warning(f"[coding-judge] analyze_queries unparsable SINCE={since_raw!r}")
|
logger.warning(
|
||||||
|
f"[coding-judge] analyze_queries unparsable SINCE ({len(since_raw)} chars)"
|
||||||
|
)
|
||||||
|
logger.debug(f"[coding-judge] analyze_queries unparsable SINCE={since_raw!r}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -388,7 +388,10 @@ class CodingMemoryReconciler:
|
|||||||
obj = None
|
obj = None
|
||||||
|
|
||||||
if not obj or not isinstance(obj, dict):
|
if not obj or not isinstance(obj, dict):
|
||||||
logger.warning(f"[coding-reconcile] failed to parse ops output: {text!r}")
|
logger.warning(
|
||||||
|
f"[coding-reconcile] failed to parse ops output ({len(text or '')} chars)"
|
||||||
|
)
|
||||||
|
logger.debug(f"[coding-reconcile] unparsable ops output: {text!r}")
|
||||||
return []
|
return []
|
||||||
ops_raw = obj.get("ops") or []
|
ops_raw = obj.get("ops") or []
|
||||||
if not isinstance(ops_raw, list):
|
if not isinstance(ops_raw, list):
|
||||||
@@ -441,10 +444,10 @@ class CodingMemoryReconciler:
|
|||||||
for op in ops:
|
for op in ops:
|
||||||
if op.action == "ADD":
|
if op.action == "ADD":
|
||||||
if op.draft_idx is None or not (0 <= op.draft_idx < len(drafts)):
|
if op.draft_idx is None or not (0 <= op.draft_idx < len(drafts)):
|
||||||
logger.warning(f"[coding-reconcile] drop invalid ADD: {op.to_dict()}")
|
logger.warning(f"[coding-reconcile] drop invalid ADD: {_op_ref(op)}")
|
||||||
continue
|
continue
|
||||||
if op.draft_idx in consumed:
|
if op.draft_idx in consumed:
|
||||||
logger.warning(f"[coding-reconcile] drop ADD on consumed draft: {op.to_dict()}")
|
logger.warning(f"[coding-reconcile] drop ADD on consumed draft: {_op_ref(op)}")
|
||||||
continue
|
continue
|
||||||
d = drafts[op.draft_idx]
|
d = drafts[op.draft_idx]
|
||||||
memory = self._draft_to_new_memory(d)
|
memory = self._draft_to_new_memory(d)
|
||||||
@@ -458,18 +461,18 @@ class CodingMemoryReconciler:
|
|||||||
|
|
||||||
elif op.action == "UPDATE":
|
elif op.action == "UPDATE":
|
||||||
if op.draft_idx is None or not (0 <= op.draft_idx < len(drafts)):
|
if op.draft_idx is None or not (0 <= op.draft_idx < len(drafts)):
|
||||||
logger.warning(f"[coding-reconcile] drop invalid UPDATE: {op.to_dict()}")
|
logger.warning(f"[coding-reconcile] drop invalid UPDATE: {_op_ref(op)}")
|
||||||
continue
|
continue
|
||||||
if not op.target_experience_id:
|
if not op.target_experience_id:
|
||||||
logger.warning(f"[coding-reconcile] drop UPDATE missing target: {op.to_dict()}")
|
logger.warning(f"[coding-reconcile] drop UPDATE missing target: {_op_ref(op)}")
|
||||||
continue
|
continue
|
||||||
if not self._target_was_offered(op, candidates_per_draft):
|
if not self._target_was_offered(op, candidates_per_draft):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[coding-reconcile] drop UPDATE outside authority scope: {op.to_dict()}"
|
f"[coding-reconcile] drop UPDATE outside authority scope: {_op_ref(op)}"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if op.draft_idx in consumed:
|
if op.draft_idx in consumed:
|
||||||
logger.warning(f"[coding-reconcile] drop UPDATE on consumed draft: {op.to_dict()}")
|
logger.warning(f"[coding-reconcile] drop UPDATE on consumed draft: {_op_ref(op)}")
|
||||||
continue
|
continue
|
||||||
d = drafts[op.draft_idx]
|
d = drafts[op.draft_idx]
|
||||||
memory = self._draft_to_new_memory(d, experience_id=op.target_experience_id)
|
memory = self._draft_to_new_memory(d, experience_id=op.target_experience_id)
|
||||||
@@ -488,17 +491,17 @@ class CodingMemoryReconciler:
|
|||||||
|
|
||||||
elif op.action == "DELETE":
|
elif op.action == "DELETE":
|
||||||
if not op.target_experience_id:
|
if not op.target_experience_id:
|
||||||
logger.warning(f"[coding-reconcile] drop DELETE missing target: {op.to_dict()}")
|
logger.warning(f"[coding-reconcile] drop DELETE missing target: {_op_ref(op)}")
|
||||||
continue
|
continue
|
||||||
if not self._target_was_offered(op, candidates_per_draft):
|
if not self._target_was_offered(op, candidates_per_draft):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[coding-reconcile] drop DELETE outside authority scope: {op.to_dict()}"
|
f"[coding-reconcile] drop DELETE outside authority scope: {_op_ref(op)}"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
# DELETE 需要知道 user_id;通过 draft_idx(如有)或第一个 draft 推断
|
# DELETE 需要知道 user_id;通过 draft_idx(如有)或第一个 draft 推断
|
||||||
user_id = self._infer_user_id(op, drafts)
|
user_id = self._infer_user_id(op, drafts)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
logger.warning(f"[coding-reconcile] drop DELETE no user_id context: {op.to_dict()}")
|
logger.warning(f"[coding-reconcile] drop DELETE no user_id context: {_op_ref(op)}")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
ok = await self.store.delete(op.target_experience_id, user_id=user_id)
|
ok = await self.store.delete(op.target_experience_id, user_id=user_id)
|
||||||
@@ -577,6 +580,7 @@ class CodingMemoryReconciler:
|
|||||||
confidence=d.confidence,
|
confidence=d.confidence,
|
||||||
source=d.source,
|
source=d.source,
|
||||||
extraction_source=d.extraction_source,
|
extraction_source=d.extraction_source,
|
||||||
|
memory_at=d.memory_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -584,6 +588,14 @@ class CodingMemoryReconciler:
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|
||||||
|
def _op_ref(op: ReconcileOp) -> str:
|
||||||
|
"""op 的日志引用:只带动作与编号,不带 LLM 写的 reason。"""
|
||||||
|
return (
|
||||||
|
f"action={op.action} draft_idx={op.draft_idx} "
|
||||||
|
f"target={op.target_experience_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _truncate(s: str, n: int) -> str:
|
def _truncate(s: str, n: int) -> str:
|
||||||
if not s:
|
if not s:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS coding_memory_meta (
|
|||||||
confidence REAL NOT NULL DEFAULT 0.7,
|
confidence REAL NOT NULL DEFAULT 0.7,
|
||||||
source TEXT NOT NULL DEFAULT 'default',
|
source TEXT NOT NULL DEFAULT 'default',
|
||||||
extraction_source TEXT NOT NULL DEFAULT 'auto_extract',
|
extraction_source TEXT NOT NULL DEFAULT 'auto_extract',
|
||||||
|
memory_at INTEGER,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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 '[]',
|
derived_experience_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
fact_chain_summary TEXT NOT NULL DEFAULT '',
|
fact_chain_summary TEXT NOT NULL DEFAULT '',
|
||||||
|
memory_at INTEGER,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
-- 反查前驱(谁的 fact_link 指向我)走这个索引,写演变总结时要顺着链往回走
|
-- 反查前驱(谁的 fact_link 指向我)走这个索引,写演变总结时要顺着链往回走
|
||||||
@@ -164,6 +166,8 @@ _ADDED_COLUMNS = {
|
|||||||
"coding_memory_meta": {
|
"coding_memory_meta": {
|
||||||
"source_fact_ids": "TEXT NOT NULL DEFAULT '[]'",
|
"source_fact_ids": "TEXT NOT NULL DEFAULT '[]'",
|
||||||
"extraction_source": "TEXT NOT NULL DEFAULT 'auto_extract'",
|
"extraction_source": "TEXT NOT NULL DEFAULT 'auto_extract'",
|
||||||
|
# 对话时间,秒级 epoch(同 chat)。老库里的行是 NULL:那时没存过。
|
||||||
|
"memory_at": "INTEGER",
|
||||||
},
|
},
|
||||||
# 早期版本存过一列 fact_chain(机械累积的历代说法),已废弃:同样的信息顺着
|
# 早期版本存过一列 fact_chain(机械累积的历代说法),已废弃:同样的信息顺着
|
||||||
# fact_link 反查就有。老库里那一列留着不管,有默认值不影响插入。
|
# fact_link 反查就有。老库里那一列留着不管,有默认值不影响插入。
|
||||||
@@ -177,6 +181,7 @@ _ADDED_COLUMNS = {
|
|||||||
"subject": "TEXT NOT NULL DEFAULT ''",
|
"subject": "TEXT NOT NULL DEFAULT ''",
|
||||||
"details": "TEXT NOT NULL DEFAULT ''",
|
"details": "TEXT NOT NULL DEFAULT ''",
|
||||||
"source": "TEXT NOT NULL DEFAULT 'default'",
|
"source": "TEXT NOT NULL DEFAULT 'default'",
|
||||||
|
"memory_at": "INTEGER",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +295,29 @@ def _parse_dt(s: Optional[str]) -> Optional[datetime]:
|
|||||||
return None
|
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:
|
def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
|
||||||
"""SQLite row → CodingMemory"""
|
"""SQLite row → CodingMemory"""
|
||||||
return CodingMemory(
|
return CodingMemory(
|
||||||
@@ -308,6 +336,7 @@ def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
|
|||||||
confidence=row["confidence"],
|
confidence=row["confidence"],
|
||||||
source=row["source"],
|
source=row["source"],
|
||||||
extraction_source=row["extraction_source"],
|
extraction_source=row["extraction_source"],
|
||||||
|
memory_at=_from_epoch(row["memory_at"]),
|
||||||
created_at=_parse_dt(row["created_at"]),
|
created_at=_parse_dt(row["created_at"]),
|
||||||
updated_at=_parse_dt(row["updated_at"]),
|
updated_at=_parse_dt(row["updated_at"]),
|
||||||
)
|
)
|
||||||
@@ -334,6 +363,7 @@ def _row_to_fact(row: sqlite3.Row) -> WorkingFact:
|
|||||||
# 老库刚 ADD COLUMN 时可能取不到这一列,用 keys() 兜一下
|
# 老库刚 ADD COLUMN 时可能取不到这一列,用 keys() 兜一下
|
||||||
fact_chain_summary=(row["fact_chain_summary"] or ""
|
fact_chain_summary=(row["fact_chain_summary"] or ""
|
||||||
if "fact_chain_summary" in row.keys() else ""),
|
if "fact_chain_summary" in row.keys() else ""),
|
||||||
|
memory_at=_from_epoch(row["memory_at"]),
|
||||||
created_at=_parse_dt(row["created_at"]),
|
created_at=_parse_dt(row["created_at"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -575,7 +605,11 @@ class CodingMemoryStore:
|
|||||||
await self._upsert_vec_keys(memory)
|
await self._upsert_vec_keys(memory)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[coding-store] inserted experience_id={memory.experience_id} "
|
f"[coding-store] inserted experience_id={memory.experience_id} "
|
||||||
f"task={memory.task!r} keys={1 + len(memory.search_keys)}"
|
f"keys={1 + len(memory.search_keys)}"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[coding-store] inserted experience_id={memory.experience_id} "
|
||||||
|
f"task={memory.task!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def update(self, memory: CodingMemory) -> None:
|
async def update(self, memory: CodingMemory) -> None:
|
||||||
@@ -597,6 +631,8 @@ class CodingMemoryStore:
|
|||||||
memory.updated_at = datetime.utcnow()
|
memory.updated_at = datetime.utcnow()
|
||||||
if memory.created_at is None:
|
if memory.created_at is None:
|
||||||
memory.created_at = prev.created_at
|
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)
|
await _run_in_pool(self._upsert_sql_sync, memory)
|
||||||
# 删旧 VDB
|
# 删旧 VDB
|
||||||
await self._delete_vec_keys_by_count(memory.experience_id, prev_key_count)
|
await self._delete_vec_keys_by_count(memory.experience_id, prev_key_count)
|
||||||
@@ -970,7 +1006,8 @@ class CodingMemoryStore:
|
|||||||
fact.created_at = datetime.utcnow()
|
fact.created_at = datetime.utcnow()
|
||||||
await _run_in_pool(self._upsert_fact_sql_sync, fact)
|
await _run_in_pool(self._upsert_fact_sql_sync, fact)
|
||||||
await self._upsert_fact_vec(fact)
|
await self._upsert_fact_vec(fact)
|
||||||
logger.info(
|
logger.info(f"[coding-store] inserted fact_id={fact.fact_id}")
|
||||||
|
logger.debug(
|
||||||
f"[coding-store] inserted fact_id={fact.fact_id} "
|
f"[coding-store] inserted fact_id={fact.fact_id} "
|
||||||
f"statement={fact.statement!r}"
|
f"statement={fact.statement!r}"
|
||||||
)
|
)
|
||||||
@@ -1199,8 +1236,8 @@ class CodingMemoryStore:
|
|||||||
experience_id, user_id, agent_id, task, search_keys, solution,
|
experience_id, user_id, agent_id, task, search_keys, solution,
|
||||||
boundary_envs, workspace_id, branch, session_id,
|
boundary_envs, workspace_id, branch, session_id,
|
||||||
files, source_fact_ids, confidence, source, extraction_source,
|
files, source_fact_ids, confidence, source, extraction_source,
|
||||||
created_at, updated_at
|
memory_at, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(experience_id) DO UPDATE SET
|
ON CONFLICT(experience_id) DO UPDATE SET
|
||||||
user_id = excluded.user_id,
|
user_id = excluded.user_id,
|
||||||
agent_id = excluded.agent_id,
|
agent_id = excluded.agent_id,
|
||||||
@@ -1216,6 +1253,7 @@ class CodingMemoryStore:
|
|||||||
confidence = excluded.confidence,
|
confidence = excluded.confidence,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
extraction_source = excluded.extraction_source,
|
extraction_source = excluded.extraction_source,
|
||||||
|
memory_at = excluded.memory_at,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
@@ -1226,6 +1264,7 @@ class CodingMemoryStore:
|
|||||||
json.dumps(memory.files, ensure_ascii=False),
|
json.dumps(memory.files, ensure_ascii=False),
|
||||||
json.dumps(memory.source_fact_ids, ensure_ascii=False),
|
json.dumps(memory.source_fact_ids, ensure_ascii=False),
|
||||||
memory.confidence, memory.source, memory.extraction_source,
|
memory.confidence, memory.source, memory.extraction_source,
|
||||||
|
_to_epoch(memory.memory_at),
|
||||||
(memory.created_at or datetime.utcnow()).isoformat(),
|
(memory.created_at or datetime.utcnow()).isoformat(),
|
||||||
(memory.updated_at or datetime.utcnow()).isoformat(),
|
(memory.updated_at or datetime.utcnow()).isoformat(),
|
||||||
),
|
),
|
||||||
@@ -1366,8 +1405,8 @@ class CodingMemoryStore:
|
|||||||
fact_id, user_id, agent_id, session_id, workspace_id, branch,
|
fact_id, user_id, agent_id, session_id, workspace_id, branch,
|
||||||
source, statement, files, subject, details,
|
source, statement, files, subject, details,
|
||||||
fact_link, derived_experience_ids,
|
fact_link, derived_experience_ids,
|
||||||
status, fact_chain_summary, created_at
|
status, fact_chain_summary, memory_at, created_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(fact_id) DO UPDATE SET
|
ON CONFLICT(fact_id) DO UPDATE SET
|
||||||
user_id = excluded.user_id,
|
user_id = excluded.user_id,
|
||||||
agent_id = excluded.agent_id,
|
agent_id = excluded.agent_id,
|
||||||
@@ -1382,7 +1421,8 @@ class CodingMemoryStore:
|
|||||||
fact_link = excluded.fact_link,
|
fact_link = excluded.fact_link,
|
||||||
derived_experience_ids = excluded.derived_experience_ids,
|
derived_experience_ids = excluded.derived_experience_ids,
|
||||||
status = excluded.status,
|
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,
|
fact.fact_id, fact.user_id, fact.agent_id,
|
||||||
@@ -1395,6 +1435,7 @@ class CodingMemoryStore:
|
|||||||
json.dumps(fact.derived_experience_ids, ensure_ascii=False),
|
json.dumps(fact.derived_experience_ids, ensure_ascii=False),
|
||||||
fact.status,
|
fact.status,
|
||||||
fact.fact_chain_summary or "",
|
fact.fact_chain_summary or "",
|
||||||
|
_to_epoch(fact.memory_at),
|
||||||
(fact.created_at or datetime.utcnow()).isoformat(),
|
(fact.created_at or datetime.utcnow()).isoformat(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ FactStatus = Literal["active", "superseded"]
|
|||||||
MAX_FACT_CHAIN_ENTRIES = 8
|
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 阶段输出)
|
# 草稿(LLM extract 阶段输出)
|
||||||
# ================================================================
|
# ================================================================
|
||||||
@@ -53,6 +58,8 @@ class CodingMemoryDraft:
|
|||||||
files: List[str] = field(default_factory=list)
|
files: List[str] = field(default_factory=list)
|
||||||
source: str = "default"
|
source: str = "default"
|
||||||
extraction_source: str = "auto_extract"
|
extraction_source: str = "auto_extract"
|
||||||
|
# 对话时间(SDK 注入,同 chat 的 memory_at)
|
||||||
|
memory_at: Optional[datetime] = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
from ..utils.source_filter import normalize_source
|
from ..utils.source_filter import normalize_source
|
||||||
@@ -73,6 +80,7 @@ class CodingMemoryDraft:
|
|||||||
"files": list(self.files),
|
"files": list(self.files),
|
||||||
"source": self.source,
|
"source": self.source,
|
||||||
"extraction_source": self.extraction_source,
|
"extraction_source": self.extraction_source,
|
||||||
|
"memory_at": _epoch(self.memory_at),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +119,9 @@ class CodingMemory:
|
|||||||
extraction_source: str = "auto_extract"
|
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
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
@@ -135,6 +146,7 @@ class CodingMemory:
|
|||||||
"confidence": self.confidence,
|
"confidence": self.confidence,
|
||||||
"source": self.source,
|
"source": self.source,
|
||||||
"extraction_source": self.extraction_source,
|
"extraction_source": self.extraction_source,
|
||||||
|
"memory_at": _epoch(self.memory_at),
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_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 = ""
|
fact_chain_summary: str = ""
|
||||||
|
|
||||||
# 时间
|
# 时间
|
||||||
|
memory_at: Optional[datetime] = None # 对话时间(同 chat),没传时是收到请求的时间
|
||||||
created_at: Optional[datetime] = None # 产生时间,新的优先
|
created_at: Optional[datetime] = None # 产生时间,新的优先
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
@@ -272,6 +285,7 @@ class WorkingFact:
|
|||||||
"derived_experience_ids": list(self.derived_experience_ids),
|
"derived_experience_ids": list(self.derived_experience_ids),
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"fact_chain_summary": self.fact_chain_summary,
|
"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,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ CodingWriter - 写入端协调器
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from ..agent.llm_provider import LLMProvider
|
from ..agent.llm_provider import LLMProvider
|
||||||
@@ -79,9 +80,11 @@ class CodingWriter:
|
|||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
request_id: Optional[str] = None,
|
request_id: Optional[str] = None,
|
||||||
source: str = "default",
|
source: str = "default",
|
||||||
|
memory_at: Optional[datetime] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
working/coding 段写入端到端。返回响应 dict(兼容 client.add 返回结构):
|
working/coding 段写入端到端。memory_at 是对话时间,记到这次写出的经验和
|
||||||
|
事实上;不传则用收到请求的时间(与 chat 链路一致)。返回响应 dict(兼容 client.add 返回结构):
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"scene": "productivity",
|
"scene": "productivity",
|
||||||
@@ -101,6 +104,8 @@ class CodingWriter:
|
|||||||
"""
|
"""
|
||||||
request_id = request_id or str(uuid.uuid4())
|
request_id = request_id or str(uuid.uuid4())
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
|
if memory_at is None:
|
||||||
|
memory_at = datetime.now()
|
||||||
|
|
||||||
if not messages:
|
if not messages:
|
||||||
return self._empty_response(request_id, t0)
|
return self._empty_response(request_id, t0)
|
||||||
@@ -172,6 +177,7 @@ class CodingWriter:
|
|||||||
# extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。
|
# extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。
|
||||||
for draft in drafts:
|
for draft in drafts:
|
||||||
draft.source = source
|
draft.source = source
|
||||||
|
draft.memory_at = memory_at
|
||||||
|
|
||||||
# 3. 经验层 reconcile + 持久化
|
# 3. 经验层 reconcile + 持久化
|
||||||
ops: List[ReconcileOp] = (
|
ops: List[ReconcileOp] = (
|
||||||
@@ -197,6 +203,7 @@ class CodingWriter:
|
|||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
branch=branch,
|
branch=branch,
|
||||||
source=source,
|
source=source,
|
||||||
|
memory_at=memory_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
experience_ids = [op.target_experience_id for op in ops
|
experience_ids = [op.target_experience_id for op in ops
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def _count_tokens(text: str) -> int:
|
|||||||
try:
|
try:
|
||||||
import tiktoken
|
import tiktoken
|
||||||
|
|
||||||
return len(tiktoken.get_encoding("cl100k_base").encode(text))
|
return len(tiktoken.get_encoding("cl100k_base").encode(text, disallowed_special=()))
|
||||||
except Exception:
|
except Exception:
|
||||||
return len(text) // _CHARS_PER_TOKEN_FALLBACK + 1
|
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
|
import tiktoken
|
||||||
|
|
||||||
enc = tiktoken.get_encoding("cl100k_base")
|
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:
|
if len(ids) <= limit:
|
||||||
return text
|
return text
|
||||||
dropped = len(ids) - limit
|
dropped = len(ids) - limit
|
||||||
@@ -753,16 +754,22 @@ class EmbedService:
|
|||||||
"""
|
"""
|
||||||
打一条 INFO 级 embedding 请求日志(含 request_id / provider / model / api / 入参 / 出参)。
|
打一条 INFO 级 embedding 请求日志(含 request_id / provider / model / api / 入参 / 出参)。
|
||||||
|
|
||||||
出参只含 vector 维度,不含向量数值。request_id 同时写入 message,
|
出参只含 vector 维度,不含向量数值。入参的 input 是待向量化的原文
|
||||||
|
(对话 / 记忆 / 查询),INFO 只记每条的字符数(input_chars),原文仅在
|
||||||
|
DEBUG 单独输出。request_id 同时写入 message,
|
||||||
并依赖 log formatter 的 %(request_id)s(setup_logging 后生效)。
|
并依赖 log formatter 的 %(request_id)s(setup_logging 后生效)。
|
||||||
"""
|
"""
|
||||||
|
texts = request_payload.get("input")
|
||||||
|
info_request = {k: v for k, v in request_payload.items() if k != "input"}
|
||||||
|
if isinstance(texts, list):
|
||||||
|
info_request["input_chars"] = [len(str(t)) for t in texts]
|
||||||
payload = {
|
payload = {
|
||||||
"request_id": get_request_id(),
|
"request_id": get_request_id(),
|
||||||
"status": status,
|
"status": status,
|
||||||
"provider": self._embed_config.provider,
|
"provider": self._embed_config.provider,
|
||||||
"model": self._embed_config.model,
|
"model": self._embed_config.model,
|
||||||
"api": api_url,
|
"api": api_url,
|
||||||
"request": request_payload,
|
"request": info_request,
|
||||||
"response": response_payload,
|
"response": response_payload,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
@@ -770,6 +777,10 @@ class EmbedService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
body = str(payload)
|
body = str(payload)
|
||||||
logger.info(f"[embed] {body}")
|
logger.info(f"[embed] {body}")
|
||||||
|
if texts is not None and logger.isEnabledFor(logging.DEBUG):
|
||||||
|
logger.debug(
|
||||||
|
f"[embed] input: {json.dumps(texts, ensure_ascii=False, default=str)}"
|
||||||
|
)
|
||||||
|
|
||||||
async def _embed_openai(
|
async def _embed_openai(
|
||||||
self, texts: List[str]
|
self, texts: List[str]
|
||||||
|
|||||||
@@ -115,11 +115,16 @@ class ReconcileHybridRetriever:
|
|||||||
fused_max = max(fused_all) if fused_all else 0.0
|
fused_max = max(fused_all) if fused_all else 0.0
|
||||||
|
|
||||||
base = (
|
base = (
|
||||||
f"[reconcile-hybrid][{req}] pool={pool_size} q_terms={q_terms!r} "
|
f"[reconcile-hybrid][{req}] pool={pool_size} q_terms={len(q_terms)} "
|
||||||
f"raw_bm25 max={raw_max:.4f} nonzero={raw_nonzero}/{len(raw_bm25)} "
|
f"raw_bm25 max={raw_max:.4f} nonzero={raw_nonzero}/{len(raw_bm25)} "
|
||||||
f"bm25_norm max={norm_max:.4f} nonzero={norm_nonzero}/{len(bm25_norms)} "
|
f"bm25_norm max={norm_max:.4f} nonzero={norm_nonzero}/{len(bm25_norms)} "
|
||||||
f"sem_max={sem_max:.4f} fused_max={fused_max:.4f} "
|
f"sem_max={sem_max:.4f} fused_max={fused_max:.4f} "
|
||||||
f"kept={kept}/{len(fused_all)} min_score={self._cfg.min_score} "
|
f"kept={kept}/{len(fused_all)} min_score={self._cfg.min_score} "
|
||||||
|
f"query({len(query_text or '')} chars)"
|
||||||
|
)
|
||||||
|
# query 是待对账的新记忆原文,词项与预览只进 DEBUG
|
||||||
|
logger.debug(
|
||||||
|
f"[reconcile-hybrid][{req}] q_terms={q_terms!r} "
|
||||||
f"query={q_preview!r} lemmatized={lem_preview!r}"
|
f"query={q_preview!r} lemmatized={lem_preview!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -692,9 +692,10 @@ class CrossDomainSweeper:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[sweeper] Created L6 core {core_id[:12]}: '{core_content[:60]}' "
|
f"[sweeper] Created L6 core {core_id[:12]} "
|
||||||
f"from {len(cluster_ids)} basics, evidence_inherited={len(inherited_evidence_ids)}"
|
f"from {len(cluster_ids)} basics, evidence_inherited={len(inherited_evidence_ids)}"
|
||||||
)
|
)
|
||||||
|
logger.debug(f"[sweeper] L6 core {core_id[:12]} content={core_content[:60]!r}")
|
||||||
return 1, 0, [core_id]
|
return 1, 0, [core_id]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -3587,8 +3587,9 @@ class BaseReadPipeline(ReadPipeline):
|
|||||||
f"hidden_l0={len(hidden_l0_hits)} fwd_l6={len(forward_l6)} rev_l6={len(reverse_l6)} "
|
f"hidden_l0={len(hidden_l0_hits)} fwd_l6={len(forward_l6)} rev_l6={len(reverse_l6)} "
|
||||||
f"fused_l6={len(fused_l6)} graph_intent={len(graph_intention_hits)} "
|
f"fused_l6={len(fused_l6)} graph_intent={len(graph_intention_hits)} "
|
||||||
f"returned={len(final_results)} "
|
f"returned={len(final_results)} "
|
||||||
f"query='{request.query[:80]}'"
|
f"query({len(request.query or '')} chars)"
|
||||||
)
|
)
|
||||||
|
logger.debug(f"[read/{self.VERSION}] query={request.query[:80]!r}")
|
||||||
|
|
||||||
response.success = True
|
response.success = True
|
||||||
|
|
||||||
|
|||||||
@@ -2516,7 +2516,8 @@ def _parse_operations_json(text: str) -> Optional[List[Dict[str, Any]]]:
|
|||||||
return result
|
return result
|
||||||
return None
|
return None
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.warning(f"[S2-agent] JSON parse failed: {json_str[:200]}")
|
logger.warning(f"[S2-agent] JSON parse failed ({len(json_str)} chars)")
|
||||||
|
logger.debug(f"[S2-agent] unparsable output: {json_str[:200]!r}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
import contextvars
|
||||||
import functools
|
import functools
|
||||||
from concurrent.futures import Executor
|
from concurrent.futures import Executor
|
||||||
from typing import Any, Callable, Optional
|
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:
|
async def to_thread_guarded(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||||
"""Guarded equivalent of ``asyncio.to_thread`` using the default pool."""
|
"""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(
|
def concurrent_result_guarded(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Pipeline Log Writer — 环节级 JSONL 日志(始终开启,与 SQLite Trace 分离)。
|
Pipeline Log Writer — 环节级 JSONL 日志(默认开启,MEMORY_PIPELINE_LOG_ENABLED=false 关闭;
|
||||||
|
与 SQLite Trace 分离)。
|
||||||
|
|
||||||
目录: {log_dir}/{subdir}/{date}.log
|
目录: {log_dir}/{subdir}/{date}.log
|
||||||
每行: request_id, namespace, user_id, agent_id, step, prompt, content, parsed,
|
每行: request_id, namespace, user_id, agent_id, step, prompt, content, parsed,
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Pipeline 可观测性:Log(文件 JSONL,始终开启)与 Trace(SQLite pipeline_logs,可关闭)。
|
Pipeline 可观测性:Log(文件 JSONL,默认开启,可关闭)与 Trace(SQLite pipeline_logs,可关闭)。
|
||||||
|
|
||||||
- Log:运维 tail/grep,每个环节即时一行,不依赖后续步骤成功与否。
|
- Log:运维 tail/grep,每个环节即时一行,不依赖后续步骤成功与否。
|
||||||
- Trace:Inspector / 调试,落库 pipeline_logs(需 MEMORY_CACHE_BACKEND=sqlite 等持久化后端)。
|
- Trace:Inspector / 调试,落库 pipeline_logs(需 MEMORY_CACHE_BACKEND=sqlite 等持久化后端)。
|
||||||
|
|
||||||
环境变量:
|
环境变量:
|
||||||
|
MEMORY_PIPELINE_LOG_ENABLED — Log 写 JSONL 文件,默认 true;false 时完全不写
|
||||||
MEMORY_PIPELINE_TRACE_ENABLED — Trace 落库,默认 true;OpenClaw C 端可设 false
|
MEMORY_PIPELINE_TRACE_ENABLED — Trace 落库,默认 true;OpenClaw C 端可设 false
|
||||||
MEMORY_MEMORY_OPERATIONS_ENABLED — memory_operations 审计表落库,默认 false
|
MEMORY_MEMORY_OPERATIONS_ENABLED — memory_operations 审计表落库,默认 false
|
||||||
MEMORY_LOG_DIR — Log 目录(优先)
|
MEMORY_LOG_DIR — Log 目录(优先)
|
||||||
MEMORY_TRACE_LOG_DIR — Log 目录(兼容旧名)
|
MEMORY_TRACE_LOG_DIR — Log 目录(兼容旧名)
|
||||||
|
|
||||||
|
Log 与 Trace 都关闭时不包装 store_pipeline_log,而是换成立即返回 True 的
|
||||||
|
空操作:热路径不做任何计时、脱敏或后台任务,也不写库。
|
||||||
|
|
||||||
子目录({log_dir}/{subdir}/{date}.log)由 PipelineLogWriter 从每条记录的
|
子目录({log_dir}/{subdir}/{date}.log)由 PipelineLogWriter 从每条记录的
|
||||||
user_id 前缀(第一个 "__" 左侧)切分;无前缀回落 "default"。该前缀是
|
user_id 前缀(第一个 "__" 左侧)切分;无前缀回落 "default"。该前缀是
|
||||||
通用 namespace,可用于项目、部署或数据分桶。
|
通用 namespace,可用于项目、部署或数据分桶。
|
||||||
@@ -94,6 +98,16 @@ def is_pipeline_trace_enabled() -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_pipeline_log_enabled() -> bool:
|
||||||
|
"""是否把 pipeline step 写成 JSONL 文件({log_dir}/<subdir>/<date>.log)。默认开启。"""
|
||||||
|
return os.getenv("MEMORY_PIPELINE_LOG_ENABLED", "true").strip().lower() in (
|
||||||
|
"true",
|
||||||
|
"1",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_memory_operations_enabled() -> bool:
|
def is_memory_operations_enabled() -> bool:
|
||||||
"""是否将知识库变动写入 memory_operations 审计表。默认关闭(false)。"""
|
"""是否将知识库变动写入 memory_operations 审计表。默认关闭(false)。"""
|
||||||
return os.getenv("MEMORY_MEMORY_OPERATIONS_ENABLED", "false").lower() in (
|
return os.getenv("MEMORY_MEMORY_OPERATIONS_ENABLED", "false").lower() in (
|
||||||
@@ -125,7 +139,7 @@ def redact_content(value: str) -> str:
|
|||||||
|
|
||||||
def resolve_pipeline_log_dir() -> str:
|
def resolve_pipeline_log_dir() -> str:
|
||||||
"""
|
"""
|
||||||
JSONL pipeline log 根目录(始终使用,有默认值)。
|
JSONL pipeline log 根目录(MEMORY_PIPELINE_LOG_ENABLED 开启时使用,有默认值)。
|
||||||
|
|
||||||
优先级: MEMORY_LOG_DIR > MEMORY_TRACE_LOG_DIR > {MEMORY_DATA_DIR}/logs/pipeline
|
优先级: MEMORY_LOG_DIR > MEMORY_TRACE_LOG_DIR > {MEMORY_DATA_DIR}/logs/pipeline
|
||||||
"""
|
"""
|
||||||
@@ -228,6 +242,7 @@ def wrap_store_pipeline_log(
|
|||||||
) -> Callable[..., Any]:
|
) -> Callable[..., Any]:
|
||||||
"""包装 cache.store_pipeline_log:计时同步、JSONL/DB 异步落盘,不堵主请求。
|
"""包装 cache.store_pipeline_log:计时同步、JSONL/DB 异步落盘,不堵主请求。
|
||||||
|
|
||||||
|
- log_writer 为 None 时不写 JSONL(MEMORY_PIPELINE_LOG_ENABLED=false)
|
||||||
- step-to-step elapsed_ms / event_at_ms 在调用瞬间打点(与业务墙钟对齐)
|
- step-to-step elapsed_ms / event_at_ms 在调用瞬间打点(与业务墙钟对齐)
|
||||||
- event_at_ms:调用方传入非 0 值则直接复用(App 已在自己被调用的那一刻
|
- event_at_ms:调用方传入非 0 值则直接复用(App 已在自己被调用的那一刻
|
||||||
同步打点,比这里更接近真实事件时刻);传 0 / 不传才用本地 time.time()
|
同步打点,比这里更接近真实事件时刻);传 0 / 不传才用本地 time.time()
|
||||||
@@ -410,6 +425,7 @@ def wrap_store_pipeline_log(
|
|||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
# JSONL 用线程池,与 loop 无亲和,留在当前 loop。
|
# JSONL 用线程池,与 loop 无亲和,留在当前 loop。
|
||||||
|
if log_writer is not None:
|
||||||
_track_task(loop.create_task(_persist_jsonl()))
|
_track_task(loop.create_task(_persist_jsonl()))
|
||||||
# DB trace 用 cache pool(可能 aiomysql,绑定 owner_loop)。若当前不在
|
# DB trace 用 cache pool(可能 aiomysql,绑定 owner_loop)。若当前不在
|
||||||
# owner_loop(如 search 走独立 loop),派发回 owner_loop 执行,避免跨 loop。
|
# owner_loop(如 search 走独立 loop),派发回 owner_loop 执行,避免跨 loop。
|
||||||
@@ -446,6 +462,7 @@ def wrap_store_pipeline_log(
|
|||||||
)
|
)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# 无 running loop(极少见):退化为同步写,保证不丢日志
|
# 无 running loop(极少见):退化为同步写,保证不丢日志
|
||||||
|
if log_writer is not None:
|
||||||
try:
|
try:
|
||||||
log_writer.write_step(
|
log_writer.write_step(
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
@@ -496,6 +513,11 @@ def wrap_store_pipeline_log(
|
|||||||
return _store_pipeline_observability
|
return _store_pipeline_observability
|
||||||
|
|
||||||
|
|
||||||
|
async def _noop_store_pipeline_log(*args: Any, **kwargs: Any) -> bool:
|
||||||
|
"""Log 与 Trace 都关闭时的 store_pipeline_log:不计时、不写文件、不写库。"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def install_pipeline_observability(
|
def install_pipeline_observability(
|
||||||
cache: Any,
|
cache: Any,
|
||||||
*,
|
*,
|
||||||
@@ -503,6 +525,9 @@ def install_pipeline_observability(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""给 cache 安装 JSONL + Trace 包装(fire-and-forget)。
|
"""给 cache 安装 JSONL + Trace 包装(fire-and-forget)。
|
||||||
|
|
||||||
|
MEMORY_PIPELINE_LOG_ENABLED=false 时不写 JSONL;若 Trace 也关闭,
|
||||||
|
store_pipeline_log 被换成空操作(不包装、不写库)。
|
||||||
|
|
||||||
在 cache 所属 loop(write loop / _LoopThread)上调用,本函数会捕获该 loop
|
在 cache 所属 loop(write loop / _LoopThread)上调用,本函数会捕获该 loop
|
||||||
作为 owner_loop 传给包装器:search 走独立 loop 后,从 search loop 触发的 DB
|
作为 owner_loop 传给包装器:search 走独立 loop 后,从 search loop 触发的 DB
|
||||||
落库会被派发回这个 owner_loop,避免跨 loop 用 aiomysql 污染连接池。
|
落库会被派发回这个 owner_loop,避免跨 loop 用 aiomysql 污染连接池。
|
||||||
@@ -514,15 +539,31 @@ def install_pipeline_observability(
|
|||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
owner_loop = None
|
owner_loop = None
|
||||||
|
|
||||||
|
log_enabled = is_pipeline_log_enabled()
|
||||||
|
trace_enabled = is_pipeline_trace_enabled()
|
||||||
|
if not log_enabled and not trace_enabled:
|
||||||
|
logger.info(
|
||||||
|
f"[{log_label}] Pipeline log (JSONL) disabled ; pipeline trace (DB)=off ; "
|
||||||
|
f"store_pipeline_log=no-op"
|
||||||
|
)
|
||||||
|
cache.store_pipeline_log = _noop_store_pipeline_log
|
||||||
|
return
|
||||||
|
|
||||||
|
log_writer = None
|
||||||
|
if log_enabled:
|
||||||
log_dir = resolve_pipeline_log_dir()
|
log_dir = resolve_pipeline_log_dir()
|
||||||
log_writer = PipelineLogWriter(log_dir)
|
log_writer = PipelineLogWriter(log_dir)
|
||||||
trace_enabled = is_pipeline_trace_enabled()
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[{log_label}] Pipeline log (JSONL) enabled: {log_dir}/<subdir>/<date>.log "
|
f"[{log_label}] Pipeline log (JSONL) enabled: {log_dir}/<subdir>/<date>.log "
|
||||||
f"(subdir from user_id prefix before '__', else default) ; "
|
f"(subdir from user_id prefix before '__', else default) ; "
|
||||||
f"pipeline trace (DB)={'on' if trace_enabled else 'off'} ; "
|
f"pipeline trace (DB)={'on' if trace_enabled else 'off'} ; "
|
||||||
f"persist=async(fire-and-forget)"
|
f"persist=async(fire-and-forget)"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"[{log_label}] Pipeline log (JSONL) disabled ; "
|
||||||
|
f"pipeline trace (DB)=on ; persist=async(fire-and-forget)"
|
||||||
|
)
|
||||||
cache.store_pipeline_log = wrap_store_pipeline_log(
|
cache.store_pipeline_log = wrap_store_pipeline_log(
|
||||||
cache.store_pipeline_log,
|
cache.store_pipeline_log,
|
||||||
log_writer=log_writer,
|
log_writer=log_writer,
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ def count_tokens(text: str) -> int:
|
|||||||
enc = _get_encoder()
|
enc = _get_encoder()
|
||||||
if enc:
|
if enc:
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
logger.debug(f"[token_count] encode failed, char fallback: {e}")
|
logger.debug(f"[token_count] encode failed, char fallback: {e}")
|
||||||
return max(1, len(text) // 4)
|
return max(1, len(text) // 4)
|
||||||
|
|||||||
+6
-4
@@ -29,10 +29,12 @@ prompt/response、密钥、访问令牌或完整本地路径。结构化日志
|
|||||||
文件。详细合同见
|
文件。详细合同见
|
||||||
[`docs/contracts/diagnostic-logging.md`](docs/contracts/diagnostic-logging.md)。
|
[`docs/contracts/diagnostic-logging.md`](docs/contracts/diagnostic-logging.md)。
|
||||||
|
|
||||||
用户显式把诊断级别切换为 `debug` 时,Core 会启用 HY pipeline trace,并将
|
HY pipeline 日志与 trace 在任何级别下都关闭。HY SDK 的文本日志只写入
|
||||||
`MEMORY_LOG_LLM_CONTENT` 设为 `true`。该模式会把完整模型 prompt/response 写入本地调试
|
`hy-memory/governed-v2/logs/hy_memory.log`,其中的 WARNING 及以上同时写入 `core.log`;
|
||||||
日志,其中可能包含获授权的对话正文和模型输出;它只应用于有意识的故障排查,不应长期
|
SDK 不在 INFO 及以上级别记录对话或记忆正文。用户显式把诊断级别切换为 `debug` 时,Core 将
|
||||||
开启,也不应在未经检查和脱敏时分享相关日志。切回 `info` 会停止后续正文记录,但不会
|
`MEMORY_LOG_LLM_CONTENT` 设为 `true`。该模式会把完整模型 prompt/response 写入
|
||||||
|
`hy_memory.log` 的 DEBUG 行,其中可能包含获授权的对话正文和模型输出;它只应用于有意识的
|
||||||
|
故障排查,不应长期开启,也不应在未经检查和脱敏时分享相关日志。切回 `info` 会停止后续正文记录,但不会
|
||||||
自动删除此前已写入的调试文件。
|
自动删除此前已写入的调试文件。
|
||||||
|
|
||||||
具体行为以当前源码、网络请求实现和发布说明为准。发现实现与本说明不一致时,请按
|
具体行为以当前源码、网络请求实现和发布说明为准。发现实现与本说明不一致时,请按
|
||||||
|
|||||||
+2
-2
@@ -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.8`。MemHub 源码版本为 `1.0.5`
|
||||||
(build 14),GitHub 仓库为
|
(build 14),GitHub 仓库为
|
||||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||||
`memhub-cli`。当前代码架构见
|
`memhub-cli`。当前代码架构见
|
||||||
@@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
|
|||||||
|
|
||||||
## Hy Memory
|
## Hy Memory
|
||||||
|
|
||||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.6`。SDK 配置、权限边界及 search
|
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.8`。SDK 配置、权限边界及 search
|
||||||
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
||||||
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
||||||
|
|
||||||
|
|||||||
@@ -2352,7 +2352,7 @@
|
|||||||
{
|
{
|
||||||
"SPDXID": "SPDXRef-Package-hy-memory",
|
"SPDXID": "SPDXRef-Package-hy-memory",
|
||||||
"name": "hy-memory",
|
"name": "hy-memory",
|
||||||
"versionInfo": "2.0.6",
|
"versionInfo": "2.0.8",
|
||||||
"downloadLocation": "NOASSERTION",
|
"downloadLocation": "NOASSERTION",
|
||||||
"filesAnalyzed": false,
|
"filesAnalyzed": false,
|
||||||
"licenseConcluded": "Apache-2.0",
|
"licenseConcluded": "Apache-2.0",
|
||||||
|
|||||||
@@ -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
|
column is filled in as part of release review, and the SBOM records
|
||||||
`NOASSERTION` until then rather than guessing.
|
`NOASSERTION` until then rather than guessing.
|
||||||
|
|
||||||
## hy-memory 2.0.6
|
## hy-memory 2.0.8
|
||||||
|
|
||||||
License: Apache-2.0
|
License: Apache-2.0
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ dependencies = [
|
|||||||
"fastapi>=0.141.1",
|
"fastapi>=0.141.1",
|
||||||
"filelock>=3.32.4",
|
"filelock>=3.32.4",
|
||||||
# Public SDK version validated against MemHub's permission and recall contracts.
|
# Public SDK version validated against MemHub's permission and recall contracts.
|
||||||
"hy-memory==2.0.6",
|
"hy-memory==2.0.8",
|
||||||
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
|
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
|
||||||
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
|
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
|
||||||
"chromadb==1.5.9",
|
"chromadb==1.5.9",
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ from one_memory.background_work import shutdown_bulk_pool, warm_bulk_pool
|
|||||||
from one_memory.config import Settings, load_settings
|
from one_memory.config import Settings, load_settings
|
||||||
from one_memory.context_routes import sdk_context_router
|
from one_memory.context_routes import sdk_context_router
|
||||||
from one_memory.conversation_processing import (
|
from one_memory.conversation_processing import (
|
||||||
_conversation_chunks,
|
|
||||||
memory_task_timeout_seconds,
|
memory_task_timeout_seconds,
|
||||||
|
turn_batches,
|
||||||
)
|
)
|
||||||
from one_memory.database_access import (
|
from one_memory.database_access import (
|
||||||
MAIN_DATABASE_REPORT_SECONDS,
|
MAIN_DATABASE_REPORT_SECONDS,
|
||||||
@@ -95,6 +95,8 @@ from one_memory.hy_sdk_runtime import (
|
|||||||
SdkClientHandle,
|
SdkClientHandle,
|
||||||
builtin_retrieval_snapshot,
|
builtin_retrieval_snapshot,
|
||||||
close_all_shared_runtimes,
|
close_all_shared_runtimes,
|
||||||
|
hold_stores_offline,
|
||||||
|
release_stores,
|
||||||
sdk_relevance_floor,
|
sdk_relevance_floor,
|
||||||
)
|
)
|
||||||
from one_memory.hy_sdk_types import (
|
from one_memory.hy_sdk_types import (
|
||||||
@@ -223,6 +225,7 @@ from one_memory.sdk_memory_qa import (
|
|||||||
SdkMemoryAnswerer,
|
SdkMemoryAnswerer,
|
||||||
)
|
)
|
||||||
from one_memory.sdk_memory_runtime import SdkMemoryRuntime
|
from one_memory.sdk_memory_runtime import SdkMemoryRuntime
|
||||||
|
from one_memory.sdk_migration_log import error_chain_text, error_frames
|
||||||
from one_memory.sdk_write_processor import SdkWriteCoordinator
|
from one_memory.sdk_write_processor import SdkWriteCoordinator
|
||||||
from one_memory.secrets import (
|
from one_memory.secrets import (
|
||||||
KeyringSecretStore,
|
KeyringSecretStore,
|
||||||
@@ -710,7 +713,8 @@ def create_app(
|
|||||||
raise SdkMigrationRequired()
|
raise SdkMigrationRequired()
|
||||||
if app.state.memory_authority is MemoryAuthority.SDK:
|
if app.state.memory_authority is MemoryAuthority.SDK:
|
||||||
# The metadata repair after a cutover reads every bundle: it is
|
# The metadata repair after a cutover reads every bundle: it is
|
||||||
# recovery work, so it runs after readiness (`recover_after_ready`).
|
# recovery work, so it runs after readiness (`recover_after_ready`)
|
||||||
|
# while the SDK's stores are held offline (see there).
|
||||||
app.state.memory_governance_store = RetiredGovernanceStore(database_path)
|
app.state.memory_governance_store = RetiredGovernanceStore(database_path)
|
||||||
app.state.evidence_store = RetiredEvidenceStore(database_path)
|
app.state.evidence_store = RetiredEvidenceStore(database_path)
|
||||||
else:
|
else:
|
||||||
@@ -1026,28 +1030,25 @@ def create_app(
|
|||||||
|
|
||||||
app.state.memory_write_intake = UnifiedWriteIntake(
|
app.state.memory_write_intake = UnifiedWriteIntake(
|
||||||
memory_write_queue,
|
memory_write_queue,
|
||||||
source_writes_permitted=lambda source_id: (
|
source_writes_permitted=lambda source_id: _source_writes_on(app, source_id),
|
||||||
source_id == "one-memory-manual" or _source_writes_on(app, source_id)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
app.state.start_memory_backfill = lambda: _start_backfill(app)
|
app.state.start_memory_backfill = lambda: _start_backfill(app)
|
||||||
|
|
||||||
async def enqueue_stable_evidence(record: EvidenceRecord) -> None:
|
async def enqueue_stable_evidence(record: EvidenceRecord) -> None:
|
||||||
priority = "live" if live_sync_arrival.get() else "normal"
|
live = live_sync_arrival.get()
|
||||||
conversation = await asyncio.to_thread(
|
conversation = await asyncio.to_thread(
|
||||||
app.state.evidence_store.list_conversation, record.evidence_id
|
app.state.evidence_store.list_conversation, record.evidence_id
|
||||||
)
|
)
|
||||||
for chunk in _conversation_chunks(conversation, max_turns=12, completed_only=True):
|
# Legacy authority. The turn rule groups the whole conversation
|
||||||
if (
|
# from its start, so a batch's records and key never change:
|
||||||
record not in chunk
|
# history (end of file) writes it all, live writes complete
|
||||||
or not any(item.role == "user" for item in chunk)
|
# batches and leaves the rest to catch-up (`_run_backfill`).
|
||||||
or not any(item.role == "assistant" for item in chunk)
|
batches = turn_batches(conversation, flush=not live).batches
|
||||||
):
|
for batch in _batches_settled_by(batches, record):
|
||||||
continue
|
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
app.state.memory_write_intake.submit_records,
|
app.state.memory_write_intake.submit_records,
|
||||||
chunk,
|
batch,
|
||||||
priority=priority,
|
priority="live" if live else "normal",
|
||||||
)
|
)
|
||||||
|
|
||||||
app.state.memory_addition_store = MemoryAdditionStore(
|
app.state.memory_addition_store = MemoryAdditionStore(
|
||||||
@@ -1189,10 +1190,14 @@ def create_app(
|
|||||||
except Exception as error:
|
except Exception as error:
|
||||||
failed.append(name)
|
failed.append(name)
|
||||||
app.state.recovery_failed_steps = tuple(failed)
|
app.state.recovery_failed_steps = tuple(failed)
|
||||||
|
# Where it failed, as `file:line:function` frames of
|
||||||
|
# the exception chain, never its message: a bare
|
||||||
|
# `error_type=KeyError` named no location.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"core_recovery_step_failed step=%s error_type=%s",
|
"core_recovery_step_failed step=%s error_type=%s chain=%s",
|
||||||
name,
|
name,
|
||||||
type(error).__name__,
|
type(error).__name__,
|
||||||
|
error_chain_text(error),
|
||||||
)
|
)
|
||||||
diagnostic_event(
|
diagnostic_event(
|
||||||
level="warn",
|
level="warn",
|
||||||
@@ -1200,20 +1205,42 @@ def create_app(
|
|||||||
event="core.recovery_step_failed",
|
event="core.recovery_step_failed",
|
||||||
outcome="failed",
|
outcome="failed",
|
||||||
error_type=type(error).__name__,
|
error_type=type(error).__name__,
|
||||||
|
safe_stack=error_frames(error) or None,
|
||||||
duration_ms=elapsed_ms(step_started),
|
duration_ms=elapsed_ms(step_started),
|
||||||
details={"phase": name},
|
details={"phase": name},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def repair_sdk_metadata() -> None:
|
async def repair_sdk_metadata() -> None:
|
||||||
|
# `sdk-migrate` repairs right after its cutover and writes
|
||||||
|
# the receipt, so this normally finds it and returns. It
|
||||||
|
# stays for homes whose repair failed before that (no
|
||||||
|
# receipt). It opens its own Chroma client on the SDK's
|
||||||
|
# vector store, and Chroma's shared System is not safe
|
||||||
|
# beside another client: a first library request that
|
||||||
|
# opened the SDK client meanwhile lost it (KeyError).
|
||||||
|
# The stores were held offline before readiness, so any
|
||||||
|
# SDK client waits for this step.
|
||||||
from one_memory.hy_sdk_migration import (
|
from one_memory.hy_sdk_migration import (
|
||||||
repair_sdk_metadata_from_bundle,
|
repair_sdk_metadata_from_bundle,
|
||||||
)
|
)
|
||||||
|
|
||||||
repair = await asyncio.to_thread(
|
if not app.state.sdk_stores_held:
|
||||||
|
logger.warning("sdk_metadata_repair_deferred reason=sdk_client_open")
|
||||||
|
return
|
||||||
|
# The hold is this step's from here; shutdown leaves it.
|
||||||
|
app.state.sdk_stores_held = False
|
||||||
|
work = asyncio.ensure_future(
|
||||||
|
asyncio.to_thread(
|
||||||
repair_sdk_metadata_from_bundle,
|
repair_sdk_metadata_from_bundle,
|
||||||
data_dir=runtime_settings.data_dir,
|
data_dir=runtime_settings.data_dir,
|
||||||
database_path=database_path,
|
database_path=database_path,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
# Released when the thread ends, not when this coroutine
|
||||||
|
# is cancelled: a cancelled await leaves the thread
|
||||||
|
# writing Chroma.
|
||||||
|
work.add_done_callback(lambda _work: release_stores(runtime_settings.data_dir))
|
||||||
|
repair = await asyncio.shield(work)
|
||||||
if repair.vector_rows or repair.coding_rows:
|
if repair.vector_rows or repair.coding_rows:
|
||||||
logger.info(
|
logger.info(
|
||||||
"sdk_metadata_repair_completed mapped=%d vector=%d coding=%d",
|
"sdk_metadata_repair_completed mapped=%d vector=%d coding=%d",
|
||||||
@@ -1337,7 +1364,12 @@ def create_app(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.state.recovery_task = None
|
app.state.recovery_task = None
|
||||||
|
app.state.sdk_stores_held = False
|
||||||
try:
|
try:
|
||||||
|
if app.state.memory_authority is MemoryAuthority.SDK:
|
||||||
|
# Before the first request can open an SDK client; the
|
||||||
|
# metadata repair step releases it.
|
||||||
|
app.state.sdk_stores_held = hold_stores_offline(runtime_settings.data_dir)
|
||||||
diagnostic_event(
|
diagnostic_event(
|
||||||
level="info",
|
level="info",
|
||||||
component="core.lifecycle",
|
component="core.lifecycle",
|
||||||
@@ -1394,6 +1426,10 @@ def create_app(
|
|||||||
if app.state.live_sync_watch is not None:
|
if app.state.live_sync_watch is not None:
|
||||||
await app.state.live_sync_watch.shutdown()
|
await app.state.live_sync_watch.shutdown()
|
||||||
await app.state.live_sync_supervisor.shutdown()
|
await app.state.live_sync_supervisor.shutdown()
|
||||||
|
if app.state.sdk_stores_held:
|
||||||
|
# Recovery never reached the metadata repair step.
|
||||||
|
release_stores(runtime_settings.data_dir)
|
||||||
|
app.state.sdk_stores_held = False
|
||||||
await app.state.import_supervisor.shutdown()
|
await app.state.import_supervisor.shutdown()
|
||||||
app.state.memory_write_drain.stop()
|
app.state.memory_write_drain.stop()
|
||||||
if app.state.memory_write_drain_task is not None:
|
if app.state.memory_write_drain_task is not None:
|
||||||
@@ -2824,11 +2860,24 @@ def create_app(
|
|||||||
dependencies=protected,
|
dependencies=protected,
|
||||||
)
|
)
|
||||||
async def sdk_home_summary() -> SdkHomeSummary:
|
async def sdk_home_summary() -> SdkHomeSummary:
|
||||||
|
"""The home card: the memory page's count and its newest memory.
|
||||||
|
|
||||||
|
The count is `memory_stats` total (chat + working + coding
|
||||||
|
experiences, facts not counted), the same set the memory page lists
|
||||||
|
with scene="all". Without a scene `list_memories` counts chat only,
|
||||||
|
which showed 55 on the home card against 94 on the memory page.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
client = await _sdk_client_handle(app).client()
|
client = await _sdk_client_handle(app).client()
|
||||||
|
stats = await asyncio.to_thread(
|
||||||
|
client.memory_stats,
|
||||||
|
user_id="local",
|
||||||
|
statuses=["active"],
|
||||||
|
)
|
||||||
raw = await asyncio.to_thread(
|
raw = await asyncio.to_thread(
|
||||||
client.list_memories,
|
client.list_memories,
|
||||||
user_id="local",
|
user_id="local",
|
||||||
|
scene="all",
|
||||||
statuses=["active"],
|
statuses=["active"],
|
||||||
limit=1,
|
limit=1,
|
||||||
offset=0,
|
offset=0,
|
||||||
@@ -2838,8 +2887,9 @@ def create_app(
|
|||||||
_log_skipped_sdk_memories(skipped)
|
_log_skipped_sdk_memories(skipped)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
raise _sdk_http_error(error, "hy_memory_list_failed") from None
|
raise _sdk_http_error(error, "hy_memory_list_failed") from None
|
||||||
|
total = stats.get("total") if isinstance(stats, dict) else None
|
||||||
return SdkHomeSummary(
|
return SdkHomeSummary(
|
||||||
memory_count=page.total,
|
memory_count=total if isinstance(total, int) and total >= 0 else page.total,
|
||||||
latest=page.memories[0] if page.memories else None,
|
latest=page.memories[0] if page.memories else None,
|
||||||
observed_at=datetime.now(UTC),
|
observed_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
@@ -3100,7 +3150,7 @@ def create_app(
|
|||||||
raise HTTPException(status_code=422, detail="memory_task_requires_experience")
|
raise HTTPException(status_code=422, detail="memory_task_requires_experience")
|
||||||
namespace = _namespace_kwargs(request.kind, request.variant)
|
namespace = _namespace_kwargs(request.kind, request.variant)
|
||||||
try:
|
try:
|
||||||
client = await app.state.sdk_write_coordinator.client()
|
async with app.state.sdk_write_coordinator.use_client() as client:
|
||||||
update_kwargs: dict[str, object] = dict(namespace)
|
update_kwargs: dict[str, object] = dict(namespace)
|
||||||
if request.task is not None:
|
if request.task is not None:
|
||||||
update_kwargs["task"] = request.task
|
update_kwargs["task"] = request.task
|
||||||
@@ -3147,7 +3197,7 @@ def create_app(
|
|||||||
) -> Response:
|
) -> Response:
|
||||||
_check_memory_namespace(kind, variant)
|
_check_memory_namespace(kind, variant)
|
||||||
try:
|
try:
|
||||||
client = await app.state.sdk_write_coordinator.client()
|
async with app.state.sdk_write_coordinator.use_client() as client:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
client.delete, memory_id, **_namespace_kwargs(kind, variant)
|
client.delete, memory_id, **_namespace_kwargs(kind, variant)
|
||||||
)
|
)
|
||||||
@@ -3392,9 +3442,9 @@ def create_app(
|
|||||||
"""Retry unknown writes only behind the user's explicit confirmation."""
|
"""Retry unknown writes only behind the user's explicit confirmation."""
|
||||||
del request
|
del request
|
||||||
reconciled = await _sdk_memory_runtime(app).reconcile_unknown_outputs()
|
reconciled = await _sdk_memory_runtime(app).reconcile_unknown_outputs()
|
||||||
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
# The startup queue knows the authority. A fresh legacy-mode queue
|
||||||
|
# would recreate the job-evidence table the SDK cutover dropped.
|
||||||
queue = DurableWriteQueue(app.state.memory_write_queue_path)
|
queue = app.state.memory_write_queue
|
||||||
unknown = queue.unknown_jobs(limit=2048)
|
unknown = queue.unknown_jobs(limit=2048)
|
||||||
if app.state.memory_authority is MemoryAuthority.LEGACY:
|
if app.state.memory_authority is MemoryAuthority.LEGACY:
|
||||||
for job in unknown:
|
for job in unknown:
|
||||||
@@ -4330,14 +4380,11 @@ async def _run_backfill(app: FastAPI) -> None:
|
|||||||
_evidence_store(app).list_conversation,
|
_evidence_store(app).list_conversation,
|
||||||
evidence_id,
|
evidence_id,
|
||||||
)
|
)
|
||||||
for chunk in _conversation_chunks(
|
# Catch-up is history: the last turn and a short batch are written.
|
||||||
conversation,
|
for batch in turn_batches(conversation, flush=True).batches:
|
||||||
max_turns=12,
|
if not any(item.evidence_id in evidence_ids for item in batch):
|
||||||
completed_only=True,
|
|
||||||
):
|
|
||||||
if not any(item.evidence_id in evidence_ids for item in chunk):
|
|
||||||
continue
|
continue
|
||||||
await asyncio.to_thread(intake.submit_records, chunk, priority="normal")
|
await asyncio.to_thread(intake.submit_records, batch, priority="normal")
|
||||||
cursor = evidence_ids[-1]
|
cursor = evidence_ids[-1]
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
@@ -4356,6 +4403,22 @@ async def _run_backfill(app: FastAPI) -> None:
|
|||||||
logger.exception("memory catch-up failed, no further memories will be made")
|
logger.exception("memory catch-up failed, no further memories will be made")
|
||||||
|
|
||||||
|
|
||||||
|
def _batches_settled_by(
|
||||||
|
batches: tuple[tuple[EvidenceRecord, ...], ...],
|
||||||
|
record: EvidenceRecord,
|
||||||
|
) -> list[tuple[EvidenceRecord, ...]]:
|
||||||
|
"""The batches one arriving record can have made writable.
|
||||||
|
|
||||||
|
Its own batch, and the last two: a user message completes the turn
|
||||||
|
before it, which can close that turn's batch and, at the 8000-character
|
||||||
|
cut, the batch before it. Submitting is idempotent, so an older batch
|
||||||
|
named again is only a hit.
|
||||||
|
"""
|
||||||
|
chosen = [batch for batch in batches if record in batch]
|
||||||
|
chosen.extend(batch for batch in batches[-2:] if batch not in chosen)
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
def _sdk_memory_runtime(app: FastAPI) -> SdkMemoryRuntime:
|
def _sdk_memory_runtime(app: FastAPI) -> SdkMemoryRuntime:
|
||||||
return cast(
|
return cast(
|
||||||
SdkMemoryRuntime,
|
SdkMemoryRuntime,
|
||||||
@@ -4651,10 +4714,17 @@ def _evidence_store(app: FastAPI) -> EvidenceStore:
|
|||||||
|
|
||||||
|
|
||||||
def _source_writes_on(app: FastAPI, source_id: str) -> bool:
|
def _source_writes_on(app: FastAPI, source_id: str) -> bool:
|
||||||
"""Whether this source's conversations may become memories."""
|
"""Whether this source's conversations may become memories.
|
||||||
|
|
||||||
|
A hand-added memory is allowed unless its grant was explicitly revoked;
|
||||||
|
a missing grant row is allowed. The SDK add path writes no grant row, so
|
||||||
|
requiring one left every SDK-authority manual add unclaimable: Pending
|
||||||
|
forever with attempts=0 while later jobs ran. Intake, claim and execute
|
||||||
|
all ask this one question.
|
||||||
|
"""
|
||||||
if source_id == "one-memory-manual":
|
if source_id == "one-memory-manual":
|
||||||
receipt = _grant_store(app).receipt(user_id="local", source_id=source_id)
|
receipt = _grant_store(app).receipt(user_id="local", source_id=source_id)
|
||||||
return receipt is not None and receipt.revoked_at is None
|
return receipt is None or receipt.revoked_at is None
|
||||||
return _source_processing_enabled(app, source_id)
|
return _source_processing_enabled(app, source_id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord
|
from one_memory.imports.models import EvidenceContentKind, EvidenceRecord
|
||||||
from one_memory.memory_tasks import segment_evidence_record
|
|
||||||
|
|
||||||
MEMORY_TASK_TIMEOUT_MARGIN_SECONDS = 60.0
|
MEMORY_TASK_TIMEOUT_MARGIN_SECONDS = 60.0
|
||||||
MEMORY_TASK_TIMEOUT_MIN_SECONDS = 180.0
|
MEMORY_TASK_TIMEOUT_MIN_SECONDS = 180.0
|
||||||
@@ -29,10 +30,13 @@ _HOST_ENVELOPE = re.compile(
|
|||||||
r")",
|
r")",
|
||||||
re.IGNORECASE | re.DOTALL,
|
re.IGNORECASE | re.DOTALL,
|
||||||
)
|
)
|
||||||
_WHOLE_TURN_CHARACTERS = 60_000
|
#: The write rule (`turn_batches`): every 5 complete turns, or fewer when
|
||||||
_WINDOW_BYTE_CEILING = 2_000_000
|
#: the next turn would take the batch past 8000 HY-read characters.
|
||||||
_HISTORY_MERGE_TURNS = 8
|
TURN_BATCH_TURNS = 5
|
||||||
_HISTORY_MERGE_CHARACTERS = 8_000
|
TURN_BATCH_CHARACTERS = 8_000
|
||||||
|
#: A live conversation's last turn counts as complete, and a short batch is
|
||||||
|
#: written, after this long without a new message in that conversation.
|
||||||
|
TURN_IDLE_FLUSH_SECONDS = 600
|
||||||
|
|
||||||
|
|
||||||
def memory_task_timeout_seconds(model_timeout_seconds: float) -> float:
|
def memory_task_timeout_seconds(model_timeout_seconds: float) -> float:
|
||||||
@@ -57,180 +61,83 @@ def _coding_tool_result_max_bytes() -> int:
|
|||||||
_HY_TOOL_CHARACTERS = _coding_tool_result_max_bytes()
|
_HY_TOOL_CHARACTERS = _coding_tool_result_max_bytes()
|
||||||
|
|
||||||
|
|
||||||
def _conversation_chunks(
|
@dataclass(frozen=True)
|
||||||
records: list[EvidenceRecord],
|
class TurnBatches:
|
||||||
*,
|
"""What a conversation's records become: batches to write, records to keep."""
|
||||||
max_characters: int | None = None,
|
|
||||||
max_turns: int | None = None,
|
#: Each batch is whole turns; every batch holds at least one user message.
|
||||||
completed_only: bool = False,
|
batches: tuple[tuple[EvidenceRecord, ...], ...]
|
||||||
) -> list[tuple[EvidenceRecord, ...]]:
|
#: Not written yet: the open last turn, complete turns short of a batch,
|
||||||
"""Build bounded, complete user/assistant/tool windows."""
|
#: or records with no user message before them. Fed back in, in order,
|
||||||
|
#: with the conversation's next records.
|
||||||
|
carry: tuple[EvidenceRecord, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def conversation_turns(records: Sequence[EvidenceRecord]) -> list[tuple[EvidenceRecord, ...]]:
|
||||||
|
"""One user message and every record after it up to the next user message.
|
||||||
|
|
||||||
|
Records before the first user message are not a turn of their own: they
|
||||||
|
join the first turn (an assistant proposal and the user turn adopting it
|
||||||
|
reach HY together). With no user message at all the result is one group
|
||||||
|
that does not open with a user, which `turn_batches` never writes.
|
||||||
|
"""
|
||||||
turns: list[list[EvidenceRecord]] = []
|
turns: list[list[EvidenceRecord]] = []
|
||||||
for record in records:
|
for record in records:
|
||||||
if not _is_hy_turn_record(record):
|
if not _is_hy_turn_record(record):
|
||||||
continue
|
continue
|
||||||
pending_proposal = bool(turns) and not _opens_turn(turns[-1][0])
|
# Until the first user message, the group is leading records only.
|
||||||
if not turns or (_opens_turn(record) and not pending_proposal):
|
leading = bool(turns) and not any(_opens_turn(item) for item in turns[-1])
|
||||||
|
if not turns or (_opens_turn(record) and not leading):
|
||||||
turns.append([record])
|
turns.append([record])
|
||||||
else:
|
else:
|
||||||
turns[-1].append(record)
|
turns[-1].append(record)
|
||||||
|
return [tuple(turn) for turn in turns]
|
||||||
if completed_only and turns and _trailing_user_turn_is_open(turns[-1]):
|
|
||||||
turns.pop()
|
|
||||||
|
|
||||||
whole = _whole_turns() and max_characters is None
|
|
||||||
if whole:
|
|
||||||
size = _hy_read_length
|
|
||||||
max_characters = _WHOLE_TURN_CHARACTERS
|
|
||||||
max_turns = max_turns or 1_000_000
|
|
||||||
else:
|
|
||||||
size = lambda record: len(record.verbatim) # noqa: E731
|
|
||||||
max_characters = max_characters or 12_000
|
|
||||||
max_turns = max_turns or 24
|
|
||||||
|
|
||||||
chunks: list[tuple[EvidenceRecord, ...]] = []
|
|
||||||
for turn in turns:
|
|
||||||
original_question = turn[0] if _opens_turn(turn[0]) else None
|
|
||||||
expanded: list[EvidenceRecord] = []
|
|
||||||
for record in turn:
|
|
||||||
reserve = (
|
|
||||||
size(original_question)
|
|
||||||
if original_question is not None
|
|
||||||
and record is not original_question
|
|
||||||
and size(original_question) < max_characters
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
segment_limit = max(1, max_characters - reserve)
|
|
||||||
if size(record) <= segment_limit:
|
|
||||||
expanded.append(record)
|
|
||||||
continue
|
|
||||||
spans = _semantic_segment_spans(record.verbatim, segment_limit)
|
|
||||||
expanded.extend(
|
|
||||||
segment_evidence_record(record, index=index, start=start, end=end)
|
|
||||||
if len(spans) > 1
|
|
||||||
else record
|
|
||||||
for index, (start, end) in enumerate(spans)
|
|
||||||
)
|
|
||||||
question = expanded[0] if expanded and _opens_turn(expanded[0]) else None
|
|
||||||
current: list[EvidenceRecord] = []
|
|
||||||
for record in expanded:
|
|
||||||
if current and (
|
|
||||||
len(current) >= max_turns
|
|
||||||
or sum(size(item) for item in current) + size(record) > max_characters
|
|
||||||
or sum(_window_bytes(item) for item in current) + _window_bytes(record)
|
|
||||||
> _WINDOW_BYTE_CEILING
|
|
||||||
):
|
|
||||||
chunks.append(tuple(current))
|
|
||||||
current = (
|
|
||||||
[question]
|
|
||||||
if question is not None
|
|
||||||
and max_turns > 1
|
|
||||||
and size(question) + size(record) <= max_characters
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
current.append(record)
|
|
||||||
chunks.append(tuple(current))
|
|
||||||
return _merge_tool_only_chunks(chunks)
|
|
||||||
|
|
||||||
|
|
||||||
def _semantic_segment_spans(text: str, limit: int) -> list[tuple[int, int]]:
|
def turn_batches(records: Sequence[EvidenceRecord], *, flush: bool) -> TurnBatches:
|
||||||
if len(text) <= limit:
|
"""Group a conversation's records into write batches by the turn rule.
|
||||||
return [(0, len(text))]
|
|
||||||
spans: list[tuple[int, int]] = []
|
|
||||||
start = 0
|
|
||||||
separators = ("\n\n", "\n", "。", "!", "?", ". ", "! ", "? ")
|
|
||||||
while start < len(text):
|
|
||||||
hard_end = min(len(text), start + limit)
|
|
||||||
if hard_end == len(text):
|
|
||||||
end = hard_end
|
|
||||||
else:
|
|
||||||
floor = start + max(1, limit // 2)
|
|
||||||
candidates = [text.rfind(separator, floor, hard_end) for separator in separators]
|
|
||||||
boundary = max(candidates)
|
|
||||||
if boundary < floor:
|
|
||||||
end = hard_end
|
|
||||||
else:
|
|
||||||
matched = next(
|
|
||||||
separator for separator in separators if text.startswith(separator, boundary)
|
|
||||||
)
|
|
||||||
end = boundary + len(matched)
|
|
||||||
spans.append((start, end))
|
|
||||||
start = end
|
|
||||||
return spans
|
|
||||||
|
|
||||||
|
- A turn is complete once the next user message follows it; `flush`
|
||||||
def _merge_tool_only_chunks(
|
completes the last one too (history at end of file, or 10 idle minutes).
|
||||||
chunks: list[tuple[EvidenceRecord, ...]],
|
- Every 5 complete turns are one batch. Before 5, a turn that would take
|
||||||
) -> list[tuple[EvidenceRecord, ...]]:
|
the batch past 8000 characters starts the next batch; one turn over
|
||||||
if not any(chunk and all(record.role == "tool" for record in chunk) for chunk in chunks):
|
8000 is a batch by itself and is never split.
|
||||||
return chunks
|
- Characters are what HY reads: user/assistant text in full, a tool
|
||||||
merged: list[tuple[EvidenceRecord, ...]] = []
|
record up to `_HY_TOOL_CHARACTERS`.
|
||||||
leading_tools: list[EvidenceRecord] = []
|
- Fewer than 5 complete turns within 8000 are written only on `flush`.
|
||||||
for chunk in chunks:
|
- No batch is written without a user message; records with none before
|
||||||
if not chunk:
|
them are carried, and dropped on `flush`.
|
||||||
continue
|
"""
|
||||||
if all(record.role == "tool" for record in chunk):
|
turns = conversation_turns(records)
|
||||||
if merged:
|
if turns and not any(_opens_turn(record) for record in turns[0]):
|
||||||
merged[-1] = (*merged[-1], *chunk)
|
# Records with no user message yet: nothing to write until one comes.
|
||||||
else:
|
return TurnBatches(batches=(), carry=() if flush else turns[0])
|
||||||
leading_tools.extend(chunk)
|
complete = turns if flush else turns[:-1]
|
||||||
continue
|
open_tail = () if flush or not turns else turns[-1]
|
||||||
if leading_tools:
|
batches: list[tuple[EvidenceRecord, ...]] = []
|
||||||
chunk = (*leading_tools, *chunk)
|
current: list[tuple[EvidenceRecord, ...]] = []
|
||||||
leading_tools = []
|
|
||||||
merged.append(chunk)
|
|
||||||
return [] if leading_tools else merged
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_historical_chunks(
|
|
||||||
chunks: list[tuple[EvidenceRecord, ...]],
|
|
||||||
*,
|
|
||||||
max_turns: int = _HISTORY_MERGE_TURNS,
|
|
||||||
max_characters: int = _HISTORY_MERGE_CHARACTERS,
|
|
||||||
) -> list[tuple[EvidenceRecord, ...]]:
|
|
||||||
if not chunks:
|
|
||||||
return []
|
|
||||||
merged: list[tuple[EvidenceRecord, ...]] = []
|
|
||||||
current: list[EvidenceRecord] = []
|
|
||||||
current_turns = 0
|
|
||||||
current_characters = 0
|
current_characters = 0
|
||||||
for chunk in chunks:
|
for turn in complete:
|
||||||
chunk_characters = sum(len(record.verbatim) for record in chunk)
|
characters = sum(_hy_read_length(record) for record in turn)
|
||||||
if current and (
|
if current and current_characters + characters > TURN_BATCH_CHARACTERS:
|
||||||
current_turns >= max_turns or current_characters + chunk_characters > max_characters
|
batches.append(tuple(record for item in current for record in item))
|
||||||
):
|
current, current_characters = [], 0
|
||||||
merged.append(tuple(current))
|
current.append(turn)
|
||||||
|
current_characters += characters
|
||||||
|
if len(current) >= TURN_BATCH_TURNS or current_characters > TURN_BATCH_CHARACTERS:
|
||||||
|
batches.append(tuple(record for item in current for record in item))
|
||||||
|
current, current_characters = [], 0
|
||||||
|
if current and flush:
|
||||||
|
batches.append(tuple(record for item in current for record in item))
|
||||||
current = []
|
current = []
|
||||||
current_turns = 0
|
carry = tuple(record for item in current for record in item) + tuple(open_tail)
|
||||||
current_characters = 0
|
return TurnBatches(batches=tuple(batches), carry=carry)
|
||||||
current.extend(chunk)
|
|
||||||
current_turns += 1
|
|
||||||
current_characters += chunk_characters
|
|
||||||
if current:
|
|
||||||
merged.append(tuple(current))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
def _chunk_input_ref(chunk: tuple[EvidenceRecord, ...]) -> str:
|
|
||||||
user_records = tuple(item for item in chunk if item.role == "user")
|
|
||||||
return user_records[-1].evidence_id if user_records else chunk[-1].evidence_id
|
|
||||||
|
|
||||||
|
|
||||||
def _opens_turn(record: EvidenceRecord) -> bool:
|
def _opens_turn(record: EvidenceRecord) -> bool:
|
||||||
return record.role == "user"
|
return record.role == "user"
|
||||||
|
|
||||||
|
|
||||||
def _trailing_user_turn_is_open(turn: list[EvidenceRecord]) -> bool:
|
|
||||||
if len(turn) != 1:
|
|
||||||
return False
|
|
||||||
opener = turn[0]
|
|
||||||
return (
|
|
||||||
_opens_turn(opener)
|
|
||||||
and opener.content_kind is EvidenceContentKind.RAW_CONVERSATION
|
|
||||||
and not _is_hand_added_memory(opener)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_hy_turn_record(record: EvidenceRecord) -> bool:
|
def _is_hy_turn_record(record: EvidenceRecord) -> bool:
|
||||||
if record.role == "tool":
|
if record.role == "tool":
|
||||||
return record.content_kind is EvidenceContentKind.RAW_CONVERSATION and bool(
|
return record.content_kind is EvidenceContentKind.RAW_CONVERSATION and bool(
|
||||||
@@ -254,23 +161,6 @@ def _is_hy_input_turn(record: EvidenceRecord) -> bool:
|
|||||||
return _HOST_ENVELOPE.search(source[:2_000]) is None
|
return _HOST_ENVELOPE.search(source[:2_000]) is None
|
||||||
|
|
||||||
|
|
||||||
def _is_hand_added_memory(record: EvidenceRecord) -> bool:
|
|
||||||
meta = record.origin_meta.get(EvidenceOrigin.MANUAL_IMPORT)
|
|
||||||
return (
|
|
||||||
record.source_id == "one-memory-manual"
|
|
||||||
and meta is not None
|
|
||||||
and meta.raw_meta.get("input_mode") == "direct_memory_note"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _window_bytes(record: EvidenceRecord) -> int:
|
|
||||||
return len(record.verbatim.encode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def _whole_turns() -> bool:
|
|
||||||
return os.environ.get("ONE_MEMORY_SPLIT_TURNS", "").strip() != "1"
|
|
||||||
|
|
||||||
|
|
||||||
def _hy_read_length(record: EvidenceRecord) -> int:
|
def _hy_read_length(record: EvidenceRecord) -> int:
|
||||||
if record.role == "tool":
|
if record.role == "tool":
|
||||||
return min(len(record.verbatim), _HY_TOOL_CHARACTERS)
|
return min(len(record.verbatim), _HY_TOOL_CHARACTERS)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
|||||||
from typing import Literal, cast
|
from typing import Literal, cast
|
||||||
|
|
||||||
HY_MEMORY_DISTRIBUTION = "hy-memory"
|
HY_MEMORY_DISTRIBUTION = "hy-memory"
|
||||||
HY_MEMORY_VERSION = "2.0.6"
|
HY_MEMORY_VERSION = "2.0.8"
|
||||||
HY_MEMORY_NAMESPACE = "governed-v2"
|
HY_MEMORY_NAMESPACE = "governed-v2"
|
||||||
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
|
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
|
||||||
|
|
||||||
@@ -109,11 +109,30 @@ def apply_diagnostic_log_level(level: DiagnosticLogLevel) -> None:
|
|||||||
if any(name == root or name.startswith(f"{root}.") for root in _NOISY_LIBRARY_LOGGERS):
|
if any(name == root or name.startswith(f"{root}.") for root in _NOISY_LIBRARY_LOGGERS):
|
||||||
logging.getLogger(name).setLevel(noisy_level)
|
logging.getLogger(name).setLevel(noisy_level)
|
||||||
|
|
||||||
|
# HY configures its text log once, on the first client, at the level of
|
||||||
|
# that moment. Its file handler must follow later changes too, or a live
|
||||||
|
# switch to debug never reaches hy_memory.log.
|
||||||
|
for handler in logging.getLogger("hy_memory").handlers:
|
||||||
|
if isinstance(handler, logging.FileHandler):
|
||||||
|
handler.setLevel(selected)
|
||||||
|
|
||||||
os.environ["MEMORY_LOG_LEVEL"] = logging.getLevelName(selected)
|
os.environ["MEMORY_LOG_LEVEL"] = logging.getLevelName(selected)
|
||||||
os.environ["MEMORY_PIPELINE_TRACE_ENABLED"] = "true" if level == "debug" else "false"
|
_apply_hy_pipeline_policy()
|
||||||
os.environ["MEMORY_LOG_LLM_CONTENT"] = "true" if level == "debug" else "false"
|
os.environ["MEMORY_LOG_LLM_CONTENT"] = "true" if level == "debug" else "false"
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_hy_pipeline_policy() -> None:
|
||||||
|
"""Keep HY's per-step pipeline log and trace off at every level.
|
||||||
|
|
||||||
|
Nothing in MemHub reads them: Core's own diagnostics carry the step
|
||||||
|
timing, and the JSONL/SQLite copies only grew on disk. With both off
|
||||||
|
the SDK skips the per-step bookkeeping altogether.
|
||||||
|
"""
|
||||||
|
|
||||||
|
os.environ["MEMORY_PIPELINE_LOG_ENABLED"] = "false"
|
||||||
|
os.environ["MEMORY_PIPELINE_TRACE_ENABLED"] = "false"
|
||||||
|
|
||||||
|
|
||||||
def hy_memory_version() -> str:
|
def hy_memory_version() -> str:
|
||||||
"""Return the installed SDK version, failing if the pinned dist is absent."""
|
"""Return the installed SDK version, failing if the pinned dist is absent."""
|
||||||
|
|
||||||
@@ -131,7 +150,9 @@ def configure_hy_runtime_environment(data_dir: Path) -> None:
|
|||||||
os.environ["MEMORY_LOG_DIR"] = str(log_dir)
|
os.environ["MEMORY_LOG_DIR"] = str(log_dir)
|
||||||
level = diagnostic_log_level(data_dir)
|
level = diagnostic_log_level(data_dir)
|
||||||
os.environ["MEMORY_LOG_LEVEL"] = level.upper()
|
os.environ["MEMORY_LOG_LEVEL"] = level.upper()
|
||||||
os.environ["MEMORY_PIPELINE_TRACE_ENABLED"] = "true" if level == "debug" else "false"
|
_apply_hy_pipeline_policy()
|
||||||
|
# Still meaningful with the pipeline log off: it decides whether HY's
|
||||||
|
# DEBUG lines in hy_memory.log carry full model prompts and responses.
|
||||||
os.environ["MEMORY_LOG_LLM_CONTENT"] = "true" if level == "debug" else "false"
|
os.environ["MEMORY_LOG_LLM_CONTENT"] = "true" if level == "debug" else "false"
|
||||||
os.environ["MEMORY_MEMORY_OPERATIONS_ENABLED"] = "false"
|
os.environ["MEMORY_MEMORY_OPERATIONS_ENABLED"] = "false"
|
||||||
os.environ["ANONYMIZED_TELEMETRY"] = "False"
|
os.environ["ANONYMIZED_TELEMETRY"] = "False"
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ def repair_sdk_metadata_from_bundle(
|
|||||||
database_path: Path,
|
database_path: Path,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
) -> MetadataRepairResult:
|
) -> MetadataRepairResult:
|
||||||
"""Restore metadata lost by historical cutovers from their verified bundles."""
|
"""Restore metadata lost by historical cutovers from their verified bundles.
|
||||||
|
|
||||||
|
Offline only: no SDK client may be open on this home (see `_repair_vector`).
|
||||||
|
"""
|
||||||
migration_id = _sdk_migration_id(database_path)
|
migration_id = _sdk_migration_id(database_path)
|
||||||
if migration_id is None:
|
if migration_id is None:
|
||||||
return MetadataRepairResult(None, 0, 0, 0)
|
return MetadataRepairResult(None, 0, 0, 0)
|
||||||
@@ -329,8 +332,11 @@ def _repair_vector(
|
|||||||
from one_memory.sdk_vector_reattach import open_offline_chroma
|
from one_memory.sdk_vector_reattach import open_offline_chroma
|
||||||
|
|
||||||
client = open_offline_chroma(directory)
|
client = open_offline_chroma(directory)
|
||||||
# The SDK client is not open yet (startup has not created its handle), so
|
# This must be the only Chroma client on the directory: Chroma shares one
|
||||||
# this is the only writer; release the store before the SDK takes it.
|
# System per path, and this client's close() drops it from under any
|
||||||
|
# other (KeyError on its next lookup). `sdk-migrate` runs it after the
|
||||||
|
# cutover with Runtime stopped; Core's startup fallback runs it while
|
||||||
|
# `hold_stores_offline` keeps every SDK client waiting.
|
||||||
try:
|
try:
|
||||||
repaired = 0
|
repaired = 0
|
||||||
ids = tuple(mapping)
|
ids = tuple(mapping)
|
||||||
@@ -420,6 +426,7 @@ def _repair_coding(
|
|||||||
path = root / name
|
path = root / name
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
continue
|
continue
|
||||||
|
_ensure_coding_schema(path)
|
||||||
with closing(sqlite3.connect(path)) as database:
|
with closing(sqlite3.connect(path)) as database:
|
||||||
tables = {
|
tables = {
|
||||||
str(row[0])
|
str(row[0])
|
||||||
@@ -431,10 +438,14 @@ def _repair_coding(
|
|||||||
):
|
):
|
||||||
if table not in tables:
|
if table not in tables:
|
||||||
continue
|
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(
|
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()
|
).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))
|
desired = mapping.get(str(memory_id))
|
||||||
if desired is None:
|
if desired is None:
|
||||||
continue
|
continue
|
||||||
@@ -443,6 +454,13 @@ def _repair_coding(
|
|||||||
if desired.source and source in {None, "", "default"}:
|
if desired.source and source in {None, "", "default"}:
|
||||||
updates.append("source=?")
|
updates.append("source=?")
|
||||||
values.append(desired.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:
|
if desired.created_at is not None:
|
||||||
desired_created_at = (
|
desired_created_at = (
|
||||||
datetime.fromtimestamp(desired.created_at, tz=UTC)
|
datetime.fromtimestamp(desired.created_at, tz=UTC)
|
||||||
@@ -464,6 +482,32 @@ def _repair_coding(
|
|||||||
return repaired
|
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.8 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, ...]]:
|
def _batches[T](values: list[T] | tuple[T, ...]) -> Iterator[tuple[T, ...]]:
|
||||||
for start in range(0, len(values), _BATCH):
|
for start in range(0, len(values), _BATCH):
|
||||||
yield tuple(values[start : start + _BATCH])
|
yield tuple(values[start : start + _BATCH])
|
||||||
@@ -36,7 +36,12 @@ from one_memory.hy_graph_admin import (
|
|||||||
)
|
)
|
||||||
from one_memory.hy_sdk_types import HySdkRuntimeError
|
from one_memory.hy_sdk_types import HySdkRuntimeError
|
||||||
from one_memory.local_embedding import LocalEmbedder
|
from one_memory.local_embedding import LocalEmbedder
|
||||||
from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind
|
from one_memory.log_file import confine_hy_sdk_logging
|
||||||
|
from one_memory.model_profiles import (
|
||||||
|
ModelProfileSnapshot,
|
||||||
|
ProviderKind,
|
||||||
|
disable_thinking_params,
|
||||||
|
)
|
||||||
|
|
||||||
_RUNTIME_LOCK = threading.RLock()
|
_RUNTIME_LOCK = threading.RLock()
|
||||||
BUILTIN_RETRIEVAL_PROFILE_ID = "builtin-retrieval"
|
BUILTIN_RETRIEVAL_PROFILE_ID = "builtin-retrieval"
|
||||||
@@ -52,6 +57,26 @@ class _RuntimeEntry:
|
|||||||
|
|
||||||
_RUNTIMES: dict[Path, _RuntimeEntry] = {}
|
_RUNTIMES: dict[Path, _RuntimeEntry] = {}
|
||||||
|
|
||||||
|
# Homes whose SDK-owned local stores are held offline for maintenance, and
|
||||||
|
# the client constructions in flight per home. Chroma keeps one process-wide
|
||||||
|
# System per path with no lock around create, lookup and release, so a
|
||||||
|
# second client that opens or closes beside the SDK's pulls the System out
|
||||||
|
# from under it (KeyError on the next lookup). Maintenance therefore runs
|
||||||
|
# only while no SDK client exists for that home, and new clients wait.
|
||||||
|
_STORE_GATE = threading.Condition()
|
||||||
|
_OFFLINE_STORES: set[Path] = set()
|
||||||
|
_OPENING: dict[Path, int] = {}
|
||||||
|
|
||||||
|
# One client constructs at a time per home, its first-time store setup
|
||||||
|
# included. The constructor opens Chroma's `PersistentClient`, and two first
|
||||||
|
# opens on one directory race over that same unlocked System cache. On a
|
||||||
|
# clean install the write drain's client and a UI read client started in the
|
||||||
|
# same second; neither reached `VectorStore ready`, and the first write job
|
||||||
|
# was lost as unknown. Taken inside `acquire_client`, after the offline gate
|
||||||
|
# and never while `_STORE_GATE` is held, so it cannot deadlock with
|
||||||
|
# `hold_stores_offline`, which refuses while `_OPENING` counts a client.
|
||||||
|
_CONSTRUCTING: dict[Path, threading.Lock] = {}
|
||||||
|
|
||||||
|
|
||||||
class LocalOnnxEmbeddingService:
|
class LocalOnnxEmbeddingService:
|
||||||
"""Public ``EmbeddingService`` implementation backed by MemHub's ONNX model."""
|
"""Public ``EmbeddingService`` implementation backed by MemHub's ONNX model."""
|
||||||
@@ -244,14 +269,22 @@ def build_sdk_config(
|
|||||||
embedding_provider, embedding_endpoint, embedding_model, dimensions
|
embedding_provider, embedding_endpoint, embedding_model, dimensions
|
||||||
)
|
)
|
||||||
|
|
||||||
return MemoryConfig.from_dict(
|
llm: dict[str, Any] = {
|
||||||
{
|
|
||||||
"llm": {
|
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": profile.model,
|
"model": profile.model,
|
||||||
"api_key": generation_key,
|
"api_key": generation_key,
|
||||||
"base_url": generation_endpoint,
|
"base_url": generation_endpoint,
|
||||||
},
|
}
|
||||||
|
# The SDK sends `extra_body` on every chat call, chat and coding/working
|
||||||
|
# alike. Without it DeepSeek spent the whole agent_max_tokens on thinking
|
||||||
|
# and answered finish_reason=length with no content.
|
||||||
|
thinking_off = disable_thinking_params(profile)
|
||||||
|
if thinking_off:
|
||||||
|
llm["extra_body"] = thinking_off
|
||||||
|
|
||||||
|
return MemoryConfig.from_dict(
|
||||||
|
{
|
||||||
|
"llm": llm,
|
||||||
"embedder": {
|
"embedder": {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": (
|
"model": (
|
||||||
@@ -295,7 +328,60 @@ def acquire_client(
|
|||||||
data_dir: Path,
|
data_dir: Path,
|
||||||
http_transport_factory: Callable[[], Any] | None = None,
|
http_transport_factory: Callable[[], Any] | None = None,
|
||||||
) -> ClientLease:
|
) -> ClientLease:
|
||||||
"""Construct a public SDK client and return its explicit runtime lease."""
|
"""Construct a public SDK client and return its explicit runtime lease.
|
||||||
|
|
||||||
|
Waits while `hold_stores_offline` holds this home's stores.
|
||||||
|
"""
|
||||||
|
key = data_dir.resolve()
|
||||||
|
with _STORE_GATE:
|
||||||
|
while key in _OFFLINE_STORES:
|
||||||
|
_STORE_GATE.wait()
|
||||||
|
_OPENING[key] = _OPENING.get(key, 0) + 1
|
||||||
|
try:
|
||||||
|
return _acquire_client(
|
||||||
|
profile,
|
||||||
|
api_key=api_key,
|
||||||
|
data_dir=data_dir,
|
||||||
|
http_transport_factory=http_transport_factory,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
with _STORE_GATE:
|
||||||
|
_OPENING[key] -= 1
|
||||||
|
if not _OPENING[key]:
|
||||||
|
del _OPENING[key]
|
||||||
|
_STORE_GATE.notify_all()
|
||||||
|
|
||||||
|
|
||||||
|
def hold_stores_offline(data_dir: Path) -> bool:
|
||||||
|
"""Hold this home's SDK stores for offline maintenance; False if a client is open.
|
||||||
|
|
||||||
|
While held, `acquire_client` for the home waits. Every SDK client in Core
|
||||||
|
is built there, so nothing reads or writes Chroma beside the maintenance.
|
||||||
|
`release_stores` ends the hold; it is idempotent.
|
||||||
|
"""
|
||||||
|
key = data_dir.resolve()
|
||||||
|
with _STORE_GATE:
|
||||||
|
with _RUNTIME_LOCK:
|
||||||
|
open_runtime = key in _RUNTIMES
|
||||||
|
if key in _OFFLINE_STORES or _OPENING.get(key) or open_runtime:
|
||||||
|
return False
|
||||||
|
_OFFLINE_STORES.add(key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def release_stores(data_dir: Path) -> None:
|
||||||
|
with _STORE_GATE:
|
||||||
|
_OFFLINE_STORES.discard(data_dir.resolve())
|
||||||
|
_STORE_GATE.notify_all()
|
||||||
|
|
||||||
|
|
||||||
|
def _acquire_client(
|
||||||
|
profile: ModelProfileSnapshot,
|
||||||
|
*,
|
||||||
|
api_key: str | None,
|
||||||
|
data_dir: Path,
|
||||||
|
http_transport_factory: Callable[[], Any] | None,
|
||||||
|
) -> ClientLease:
|
||||||
config = build_sdk_config(profile, api_key=api_key, data_dir=data_dir)
|
config = build_sdk_config(profile, api_key=api_key, data_dir=data_dir)
|
||||||
config.http_transport_factory = http_transport_factory
|
config.http_transport_factory = http_transport_factory
|
||||||
mismatch = inspect_graph_width_mismatch(config)
|
mismatch = inspect_graph_width_mismatch(config)
|
||||||
@@ -306,7 +392,10 @@ def acquire_client(
|
|||||||
embedding_service: LocalOnnxEmbeddingService | None = None
|
embedding_service: LocalOnnxEmbeddingService | None = None
|
||||||
if (profile.embedding_provider_kind or profile.provider_kind) is ProviderKind.LOCAL_ONNX:
|
if (profile.embedding_provider_kind or profile.provider_kind) is ProviderKind.LOCAL_ONNX:
|
||||||
embedding_service = LocalOnnxEmbeddingService()
|
embedding_service = LocalOnnxEmbeddingService()
|
||||||
|
with _STORE_GATE:
|
||||||
|
constructing = _CONSTRUCTING.setdefault(key, threading.Lock())
|
||||||
try:
|
try:
|
||||||
|
with constructing:
|
||||||
client = HyMemoryClient(
|
client = HyMemoryClient(
|
||||||
config=config,
|
config=config,
|
||||||
mode="ultra" if graph_store_enabled() else "pro",
|
mode="ultra" if graph_store_enabled() else "pro",
|
||||||
@@ -316,6 +405,10 @@ def acquire_client(
|
|||||||
except BaseException:
|
except BaseException:
|
||||||
_release_runtime(key)
|
_release_runtime(key)
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
# The constructor's first step configures HY's text log (once per
|
||||||
|
# process), so by here it exists whether or not construction succeeded.
|
||||||
|
confine_hy_sdk_logging()
|
||||||
return ClientLease(client, key)
|
return ClientLease(client, key)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ class HySdkMemoryRecord(BaseModel):
|
|||||||
if not content.strip():
|
if not content.strip():
|
||||||
raise HySdkReadError("hy_memory_record_shape_unknown")
|
raise HySdkReadError("hy_memory_record_shape_unknown")
|
||||||
status = str(raw.get("status") or "active")
|
status = str(raw.get("status") or "active")
|
||||||
|
memory_at = raw.get("memory_at")
|
||||||
created_at = _epoch(raw.get("created_at"))
|
created_at = _epoch(raw.get("created_at"))
|
||||||
updated_at = _epoch(raw.get("updated_at"))
|
updated_at = _epoch(raw.get("updated_at"))
|
||||||
try:
|
try:
|
||||||
@@ -167,6 +168,9 @@ class HySdkMemoryRecord(BaseModel):
|
|||||||
str(raw["session_id"]) if isinstance(raw.get("session_id"), str) else None
|
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,
|
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,
|
gmt_created=created_at,
|
||||||
scene=cast(MemoryScene, variant),
|
scene=cast(MemoryScene, variant),
|
||||||
kind=cast(MemoryKindName, kind),
|
kind=cast(MemoryKindName, kind),
|
||||||
|
|||||||
@@ -662,11 +662,15 @@ class ImportRunner:
|
|||||||
and self.sdk_queue is not None
|
and self.sdk_queue is not None
|
||||||
and event.cursor.read_to_end
|
and event.cursor.read_to_end
|
||||||
):
|
):
|
||||||
|
# History ends at this end of file, so its last turn
|
||||||
|
# and a short batch are written. Live polling keeps
|
||||||
|
# them for the next increment or the idle flush.
|
||||||
self.sdk_queue.finalize_sdk_file(
|
self.sdk_queue.finalize_sdk_file(
|
||||||
user_id=current.user_id,
|
user_id=current.user_id,
|
||||||
source_id=current.source_id,
|
source_id=current.source_id,
|
||||||
file_key=event.cursor.file_key,
|
file_key=event.cursor.file_key,
|
||||||
connection=connection,
|
connection=connection,
|
||||||
|
flush=not self._live_import,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if self.on_stable_evidence is not None and event.cursor.read_to_end:
|
if self.on_stable_evidence is not None and event.cursor.read_to_end:
|
||||||
|
|||||||
@@ -256,24 +256,19 @@ def record_capture_rejection(
|
|||||||
|
|
||||||
|
|
||||||
def _submit_live_memory(store: EvidenceStore, record: EvidenceRecord, *, changed: bool) -> None:
|
def _submit_live_memory(store: EvidenceStore, record: EvidenceRecord, *, changed: bool) -> None:
|
||||||
|
"""Add the record to its conversation's live assembling row.
|
||||||
|
|
||||||
|
The turn rule decides what is written (`turn_batches`): a turn is
|
||||||
|
complete when the next user message arrives, batches are 5 turns or
|
||||||
|
8000 characters, and Core writes the rest after 10 quiet minutes.
|
||||||
|
"""
|
||||||
if not changed or not _sync_open(store.database_path, record.source_id):
|
if not changed or not _sync_open(store.database_path, record.source_id):
|
||||||
return
|
return
|
||||||
from one_memory.conversation_processing import _conversation_chunks
|
|
||||||
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
||||||
from one_memory.memory_server.unified_entry import UnifiedWriteIntake
|
|
||||||
|
|
||||||
intake = UnifiedWriteIntake(DurableWriteQueue(store.database_path, evidence=store))
|
DurableWriteQueue(store.database_path, evidence=store).assemble_live_records(
|
||||||
for chunk in _conversation_chunks(
|
(record,), file_key=f"hook:{record.conversation_id}"
|
||||||
store.list_conversation(record.evidence_id),
|
)
|
||||||
max_turns=12,
|
|
||||||
completed_only=True,
|
|
||||||
):
|
|
||||||
if (
|
|
||||||
record in chunk
|
|
||||||
and any(item.role == "user" for item in chunk)
|
|
||||||
and any(item.role == "assistant" for item in chunk)
|
|
||||||
):
|
|
||||||
intake.submit_records(chunk, priority="live")
|
|
||||||
|
|
||||||
|
|
||||||
def _conversation(payload: dict[str, object]) -> str | None:
|
def _conversation(payload: dict[str, object]) -> str | None:
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ def start_logging_to_file(data_dir: Path) -> Path:
|
|||||||
nowhere. That includes the loop watchdog: the one report that names what
|
nowhere. That includes the loop watchdog: the one report that names what
|
||||||
stalled the app was unreadable in exactly the situation it exists for.
|
stalled the app was unreadable in exactly the situation it exists for.
|
||||||
|
|
||||||
The active diagnostic collection level controls Core, HY SDK, and provider
|
The active diagnostic collection level controls Core and provider client
|
||||||
client records. Uvicorn keeps its request log on its own non-propagating
|
records. The HY SDK keeps its own text log, hy_memory.log under the SDK's
|
||||||
|
log directory, and stops propagating here once its first client is built;
|
||||||
|
`confine_hy_sdk_logging` then forwards only its warnings and errors to
|
||||||
|
this file. Uvicorn keeps its request log on its own non-propagating
|
||||||
loggers.
|
loggers.
|
||||||
"""
|
"""
|
||||||
destination = log_path(data_dir)
|
destination = log_path(data_dir)
|
||||||
@@ -49,3 +52,53 @@ def start_logging_to_file(data_dir: Path) -> Path:
|
|||||||
if root.level > logging.INFO or root.level == logging.NOTSET:
|
if root.level > logging.INFO or root.level == logging.NOTSET:
|
||||||
root.setLevel(logging.INFO)
|
root.setLevel(logging.INFO)
|
||||||
return destination
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
HY_SDK_LOGGER = "hy_memory"
|
||||||
|
|
||||||
|
|
||||||
|
class _CoreLogForwarder(logging.Handler):
|
||||||
|
"""Hands HY warnings to Core's own core.log handler, rotation and format."""
|
||||||
|
|
||||||
|
def __init__(self, target: logging.Handler) -> None:
|
||||||
|
super().__init__(level=logging.WARNING)
|
||||||
|
self.target = target
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
self.target.handle(record)
|
||||||
|
|
||||||
|
|
||||||
|
def confine_hy_sdk_logging() -> None:
|
||||||
|
"""Keep HY's text log in hy_memory.log, with only its problems in core.log.
|
||||||
|
|
||||||
|
HY's first client sets up hy_memory.log plus a stdout and a stderr stream
|
||||||
|
on the "hy_memory" logger, and stops it propagating to the root. Under
|
||||||
|
launchd those streams are service.log and service-error.log, so every SDK
|
||||||
|
line was written twice, and none reached core.log. Drop the streams and
|
||||||
|
forward WARNING and above to core.log, which the Debug panel reads; the
|
||||||
|
SDK keeps conversation text out of those levels.
|
||||||
|
|
||||||
|
Idempotent. Does nothing until HY has configured its logger, because a
|
||||||
|
propagating logger already reaches core.log through the root.
|
||||||
|
"""
|
||||||
|
sdk = logging.getLogger(HY_SDK_LOGGER)
|
||||||
|
if sdk.propagate:
|
||||||
|
return
|
||||||
|
for handler in list(sdk.handlers):
|
||||||
|
if isinstance(handler, logging.StreamHandler) and not isinstance(
|
||||||
|
handler, logging.FileHandler
|
||||||
|
):
|
||||||
|
sdk.removeHandler(handler)
|
||||||
|
if any(isinstance(handler, _CoreLogForwarder) for handler in sdk.handlers):
|
||||||
|
return
|
||||||
|
core = next(
|
||||||
|
(
|
||||||
|
handler
|
||||||
|
for handler in logging.getLogger().handlers
|
||||||
|
if isinstance(handler, RotatingFileHandler)
|
||||||
|
and Path(handler.baseFilename).name == "core.log"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if core is not None:
|
||||||
|
sdk.addHandler(_CoreLogForwarder(core))
|
||||||
@@ -130,10 +130,18 @@ class MemoryAdditionStore:
|
|||||||
raise RuntimeError("sdk_memory_add_intake_unavailable")
|
raise RuntimeError("sdk_memory_add_intake_unavailable")
|
||||||
record = materialize_write_input(value, observed_at=now)
|
record = materialize_write_input(value, observed_at=now)
|
||||||
with main_database(self.write_queue.database_path).read() as database:
|
with main_database(self.write_queue.database_path).read() as database:
|
||||||
|
# No grant row is written here: a missing row means allowed,
|
||||||
|
# only an explicit revocation refuses (same as the claim gate).
|
||||||
|
grant = database.execute(
|
||||||
|
"SELECT revoked_at FROM source_grants WHERE user_id='local' AND source_id=?",
|
||||||
|
(SOURCE_ID,),
|
||||||
|
).fetchone()
|
||||||
existing = database.execute(
|
existing = database.execute(
|
||||||
"SELECT request_id FROM memory_note_requests WHERE note_id=?",
|
"SELECT request_id FROM memory_note_requests WHERE note_id=?",
|
||||||
(record.evidence_id,),
|
(record.evidence_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
if grant is not None and grant[0] is not None:
|
||||||
|
raise ValueError("memory_add_source_revoked")
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
current = self.require(record.evidence_id)
|
current = self.require(record.evidence_id)
|
||||||
if current.verbatim != record.verbatim:
|
if current.verbatim != record.verbatim:
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ from collections.abc import Awaitable, Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from one_memory.background_work import in_the_background
|
from one_memory.background_work import in_the_background
|
||||||
|
from one_memory.conversation_processing import TURN_IDLE_FLUSH_SECONDS, turn_batches
|
||||||
from one_memory.database_access import main_database
|
from one_memory.database_access import main_database
|
||||||
from one_memory.diagnostic_log import diagnostic_event
|
from one_memory.diagnostic_log import diagnostic_event
|
||||||
from one_memory.imports.models import EvidenceOrigin, EvidenceRecord
|
from one_memory.imports.models import EvidenceOrigin, EvidenceRecord
|
||||||
@@ -51,6 +52,7 @@ from one_memory.memory_tasks import (
|
|||||||
from one_memory.memory_tasks import (
|
from one_memory.memory_tasks import (
|
||||||
POLICY_STOPPED_CODES as _TASK_POLICY_STOPPED_CODES,
|
POLICY_STOPPED_CODES as _TASK_POLICY_STOPPED_CODES,
|
||||||
)
|
)
|
||||||
|
from one_memory.sdk_migration_log import error_chain_text
|
||||||
from one_memory.service_levels import ServiceLevel, service_level_context
|
from one_memory.service_levels import ServiceLevel, service_level_context
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -240,14 +242,16 @@ PERMISSION_REQUIRED_CODES = frozenset(
|
|||||||
)
|
)
|
||||||
|
|
||||||
#: Model-side codes the SDK write coordinator raises before it can lease a
|
#: Model-side codes the SDK write coordinator raises before it can lease a
|
||||||
#: client (no active profile, missing credential, secure storage down). They prove
|
#: client (no active profile, missing credential, secure storage down, or the
|
||||||
#: nothing was sent, so the write parks instead of being lost. Transport codes
|
#: client failed to construct). They prove nothing was sent, so the write
|
||||||
#: that can arrive after a request was in flight stay `unknown`.
|
#: parks instead of being lost. Transport codes that can arrive after a
|
||||||
|
#: request was in flight stay `unknown`.
|
||||||
MODEL_NOT_READY_CODES = frozenset(
|
MODEL_NOT_READY_CODES = frozenset(
|
||||||
{
|
{
|
||||||
"model_profile_not_active",
|
"model_profile_not_active",
|
||||||
"model_secret_unavailable",
|
"model_secret_unavailable",
|
||||||
"secure_storage_unavailable",
|
"secure_storage_unavailable",
|
||||||
|
"hy_memory_client_unavailable",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -291,6 +295,23 @@ _VALID_JOB_STATES = frozenset(
|
|||||||
#: A row in one of these states can never be moved back to a live one.
|
#: A row in one of these states can never be moved back to a live one.
|
||||||
_TERMINAL_WRITE_STATES = frozenset({COMPLETED, FAILED, DEAD, CANCELLED})
|
_TERMINAL_WRITE_STATES = frozenset({COMPLETED, FAILED, DEAD, CANCELLED})
|
||||||
|
|
||||||
|
#: The legacy task rows a write job can be linked to. The SDK cutover drops
|
||||||
|
#: them (`target_schema.LEGACY_TABLES_IN_DROP_ORDER`, inputs before tasks), so
|
||||||
|
#: the job-only paths must not assume any of them.
|
||||||
|
_LEGACY_TASK_LINK_TABLES = ("memory_tasks", "memory_task_inputs", "memory_write_job_evidence")
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_task_links_present(connection: sqlite3.Connection) -> bool:
|
||||||
|
present = {
|
||||||
|
str(row[0])
|
||||||
|
for row in connection.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?,?,?)",
|
||||||
|
_LEGACY_TASK_LINK_TABLES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return present == set(_LEGACY_TASK_LINK_TABLES)
|
||||||
|
|
||||||
|
|
||||||
#: Columns a current schema declares but an older `memory_write_jobs` may lack.
|
#: Columns a current schema declares but an older `memory_write_jobs` may lack.
|
||||||
#: `CREATE TABLE IF NOT EXISTS` is a no-op for an existing table, so without
|
#: `CREATE TABLE IF NOT EXISTS` is a no-op for an existing table, so without
|
||||||
#: this an upgraded install would keep an older queue that every claim/lease
|
#: this an upgraded install would keep an older queue that every claim/lease
|
||||||
@@ -894,16 +915,30 @@ class DurableWriteQueue:
|
|||||||
priority: str,
|
priority: str,
|
||||||
connection: sqlite3.Connection,
|
connection: sqlite3.Connection,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Append fresh-install input to an assembling queue row.
|
"""Append input to its conversation's assembling queue row.
|
||||||
|
|
||||||
The row is not claimable until the scanner commits EOF and finalizes
|
The row is not claimable. `finalize_sdk_file` turns what is complete
|
||||||
it. No Evidence table is involved.
|
into write jobs (`turn_batches`) and keeps the rest here for the next
|
||||||
|
increment; `flush_idle_assemblies` writes a live row's rest once the
|
||||||
|
conversation has been quiet. `updated_at` moves only when a record is
|
||||||
|
new or revised, so a re-read does not keep a quiet conversation open.
|
||||||
|
No Evidence table is involved.
|
||||||
"""
|
"""
|
||||||
assembly_key = (
|
assembly_key = (
|
||||||
f"assembly:{record.user_id}:{record.source_id}:"
|
f"assembly:{record.user_id}:{record.source_id}:"
|
||||||
f"{hashlib.sha256(file_key.encode()).hexdigest()}:"
|
f"{hashlib.sha256(file_key.encode()).hexdigest()}:"
|
||||||
f"{hashlib.sha256(record.conversation_id.encode()).hexdigest()}"
|
f"{hashlib.sha256(record.conversation_id.encode()).hexdigest()}"
|
||||||
)
|
)
|
||||||
|
session_key = internal_session_id_for(record.conversation_id, record.source_id)
|
||||||
|
written = connection.execute(
|
||||||
|
"SELECT 1 FROM memory_write_jobs AS job, json_each(job.evidence_ids_json) AS ref "
|
||||||
|
"WHERE job.session_key=? AND job.state!='assembling' AND ref.value=? LIMIT 1",
|
||||||
|
(session_key, record.evidence_id),
|
||||||
|
).fetchone()
|
||||||
|
if written is not None:
|
||||||
|
# A re-read of a record a job already holds: staging it again
|
||||||
|
# would regroup it with later turns into a second job.
|
||||||
|
return
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT job_id,payload_json,priority FROM memory_write_jobs "
|
"SELECT job_id,payload_json,priority FROM memory_write_jobs "
|
||||||
"WHERE idempotency_key=? AND state='assembling'",
|
"WHERE idempotency_key=? AND state='assembling'",
|
||||||
@@ -914,8 +949,20 @@ class DurableWriteQueue:
|
|||||||
payload = json.loads(str(row["payload_json"]))
|
payload = json.loads(str(row["payload_json"]))
|
||||||
records = list(payload.get("input_records") or [])
|
records = list(payload.get("input_records") or [])
|
||||||
dumped = record.model_dump(mode="json")
|
dumped = record.model_dump(mode="json")
|
||||||
if not any(item.get("evidence_id") == record.evidence_id for item in records):
|
index = next(
|
||||||
|
(
|
||||||
|
position
|
||||||
|
for position, item in enumerate(records)
|
||||||
|
if item.get("evidence_id") == record.evidence_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
changed = index is None or records[index] != dumped
|
||||||
|
if index is None:
|
||||||
records.append(dumped)
|
records.append(dumped)
|
||||||
|
elif changed:
|
||||||
|
# A revision replaces the carried copy in place.
|
||||||
|
records[index] = dumped
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
payload_json = json.dumps(
|
payload_json = json.dumps(
|
||||||
{
|
{
|
||||||
@@ -926,15 +973,13 @@ class DurableWriteQueue:
|
|||||||
sort_keys=True,
|
sort_keys=True,
|
||||||
)
|
)
|
||||||
if row is not None:
|
if row is not None:
|
||||||
|
merged_priority = "live" if "live" in (row["priority"], priority) else "normal"
|
||||||
|
if not changed and merged_priority == row["priority"]:
|
||||||
|
return
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE memory_write_jobs SET payload_json=?,priority=?,updated_at=? "
|
"UPDATE memory_write_jobs SET payload_json=?,priority=?,"
|
||||||
"WHERE job_id=?",
|
"updated_at=CASE WHEN ? THEN ? ELSE updated_at END WHERE job_id=?",
|
||||||
(
|
(payload_json, merged_priority, int(changed), now, str(row["job_id"])),
|
||||||
payload_json,
|
|
||||||
"live" if row["priority"] == "live" or priority == "live" else "normal",
|
|
||||||
now,
|
|
||||||
str(row["job_id"]),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
queue_seq = int(
|
queue_seq = int(
|
||||||
@@ -952,7 +997,7 @@ class DurableWriteQueue:
|
|||||||
(
|
(
|
||||||
str(uuid4()),
|
str(uuid4()),
|
||||||
queue_seq,
|
queue_seq,
|
||||||
internal_session_id_for(record.conversation_id, record.source_id),
|
session_key,
|
||||||
record.source_id,
|
record.source_id,
|
||||||
assembly_key,
|
assembly_key,
|
||||||
assembly_key,
|
assembly_key,
|
||||||
@@ -982,46 +1027,148 @@ class DurableWriteQueue:
|
|||||||
source_id: str,
|
source_id: str,
|
||||||
file_key: str,
|
file_key: str,
|
||||||
connection: sqlite3.Connection,
|
connection: sqlite3.Connection,
|
||||||
|
flush: bool = True,
|
||||||
) -> tuple[str, ...]:
|
) -> tuple[str, ...]:
|
||||||
|
"""Write what one file's assembling rows hold that the turn rule completes.
|
||||||
|
|
||||||
|
`flush` (history at end of file) completes the last turn and writes a
|
||||||
|
short batch; without it (live polling) those stay in the row.
|
||||||
|
"""
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"SELECT * FROM memory_write_jobs WHERE state='assembling' "
|
"SELECT * FROM memory_write_jobs WHERE state='assembling' "
|
||||||
"AND source_key=? ORDER BY queue_seq",
|
"AND source_key=? ORDER BY queue_seq",
|
||||||
(source_id,),
|
(source_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
finalized = []
|
finalized: list[str] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
payload = json.loads(str(row["payload_json"]))
|
payload = json.loads(str(row["payload_json"]))
|
||||||
if payload.get("file_key") != file_key:
|
if payload.get("file_key") != file_key:
|
||||||
continue
|
continue
|
||||||
|
finalized.extend(
|
||||||
|
self._write_assembled_turns(connection, row, user_id=user_id, flush=flush)
|
||||||
|
)
|
||||||
|
return tuple(finalized)
|
||||||
|
|
||||||
|
def assemble_live_records(
|
||||||
|
self,
|
||||||
|
records: tuple[EvidenceRecord, ...],
|
||||||
|
*,
|
||||||
|
file_key: str,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""Stage live records and write what is complete, in one transaction.
|
||||||
|
|
||||||
|
For live capture outside the import runner (host hooks): the same
|
||||||
|
assembling rows, turn rule and idle flush as live file polling.
|
||||||
|
"""
|
||||||
|
if not records:
|
||||||
|
return ()
|
||||||
|
first = records[0]
|
||||||
|
with (
|
||||||
|
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
|
||||||
|
connection,
|
||||||
|
):
|
||||||
|
for record in records:
|
||||||
|
self.stage_sdk_input(
|
||||||
|
record, file_key=file_key, priority="live", connection=connection
|
||||||
|
)
|
||||||
|
return self.finalize_sdk_file(
|
||||||
|
user_id=first.user_id,
|
||||||
|
source_id=first.source_id,
|
||||||
|
file_key=file_key,
|
||||||
|
connection=connection,
|
||||||
|
flush=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def flush_idle_assemblies(self, *, now: datetime | None = None) -> tuple[str, ...]:
|
||||||
|
"""Write the rest of every live conversation quiet for 10 minutes.
|
||||||
|
|
||||||
|
Its last turn counts as complete and a batch short of 5 turns is
|
||||||
|
written. History rows are left alone: their end of file flushes them.
|
||||||
|
"""
|
||||||
|
current = now or datetime.now(UTC)
|
||||||
|
cutoff = (current - timedelta(seconds=TURN_IDLE_FLUSH_SECONDS)).isoformat()
|
||||||
|
query = (
|
||||||
|
"SELECT * FROM memory_write_jobs WHERE state='assembling' AND priority='live' "
|
||||||
|
"AND updated_at<=? ORDER BY queue_seq"
|
||||||
|
)
|
||||||
|
# A read first: most ticks find nothing and must not take the writer.
|
||||||
|
with main_database(self.database_path).read(row_factory=sqlite3.Row) as connection:
|
||||||
|
if connection.execute(query, (cutoff,)).fetchone() is None:
|
||||||
|
return ()
|
||||||
|
finalized: list[str] = []
|
||||||
|
with (
|
||||||
|
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
|
||||||
|
connection,
|
||||||
|
):
|
||||||
|
for row in connection.execute(query, (cutoff,)).fetchall():
|
||||||
|
finalized.extend(self._write_assembled_turns(connection, row, flush=True))
|
||||||
|
return tuple(finalized)
|
||||||
|
|
||||||
|
def _write_assembled_turns(
|
||||||
|
self,
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
row: sqlite3.Row,
|
||||||
|
*,
|
||||||
|
flush: bool,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Turn one assembling row's complete batches into jobs; keep the carry."""
|
||||||
|
payload = json.loads(str(row["payload_json"]))
|
||||||
records = tuple(
|
records = tuple(
|
||||||
EvidenceRecord.model_validate(item)
|
EvidenceRecord.model_validate(item)
|
||||||
for item in payload.get("input_records") or []
|
for item in payload.get("input_records") or []
|
||||||
if item.get("user_id") == user_id
|
if user_id is None or item.get("user_id") == user_id
|
||||||
)
|
)
|
||||||
if not records:
|
grouped = turn_batches(records, flush=flush)
|
||||||
|
priority: Literal["live", "normal"] = "live" if str(row["priority"]) == "live" else "normal"
|
||||||
|
job_ids = [
|
||||||
|
self._insert_turn_batch(connection, batch, priority=priority)
|
||||||
|
for batch in grouped.batches
|
||||||
|
]
|
||||||
|
if not grouped.carry:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"DELETE FROM memory_write_jobs WHERE job_id=?",
|
"DELETE FROM memory_write_jobs WHERE job_id=?", (str(row["job_id"]),)
|
||||||
(str(row["job_id"]),),
|
|
||||||
)
|
)
|
||||||
continue
|
elif grouped.batches or len(grouped.carry) != len(records):
|
||||||
|
# `updated_at` stays: the idle clock counts new messages only.
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE memory_write_jobs SET payload_json=? WHERE job_id=?",
|
||||||
|
(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
**payload,
|
||||||
|
"input_records": [
|
||||||
|
record.model_dump(mode="json") for record in grouped.carry
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
),
|
||||||
|
str(row["job_id"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return job_ids
|
||||||
|
|
||||||
|
def _insert_turn_batch(
|
||||||
|
self,
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
records: tuple[EvidenceRecord, ...],
|
||||||
|
*,
|
||||||
|
priority: Literal["live", "normal"],
|
||||||
|
) -> str:
|
||||||
|
"""One pending job for one batch of whole turns, keyed by its content.
|
||||||
|
|
||||||
|
Same records, same key: a re-read returns the job already written.
|
||||||
|
"""
|
||||||
first = records[0]
|
first = records[0]
|
||||||
session_id = internal_session_id_for(
|
session_id = internal_session_id_for(first.conversation_id, first.source_id)
|
||||||
first.conversation_id,
|
|
||||||
first.source_id,
|
|
||||||
)
|
|
||||||
material = ":".join(f"{item.evidence_id}:{item.content_hash}" for item in records)
|
material = ":".join(f"{item.evidence_id}:{item.content_hash}" for item in records)
|
||||||
request_id = f"turn:v2:{hashlib.sha256(material.encode()).hexdigest()}"
|
request_id = f"turn:v2:{hashlib.sha256(material.encode()).hexdigest()}"
|
||||||
existing = connection.execute(
|
existing = connection.execute(
|
||||||
"SELECT job_id FROM memory_write_jobs WHERE idempotency_key=? AND job_id!=?",
|
"SELECT job_id FROM memory_write_jobs WHERE idempotency_key=?",
|
||||||
(request_id, str(row["job_id"])),
|
(request_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
connection.execute(
|
return str(existing["job_id"])
|
||||||
"DELETE FROM memory_write_jobs WHERE job_id=?",
|
|
||||||
(str(row["job_id"]),),
|
|
||||||
)
|
|
||||||
finalized.append(str(existing["job_id"]))
|
|
||||||
continue
|
|
||||||
request = WriteRequest(
|
request = WriteRequest(
|
||||||
internal_session_id=session_id,
|
internal_session_id=session_id,
|
||||||
source_id=first.source_id,
|
source_id=first.source_id,
|
||||||
@@ -1033,41 +1180,62 @@ class DurableWriteQueue:
|
|||||||
evidence_ids=[item.evidence_id for item in records],
|
evidence_ids=[item.evidence_id for item in records],
|
||||||
input_records=records,
|
input_records=records,
|
||||||
idempotency_key=request_id,
|
idempotency_key=request_id,
|
||||||
priority="live" if str(row["priority"]) == "live" else "normal",
|
priority=priority,
|
||||||
event_at=max(
|
|
||||||
(item.created_at for item in records if item.created_at),
|
|
||||||
default=first.imported_at,
|
|
||||||
),
|
|
||||||
time_quality="exact" if any(item.created_at for item in records) else "approximate",
|
|
||||||
)
|
)
|
||||||
|
# The batch's time is its first user message's, as for every other job.
|
||||||
|
event_at, time_quality = self._event_time_for_request(request)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
job_id = str(uuid4())
|
||||||
|
queue_seq = int(
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE memory_write_jobs SET session_key=?,batch_id=?,"
|
"SELECT COALESCE(MAX(queue_seq),0)+1 FROM memory_write_jobs"
|
||||||
"idempotency_key=?,payload_json=?,evidence_ids_json=?,state='pending',"
|
).fetchone()[0]
|
||||||
"event_at=?,time_quality=?,updated_at=? WHERE job_id=?",
|
)
|
||||||
|
event = event_at or now
|
||||||
|
if event.tzinfo is None:
|
||||||
|
event = event.replace(tzinfo=UTC)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO memory_write_jobs ("
|
||||||
|
"job_id,queue_seq,session_key,source_key,batch_id,idempotency_key,"
|
||||||
|
"payload_json,evidence_ids_json,state,priority,attempts,max_attempts,"
|
||||||
|
"lease_owner,lease_expires_at,hy_invoked,last_error,event_at,time_quality,"
|
||||||
|
"created_at,updated_at,sdk_raw_ids_json,sdk_memory_ids_json"
|
||||||
|
") VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
(
|
(
|
||||||
|
job_id,
|
||||||
|
queue_seq,
|
||||||
session_id,
|
session_id,
|
||||||
|
first.source_id,
|
||||||
request.batch_identity.batch_id,
|
request.batch_identity.batch_id,
|
||||||
request_id,
|
request_id,
|
||||||
json.dumps(
|
json.dumps(
|
||||||
request.model_dump(
|
request.model_dump(mode="json", exclude={"event_at", "time_quality"}),
|
||||||
mode="json",
|
|
||||||
exclude={"event_at", "time_quality"},
|
|
||||||
),
|
|
||||||
sort_keys=True,
|
sort_keys=True,
|
||||||
),
|
),
|
||||||
json.dumps(list(request.evidence_ids)),
|
json.dumps(list(request.evidence_ids)),
|
||||||
(
|
PENDING,
|
||||||
request.event_at.isoformat()
|
priority,
|
||||||
if request.event_at
|
0,
|
||||||
else datetime.now(UTC).isoformat()
|
3,
|
||||||
),
|
None,
|
||||||
request.time_quality,
|
None,
|
||||||
datetime.now(UTC).isoformat(),
|
0,
|
||||||
str(row["job_id"]),
|
None,
|
||||||
|
event.astimezone(UTC).isoformat(),
|
||||||
|
time_quality,
|
||||||
|
now.isoformat(),
|
||||||
|
now.isoformat(),
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
finalized.append(str(row["job_id"]))
|
if not self.sdk_authority:
|
||||||
return tuple(finalized)
|
# Legacy catch-up reads coverage from these references.
|
||||||
|
connection.executemany(
|
||||||
|
"INSERT INTO memory_write_job_evidence (job_id, evidence_id) VALUES (?,?)",
|
||||||
|
((job_id, evidence_id) for evidence_id in request.evidence_ids),
|
||||||
|
)
|
||||||
|
return job_id
|
||||||
|
|
||||||
def _freeze_request_inputs(self, request: WriteRequest) -> WriteRequest:
|
def _freeze_request_inputs(self, request: WriteRequest) -> WriteRequest:
|
||||||
if request.input_records or self.evidence is None:
|
if request.input_records or self.evidence is None:
|
||||||
@@ -1834,6 +2002,9 @@ class DurableWriteQueue:
|
|||||||
or str(row["last_error"]) in NON_RETRYABLE_POST_WRITE_CODES
|
or str(row["last_error"]) in NON_RETRYABLE_POST_WRITE_CODES
|
||||||
):
|
):
|
||||||
return 0
|
return 0
|
||||||
|
# After the SDK cutover the legacy task tables are gone and the
|
||||||
|
# job is the only row to reset.
|
||||||
|
if not self.sdk_authority and _legacy_task_links_present(connection):
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE memory_tasks
|
UPDATE memory_tasks
|
||||||
@@ -1950,9 +2121,7 @@ class DurableWriteQueue:
|
|||||||
f"WHERE state=? AND job_id IN ({placeholders})",
|
f"WHERE state=? AND job_id IN ({placeholders})",
|
||||||
(PENDING, now, UNKNOWN, *job_ids),
|
(PENDING, now, UNKNOWN, *job_ids),
|
||||||
)
|
)
|
||||||
if connection.execute(
|
if _legacy_task_links_present(connection):
|
||||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='memory_tasks'"
|
|
||||||
).fetchone():
|
|
||||||
connection.execute(
|
connection.execute(
|
||||||
f"UPDATE memory_tasks SET status=?, runner_token=NULL, "
|
f"UPDATE memory_tasks SET status=?, runner_token=NULL, "
|
||||||
f"last_error_code='model_content_reconciled_absent_retry', updated_at=? "
|
f"last_error_code='model_content_reconciled_absent_retry', updated_at=? "
|
||||||
@@ -2010,9 +2179,7 @@ class DurableWriteQueue:
|
|||||||
f"WHERE state=? AND attempts=1 AND job_id IN ({placeholders})",
|
f"WHERE state=? AND attempts=1 AND job_id IN ({placeholders})",
|
||||||
(PENDING, now, UNKNOWN, *job_ids),
|
(PENDING, now, UNKNOWN, *job_ids),
|
||||||
)
|
)
|
||||||
if connection.execute(
|
if _legacy_task_links_present(connection):
|
||||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='memory_tasks'"
|
|
||||||
).fetchone():
|
|
||||||
connection.execute(
|
connection.execute(
|
||||||
f"UPDATE memory_tasks SET status=?, runner_token=NULL, "
|
f"UPDATE memory_tasks SET status=?, runner_token=NULL, "
|
||||||
f"last_error_code='model_content_unknown_auto_retry', updated_at=? "
|
f"last_error_code='model_content_unknown_auto_retry', updated_at=? "
|
||||||
@@ -2029,6 +2196,14 @@ class DurableWriteQueue:
|
|||||||
def resolve_unknown_from_linked_output(self, job_id: str) -> bool:
|
def resolve_unknown_from_linked_output(self, job_id: str) -> bool:
|
||||||
"""Close an unknown durable row when local governance output proves success."""
|
"""Close an unknown durable row when local governance output proves success."""
|
||||||
with main_database(self.database_path).read(row_factory=sqlite3.Row) as connection:
|
with main_database(self.database_path).read(row_factory=sqlite3.Row) as connection:
|
||||||
|
if not (
|
||||||
|
_legacy_task_links_present(connection)
|
||||||
|
and connection.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='memory_task_outputs'"
|
||||||
|
).fetchone()
|
||||||
|
):
|
||||||
|
# No legacy governance output survives the SDK cutover.
|
||||||
|
return False
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"""
|
"""
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -2411,9 +2586,22 @@ async def execute_job(
|
|||||||
if code == "model_profile_not_active":
|
if code == "model_profile_not_active":
|
||||||
logger.debug("durable_write_job_parked job_id=%s error_code=%s", job_id, code)
|
logger.debug("durable_write_job_parked job_id=%s error_code=%s", job_id, code)
|
||||||
elif state in PARKED_STATES:
|
elif state in PARKED_STATES:
|
||||||
logger.info("durable_write_job_parked job_id=%s error_code=%s", job_id, code)
|
logger.info(
|
||||||
|
"durable_write_job_parked job_id=%s error_code=%s chain=%s",
|
||||||
|
job_id,
|
||||||
|
code,
|
||||||
|
error_chain_text(error),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.error("durable_write_job_failed job_id=%s error_code=%s", job_id, code)
|
# Where it failed, as `file:line:function` frames of the exception
|
||||||
|
# chain, never its message: `error_code=AttributeError` alone
|
||||||
|
# named no location.
|
||||||
|
logger.error(
|
||||||
|
"durable_write_job_failed job_id=%s error_code=%s chain=%s",
|
||||||
|
job_id,
|
||||||
|
code,
|
||||||
|
error_chain_text(error),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
await in_the_background(
|
await in_the_background(
|
||||||
queue.settle,
|
queue.settle,
|
||||||
@@ -2500,6 +2688,9 @@ class DurableWriteDrain:
|
|||||||
#: a model outage clears on its own, and a queue that never goes idle
|
#: a model outage clears on its own, and a queue that never goes idle
|
||||||
#: must still re-admit the write once the wait has elapsed.
|
#: must still re-admit the write once the wait has elapsed.
|
||||||
self._last_model_readmit: datetime | None = None
|
self._last_model_readmit: datetime | None = None
|
||||||
|
#: A live conversation quiet for 10 minutes has its last turn and short
|
||||||
|
#: batch written (`flush_idle_assemblies`); checked on the same timer.
|
||||||
|
self._last_assembly_flush: datetime | None = None
|
||||||
#: Idle polls do not need to hit the single writer every 50ms. A
|
#: Idle polls do not need to hit the single writer every 50ms. A
|
||||||
#: 1m Evidence table makes a miss expensive enough that the tail
|
#: 1m Evidence table makes a miss expensive enough that the tail
|
||||||
#: call-event write can be behind it; back off only while idle and
|
#: call-event write can be behind it; back off only while idle and
|
||||||
@@ -3032,6 +3223,15 @@ class DurableWriteDrain:
|
|||||||
self.queue.readmit_model_waiters,
|
self.queue.readmit_model_waiters,
|
||||||
operation_name="readmit_model_waiters",
|
operation_name="readmit_model_waiters",
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
self._last_assembly_flush is None
|
||||||
|
or tick_started - self._last_assembly_flush >= self._reap_interval
|
||||||
|
):
|
||||||
|
self._last_assembly_flush = tick_started
|
||||||
|
await self._run_idle_maintenance(
|
||||||
|
self.queue.flush_idle_assemblies,
|
||||||
|
operation_name="flush_idle_assemblies",
|
||||||
|
)
|
||||||
if self.reconcile_unknown is not None and (
|
if self.reconcile_unknown is not None and (
|
||||||
self._last_unknown_reconcile is None
|
self._last_unknown_reconcile is None
|
||||||
or tick_started - self._last_unknown_reconcile
|
or tick_started - self._last_unknown_reconcile
|
||||||
|
|||||||
@@ -1136,20 +1136,31 @@ class ModelProfileStore:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def disable_thinking_params(
|
||||||
|
profile: ModelProfile | ModelProfileSnapshot,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Provider-specific thinking-off params, empty unless the profile opts in.
|
||||||
|
|
||||||
|
The one mapping for every request Core builds itself and for the SDK's
|
||||||
|
`llm.extra_body`, so a model that thinks on one path thinks on none.
|
||||||
|
"""
|
||||||
|
if not profile.disable_thinking:
|
||||||
|
return {}
|
||||||
|
if profile.provider_kind is ProviderKind.OPENAI_COMPATIBLE:
|
||||||
|
if urlparse(profile.base_url or "").hostname == "api.deepseek.com":
|
||||||
|
return {"thinking": {"type": "disabled"}}
|
||||||
|
return {"reasoning_effort": "none"}
|
||||||
|
if profile.provider_kind is ProviderKind.OLLAMA:
|
||||||
|
return {"think": False}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def attach_disable_thinking(
|
def attach_disable_thinking(
|
||||||
profile: ModelProfile | ModelProfileSnapshot,
|
profile: ModelProfile | ModelProfileSnapshot,
|
||||||
payload: dict[str, object],
|
payload: dict[str, object],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Attach provider-specific thinking-off params when the profile opts in."""
|
"""Attach provider-specific thinking-off params when the profile opts in."""
|
||||||
if not profile.disable_thinking:
|
payload.update(disable_thinking_params(profile))
|
||||||
return payload
|
|
||||||
if profile.provider_kind is ProviderKind.OPENAI_COMPATIBLE:
|
|
||||||
if urlparse(profile.base_url or "").hostname == "api.deepseek.com":
|
|
||||||
payload["thinking"] = {"type": "disabled"}
|
|
||||||
else:
|
|
||||||
payload["reasoning_effort"] = "none"
|
|
||||||
elif profile.provider_kind is ProviderKind.OLLAMA:
|
|
||||||
payload["think"] = False
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from contextlib import suppress
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -164,8 +165,9 @@ class SdkMemoryRuntime:
|
|||||||
if str(error) == "model_profile_not_active":
|
if str(error) == "model_profile_not_active":
|
||||||
return HyGraphSnapshot(consulted=False)
|
return HyGraphSnapshot(consulted=False)
|
||||||
raise
|
raise
|
||||||
|
async with AsyncExitStack() as stack:
|
||||||
try:
|
try:
|
||||||
client = await self.coordinator.client()
|
client = await stack.enter_async_context(self.coordinator.use_client())
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
raise HySdkError("hy_memory_graph_read_failed") from error
|
raise HySdkError("hy_memory_graph_read_failed") from error
|
||||||
return await asyncio.to_thread(
|
return await asyncio.to_thread(
|
||||||
@@ -177,7 +179,7 @@ class SdkMemoryRuntime:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def digest(self) -> dict[str, object]:
|
async def digest(self) -> dict[str, object]:
|
||||||
client = await self.coordinator.client()
|
async with self.coordinator.use_client() as client:
|
||||||
try:
|
try:
|
||||||
raw = await asyncio.to_thread(
|
raw = await asyncio.to_thread(
|
||||||
client.list_memories,
|
client.list_memories,
|
||||||
@@ -240,22 +242,25 @@ class SdkMemoryRuntime:
|
|||||||
decision=decision,
|
decision=decision,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _mutation_client(self) -> Any:
|
@asynccontextmanager
|
||||||
|
async def _mutation_client(self) -> AsyncIterator[Any]:
|
||||||
"""The write client, with no-model and keychain refusals as SDK codes.
|
"""The write client, with no-model and keychain refusals as SDK codes.
|
||||||
|
|
||||||
Callers record these against the edit or deletion instead of failing
|
Callers record these against the edit or deletion instead of failing
|
||||||
the request that already committed it.
|
the request that already committed it.
|
||||||
"""
|
"""
|
||||||
|
async with AsyncExitStack() as stack:
|
||||||
try:
|
try:
|
||||||
return await self.coordinator.client()
|
client = await stack.enter_async_context(self.coordinator.use_client())
|
||||||
except RuntimeError as error:
|
except RuntimeError as error:
|
||||||
code = str(error)
|
code = str(error)
|
||||||
if not _STABLE_CODE.fullmatch(code):
|
if not _STABLE_CODE.fullmatch(code):
|
||||||
code = "hy_memory_client_unavailable"
|
code = "hy_memory_client_unavailable"
|
||||||
raise HySdkError(code) from error
|
raise HySdkError(code) from error
|
||||||
|
yield client
|
||||||
|
|
||||||
async def update_hy_memory(self, memory_id: str, content: str) -> dict[str, object]:
|
async def update_hy_memory(self, memory_id: str, content: str) -> dict[str, object]:
|
||||||
client = await self._mutation_client()
|
async with self._mutation_client() as client:
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(client.update, memory_id, content)
|
result = await asyncio.to_thread(client.update, memory_id, content)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
@@ -265,7 +270,7 @@ class SdkMemoryRuntime:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
async def finish_deletion(self, memory_id: str) -> None:
|
async def finish_deletion(self, memory_id: str) -> None:
|
||||||
client = await self._mutation_client()
|
async with self._mutation_client() as client:
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(client.delete, memory_id)
|
result = await asyncio.to_thread(client.delete, memory_id)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
|||||||
@@ -125,6 +125,31 @@ def describe_error(error: BaseException) -> dict[str, object]:
|
|||||||
return {"chain": chain}
|
return {"chain": chain}
|
||||||
|
|
||||||
|
|
||||||
|
_PY_FRAME = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*\.py:\d{1,7}:[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
|
||||||
|
|
||||||
|
def error_frames(error: BaseException, *, limit: int = 16) -> tuple[str, ...]:
|
||||||
|
"""The raised error's innermost `file.py:line:function` frames, for a stack field.
|
||||||
|
|
||||||
|
Library frames are kept (a KeyError raised inside Chroma is located by
|
||||||
|
them); `<module>`-style names are dropped because stack fields refuse them.
|
||||||
|
"""
|
||||||
|
chain = describe_error(error)["chain"]
|
||||||
|
frames = chain[0].get("frames", ()) if chain else () # type: ignore[index,union-attr]
|
||||||
|
return tuple(frame for frame in frames if _PY_FRAME.fullmatch(frame))[-limit:]
|
||||||
|
|
||||||
|
|
||||||
|
def error_chain_text(error: BaseException) -> str:
|
||||||
|
"""`describe_error` as one log token: `Type@file:line:function>...;Cause@...`.
|
||||||
|
|
||||||
|
For a plain text log (core.log). Types and frames only, never a message.
|
||||||
|
"""
|
||||||
|
return ";".join(
|
||||||
|
f"{entry['type']}@{'>'.join(entry.get('frames', ()))}" # type: ignore[arg-type]
|
||||||
|
for entry in describe_error(error)["chain"] # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build() -> dict[str, object]:
|
def _build() -> dict[str, object]:
|
||||||
from one_memory import __version__
|
from one_memory import __version__
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ from one_memory.memory_server.durable_queue import (
|
|||||||
COMPLETED,
|
COMPLETED,
|
||||||
FAILED,
|
FAILED,
|
||||||
REAPED_PAYLOAD_MARKER,
|
REAPED_PAYLOAD_MARKER,
|
||||||
|
RUNNING,
|
||||||
|
UNKNOWN,
|
||||||
DurableWriteQueue,
|
DurableWriteQueue,
|
||||||
)
|
)
|
||||||
from one_memory.memory_server.unified_entry import UnifiedWriteIntake
|
from one_memory.memory_server.unified_entry import UnifiedWriteIntake
|
||||||
@@ -317,11 +319,45 @@ def move_pending_dispatches(database_path: Path) -> int:
|
|||||||
return len(conversations)
|
return len(conversations)
|
||||||
|
|
||||||
|
|
||||||
|
#: Why a pre-cutover write job that may have reached HY was closed.
|
||||||
|
ABANDONED_AT_CUTOVER = "abandoned_at_cutover"
|
||||||
|
|
||||||
|
|
||||||
|
def settle_legacy_unknown_write_jobs(database_path: Path) -> int:
|
||||||
|
"""Close write jobs the legacy pipeline left possibly-sent. Safe to repeat.
|
||||||
|
|
||||||
|
They ran under the legacy pipeline, so the SDK's `get_write_outcome` knows
|
||||||
|
none of them and the unknown reconcile can never settle them: a migrated
|
||||||
|
1.0.4 home showed 1000+ unknown forever. They are not retried, since they
|
||||||
|
may already have written and the SDK has no idempotency key. `cancelled`,
|
||||||
|
not `failed`: the upgrade ended them, nothing broke, and a `failed` count
|
||||||
|
would put the home in the failed state for good. `hy_invoked` is kept, so
|
||||||
|
no readmission path can pick one up again.
|
||||||
|
"""
|
||||||
|
with main_database(database_path).read(row_factory=sqlite3.Row) as database:
|
||||||
|
if "memory_write_jobs" not in _tables(database):
|
||||||
|
return 0
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
with main_database(database_path).write(autocommit=True) as database:
|
||||||
|
# A `running` row already marked invoked would be reclaimed to
|
||||||
|
# `unknown` on the next open, so it is the same case.
|
||||||
|
return int(
|
||||||
|
database.execute(
|
||||||
|
"UPDATE memory_write_jobs SET state=?,lease_owner=NULL,"
|
||||||
|
"lease_expires_at=NULL,last_error=?,updated_at=? "
|
||||||
|
"WHERE state=? OR (state=? AND hy_invoked=1)",
|
||||||
|
(CANCELLED, ABANDONED_AT_CUTOVER, now, UNKNOWN, RUNNING),
|
||||||
|
).rowcount
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def abandoned_legacy_work(database_path: Path) -> dict[str, int]:
|
def abandoned_legacy_work(database_path: Path) -> dict[str, int]:
|
||||||
"""Count legacy work that ends with the cutover instead of blocking it.
|
"""Count legacy work that ends with the cutover instead of blocking it.
|
||||||
|
|
||||||
A task that may have reached the SDK is not replayed: the SDK already holds
|
A task that may have reached the SDK is not replayed: the SDK already holds
|
||||||
whatever it committed. Pending user deletions are not carried over.
|
whatever it committed. Pending user deletions are not carried over.
|
||||||
|
Settled write jobs are counted by their code, so a repeated preparation
|
||||||
|
reports the same number.
|
||||||
"""
|
"""
|
||||||
abandoned: dict[str, int] = {}
|
abandoned: dict[str, int] = {}
|
||||||
with main_database(database_path).read(row_factory=sqlite3.Row) as database:
|
with main_database(database_path).read(row_factory=sqlite3.Row) as database:
|
||||||
@@ -332,6 +368,11 @@ def abandoned_legacy_work(database_path: Path) -> dict[str, int]:
|
|||||||
"memory_tasks",
|
"memory_tasks",
|
||||||
"status='unknown' OR (status='running' AND hy_invocation_count > 0)",
|
"status='unknown' OR (status='running' AND hy_invocation_count > 0)",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"unknown_write_jobs",
|
||||||
|
"memory_write_jobs",
|
||||||
|
f"state='{CANCELLED}' AND last_error='{ABANDONED_AT_CUTOVER}'",
|
||||||
|
),
|
||||||
("deletions", "memory_deletions", "status IN ('pending','running')"),
|
("deletions", "memory_deletions", "status IN ('pending','running')"),
|
||||||
)
|
)
|
||||||
for name, table, where in checks:
|
for name, table, where in checks:
|
||||||
@@ -354,6 +395,10 @@ def prepare_legacy_work_for_cutover(
|
|||||||
running, so a failure names it.
|
running, so a failure names it.
|
||||||
"""
|
"""
|
||||||
trace = {} if trace is None else trace
|
trace = {} if trace is None else trace
|
||||||
|
# First: a possibly-sent job never runs again, so its input is not
|
||||||
|
# frozen, and input released since must not block the cutover.
|
||||||
|
trace["sub_step"] = "unknown_write_jobs"
|
||||||
|
settle_legacy_unknown_write_jobs(database_path)
|
||||||
trace["sub_step"] = "write_queue"
|
trace["sub_step"] = "write_queue"
|
||||||
frozen, moved = prepare_legacy_write_queue_for_cutover(database_path)
|
frozen, moved = prepare_legacy_write_queue_for_cutover(database_path)
|
||||||
trace["sub_step"] = "pending_dispatches"
|
trace["sub_step"] = "pending_dispatches"
|
||||||
@@ -829,6 +874,7 @@ def run_startup_migration(
|
|||||||
ignore_errors=True,
|
ignore_errors=True,
|
||||||
)
|
)
|
||||||
discard_failed_state(data_dir)
|
discard_failed_state(data_dir)
|
||||||
|
_repair_metadata_after_cutover(data_dir, database_path, log)
|
||||||
progress("cleanup")
|
progress("cleanup")
|
||||||
with log.step("cleanup.drop_legacy_tables") as step:
|
with log.step("cleanup.drop_legacy_tables") as step:
|
||||||
dropped = resume_legacy_cleanup(data_dir=data_dir, database_path=database_path)
|
dropped = resume_legacy_cleanup(data_dir=data_dir, database_path=database_path)
|
||||||
@@ -847,6 +893,25 @@ def run_startup_migration(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_metadata_after_cutover(data_dir: Path, database_path: Path, log: _StepLog) -> None:
|
||||||
|
"""Restore lost metadata here, where Runtime is stopped and no Chroma client is open.
|
||||||
|
|
||||||
|
Writes the repair receipt, so Core's startup fallback finds it and does
|
||||||
|
nothing. Authority is already committed: a failure is logged and left to
|
||||||
|
that fallback instead of failing the migration.
|
||||||
|
"""
|
||||||
|
from one_memory.hy_sdk_migration import repair_sdk_metadata_from_bundle
|
||||||
|
|
||||||
|
try:
|
||||||
|
with log.step("cutover.metadata_repair") as step:
|
||||||
|
repair = repair_sdk_metadata_from_bundle(data_dir=data_dir, database_path=database_path)
|
||||||
|
step["mapped"] = repair.mapped
|
||||||
|
step["vector_rows"] = repair.vector_rows
|
||||||
|
step["coding_rows"] = repair.coding_rows
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _restore_after_failure(
|
def _restore_after_failure(
|
||||||
*,
|
*,
|
||||||
data_dir: Path,
|
data_dir: Path,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -16,6 +17,7 @@ from one_memory.secrets import SecretStore, SecretStoreUnavailableError
|
|||||||
|
|
||||||
PRODUCT_AGENT_ID = "all-apps"
|
PRODUCT_AGENT_ID = "all-apps"
|
||||||
_MESSAGE_ROLES = frozenset({"user", "assistant", "tool"})
|
_MESSAGE_ROLES = frozenset({"user", "assistant", "tool"})
|
||||||
|
_CONVERSATION_ROLES = frozenset({"user", "assistant"})
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -41,12 +43,28 @@ class SdkWriteProcessor:
|
|||||||
) -> SdkWriteReceipt:
|
) -> SdkWriteReceipt:
|
||||||
if not records:
|
if not records:
|
||||||
raise RuntimeError("hy_memory_task_without_inputs")
|
raise RuntimeError("hy_memory_task_without_inputs")
|
||||||
|
extracted = all(_is_extracted_content(record) for record in records)
|
||||||
|
if not extracted and not any(
|
||||||
|
record.content_kind is EvidenceContentKind.RAW_CONVERSATION
|
||||||
|
and record.role in _CONVERSATION_ROLES
|
||||||
|
for record in records
|
||||||
|
):
|
||||||
|
# Nothing to remember: tool records alone. The SDK strips them on
|
||||||
|
# the chat path and answers 400 "content or messages is
|
||||||
|
# required", which used to settle the job as unknown. Completed
|
||||||
|
# with no memories, and the SDK is never called.
|
||||||
|
return SdkWriteReceipt()
|
||||||
|
|
||||||
scope = _write_scope(records)
|
scope = _write_scope(records)
|
||||||
metadata = _write_metadata(records, scope)
|
metadata = _write_metadata(records, scope)
|
||||||
|
try:
|
||||||
client = await self._client_handle.client()
|
client = await self._client_handle.client()
|
||||||
|
except Exception as error:
|
||||||
|
# Before `before_hy_invocation`: nothing was sent, so the job
|
||||||
|
# waits and retries instead of being left `unknown`.
|
||||||
|
raise RuntimeError("hy_memory_client_unavailable") from error
|
||||||
|
|
||||||
if all(_is_extracted_content(record) for record in records):
|
if extracted:
|
||||||
contents = [sanitize_model_input(record.verbatim) for record in records]
|
contents = [sanitize_model_input(record.verbatim) for record in records]
|
||||||
await before_hy_invocation()
|
await before_hy_invocation()
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
@@ -113,7 +131,14 @@ class SdkWriteProcessor:
|
|||||||
|
|
||||||
|
|
||||||
class SdkWriteCoordinator:
|
class SdkWriteCoordinator:
|
||||||
"""Resolve the active model profile, then execute one public SDK write."""
|
"""Resolve the active model profile, then execute one public SDK write.
|
||||||
|
|
||||||
|
A model profile change retires the previous profile's handle, but only
|
||||||
|
closes it once nothing uses it. Closing it at once tore the client out
|
||||||
|
from under a write still running on it: the SDK cleared its stores and
|
||||||
|
the SharedRuntime closed mid-`add`, the job failed with AttributeError
|
||||||
|
after HY was invoked, and it was left `unknown`.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -130,6 +155,9 @@ class SdkWriteCoordinator:
|
|||||||
self.transport_factory = transport_factory
|
self.transport_factory = transport_factory
|
||||||
self.sedimentation_enabled = sedimentation_enabled
|
self.sedimentation_enabled = sedimentation_enabled
|
||||||
self._handles: dict[tuple[str, int], SdkClientHandle] = {}
|
self._handles: dict[tuple[str, int], SdkClientHandle] = {}
|
||||||
|
#: Uses in flight per handle; a retired handle closes at zero.
|
||||||
|
self._uses: dict[SdkClientHandle, int] = {}
|
||||||
|
self._retired: set[SdkClientHandle] = set()
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
async def process_sdk_job(
|
async def process_sdk_job(
|
||||||
@@ -141,7 +169,7 @@ class SdkWriteCoordinator:
|
|||||||
) -> SdkWriteReceipt:
|
) -> SdkWriteReceipt:
|
||||||
if not self.sedimentation_enabled():
|
if not self.sedimentation_enabled():
|
||||||
raise RuntimeError("model_sedimentation_disabled")
|
raise RuntimeError("model_sedimentation_disabled")
|
||||||
handle = await self._active_handle()
|
async with self._active_handle() as handle:
|
||||||
return await SdkWriteProcessor(handle).process_sdk_job(
|
return await SdkWriteProcessor(handle).process_sdk_job(
|
||||||
records,
|
records,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
@@ -151,18 +179,20 @@ class SdkWriteCoordinator:
|
|||||||
async def inquire_write_outcome(
|
async def inquire_write_outcome(
|
||||||
self, request_id: str
|
self, request_id: str
|
||||||
) -> SdkWriteReceipt | None:
|
) -> SdkWriteReceipt | None:
|
||||||
return await SdkWriteProcessor(
|
async with self._active_handle() as handle:
|
||||||
await self._active_handle()
|
return await SdkWriteProcessor(handle).inquire_write_outcome(request_id)
|
||||||
).inquire_write_outcome(request_id)
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
handles, self._handles = tuple(self._handles.values()), {}
|
handles = (*self._handles.values(), *self._retired)
|
||||||
|
self._handles, self._retired, self._uses = {}, set(), {}
|
||||||
await asyncio.gather(*(handle.close() for handle in handles))
|
await asyncio.gather(*(handle.close() for handle in handles))
|
||||||
|
|
||||||
async def client(self) -> Any:
|
@asynccontextmanager
|
||||||
"""Return the public SDK client selected by the active write profile."""
|
async def use_client(self) -> AsyncIterator[Any]:
|
||||||
return await (await self._active_handle()).client()
|
"""The public SDK client of the active write profile, kept open while used."""
|
||||||
|
async with self._active_handle() as handle:
|
||||||
|
yield await handle.client()
|
||||||
|
|
||||||
async def active_profile_credentials(
|
async def active_profile_credentials(
|
||||||
self,
|
self,
|
||||||
@@ -171,7 +201,9 @@ class SdkWriteCoordinator:
|
|||||||
_identity, profile, secret = await asyncio.to_thread(self._read_active_credentials)
|
_identity, profile, secret = await asyncio.to_thread(self._read_active_credentials)
|
||||||
return profile, secret
|
return profile, secret
|
||||||
|
|
||||||
async def _active_handle(self) -> SdkClientHandle:
|
@asynccontextmanager
|
||||||
|
async def _active_handle(self) -> AsyncIterator[SdkClientHandle]:
|
||||||
|
"""Use the active profile's handle; retire the others, closing the unused."""
|
||||||
identity, profile, secret = await asyncio.to_thread(
|
identity, profile, secret = await asyncio.to_thread(
|
||||||
self._read_active_credentials
|
self._read_active_credentials
|
||||||
)
|
)
|
||||||
@@ -185,14 +217,25 @@ class SdkWriteCoordinator:
|
|||||||
http_transport_factory=self.transport_factory(profile),
|
http_transport_factory=self.transport_factory(profile),
|
||||||
)
|
)
|
||||||
self._handles[identity] = handle
|
self._handles[identity] = handle
|
||||||
stale = [
|
self._retired.update(value for key, value in self._handles.items() if key != identity)
|
||||||
value
|
|
||||||
for key, value in self._handles.items()
|
|
||||||
if key != identity
|
|
||||||
]
|
|
||||||
self._handles = {identity: handle}
|
self._handles = {identity: handle}
|
||||||
await asyncio.gather(*(value.close() for value in stale))
|
self._uses[handle] = self._uses.get(handle, 0) + 1
|
||||||
return handle
|
unused = {value for value in self._retired if not self._uses.get(value)}
|
||||||
|
self._retired -= unused
|
||||||
|
await asyncio.gather(*(value.close() for value in unused))
|
||||||
|
try:
|
||||||
|
yield handle
|
||||||
|
finally:
|
||||||
|
# Bookkeeping without an await, so a cancelled caller still
|
||||||
|
# releases its use.
|
||||||
|
remaining = self._uses.get(handle, 0) - 1
|
||||||
|
if remaining > 0:
|
||||||
|
self._uses[handle] = remaining
|
||||||
|
else:
|
||||||
|
self._uses.pop(handle, None)
|
||||||
|
if remaining <= 0 and handle in self._retired:
|
||||||
|
self._retired.discard(handle)
|
||||||
|
await handle.close()
|
||||||
|
|
||||||
def _read_active_credentials(
|
def _read_active_credentials(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
| `test_database_file_permissions.py` | `contract` | 覆盖 a new main database is readable by its owner alone、the wal sidecars are not left world readable、a database from an older build is tightened on open 场景。 |
|
| `test_database_file_permissions.py` | `contract` | 覆盖 a new main database is readable by its owner alone、the wal sidecars are not left world readable、a database from an older build is tightened on open 场景。 |
|
||||||
| `test_db_session_utc.py` | `contract` | The MySQL session must be pinned to UTC. |
|
| `test_db_session_utc.py` | `contract` | The MySQL session must be pinned to UTC. |
|
||||||
| `test_derived_echo.py` | `contract` | A host that saves our injection back must not mint a new user decision. |
|
| `test_derived_echo.py` | `contract` | A host that saves our injection back must not mint a new user decision. |
|
||||||
| `test_detailed_logging.py` | `contract` | 覆盖 detailed pipeline logging defaults off and survives restart、live log level applies to full hy and provider call chain、info collects hy info without enabling debug pipeline content 等 7 个场景。 |
|
| `test_detailed_logging.py` | `contract` | 覆盖 detailed logging defaults off and survives restart、hy pipeline log and trace stay off at every level、live level change reaches the hy text log file 等 7 个场景。 |
|
||||||
| `test_diagnostic_log.py` | `contract` | 覆盖 high frequency success events are summarized but warnings are not、structured log is private correlated and owner only、arbitrary detail and absolute stack are rejected 等 14 个场景。 |
|
| `test_diagnostic_log.py` | `contract` | 覆盖 high frequency success events are summarized but warnings are not、structured log is private correlated and owner only、arbitrary detail and absolute stack are rejected 等 14 个场景。 |
|
||||||
| `test_diagnostic_log_durability.py` | `contract` | 写线程报告的「已落盘」必须是真的。 |
|
| `test_diagnostic_log_durability.py` | `contract` | 写线程报告的「已落盘」必须是真的。 |
|
||||||
| `test_diagnostic_log_off_loop.py` | `contract` | 诊断日志不得在调用线程上做磁盘 I/O。 |
|
| `test_diagnostic_log_off_loop.py` | `contract` | 诊断日志不得在调用线程上做磁盘 I/O。 |
|
||||||
@@ -112,6 +112,7 @@
|
|||||||
| `test_hy_log_privacy.py` | `contract` | HY SDK records are not rewritten by One Memory's logging setup. |
|
| `test_hy_log_privacy.py` | `contract` | HY SDK records are not rewritten by One Memory's logging setup. |
|
||||||
| `test_hy_network_egress.py` | `contract` | Where a real HY client is allowed to send bytes, and where it is not. |
|
| `test_hy_network_egress.py` | `contract` | Where a real HY client is allowed to send bytes, and where it is not. |
|
||||||
| `test_hy_runtime_config.py` | `contract` | MEMORY.md is the audited record of what we send HY; keep it answerable. |
|
| `test_hy_runtime_config.py` | `contract` | MEMORY.md is the audited record of what we send HY; keep it answerable. |
|
||||||
|
| `test_hy_sdk_log_routing.py` | `contract` | HY SDK text logs go to hy_memory.log only, with warnings also in core.log. |
|
||||||
| `test_hy_step_trace_switch.py` | `contract` | Rule 15: the per-step HY trace is off unless the debug tier asks for it. |
|
| `test_hy_step_trace_switch.py` | `contract` | Rule 15: the per-step HY trace is off unless the debug tier asks for it. |
|
||||||
| `test_import_api.py` | `integration` | 覆盖 resume during cleanup returns conflict without internal error、authorize detect import and revoke api flow、protected api rejects missing token and foreign origin 等 5 个场景。 |
|
| `test_import_api.py` | `integration` | 覆盖 resume during cleanup returns conflict without internal error、authorize detect import and revoke api flow、protected api rejects missing token and foreign origin 等 5 个场景。 |
|
||||||
| `test_import_control_races_review.py` | `contract` | Read-only review repro: real runner/store, synthetic delayed adapter cleanup. |
|
| `test_import_control_races_review.py` | `contract` | Read-only review repro: real runner/store, synthetic delayed adapter cleanup. |
|
||||||
@@ -245,6 +246,7 @@
|
|||||||
| `test_schema_gate.py` | `contract` | 覆盖 fresh database records the build schema、future schema refuses to open、legacy database without a ledger is left untouched 场景。 |
|
| `test_schema_gate.py` | `contract` | 覆盖 fresh database records the build schema、future schema refuses to open、legacy database without a ledger is left untouched 场景。 |
|
||||||
| `test_schema_migrations.py` | `contract` | 覆盖 evidence store adds source ordinal to previous schema、import store migrates previous job and error schema、old active job index lets a sync sit beside paused history 等 4 个场景。 |
|
| `test_schema_migrations.py` | `contract` | 覆盖 evidence store adds source ordinal to previous schema、import store migrates previous job and error schema、old active job index lets a sync sit beside paused history 等 4 个场景。 |
|
||||||
| `test_schema_ownership_gate.py` | `contract` | A1-03/A1-04: schema ownership, and the hard part of telling DDL from reads. |
|
| `test_schema_ownership_gate.py` | `contract` | A1-03/A1-04: schema ownership, and the hard part of telling DDL from reads. |
|
||||||
|
| `test_sdk_client_construction.py` | `contract` | SDK clients of one home are constructed one at a time. |
|
||||||
| `test_sdk_data_plane_migration.py` | `contract` | SDK-authority migration ledger, aliases and atomic cutover marker. |
|
| `test_sdk_data_plane_migration.py` | `contract` | SDK-authority migration ledger, aliases and atomic cutover marker. |
|
||||||
| `test_sdk_memory_qa.py` | `contract` | 覆盖 sdk ask propagates sdk search failure without legacy fallback、sdk ask revalidates memory before returning extract、sdk ask uses native sdk memory without governance fields 场景。 |
|
| `test_sdk_memory_qa.py` | `contract` | 覆盖 sdk ask propagates sdk search failure without legacy fallback、sdk ask revalidates memory before returning extract、sdk ask uses native sdk memory without governance fields 场景。 |
|
||||||
| `test_sdk_migration_bundle.py` | `contract` | SDK-authority cutover requires a verified management + SDK data-plane bundle. |
|
| `test_sdk_migration_bundle.py` | `contract` | SDK-authority cutover requires a verified management + SDK data-plane bundle. |
|
||||||
@@ -307,4 +309,4 @@
|
|||||||
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
|
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
|
||||||
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
|
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
|
||||||
|
|
||||||
共 302 个测试文件。
|
共 304 个测试文件。
|
||||||
@@ -14,8 +14,8 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
||||||
EXPECTED_TEST_FILE_COUNT = 302
|
EXPECTED_TEST_FILE_COUNT = 304
|
||||||
EXPECTED_TEST_FILES_SHA256 = "07707528025ac134e46c93ebef12335e852df1ed0f52f8beb62abb3b4d1b0e97"
|
EXPECTED_TEST_FILES_SHA256 = "b28f022b9688c96ca563c59de63e95d344d2f01ba3b6020b422bf4dfb27520d6"
|
||||||
|
|
||||||
# Pure transformations, validation models, parity checks and state machines.
|
# Pure transformations, validation models, parity checks and state machines.
|
||||||
# This list is intentionally small: uncertain files default to contract.
|
# This list is intentionally small: uncertain files default to contract.
|
||||||
|
|||||||
@@ -1,90 +1,199 @@
|
|||||||
"""A proposal and the turn that adopts it must reach HY as one turn."""
|
"""The turn rule: what a conversation's records become before they reach HY.
|
||||||
|
|
||||||
|
A turn is one user message and everything up to the next one. Every 5
|
||||||
|
complete turns are written together, sooner when the next turn would pass
|
||||||
|
8000 characters; a short batch waits for the end of a history file or 10
|
||||||
|
quiet minutes. No batch is written without a user message.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from one_memory.conversation_processing import _conversation_chunks
|
from one_memory.conversation_processing import (
|
||||||
|
TURN_BATCH_CHARACTERS,
|
||||||
|
TURN_BATCH_TURNS,
|
||||||
|
conversation_turns,
|
||||||
|
turn_batches,
|
||||||
|
)
|
||||||
from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy
|
from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy
|
||||||
from one_memory.imports.storage import EvidenceStore
|
from one_memory.imports.storage import EvidenceStore
|
||||||
|
|
||||||
|
_WRITER = {"user": "person", "assistant": "agent", "tool": "tool"}
|
||||||
|
|
||||||
def _records(tmp_path, *speakers: tuple[str, str, str]) -> list:
|
|
||||||
|
def _records(tmp_path, *speakers: tuple[str, str]) -> list:
|
||||||
store = EvidenceStore(tmp_path / "one-memory.sqlite3")
|
store = EvidenceStore(tmp_path / "one-memory.sqlite3")
|
||||||
records = []
|
return [
|
||||||
for index, (role, written_by, text) in enumerate(speakers):
|
|
||||||
records.append(
|
|
||||||
store.put(
|
store.put(
|
||||||
EvidenceInput(
|
EvidenceInput(
|
||||||
source_id="cursor",
|
source_id="cursor",
|
||||||
conversation_id="conversation",
|
conversation_id="conversation",
|
||||||
message_id=f"m{index}",
|
message_id=f"m{index}",
|
||||||
role=role,
|
role=role,
|
||||||
written_by=WrittenBy(written_by),
|
written_by=WrittenBy(_WRITER[role]),
|
||||||
verbatim=text,
|
verbatim=text,
|
||||||
created_at=datetime.now(UTC),
|
created_at=datetime.now(UTC),
|
||||||
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
||||||
)
|
)
|
||||||
).record
|
).record
|
||||||
)
|
for index, (role, text) in enumerate(speakers)
|
||||||
return records
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_unanswered_assistant_prefix_joins_the_turn_that_adopts_it(tmp_path) -> None:
|
|
||||||
"""`assistant` proposal -> `user` explicit adoption is one turn, not two.
|
|
||||||
|
|
||||||
Splitting it sends HY an answer without what it answers, and loses the
|
|
||||||
authority pair the author/adoption quality gate depends on.
|
|
||||||
"""
|
|
||||||
records = _records(
|
|
||||||
tmp_path,
|
|
||||||
("assistant", "agent", "建议把超时改成 30 秒。"),
|
|
||||||
("user", "person", "就这么做,超时改成 30 秒。"),
|
|
||||||
)
|
|
||||||
|
|
||||||
chunks = _conversation_chunks(records)
|
|
||||||
|
|
||||||
assert [[record.role for record in chunk] for chunk in chunks] == [["assistant", "user"]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_lone_user_message_does_not_become_a_model_input(tmp_path) -> None:
|
|
||||||
"""A user message with no reply yet is Evidence, not a completed turn.
|
|
||||||
|
|
||||||
Concurrent-import fixtures write `user` then `assistant` pairs, but a lone
|
|
||||||
user message must not create an HY task: that is the complete-turn rule,
|
|
||||||
not a queue stall.
|
|
||||||
"""
|
|
||||||
records = _records(tmp_path, ("user", "person", "把超时改成 30 秒。"))
|
|
||||||
|
|
||||||
assert _conversation_chunks(records, completed_only=True) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_only_the_unanswered_trailing_turn_is_held_back(tmp_path) -> None:
|
|
||||||
"""Earlier complete turns stay eligible while the open tail waits."""
|
|
||||||
records = _records(
|
|
||||||
tmp_path,
|
|
||||||
("user", "person", "先把超时改成 30 秒。"),
|
|
||||||
("assistant", "agent", "已经改好了。"),
|
|
||||||
("user", "person", "接着把发布包传上去。"),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert _conversation_chunks(records, completed_only=True) == [(records[0], records[1])]
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_complete_turn_contract_used_for_concurrent_import_reds(tmp_path) -> None:
|
|
||||||
"""Node-id level answer for `[BLOCK C2 -> C1]` concurrent-import reds.
|
|
||||||
|
|
||||||
Those fixtures write `user` turns and never emit the closing
|
|
||||||
`assistant`/tool event, so under the complete-turn rule no HY task is
|
|
||||||
created inside the 5s window. That is the contract, not a queue stall:
|
|
||||||
Evidence is still committed and searchable, and the task appears once the
|
|
||||||
closing event arrives.
|
|
||||||
"""
|
|
||||||
closed = _records(
|
|
||||||
tmp_path,
|
|
||||||
("user", "person", "把超时改成 30 秒。"),
|
|
||||||
("assistant", "agent", "已改为 30 秒。"),
|
|
||||||
)
|
|
||||||
assert [[record.role for record in chunk] for chunk in _conversation_chunks(closed)] == [
|
|
||||||
["user", "assistant"]
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _turns(count: int, *, answer: str = "好的。") -> list[tuple[str, str]]:
|
||||||
|
return [
|
||||||
|
part
|
||||||
|
for index in range(count)
|
||||||
|
for part in (("user", f"第 {index} 个问题"), ("assistant", answer))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _roles(batches) -> list[list[str]]:
|
||||||
|
return [[record.role for record in batch] for batch in batches]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_constants_are_the_product_rule() -> None:
|
||||||
|
assert (TURN_BATCH_TURNS, TURN_BATCH_CHARACTERS) == (5, 8_000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_turn_runs_from_one_user_message_to_the_next(tmp_path) -> None:
|
||||||
|
records = _records(
|
||||||
|
tmp_path,
|
||||||
|
("user", "跑一下测试"),
|
||||||
|
("assistant", "好"),
|
||||||
|
("tool", "3 passed"),
|
||||||
|
("assistant", "都通过了"),
|
||||||
|
("user", "提交吧"),
|
||||||
|
("assistant", "已提交"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [len(turn) for turn in conversation_turns(records)] == [4, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_records_before_the_first_user_message_join_the_first_turn(tmp_path) -> None:
|
||||||
|
"""An assistant proposal and the user turn adopting it reach HY together."""
|
||||||
|
records = _records(
|
||||||
|
tmp_path,
|
||||||
|
("assistant", "建议把超时改成 30 秒。"),
|
||||||
|
("user", "就这么做,超时改成 30 秒。"),
|
||||||
|
("assistant", "改好了。"),
|
||||||
|
("user", "再把重试改成 3 次。"),
|
||||||
|
("assistant", "也改好了。"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _roles(conversation_turns(records)) == [
|
||||||
|
["assistant", "user", "assistant"],
|
||||||
|
["user", "assistant"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_five_complete_turns_are_one_batch_and_the_open_turn_waits(tmp_path) -> None:
|
||||||
|
records = _records(tmp_path, *_turns(6))
|
||||||
|
|
||||||
|
grouped = turn_batches(records, flush=False)
|
||||||
|
|
||||||
|
assert [len(batch) for batch in grouped.batches] == [10]
|
||||||
|
assert grouped.batches[0] == tuple(records[:10])
|
||||||
|
assert grouped.carry == tuple(records[10:])
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_turns_short_of_five_wait_for_flush(tmp_path) -> None:
|
||||||
|
records = _records(tmp_path, *_turns(3))
|
||||||
|
|
||||||
|
waiting = turn_batches(records, flush=False)
|
||||||
|
assert waiting.batches == ()
|
||||||
|
assert waiting.carry == tuple(records)
|
||||||
|
|
||||||
|
flushed = turn_batches(records, flush=True)
|
||||||
|
assert flushed.batches == (tuple(records),)
|
||||||
|
assert flushed.carry == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lone_user_message_is_not_written_until_flush(tmp_path) -> None:
|
||||||
|
records = _records(tmp_path, ("user", "把超时改成 30 秒。"))
|
||||||
|
|
||||||
|
assert turn_batches(records, flush=False).batches == ()
|
||||||
|
assert turn_batches(records, flush=True).batches == (tuple(records),)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_turn_that_would_pass_8000_characters_starts_the_next_batch(tmp_path) -> None:
|
||||||
|
answer = "句" * 3_000
|
||||||
|
records = _records(tmp_path, *_turns(4, answer=answer))
|
||||||
|
|
||||||
|
grouped = turn_batches(records, flush=False)
|
||||||
|
|
||||||
|
# Turns 1+2 fit; turn 3 would pass 8000, so 1+2 are written now. Turn 3
|
||||||
|
# is complete but alone, and turn 4 is still open.
|
||||||
|
assert grouped.batches == (tuple(records[:4]),)
|
||||||
|
assert grouped.carry == tuple(records[4:])
|
||||||
|
assert sum(len(record.verbatim) for record in grouped.batches[0]) <= TURN_BATCH_CHARACTERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_turn_over_8000_characters_is_a_batch_of_its_own_and_not_split(tmp_path) -> None:
|
||||||
|
records = _records(
|
||||||
|
tmp_path,
|
||||||
|
("user", "先问个小问题"),
|
||||||
|
("assistant", "好"),
|
||||||
|
("user", "写一份方案"),
|
||||||
|
("assistant", "段落。" * 7_000),
|
||||||
|
("user", "谢谢"),
|
||||||
|
)
|
||||||
|
|
||||||
|
grouped = turn_batches(records, flush=False)
|
||||||
|
|
||||||
|
assert grouped.batches == (tuple(records[:2]), tuple(records[2:4]))
|
||||||
|
assert grouped.carry == (records[4],)
|
||||||
|
assert all(":segment:" not in record.evidence_id for record in grouped.batches[1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_output_counts_only_what_hy_reads(tmp_path) -> None:
|
||||||
|
"""A tool record counts up to `_HY_TOOL_CHARACTERS`, not its full text."""
|
||||||
|
from one_memory.conversation_processing import _HY_TOOL_CHARACTERS
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
part
|
||||||
|
for index in range(5)
|
||||||
|
for part in (("user", f"第 {index} 步"), ("tool", "x" * 50_000), ("assistant", "完成"))
|
||||||
|
]
|
||||||
|
records = _records(tmp_path, *parts, ("user", "下一步"))
|
||||||
|
|
||||||
|
grouped = turn_batches(records, flush=False)
|
||||||
|
|
||||||
|
assert _HY_TOOL_CHARACTERS == 512
|
||||||
|
assert [len(batch) for batch in grouped.batches] == [15]
|
||||||
|
|
||||||
|
|
||||||
|
def test_records_with_no_user_message_are_never_a_batch(tmp_path) -> None:
|
||||||
|
"""The SDK strips tool records on the chat path and refuses what is left.
|
||||||
|
|
||||||
|
A tool-only batch came back 400 "content or messages is required" and
|
||||||
|
was recorded unknown, 16 times. Carried until a user message arrives;
|
||||||
|
dropped when the conversation is flushed without one.
|
||||||
|
"""
|
||||||
|
records = _records(tmp_path, ("tool", "ls output"), ("assistant", "看完了"))
|
||||||
|
|
||||||
|
waiting = turn_batches(records, flush=False)
|
||||||
|
assert waiting.batches == ()
|
||||||
|
assert waiting.carry == tuple(records)
|
||||||
|
assert turn_batches(records, flush=True) == turn_batches((), flush=True)
|
||||||
|
|
||||||
|
later = _records(tmp_path, ("user", "那就开始吧"), ("assistant", "开始"))
|
||||||
|
joined = turn_batches((*waiting.carry, *later), flush=True)
|
||||||
|
assert _roles(joined.batches) == [["tool", "assistant", "user", "assistant"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_batch_holds_a_user_message(tmp_path) -> None:
|
||||||
|
records = _records(
|
||||||
|
tmp_path,
|
||||||
|
("tool", "前置输出"),
|
||||||
|
*_turns(7, answer="答" * 2_500),
|
||||||
|
("tool", "尾部输出"),
|
||||||
|
)
|
||||||
|
|
||||||
|
for flush in (False, True):
|
||||||
|
grouped = turn_batches(records, flush=flush)
|
||||||
|
assert grouped.batches
|
||||||
|
assert all(any(record.role == "user" for record in batch) for batch in grouped.batches)
|
||||||
|
written = [record for batch in grouped.batches for record in batch]
|
||||||
|
assert written + list(grouped.carry) == records
|
||||||
@@ -142,3 +142,112 @@ def test_shutdown_during_recovery_stops_it_without_starting_more(tmp_path, monke
|
|||||||
release.set()
|
release.set()
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_repair_never_overlaps_an_sdk_client(tmp_path, monkeypatch):
|
||||||
|
"""The repair's own Chroma client dropped the SDK's shared System (KeyError).
|
||||||
|
|
||||||
|
The first library request opened the SDK client while the repair ran
|
||||||
|
after readiness. Now every SDK client of the home waits for the repair.
|
||||||
|
"""
|
||||||
|
from one_memory import hy_sdk_runtime
|
||||||
|
from one_memory.hy_sdk_runtime import ClientLease
|
||||||
|
|
||||||
|
release = threading.Event()
|
||||||
|
entered = threading.Event()
|
||||||
|
events: list[str] = []
|
||||||
|
|
||||||
|
def slow_repair(**_):
|
||||||
|
entered.set()
|
||||||
|
events.append("repair_started")
|
||||||
|
release.wait(10)
|
||||||
|
events.append("repair_finished")
|
||||||
|
return MetadataRepairResult(None, 0, 0, 0)
|
||||||
|
|
||||||
|
def open_client(profile, *, api_key, data_dir, http_transport_factory):
|
||||||
|
events.append("client_opened")
|
||||||
|
return ClientLease(object(), data_dir.resolve())
|
||||||
|
|
||||||
|
monkeypatch.setattr(hy_sdk_migration, "repair_sdk_metadata_from_bundle", slow_repair)
|
||||||
|
monkeypatch.setattr(hy_sdk_runtime, "_acquire_client", open_client)
|
||||||
|
monkeypatch.setattr(ClientLease, "close", lambda self: None)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
app = application(tmp_path)
|
||||||
|
try:
|
||||||
|
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||||
|
assert await asyncio.to_thread(entered.wait, 5)
|
||||||
|
handle = hy_sdk_runtime.SdkClientHandle(
|
||||||
|
profile=hy_sdk_runtime.builtin_retrieval_snapshot(),
|
||||||
|
api_key=None,
|
||||||
|
data_dir=app.state.settings.data_dir,
|
||||||
|
)
|
||||||
|
opening = asyncio.create_task(handle.client())
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
# Readiness and reads answer; the SDK client waits.
|
||||||
|
assert (await health(client))["status"] == "ok"
|
||||||
|
assert not opening.done()
|
||||||
|
release.set()
|
||||||
|
await asyncio.wait_for(opening, 5)
|
||||||
|
await wait_for_recovery(client)
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
assert events == ["repair_started", "repair_finished", "client_opened"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stores_are_not_held_beside_an_open_sdk_client(tmp_path):
|
||||||
|
from one_memory import hy_sdk_runtime
|
||||||
|
|
||||||
|
key = tmp_path.resolve()
|
||||||
|
with hy_sdk_runtime._STORE_GATE:
|
||||||
|
hy_sdk_runtime._OPENING[key] = 1
|
||||||
|
try:
|
||||||
|
assert hy_sdk_runtime.hold_stores_offline(tmp_path) is False
|
||||||
|
finally:
|
||||||
|
with hy_sdk_runtime._STORE_GATE:
|
||||||
|
del hy_sdk_runtime._OPENING[key]
|
||||||
|
assert hy_sdk_runtime.hold_stores_offline(tmp_path) is True
|
||||||
|
assert hy_sdk_runtime.hold_stores_offline(tmp_path) is False
|
||||||
|
hy_sdk_runtime.release_stores(tmp_path)
|
||||||
|
hy_sdk_runtime.release_stores(tmp_path)
|
||||||
|
assert hy_sdk_runtime.hold_stores_offline(tmp_path) is True
|
||||||
|
hy_sdk_runtime.release_stores(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_recovery_step_records_where_it_failed(tmp_path, monkeypatch, caplog):
|
||||||
|
"""`error_type=KeyError` alone named no location; frames, never the message."""
|
||||||
|
from one_memory import api
|
||||||
|
|
||||||
|
def lookup(systems):
|
||||||
|
return systems["private-identifier"]
|
||||||
|
|
||||||
|
def broken_repair(**_):
|
||||||
|
return lookup({})
|
||||||
|
|
||||||
|
events: list[dict] = []
|
||||||
|
monkeypatch.setattr(hy_sdk_migration, "repair_sdk_metadata_from_bundle", broken_repair)
|
||||||
|
monkeypatch.setattr(api, "diagnostic_event", lambda **values: events.append(values))
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
app = application(tmp_path)
|
||||||
|
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||||
|
await wait_for_recovery(client)
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
[line] = [
|
||||||
|
record.getMessage()
|
||||||
|
for record in caplog.records
|
||||||
|
if "core_recovery_step_failed" in record.getMessage()
|
||||||
|
]
|
||||||
|
assert "step=sdk_metadata_repair error_type=KeyError chain=KeyError@" in line
|
||||||
|
assert "test_core_ready_before_recovery.py:" in line and ":lookup" in line
|
||||||
|
assert "private-identifier" not in line
|
||||||
|
[failed] = [event for event in events if event.get("event") == "core.recovery_step_failed"]
|
||||||
|
assert failed["error_type"] == "KeyError"
|
||||||
|
assert failed["safe_stack"][-1].startswith("test_core_ready_before_recovery.py:")
|
||||||
|
assert failed["safe_stack"][-1].endswith(":lookup")
|
||||||
|
assert "private-identifier" not in repr(failed)
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from importlib import import_module
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from one_memory.hy_runtime_config import (
|
from one_memory.hy_runtime_config import (
|
||||||
@@ -12,20 +11,50 @@ from one_memory.hy_runtime_config import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_detailed_pipeline_logging_defaults_off_and_survives_restart(
|
def test_detailed_logging_defaults_off_and_survives_restart(tmp_path: Path, monkeypatch) -> None:
|
||||||
tmp_path: Path, monkeypatch
|
monkeypatch.delenv("MEMORY_LOG_LEVEL", raising=False)
|
||||||
) -> None:
|
|
||||||
monkeypatch.delenv("MEMORY_PIPELINE_TRACE_ENABLED", raising=False)
|
|
||||||
assert detailed_logging_enabled(tmp_path) is False
|
assert detailed_logging_enabled(tmp_path) is False
|
||||||
configure_hy_runtime_environment(tmp_path)
|
configure_hy_runtime_environment(tmp_path)
|
||||||
assert __import__("os").environ["MEMORY_PIPELINE_TRACE_ENABLED"] == "false"
|
assert __import__("os").environ["MEMORY_LOG_LEVEL"] == "INFO"
|
||||||
assert diagnostic_log_level(tmp_path) == "info"
|
assert diagnostic_log_level(tmp_path) == "info"
|
||||||
|
|
||||||
assert set_detailed_logging(tmp_path, True) is True
|
assert set_detailed_logging(tmp_path, True) is True
|
||||||
assert detailed_logging_enabled(tmp_path) is True
|
assert detailed_logging_enabled(tmp_path) is True
|
||||||
monkeypatch.delenv("MEMORY_PIPELINE_TRACE_ENABLED", raising=False)
|
monkeypatch.delenv("MEMORY_LOG_LEVEL", raising=False)
|
||||||
configure_hy_runtime_environment(tmp_path)
|
configure_hy_runtime_environment(tmp_path)
|
||||||
assert __import__("os").environ["MEMORY_PIPELINE_TRACE_ENABLED"] == "true"
|
assert __import__("os").environ["MEMORY_LOG_LEVEL"] == "DEBUG"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hy_pipeline_log_and_trace_stay_off_at_every_level(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
environment = __import__("os").environ
|
||||||
|
for level in ("error", "warning", "info", "debug"):
|
||||||
|
for name in ("MEMORY_PIPELINE_LOG_ENABLED", "MEMORY_PIPELINE_TRACE_ENABLED"):
|
||||||
|
monkeypatch.setenv(name, "true")
|
||||||
|
set_diagnostic_log_level(tmp_path, level)
|
||||||
|
assert environment["MEMORY_PIPELINE_LOG_ENABLED"] == "false"
|
||||||
|
assert environment["MEMORY_PIPELINE_TRACE_ENABLED"] == "false"
|
||||||
|
|
||||||
|
for name in ("MEMORY_PIPELINE_LOG_ENABLED", "MEMORY_PIPELINE_TRACE_ENABLED"):
|
||||||
|
monkeypatch.setenv(name, "true")
|
||||||
|
configure_hy_runtime_environment(tmp_path)
|
||||||
|
assert environment["MEMORY_PIPELINE_LOG_ENABLED"] == "false"
|
||||||
|
assert environment["MEMORY_PIPELINE_TRACE_ENABLED"] == "false"
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_level_change_reaches_the_hy_text_log_file(tmp_path: Path) -> None:
|
||||||
|
sdk = logging.getLogger("hy_memory")
|
||||||
|
text_log = logging.FileHandler(tmp_path / "hy_memory.log", encoding="utf-8")
|
||||||
|
text_log.setLevel(logging.INFO)
|
||||||
|
sdk.addHandler(text_log)
|
||||||
|
try:
|
||||||
|
set_diagnostic_log_level(tmp_path, "debug")
|
||||||
|
assert text_log.level == logging.DEBUG
|
||||||
|
set_diagnostic_log_level(tmp_path, "warning")
|
||||||
|
assert text_log.level == logging.WARNING
|
||||||
|
finally:
|
||||||
|
sdk.removeHandler(text_log)
|
||||||
|
text_log.close()
|
||||||
|
set_diagnostic_log_level(tmp_path, "info")
|
||||||
|
|
||||||
|
|
||||||
def test_live_log_level_applies_to_full_hy_and_provider_call_chain(
|
def test_live_log_level_applies_to_full_hy_and_provider_call_chain(
|
||||||
@@ -60,43 +89,9 @@ def test_info_collects_hy_info_without_enabling_debug_pipeline_content(
|
|||||||
assert logging.getLogger("openai._base_client").isEnabledFor(logging.INFO)
|
assert logging.getLogger("openai._base_client").isEnabledFor(logging.INFO)
|
||||||
assert not logging.getLogger("openai._base_client").isEnabledFor(logging.DEBUG)
|
assert not logging.getLogger("openai._base_client").isEnabledFor(logging.DEBUG)
|
||||||
assert __import__("os").environ["MEMORY_LOG_LEVEL"] == "INFO"
|
assert __import__("os").environ["MEMORY_LOG_LEVEL"] == "INFO"
|
||||||
assert __import__("os").environ["MEMORY_PIPELINE_TRACE_ENABLED"] == "false"
|
|
||||||
assert __import__("os").environ["MEMORY_LOG_LLM_CONTENT"] == "false"
|
assert __import__("os").environ["MEMORY_LOG_LLM_CONTENT"] == "false"
|
||||||
|
|
||||||
|
|
||||||
def test_live_debug_switch_makes_sdk_pipeline_writer_keep_original_content(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
writer_type = import_module("hy_memory.utils.pipeline_log_writer").PipelineLogWriter
|
|
||||||
writer = writer_type(str(tmp_path / "pipeline"))
|
|
||||||
|
|
||||||
set_diagnostic_log_level(tmp_path, "info")
|
|
||||||
writer.write_step(
|
|
||||||
request_id="info",
|
|
||||||
step="EXTRACT",
|
|
||||||
prompt="info prompt",
|
|
||||||
response="info response",
|
|
||||||
)
|
|
||||||
set_diagnostic_log_level(tmp_path, "debug")
|
|
||||||
writer.write_step(
|
|
||||||
request_id="debug",
|
|
||||||
step="EXTRACT",
|
|
||||||
prompt="debug prompt",
|
|
||||||
response="debug response",
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
json.loads(line)
|
|
||||||
for target in (tmp_path / "pipeline").rglob("*.log")
|
|
||||||
for line in target.read_text(encoding="utf-8").splitlines()
|
|
||||||
]
|
|
||||||
info, debug = lines
|
|
||||||
assert info["prompt"].startswith("<redacted len=")
|
|
||||||
assert info["content"].startswith("<redacted len=")
|
|
||||||
assert debug["prompt"] == "debug prompt"
|
|
||||||
assert debug["content"] == "debug response"
|
|
||||||
|
|
||||||
|
|
||||||
def test_log_level_document_migrates_the_old_boolean_shape(tmp_path: Path) -> None:
|
def test_log_level_document_migrates_the_old_boolean_shape(tmp_path: Path) -> None:
|
||||||
target = tmp_path / "settings" / "diagnostic-logging.json"
|
target = tmp_path / "settings" / "diagnostic-logging.json"
|
||||||
target.parent.mkdir(parents=True)
|
target.parent.mkdir(parents=True)
|
||||||
@@ -117,6 +112,7 @@ def test_configure_environment_passes_debug_content_settings_to_sdk(
|
|||||||
"MEMORY_DATA_DIR",
|
"MEMORY_DATA_DIR",
|
||||||
"MEMORY_LOG_DIR",
|
"MEMORY_LOG_DIR",
|
||||||
"MEMORY_LOG_LEVEL",
|
"MEMORY_LOG_LEVEL",
|
||||||
|
"MEMORY_PIPELINE_LOG_ENABLED",
|
||||||
"MEMORY_PIPELINE_TRACE_ENABLED",
|
"MEMORY_PIPELINE_TRACE_ENABLED",
|
||||||
"MEMORY_LOG_LLM_CONTENT",
|
"MEMORY_LOG_LLM_CONTENT",
|
||||||
):
|
):
|
||||||
@@ -129,5 +125,6 @@ def test_configure_environment_passes_debug_content_settings_to_sdk(
|
|||||||
assert environment["MEMORY_DATA_DIR"] == str(memory_root)
|
assert environment["MEMORY_DATA_DIR"] == str(memory_root)
|
||||||
assert environment["MEMORY_LOG_DIR"] == str(memory_root / "logs")
|
assert environment["MEMORY_LOG_DIR"] == str(memory_root / "logs")
|
||||||
assert environment["MEMORY_LOG_LEVEL"] == "DEBUG"
|
assert environment["MEMORY_LOG_LEVEL"] == "DEBUG"
|
||||||
assert environment["MEMORY_PIPELINE_TRACE_ENABLED"] == "true"
|
assert environment["MEMORY_PIPELINE_LOG_ENABLED"] == "false"
|
||||||
|
assert environment["MEMORY_PIPELINE_TRACE_ENABLED"] == "false"
|
||||||
assert environment["MEMORY_LOG_LLM_CONTENT"] == "true"
|
assert environment["MEMORY_LOG_LLM_CONTENT"] == "true"
|
||||||
@@ -8,6 +8,7 @@ import json
|
|||||||
import httpx
|
import httpx
|
||||||
from pydantic import SecretStr
|
from pydantic import SecretStr
|
||||||
|
|
||||||
|
from one_memory.hy_sdk_runtime import build_sdk_config
|
||||||
from one_memory.imports.models import (
|
from one_memory.imports.models import (
|
||||||
EvidenceInput,
|
EvidenceInput,
|
||||||
EvidenceOrigin,
|
EvidenceOrigin,
|
||||||
@@ -17,6 +18,7 @@ from one_memory.imports.models import (
|
|||||||
from one_memory.imports.storage import EvidenceStore
|
from one_memory.imports.storage import EvidenceStore
|
||||||
from one_memory.memory_batch_extraction import MemoryBatchExtractor
|
from one_memory.memory_batch_extraction import MemoryBatchExtractor
|
||||||
from one_memory.memory_qa import _answer_request
|
from one_memory.memory_qa import _answer_request
|
||||||
|
from one_memory.model_probe import _structured_request
|
||||||
from one_memory.model_profiles import (
|
from one_memory.model_profiles import (
|
||||||
ModelProfileInput,
|
ModelProfileInput,
|
||||||
ModelProfileStore,
|
ModelProfileStore,
|
||||||
@@ -243,3 +245,94 @@ def test_ollama_batch_sends_think_false_only_when_disabled(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
assert payloads[0]["think"] is False
|
assert payloads[0]["think"] is False
|
||||||
assert "reasoning_effort" not in payloads[0]
|
assert "reasoning_effort" not in payloads[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _deepseek(store, secrets, *, disable_thinking: bool):
|
||||||
|
return store.create(
|
||||||
|
ModelProfileInput(
|
||||||
|
name=f"deepseek-{disable_thinking}",
|
||||||
|
provider_kind=ProviderKind.OPENAI_COMPATIBLE,
|
||||||
|
base_url="https://api.deepseek.com/v1",
|
||||||
|
model="deepseek-v4-flash",
|
||||||
|
api_key=SecretStr("sk-test"),
|
||||||
|
embedding_model="embedding-model",
|
||||||
|
embedding_provider_kind=ProviderKind.LOCAL_ONNX,
|
||||||
|
disable_thinking=disable_thinking,
|
||||||
|
),
|
||||||
|
secrets,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sdk_config_turns_deepseek_thinking_off_like_the_probe(tmp_path) -> None:
|
||||||
|
"""DeepSeek Flash spent the SDK's 2000 agent tokens thinking: EMPTY_RESPONSE.
|
||||||
|
|
||||||
|
The SDK sends `llm.extra_body` on every chat call (chat and the shared
|
||||||
|
coding/working provider), and the probe must ask the model the same way.
|
||||||
|
"""
|
||||||
|
database = tmp_path / "one-memory.sqlite3"
|
||||||
|
secrets = InMemorySecretStore()
|
||||||
|
store = ModelProfileStore(database)
|
||||||
|
profile = _deepseek(store, secrets, disable_thinking=True)
|
||||||
|
snapshot = store.snapshot(profile.profile_id, profile.revision)
|
||||||
|
|
||||||
|
config = build_sdk_config(snapshot, api_key="sk-test", data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
assert config.llm.extra_body == {"thinking": {"type": "disabled"}}
|
||||||
|
_m, _u, _h, probe = _structured_request(profile, "sk-test")
|
||||||
|
assert probe["thinking"] == config.llm.extra_body["thinking"]
|
||||||
|
assert "reasoning_effort" not in probe
|
||||||
|
|
||||||
|
|
||||||
|
def test_sdk_config_sends_no_thinking_fields_unless_the_profile_opts_in(tmp_path) -> None:
|
||||||
|
database = tmp_path / "one-memory.sqlite3"
|
||||||
|
secrets = InMemorySecretStore()
|
||||||
|
store = ModelProfileStore(database)
|
||||||
|
profile = _deepseek(store, secrets, disable_thinking=False)
|
||||||
|
snapshot = store.snapshot(profile.profile_id, profile.revision)
|
||||||
|
|
||||||
|
config = build_sdk_config(snapshot, api_key="sk-test", data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
assert not config.llm.extra_body
|
||||||
|
|
||||||
|
|
||||||
|
def test_sdk_config_maps_other_providers_through_the_same_table(tmp_path) -> None:
|
||||||
|
database = tmp_path / "one-memory.sqlite3"
|
||||||
|
secrets = InMemorySecretStore()
|
||||||
|
store = ModelProfileStore(database)
|
||||||
|
openai = store.create(
|
||||||
|
ModelProfileInput(
|
||||||
|
name="openai",
|
||||||
|
provider_kind=ProviderKind.OPENAI_COMPATIBLE,
|
||||||
|
base_url="https://api.example.com/v1",
|
||||||
|
model="gpt-test",
|
||||||
|
api_key=SecretStr("sk-test"),
|
||||||
|
embedding_model="embedding-model",
|
||||||
|
embedding_provider_kind=ProviderKind.LOCAL_ONNX,
|
||||||
|
),
|
||||||
|
secrets,
|
||||||
|
)
|
||||||
|
ollama = store.create(
|
||||||
|
ModelProfileInput(
|
||||||
|
name="ollama",
|
||||||
|
provider_kind=ProviderKind.OLLAMA,
|
||||||
|
base_url="http://127.0.0.1:11434",
|
||||||
|
model="qwen",
|
||||||
|
embedding_model="embedding-model",
|
||||||
|
embedding_provider_kind=ProviderKind.LOCAL_ONNX,
|
||||||
|
),
|
||||||
|
secrets,
|
||||||
|
)
|
||||||
|
|
||||||
|
openai_config = build_sdk_config(
|
||||||
|
store.snapshot(openai.profile_id, openai.revision),
|
||||||
|
api_key="sk-test",
|
||||||
|
data_dir=tmp_path / "data",
|
||||||
|
)
|
||||||
|
ollama_config = build_sdk_config(
|
||||||
|
store.snapshot(ollama.profile_id, ollama.revision),
|
||||||
|
api_key=None,
|
||||||
|
data_dir=tmp_path / "data",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert openai_config.llm.extra_body == {"reasoning_effort": "none"}
|
||||||
|
assert ollama_config.llm.extra_body == {"think": False}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""HY SDK text logs go to hy_memory.log only, with warnings also in core.log.
|
||||||
|
|
||||||
|
The SDK's first client adds a stdout and a stderr stream to the "hy_memory"
|
||||||
|
logger and stops it propagating. Under launchd those streams are
|
||||||
|
service.log and service-error.log, so each SDK line used to be written
|
||||||
|
twice and none reached core.log.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from one_memory import hy_sdk_runtime
|
||||||
|
from one_memory.log_file import (
|
||||||
|
HY_SDK_LOGGER,
|
||||||
|
confine_hy_sdk_logging,
|
||||||
|
log_path,
|
||||||
|
start_logging_to_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sdk_logger():
|
||||||
|
"""The "hy_memory" logger as HY's setup_logging leaves it, restored after."""
|
||||||
|
sdk = logging.getLogger(HY_SDK_LOGGER)
|
||||||
|
root = logging.getLogger()
|
||||||
|
saved = (list(sdk.handlers), sdk.propagate, sdk.level, list(root.handlers), root.level)
|
||||||
|
yield sdk
|
||||||
|
for handler in sdk.handlers:
|
||||||
|
if handler not in saved[0]:
|
||||||
|
handler.close()
|
||||||
|
for handler in root.handlers:
|
||||||
|
if handler not in saved[3]:
|
||||||
|
handler.close()
|
||||||
|
sdk.handlers, sdk.propagate, root.handlers = saved[0], saved[1], saved[3]
|
||||||
|
sdk.setLevel(saved[2])
|
||||||
|
root.setLevel(saved[4])
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_like_hy(sdk: logging.Logger, log_dir: Path) -> logging.FileHandler:
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
text_log = logging.FileHandler(log_dir / "hy_memory.log", encoding="utf-8")
|
||||||
|
stdout = logging.StreamHandler(sys.stdout)
|
||||||
|
stderr = logging.StreamHandler(sys.stderr)
|
||||||
|
stderr.setLevel(logging.WARNING)
|
||||||
|
sdk.handlers = [text_log, stdout, stderr]
|
||||||
|
sdk.propagate = False
|
||||||
|
sdk.setLevel(logging.INFO)
|
||||||
|
return text_log
|
||||||
|
|
||||||
|
|
||||||
|
def test_sdk_lines_stay_in_hy_memory_log_and_warnings_reach_core_log(
|
||||||
|
tmp_path: Path, sdk_logger: logging.Logger
|
||||||
|
) -> None:
|
||||||
|
start_logging_to_file(tmp_path)
|
||||||
|
text_log = _configure_like_hy(sdk_logger, tmp_path / "hy")
|
||||||
|
|
||||||
|
confine_hy_sdk_logging()
|
||||||
|
confine_hy_sdk_logging()
|
||||||
|
|
||||||
|
assert not [
|
||||||
|
handler
|
||||||
|
for handler in sdk_logger.handlers
|
||||||
|
if isinstance(handler, logging.StreamHandler)
|
||||||
|
and not isinstance(handler, logging.FileHandler)
|
||||||
|
]
|
||||||
|
assert text_log in sdk_logger.handlers
|
||||||
|
assert len(sdk_logger.handlers) == 2, "the core.log forwarder is added once"
|
||||||
|
|
||||||
|
child = logging.getLogger("hy_memory.client")
|
||||||
|
child.info("[add] routine sdk line")
|
||||||
|
child.warning("[add] sdk problem")
|
||||||
|
for handler in logging.getLogger().handlers:
|
||||||
|
handler.flush()
|
||||||
|
text_log.flush()
|
||||||
|
|
||||||
|
hy_text = (tmp_path / "hy" / "hy_memory.log").read_text(encoding="utf-8")
|
||||||
|
core_text = log_path(tmp_path).read_text(encoding="utf-8")
|
||||||
|
assert "routine sdk line" in hy_text and "sdk problem" in hy_text
|
||||||
|
assert "sdk problem" in core_text
|
||||||
|
assert "routine sdk line" not in core_text
|
||||||
|
assert core_text.count("sdk problem") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_changes_before_hy_has_configured_its_logger(
|
||||||
|
tmp_path: Path, sdk_logger: logging.Logger
|
||||||
|
) -> None:
|
||||||
|
start_logging_to_file(tmp_path)
|
||||||
|
sdk_logger.handlers = []
|
||||||
|
sdk_logger.propagate = True
|
||||||
|
|
||||||
|
confine_hy_sdk_logging()
|
||||||
|
|
||||||
|
assert sdk_logger.handlers == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_core_log_the_streams_are_still_dropped(
|
||||||
|
tmp_path: Path, sdk_logger: logging.Logger
|
||||||
|
) -> None:
|
||||||
|
logging.getLogger().handlers = [
|
||||||
|
handler
|
||||||
|
for handler in logging.getLogger().handlers
|
||||||
|
if not isinstance(handler, RotatingFileHandler)
|
||||||
|
]
|
||||||
|
text_log = _configure_like_hy(sdk_logger, tmp_path / "hy")
|
||||||
|
|
||||||
|
confine_hy_sdk_logging()
|
||||||
|
|
||||||
|
assert sdk_logger.handlers == [text_log]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("fails", [False, True])
|
||||||
|
def test_every_client_construction_confines_the_sdk_log(
|
||||||
|
tmp_path: Path, monkeypatch, fails: bool
|
||||||
|
) -> None:
|
||||||
|
order: list[str] = []
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, **_options):
|
||||||
|
order.append("client")
|
||||||
|
if fails:
|
||||||
|
raise RuntimeError("construction failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(hy_sdk_runtime, "HyMemoryClient", Client)
|
||||||
|
monkeypatch.setattr(hy_sdk_runtime, "build_sdk_config", lambda *_a, **_k: SimpleNamespace())
|
||||||
|
monkeypatch.setattr(hy_sdk_runtime, "inspect_graph_width_mismatch", lambda _c: None)
|
||||||
|
monkeypatch.setattr(hy_sdk_runtime, "_acquire_runtime", lambda _c, _k: object())
|
||||||
|
monkeypatch.setattr(hy_sdk_runtime, "_release_runtime", lambda _k: None)
|
||||||
|
monkeypatch.setattr(hy_sdk_runtime, "confine_hy_sdk_logging", lambda: order.append("confine"))
|
||||||
|
profile = SimpleNamespace(embedding_provider_kind=None, provider_kind=None)
|
||||||
|
|
||||||
|
if fails:
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
hy_sdk_runtime._acquire_client(
|
||||||
|
profile, api_key=None, data_dir=tmp_path, http_transport_factory=None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
hy_sdk_runtime._acquire_client(
|
||||||
|
profile, api_key=None, data_dir=tmp_path, http_transport_factory=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert order == ["client", "confine"]
|
||||||
@@ -773,3 +773,131 @@ def test_cancel_during_callback_keeps_a_replayable_pending_marker(tmp_path) -> N
|
|||||||
|
|
||||||
assert evidence_store.count() == 1
|
assert evidence_store.count() == 1
|
||||||
assert len(_pending_markers(evidence_store.database_path)) == 1
|
assert len(_pending_markers(evidence_store.database_path)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def said(message_id: str, role: str, text: str) -> EvidenceInput:
|
||||||
|
writer = {"user": WrittenBy.PERSON, "assistant": WrittenBy.AGENT, "tool": WrittenBy.TOOL}
|
||||||
|
return evidence(message_id).model_copy(
|
||||||
|
update={"role": role, "written_by": writer[role], "verbatim": text}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sdk_runner(tmp_path):
|
||||||
|
database = tmp_path / "one-memory.sqlite3"
|
||||||
|
bootstrap_fresh_management_schema(database)
|
||||||
|
job_store = ImportJobStore(database)
|
||||||
|
queue = DurableWriteQueue(database, sdk_authority=True)
|
||||||
|
runner = ImportRunner(
|
||||||
|
evidence_store=RetiredEvidenceStore(database), # type: ignore[arg-type]
|
||||||
|
job_store=job_store,
|
||||||
|
sdk_queue=queue,
|
||||||
|
sdk_authority=lambda: True,
|
||||||
|
)
|
||||||
|
return database, job_store, queue, runner
|
||||||
|
|
||||||
|
|
||||||
|
def _read(runner, job_store, parts, *, operation: str, first_line: int = 1):
|
||||||
|
events: list[ScanEvent] = [
|
||||||
|
ScannedEvidence(evidence=part, cursor=cursor(f"line:{first_line + index}"))
|
||||||
|
for index, part in enumerate(parts)
|
||||||
|
]
|
||||||
|
last = f"line:{first_line + len(parts) - 1}"
|
||||||
|
events.append(ScanCursorTouch(cursor=cursor(last).model_copy(update={"read_to_end": True})))
|
||||||
|
job = job_store.create(user_id="local", source_id="fixture", operation=operation)
|
||||||
|
asyncio.run(runner.run(adapter=FixtureAdapter(events), job_id=job.job_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _jobs(database) -> list[tuple[str, list[str]]]:
|
||||||
|
import json
|
||||||
|
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT state,payload_json FROM memory_write_jobs ORDER BY queue_seq"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
(state, [item["role"] for item in json.loads(payload)["input_records"]])
|
||||||
|
for state, payload in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _turns(count: int, *, start: int = 0) -> list[EvidenceInput]:
|
||||||
|
return [
|
||||||
|
said(f"{role[0]}{index}", role, f"{role} {index}")
|
||||||
|
for index in range(start, start + count)
|
||||||
|
for role in ("user", "assistant")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_import_writes_five_turn_batches_and_flushes_the_rest_at_eof(tmp_path):
|
||||||
|
"""A first import used to be one giant job per conversation."""
|
||||||
|
database, job_store, _queue, runner = _sdk_runner(tmp_path)
|
||||||
|
|
||||||
|
_read(runner, job_store, _turns(7), operation="history")
|
||||||
|
|
||||||
|
assert _jobs(database) == [
|
||||||
|
("pending", ["user", "assistant"] * 5),
|
||||||
|
("pending", ["user", "assistant"] * 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_polling_carries_an_open_turn_and_a_tool_only_increment(tmp_path):
|
||||||
|
"""A transcript read mid-turn used to give tool-only jobs (16 unknown)."""
|
||||||
|
database, job_store, queue, runner = _sdk_runner(tmp_path)
|
||||||
|
|
||||||
|
_read(runner, job_store, _turns(2), operation="sync")
|
||||||
|
assert _jobs(database) == [("assembling", ["user", "assistant"] * 2)]
|
||||||
|
|
||||||
|
tools = [said("t1", "tool", "ls"), said("t2", "tool", "3 passed")]
|
||||||
|
_read(runner, job_store, tools, operation="sync", first_line=5)
|
||||||
|
assert _jobs(database) == [
|
||||||
|
("assembling", ["user", "assistant", "user", "assistant", "tool", "tool"])
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ten quiet minutes: the open turn is complete and the short batch goes.
|
||||||
|
assert queue.flush_idle_assemblies(now=datetime.now(UTC) + timedelta(minutes=9)) == ()
|
||||||
|
flushed = queue.flush_idle_assemblies(now=datetime.now(UTC) + timedelta(minutes=11))
|
||||||
|
assert len(flushed) == 1
|
||||||
|
assert _jobs(database) == [
|
||||||
|
("pending", ["user", "assistant", "user", "assistant", "tool", "tool"])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_polling_writes_five_complete_turns_and_keeps_the_open_one(tmp_path):
|
||||||
|
database, job_store, queue, runner = _sdk_runner(tmp_path)
|
||||||
|
|
||||||
|
_read(runner, job_store, _turns(5), operation="sync")
|
||||||
|
assert [state for state, _ in _jobs(database)] == ["assembling"]
|
||||||
|
|
||||||
|
_read(runner, job_store, [said("u5", "user", "user 5")], operation="sync", first_line=11)
|
||||||
|
assert _jobs(database) == [
|
||||||
|
("assembling", ["user"]),
|
||||||
|
("pending", ["user", "assistant"] * 5),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reread_increment_creates_no_second_job(tmp_path):
|
||||||
|
database, job_store, queue, runner = _sdk_runner(tmp_path)
|
||||||
|
|
||||||
|
_read(runner, job_store, _turns(6), operation="sync")
|
||||||
|
_read(runner, job_store, _turns(6), operation="sync")
|
||||||
|
queue.flush_idle_assemblies(now=datetime.now(UTC) + timedelta(minutes=11))
|
||||||
|
_read(runner, job_store, _turns(6), operation="sync")
|
||||||
|
queue.flush_idle_assemblies(now=datetime.now(UTC) + timedelta(minutes=11))
|
||||||
|
|
||||||
|
assert _jobs(database) == [
|
||||||
|
("pending", ["user", "assistant"] * 5),
|
||||||
|
("pending", ["user", "assistant"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_records_with_no_user_message_are_dropped_at_eof(tmp_path):
|
||||||
|
database, job_store, _queue, runner = _sdk_runner(tmp_path)
|
||||||
|
|
||||||
|
_read(
|
||||||
|
runner,
|
||||||
|
job_store,
|
||||||
|
[said("t1", "tool", "ls"), said("a1", "assistant", "ok")],
|
||||||
|
operation="history",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _jobs(database) == []
|
||||||
@@ -5,6 +5,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -107,7 +108,15 @@ def test_same_message_id_from_the_file_does_not_duplicate(tmp_path) -> None:
|
|||||||
assert store.count() == 1
|
assert store.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def _quiet_for_ten_minutes(database: Path, store: EvidenceStore) -> tuple[str, ...]:
|
||||||
|
"""What Core's drain does once a live conversation has been quiet."""
|
||||||
|
return DurableWriteQueue(database, evidence=store).flush_idle_assemblies(
|
||||||
|
now=datetime.now(UTC) + timedelta(minutes=11)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_hook_submits_complete_turn_to_the_live_durable_lane(tmp_path) -> None:
|
def test_hook_submits_complete_turn_to_the_live_durable_lane(tmp_path) -> None:
|
||||||
|
"""The turn rule holds the turn until the next user message or 10 quiet minutes."""
|
||||||
store = _grant(tmp_path, "workbuddy", sync=True)
|
store = _grant(tmp_path, "workbuddy", sync=True)
|
||||||
database = tmp_path / "one-memory.sqlite3"
|
database = tmp_path / "one-memory.sqlite3"
|
||||||
|
|
||||||
@@ -132,10 +141,13 @@ def test_hook_submits_complete_turn_to_the_live_durable_lane(tmp_path) -> None:
|
|||||||
database,
|
database,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with closing(sqlite3.connect(database)) as db:
|
||||||
|
assert db.execute("SELECT state FROM memory_write_jobs").fetchall() == [("assembling",)]
|
||||||
|
assert len(_quiet_for_ten_minutes(database, store)) == 1
|
||||||
with closing(sqlite3.connect(database)) as db:
|
with closing(sqlite3.connect(database)) as db:
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
"SELECT priority,evidence_ids_json FROM memory_write_jobs "
|
"SELECT priority,evidence_ids_json FROM memory_write_jobs "
|
||||||
"WHERE idempotency_key LIKE 'turn:v1:%'"
|
"WHERE idempotency_key LIKE 'turn:v2:%'"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert row[0] == "live"
|
assert row[0] == "live"
|
||||||
assert len(json.loads(row[1])) == 2
|
assert len(json.loads(row[1])) == 2
|
||||||
@@ -161,6 +173,41 @@ def test_hook_submits_complete_turn_to_the_live_durable_lane(tmp_path) -> None:
|
|||||||
assert db.execute("SELECT COUNT(*) FROM evidence").fetchone() == (2,)
|
assert db.execute("SELECT COUNT(*) FROM evidence").fetchone() == (2,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hook_writes_five_turns_once_the_next_user_message_arrives(tmp_path) -> None:
|
||||||
|
store = _grant(tmp_path, "workbuddy", sync=True)
|
||||||
|
database = tmp_path / "one-memory.sqlite3"
|
||||||
|
|
||||||
|
def say(event: str, field: str, message_id: str, text: str) -> None:
|
||||||
|
assert capture_hook(
|
||||||
|
"workbuddy",
|
||||||
|
{
|
||||||
|
"hook_event_name": event,
|
||||||
|
"session_id": "sess-five",
|
||||||
|
"message_id": message_id,
|
||||||
|
field: text,
|
||||||
|
},
|
||||||
|
database,
|
||||||
|
)
|
||||||
|
|
||||||
|
for index in range(5):
|
||||||
|
say("UserPromptSubmit", "prompt", f"u{index}", f"第 {index} 个偏好")
|
||||||
|
say("Stop", "last_assistant_message", f"a{index}", f"记住第 {index} 个")
|
||||||
|
with closing(sqlite3.connect(database)) as db:
|
||||||
|
assert db.execute("SELECT state FROM memory_write_jobs").fetchall() == [("assembling",)]
|
||||||
|
|
||||||
|
say("UserPromptSubmit", "prompt", "u5", "第 5 个偏好")
|
||||||
|
|
||||||
|
with closing(sqlite3.connect(database)) as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT state,evidence_ids_json FROM memory_write_jobs ORDER BY queue_seq"
|
||||||
|
).fetchall()
|
||||||
|
assert [(state, len(json.loads(ids))) for state, ids in rows] == [
|
||||||
|
("assembling", 0),
|
||||||
|
("pending", 10),
|
||||||
|
]
|
||||||
|
assert store.count() == 11
|
||||||
|
|
||||||
|
|
||||||
def test_file_adopts_the_hook_row_when_the_text_is_unique(tmp_path) -> None:
|
def test_file_adopts_the_hook_row_when_the_text_is_unique(tmp_path) -> None:
|
||||||
store = _grant(tmp_path, "workbuddy", sync=True)
|
store = _grant(tmp_path, "workbuddy", sync=True)
|
||||||
database = tmp_path / "one-memory.sqlite3"
|
database = tmp_path / "one-memory.sqlite3"
|
||||||
@@ -211,6 +258,7 @@ def test_hook_job_completes_after_file_identity_adoption(tmp_path) -> None:
|
|||||||
},
|
},
|
||||||
database,
|
database,
|
||||||
)
|
)
|
||||||
|
assert len(_quiet_for_ten_minutes(database, store)) == 1
|
||||||
for message_id, role, author, text in (
|
for message_id, role, author, text in (
|
||||||
("file-user", "user", WrittenBy.PERSON, "我喜欢蓝莓"),
|
("file-user", "user", WrittenBy.PERSON, "我喜欢蓝莓"),
|
||||||
("file-assistant", "assistant", WrittenBy.AGENT, "我会记住你喜欢蓝莓"),
|
("file-assistant", "assistant", WrittenBy.AGENT, "我会记住你喜欢蓝莓"),
|
||||||
|
|||||||
@@ -166,6 +166,57 @@ def test_revoked_manual_source_is_not_silently_reauthorized(tmp_path):
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fresh_sdk
|
||||||
|
def test_sdk_manual_add_without_a_grant_row_is_claimed(tmp_path, monkeypatch):
|
||||||
|
"""The SDK add path writes no grant row; the claim gate used to require one.
|
||||||
|
|
||||||
|
The intake admitted the job, the drain skipped it on every tick, and the
|
||||||
|
note stayed Pending with attempts=0 while later jobs ran. A missing grant
|
||||||
|
row is allowed; only an explicit revocation holds the job.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
app = application(tmp_path)
|
||||||
|
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||||
|
addition = {"request_id": str(uuid4()), "text": "Remember this.", "scope": "all-apps"}
|
||||||
|
assert (await client.post("/api/v2/memories", json=addition)).status_code == 201
|
||||||
|
database = app.state.settings.data_dir / "one-memory.sqlite3"
|
||||||
|
with sqlite3.connect(database) as db:
|
||||||
|
assert (
|
||||||
|
db.execute(
|
||||||
|
"SELECT COUNT(*) FROM source_grants WHERE source_id='one-memory-manual'"
|
||||||
|
).fetchone()[0]
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
request_id = db.execute("SELECT request_id FROM memory_note_requests").fetchone()[0]
|
||||||
|
job = app.state.memory_write_queue.get(request_id)
|
||||||
|
assert job is not None and job.state == "pending"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
app.state.model_profile_store, "active_identity", lambda: ("profile", 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert app.state.memory_write_drain.claim(limit=1) == (job.job_id,)
|
||||||
|
|
||||||
|
# An explicit revocation still holds the job and refuses new adds.
|
||||||
|
app.state.memory_write_queue.settle(job.job_id, state="pending", owner=None)
|
||||||
|
with sqlite3.connect(database) as db:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO source_grants (user_id,source_id,root_path,requested_path,"
|
||||||
|
"root_identity,candidate_kind,granted_at,revoked_at,revision) VALUES "
|
||||||
|
"('local','one-memory-manual','x','x','manual','default',"
|
||||||
|
"'2026-10-01T00:00:00+00:00','2026-10-27T00:00:00+00:00',1)"
|
||||||
|
)
|
||||||
|
assert app.state.memory_write_drain.claim(limit=1) == ()
|
||||||
|
refused = await client.post(
|
||||||
|
"/api/v2/memories",
|
||||||
|
json={**addition, "request_id": str(uuid4())},
|
||||||
|
)
|
||||||
|
assert refused.status_code == 409
|
||||||
|
assert refused.json()["detail"] == "memory_add_source_revoked"
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.fresh_sdk
|
@pytest.mark.fresh_sdk
|
||||||
def test_sdk_memory_write_accepts_openai_messages_without_evidence_tables(tmp_path):
|
def test_sdk_memory_write_accepts_openai_messages_without_evidence_tables(tmp_path):
|
||||||
async def scenario():
|
async def scenario():
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ and delete pass the memory's kind/variant through to the SDK.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
@@ -55,6 +56,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"):
|
def experience_row(memory_id, task, solution, *, variant="working", source="codebuddy"):
|
||||||
return {
|
return {
|
||||||
"experience_id": memory_id,
|
"experience_id": memory_id,
|
||||||
@@ -63,6 +67,8 @@ def experience_row(memory_id, task, solution, *, variant="working", source="code
|
|||||||
"task": task,
|
"task": task,
|
||||||
"solution": solution,
|
"solution": solution,
|
||||||
"source": source,
|
"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",
|
"created_at": "2026-10-01T08:00:00",
|
||||||
"updated_at": "2026-10-02T08:00:00",
|
"updated_at": "2026-10-02T08:00:00",
|
||||||
"_variant": variant,
|
"_variant": variant,
|
||||||
@@ -90,6 +96,7 @@ class FakeSdk:
|
|||||||
self.productivity = {}
|
self.productivity = {}
|
||||||
self.search_calls = []
|
self.search_calls = []
|
||||||
self.list_calls = []
|
self.list_calls = []
|
||||||
|
self.stats_calls = []
|
||||||
self.get_calls = []
|
self.get_calls = []
|
||||||
self.update_calls = []
|
self.update_calls = []
|
||||||
self.delete_calls = []
|
self.delete_calls = []
|
||||||
@@ -227,6 +234,22 @@ class FakeSdk:
|
|||||||
"elapsed_ms": 1.0,
|
"elapsed_ms": 1.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def memory_stats(self, *, user_id, statuses=None):
|
||||||
|
self.stats_calls.append({"user_id": user_id, "statuses": statuses})
|
||||||
|
experiences = [
|
||||||
|
variant for (kind, variant, _id) in self.productivity if kind == "experience"
|
||||||
|
]
|
||||||
|
facts = [variant for (kind, variant, _id) in self.productivity if kind == "fact"]
|
||||||
|
scenes = {"chat": {"count": len(self.chat)}}
|
||||||
|
for variant in ("coding", "working"):
|
||||||
|
scenes[variant] = {"count": experiences.count(variant), "facts": facts.count(variant)}
|
||||||
|
return {
|
||||||
|
"total": len(self.chat) + len(experiences),
|
||||||
|
"scenes": scenes,
|
||||||
|
"request_id": "stats",
|
||||||
|
"elapsed_ms": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
def get(self, memory_id, kind=None, variant=None):
|
def get(self, memory_id, kind=None, variant=None):
|
||||||
self.get_calls.append((memory_id, kind, variant))
|
self.get_calls.append((memory_id, kind, variant))
|
||||||
match = self._matches(memory_id, kind, variant)
|
match = self._matches(memory_id, kind, variant)
|
||||||
@@ -288,12 +311,13 @@ def run_with_sdk(tmp_path, monkeypatch, scenario):
|
|||||||
async def close(self):
|
async def close(self):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
async def write_client():
|
async def write_client():
|
||||||
return sdk
|
yield sdk
|
||||||
|
|
||||||
async with app.router.lifespan_context(app):
|
async with app.router.lifespan_context(app):
|
||||||
app.state.sdk_client_handle = Handle()
|
app.state.sdk_client_handle = Handle()
|
||||||
monkeypatch.setattr(app.state.sdk_write_coordinator, "client", write_client)
|
monkeypatch.setattr(app.state.sdk_write_coordinator, "use_client", write_client)
|
||||||
async with client_for(app) as client:
|
async with client_for(app) as client:
|
||||||
await scenario(app, sdk, client)
|
await scenario(app, sdk, client)
|
||||||
|
|
||||||
@@ -429,6 +453,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["content"] == "Fix flaky tests\nPin the pytest seed"
|
||||||
assert experience["status"] == "active"
|
assert experience["status"] == "active"
|
||||||
assert experience["updated_at"] > experience["created_at"]
|
assert experience["updated_at"] > experience["created_at"]
|
||||||
|
assert experience["memory_at"] == EXPERIENCE_MEMORY_AT
|
||||||
fact = by_id["fact-1"]
|
fact = by_id["fact-1"]
|
||||||
assert (fact["scene"], fact["kind"], fact["variant"]) == ("coding", "fact", "coding")
|
assert (fact["scene"], fact["kind"], fact["variant"]) == ("coding", "fact", "coding")
|
||||||
assert fact["content"] == "The pytest suite runs in CI"
|
assert fact["content"] == "The pytest suite runs in CI"
|
||||||
@@ -471,6 +496,10 @@ def test_empty_query_lists_all_scenes_with_source_filter_and_totals(tmp_path, mo
|
|||||||
"chat",
|
"chat",
|
||||||
"chat",
|
"chat",
|
||||||
]
|
]
|
||||||
|
assert [item["memory_at"] for item in body["memories"][:2]] == [
|
||||||
|
EXPERIENCE_MEMORY_AT,
|
||||||
|
EXPERIENCE_MEMORY_AT,
|
||||||
|
]
|
||||||
call = sdk.list_calls[0]
|
call = sdk.list_calls[0]
|
||||||
assert call["scene"] == "all"
|
assert call["scene"] == "all"
|
||||||
assert call["include_sources"] is None
|
assert call["include_sources"] is None
|
||||||
@@ -510,6 +539,7 @@ def test_detail_edit_delete_pass_kind_and_variant(tmp_path, monkeypatch):
|
|||||||
assert record["task"] == "Old task"
|
assert record["task"] == "Old task"
|
||||||
assert record["solution"] == "Old solution"
|
assert record["solution"] == "Old solution"
|
||||||
assert record["content"] == "Old task\nOld solution"
|
assert record["content"] == "Old task\nOld solution"
|
||||||
|
assert record["memory_at"] == EXPERIENCE_MEMORY_AT
|
||||||
assert sdk.get_calls[-1] == ("exp-1", "experience", "working")
|
assert sdk.get_calls[-1] == ("exp-1", "experience", "working")
|
||||||
|
|
||||||
# Chat detail with no namespace keeps the plain SDK call.
|
# Chat detail with no namespace keeps the plain SDK call.
|
||||||
@@ -647,3 +677,34 @@ def test_scene_filter_narrows_search_and_binds_the_window(tmp_path, monkeypatch)
|
|||||||
assert len(sdk.search_calls) == 2
|
assert len(sdk.search_calls) == 2
|
||||||
|
|
||||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fresh_sdk
|
||||||
|
def test_home_summary_counts_what_the_memory_page_lists(tmp_path, monkeypatch):
|
||||||
|
"""Home showed 55 (chat only) while the memory page showed 94."""
|
||||||
|
|
||||||
|
async def scenario(app, sdk, client):
|
||||||
|
sdk.add_chat(chat_row("chat-1", "cursor chat"))
|
||||||
|
sdk.add_chat(chat_row("chat-2", "codebuddy chat", source="codebuddy"))
|
||||||
|
sdk.add_productivity("experience", experience_row("exp-w", "task w", "solution w"))
|
||||||
|
sdk.add_productivity(
|
||||||
|
"experience", experience_row("exp-c", "task c", "solution c", variant="coding")
|
||||||
|
)
|
||||||
|
# Facts are not listed on the memory page, so they are not counted.
|
||||||
|
sdk.add_productivity("fact", fact_row("fact-1", "The pytest suite runs in CI"))
|
||||||
|
|
||||||
|
response = await client.get("/api/v2/home-summary")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
library = (await client.get("/api/v2/memory-library")).json()
|
||||||
|
assert body["memory_count"] == library["total"] == 4
|
||||||
|
assert sdk.stats_calls == [{"user_id": "local", "statuses": ["active"]}]
|
||||||
|
# The newest memory across scenes, not the newest chat memory.
|
||||||
|
home_list = sdk.list_calls[0]
|
||||||
|
assert home_list["scene"] == "all"
|
||||||
|
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)
|
||||||
@@ -1118,6 +1118,236 @@ def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_additions
|
|||||||
assert len(client.calls) == 1
|
assert len(client.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_batch_with_no_user_or_assistant_message_is_nothing_to_remember(tmp_path) -> None:
|
||||||
|
"""Tool-only input came back 400 "content or messages is required" (16 unknown).
|
||||||
|
|
||||||
|
The SDK strips tool records on the chat path. Such a batch completes with
|
||||||
|
no memories; the SDK is never called and nothing is recorded unknown.
|
||||||
|
"""
|
||||||
|
from one_memory.imports.models import WrittenBy
|
||||||
|
from one_memory.sdk_write_processor import SdkWriteProcessor
|
||||||
|
|
||||||
|
client = _SdkPublicClient()
|
||||||
|
processor = SdkWriteProcessor(_SdkPublicHandle(client)) # type: ignore[arg-type]
|
||||||
|
tools = tuple(
|
||||||
|
_sdk_evidence(f"tool-{index}", text="3 passed").model_copy(
|
||||||
|
update={"role": "tool", "written_by": WrittenBy.TOOL}
|
||||||
|
)
|
||||||
|
for index in range(2)
|
||||||
|
)
|
||||||
|
path = tmp_path / "one-memory.sqlite3"
|
||||||
|
queue = DurableWriteQueue(path)
|
||||||
|
record = queue.submit(
|
||||||
|
WriteRequest(
|
||||||
|
internal_session_id="sess-tools",
|
||||||
|
source_id="cursor",
|
||||||
|
batch_identity=BatchIdentity(
|
||||||
|
internal_session_id="sess-tools", first_round=1, last_round=1
|
||||||
|
),
|
||||||
|
evidence_ids=[item.evidence_id for item in tools],
|
||||||
|
input_records=tools,
|
||||||
|
idempotency_key="tools-only",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_claim_through_drain(queue, record.job_id)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
execute_job(
|
||||||
|
record.job_id,
|
||||||
|
queue=queue,
|
||||||
|
supervisor=processor,
|
||||||
|
evidence=_EvidenceStore(set()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.calls == []
|
||||||
|
assert client.events == []
|
||||||
|
[row] = _rows(path)
|
||||||
|
assert (row["state"], row["hy_invoked"], row["sdk_memory_ids_json"]) == (COMPLETED, 0, "[]")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_drain_tick_writes_a_quiet_live_conversation(tmp_path, monkeypatch) -> None:
|
||||||
|
"""The 10-minute idle flush runs on the drain's maintenance timer."""
|
||||||
|
path = tmp_path / "one-memory.sqlite3"
|
||||||
|
queue = DurableWriteQueue(path)
|
||||||
|
flushed: list[str] = []
|
||||||
|
monkeypatch.setattr(queue, "flush_idle_assemblies", lambda: flushed.append("flush") or ())
|
||||||
|
drain = DurableWriteDrain(queue, execute=_never_execute, reap_interval=timedelta(0))
|
||||||
|
|
||||||
|
async def tick() -> None:
|
||||||
|
task = asyncio.create_task(drain.run_forever())
|
||||||
|
for _ in range(100):
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
if flushed:
|
||||||
|
break
|
||||||
|
drain.stop()
|
||||||
|
await task
|
||||||
|
|
||||||
|
asyncio.run(tick())
|
||||||
|
assert flushed
|
||||||
|
|
||||||
|
|
||||||
|
class _SwitchableProfiles:
|
||||||
|
"""Active profile identity that a test can change mid-write."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.identity = ("profile-a", 1)
|
||||||
|
|
||||||
|
def active_identity(self):
|
||||||
|
return self.identity
|
||||||
|
|
||||||
|
def snapshot(self, profile_id, revision):
|
||||||
|
return type(
|
||||||
|
"Snapshot", (), {"profile_id": profile_id, "revision": revision, "has_api_key": False}
|
||||||
|
)()
|
||||||
|
|
||||||
|
def resolve_snapshot_secret(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _TrackedHandle:
|
||||||
|
def __init__(self, profile, **_kwargs) -> None:
|
||||||
|
self.profile_id = profile.profile_id
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def client(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def _coordinator(monkeypatch, opened: list[_TrackedHandle]):
|
||||||
|
from one_memory import sdk_write_processor
|
||||||
|
|
||||||
|
def handle(profile, **kwargs):
|
||||||
|
value = _TrackedHandle(profile, **kwargs)
|
||||||
|
opened.append(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
monkeypatch.setattr(sdk_write_processor, "SdkClientHandle", handle)
|
||||||
|
profiles = _SwitchableProfiles()
|
||||||
|
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
||||||
|
profiles=profiles, # type: ignore[arg-type]
|
||||||
|
secrets=None, # type: ignore[arg-type]
|
||||||
|
data_dir=None,
|
||||||
|
transport_factory=lambda _profile: None,
|
||||||
|
sedimentation_enabled=lambda: True,
|
||||||
|
)
|
||||||
|
return coordinator, profiles
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeypatch) -> None:
|
||||||
|
"""Build 35 lost a clean install's first write as unknown (AttributeError).
|
||||||
|
|
||||||
|
Saving a model profile changed the active identity while the write ran on
|
||||||
|
the old profile's client. The next coordinator call closed that client at
|
||||||
|
once: the SDK cleared its stores and the SharedRuntime closed mid-`add`.
|
||||||
|
"""
|
||||||
|
from one_memory import sdk_write_processor
|
||||||
|
|
||||||
|
opened: list[_TrackedHandle] = []
|
||||||
|
coordinator, profiles = _coordinator(monkeypatch, opened)
|
||||||
|
seen: list[tuple[str, bool]] = []
|
||||||
|
writing = asyncio.Event()
|
||||||
|
|
||||||
|
async def process(self, records, *, request_id, before_hy_invocation):
|
||||||
|
client = await self._client_handle.client()
|
||||||
|
await before_hy_invocation()
|
||||||
|
seen.append((client.profile_id, client.closed))
|
||||||
|
if client.profile_id == "profile-a":
|
||||||
|
await writing.wait()
|
||||||
|
# Still open while its own write runs.
|
||||||
|
seen.append((client.profile_id, client.closed))
|
||||||
|
return sdk_write_processor.SdkWriteReceipt()
|
||||||
|
|
||||||
|
monkeypatch.setattr(sdk_write_processor.SdkWriteProcessor, "process_sdk_job", process)
|
||||||
|
|
||||||
|
async def noop():
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
first = asyncio.create_task(
|
||||||
|
coordinator.process_sdk_job((), request_id="a", before_hy_invocation=noop)
|
||||||
|
)
|
||||||
|
while not seen:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
profiles.identity = ("profile-b", 2)
|
||||||
|
# A second job and an inquiry under the new profile.
|
||||||
|
await coordinator.process_sdk_job((), request_id="b", before_hy_invocation=noop)
|
||||||
|
assert [handle.closed for handle in opened] == [False, False]
|
||||||
|
writing.set()
|
||||||
|
await first
|
||||||
|
old, new = opened
|
||||||
|
# Retired after its last use, and new work used the new profile.
|
||||||
|
assert old.closed is True and new.closed is False
|
||||||
|
await coordinator.close()
|
||||||
|
assert new.closed is True
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
assert seen == [
|
||||||
|
("profile-a", False),
|
||||||
|
("profile-b", False),
|
||||||
|
("profile-b", False),
|
||||||
|
("profile-a", False),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unused_stale_handle_still_closes_at_once(monkeypatch) -> None:
|
||||||
|
opened: list[_TrackedHandle] = []
|
||||||
|
coordinator, profiles = _coordinator(monkeypatch, opened)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async with coordinator.use_client() as client:
|
||||||
|
assert client.profile_id == "profile-a"
|
||||||
|
assert opened[0].closed is False
|
||||||
|
profiles.identity = ("profile-b", 2)
|
||||||
|
async with coordinator.use_client() as client:
|
||||||
|
assert client.profile_id == "profile-b"
|
||||||
|
assert opened[0].closed is True
|
||||||
|
assert opened[1].closed is False
|
||||||
|
await coordinator.close()
|
||||||
|
assert opened[1].closed is True
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_client_that_fails_to_construct_parks_the_job_instead_of_unknown(tmp_path) -> None:
|
||||||
|
"""Nothing was sent when the client could not be built, so it retries on its own."""
|
||||||
|
from one_memory.sdk_write_processor import SdkWriteProcessor
|
||||||
|
|
||||||
|
class _BrokenHandle:
|
||||||
|
async def client(self):
|
||||||
|
raise AttributeError("'RustBindingsAPI' object has no attribute 'bindings'")
|
||||||
|
|
||||||
|
path = tmp_path / "one-memory.sqlite3"
|
||||||
|
queue = DurableWriteQueue(path)
|
||||||
|
record = queue.submit(_request())
|
||||||
|
_claim_through_drain(queue, record.job_id)
|
||||||
|
|
||||||
|
class _Supervisor:
|
||||||
|
async def process_sdk_job(self, _records, **kwargs):
|
||||||
|
return await SdkWriteProcessor(_BrokenHandle()).process_sdk_job( # type: ignore[arg-type]
|
||||||
|
(_sdk_evidence("e1"),), **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
execute_job(
|
||||||
|
record.job_id,
|
||||||
|
queue=queue,
|
||||||
|
supervisor=_Supervisor(),
|
||||||
|
evidence=_EvidenceStore({"e1"}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
[row] = _rows(path)
|
||||||
|
assert (row["state"], row["hy_invoked"]) == (WAITING_FOR_MODEL, 0)
|
||||||
|
assert row["last_error"] == "hy_memory_client_unavailable"
|
||||||
|
later = datetime.now(UTC) + timedelta(minutes=5)
|
||||||
|
assert queue.readmit_model_waiters(now=later) == 1
|
||||||
|
assert _state(path, record.job_id) == PENDING
|
||||||
|
|
||||||
|
|
||||||
def test_sdk_unknown_outcome_inquiry_uses_only_public_receipt() -> None:
|
def test_sdk_unknown_outcome_inquiry_uses_only_public_receipt() -> None:
|
||||||
from one_memory.sdk_write_processor import SdkWriteProcessor, SdkWriteReceipt
|
from one_memory.sdk_write_processor import SdkWriteProcessor, SdkWriteReceipt
|
||||||
|
|
||||||
@@ -1630,6 +1860,46 @@ def test_a_raw_provider_message_is_never_stored_as_the_error(tmp_path) -> None:
|
|||||||
assert stored == "RuntimeError"
|
assert stored == "RuntimeError"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_job_logs_where_it_failed_but_never_the_message(tmp_path, caplog) -> None:
|
||||||
|
"""`error_code=AttributeError` alone named no location; frames, never the message."""
|
||||||
|
path = tmp_path / "one-memory.sqlite3"
|
||||||
|
queue = DurableWriteQueue(path)
|
||||||
|
record = queue.submit(_request())
|
||||||
|
_claim_through_drain(queue, record.job_id)
|
||||||
|
|
||||||
|
def closed_store(client):
|
||||||
|
return client.private_store_name.write
|
||||||
|
|
||||||
|
class _Failing:
|
||||||
|
async def process_sdk_job(self, _records, **kwargs):
|
||||||
|
await kwargs["before_hy_invocation"]()
|
||||||
|
try:
|
||||||
|
closed_store(type("Client", (), {"private_store_name": None})())
|
||||||
|
except AttributeError as error:
|
||||||
|
raise RuntimeError("quoting 'my secret diary'") from error
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR, logger="one_memory.memory_server.durable_queue"):
|
||||||
|
asyncio.run(
|
||||||
|
execute_job(
|
||||||
|
record.job_id,
|
||||||
|
queue=queue,
|
||||||
|
supervisor=_Failing(),
|
||||||
|
evidence=_EvidenceStore({"e1"}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
[line] = [
|
||||||
|
message.getMessage()
|
||||||
|
for message in caplog.records
|
||||||
|
if "durable_write_job_failed" in message.getMessage()
|
||||||
|
]
|
||||||
|
assert f"job_id={record.job_id} error_code=RuntimeError chain=RuntimeError@" in line
|
||||||
|
assert ";AttributeError@" in line
|
||||||
|
assert "test_memory_server_durable_queue.py:" in line and ":closed_store" in line
|
||||||
|
assert "secret diary" not in line
|
||||||
|
assert "private_store_name" not in line
|
||||||
|
|
||||||
|
|
||||||
def test_a_machine_error_code_is_preserved(tmp_path) -> None:
|
def test_a_machine_error_code_is_preserved(tmp_path) -> None:
|
||||||
"""Recognised codes survive; only free-form text is dropped."""
|
"""Recognised codes survive; only free-form text is dropped."""
|
||||||
from one_memory.memory_server.durable_queue import safe_error_code
|
from one_memory.memory_server.durable_queue import safe_error_code
|
||||||
|
|||||||
@@ -127,6 +127,28 @@ def test_the_manual_source_is_admitted_by_grant_without_a_toggle(tmp_path) -> No
|
|||||||
assert _sedimentation_claim_enabled(app, "one-memory-manual") is False
|
assert _sedimentation_claim_enabled(app, "one-memory-manual") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_manual_source_is_allowed_without_a_grant_row_until_revoked(tmp_path) -> None:
|
||||||
|
"""The SDK add path writes no grant row, and intake admits the record.
|
||||||
|
|
||||||
|
Claim must answer the same: a missing row is allowed, an explicit
|
||||||
|
revocation is not.
|
||||||
|
"""
|
||||||
|
receipt: list[object] = [None]
|
||||||
|
app = SimpleNamespace(
|
||||||
|
state=SimpleNamespace(
|
||||||
|
config_store=ConfigStore(tmp_path),
|
||||||
|
model_profile_store=SimpleNamespace(active_identity=lambda: ("profile", 1)),
|
||||||
|
source_grant_store=SimpleNamespace(receipt=lambda **_kwargs: receipt[0]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _source_writes_on(app, "one-memory-manual") is True
|
||||||
|
assert _sedimentation_claim_enabled(app, "one-memory-manual") is True
|
||||||
|
receipt[0] = SimpleNamespace(revoked_at="2026-10-27")
|
||||||
|
assert _source_writes_on(app, "one-memory-manual") is False
|
||||||
|
assert _sedimentation_claim_enabled(app, "one-memory-manual") is False
|
||||||
|
|
||||||
|
|
||||||
def test_claim_gate_waits_without_polling_when_no_model_is_active(tmp_path) -> None:
|
def test_claim_gate_waits_without_polling_when_no_model_is_active(tmp_path) -> None:
|
||||||
config = ConfigStore(tmp_path)
|
config = ConfigStore(tmp_path)
|
||||||
app = SimpleNamespace(
|
app = SimpleNamespace(
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""SDK clients of one home are constructed one at a time.
|
||||||
|
|
||||||
|
Build 35, clean install: right after the model was activated, the write
|
||||||
|
drain's client and a UI read client started in the same second. Each opened
|
||||||
|
Chroma on the same empty vector directory, Chroma's shared System cache has
|
||||||
|
no lock around that first open, and neither client reached `VectorStore
|
||||||
|
ready`. The first write job was lost as unknown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from one_memory import hy_sdk_runtime
|
||||||
|
from one_memory.hy_sdk_runtime import acquire_client, builtin_retrieval_snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_clients_on_a_fresh_home_are_constructed_one_at_a_time(tmp_path, monkeypatch):
|
||||||
|
real = hy_sdk_runtime.HyMemoryClient
|
||||||
|
state = {"inside": 0, "most": 0}
|
||||||
|
count = threading.Lock()
|
||||||
|
|
||||||
|
def recording_client(**options):
|
||||||
|
with count:
|
||||||
|
state["inside"] += 1
|
||||||
|
state["most"] = max(state["most"], state["inside"])
|
||||||
|
try:
|
||||||
|
# Widen the window two unserialized constructors would share.
|
||||||
|
time.sleep(0.2)
|
||||||
|
return real(**options)
|
||||||
|
finally:
|
||||||
|
with count:
|
||||||
|
state["inside"] -= 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(hy_sdk_runtime, "HyMemoryClient", recording_client)
|
||||||
|
start = threading.Barrier(2)
|
||||||
|
leases: list[hy_sdk_runtime.ClientLease] = []
|
||||||
|
failures: list[BaseException] = []
|
||||||
|
|
||||||
|
def open_client():
|
||||||
|
start.wait()
|
||||||
|
try:
|
||||||
|
leases.append(
|
||||||
|
acquire_client(builtin_retrieval_snapshot(), api_key=None, data_dir=tmp_path)
|
||||||
|
)
|
||||||
|
except BaseException as error: # noqa: BLE001 - reported below
|
||||||
|
failures.append(error)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=open_client) for _ in range(2)]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(120)
|
||||||
|
try:
|
||||||
|
assert failures == []
|
||||||
|
assert len(leases) == 2
|
||||||
|
assert state["most"] == 1
|
||||||
|
for lease in leases:
|
||||||
|
assert lease.client.memory_stats(user_id="local") is not None
|
||||||
|
finally:
|
||||||
|
for lease in leases:
|
||||||
|
lease.close()
|
||||||
@@ -37,6 +37,7 @@ from one_memory.sdk_migration_runner import (
|
|||||||
abandoned_legacy_work,
|
abandoned_legacy_work,
|
||||||
commit_sdk_cutover,
|
commit_sdk_cutover,
|
||||||
move_pending_dispatches,
|
move_pending_dispatches,
|
||||||
|
prepare_legacy_work_for_cutover,
|
||||||
prepare_legacy_write_queue_for_cutover,
|
prepare_legacy_write_queue_for_cutover,
|
||||||
recover_abandoned_staging,
|
recover_abandoned_staging,
|
||||||
resume_legacy_cleanup,
|
resume_legacy_cleanup,
|
||||||
@@ -137,19 +138,38 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store):
|
|||||||
)
|
)
|
||||||
coding = data_dir / "hy-memory" / "governed-v2" / "coding_cv.sqlite3"
|
coding = data_dir / "hy-memory" / "governed-v2" / "coding_cv.sqlite3"
|
||||||
with sqlite3.connect(coding) as database:
|
with sqlite3.connect(coding) as database:
|
||||||
database.execute(
|
# A store written by hy-memory 2.0.6, before the conversation time
|
||||||
"CREATE TABLE coding_memory_meta ("
|
# column: the repair must add it through the SDK and then fill it.
|
||||||
"experience_id TEXT,user_id TEXT,agent_id TEXT,solution TEXT,"
|
database.executescript(
|
||||||
"source TEXT,created_at TEXT,updated_at TEXT)"
|
"""
|
||||||
|
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(
|
database.execute(
|
||||||
"CREATE TABLE working_fact ("
|
"INSERT INTO coding_memory_meta "
|
||||||
"fact_id TEXT,user_id TEXT,agent_id TEXT,statement TEXT,"
|
"(experience_id,user_id,agent_id,task,solution,source,created_at,updated_at) "
|
||||||
"source TEXT,created_at TEXT)"
|
"VALUES ('sdk-1','local','scope','remember','remember','default','now','now')"
|
||||||
)
|
|
||||||
database.execute(
|
|
||||||
"INSERT INTO coding_memory_meta VALUES "
|
|
||||||
"('sdk-1','local','scope','remember','default','now','now')"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
result = repair_sdk_metadata_from_bundle(
|
result = repair_sdk_metadata_from_bundle(
|
||||||
@@ -168,10 +188,11 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store):
|
|||||||
2026, 10, 18, tzinfo=UTC
|
2026, 10, 18, tzinfo=UTC
|
||||||
)
|
)
|
||||||
with sqlite3.connect(coding) as database:
|
with sqlite3.connect(coding) as database:
|
||||||
source, created_at = database.execute(
|
source, memory_at, created_at = database.execute(
|
||||||
"SELECT source,created_at FROM coding_memory_meta WHERE experience_id='sdk-1'"
|
"SELECT source,memory_at,created_at FROM coding_memory_meta WHERE experience_id='sdk-1'"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert source == "workbuddy"
|
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")
|
assert created_at.startswith("2026-10-18T00:00:00")
|
||||||
|
|
||||||
repeated = repair_sdk_metadata_from_bundle(
|
repeated = repair_sdk_metadata_from_bundle(
|
||||||
@@ -705,6 +726,29 @@ def test_cutover_waits_until_the_new_runtime_reads_every_chat_memory(store, acti
|
|||||||
assert store.authority() is MemoryAuthority.SDK
|
assert store.authority() is MemoryAuthority.SDK
|
||||||
|
|
||||||
|
|
||||||
|
def test_sdk_migrate_repairs_metadata_after_cutover_and_writes_the_receipt(store, active_profile):
|
||||||
|
"""76/76 memories showed source=default after an upgrade whose repair failed.
|
||||||
|
|
||||||
|
The repair now runs here, offline, right after the cutover commits; Core's
|
||||||
|
startup finds the receipt and does nothing.
|
||||||
|
"""
|
||||||
|
_confirmed(store, "m1")
|
||||||
|
home = store.database_path.parent
|
||||||
|
|
||||||
|
result = run_startup_migration(
|
||||||
|
data_dir=home,
|
||||||
|
runtime_build="21",
|
||||||
|
sdk_version="2.0.5",
|
||||||
|
namespace="governed-v2",
|
||||||
|
open_client=_reading({"m1"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.state == "cutover"
|
||||||
|
receipt = home / f".sdk-metadata-repair-v2-{result.migration_id}.json"
|
||||||
|
assert receipt.is_file()
|
||||||
|
assert json.loads(receipt.read_text())["migration_id"] == result.migration_id
|
||||||
|
|
||||||
|
|
||||||
def test_retired_coding_experiences_do_not_block_but_an_empty_store_does(store, active_profile):
|
def test_retired_coding_experiences_do_not_block_but_an_empty_store_does(store, active_profile):
|
||||||
_confirmed(store, "chat-1")
|
_confirmed(store, "chat-1")
|
||||||
_confirmed(store, "exp-kept", "exp-retired", kind="coding")
|
_confirmed(store, "exp-kept", "exp-retired", kind="coding")
|
||||||
@@ -853,6 +897,116 @@ def test_legacy_work_that_may_have_reached_the_sdk_does_not_block_cutover(tmp_pa
|
|||||||
assert store.authority() is MemoryAuthority.SDK
|
assert store.authority() is MemoryAuthority.SDK
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_upgrade_unknown_writes_are_settled_at_cutover_not_retried(store):
|
||||||
|
"""A migrated 1.0.4 home kept 1000+ jobs unknown forever.
|
||||||
|
|
||||||
|
The legacy pipeline invoked them, so the SDK's write-outcome inquiry knows
|
||||||
|
none of them and the automatic reconcile could never settle one.
|
||||||
|
"""
|
||||||
|
from one_memory.memory_server.durable_queue import (
|
||||||
|
CANCELLED,
|
||||||
|
PENDING,
|
||||||
|
RUNNING,
|
||||||
|
UNKNOWN,
|
||||||
|
)
|
||||||
|
|
||||||
|
queue = DurableWriteQueue(store.database_path)
|
||||||
|
|
||||||
|
def job(key: str, state: str, *, invoked: bool) -> str:
|
||||||
|
identifier = queue.submit(
|
||||||
|
WriteRequest(
|
||||||
|
internal_session_id="session-1",
|
||||||
|
source_id="cursor",
|
||||||
|
batch_identity=BatchIdentity(
|
||||||
|
internal_session_id="session-1", first_round=1, last_round=1
|
||||||
|
),
|
||||||
|
evidence_ids=["gone"],
|
||||||
|
idempotency_key=key,
|
||||||
|
)
|
||||||
|
).job_id
|
||||||
|
with main_database(store.database_path).write() as database, database:
|
||||||
|
database.execute(
|
||||||
|
"UPDATE memory_write_jobs SET state=?,hy_invoked=?,"
|
||||||
|
"last_error='model_content_outcome_unknown' WHERE job_id=?",
|
||||||
|
(state, int(invoked), identifier),
|
||||||
|
)
|
||||||
|
return identifier
|
||||||
|
|
||||||
|
# Their Evidence is gone too, which must not block the cutover.
|
||||||
|
unknown = [job(f"unknown-{index}", UNKNOWN, invoked=True) for index in range(3)]
|
||||||
|
sent_running = job("sent-running", RUNNING, invoked=True)
|
||||||
|
finished = job("finished", "completed", invoked=True)
|
||||||
|
|
||||||
|
first = prepare_legacy_work_for_cutover(store.database_path)
|
||||||
|
assert first.abandoned == {"unknown_write_jobs": 4}
|
||||||
|
# Resumed after an interruption: nothing changes, the count is the same.
|
||||||
|
again = prepare_legacy_work_for_cutover(store.database_path)
|
||||||
|
assert again.as_document() == first.as_document()
|
||||||
|
|
||||||
|
with main_database(store.database_path).read() as database:
|
||||||
|
rows = dict(
|
||||||
|
(str(row[0]), (str(row[1]), str(row[2]), int(row[3])))
|
||||||
|
for row in database.execute(
|
||||||
|
"SELECT job_id,state,last_error,hy_invoked FROM memory_write_jobs"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for identifier in (*unknown, sent_running):
|
||||||
|
assert rows[identifier] == (CANCELLED, "abandoned_at_cutover", 1)
|
||||||
|
assert rows[finished][0] == "completed"
|
||||||
|
surviving = DurableWriteQueue(store.database_path, sdk_authority=True)
|
||||||
|
assert surviving.unknown_jobs() == ()
|
||||||
|
# Never requeued: they may already have written.
|
||||||
|
assert surviving.retry_unknown_confirmed(unknown[0]) == 0
|
||||||
|
assert surviving.readmit_parked() == 0
|
||||||
|
assert PENDING not in {state for state, _, _ in rows.values()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrying_unknown_writes_works_after_the_legacy_tables_are_dropped(store):
|
||||||
|
"""The retry button raised `no such table: memory_tasks` after an upgrade."""
|
||||||
|
from one_memory.memory_server.durable_queue import PENDING, UNKNOWN
|
||||||
|
from one_memory.target_schema import drop_legacy_tables
|
||||||
|
|
||||||
|
queue = DurableWriteQueue(store.database_path)
|
||||||
|
identifier = queue.submit(
|
||||||
|
WriteRequest(
|
||||||
|
internal_session_id="session-1",
|
||||||
|
source_id="cursor",
|
||||||
|
batch_identity=BatchIdentity(
|
||||||
|
internal_session_id="session-1", first_round=1, last_round=1
|
||||||
|
),
|
||||||
|
evidence_ids=["e1"],
|
||||||
|
idempotency_key="after-upgrade",
|
||||||
|
)
|
||||||
|
).job_id
|
||||||
|
drop_legacy_tables(store.database_path)
|
||||||
|
assert legacy_tables_present(store.database_path) == ()
|
||||||
|
old = datetime(2000, 1, 1, tzinfo=UTC).isoformat()
|
||||||
|
|
||||||
|
def make_unknown(code: str, attempts: int) -> None:
|
||||||
|
with main_database(store.database_path).write() as database, database:
|
||||||
|
database.execute(
|
||||||
|
"UPDATE memory_write_jobs SET state=?,hy_invoked=1,last_error=?,"
|
||||||
|
"attempts=?,updated_at=? WHERE job_id=?",
|
||||||
|
(UNKNOWN, code, attempts, old, identifier),
|
||||||
|
)
|
||||||
|
|
||||||
|
for sdk_authority in (True, False):
|
||||||
|
surviving = DurableWriteQueue(store.database_path, sdk_authority=sdk_authority)
|
||||||
|
make_unknown("model_content_outcome_unknown", 1)
|
||||||
|
assert surviving.resolve_unknown_from_linked_output(identifier) is False
|
||||||
|
assert surviving.retry_unknown_confirmed(identifier) == 1
|
||||||
|
assert surviving.get("after-upgrade").state == PENDING
|
||||||
|
|
||||||
|
make_unknown("hy_memory_timeout", 1)
|
||||||
|
since = datetime.now(UTC)
|
||||||
|
assert surviving.retry_timed_out_unknown_after_reconcile(older_than=since) == 1
|
||||||
|
make_unknown("model_content_outcome_unknown", 1)
|
||||||
|
assert surviving.retry_first_unknown_after_reconcile(older_than=since) == 1
|
||||||
|
if sdk_authority:
|
||||||
|
# The SDK-authority queue never brings a dropped table back.
|
||||||
|
assert legacy_tables_present(store.database_path) == ()
|
||||||
|
|
||||||
|
|
||||||
def _protective(home: Path) -> Path:
|
def _protective(home: Path) -> Path:
|
||||||
return home / "hy-memory" / ".governed-v2.pre-migration-0123456789abcdef"
|
return home / "hy-memory" / ".governed-v2.pre-migration-0123456789abcdef"
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path):
|
|||||||
assert manifest["bundle_id"] == "migration-1"
|
assert manifest["bundle_id"] == "migration-1"
|
||||||
assert manifest["sdk_namespace"] == "governed-v2"
|
assert manifest["sdk_namespace"] == "governed-v2"
|
||||||
assert manifest["runtime_build"] == "build-13"
|
assert manifest["runtime_build"] == "build-13"
|
||||||
assert manifest["snapshot_sdk_version"] == "2.0.6"
|
assert manifest["snapshot_sdk_version"] == "2.0.8"
|
||||||
paths = {item["path"]: item for item in manifest["files"]}
|
paths = {item["path"]: item for item in manifest["files"]}
|
||||||
assert {
|
assert {
|
||||||
"management/one-memory.sqlite3",
|
"management/one-memory.sqlite3",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -197,14 +198,17 @@ def test_live_import_callback_submits_realtime_priority(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
app.state.config_store.set_sync(source_id, True)
|
app.state.config_store.set_sync(source_id, True)
|
||||||
user = _committed_record(app, source_id=source_id, message_id="live-user")
|
user = _committed_record(app, source_id=source_id, message_id="live-user")
|
||||||
|
# Live writes by the turn rule: 5 complete turns, the fifth
|
||||||
|
# completed by the next user message.
|
||||||
|
for index in range(10):
|
||||||
record = app.state.evidence_store.put(
|
record = app.state.evidence_store.put(
|
||||||
EvidenceInput(
|
EvidenceInput(
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
conversation_id=user.conversation_id,
|
conversation_id=user.conversation_id,
|
||||||
message_id="live-assistant",
|
message_id=f"live-{index}",
|
||||||
role="assistant",
|
role="assistant" if index % 2 == 0 else "user",
|
||||||
written_by=WrittenBy.AGENT,
|
written_by=WrittenBy.AGENT if index % 2 == 0 else WrittenBy.PERSON,
|
||||||
verbatim="Live reply",
|
verbatim=f"Live line {index}",
|
||||||
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
||||||
)
|
)
|
||||||
).record
|
).record
|
||||||
@@ -216,11 +220,12 @@ def test_live_import_callback_submits_realtime_priority(tmp_path) -> None:
|
|||||||
finally:
|
finally:
|
||||||
live_sync_arrival.reset(token)
|
live_sync_arrival.reset(token)
|
||||||
with sqlite3.connect(app.state.evidence_store.database_path) as db:
|
with sqlite3.connect(app.state.evidence_store.database_path) as db:
|
||||||
priority = db.execute(
|
rows = db.execute(
|
||||||
"SELECT priority FROM memory_write_jobs WHERE idempotency_key LIKE ?",
|
"SELECT priority,evidence_ids_json FROM memory_write_jobs "
|
||||||
|
"WHERE idempotency_key LIKE ?",
|
||||||
("turn:v1:%",),
|
("turn:v1:%",),
|
||||||
).fetchone()[0]
|
).fetchall()
|
||||||
assert priority == "live"
|
assert [(priority, len(json.loads(ids))) for priority, ids in rows] == [("live", 10)]
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""D74: a turn reaches HY whole; only what HY will read counts toward the ceiling."""
|
"""D74: a turn reaches HY whole; only what HY will read counts toward the batch size."""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from one_memory.conversation_processing import _conversation_chunks
|
from one_memory.conversation_processing import turn_batches
|
||||||
from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy
|
from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy
|
||||||
from one_memory.imports.storage import EvidenceStore
|
from one_memory.imports.storage import EvidenceStore
|
||||||
|
|
||||||
@@ -36,66 +36,33 @@ def test_tool_heavy_turn_is_one_window(tmp_path):
|
|||||||
*[("assistant", "定位中") for _ in range(30)],
|
*[("assistant", "定位中") for _ in range(30)],
|
||||||
("assistant", "原因是 fixture 没清理" * 100),
|
("assistant", "原因是 fixture 没清理" * 100),
|
||||||
)
|
)
|
||||||
chunks = _conversation_chunks(records)
|
batches = turn_batches(records, flush=True).batches
|
||||||
assert len(chunks) == 1
|
assert batches == (tuple(records),)
|
||||||
assert chunks[0] == tuple(records)
|
|
||||||
|
|
||||||
|
|
||||||
def test_long_answer_is_not_cut(tmp_path):
|
def test_long_answer_is_not_cut(tmp_path):
|
||||||
records = _turn(tmp_path, ("user", "写一份方案"), ("assistant", "段落。" * 7_000))
|
records = _turn(tmp_path, ("user", "写一份方案"), ("assistant", "段落。" * 7_000))
|
||||||
chunks = _conversation_chunks(records)
|
batches = turn_batches(records, flush=True).batches
|
||||||
assert chunks == [tuple(records)]
|
assert batches == (tuple(records),)
|
||||||
assert all(":segment:" not in record.evidence_id for record in chunks[0])
|
assert all(":segment:" not in record.evidence_id for record in batches[0])
|
||||||
|
|
||||||
|
|
||||||
def test_text_over_the_ceiling_still_splits(tmp_path):
|
def test_a_turn_far_over_the_batch_size_is_still_one_window(tmp_path):
|
||||||
records = _turn(tmp_path, ("user", "整理"), ("assistant", "句子。" * 25_000))
|
"""The rule never splits a turn: it is written alone, whole.
|
||||||
chunks = _conversation_chunks(records)
|
|
||||||
assert len(chunks) > 1
|
|
||||||
assert all(chunk[0].evidence_id == records[0].evidence_id for chunk in chunks)
|
|
||||||
|
|
||||||
|
The old windows cut text past 60k characters and raw bytes past 2 MB into
|
||||||
def test_switch_restores_the_old_split(tmp_path, monkeypatch):
|
segments that repeated the user question. HY reads at most
|
||||||
monkeypatch.setenv("ONE_MEMORY_SPLIT_TURNS", "1")
|
`_HY_TOOL_CHARACTERS` of each tool record, so the window HY reads stays
|
||||||
records = _turn(tmp_path, ("user", "写一份方案"), ("assistant", "段落。" * 7_000))
|
bounded by the turn's own text.
|
||||||
assert len(_conversation_chunks(records)) > 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_explicit_limits_keep_old_semantics(tmp_path):
|
|
||||||
records = _turn(tmp_path, ("user", "q"), *[("assistant", "a" * 100) for _ in range(30)])
|
|
||||||
assert len(_conversation_chunks(records, max_turns=6, max_characters=1200)) > 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_window_is_capped_in_raw_bytes(tmp_path):
|
|
||||||
"""HY counts 2 KiB per tool record, but we still hold the raw text.
|
|
||||||
|
|
||||||
`client.add` is in-process, so a window's raw bytes are what the process
|
|
||||||
actually holds even though HY will cut each tool record down before it
|
|
||||||
reaches the model. Past the byte ceiling the window is cut, so no single
|
|
||||||
call can grow without bound.
|
|
||||||
"""
|
"""
|
||||||
from one_memory.conversation_processing import _WINDOW_BYTE_CEILING
|
|
||||||
|
|
||||||
records = _turn(
|
records = _turn(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
("user", "跑一下全部测试"),
|
("user", "跑一下全部测试"),
|
||||||
*[("tool", "y" * 400_000) for _ in range(20)],
|
*[("tool", "y" * 400_000) for _ in range(20)],
|
||||||
|
("assistant", "句子。" * 25_000),
|
||||||
)
|
)
|
||||||
chunks = _conversation_chunks(records)
|
batches = turn_batches(records, flush=True).batches
|
||||||
assert len(chunks) > 1
|
assert batches == (tuple(records),)
|
||||||
assert all(
|
|
||||||
sum(len(item.verbatim.encode("utf-8")) for item in chunk) <= _WINDOW_BYTE_CEILING
|
|
||||||
for chunk in chunks
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_byte_ceiling_counts_bytes_not_characters(tmp_path):
|
|
||||||
"""One CJK character is three bytes; a character count would let three
|
|
||||||
times as much through."""
|
|
||||||
from one_memory.conversation_processing import _WINDOW_BYTE_CEILING, _window_bytes
|
|
||||||
|
|
||||||
assert _window_bytes(_turn(tmp_path, ("user", "中文"))[0]) > len("中文")
|
|
||||||
assert _WINDOW_BYTE_CEILING == 2_000_000
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_sdk_strips_tool_messages_on_the_chat_path():
|
def test_the_sdk_strips_tool_messages_on_the_chat_path():
|
||||||
|
|||||||
Generated
+4
-4
@@ -991,7 +991,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hy-memory"
|
name = "hy-memory"
|
||||||
version = "2.0.6"
|
version = "2.0.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "chromadb" },
|
{ name = "chromadb" },
|
||||||
@@ -1008,9 +1008,9 @@ dependencies = [
|
|||||||
{ name = "scikit-learn" },
|
{ name = "scikit-learn" },
|
||||||
{ name = "tiktoken" },
|
{ 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/21/15/83ca83b649ead238f186fdb33efdc73615fbba279efbbdca3857c1a86beb/hy_memory-2.0.8.tar.gz", hash = "sha256:0fa0ed1a1160b389a3d7c13a9fb6528b140d7ec23cf49e65f1be5974820a576b", size = 856025, upload-time = "2026-10-28T07:35:47.783Z" }
|
||||||
wheels = [
|
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/11/6a/c699cac0ff06bd089fceeff90446130763d8187e649dbdb0313014491a10/hy_memory-2.0.8-py3-none-any.whl", hash = "sha256:68bd789df24e51e64bb231e97a8d24ee89b4aa5f9ba14c3e0824589e9213bf7c", size = 921119, upload-time = "2026-10-28T07:35:45.571Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1837,7 +1837,7 @@ requires-dist = [
|
|||||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||||
{ name = "filelock", specifier = ">=3.32.4" },
|
{ name = "filelock", specifier = ">=3.32.4" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "hy-memory", specifier = "==2.0.6" },
|
{ name = "hy-memory", specifier = "==2.0.8" },
|
||||||
{ name = "keyring", specifier = ">=25.7.0" },
|
{ name = "keyring", specifier = ">=25.7.0" },
|
||||||
{ name = "numpy", specifier = "==2.5.2" },
|
{ name = "numpy", specifier = "==2.5.2" },
|
||||||
{ name = "onnxruntime", specifier = "==1.23.2" },
|
{ name = "onnxruntime", specifier = "==1.23.2" },
|
||||||
|
|||||||
@@ -230,39 +230,21 @@ it("filters raw Core INFO and DEBUG while preserving warnings and errors", async
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows HY pipeline metrics without exposing model content", async () => {
|
it("does not read HY's own log directory", async () => {
|
||||||
const dataDir = await mkdtemp(
|
// MemHub turns the HY pipeline log off; files left by older builds and
|
||||||
path.join(os.tmpdir(), "om-debug-hy-pipeline-"),
|
// HY's text log stay on disk but are not part of the Debug panel.
|
||||||
);
|
const dataDir = await mkdtemp(path.join(os.tmpdir(), "om-debug-hy-logs-"));
|
||||||
roots.push(dataDir);
|
roots.push(dataDir);
|
||||||
const pipelineDir = path.join(
|
const hyLogs = path.join(dataDir, "hy-memory", "governed-v2", "logs");
|
||||||
dataDir,
|
await mkdir(path.join(hyLogs, "default"), { recursive: true });
|
||||||
"hy-memory",
|
|
||||||
"governed-v2",
|
|
||||||
"logs",
|
|
||||||
"default",
|
|
||||||
);
|
|
||||||
await mkdir(pipelineDir, { recursive: true });
|
|
||||||
const target = path.join(pipelineDir, "2026-10-14.log");
|
|
||||||
await writeFile(target, "");
|
|
||||||
const cursor = await captureDebugLogCursor(dataDir);
|
const cursor = await captureDebugLogCursor(dataDir);
|
||||||
const pipelineEntry = {
|
|
||||||
request_id: "request-safe-1",
|
|
||||||
user_id: "private-user",
|
|
||||||
agent_id: "private-agent",
|
|
||||||
session_id: "private-session",
|
|
||||||
step: "EXTRACT",
|
|
||||||
prompt: "private prompt",
|
|
||||||
content: "private response",
|
|
||||||
parsed: "private parsed output",
|
|
||||||
tokens: 42,
|
|
||||||
elapsed_ms: 123.5,
|
|
||||||
timestamp: "2026-10-14T10:01:00.000",
|
|
||||||
};
|
|
||||||
await appendFile(target, `${JSON.stringify(pipelineEntry)}\n`);
|
|
||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(dataDir, "hy-memory", "governed-v2", "logs", "hy_memory.log"),
|
path.join(hyLogs, "default", "2026-10-14.log"),
|
||||||
`${JSON.stringify({ ...pipelineEntry, request_id: "must-not-appear" })}\n`,
|
`${JSON.stringify({ request_id: "old-pipeline", step: "EXTRACT" })}\n`,
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(hyLogs, "hy_memory.log"),
|
||||||
|
"2026-10-14 10:01:00 [WARNING] [-] hy_memory.client: hy text log\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
const snapshot = await readDebugLogSnapshot(
|
const snapshot = await readDebugLogSnapshot(
|
||||||
@@ -271,29 +253,11 @@ it("shows HY pipeline metrics without exposing model content", async () => {
|
|||||||
cursor,
|
cursor,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(snapshot.entries).toEqual([
|
expect(snapshot.entries).toEqual([]);
|
||||||
expect.objectContaining({
|
expect(cursor).toEqual({
|
||||||
service: "hy-memory",
|
diagnosticLogs: [],
|
||||||
component: "hy.pipeline",
|
serviceLogs: expect.any(Array),
|
||||||
event: "hy.pipeline.extract",
|
});
|
||||||
message: "EXTRACT · 42 tokens · 124 ms",
|
|
||||||
duration_ms: 123.5,
|
|
||||||
trace_id: "request-safe-1",
|
|
||||||
details: { phase: "EXTRACT", count: 42 },
|
|
||||||
source: "pipeline",
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const serialized = JSON.stringify(snapshot);
|
|
||||||
for (const secret of [
|
|
||||||
"private prompt",
|
|
||||||
"private response",
|
|
||||||
"private parsed output",
|
|
||||||
"private-user",
|
|
||||||
"private-agent",
|
|
||||||
"private-session",
|
|
||||||
]) {
|
|
||||||
expect(serialized).not.toContain(secret);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs a renderer ui-action with action, page, phase and the shared trace id", async () => {
|
it("logs a renderer ui-action with action, page, phase and the shared trace id", async () => {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const UI_TRACE = "one-memory:log-ui-trace";
|
|||||||
const DEBUG_LOGS = "one-memory:get-debug-logs";
|
const DEBUG_LOGS = "one-memory:get-debug-logs";
|
||||||
const DEBUG_LOG_NAME =
|
const DEBUG_LOG_NAME =
|
||||||
/^diagnostic-(runtime|core|host-bridge|desktop|cli)\.jsonl(?:\.\d+)?$/;
|
/^diagnostic-(runtime|core|host-bridge|desktop|cli)\.jsonl(?:\.\d+)?$/;
|
||||||
const PIPELINE_LOG_NAME = /^\d{4}-\d{2}-\d{2}\.log$/;
|
|
||||||
const SERVICE_LOG_NAMES = [
|
const SERVICE_LOG_NAMES = [
|
||||||
"service.log",
|
"service.log",
|
||||||
"service-error.log",
|
"service-error.log",
|
||||||
@@ -30,24 +29,16 @@ interface ServiceLogCursor {
|
|||||||
files: Array<{ device: number; inode: number; offset: number }>;
|
files: Array<{ device: number; inode: number; offset: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PipelineLogCursor {
|
|
||||||
relativePath: string;
|
|
||||||
device: number;
|
|
||||||
inode: number;
|
|
||||||
offset: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DebugLogCursor {
|
export interface DebugLogCursor {
|
||||||
diagnosticLogs: Array<{ device: number; inode: number; offset: number }>;
|
diagnosticLogs: Array<{ device: number; inode: number; offset: number }>;
|
||||||
serviceLogs: ServiceLogCursor[];
|
serviceLogs: ServiceLogCursor[];
|
||||||
pipelineLogs: PipelineLogCursor[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DebugLogEntry {
|
export interface DebugLogEntry {
|
||||||
id: string;
|
id: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
level: "debug" | "info" | "warn" | "error";
|
level: "debug" | "info" | "warn" | "error";
|
||||||
service: "runtime" | "core" | "host-bridge" | "desktop" | "cli" | "hy-memory";
|
service: "runtime" | "core" | "host-bridge" | "desktop" | "cli";
|
||||||
component: string;
|
component: string;
|
||||||
event: string;
|
event: string;
|
||||||
outcome: string;
|
outcome: string;
|
||||||
@@ -57,7 +48,7 @@ export interface DebugLogEntry {
|
|||||||
trace_id?: string;
|
trace_id?: string;
|
||||||
details?: Record<string, string | number | boolean | null>;
|
details?: Record<string, string | number | boolean | null>;
|
||||||
message?: string;
|
message?: string;
|
||||||
source: "diagnostic" | "stdout" | "stderr" | "pipeline";
|
source: "diagnostic" | "stdout" | "stderr";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DebugLogSnapshot {
|
export interface DebugLogSnapshot {
|
||||||
@@ -259,7 +250,6 @@ export async function readDebugLogSnapshot(
|
|||||||
cursor: DebugLogCursor = {
|
cursor: DebugLogCursor = {
|
||||||
diagnosticLogs: [],
|
diagnosticLogs: [],
|
||||||
serviceLogs: [],
|
serviceLogs: [],
|
||||||
pipelineLogs: [],
|
|
||||||
},
|
},
|
||||||
): Promise<DebugLogSnapshot> {
|
): Promise<DebugLogSnapshot> {
|
||||||
const started = Number.isFinite(startedAt) ? startedAt : Date.now();
|
const started = Number.isFinite(startedAt) ? startedAt : Date.now();
|
||||||
@@ -300,7 +290,6 @@ export async function readDebugLogSnapshot(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entries.push(...(await readPipelineLogEntries(dataDir, cursor, started)));
|
|
||||||
entries.sort((left, right) =>
|
entries.sort((left, right) =>
|
||||||
left.timestamp === right.timestamp
|
left.timestamp === right.timestamp
|
||||||
? left.id.localeCompare(right.id)
|
? left.id.localeCompare(right.id)
|
||||||
@@ -319,7 +308,6 @@ export async function captureDebugLogCursor(
|
|||||||
): Promise<DebugLogCursor> {
|
): Promise<DebugLogCursor> {
|
||||||
const directory = path.join(dataDir, "logs");
|
const directory = path.join(dataDir, "logs");
|
||||||
const names = await readdir(directory).catch(() => []);
|
const names = await readdir(directory).catch(() => []);
|
||||||
const pipelineRoot = path.join(dataDir, "hy-memory", "governed-v2", "logs");
|
|
||||||
return {
|
return {
|
||||||
diagnosticLogs: (
|
diagnosticLogs: (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -355,7 +343,6 @@ export async function captureDebugLogCursor(
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
pipelineLogs: await pipelineLogFiles(pipelineRoot),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,8 +449,8 @@ async function readServiceLogEntries(
|
|||||||
if (!message) continue;
|
if (!message) continue;
|
||||||
const level = serviceLogLevel(message, cursor.name);
|
const level = serviceLogLevel(message, cursor.name);
|
||||||
// Raw stdout is an implementation trace, not a product event. Core's
|
// Raw stdout is an implementation trace, not a product event. Core's
|
||||||
// structured diagnostics and HY pipeline logs already carry meaningful
|
// structured diagnostics already carry meaningful successful work with
|
||||||
// successful work with request/task identity. Showing raw INFO/DEBUG
|
// request/task identity. Showing raw INFO/DEBUG
|
||||||
// here duplicated those events and turned polling access logs, file
|
// here duplicated those events and turned polling access logs, file
|
||||||
// watchers and library initialization into thousands of fake "useful"
|
// watchers and library initialization into thousands of fake "useful"
|
||||||
// rows. Keep raw output only when it needs attention.
|
// rows. Keep raw output only when it needs attention.
|
||||||
@@ -543,135 +530,6 @@ function logLineTimestamp(
|
|||||||
return new Date(Math.max(startedAt, fallback)).toISOString();
|
return new Date(Math.max(startedAt, fallback)).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pipelineLogFiles(root: string): Promise<PipelineLogCursor[]> {
|
|
||||||
const rootInfo = await lstat(root).catch(() => null);
|
|
||||||
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink()) return [];
|
|
||||||
const files: PipelineLogCursor[] = [];
|
|
||||||
const visit = async (directory: string): Promise<void> => {
|
|
||||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
||||||
const target = path.join(directory, entry.name);
|
|
||||||
if (entry.isSymbolicLink()) continue;
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
await visit(target);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// The same root also contains HY's human-readable hy_memory.log.
|
|
||||||
// Pipeline files are date-named JSONL; accepting every .log file makes
|
|
||||||
// the Debug poller repeatedly parse unrelated text and still display no
|
|
||||||
// HY entries.
|
|
||||||
if (!entry.isFile() || !PIPELINE_LOG_NAME.test(entry.name)) continue;
|
|
||||||
const info = await safeRegularFile(target);
|
|
||||||
if (!info) continue;
|
|
||||||
files.push({
|
|
||||||
relativePath: path.relative(root, target),
|
|
||||||
device: info.dev,
|
|
||||||
inode: info.ino,
|
|
||||||
offset: info.size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await visit(root);
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readPipelineLogEntries(
|
|
||||||
dataDir: string,
|
|
||||||
cursor: DebugLogCursor,
|
|
||||||
startedAt: number,
|
|
||||||
): Promise<DebugLogEntry[]> {
|
|
||||||
const root = path.join(dataDir, "hy-memory", "governed-v2", "logs");
|
|
||||||
const current = await pipelineLogFiles(root);
|
|
||||||
const baseline = new Map(
|
|
||||||
cursor.pipelineLogs.map((file) => [
|
|
||||||
`${file.device}:${file.inode}`,
|
|
||||||
file.offset,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
const entries: DebugLogEntry[] = [];
|
|
||||||
for (const file of current) {
|
|
||||||
const target = path.join(root, file.relativePath);
|
|
||||||
const offset = baseline.get(`${file.device}:${file.inode}`) ?? 0;
|
|
||||||
const info = await safeRegularFile(target);
|
|
||||||
if (!info || info.size <= offset) continue;
|
|
||||||
const content = await readFileFromOffset(target, offset).catch(() => "");
|
|
||||||
let lineNumber = 0;
|
|
||||||
for (const line of content.split("\n")) {
|
|
||||||
lineNumber += 1;
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
const entry = pipelineLogEntry(
|
|
||||||
line,
|
|
||||||
file,
|
|
||||||
lineNumber,
|
|
||||||
offset,
|
|
||||||
startedAt,
|
|
||||||
info.mtimeMs,
|
|
||||||
);
|
|
||||||
if (entry) entries.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pipelineLogEntry(
|
|
||||||
line: string,
|
|
||||||
file: PipelineLogCursor,
|
|
||||||
lineNumber: number,
|
|
||||||
offset: number,
|
|
||||||
startedAt: number,
|
|
||||||
fallbackTime: number,
|
|
||||||
): DebugLogEntry | null {
|
|
||||||
let value: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
value = JSON.parse(line) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const step = typeof value.step === "string" ? value.step.trim() : "";
|
|
||||||
if (!step || !/^[A-Za-z0-9_-]{1,80}$/.test(step)) return null;
|
|
||||||
const rawTimestamp =
|
|
||||||
typeof value.timestamp === "string" ? value.timestamp : "";
|
|
||||||
const parsedTimestamp = Date.parse(rawTimestamp);
|
|
||||||
const timestamp = new Date(
|
|
||||||
Math.max(
|
|
||||||
startedAt,
|
|
||||||
Number.isFinite(parsedTimestamp) ? parsedTimestamp : fallbackTime,
|
|
||||||
),
|
|
||||||
).toISOString();
|
|
||||||
const elapsed = safeFiniteNumber(value.elapsed_ms);
|
|
||||||
const tokens = safeFiniteNumber(value.tokens);
|
|
||||||
const requestId =
|
|
||||||
typeof value.request_id === "string" && value.request_id.length <= 256
|
|
||||||
? value.request_id
|
|
||||||
: undefined;
|
|
||||||
return {
|
|
||||||
id: `hy-pipeline:${file.device}:${file.inode}:${offset}:${lineNumber}`,
|
|
||||||
timestamp,
|
|
||||||
level: "info",
|
|
||||||
service: "hy-memory",
|
|
||||||
component: "hy.pipeline",
|
|
||||||
event: `hy.pipeline.${step.toLowerCase().replaceAll("_", "-")}`,
|
|
||||||
outcome: "observed",
|
|
||||||
...(elapsed === undefined ? {} : { duration_ms: elapsed }),
|
|
||||||
...(requestId ? { trace_id: requestId } : {}),
|
|
||||||
details: {
|
|
||||||
phase: step,
|
|
||||||
...(tokens === undefined ? {} : { count: tokens }),
|
|
||||||
},
|
|
||||||
message: [
|
|
||||||
step,
|
|
||||||
...(tokens === undefined ? [] : [`${Math.round(tokens)} tokens`]),
|
|
||||||
...(elapsed === undefined ? [] : [`${Math.round(elapsed)} ms`]),
|
|
||||||
].join(" · "),
|
|
||||||
source: "pipeline",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeFiniteNumber(value: unknown): number | undefined {
|
|
||||||
return typeof value === "number" && Number.isFinite(value)
|
|
||||||
? value
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function installDiagnosticsIpc(
|
export function installDiagnosticsIpc(
|
||||||
options: DiagnosticIpcOptions,
|
options: DiagnosticIpcOptions,
|
||||||
): () => void {
|
): () => void {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const fake = vi.hoisted(() => ({
|
|||||||
missingBroker: false,
|
missingBroker: false,
|
||||||
hostCapability: true,
|
hostCapability: true,
|
||||||
open: vi.fn(),
|
open: vi.fn(),
|
||||||
|
refreshHosts: vi.fn(async (_layouts: unknown, _options: unknown) => ({})),
|
||||||
host: vi.fn<
|
host: vi.fn<
|
||||||
(input: {
|
(input: {
|
||||||
hostId: string;
|
hostId: string;
|
||||||
@@ -274,7 +275,9 @@ beforeEach(async () => {
|
|||||||
fake.open.mockResolvedValue({
|
fake.open.mockResolvedValue({
|
||||||
hostGrantStore: {},
|
hostGrantStore: {},
|
||||||
repairInstalledCommand: vi.fn(async () => []),
|
repairInstalledCommand: vi.fn(async () => []),
|
||||||
|
refreshInstalledHosts: fake.refreshHosts,
|
||||||
});
|
});
|
||||||
|
fake.refreshHosts.mockClear();
|
||||||
fake.host.mockResolvedValue({ state: "synthetic-remote-result" });
|
fake.host.mockResolvedValue({ state: "synthetic-remote-result" });
|
||||||
Object.defineProperty(process, "resourcesPath", {
|
Object.defineProperty(process, "resourcesPath", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -590,5 +593,11 @@ describe("packaged Desktop host-control ownership", () => {
|
|||||||
expect(fake.ensureRuntime).not.toHaveBeenCalled();
|
expect(fake.ensureRuntime).not.toHaveBeenCalled();
|
||||||
expect(fake.createClient).not.toHaveBeenCalled();
|
expect(fake.createClient).not.toHaveBeenCalled();
|
||||||
expect(fake.windows).toHaveLength(1);
|
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(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
claudeCodeLayout,
|
claudeCodeLayout,
|
||||||
codexLayout,
|
codexLayout,
|
||||||
cursorLayout,
|
cursorLayout,
|
||||||
|
MCP_TOOLSET_KEY,
|
||||||
|
MCP_TOOLSET_VERSION,
|
||||||
mcpConfigPath,
|
mcpConfigPath,
|
||||||
piLayout,
|
piLayout,
|
||||||
planRecallInstall,
|
planRecallInstall,
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
workbuddyLayout,
|
workbuddyLayout,
|
||||||
workbuddyScatteredLayout,
|
workbuddyScatteredLayout,
|
||||||
} from "@one-memory/host-bridge/installer";
|
} from "@one-memory/host-bridge/installer";
|
||||||
|
import type { DiagnosticInput } from "@one-memory/runtime-control";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { probeRecallInstall } from "../../../packages/host-bridge/src/recall-probe.js";
|
import { probeRecallInstall } from "../../../packages/host-bridge/src/recall-probe.js";
|
||||||
import { skillFilePath } from "../../../packages/host-bridge/src/skill-drift.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<ReturnType<typeof fixture>>) {
|
||||||
|
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<typeof workbuddyLayout>) {
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1369,6 +1369,24 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
|||||||
});
|
});
|
||||||
await runtimeSupervisor.start();
|
await runtimeSupervisor.start();
|
||||||
if (isQuitting()) return;
|
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 (
|
if (
|
||||||
app.isPackaged &&
|
app.isPackaged &&
|
||||||
process.platform === "darwin" &&
|
process.platform === "darwin" &&
|
||||||
|
|||||||
@@ -28,11 +28,15 @@ describe("ConnectionFallback", () => {
|
|||||||
} as unknown as Window["oneMemory"];
|
} as unknown as Window["oneMemory"];
|
||||||
render(
|
render(
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<ConnectionFallback />
|
<ConnectionFallback defaultExpanded />
|
||||||
</LanguageProvider>,
|
</LanguageProvider>,
|
||||||
);
|
);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await user.click(await screen.findByText("Conectar otras herramientas"));
|
expect(
|
||||||
|
(await screen.findByText("Conectar otras herramientas")).closest(
|
||||||
|
"details",
|
||||||
|
),
|
||||||
|
).toHaveProperty("open", true);
|
||||||
await user.type(
|
await user.type(
|
||||||
screen.getByLabelText("Nombre del Harness"),
|
screen.getByLabelText("Nombre del Harness"),
|
||||||
"Harness 原名",
|
"Harness 原名",
|
||||||
@@ -59,8 +63,11 @@ describe("ConnectionFallback", () => {
|
|||||||
createCustomHarnessSkill,
|
createCustomHarnessSkill,
|
||||||
copyText,
|
copyText,
|
||||||
} as unknown as Window["oneMemory"];
|
} as unknown as Window["oneMemory"];
|
||||||
render(<ConnectionFallback />);
|
render(<ConnectionFallback defaultExpanded />);
|
||||||
await user.click(screen.getByText("连接其他工具"));
|
expect(screen.getByText("连接其他工具").closest("details")).toHaveProperty(
|
||||||
|
"open",
|
||||||
|
true,
|
||||||
|
);
|
||||||
const generate = screen.getByRole("button", { name: "生成配对 Skill" });
|
const generate = screen.getByRole("button", { name: "生成配对 Skill" });
|
||||||
expect(generate).toHaveProperty("disabled", true);
|
expect(generate).toHaveProperty("disabled", true);
|
||||||
await user.type(screen.getByLabelText("Harness 名称"), "Kimi Code");
|
await user.type(screen.getByLabelText("Harness 名称"), "Kimi Code");
|
||||||
@@ -90,8 +97,7 @@ describe("ConnectionFallback", () => {
|
|||||||
createCustomHarnessSkill,
|
createCustomHarnessSkill,
|
||||||
copyText: vi.fn().mockRejectedValue(new Error()),
|
copyText: vi.fn().mockRejectedValue(new Error()),
|
||||||
} as unknown as Window["oneMemory"];
|
} as unknown as Window["oneMemory"];
|
||||||
render(<ConnectionFallback />);
|
render(<ConnectionFallback defaultExpanded />);
|
||||||
await user.click(screen.getByText("连接其他工具"));
|
|
||||||
await user.type(screen.getByLabelText("Harness 名称"), "Kimi Code");
|
await user.type(screen.getByLabelText("Harness 名称"), "Kimi Code");
|
||||||
await user.click(screen.getByRole("button", { name: "生成配对 Skill" }));
|
await user.click(screen.getByRole("button", { name: "生成配对 Skill" }));
|
||||||
await user.click(await screen.findByRole("button", { name: "重试" }));
|
await user.click(await screen.findByRole("button", { name: "重试" }));
|
||||||
@@ -103,6 +109,10 @@ describe("ConnectionFallback", () => {
|
|||||||
});
|
});
|
||||||
it("does not invent runtime paths in the browser", () => {
|
it("does not invent runtime paths in the browser", () => {
|
||||||
render(<ConnectionFallback />);
|
render(<ConnectionFallback />);
|
||||||
|
expect(screen.getByText("连接其他工具").closest("details")).toHaveProperty(
|
||||||
|
"open",
|
||||||
|
false,
|
||||||
|
);
|
||||||
expect(screen.queryByLabelText("配对 Skill")).toBeNull();
|
expect(screen.queryByLabelText("配对 Skill")).toBeNull();
|
||||||
expect(screen.getByText("本机配对仅在桌面 App 中可用。")).toBeTruthy();
|
expect(screen.getByText("本机配对仅在桌面 App 中可用。")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ type MaterialBridge = {
|
|||||||
getConnectionMaterial(): Promise<ConnectionMaterial>;
|
getConnectionMaterial(): Promise<ConnectionMaterial>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ConnectionFallback() {
|
export function ConnectionFallback({
|
||||||
|
defaultExpanded = false,
|
||||||
|
}: {
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
} = {}) {
|
||||||
const { locale, t } = useGuiLanguage();
|
const { locale, t } = useGuiLanguage();
|
||||||
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [material, setMaterial] = useState<Material | null>(null);
|
const [material, setMaterial] = useState<Material | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -42,7 +47,11 @@ export function ConnectionFallback() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details className="connection-fallback">
|
<details
|
||||||
|
className="connection-fallback"
|
||||||
|
open={expanded}
|
||||||
|
onToggle={(event) => setExpanded(event.currentTarget.open)}
|
||||||
|
>
|
||||||
<summary>{t("connectionFallback.title")}</summary>
|
<summary>{t("connectionFallback.title")}</summary>
|
||||||
{materialBridge?.getConnectionMaterial ? (
|
{materialBridge?.getConnectionMaterial ? (
|
||||||
<ConnectionMaterialFallback
|
<ConnectionMaterialFallback
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ const SERVICE_LABELS: Record<DebugLogEntry["service"], string> = {
|
|||||||
core: "Core",
|
core: "Core",
|
||||||
"host-bridge": "Host Bridge",
|
"host-bridge": "Host Bridge",
|
||||||
cli: "CLI",
|
cli: "CLI",
|
||||||
"hy-memory": "HY Memory",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DebugLogPanel() {
|
export function DebugLogPanel() {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
it.each(["zh-CN", "en", "es"] as const)(
|
it.each(["zh-CN", "en", "es"] as const)(
|
||||||
"hides the compact label while preserving its accessible name in %s",
|
"hides the compact label while preserving its accessible name in %s",
|
||||||
(locale) => {
|
async (locale) => {
|
||||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
|
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
|
||||||
render(
|
render(
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
@@ -28,6 +28,7 @@ it.each(["zh-CN", "en", "es"] as const)(
|
|||||||
expect(label.hidden).toBe(true);
|
expect(label.hidden).toBe(true);
|
||||||
expect(label.htmlFor).toBe(selector.id);
|
expect(label.htmlFor).toBe(selector.id);
|
||||||
expect(screen.getByRole("region", { name })).toBeTruthy();
|
expect(screen.getByRole("region", { name })).toBeTruthy();
|
||||||
|
await userEvent.click(selector);
|
||||||
expect(screen.getByRole("option", { name: "Español" })).toBeTruthy();
|
expect(screen.getByRole("option", { name: "Español" })).toBeTruthy();
|
||||||
expect(screen.getByRole("option", { name: "English" })).toBeTruthy();
|
expect(screen.getByRole("option", { name: "English" })).toBeTruthy();
|
||||||
expect(screen.getByRole("option", { name: "简体中文" })).toBeTruthy();
|
expect(screen.getByRole("option", { name: "简体中文" })).toBeTruthy();
|
||||||
@@ -60,18 +61,19 @@ it.each(["zh-CN", "en", "es"] as const)(
|
|||||||
|
|
||||||
it.each(["system", "zh-CN", "en", "es"] as const)(
|
it.each(["system", "zh-CN", "en", "es"] as const)(
|
||||||
"keeps one stable settings row for the %s preference",
|
"keeps one stable settings row for the %s preference",
|
||||||
(preference) => {
|
async (preference) => {
|
||||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, preference);
|
localStorage.setItem(LANGUAGE_STORAGE_KEY, preference);
|
||||||
render(
|
render(
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
</LanguageProvider>,
|
</LanguageProvider>,
|
||||||
);
|
);
|
||||||
const selector = screen.getByRole("combobox") as HTMLSelectElement;
|
const selector = screen.getByRole("combobox") as HTMLButtonElement;
|
||||||
const row = selector.closest(".r6-language-setting-row");
|
const row = selector.closest(".r6-language-setting-row");
|
||||||
expect(selector.value).toBe(preference);
|
expect(selector.value).toBe(preference);
|
||||||
expect(row).toBeTruthy();
|
expect(row).toBeTruthy();
|
||||||
expect(row?.querySelector("label")?.hidden).toBe(false);
|
expect(row?.querySelector("label")?.hidden).toBe(false);
|
||||||
|
await userEvent.click(selector);
|
||||||
expect(
|
expect(
|
||||||
screen
|
screen
|
||||||
.getByRole("option", {
|
.getByRole("option", {
|
||||||
@@ -102,9 +104,10 @@ it("offers an accessible selector, persists and updates its label", async () =>
|
|||||||
const selector = screen.getByRole("combobox", {
|
const selector = screen.getByRole("combobox", {
|
||||||
name: translate("zh-CN", "languageLabel"),
|
name: translate("zh-CN", "languageLabel"),
|
||||||
});
|
});
|
||||||
expect(screen.getAllByRole("option")).toHaveLength(4);
|
|
||||||
expect(selector.getAttribute("aria-describedby")).toBeTruthy();
|
expect(selector.getAttribute("aria-describedby")).toBeTruthy();
|
||||||
await userEvent.selectOptions(selector, "es");
|
await userEvent.click(selector);
|
||||||
|
expect(screen.getAllByRole("option")).toHaveLength(4);
|
||||||
|
await userEvent.click(screen.getByRole("option", { name: "西班牙语" }));
|
||||||
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("es");
|
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("es");
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("combobox", { name: translate("es", "languageLabel") }),
|
screen.getByRole("combobox", { name: translate("es", "languageLabel") }),
|
||||||
@@ -112,6 +115,31 @@ it("offers an accessible selector, persists and updates its label", async () =>
|
|||||||
expect(document.documentElement.lang).toBe("es");
|
expect(document.documentElement.lang).toBe("es");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supports keyboard selection without using the system select menu", async () => {
|
||||||
|
localStorage.setItem(LANGUAGE_STORAGE_KEY, "system");
|
||||||
|
render(
|
||||||
|
<LanguageProvider>
|
||||||
|
<LanguageSelector />
|
||||||
|
</LanguageProvider>,
|
||||||
|
);
|
||||||
|
const selector = screen.getByRole("combobox");
|
||||||
|
selector.focus();
|
||||||
|
await userEvent.keyboard("{Enter}");
|
||||||
|
expect(selector.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
await userEvent.tab();
|
||||||
|
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
selector.focus();
|
||||||
|
await userEvent.keyboard("{Enter}");
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
await userEvent.keyboard(" ");
|
||||||
|
expect(selector.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}");
|
||||||
|
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("zh-CN");
|
||||||
|
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
it("announces storage errors without replacing the confirmed choice", async () => {
|
it("announces storage errors without replacing the confirmed choice", async () => {
|
||||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||||
render(
|
render(
|
||||||
@@ -122,11 +150,12 @@ it("announces storage errors without replacing the confirmed choice", async () =
|
|||||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||||
throw new Error("denied");
|
throw new Error("denied");
|
||||||
});
|
});
|
||||||
await userEvent.selectOptions(screen.getByRole("combobox"), "es");
|
await userEvent.click(screen.getByRole("combobox"));
|
||||||
|
await userEvent.click(screen.getByRole("option", { name: "西班牙语" }));
|
||||||
expect(screen.getByRole("alert").textContent).toBe(
|
expect(screen.getByRole("alert").textContent).toBe(
|
||||||
translate("zh-CN", "languageSaveError"),
|
translate("zh-CN", "languageSaveError"),
|
||||||
);
|
);
|
||||||
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe(
|
expect((screen.getByRole("combobox") as HTMLButtonElement).value).toBe(
|
||||||
"zh-CN",
|
"zh-CN",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1,11 +1,30 @@
|
|||||||
import { isLanguagePreference } from "@one-memory/i18n";
|
import { isLanguagePreference } from "@one-memory/i18n";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { useGuiLanguage } from "./gui-language";
|
import { useGuiLanguage } from "./gui-language";
|
||||||
|
import { OptionMenu } from "./OptionMenu";
|
||||||
|
|
||||||
export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const { preference, setPreference, t, error } = useGuiLanguage();
|
const { preference, setPreference, t, error } = useGuiLanguage();
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
const options = [
|
||||||
|
{ value: "system", label: t("languageSystem") },
|
||||||
|
{
|
||||||
|
value: "zh-CN",
|
||||||
|
label: compact ? "简体中文" : t("languageChinese"),
|
||||||
|
lang: "zh-CN",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "en",
|
||||||
|
label: compact ? "English" : t("languageEnglish"),
|
||||||
|
lang: "en",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "es",
|
||||||
|
label: compact ? "Español" : t("languageSpanish"),
|
||||||
|
lang: "es",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-labelledby={compact ? `${id}-label` : `${id}-title`}
|
aria-labelledby={compact ? `${id}-label` : `${id}-title`}
|
||||||
@@ -26,14 +45,16 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
|||||||
<label id={`${id}-label`} htmlFor={`${id}-select`} hidden={compact}>
|
<label id={`${id}-label`} htmlFor={`${id}-select`} hidden={compact}>
|
||||||
{t(compact ? "languageTitle" : "languageLabel")}
|
{t(compact ? "languageTitle" : "languageLabel")}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<OptionMenu
|
||||||
id={`${id}-select`}
|
id={`${id}-select`}
|
||||||
aria-label={compact ? t("languageTitle") : undefined}
|
ariaLabel={compact ? t("languageTitle") : undefined}
|
||||||
aria-describedby={compact ? undefined : `${id}-description`}
|
labelledBy={compact ? undefined : `${id}-label`}
|
||||||
|
describedBy={compact ? undefined : `${id}-description`}
|
||||||
value={preference}
|
value={preference}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onChange={async (event) => {
|
options={options}
|
||||||
const value = event.currentTarget.value;
|
className="r6-language-option-menu"
|
||||||
|
onChange={async (value) => {
|
||||||
if (!isLanguagePreference(value)) return;
|
if (!isLanguagePreference(value)) return;
|
||||||
setPending(true);
|
setPending(true);
|
||||||
try {
|
try {
|
||||||
@@ -42,18 +63,7 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
|||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="system">{t("languageSystem")}</option>
|
|
||||||
<option value="zh-CN" lang="zh-CN">
|
|
||||||
{compact ? "简体中文" : t("languageChinese")}
|
|
||||||
</option>
|
|
||||||
<option value="en" lang="en">
|
|
||||||
{compact ? "English" : t("languageEnglish")}
|
|
||||||
</option>
|
|
||||||
<option value="es" lang="es">
|
|
||||||
{compact ? "Español" : t("languageSpanish")}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<p id={`${id}-description`} className="r6-language-setting-description">
|
<p id={`${id}-description`} className="r6-language-setting-description">
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
MemoryGraphNode,
|
MemoryGraphNode,
|
||||||
MemoryGraphResponse,
|
MemoryGraphResponse,
|
||||||
MemoryProcessingSummary,
|
MemoryProcessingSummary,
|
||||||
SourceImportReceipt as Receipt,
|
|
||||||
SourceResponse,
|
SourceResponse,
|
||||||
} from "@one-memory/contracts";
|
} from "@one-memory/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -40,12 +39,6 @@ import { afterEach, expect, it, vi } from "vitest";
|
|||||||
import { MemoryMap } from "./MemoryMap";
|
import { MemoryMap } from "./MemoryMap";
|
||||||
import type { MemoryArrival } from "./memory-arrival-state";
|
import type { MemoryArrival } from "./memory-arrival-state";
|
||||||
|
|
||||||
const { sourceReceipt } = vi.hoisted(() => ({
|
|
||||||
sourceReceipt: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("./api", () => ({
|
|
||||||
getSourceImportReceipt: sourceReceipt,
|
|
||||||
}));
|
|
||||||
vi.mock("./MemoryGraph3D", () => ({
|
vi.mock("./MemoryGraph3D", () => ({
|
||||||
MemoryGraph3D: ({
|
MemoryGraph3D: ({
|
||||||
graph,
|
graph,
|
||||||
@@ -212,6 +205,7 @@ it("searches only when 搜索 is pressed, not on every keystroke", async () => {
|
|||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
expect(screen.getByRole("combobox", { name: "来源" })).toBeTruthy();
|
||||||
await userEvent.type(
|
await userEvent.type(
|
||||||
screen.getByRole("textbox", { name: "筛选地图记忆" }),
|
screen.getByRole("textbox", { name: "筛选地图记忆" }),
|
||||||
"版本号",
|
"版本号",
|
||||||
@@ -265,6 +259,29 @@ it("OBS-15: switching memory shows a read in progress instead of the previous me
|
|||||||
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
|
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the detail header outside the scrolling body and resets detail scroll on selection", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Host simplified sources={[importedSources[0]]} />,
|
||||||
|
);
|
||||||
|
const heading = screen
|
||||||
|
.getByRole("heading", { name: "记忆详情" })
|
||||||
|
.closest(".map-inspector-heading");
|
||||||
|
const body = container.querySelector<HTMLElement>(".map-inspector-body");
|
||||||
|
expect(heading?.nextElementSibling).toBe(body);
|
||||||
|
if (!body) throw new Error("Memory detail scroll body is missing");
|
||||||
|
body.scrollTop = 180;
|
||||||
|
await userEvent.click(
|
||||||
|
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
|
||||||
|
"button",
|
||||||
|
{ name: /第二条/ },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(body.scrollTop).toBe(0);
|
||||||
|
body.scrollTop = 120;
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||||
|
expect(body.scrollTop).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("OBS-12: the graph legend stays with the graph instead of sitting under a stage without one", () => {
|
it("OBS-12: the graph legend stays with the graph instead of sitting under a stage without one", () => {
|
||||||
// While the first page is still being read there is no graph yet: the
|
// While the first page is still being read there is no graph yet: the
|
||||||
// legend used to sit there anyway, describing nothing.
|
// legend used to sit there anyway, describing nothing.
|
||||||
@@ -493,7 +510,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", () => {
|
it("D96: lists a memory by when its conversation happened, not when it was generated", () => {
|
||||||
const today = new Date().toISOString();
|
const today = new Date().toISOString();
|
||||||
const thisYear = new Date().getFullYear();
|
const thisYear = new Date().getFullYear();
|
||||||
const items = ["第一条", "第二条", "第三条"].map((content, i) => ({
|
const items = ["第一条", "第二条", "第三条", "第四条"].map((content, i) => ({
|
||||||
...memories[0],
|
...memories[0],
|
||||||
memory_id: `t${i}`,
|
memory_id: `t${i}`,
|
||||||
content,
|
content,
|
||||||
@@ -508,10 +525,11 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
|
|||||||
total: 519,
|
total: 519,
|
||||||
offset: 100,
|
offset: 100,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
source_ids_by_memory: { t0: [], t1: [], t2: [] },
|
source_ids_by_memory: { t0: [], t1: [], t2: [], t3: [] },
|
||||||
source_time_by_memory: {
|
source_time_by_memory: {
|
||||||
t0: { at: `${thisYear}-10-02T10:54:00`, basis: "conversation" },
|
t0: { at: `${thisYear}-10-02T10:54:00`, basis: "conversation" },
|
||||||
t1: { at: `${thisYear}-09-30T08:00:00`, basis: "import" },
|
t1: { at: `${thisYear}-09-30T08:00:00`, basis: "import" },
|
||||||
|
t3: { at: `${thisYear}-09-29T08:00:00`, basis: "write" },
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
/>,
|
/>,
|
||||||
@@ -523,6 +541,10 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
|
|||||||
expect(
|
expect(
|
||||||
within(list).getByRole("button", { name: /第二条/ }).textContent,
|
within(list).getByRole("button", { name: /第二条/ }).textContent,
|
||||||
).toContain("导入于 9月30日");
|
).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
|
// 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.
|
// old conversation still reads as new rather than looking misplaced.
|
||||||
expect(
|
expect(
|
||||||
@@ -546,6 +568,73 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps pagination and current detail stable while the next page is loading", async () => {
|
||||||
|
const onPage = vi.fn();
|
||||||
|
const page = {
|
||||||
|
items: memories,
|
||||||
|
total: 202,
|
||||||
|
offset: 0,
|
||||||
|
limit: 100,
|
||||||
|
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||||
|
};
|
||||||
|
const libraryView = { ...pagedView(page), onPage };
|
||||||
|
const view = render(<Host simplified libraryView={libraryView} />);
|
||||||
|
const list = screen.getByRole("list", { name: "地图记忆列表" });
|
||||||
|
list.scrollTop = 160;
|
||||||
|
const pager = screen.getByRole("navigation", { name: "地图记忆分页" });
|
||||||
|
await userEvent.click(
|
||||||
|
within(pager).getByRole("button", { name: "下一页记忆" }),
|
||||||
|
);
|
||||||
|
expect(onPage).toHaveBeenCalledWith(100);
|
||||||
|
view.rerender(
|
||||||
|
<Host simplified libraryView={{ ...libraryView, loading: true }} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen
|
||||||
|
.getByRole("navigation", { name: "地图记忆分页" })
|
||||||
|
.getAttribute("aria-busy"),
|
||||||
|
).toBe("true");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "下一页记忆",
|
||||||
|
}) as HTMLButtonElement
|
||||||
|
).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||||
|
expect(list.scrollTop).toBe(160);
|
||||||
|
|
||||||
|
const nextItems = memories.map((memory, index) => ({
|
||||||
|
...memory,
|
||||||
|
memory_id: `next-${index}`,
|
||||||
|
content: `下一页第 ${index + 1} 条`,
|
||||||
|
}));
|
||||||
|
view.rerender(
|
||||||
|
<Host
|
||||||
|
simplified
|
||||||
|
items={nextItems}
|
||||||
|
libraryView={{
|
||||||
|
...libraryView,
|
||||||
|
page: {
|
||||||
|
...page,
|
||||||
|
items: nextItems,
|
||||||
|
offset: 100,
|
||||||
|
source_ids_by_memory: {
|
||||||
|
"next-0": ["codex"],
|
||||||
|
"next-1": ["codex"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(list.scrollTop).toBe(0);
|
||||||
|
expect(within(list).getByText("下一页第 1 条")).toBeTruthy();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
|
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
|
||||||
const items = ["有来源", "无来源"].map((content, i) => ({
|
const items = ["有来源", "无来源"].map((content, i) => ({
|
||||||
...memories[0],
|
...memories[0],
|
||||||
@@ -891,35 +980,6 @@ const importedSources = [
|
|||||||
imported_conversations: 1,
|
imported_conversations: 1,
|
||||||
},
|
},
|
||||||
] as SourceResponse[];
|
] 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 () => {
|
it("REV09: selecting an imported source retains the canvas and only its authorized node", async () => {
|
||||||
render(<Host items={[]} sources={importedSources} />);
|
render(<Host items={[]} sources={importedSources} />);
|
||||||
const canvas = screen.getByTestId("canvas");
|
const canvas = screen.getByTestId("canvas");
|
||||||
@@ -934,7 +994,8 @@ it("REV09: selecting an imported source retains the canvas and only its authoriz
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole("list", { name: "地图记忆列表" }).children,
|
screen.getByRole("list", { name: "地图记忆列表" }).children,
|
||||||
).toHaveLength(0);
|
).toHaveLength(0);
|
||||||
expect(sourceReceipt).not.toHaveBeenCalled();
|
expect(screen.getByRole("heading", { name: "记忆详情" })).toBeTruthy();
|
||||||
|
expect(screen.getByText("还没有已生成的记忆")).toBeTruthy();
|
||||||
await userEvent.click(screen.getByRole("button", { name: /全部记忆/ }));
|
await userEvent.click(screen.getByRole("button", { name: /全部记忆/ }));
|
||||||
expect(within(canvas).getAllByRole("button")).toHaveLength(2);
|
expect(within(canvas).getAllByRole("button")).toHaveLength(2);
|
||||||
expect(canvas.dataset.selected ?? "").toBe("");
|
expect(canvas.dataset.selected ?? "").toBe("");
|
||||||
@@ -956,83 +1017,247 @@ it("REV09: fallback search matches source names and IDs without bypassing the se
|
|||||||
);
|
);
|
||||||
await userEvent.type(search, "cOdEx");
|
await userEvent.type(search, "cOdEx");
|
||||||
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
|
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
|
||||||
expect(screen.getByText("查看已读内容")).toBeTruthy();
|
expect(screen.getByText("还没有已生成的记忆")).toBeTruthy();
|
||||||
await userEvent.clear(search);
|
await userEvent.clear(search);
|
||||||
await userEvent.type(search, "Cursor");
|
await userEvent.type(search, "Cursor");
|
||||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||||
expect(screen.queryByText("查看已读内容")).toBeNull();
|
expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
|
||||||
await userEvent.clear(search);
|
await userEvent.clear(search);
|
||||||
expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe("");
|
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 () => {
|
it("selecting a source node waits for its settled page, then opens the first memory", async () => {
|
||||||
sourceReceipt.mockResolvedValue(importedReceipt);
|
const onQuery = vi.fn();
|
||||||
render(<Host items={[]} sources={importedSources} />);
|
const onSource = vi.fn();
|
||||||
await openSourceReceipt();
|
const resetPage = vi.fn();
|
||||||
expect(
|
const oldPage = {
|
||||||
await screen.findByText(importedReceipt.examples[0].excerpt),
|
items: memories,
|
||||||
).toBeTruthy();
|
total: 2,
|
||||||
expect(screen.getByText("用户 · 时间未知")).toBeTruthy();
|
offset: 0,
|
||||||
expect(screen.getByText("原始消息").nextElementSibling?.textContent).toBe(
|
limit: 20,
|
||||||
"5",
|
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||||
);
|
};
|
||||||
// No raw conversation is read by id on the SDK data plane.
|
const view = render(
|
||||||
expect(screen.queryByRole("button", { name: "查看完整对话" })).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<Receipt>((resolve) => {
|
|
||||||
finish = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const view = render(<Host items={[]} sources={[importedSources[0]]} />);
|
|
||||||
await openSourceReceipt();
|
|
||||||
view.rerender(
|
|
||||||
<Host
|
<Host
|
||||||
items={[]}
|
simplified
|
||||||
hidden={state === "revoked-hidden"}
|
sources={importedSources}
|
||||||
sources={
|
libraryView={{
|
||||||
state === "removed"
|
...pagedView(oldPage),
|
||||||
? []
|
query: "旧查询",
|
||||||
: [{ ...importedSources[0], authorized: false }]
|
settledQuery: "旧查询",
|
||||||
}
|
onQuery,
|
||||||
|
onSource,
|
||||||
|
resetPage,
|
||||||
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
await act(async () => {});
|
||||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||||
await act(async () => finish(importedReceipt));
|
expect(onQuery).toHaveBeenCalledWith("");
|
||||||
view.rerender(<Host items={[]} sources={[importedSources[0]]} />);
|
expect(onSource).toHaveBeenCalledWith("codex");
|
||||||
expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe("");
|
expect(resetPage).toHaveBeenCalledOnce();
|
||||||
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 () => {
|
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)} />,
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closing the compact source inspector cancels its pending auto-open", async () => {
|
||||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||||
matches: query.includes("max-width"),
|
matches: query.includes("max-width"),
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
removeEventListener() {},
|
removeEventListener() {},
|
||||||
}));
|
}));
|
||||||
sourceReceipt.mockResolvedValue(importedReceipt);
|
const page = {
|
||||||
render(<Host items={[]} sources={importedSources} />);
|
items: memories,
|
||||||
await openSourceReceipt();
|
total: 2,
|
||||||
await screen.findByText(importedReceipt.examples[0].excerpt);
|
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" }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "关闭记忆详情" }));
|
await userEvent.click(screen.getByRole("button", { name: "关闭记忆详情" }));
|
||||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
|
||||||
|
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(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
|
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
|
||||||
await userEvent.click(
|
|
||||||
screen.getByRole("button", { name: "查看选中来源原文" }),
|
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",
|
||||||
);
|
);
|
||||||
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 () => {
|
it("REV09: first governed memories replace fallback without remounting the graph or breaking memory IDs", async () => {
|
||||||
@@ -1071,5 +1296,4 @@ it.each([
|
|||||||
render(<Host items={[]} sources={sources} />);
|
render(<Host items={[]} sources={sources} />);
|
||||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
||||||
expect(sourceReceipt).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type RefObject,
|
type RefObject,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -47,7 +48,6 @@ import {
|
|||||||
import { OptionMenu } from "./OptionMenu";
|
import { OptionMenu } from "./OptionMenu";
|
||||||
import { R6Button } from "./R6Controls";
|
import { R6Button } from "./R6Controls";
|
||||||
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
|
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
|
||||||
import { SourceImportReceipt } from "./SourceImportReceipt";
|
|
||||||
import { sourceNameOr } from "./source-label";
|
import { sourceNameOr } from "./source-label";
|
||||||
import { useMediaPreference } from "./use-media-preference";
|
import { useMediaPreference } from "./use-media-preference";
|
||||||
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
|
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
|
||||||
@@ -96,6 +96,8 @@ type Props = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
failed: boolean;
|
failed: boolean;
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
|
settledSource?: string;
|
||||||
|
settledQuery?: string;
|
||||||
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
||||||
unattributedSource?: string;
|
unattributedSource?: string;
|
||||||
onQuery(value: string): void;
|
onQuery(value: string): void;
|
||||||
@@ -138,7 +140,7 @@ function monthDay(value: string, locale: GuiLocale): string | null {
|
|||||||
|
|
||||||
/** D96: when the cited conversation happened, not when the memory was generated. */
|
/** D96: when the cited conversation happened, not when the memory was generated. */
|
||||||
function sourceStamp(
|
function sourceStamp(
|
||||||
time: { at: string; basis: "conversation" | "import" } | undefined,
|
time: { at: string; basis: "conversation" | "import" | "write" } | undefined,
|
||||||
fallback: string | null | undefined,
|
fallback: string | null | undefined,
|
||||||
locale: GuiLocale = "zh-CN",
|
locale: GuiLocale = "zh-CN",
|
||||||
): string {
|
): string {
|
||||||
@@ -146,7 +148,11 @@ function sourceStamp(
|
|||||||
if (time && day)
|
if (time && day)
|
||||||
return memoryText(
|
return memoryText(
|
||||||
locale,
|
locale,
|
||||||
time.basis === "conversation" ? "对话于 {day}" : "导入于 {day}",
|
time.basis === "conversation"
|
||||||
|
? "对话于 {day}"
|
||||||
|
: time.basis === "write"
|
||||||
|
? "写入于 {day}"
|
||||||
|
: "导入于 {day}",
|
||||||
{ day },
|
{ day },
|
||||||
);
|
);
|
||||||
return dayStamp(fallback, locale);
|
return dayStamp(fallback, locale);
|
||||||
@@ -212,6 +218,9 @@ export function MemoryMap(props: Props) {
|
|||||||
}, [submittedQuery]);
|
}, [submittedQuery]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
|
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
|
||||||
|
const [pendingSourceSelection, setPendingSourceSelection] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
const [question, setQuestion] = useState("");
|
const [question, setQuestion] = useState("");
|
||||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||||
const [filterNotice, setFilterNotice] = useState("");
|
const [filterNotice, setFilterNotice] = useState("");
|
||||||
@@ -220,6 +229,8 @@ export function MemoryMap(props: Props) {
|
|||||||
const compact = useMediaPreference("(max-width: 1100px)");
|
const compact = useMediaPreference("(max-width: 1100px)");
|
||||||
const composing = useRef(false);
|
const composing = useRef(false);
|
||||||
const pane = useRef<HTMLElement>(null);
|
const pane = useRef<HTMLElement>(null);
|
||||||
|
const memoryList = useRef<HTMLUListElement>(null);
|
||||||
|
const inspectorBody = useRef<HTMLDivElement>(null);
|
||||||
const openedDefault = useRef(false);
|
const openedDefault = useRef(false);
|
||||||
const scrollSelection = useRef(false);
|
const scrollSelection = useRef(false);
|
||||||
const controlledSource = library
|
const controlledSource = library
|
||||||
@@ -360,6 +371,7 @@ export function MemoryMap(props: Props) {
|
|||||||
// Source revocation also clears a suspended preview, without closing shared memory detail.
|
// Source revocation also clears a suspended preview, without closing shared memory detail.
|
||||||
if (groupNode && !visibleGroupNode) {
|
if (groupNode && !visibleGroupNode) {
|
||||||
setGroupNode(null);
|
setGroupNode(null);
|
||||||
|
setPendingSourceSelection(null);
|
||||||
setInspectorOpen(false);
|
setInspectorOpen(false);
|
||||||
}
|
}
|
||||||
}, [groupNode, visibleGroupNode]);
|
}, [groupNode, visibleGroupNode]);
|
||||||
@@ -367,6 +379,11 @@ export function MemoryMap(props: Props) {
|
|||||||
const activeId = active.some((memory) => memory.memory_id === requestedId)
|
const activeId = active.some((memory) => memory.memory_id === requestedId)
|
||||||
? requestedId
|
? requestedId
|
||||||
: null;
|
: null;
|
||||||
|
const inspectorIdentity = activeId
|
||||||
|
? `memory:${activeId}`
|
||||||
|
: selectedSource
|
||||||
|
? `source:${selectedSource.source_id}`
|
||||||
|
: (visibleGroupNode?.node_id ?? "empty");
|
||||||
const visibleDetail =
|
const visibleDetail =
|
||||||
detail &&
|
detail &&
|
||||||
(!activeId || activeId === detail.memory.memory_id) &&
|
(!activeId || activeId === detail.memory.memory_id) &&
|
||||||
@@ -381,6 +398,7 @@ export function MemoryMap(props: Props) {
|
|||||||
const pick = useCallback(
|
const pick = useCallback(
|
||||||
(memoryId: string, scroll = true) => {
|
(memoryId: string, scroll = true) => {
|
||||||
if (!active.some((memory) => memory.memory_id === memoryId)) return;
|
if (!active.some((memory) => memory.memory_id === memoryId)) return;
|
||||||
|
setPendingSourceSelection(null);
|
||||||
scrollSelection.current = scroll;
|
scrollSelection.current = scroll;
|
||||||
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
|
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
|
||||||
setGroupKey("");
|
setGroupKey("");
|
||||||
@@ -394,6 +412,32 @@ export function MemoryMap(props: Props) {
|
|||||||
},
|
},
|
||||||
[active, filtered, onOpenMemory],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!props.simplified || openedDefault.current || overlaysSuspended) return;
|
if (!props.simplified || openedDefault.current || overlaysSuspended) return;
|
||||||
if (library?.loading && filtered.length === 0) return;
|
if (library?.loading && filtered.length === 0) return;
|
||||||
@@ -427,10 +471,24 @@ export function MemoryMap(props: Props) {
|
|||||||
container.scrollTop += itemBounds.bottom - containerBounds.bottom;
|
container.scrollTop += itemBounds.bottom - containerBounds.bottom;
|
||||||
}
|
}
|
||||||
}, [activeId]);
|
}, [activeId]);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
// Detail content owns its own scrollport. A newly selected memory must
|
||||||
|
// start at its heading instead of inheriting the previous inspector view.
|
||||||
|
if (inspectorIdentity === "empty") return;
|
||||||
|
if (inspectorBody.current) inspectorBody.current.scrollTop = 0;
|
||||||
|
}, [inspectorIdentity]);
|
||||||
|
const settledPageOffset = library?.page?.offset;
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
// A settled page is a new reading surface, not a continuation of the
|
||||||
|
// previous page's scroll position.
|
||||||
|
if (settledPageOffset === undefined) return;
|
||||||
|
if (memoryList.current) memoryList.current.scrollTop = 0;
|
||||||
|
}, [settledPageOffset]);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
setGroupNode(null);
|
setGroupNode(null);
|
||||||
|
setPendingSourceSelection(null);
|
||||||
setInspectorOpen(false);
|
setInspectorOpen(false);
|
||||||
onCloseDetail();
|
onCloseDetail();
|
||||||
}, [onCloseDetail]);
|
}, [onCloseDetail]);
|
||||||
@@ -462,6 +520,7 @@ export function MemoryMap(props: Props) {
|
|||||||
clear();
|
clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setPendingSourceSelection(null);
|
||||||
setInspectorOpen(false);
|
setInspectorOpen(false);
|
||||||
}
|
}
|
||||||
function chooseGroup(key: string) {
|
function chooseGroup(key: string) {
|
||||||
@@ -488,8 +547,17 @@ export function MemoryMap(props: Props) {
|
|||||||
setAxis("source");
|
setAxis("source");
|
||||||
setGroupKey(`source:${node.source_id}`);
|
setGroupKey(`source:${node.source_id}`);
|
||||||
if (library) {
|
if (library) {
|
||||||
|
setPendingSourceSelection(node.source_id);
|
||||||
|
library.onQuery("");
|
||||||
library.onSource(node.source_id);
|
library.onSource(node.source_id);
|
||||||
library.resetPage();
|
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") {
|
if (node.node_type === "topic") {
|
||||||
@@ -586,6 +654,7 @@ export function MemoryMap(props: Props) {
|
|||||||
<label htmlFor="selected-source-filter">{text("来源")}</label>
|
<label htmlFor="selected-source-filter">{text("来源")}</label>
|
||||||
<OptionMenu
|
<OptionMenu
|
||||||
id="selected-source-filter"
|
id="selected-source-filter"
|
||||||
|
ariaLabel={text("来源")}
|
||||||
className="om-option-menu--plain"
|
className="om-option-menu--plain"
|
||||||
value={controlledSource}
|
value={controlledSource}
|
||||||
options={[
|
options={[
|
||||||
@@ -781,6 +850,7 @@ export function MemoryMap(props: Props) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul
|
<ul
|
||||||
|
ref={memoryList}
|
||||||
className="map-memory-list"
|
className="map-memory-list"
|
||||||
aria-label={text("地图记忆列表")}
|
aria-label={text("地图记忆列表")}
|
||||||
id="memory-pane-memories"
|
id="memory-pane-memories"
|
||||||
@@ -874,18 +944,21 @@ export function MemoryMap(props: Props) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{library?.page &&
|
{library?.page &&
|
||||||
!library.loading &&
|
(!library.loading || library.page.total > 0) &&
|
||||||
!library.failed &&
|
!library.failed &&
|
||||||
!library.stale && (
|
!library.stale && (
|
||||||
<nav aria-label={text("地图记忆分页")} className="map-pagination">
|
<nav
|
||||||
|
aria-label={text("地图记忆分页")}
|
||||||
|
aria-busy={library.loading || undefined}
|
||||||
|
className="map-pagination"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="quiet map-tool"
|
className="quiet map-tool"
|
||||||
aria-label={text("上一页记忆")}
|
aria-label={text("上一页记忆")}
|
||||||
title={text("上一页记忆")}
|
title={text("上一页记忆")}
|
||||||
disabled={library.page.offset === 0}
|
disabled={library.loading || library.page.offset === 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clear();
|
|
||||||
if (axis === "topic") setGroupKey("");
|
if (axis === "topic") setGroupKey("");
|
||||||
if (library.page)
|
if (library.page)
|
||||||
library.onPage(library.page.offset - library.page.limit);
|
library.onPage(library.page.offset - library.page.limit);
|
||||||
@@ -914,10 +987,10 @@ export function MemoryMap(props: Props) {
|
|||||||
aria-label={text("下一页记忆")}
|
aria-label={text("下一页记忆")}
|
||||||
title={text("下一页记忆")}
|
title={text("下一页记忆")}
|
||||||
disabled={
|
disabled={
|
||||||
|
library.loading ||
|
||||||
library.page.offset + library.page.limit >= library.page.total
|
library.page.offset + library.page.limit >= library.page.total
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clear();
|
|
||||||
if (axis === "topic") setGroupKey("");
|
if (axis === "topic") setGroupKey("");
|
||||||
if (library.page)
|
if (library.page)
|
||||||
library.onPage(library.page.offset + library.page.limit);
|
library.onPage(library.page.offset + library.page.limit);
|
||||||
@@ -961,11 +1034,13 @@ export function MemoryMap(props: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="quiet map-tool"
|
className="quiet map-tool"
|
||||||
aria-label={text(
|
aria-label={text("记忆详情")}
|
||||||
selectedSource ? "查看选中来源原文" : "查看选中记忆原文",
|
|
||||||
)}
|
|
||||||
title={text("查看原文")}
|
title={text("查看原文")}
|
||||||
onClick={() => setInspectorOpen(true)}
|
onClick={() => {
|
||||||
|
setInspectorOpen(true);
|
||||||
|
if (selectedSource && library)
|
||||||
|
setPendingSourceSelection(selectedSource.source_id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FileText size={17} />
|
<FileText size={17} />
|
||||||
</button>
|
</button>
|
||||||
@@ -1100,7 +1175,7 @@ export function MemoryMap(props: Props) {
|
|||||||
{inspectorDetail
|
{inspectorDetail
|
||||||
? text("记忆详情")
|
? text("记忆详情")
|
||||||
: selectedSource
|
: selectedSource
|
||||||
? text("来源原文")
|
? text("记忆详情")
|
||||||
: visibleGroupNode
|
: visibleGroupNode
|
||||||
? text("关联节点")
|
? text("关联节点")
|
||||||
: text("记忆详情")}
|
: text("记忆详情")}
|
||||||
@@ -1117,14 +1192,35 @@ export function MemoryMap(props: Props) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
<div className="map-inspector-body" ref={inspectorBody}>
|
||||||
{selectedSource ? (
|
{selectedSource ? (
|
||||||
inspectorOpen &&
|
<div className="map-inspector-empty map-inspector-loading">
|
||||||
!overlaysSuspended && (
|
{library?.failed &&
|
||||||
<SourceImportReceipt
|
library.source === selectedSource.source_id ? (
|
||||||
key={selectedSource.source_id}
|
<>
|
||||||
source={selectedSource}
|
<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>
|
||||||
) : props.simplified && inspectorDetail ? (
|
) : props.simplified && inspectorDetail ? (
|
||||||
<div
|
<div
|
||||||
aria-busy={
|
aria-busy={
|
||||||
@@ -1253,6 +1349,7 @@ export function MemoryMap(props: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</MemoryEvidencePanel>
|
</MemoryEvidencePanel>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -201,8 +201,9 @@ it("keeps an added memory visible while it waits for model processing", async ()
|
|||||||
render(<MemorySpace simplified onConfigureModel={vi.fn()} />);
|
render(<MemorySpace simplified onConfigureModel={vi.fn()} />);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "补充记忆" }));
|
fireEvent.click(screen.getByRole("button", { name: "补充记忆" }));
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/原话已保存,正在等待整理:项目周会统一安排在周三上午。/),
|
screen.getByText(/正在等待整理:项目周会统一安排在周三上午。/),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/原话已保存/)).toBeNull();
|
||||||
expect(screen.getByRole("button", { name: "配置整理模型" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "配置整理模型" })).toBeTruthy();
|
||||||
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
|
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -953,7 +953,7 @@ export function MemorySpace({
|
|||||||
{simplified && pendingAdd ? (
|
{simplified && pendingAdd ? (
|
||||||
<p className="memory-pending-add" role="status">
|
<p className="memory-pending-add" role="status">
|
||||||
<span>
|
<span>
|
||||||
{t("memory:原话已保存,正在等待整理:")}
|
{t("memory:正在等待整理:")}
|
||||||
{pendingAdd.text}
|
{pendingAdd.text}
|
||||||
</span>
|
</span>
|
||||||
{pendingAdd.state === "waiting_for_model" ? (
|
{pendingAdd.state === "waiting_for_model" ? (
|
||||||
@@ -1027,6 +1027,8 @@ export function MemorySpace({
|
|||||||
loading: sdkLibrary.loading,
|
loading: sdkLibrary.loading,
|
||||||
failed: sdkLibrary.failed,
|
failed: sdkLibrary.failed,
|
||||||
stale: false,
|
stale: false,
|
||||||
|
settledSource: sdkLibrary.settledSource,
|
||||||
|
settledQuery: sdkLibrary.settledQuery,
|
||||||
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
||||||
onQuery: setSdkLibraryQuery,
|
onQuery: setSdkLibraryQuery,
|
||||||
onSource: setSdkLibrarySource,
|
onSource: setSdkLibrarySource,
|
||||||
|
|||||||
@@ -117,7 +117,8 @@ it("changes the first-screen language without losing source choices or starting
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await user.click(screen.getByLabelText("导入 Cursor 历史"));
|
await user.click(screen.getByLabelText("导入 Cursor 历史"));
|
||||||
await user.selectOptions(selector, "es");
|
await user.click(selector);
|
||||||
|
await user.click(screen.getByRole("option", { name: "Español" }));
|
||||||
expect(screen.getByRole("combobox", { name: "Idioma de la interfaz" })).toBe(
|
expect(screen.getByRole("combobox", { name: "Idioma de la interfaz" })).toBe(
|
||||||
selector,
|
selector,
|
||||||
);
|
);
|
||||||
@@ -125,7 +126,8 @@ it("changes the first-screen language without losing source choices or starting
|
|||||||
screen.getByLabelText("Importar el historial de Cursor"),
|
screen.getByLabelText("Importar el historial de Cursor"),
|
||||||
).toHaveProperty("checked", false);
|
).toHaveProperty("checked", false);
|
||||||
await user.click(screen.getByLabelText("Importar el historial de Cursor"));
|
await user.click(screen.getByLabelText("Importar el historial de Cursor"));
|
||||||
await user.selectOptions(selector, "zh-CN");
|
await user.click(selector);
|
||||||
|
await user.click(screen.getByRole("option", { name: "简体中文" }));
|
||||||
expect(screen.getByLabelText("导入 Cursor 历史")).toHaveProperty(
|
expect(screen.getByLabelText("导入 Cursor 历史")).toHaveProperty(
|
||||||
"checked",
|
"checked",
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
import { useGuiLanguage } from "./gui-language";
|
import { useGuiLanguage } from "./gui-language";
|
||||||
|
|
||||||
export interface OptionMenuItem {
|
export interface OptionMenuItem {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
lang?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replaces a native select so the open list follows the product field, not the OS menu. */
|
/** Replaces a native select so the open list follows the product field, not the OS menu. */
|
||||||
@@ -20,11 +21,50 @@ export function OptionMenu(props: {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useGuiLanguage();
|
const { t } = useGuiLanguage();
|
||||||
|
const generatedId = useId();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const selectedIndex = props.options.findIndex(
|
||||||
|
(item) => item.value === props.value,
|
||||||
|
);
|
||||||
|
const firstEnabledIndex = props.options.findIndex((item) => !item.disabled);
|
||||||
|
const preferredIndex =
|
||||||
|
selectedIndex >= 0 && !props.options[selectedIndex]?.disabled
|
||||||
|
? selectedIndex
|
||||||
|
: firstEnabledIndex;
|
||||||
|
const [activeIndex, setActiveIndex] = useState(preferredIndex);
|
||||||
const root = useRef<HTMLDivElement>(null);
|
const root = useRef<HTMLDivElement>(null);
|
||||||
|
const button = useRef<HTMLButtonElement>(null);
|
||||||
const selected = props.options.find((item) => item.value === props.value);
|
const selected = props.options.find((item) => item.value === props.value);
|
||||||
|
const visibleLabel = selected?.label ?? t("wb.select");
|
||||||
|
const controlId = props.id ?? `${generatedId}-control`;
|
||||||
|
const panelId = `${controlId}-options`;
|
||||||
|
|
||||||
|
function enabledIndex(from: number, direction: 1 | -1) {
|
||||||
|
if (firstEnabledIndex < 0) return -1;
|
||||||
|
let next = from;
|
||||||
|
for (let checked = 0; checked < props.options.length; checked += 1) {
|
||||||
|
next =
|
||||||
|
next < 0
|
||||||
|
? direction === 1
|
||||||
|
? 0
|
||||||
|
: props.options.length - 1
|
||||||
|
: (next + direction + props.options.length) % props.options.length;
|
||||||
|
if (!props.options[next]?.disabled) return next;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(index: number) {
|
||||||
|
const item = props.options[index];
|
||||||
|
if (!item || item.disabled) return;
|
||||||
|
props.onChange(item.value);
|
||||||
|
setOpen(false);
|
||||||
|
button.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
setActiveIndex(preferredIndex);
|
||||||
const close = (event: MouseEvent) => {
|
const close = (event: MouseEvent) => {
|
||||||
if (!root.current?.contains(event.target as Node)) setOpen(false);
|
if (!root.current?.contains(event.target as Node)) setOpen(false);
|
||||||
};
|
};
|
||||||
@@ -37,47 +77,98 @@ export function OptionMenu(props: {
|
|||||||
document.removeEventListener("mousedown", close);
|
document.removeEventListener("mousedown", close);
|
||||||
document.removeEventListener("keydown", onKey);
|
document.removeEventListener("keydown", onKey);
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open, preferredIndex]);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={["om-option-menu", props.className].filter(Boolean).join(" ")}
|
className={["om-option-menu", props.className].filter(Boolean).join(" ")}
|
||||||
ref={root}
|
ref={root}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
ref={button}
|
||||||
type="button"
|
type="button"
|
||||||
id={props.id}
|
id={controlId}
|
||||||
value={props.value}
|
value={props.value}
|
||||||
className="om-option-menu-button"
|
className="om-option-menu-button"
|
||||||
|
role="combobox"
|
||||||
aria-labelledby={props.labelledBy}
|
aria-labelledby={props.labelledBy}
|
||||||
aria-label={props.ariaLabel}
|
aria-label={
|
||||||
|
props.ariaLabel ?? (props.labelledBy ? undefined : t("wb.select"))
|
||||||
|
}
|
||||||
aria-describedby={props.describedBy}
|
aria-describedby={props.describedBy}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
aria-controls={panelId}
|
||||||
|
aria-activedescendant={
|
||||||
|
open && activeIndex >= 0
|
||||||
|
? `${panelId}-option-${activeIndex}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
onClick={() => setOpen((current) => !current)}
|
onBlur={(event) => {
|
||||||
|
if (!root.current?.contains(event.relatedTarget as Node | null))
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (preferredIndex < 0) return;
|
||||||
|
setActiveIndex(preferredIndex);
|
||||||
|
setOpen((current) => !current);
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "ArrowDown" || event.key === "Enter") {
|
if (event.key === "ArrowDown") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!open) {
|
||||||
|
if (preferredIndex >= 0) {
|
||||||
|
setActiveIndex(preferredIndex);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setActiveIndex((current) => enabledIndex(current, 1));
|
||||||
|
}
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!open) {
|
||||||
|
if (preferredIndex >= 0) {
|
||||||
|
setActiveIndex(preferredIndex);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setActiveIndex((current) => enabledIndex(current, -1));
|
||||||
|
}
|
||||||
|
} else if (event.key === "Home" && open) {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex(enabledIndex(-1, 1));
|
||||||
|
} else if (event.key === "End" && open) {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex(enabledIndex(0, -1));
|
||||||
|
} else if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (open) choose(activeIndex);
|
||||||
|
else if (preferredIndex >= 0) {
|
||||||
|
setActiveIndex(preferredIndex);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selected?.label ?? t("wb.select")}
|
{visibleLabel}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="om-option-menu-panel" role="listbox">
|
<div id={panelId} className="om-option-menu-panel" role="listbox">
|
||||||
{props.options.map((item) => (
|
{props.options.map((item, index) => (
|
||||||
<button
|
<button
|
||||||
key={item.value || item.label}
|
key={item.value || item.label}
|
||||||
|
id={`${panelId}-option-${index}`}
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
|
lang={item.lang}
|
||||||
|
data-active={activeIndex === index || undefined}
|
||||||
aria-selected={item.value === props.value}
|
aria-selected={item.value === props.value}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
onClick={() => {
|
tabIndex={-1}
|
||||||
if (item.disabled) return;
|
onMouseEnter={() => {
|
||||||
props.onChange(item.value);
|
if (!item.disabled) setActiveIndex(index);
|
||||||
setOpen(false);
|
|
||||||
}}
|
}}
|
||||||
|
onClick={() => choose(index)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ it("keeps selection status outside decorative fades and readable when disabled",
|
|||||||
).toMatch(/opacity:\s*1/);
|
).toMatch(/opacity:\s*1/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the native selector keyboard-focusable and switches both languages", async () => {
|
it("keeps the product language menu keyboard-focusable and switches both languages", async () => {
|
||||||
render(
|
render(
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<R6ColdStartFrame
|
<R6ColdStartFrame
|
||||||
@@ -116,17 +116,19 @@ it("keeps the native selector keyboard-focusable and switches both languages", a
|
|||||||
const selector = screen.getByRole("combobox");
|
const selector = screen.getByRole("combobox");
|
||||||
await user.tab();
|
await user.tab();
|
||||||
expect(document.activeElement).toBe(selector);
|
expect(document.activeElement).toBe(selector);
|
||||||
await user.selectOptions(selector, "es");
|
await user.click(selector);
|
||||||
|
await user.click(screen.getByRole("option", { name: "Español" }));
|
||||||
expect(document.documentElement.lang).toBe("es");
|
expect(document.documentElement.lang).toBe("es");
|
||||||
expect(screen.getByRole("combobox").getAttribute("id")).toBe(selector.id);
|
expect(screen.getByRole("combobox").getAttribute("id")).toBe(selector.id);
|
||||||
await user.selectOptions(selector, "zh-CN");
|
await user.click(selector);
|
||||||
|
await user.click(screen.getByRole("option", { name: "简体中文" }));
|
||||||
expect(document.documentElement.lang).toBe("zh-CN");
|
expect(document.documentElement.lang).toBe("zh-CN");
|
||||||
expect(screen.getByRole("combobox", { name: "界面语言" })).toBe(selector);
|
expect(screen.getByRole("combobox", { name: "界面语言" })).toBe(selector);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([1, 2, 3] as const)(
|
it.each([1, 2, 3] as const)(
|
||||||
"offers a compact language selector on step %s",
|
"offers a compact language selector on step %s",
|
||||||
(step) => {
|
async (step) => {
|
||||||
render(
|
render(
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<R6ColdStartFrame
|
<R6ColdStartFrame
|
||||||
@@ -141,6 +143,7 @@ it.each([1, 2, 3] as const)(
|
|||||||
);
|
);
|
||||||
const selector = screen.getByRole("combobox", { name: "界面语言" });
|
const selector = screen.getByRole("combobox", { name: "界面语言" });
|
||||||
expect(selector.closest(".r6-language-selector--compact")).toBeTruthy();
|
expect(selector.closest(".r6-language-selector--compact")).toBeTruthy();
|
||||||
|
await userEvent.click(selector);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("option", { name: "简体中文" }).getAttribute("lang"),
|
screen.getByRole("option", { name: "简体中文" }).getAttribute("lang"),
|
||||||
).toBe("zh-CN");
|
).toBe("zh-CN");
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ it("changes settings copy live without rereading profiles or changing permission
|
|||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
const reads = vi.mocked(getModelProfiles).mock.calls.length;
|
const reads = vi.mocked(getModelProfiles).mock.calls.length;
|
||||||
await user.selectOptions(screen.getByRole("combobox"), "zh-CN");
|
await user.click(screen.getByRole("combobox"));
|
||||||
|
await user.click(screen.getByRole("option", { name: "Chino simplificado" }));
|
||||||
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
|
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
|
||||||
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
|
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
|
||||||
});
|
});
|
||||||
@@ -66,7 +67,9 @@ vi.mock("./ModelSettings", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("./ConnectionFallback", () => ({
|
vi.mock("./ConnectionFallback", () => ({
|
||||||
ConnectionFallback: () => null,
|
ConnectionFallback: ({ defaultExpanded }: { defaultExpanded?: boolean }) => (
|
||||||
|
<p data-testid="connection-default">{String(defaultExpanded)}</p>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("./runtime-state", () => ({
|
vi.mock("./runtime-state", () => ({
|
||||||
refreshLoginPreference: vi.fn(),
|
refreshLoginPreference: vi.fn(),
|
||||||
@@ -95,7 +98,11 @@ it("shows the help and diagnostics group by default and hides it when opted out"
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
|
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||||
|
await user.click(screen.getByRole("combobox"));
|
||||||
expect(screen.getAllByRole("option")).toHaveLength(4);
|
expect(screen.getAllByRole("option")).toHaveLength(4);
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
await user.click(screen.getByRole("button", { name: "连接其他应用" }));
|
||||||
|
expect(screen.getByTestId("connection-default").textContent).toBe("true");
|
||||||
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
|
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
|
||||||
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
|
||||||
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ export function R6Settings({
|
|||||||
className="r6-settings-connections"
|
className="r6-settings-connections"
|
||||||
>
|
>
|
||||||
<h2>{text("连接其他应用")}</h2>
|
<h2>{text("连接其他应用")}</h2>
|
||||||
<ConnectionFallback />
|
<ConnectionFallback defaultExpanded />
|
||||||
</section>
|
</section>
|
||||||
{showDiagnostics && (
|
{showDiagnostics && (
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -176,3 +176,41 @@ it("stamps today's source and conversation time without an evidence page", () =>
|
|||||||
expect(screen.getByText(/对话时间 今天 /)).toBeTruthy();
|
expect(screen.getByText(/对话时间 今天 /)).toBeTruthy();
|
||||||
expect(screen.queryByRole("button", { name: "查看原文" })).toBeNull();
|
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();
|
||||||
|
});
|
||||||
@@ -19,7 +19,10 @@ export function SelectedMemoryDetail({
|
|||||||
detail: MemoryDetail;
|
detail: MemoryDetail;
|
||||||
sources: SourceResponse[];
|
sources: SourceResponse[];
|
||||||
/** D99: when the cited conversation happened, in the list's own terms. */
|
/** 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 { locale, t } = useGuiLanguage();
|
||||||
const memory = detail.memory;
|
const memory = detail.memory;
|
||||||
@@ -42,6 +45,17 @@ export function SelectedMemoryDetail({
|
|||||||
? t("selectedMemory.status.superseded")
|
? t("selectedMemory.status.superseded")
|
||||||
: t("selectedMemory.status.unknown");
|
: t("selectedMemory.status.unknown");
|
||||||
const sourceId = detail.source_id ?? evidence?.source_id;
|
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 =
|
const source =
|
||||||
detail.sdk_status && sourceId === UNATTRIBUTED_SOURCE_ID
|
detail.sdk_status && sourceId === UNATTRIBUTED_SOURCE_ID
|
||||||
? t("selectedMemory.sourceUnattributed")
|
? t("selectedMemory.sourceUnattributed")
|
||||||
@@ -77,25 +91,28 @@ export function SelectedMemoryDetail({
|
|||||||
<p className="selected-memory-source">
|
<p className="selected-memory-source">
|
||||||
{t("selectedMemory.sourceLine", {
|
{t("selectedMemory.sourceLine", {
|
||||||
source,
|
source,
|
||||||
time: clockStamp(
|
time: writtenOnly
|
||||||
|
? t("selectedMemory.writeTime", { created })
|
||||||
|
: clockStamp(
|
||||||
detail.sdk_memory_at ?? evidence?.created_at,
|
detail.sdk_memory_at ?? evidence?.created_at,
|
||||||
locale,
|
locale,
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{writtenOnly ? null : (
|
||||||
<p className="selected-memory-times">
|
<p className="selected-memory-times">
|
||||||
{t("selectedMemory.timestamps", {
|
{t("selectedMemory.timestamps", {
|
||||||
conversation: clockStamp(
|
conversation: clockStamp(
|
||||||
conversationAt?.at ?? detail.sdk_memory_at ?? evidence?.created_at,
|
conversationAt?.at ??
|
||||||
locale,
|
detail.sdk_memory_at ??
|
||||||
),
|
evidence?.created_at,
|
||||||
created: clockStamp(
|
|
||||||
detail.sdk_created_at ?? memory.updated_at,
|
|
||||||
locale,
|
locale,
|
||||||
),
|
),
|
||||||
|
created,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,16 +14,20 @@ import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
|||||||
import { OptionMenu } from "./OptionMenu";
|
import { OptionMenu } from "./OptionMenu";
|
||||||
import { Answer, NodeDetail } from "./Workbench";
|
import { Answer, NodeDetail } from "./Workbench";
|
||||||
|
|
||||||
it("localiza la selección vacía del menú auxiliar", () => {
|
it("localiza y mantiene cerrado el menú auxiliar sin opciones", async () => {
|
||||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
|
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
|
||||||
render(
|
render(
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<OptionMenu value="" options={[]} onChange={vi.fn()} />
|
<OptionMenu value="" options={[]} onChange={vi.fn()} />
|
||||||
</LanguageProvider>,
|
</LanguageProvider>,
|
||||||
);
|
);
|
||||||
expect(
|
const menu = screen.getByRole("combobox", {
|
||||||
screen.getByRole("button", { name: "Selecciona una opción" }),
|
name: "Selecciona una opción",
|
||||||
).toBeTruthy();
|
});
|
||||||
|
menu.focus();
|
||||||
|
await userEvent.keyboard("{Enter}{ArrowDown}");
|
||||||
|
expect(menu.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(menu.getAttribute("aria-activedescendant")).toBeNull();
|
||||||
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
|
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1086,8 +1086,9 @@ button.map-tool {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
.map-inspector {
|
.map-inspector {
|
||||||
overflow-y: auto;
|
display: flex;
|
||||||
overscroll-behavior: contain;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: var(--glass-drawer);
|
background: var(--glass-drawer);
|
||||||
border-left: 1px solid var(--bd);
|
border-left: 1px solid var(--bd);
|
||||||
@@ -1095,10 +1096,19 @@ button.map-tool {
|
|||||||
}
|
}
|
||||||
.map-inspector-heading {
|
.map-inspector-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: none;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 22px;
|
margin-bottom: 22px;
|
||||||
}
|
}
|
||||||
|
.map-inspector-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-anchor: none;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
.map-inspector-heading h2 {
|
.map-inspector-heading h2 {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -4,14 +4,7 @@ export interface DebugLogEntry {
|
|||||||
id: string;
|
id: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
level: DebugLogLevel;
|
level: DebugLogLevel;
|
||||||
service:
|
service: "runtime" | "core" | "host-bridge" | "desktop" | "cli" | "renderer";
|
||||||
| "runtime"
|
|
||||||
| "core"
|
|
||||||
| "host-bridge"
|
|
||||||
| "desktop"
|
|
||||||
| "cli"
|
|
||||||
| "hy-memory"
|
|
||||||
| "renderer";
|
|
||||||
component: string;
|
component: string;
|
||||||
event: string;
|
event: string;
|
||||||
outcome: string;
|
outcome: string;
|
||||||
@@ -21,7 +14,7 @@ export interface DebugLogEntry {
|
|||||||
trace_id?: string;
|
trace_id?: string;
|
||||||
details?: Record<string, string | number | boolean | null>;
|
details?: Record<string, string | number | boolean | null>;
|
||||||
message?: string;
|
message?: string;
|
||||||
source: "diagnostic" | "stdout" | "stderr" | "console" | "pipeline";
|
source: "diagnostic" | "stdout" | "stderr" | "console";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DebugLogSnapshot {
|
export interface DebugLogSnapshot {
|
||||||
|
|||||||
@@ -55,18 +55,29 @@
|
|||||||
gap: 6px 10px;
|
gap: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.onboarding-r6-titlebar select {
|
.onboarding-r6-titlebar .r6-language-option-menu {
|
||||||
|
width: 144px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button {
|
||||||
|
height: 32px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
padding: 4px 8px;
|
padding: 4px 32px 4px 10px;
|
||||||
border: 1px solid #9badbd;
|
border: 1px solid #9badbd;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: #f7fafc;
|
background: #f7fafc;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
.onboarding-r6-titlebar select:focus-visible {
|
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button::after {
|
||||||
|
top: 10px;
|
||||||
|
right: 11px;
|
||||||
|
}
|
||||||
|
.onboarding-r6-titlebar
|
||||||
|
.r6-language-option-menu
|
||||||
|
.om-option-menu-button:focus-visible {
|
||||||
outline: 2px solid #356d99;
|
outline: 2px solid #356d99;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/* R6 source 139:5728 / 6765 / 7176 / 7585. Overrides stay inside settings. */
|
/* R6 source 139:5728 / 6765 / 7176 / 7585. Overrides stay inside settings. */
|
||||||
.r6-settings {
|
.r6-settings {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 26px 40px 46px;
|
padding: 30px 40px 48px;
|
||||||
color: #262a2e;
|
color: #304757;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -10,161 +10,230 @@
|
|||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
.r6-settings > h1 {
|
.r6-settings > h1 {
|
||||||
height: 44px;
|
height: auto;
|
||||||
font:
|
font:
|
||||||
500 28px / 44px "Noto Sans SC",
|
600 26px / 36px "Noto Sans SC",
|
||||||
"PingFang SC",
|
"PingFang SC",
|
||||||
sans-serif;
|
sans-serif;
|
||||||
margin: 0 0 28px;
|
margin: 0 0 22px;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-language-setting {
|
.r6-settings .r6-language-setting {
|
||||||
margin-bottom: 24px;
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
margin-bottom: 20px;
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
border: 1px solid #dce4eb;
|
border: 1px solid #d5dfe8;
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
background: #fff;
|
background: rgb(255 255 255 / 78%);
|
||||||
}
|
}
|
||||||
.r6-language-setting-title {
|
.r6-language-setting-title {
|
||||||
margin: 0 0 18px;
|
margin: 0 0 14px;
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
line-height: 28px;
|
font-weight: 600;
|
||||||
|
line-height: 26px;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-language-setting-row {
|
.r6-settings .r6-language-setting-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 96px minmax(0, 1fr);
|
grid-template-columns: 104px minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-language-setting-row label {
|
.r6-settings .r6-language-setting-row label {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 14px;
|
color: #4f6373;
|
||||||
line-height: 24px;
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 22px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-language-setting select {
|
.r6-settings .r6-language-option-menu {
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 44px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #dce4eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: inherit;
|
|
||||||
background: #fff;
|
|
||||||
font: inherit;
|
|
||||||
}
|
}
|
||||||
.r6-settings .r6-language-setting select:focus-visible {
|
.r6-settings .r6-language-option-menu .om-option-menu-button {
|
||||||
outline: 2px solid #225f9d;
|
height: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 42px 0 14px;
|
||||||
|
border: 1px solid #c6d2da;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #304757;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
.r6-settings .r6-language-option-menu .om-option-menu-button::after {
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
.r6-settings
|
||||||
|
.r6-language-option-menu
|
||||||
|
.om-option-menu-button[aria-expanded="true"] {
|
||||||
|
border-color: #7892a6;
|
||||||
|
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
|
||||||
|
}
|
||||||
|
.r6-settings .r6-language-option-menu .om-option-menu-panel {
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
padding: 6px;
|
||||||
|
border-color: #c6d2da;
|
||||||
|
box-shadow: 0 12px 32px rgb(48 71 87 / 13%);
|
||||||
|
}
|
||||||
|
.r6-settings .r6-language-option-menu .om-option-menu-panel [role="option"] {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: #304757;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
.r6-settings
|
||||||
|
.r6-language-option-menu
|
||||||
|
.om-option-menu-panel
|
||||||
|
[role="option"][aria-selected="true"] {
|
||||||
|
background: #dfe9ef;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.r6-settings
|
||||||
|
.r6-language-option-menu
|
||||||
|
.om-option-menu-panel
|
||||||
|
[role="option"][data-active="true"] {
|
||||||
|
color: #304757;
|
||||||
|
background: #e9f0f4;
|
||||||
|
}
|
||||||
|
.r6-settings
|
||||||
|
.r6-language-option-menu
|
||||||
|
.om-option-menu-panel
|
||||||
|
[role="option"]:active:not(:disabled) {
|
||||||
|
color: #304757;
|
||||||
|
background: #d4e2ea;
|
||||||
|
}
|
||||||
|
.r6-settings .r6-language-option-menu .om-option-menu-button:focus-visible {
|
||||||
|
outline: 2px solid #557c93;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
.r6-language-setting-description {
|
.r6-language-setting-description {
|
||||||
margin: 16px 0 0;
|
margin: 12px 0 0 120px;
|
||||||
color: #5e6e7a;
|
color: #5e6e7a;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 22px;
|
line-height: 21px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-settings-layout {
|
.r6-settings .r6-settings-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
grid-template-columns: 190px minmax(0, 1fr);
|
||||||
gap: 24px;
|
gap: 0;
|
||||||
align-items: start;
|
align-items: stretch;
|
||||||
|
border: 1px solid #d5dfe8;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-settings-layout > nav {
|
.r6-settings .r6-settings-layout > nav {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 710px;
|
min-height: 560px;
|
||||||
padding: 13px 11px;
|
padding: 16px 12px;
|
||||||
background: rgb(255 255 255 / 27.45%);
|
background: #f3f7f9;
|
||||||
border: 1px solid white;
|
border: 0;
|
||||||
border-radius: 16px;
|
border-right: 1px solid #d5dfe8;
|
||||||
box-shadow: inset 0 1px white;
|
border-radius: 11px 0 0 11px;
|
||||||
backdrop-filter: blur(72px);
|
box-shadow: none;
|
||||||
|
backdrop-filter: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 14px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-settings-layout > nav > button {
|
.r6-settings .r6-settings-layout > nav > button {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 8px 16px;
|
padding: 8px 12px;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 46px;
|
min-height: 42px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #262a2e;
|
color: #405665;
|
||||||
font:
|
font:
|
||||||
400 16px / 28px "Noto Sans SC",
|
400 14px / 22px "Noto Sans SC",
|
||||||
"PingFang SC",
|
"PingFang SC",
|
||||||
sans-serif;
|
sans-serif;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transform: none;
|
transform: none;
|
||||||
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
color 140ms ease-out,
|
||||||
|
background-color 140ms ease-out;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-settings-layout > nav > button[aria-current="page"] {
|
.r6-settings .r6-settings-layout > nav > button[aria-current="page"] {
|
||||||
background: #e1eaf0;
|
color: #304757;
|
||||||
|
background: #dfe9ef;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-settings-layout > nav > button:hover:not(:disabled) {
|
.r6-settings .r6-settings-layout > nav > button:hover:not(:disabled) {
|
||||||
background: rgb(225 234 240 / 65%);
|
color: #304757;
|
||||||
|
background: #e9f0f4;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
.r6-settings .r6-settings-layout > nav > button:active:not(:disabled) {
|
||||||
|
color: #304757;
|
||||||
|
background: #d4e2ea;
|
||||||
|
}
|
||||||
.r6-settings .r6-settings-content {
|
.r6-settings .r6-settings-content {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 710px;
|
min-height: 560px;
|
||||||
padding: 25px 31px;
|
padding: 28px 34px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #dce4eb;
|
border: 0;
|
||||||
border-radius: 16px;
|
border-radius: 0 11px 11px 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-settings-content > section[hidden] {
|
.r6-settings .r6-settings-content > section[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-settings-content > section > h2 {
|
.r6-settings .r6-settings-content > section > h2 {
|
||||||
min-height: 36px;
|
min-height: 32px;
|
||||||
font:
|
font:
|
||||||
500 22px / 36px "Noto Sans SC",
|
600 20px / 32px "Noto Sans SC",
|
||||||
"PingFang SC",
|
"PingFang SC",
|
||||||
sans-serif;
|
sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-setting-row {
|
.r6-settings .r6-setting-row {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 24px;
|
gap: 32px;
|
||||||
padding: 30px 0 25px;
|
padding: 24px 0;
|
||||||
border-bottom: 1px solid #d8e2ea;
|
border-bottom: 1px solid #d8e2ea;
|
||||||
min-height: 132px;
|
min-height: 108px;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-setting-row > div {
|
.r6-settings .r6-setting-row > div {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 68ch;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-setting-row > button {
|
.r6-settings .r6-setting-row > button {
|
||||||
margin-top: 12px;
|
margin-top: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-setting-row h3 {
|
.r6-settings .r6-setting-row h3 {
|
||||||
font:
|
font:
|
||||||
400 18px / 28px "Noto Sans SC",
|
500 16px / 26px "Noto Sans SC",
|
||||||
"PingFang SC",
|
"PingFang SC",
|
||||||
sans-serif;
|
sans-serif;
|
||||||
margin: 0 0 10px;
|
margin: 0 0 6px;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-setting-row p {
|
.r6-settings .r6-setting-row p {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
line-height: 26px;
|
line-height: 23px;
|
||||||
color: #63707b;
|
color: #5e6e7a;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
text-wrap: pretty;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-setting-row > span {
|
.r6-settings .r6-setting-row > span {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -205,6 +274,97 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #4f6373;
|
color: #4f6373;
|
||||||
}
|
}
|
||||||
|
.r6-settings .connection-fallback {
|
||||||
|
margin: 22px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.r6-settings .connection-fallback summary {
|
||||||
|
width: fit-content;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin-left: -8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #405665;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 24px;
|
||||||
|
transition:
|
||||||
|
color 140ms ease-out,
|
||||||
|
background-color 140ms ease-out;
|
||||||
|
}
|
||||||
|
.r6-settings .connection-fallback summary:hover {
|
||||||
|
color: #304757;
|
||||||
|
background: #e9f0f4;
|
||||||
|
}
|
||||||
|
.r6-settings .connection-fallback summary:active {
|
||||||
|
color: #304757;
|
||||||
|
background: #d4e2ea;
|
||||||
|
}
|
||||||
|
.r6-settings .connection-fallback .connection-boundary {
|
||||||
|
max-width: 68ch;
|
||||||
|
margin: 16px 0 20px;
|
||||||
|
color: #5e6e7a;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 23px;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
.r6-settings .connection-fallback .custom-harness-name {
|
||||||
|
max-width: 560px;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: #4f6373;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.r6-settings .connection-fallback .custom-harness-name input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 13px;
|
||||||
|
border: 1px solid #c6d2da;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #304757;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.r6-settings .connection-fallback .custom-harness-name input:hover {
|
||||||
|
border-color: #aabcc8;
|
||||||
|
}
|
||||||
|
.r6-settings .connection-fallback .custom-harness-name input:focus-visible {
|
||||||
|
outline: 2px solid #557c93;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.r6-settings .connection-fallback > button:not(.quiet) {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 1px solid #5b6f7e;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #4f6373;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 140ms ease-out,
|
||||||
|
transform 90ms ease-out;
|
||||||
|
}
|
||||||
|
.r6-settings .connection-fallback > button:not(.quiet):hover:not(:disabled) {
|
||||||
|
border-color: #5b6f7e;
|
||||||
|
background: #435966;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.r6-settings .connection-fallback > button:not(.quiet):active:not(:disabled) {
|
||||||
|
background: #3d4f5d;
|
||||||
|
color: #fff;
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
.r6-settings .connection-fallback > button:not(.quiet):disabled {
|
||||||
|
border-color: #c6d2da;
|
||||||
|
background: #dbe4e9;
|
||||||
|
color: #647783;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
.r6-settings .r6-settings-error {
|
.r6-settings .r6-settings-error {
|
||||||
color: #953f3f;
|
color: #953f3f;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -222,7 +382,7 @@
|
|||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-model-overview {
|
.r6-settings .r6-model-overview {
|
||||||
padding: 32px 0 24px;
|
padding: 26px 0 20px;
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-model-caption {
|
.r6-settings .r6-model-caption {
|
||||||
@@ -233,7 +393,7 @@
|
|||||||
}
|
}
|
||||||
.r6-settings .r6-current-model {
|
.r6-settings .r6-current-model {
|
||||||
font:
|
font:
|
||||||
400 24px / 40px "Noto Sans SC",
|
500 22px / 34px "Noto Sans SC",
|
||||||
"PingFang SC",
|
"PingFang SC",
|
||||||
sans-serif;
|
sans-serif;
|
||||||
margin: 0 0 7px;
|
margin: 0 0 7px;
|
||||||
@@ -363,23 +523,26 @@
|
|||||||
}
|
}
|
||||||
.r6-settings .r6-settings-help .r6-setting-row {
|
.r6-settings .r6-settings-help .r6-setting-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 132px;
|
min-height: 108px;
|
||||||
padding: 30px 0 25px;
|
padding: 24px 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-settings-help .r6-setting-row h3 {
|
.r6-settings .r6-settings-help .r6-setting-row h3 {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-settings-help .r6-setting-row p {
|
.r6-settings .r6-settings-help .r6-setting-row p {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-settings-help .r6-setting-row > button {
|
.r6-settings .r6-settings-help .r6-setting-row > button:not(.r6-switch) {
|
||||||
min-width: 132px;
|
min-width: 132px;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
.r6-settings .r6-settings-help .r6-setting-row > .r6-switch {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
.r6-settings .r6-settings-help .r6-settings-about {
|
.r6-settings .r6-settings-help .r6-settings-about {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding-bottom: 24px;
|
padding-bottom: 24px;
|
||||||
@@ -534,10 +697,9 @@
|
|||||||
}
|
}
|
||||||
.r6-settings .r6-settings-layout {
|
.r6-settings .r6-settings-layout {
|
||||||
grid-template-columns: 176px minmax(0, 1fr);
|
grid-template-columns: 176px minmax(0, 1fr);
|
||||||
gap: 20px;
|
|
||||||
}
|
}
|
||||||
.r6-settings .r6-settings-content {
|
.r6-settings .r6-settings-content {
|
||||||
padding: 25px 23px;
|
padding: 26px 28px;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-setting-row {
|
.r6-settings .r6-setting-row {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -559,6 +721,9 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #d5dfe8;
|
||||||
|
border-radius: 11px 11px 0 0;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-settings-layout > nav > button {
|
.r6-settings .r6-settings-layout > nav > button {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -566,6 +731,18 @@
|
|||||||
.r6-settings .model-form {
|
.r6-settings .model-form {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
.r6-settings .r6-settings-content {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-radius: 0 0 11px 11px;
|
||||||
|
}
|
||||||
|
.r6-settings .r6-language-setting-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.r6-language-setting-description {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-transparency: reduce) {
|
@media (prefers-reduced-transparency: reduce) {
|
||||||
.r6-settings .r6-settings-layout > nav {
|
.r6-settings .r6-settings-layout > nav {
|
||||||
|
|||||||
@@ -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_id).toBe("w1");
|
||||||
expect(page.window_reset).toBe(true);
|
expect(page.window_reset).toBe(true);
|
||||||
// An experience has no conversation time to show as "imported".
|
// Stored without a conversation time: it shows when it was written, not
|
||||||
expect(page.source_time_by_memory?.e).toBeUndefined();
|
// 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({
|
expect(page.items[1]).toMatchObject({
|
||||||
kind: "working",
|
kind: "working",
|
||||||
updated_at: new Date((NOW - 3_600) * 1_000).toISOString(),
|
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(experience)).toBe("Pin seed");
|
||||||
expect(sdkEditableText(record({}))).toBe("memory");
|
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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -154,22 +154,24 @@ export function sdkDetail(record: SdkMemoryRecord): MemoryDetail {
|
|||||||
export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
|
export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
|
||||||
const sourceTime: Record<
|
const sourceTime: Record<
|
||||||
string,
|
string,
|
||||||
{ at: string; basis: "conversation" | "import" }
|
{ at: string; basis: "conversation" | "import" | "write" }
|
||||||
> = {};
|
> = {};
|
||||||
const sourceIds: Record<string, string[]> = {};
|
const sourceIds: Record<string, string[]> = {};
|
||||||
const refs: Record<string, SdkMemoryRef> = {};
|
const refs: Record<string, SdkMemoryRef> = {};
|
||||||
for (const record of page.memories) {
|
for (const record of page.memories) {
|
||||||
sourceIds[record.memory_id] = [sourceOf(record)];
|
sourceIds[record.memory_id] = [sourceOf(record)];
|
||||||
refs[record.memory_id] = sdkMemoryRef(record);
|
refs[record.memory_id] = sdkMemoryRef(record);
|
||||||
// An experience or fact has no conversation time; the list shows when it
|
// An experience or fact stored before hy-memory 2.0.7 has no
|
||||||
// was last updated instead of calling its creation an import.
|
// conversation time. It shows when it was written, labelled as such,
|
||||||
if (refs[record.memory_id].kind !== "chat") continue;
|
// rather than calling its creation an import.
|
||||||
|
const chat = refs[record.memory_id].kind === "chat";
|
||||||
const conversation = plausible(record.memory_at);
|
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;
|
if (at === null) continue;
|
||||||
sourceTime[record.memory_id] = {
|
sourceTime[record.memory_id] = {
|
||||||
at: new Date(at * 1_000).toISOString(),
|
at: new Date(at * 1_000).toISOString(),
|
||||||
basis: conversation !== null ? "conversation" : "import",
|
basis: conversation !== null ? "conversation" : chat ? "import" : "write",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -219,12 +219,14 @@
|
|||||||
}
|
}
|
||||||
.r6-shell .r6-memory .map-inspector {
|
.r6-shell .r6-memory .map-inspector {
|
||||||
padding: 14px 22px 24px;
|
padding: 14px 22px 24px;
|
||||||
background: rgb(255 255 255 / 30%);
|
background: #f2f6f8;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
overflow-anchor: none;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
isolation: isolate;
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
}
|
}
|
||||||
.r6-shell .r6-memory .map-inspector-heading {
|
.r6-shell .r6-memory .map-inspector-heading {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -498,11 +500,8 @@
|
|||||||
width: min(418px, calc(100vw - 32px));
|
width: min(418px, calc(100vw - 32px));
|
||||||
}
|
}
|
||||||
.r6-shell .r6-memory .map-inspector-heading {
|
.r6-shell .r6-memory .map-inspector-heading {
|
||||||
position: sticky;
|
position: static;
|
||||||
top: -14px;
|
padding: 0;
|
||||||
z-index: 1;
|
|
||||||
background: #eff5f8;
|
|
||||||
padding: 10px 0;
|
|
||||||
}
|
}
|
||||||
.selected-editor {
|
.selected-editor {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ Support is declared per immutable GitHub Release manifest:
|
|||||||
- Linux x64/arm64 Runtime on native GHA runners.
|
- Linux x64/arm64 Runtime on native GHA runners.
|
||||||
- macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance.
|
- macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance.
|
||||||
- Windows is not supported.
|
- Windows is not supported.
|
||||||
- `hy-memory==2.0.6` is the current stable SDK dependency; the release
|
- `hy-memory==2.0.8` is the current stable SDK dependency; the release
|
||||||
lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes.
|
lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes.
|
||||||
|
|
||||||
See [`INSTALLATION.md`](INSTALLATION.md).
|
See [`INSTALLATION.md`](INSTALLATION.md).
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
3. 如果停住,停在导入、模型调用还是最终写入。
|
3. 如果停住,停在导入、模型调用还是最终写入。
|
||||||
|
|
||||||
默认 `info` 下的官方结构化事件不应写入会话正文、模型输入输出或 API Key;但本地原始日志允许
|
默认 `info` 下的官方结构化事件不应写入会话正文、模型输入输出或 API Key;但本地原始日志允许
|
||||||
新增受限标量标签,仍应按私人数据处理,不能直接对外发送。`debug` 会额外启用 HY pipeline
|
新增受限标量标签,仍应按私人数据处理,不能直接对外发送。`debug` 会让 HY 的
|
||||||
trace,其中可能包含完整 prompt/response 和获授权的对话正文。
|
`hy_memory.log` 记录 DEBUG 行,其中可能包含完整 prompt/response 和获授权的对话正文。
|
||||||
|
|
||||||
排查时只通过 App 的“导出日志”提供文件:导出过程会按安全白名单重新生成诊断事件,不会打包
|
排查时只通过 App 的“导出日志”提供文件:导出过程会按安全白名单重新生成诊断事件,不会打包
|
||||||
原始日志。不要直接发送 `diagnostic-*.jsonl`、整个 `~/.one-memory` 数据目录或 debug pipeline
|
原始日志。不要直接发送 `diagnostic-*.jsonl`、整个 `~/.one-memory` 数据目录或 debug 级别的
|
||||||
日志;后者如确需分享,必须由开发人员另行确认并脱敏。
|
`hy_memory.log`;后者如确需分享,必须由开发人员另行确认并脱敏。
|
||||||
|
|
||||||
## 1. Log level 设什么
|
## 1. Log level 设什么
|
||||||
|
|
||||||
@@ -181,8 +181,8 @@ memhub logs --since 24h --errors-only --tail 500 --json
|
|||||||
memhub status --json
|
memhub status --json
|
||||||
```
|
```
|
||||||
|
|
||||||
以下 `jq` 命令仅用于 `diagnostic-core.jsonl`,不要对 `core.log`、`service.log` 或 debug
|
以下 `jq` 命令仅用于 `diagnostic-core.jsonl`,不要对 `core.log`、`service.log` 或
|
||||||
pipeline 日志直接运行后把完整输出发给他人。
|
`hy_memory.log` 直接运行后把完整输出发给他人。
|
||||||
|
|
||||||
### 查看最近的失败
|
### 查看最近的失败
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# TODO: recall imported conversations before a model is configured
|
||||||
|
|
||||||
|
Status: open. Found 2026-10-28 by the Linux lifecycle run on build 35.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
A new user who has imported conversations but not configured a generation
|
||||||
|
model gets nothing from `MemHub_recall` or `memhub recall`.
|
||||||
|
|
||||||
|
`recall_with_fallback` (`apps/core/src/one_memory/recall_fallback.py`) is
|
||||||
|
meant to cover this case: without a model it searches the SDK raw layer
|
||||||
|
(`l1_raw`), the imported conversations themselves. But under SDK authority
|
||||||
|
nothing writes that layer until a model is active:
|
||||||
|
|
||||||
|
- File import and live capture only stage conversations into
|
||||||
|
`memory_write_jobs` (assembling, then pending).
|
||||||
|
- The durable write drain claims a job only when
|
||||||
|
`_sedimentation_claim_enabled` holds, which requires an active model
|
||||||
|
profile (`api.py`).
|
||||||
|
- The SDK writes `l1_raw` only inside `client.add`, which the drain calls.
|
||||||
|
|
||||||
|
So until a model is configured, the raw layer is empty and the fallback has
|
||||||
|
nothing to search. Before the SDK cutover, imported Evidence lived in
|
||||||
|
MemHub's own database and was searchable without a model.
|
||||||
|
|
||||||
|
The Linux lifecycle verification used to assert "import, then recall finds
|
||||||
|
it" on a machine with no model. It now asserts that the import reached the
|
||||||
|
write queue instead (`scripts/platform/linux/verify-linux-lifecycle.mjs`,
|
||||||
|
`waitForQueued`). Restore the recall assertion once this is fixed.
|
||||||
|
|
||||||
|
## Direction
|
||||||
|
|
||||||
|
Write imported conversations to the SDK raw layer without a generation
|
||||||
|
model, and leave extraction for when a model is configured.
|
||||||
|
|
||||||
|
- The raw layer needs embeddings only. The bundled local ONNX model
|
||||||
|
(`builtin_retrieval_snapshot`) can produce them, so no network model is
|
||||||
|
needed.
|
||||||
|
- SDK: a public raw-only write, for example `add(..., infer=False)` or
|
||||||
|
`add_raw(messages, ...)`, that stores `l1_raw` with source, session,
|
||||||
|
workspace and `memory_at`, and returns its raw ids. It must be idempotent
|
||||||
|
by `request_id`, like `add`.
|
||||||
|
- MemHub: split the claim gate. Without a model, the drain may claim a
|
||||||
|
pending job for a raw-only write. Once a model is active, the same job
|
||||||
|
still needs extraction, and that write must not store `l1_raw` again.
|
||||||
|
Either the job records that its raw part is done, or extraction runs
|
||||||
|
through `add_extracted` / a mode that skips the raw store.
|
||||||
|
- The source sync switch and grant still apply, exactly as for extraction.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Should raw-only ingestion run at all when the user has turned
|
||||||
|
sedimentation off, or does that switch mean "store nothing"?
|
||||||
|
- Storage cost: raw ingestion of a full history import without a model
|
||||||
|
embeds every turn up front. Check the time and disk cost on a large
|
||||||
|
history.
|
||||||
|
- When the user configures a model later, extract from the stored raw rows,
|
||||||
|
or re-read the jobs' frozen inputs? The second keeps one write path.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- On a fresh install with no model, `memhub recall "<phrase from an
|
||||||
|
imported conversation>"` returns that conversation with `mode: "raw"`.
|
||||||
|
- Configuring a model afterwards extracts memories from the same
|
||||||
|
conversations without duplicating their raw rows.
|
||||||
|
- `verify-linux-lifecycle.mjs` asserts recall again.
|
||||||
@@ -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_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. |
|
| `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. |
|
| `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
|
`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
|
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
|
## 3. MCP hosts
|
||||||
|
|
||||||
@@ -57,7 +59,8 @@ Register a stdio server named `one-memory`. The product name is MemHub; the conf
|
|||||||
"env": {
|
"env": {
|
||||||
"ELECTRON_RUN_AS_NODE": "1",
|
"ELECTRON_RUN_AS_NODE": "1",
|
||||||
"ONE_MEMORY_BROKER_DISCOVERY": "<absolute path to the discovery file>",
|
"ONE_MEMORY_BROKER_DISCOVERY": "<absolute path to the discovery file>",
|
||||||
"ONE_MEMORY_MANAGED_BY": "one-memory"
|
"ONE_MEMORY_MANAGED_BY": "one-memory",
|
||||||
|
"ONE_MEMORY_MCP_TOOLSET": "3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "one-memory",
|
"name": "one-memory",
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"release": {
|
"release": {
|
||||||
"build_number": 32,
|
"build_number": 35,
|
||||||
"macos_min_version": "13.0",
|
"macos_min_version": "13.0",
|
||||||
"app_update": "manual",
|
"app_update": "manual",
|
||||||
"runtime_manifest_schema": 1,
|
"runtime_manifest_schema": 1,
|
||||||
|
|||||||
@@ -468,10 +468,12 @@ export interface MemoryLibraryPage {
|
|||||||
limit: number;
|
limit: number;
|
||||||
/** Active evidence sources for each returned memory, from the same authorized page snapshot. */
|
/** Active evidence sources for each returned memory, from the same authorized page snapshot. */
|
||||||
source_ids_by_memory?: Record<string, string[]>;
|
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<
|
source_time_by_memory?: Record<
|
||||||
string,
|
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
|
/** True when the read budget ran out mid-page (typically during a history
|
||||||
* import): a partial snapshot, not an authoritative view. Show "importing"
|
* import): a partial snapshot, not an authoritative view. Show "importing"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { RecallRuntime } from "./recall-installer.js";
|
import type { RecallRuntime } from "./recall-installer.js";
|
||||||
import {
|
import {
|
||||||
DISCOVERY_KEY,
|
DISCOVERY_KEY,
|
||||||
|
MCP_TOOLSET_KEY,
|
||||||
|
MCP_TOOLSET_VERSION,
|
||||||
OWNER_KEY,
|
OWNER_KEY,
|
||||||
OWNER_VALUE,
|
OWNER_VALUE,
|
||||||
renderConnectionMaterial,
|
renderConnectionMaterial,
|
||||||
@@ -60,6 +62,7 @@ describe("mcp install snippet", () => {
|
|||||||
const env = parsed.mcpServers[SERVER_NAME].env;
|
const env = parsed.mcpServers[SERVER_NAME].env;
|
||||||
expect(env[DISCOVERY_KEY]).toBe(runtime().discoveryPath);
|
expect(env[DISCOVERY_KEY]).toBe(runtime().discoveryPath);
|
||||||
expect(env[OWNER_KEY]).toBe(OWNER_VALUE);
|
expect(env[OWNER_KEY]).toBe(OWNER_VALUE);
|
||||||
|
expect(env[MCP_TOOLSET_KEY]).toBe(MCP_TOOLSET_VERSION);
|
||||||
expect(env.ELECTRON_RUN_AS_NODE).toBe("1");
|
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,
|
// 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.
|
// and treated the same way: one source, checked by a test.
|
||||||
const doc = await fs.readFile(CONTRACT_DOC, "utf8");
|
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(key);
|
||||||
}
|
}
|
||||||
|
expect(doc).toContain(`"${MCP_TOOLSET_KEY}": "${MCP_TOOLSET_VERSION}"`);
|
||||||
expect(doc).toContain(OWNER_VALUE);
|
expect(doc).toContain(OWNER_VALUE);
|
||||||
expect(doc).toContain(SERVER_NAME);
|
expect(doc).toContain(SERVER_NAME);
|
||||||
});
|
});
|
||||||
|
|||||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user