Compare commits

..
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 528d2c9fd7 fix(memhub): Linux lifecycle checks the write queue, not recall without a model
The lifecycle verification imported a conversation on a runner with no
generation model and then expected recall to find it. Under SDK authority
nothing reaches the SDK, not even its raw layer, until a model is active,
so the check could never pass and blocked the Linux release archive.

It now asserts what the runner can prove: the imported and appended turns
reach Core's durable write queue, and survive an idempotent upgrade.
docs/TODO-raw-recall-without-model.md records the product gap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 20:05:44 +08:00
feitianxiang 8e56bf7a37 Merge pull request #218 from Tencent-Hunyuan/fix/sdk-client-race-and-unknown-jobs
fix(memhub): serialize SDK client startup, log failure frames, settle pre-upgrade unknown writes
2026-10-28 19:49:24 +08:00
alvinfeiandClaude Opus 5.5 352fffb3da fix(memhub): serialize SDK client startup, log failure frames, settle pre-upgrade unknown writes
- hy_sdk_runtime: construct one HyMemoryClient at a time per home. Two
  first-time Chroma opens on a fresh vector dir race Chroma's unlocked
  System cache; on a clean install the drain's and the UI's clients both
  failed and the first write job was lost as unknown.
- sdk_write_processor: a client that fails to construct raises
  hy_memory_client_unavailable before HY is invoked, which parks the job
  (waiting_for_model) instead of leaving it unknown.
- sdk_write_coordinator: a retired profile's handle closes only after its
  last in-flight use (defensive; a profile revision change used to close
  the client under a running write).
- durable_queue: durable_write_job_failed/parked log the exception chain as
  file:line:function frames, never the message.
- sdk_migration_runner: the cutover settles pre-cutover unknown (and
  invoked running) write jobs as cancelled/abandoned_at_cutover and counts
  them as abandoned.unknown_write_jobs; never retried.
- durable_queue/api: retry-unknown and the reconcile helpers no longer
  assume the dropped legacy task tables; the endpoint uses the startup
  queue so it does not recreate memory_write_job_evidence.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 19:39:13 +08:00
feitianxiang 42c0ca1d14 Merge pull request #215 from Tencent-Hunyuan/feat/memhub-sdk-logs-quiet
feat(memhub): HY pipeline log off, SDK text logs only in hy_memory.log (hy-memory 2.0.8)
2026-10-28 15:46:40 +08:00
alvinfeiandClaude Opus 5.5 0ca2e56ccc chore(release): bump MemHub build number to 35
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 15:38:05 +08:00
alvinfeiandClaude Opus 5.5 f328cdbf8e docs: record hy-memory 2.0.8 as published
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 15:38:05 +08:00
alvinfeiandClaude Opus 5.5 3f57a3c978 build(memhub): lock hy-memory 2.0.8 from PyPI
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 15:37:51 +08:00
alvinfeiandClaude Opus 5.5 19a7af0e71 feat(memhub): remove the HY pipeline section from the Debug panel
MemHub no longer writes the HY pipeline JSONL, so the Desktop diagnostics
reader stops scanning hy-memory/governed-v2/logs and the renderer drops the
"hy-memory" service and "pipeline" source. A test covers leftover pipeline
files and hy_memory.log staying out of the panel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 15:36:50 +08:00
alvinfeiandClaude Opus 5.5 5791c0d1e7 feat(memhub): HY pipeline log off, SDK text logs only in hy_memory.log
- MEMORY_PIPELINE_LOG_ENABLED and MEMORY_PIPELINE_TRACE_ENABLED are false
  at every diagnostic level, both at startup and on a live level change.
  MEMORY_LOG_LLM_CONTENT keeps its debug-only setting: it still decides
  whether HY's DEBUG lines in hy_memory.log carry full prompts/responses.
- After each HyMemoryClient construction (success or failure), Core drops
  the SDK's stdout/stderr StreamHandlers, which under launchd duplicated
  every SDK line into service.log/service-error.log, and forwards SDK
  WARNING+ to Core's core.log handler so the Debug panel still shows SDK
  problems.
- A live level change also sets the level of HY's hy_memory.log handler,
  which the SDK otherwise fixes at the level of the first client.
- log_file.py no longer claims core.log covers the HY SDK at every level.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 15:36:50 +08:00
alvinfeiandClaude Opus 5.5 bf92105210 build(memhub): pin hy-memory 2.0.8
2.0.8 adds MEMORY_PIPELINE_LOG_ENABLED and keeps conversation text out of
INFO logs. apps/core/uv.lock is not updated: 2.0.8 is not on PyPI yet, so
the lock is regenerated (uv lock --directory apps/core) once it is
published.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 15:36:50 +08:00
feitianxiang 3387247842 Merge pull request #214 from Tencent-Hunyuan/feat/sdk-pipeline-log-switch
feat(sdk): switch to turn the pipeline log off, keep conversation text out of INFO logs (2.0.8)
2026-10-28 15:34:41 +08:00
alvinfeiandClaude Opus 5.5 791285f4c9 fix(sdk): keep model output out of the reconcile parse-failure warning
The final RECONCILE JSON parse failure warning embedded the raw response
whenever MEMORY_LOG_LLM_CONTENT was on. It now reports the length; the raw
output is already logged per attempt at DEBUG.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 15:06:09 +08:00
alvinfeiandClaude Opus 5.5 dc762df78b feat(sdk): switch to turn the pipeline log off, keep conversation text out of INFO logs (2.0.8)
MEMORY_PIPELINE_LOG_ENABLED (default true) turns off the pipeline JSONL
log. With the DB trace also off, store_pipeline_log becomes a no-op that
does no timing, redaction, background work or database write. The init line
states the real on/off state.

Lines that carried conversation or memory text at INFO/WARNING now log ids,
counts and lengths; the text goes to DEBUG: [add] input, search queries and
rewrites, coding tasks and fact statements/subjects, reconcile op detail,
extracted info, embedding inputs and unparsable model output.

Bump hy-memory to 2.0.8 (not yet published).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 15:03:21 +08:00
feitianxiang 8277a72aea Merge pull request #206 from rahul05ranjan/fix/runtime-stale-lease-boot-clean
fix(runtime): 清理早于本次开机的服务锁,异常关机后不再崩溃循环
2026-10-28 14:42:46 +08:00
feitianxiang 5eed0f81fb Merge pull request #200 from u7-u7/fix/literal-special-token-counting
fix(tokens): count literal special-token spellings as ordinary text
2026-10-28 14:36:16 +08:00
feitianxiang 59ca5afca6 Merge pull request #199 from u7-u7/fix/guarded-thread-context
fix(async): preserve context in guarded thread calls
2026-10-28 14:36:11 +08:00
feitianxiang 2648012ad5 Merge pull request #203 from u7-u7/fix/migration-log-write-order
fix(memhub): preserve migration log write order
2026-10-28 14:36:05 +08:00
alvinfeiandClaude Opus 5.5 63a80092c2 fix(runtime): identify stale leases by boot id and publish them atomically
Comparing the lock directory's mtime with Date.now() - os.uptime() breaks
when the wall clock jumps forward after a lease is taken (no RTC, VM
resume, NTP or manual correction): a live lease looks older than the boot
and is cleared, so a second Runtime can open the same data dir.

owner.json now records the kernel boot identity (kern.bootsessionuuid on
macOS, /proc/sys/kernel/random/boot_id on Linux, null elsewhere), read
once per process. removeStaleLease clears a lease whose boot_id differs
from the current one, which still fixes the recycled-PID crash loop of
#184 without consulting the clock; when either id is unknown the PID
probe decides as before. bootTime, BOOT_SLACK_MS and defaultBootTime are
gone; the boot id source is injectable via RuntimeServiceOptions.bootId.

The lease is published in one step: owner.json is written into a
runtime-service.lock.tmp-<uuid> staging dir that is then renamed onto the
lock, so a power cut can no longer leave a lock without an owner. Because
rename() replaces an existing empty directory, an existing lock of any
kind is reported as taken first. releaseLease also removes the lock with
a single rename, so this version never shows an owner-less lock; staging
and cleared-lease debris is swept once the lease is held.

An owner-less lock can therefore only come from an older version. It is
cleared when its mtime predates the kernel-reported boot time
(kern.boottime, /proc/stat btime) and otherwise keeps failing as
runtime_service_lock_invalid, as before.

Refs #184

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 14:33:33 +08:00
feitianxiang 6ba49bf112 Merge pull request #209 from Tencent-Hunyuan/fix/memory-map-node-navigation
fix(memhub): open source memories from map nodes
2026-10-28 14:10:28 +08:00
feitianxiang cf410aa9cf Merge pull request #210 from Tencent-Hunyuan/fix/harness-refresh-on-upgrade
fix(memhub): refresh harness skills and MCP entry after an upgrade
2026-10-28 14:08:01 +08:00
kristinheandCursor 81ed1d816f fix(memhub): open source memories from map nodes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 12:00:26 +08:00
alvinfeiandClaude Opus 5.5 30c0b1d5ff fix(memhub): refresh harness skills and MCP entry after an upgrade
After an upgrade, hosts kept the call-rule skill and MCP entry an older
release wrote, so models were still told to call MemHub_ask and the other
removed tools, and WorkBuddy kept listing them from its tool-list cache.

- Stamp every managed MCP entry with ONE_MEMORY_MCP_TOOLSET, a tool
  contract version kept beside the tool list (mcp-toolset.ts). WorkBuddy
  fingerprints the stdio transport, env included, so a new stamp makes it
  restart the server and drop the cached list. An entry without the
  current stamp reads as stale in skill drift; verify still passes it.
- HostPermissions.refreshInstalledHosts runs the existing repoint and
  stale-sync planners for every host this data dir installed, leaves
  user-edited skills alone, emits allowlisted host_refresh.* diagnostics
  (host ids and counts only), and records the build it completed in
  host-permissions/host-refresh.json so it runs once per build.
- The Runtime calls it as it starts (covering App and `memhub upgrade`),
  and Desktop calls it after an embedded Runtime is up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:58:21 +08:00
feitianxiang 1086f4d582 Merge pull request #208 from Tencent-Hunyuan/feat/memhub-experience-memory-at
fix(memhub): experiences show their conversation time (hy-memory 2.0.7)
2026-10-28 11:50:41 +08:00
alvinfeiandClaude Opus 5.5 32539e7a82 chore(release): bump MemHub build number to 34
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:44:42 +08:00
alvinfeiandClaude Opus 5.5 cfae020a31 test(memhub): install docs name hy-memory 2.0.7
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:44:42 +08:00
alvinfeiandClaude Opus 5.5 5d51513ec8 docs: record hy-memory 2.0.7 as published
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:37:30 +08:00
alvinfeiandClaude Opus 5.5 9db5b49ccb build(memhub): lock hy-memory 2.0.7 from PyPI
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:37:23 +08:00
alvinfeiandClaude Opus 5.5 cb7df2a022 fix(memhub): metadata repair adds the SDK's memory_at column itself
The repair wrote memory_at only when the coding store already had the
column, which depended on the cutover readback having opened the store
through a 2.0.7 client first. On a migrated 1.0.4 home it now brings each
coding.sqlite3 / coding_cv.sqlite3 to the installed SDK's schema before
repairing, by running CodingMemoryStore's own SQLite schema step (its
initialize() would also open Chroma, which must stay closed offline). The
SDK's DDL is not repeated here.

The test now starts from a 2.0.6-shaped store without the column and
expects the bundle's conversation time in it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:37:13 +08:00
alvinfeiandClaude Opus 5.5 812d1c3846 build(memhub): pin hy-memory 2.0.7
2.0.7 stores and returns the conversation time of working/coding
experiences. apps/core/uv.lock is not updated: 2.0.7 is not on PyPI yet,
so the lock is regenerated (uv lock --directory apps/core) once it is
published.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:36:56 +08:00
alvinfeiandClaude Opus 5.5 c8b660651e fix(memhub): experiences show their conversation time
After the SDK upgrade 124 working/coding experiences read 时间未知: the SDK
never stored their conversation time. hy-memory 2.0.7 stores memory_at for
experiences and facts; Core now maps it from list, get and search, so the
memory library, detail, map list and home summary carry it.

An experience stored before 2.0.7 has none. The list labels its write time
写入于 and the detail 写入时间, instead of 时间未知 or calling it an import.

Metadata repair also writes the conversation time recovered from the
migration bundle into coding_memory_meta / working_fact when the store has
the memory_at column. The repair version is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:36:56 +08:00
feitianxiang bdd2d9cf84 Merge pull request #207 from Tencent-Hunyuan/feat/sdk-coding-memory-at
feat(sdk): coding and working experiences keep their conversation time (2.0.7)
2026-10-28 11:33:29 +08:00
alvinfeiandClaude Opus 5.5 fc2e51a8cc release: hy-memory 2.0.7
Coding and working experiences and facts keep their conversation time
(memory_at) and return it from list, get and search. Not yet published to
PyPI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:09:54 +08:00
alvinfeiandClaude Opus 5.5 d4fca0639e feat(sdk): coding and working experiences keep their conversation time
client.add passed memory_at only to the chat path; the productivity writer
dropped it and coding_memory_meta / working_fact had no column for it, so
every experience had only its write time and MemHub showed it as 时间未知.

Both tables gain a nullable memory_at column (epoch seconds), added to
existing coding/working databases on open. The writer stamps it on drafts
and new facts, defaulting to the request time as chat does; an UPDATE keeps
the later of the stored and incoming times. list_memories (coding, working,
all), get and search return memory_at in chat's format.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:09:54 +08:00
Rahul Ranjanandrahul05ranjan a17230dadb fix(runtime): clear leases that predate the current boot
A forced power-off leaves runtime-service.lock behind with no chance to
release it (#184). After the reboot the liveness probe cannot tell the
dead owner from the process that reused its PID, so launchd's KeepAlive
restarts the wrapper into runtime_service_already_running without end;
and a power cut inside the mkdir -> privateWrite window leaves the lock
without owner.json at all, which fails as runtime_service_lock_invalid
forever.

A lease created before the current boot can never have a living owner:
every process from that boot is gone. removeStaleLease now compares the
lock directory's mtime with the boot time (os.uptime(), injectable via
bootTime for tests) and clears such leases before it ever trusts owner
or PID. A two-minute slack absorbs forward clock steps taken since
boot, so a lease taken right after boot is not mistaken for stale.
Same-boot leases keep the old behavior exactly: live owner still
refused, dead owner still reclaimed, corrupt same-boot lease still
refused.

Fixes #184

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
2026-10-28 01:00:05 +05:30
feitianxiang f7961f340a Merge pull request #198 from Tencent-Hunyuan/fix/memory-detail-layout
fix(memhub): 稳定记忆页并优化设置交互
2026-10-28 00:54:42 +08:00
feitianxiang cfebae118d Merge pull request #202 from Tencent-Hunyuan/fix/sdk-write-pipeline
fix(memhub): SDK write pipeline — turn grouping, thinking off, manual adds, home count
2026-10-28 00:54:05 +08:00
u7-u7 d76bfa6474 fix(memhub): preserve migration log write order 2026-10-28 00:49:54 +08:00
alvinfeiandClaude Opus 5.5 b169906fcb chore(release): bump MemHub build number to 33
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 00:38:34 +08:00
alvinfeiandClaude Opus 5.5 f3b417ced4 fix(memhub): write conversations by one turn rule on every path
File import made one job per file-read increment per conversation: a
transcript read mid-turn gave tool-only jobs, which the SDK refused with
400 "content or messages is required" and Core recorded unknown (16
times); a first import gave one giant job per conversation. Hook capture
and catch-up wrote each completed turn alone, splitting long turns into
pieces that repeated the question.

turn_batches (conversation_processing) is now the one rule:
- a turn is a user message and everything up to the next one; records
  before the first user message join the first turn, and are dropped if
  no user message ever comes;
- a turn is complete when the next user message arrives, or on flush;
- every 5 complete turns are one batch; before 5, the turn that would
  pass 8000 characters starts the next batch; one turn over 8000 is a
  batch of its own and is never split;
- characters are what HY reads (tool records capped at
  _HY_TOOL_CHARACTERS);
- a short batch is written only on flush; no batch lacks a user message.

File import keeps its assembling row per (file, conversation) and now
writes only what the rule completes, carrying the rest to the next
increment. History flushes at end of file. Live polling and hook capture
(which now stage into the same rows) flush after 10 minutes without a
new message, swept on the drain's maintenance timer. A record a job
already holds is not staged again, so a re-read creates no second job;
batch keys stay content hashes. Legacy catch-up groups by the same rule.

Backstop: SdkWriteProcessor completes a batch with no user/assistant
message as nothing to remember, without calling the SDK.

The unused _merge_historical_chunks and the old window code
(_conversation_chunks, segmenting, ONE_MEMORY_SPLIT_TURNS) are removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 00:38:06 +08:00
u7-u7 e9698a293c fix(tokens): count literal special-token spellings as ordinary text 2026-10-28 00:28:14 +08:00
alvinfeiandClaude Opus 5.5 aaebe64244 fix(memhub): metadata repair never runs beside an SDK client
After an upgrade 76/76 memories showed source=default: the startup
metadata repair failed with a KeyError. It ran after readiness and
opened its own Chroma client on the SDK's vector store. Chroma keeps one
process-wide System per path with no lock, and the repair's close() (or
a failed client init) released it while the first library request was
opening the SDK client, whose next lookup raised KeyError.

- sdk-migrate now runs the repair offline right after the cutover
  commits and writes the receipt, so startup normally finds it and does
  nothing. A repair failure there is logged and left to startup.
- Startup keeps the repair as a fallback but holds the home's SDK stores
  offline from before readiness until the repair thread ends; every SDK
  client is built through acquire_client, which waits meanwhile.
  Readiness is not delayed; the first request waits for the repair.
  Homes migrated with a failed repair have no receipt, so they are
  repaired on the next launch.
- A failed recovery step logs its exception chain as file:line:function
  frames (core.log) and safe_stack (diagnostic event), never messages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 23:53:18 +08:00
alvinfeiandClaude Opus 5.5 f3d13c4351 fix(memhub): a hand-added memory no longer waits Pending forever
On SDK authority the add path submits straight to the intake and writes
no one-memory-manual grant row. The intake admitted the record, but the
drain's claim gate required the grant receipt, so the job was never
eligible: attempts=0 forever while later jobs ran.

_source_writes_on now treats a missing manual grant as allowed and only
an explicit revocation as refused. Intake, claim and execute all ask it,
so jobs already stuck become claimable on the next drain tick. The SDK
add path refuses a revoked manual source with memory_add_source_revoked,
like the legacy path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 23:53:00 +08:00
kristinheandCursor 3e8a006f65 fix(memhub): satisfy full lint gate
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:51:51 +08:00
kristinheandCursor a70830970d fix(memhub): polish settings interactions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:45:08 +08:00
kristinheandCursor 556d95df84 fix(memhub): stabilize memory detail and pagination
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:45:00 +08:00
alvinfeiandClaude Opus 5.5 ad05303d23 fix(memhub): turn model thinking off on the SDK write path
Profiles default to disable_thinking, but build_sdk_config never told
the SDK. DeepSeek Flash spent the SDK's 2000 agent_max_tokens thinking
and answered finish_reason=length with no content (EMPTY_RESPONSE).

The provider mapping moves into disable_thinking_params, shared by
attach_disable_thinking and the SDK's llm.extra_body. The SDK sends
extra_body on every chat call, and the coding/working writers build
their LLMProvider from the same llm config.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 23:37:09 +08:00
alvinfeiandClaude Opus 5.5 423cb00dbf fix(memhub): home memory count matches the memory page
The home card counted chat memories only (list_memories without a
scene), so it showed 55 while the memory page showed 94. It now reads
memory_stats total (chat + working + coding experiences, facts not
counted) and takes the newest memory from list_memories(scene="all").

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 23:33:19 +08:00
feitianxiang e09fbd16f9 Merge pull request #197 from Tencent-Hunyuan/feat/cli-simplify
feat(memhub): simplify the CLI and drop the notes concept
2026-10-27 20:21:20 +08:00
u7-u7 6718ba8bdf fix(async): preserve context in guarded thread calls 2026-10-27 10:41:13 +08:00
130 changed files with 5552 additions and 1226 deletions

No files matched your search

+58
View File
@@ -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
+1
View File
@@ -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` | 是否启用历史记录 |
+4 -3
View File
@@ -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,
+1 -2
View File
@@ -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
+1 -1
View File
@@ -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:
+11 -9
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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,
+4 -1
View File
@@ -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 缓存 / 旧模型可能还这么回
+20 -7
View File
@@ -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
View File
@@ -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
+22 -10
View File
@@ -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 ""
+48 -7
View File
@@ -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(),
), ),
) )
+14
View File
@@ -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,
} }
+8 -1
View File
@@ -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
+15 -4
View File
@@ -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}"
) )
+2 -1
View File
@@ -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:
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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 -1
View File
@@ -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(
+2 -1
View File
@@ -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,
+44 -3
View File
@@ -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,
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -18,7 +18,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同 ## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.6`。MemHub 源码版本为 `1.0.5` 源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.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`。
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
column is filled in as part of release review, and the SBOM records 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
+1 -1
View File
@@ -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",
+102 -32
View File
@@ -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:
+55 -2
View File
@@ -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,
+4 -2
View File
@@ -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)
+40 -43
View File
@@ -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) == []
+49 -1
View File
@@ -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():
+4 -4
View File
@@ -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" },
+17 -53
View File
@@ -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 () => {
+4 -146
View File
@@ -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" });
});
});
+18
View File
@@ -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();
}); });
+11 -2
View File
@@ -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
-1
View File
@@ -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() {
+36 -7
View File
@@ -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",
); );
}); });
+27 -17
View File
@@ -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">
+322 -98
View File
@@ -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();
}); });
+117 -20
View File
@@ -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>
); );
+2 -1
View File
@@ -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();
}); });
+3 -1
View File
@@ -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,
+104 -13
View File
@@ -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");
+9 -2
View File
@@ -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();
+1 -1
View File
@@ -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();
});
+24 -7
View File
@@ -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>
); );
} }
+8 -4
View File
@@ -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);
}); });
+12 -2
View File
@@ -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;
+2 -9
View File
@@ -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 {
+14 -3
View File
@@ -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;
} }
+249 -72
View File
@@ -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 {
+32 -2
View File
@@ -119,8 +119,12 @@ it("carries each memory's scene and namespace, and edits an experience's solutio
}); });
expect(page.window_id).toBe("w1"); expect(page.window_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(),
);
});
+8 -6
View File
@@ -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 {
+7 -8
View File
@@ -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;
+1 -1
View File
@@ -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).
+6 -6
View File
@@ -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
View File
@@ -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,
+4 -2
View File
@@ -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