Compare commits

...
Author SHA1 Message Date
kkjzkangandCursor 8918098fa8 docs: announce Hy-Memory-30B-A3B FP8 and GGUF quantized models
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 11:22:16 +08:00
0afe787c95 fix: stop authority-invalid HY retries (#78)
* fix: prevent authority-invalid HY retries

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: align coding authority with conversations

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 15:30:32 +08:00
67ae43f920 fix(memhub): repair visibility dirty trigger conflicts (#77)
* fix(memhub): repair visibility link trigger

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memhub): make trigger upgrade atomic

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 15:30:11 +08:00
3ce89ea560 fix(sdk): serialize sqlite connection lifecycle (#73)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 13:02:22 +08:00
7b7fd217a8 fix(sdk): clarify metadata and allow opt-in raw get (#65)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 22:30:23 +08:00
7efe054e83 fix(core): park writes until a model is active (#63)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:43:24 +08:00
fdbcd9934d fix(web): distinguish scanned and saved imports (#60)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:10:10 +08:00
3171007b3c fix(core): keep initial sync behind bounded history (#59)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:00:54 +08:00
a04fcb50d5 docs: announce MemHub and CLI releases (#56)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:56:32 +08:00
a28ade2faf fix(release): hash Runtime symlinks consistently (#55)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:56:07 +08:00
1c424928a2 fix(cli): trust GitHub release asset redirects (#54)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:49:40 +08:00
kristinhe1111 688878171e chore(release): bump MemHub build number to 13 (#52) 2026-10-18 18:03:08 +08:00
a3f09f4cac release: merge MemHub 1.0.4 development fixes (#50)
* feat(memhub): add model usage audit and cost controls

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memhub): typed source authorization state and managed-key registry ownership

Closes #29

Issue #29 surfaced as "WorkBuddy plugin cannot be uninstalled / source sync
cannot be turned on", but it is two independent failure domains that were both
being reported as a single opaque `desktop_action_failed`:

1. Source sync domain: the UI could not tell whether a source failed to sync
   because the grant was missing, revoked, or because the source identity or
   root path changed. Every failure collapsed into one error, so the UI could
   only say "failed".

2. Host plugin install domain: WorkBuddy shares three registry files
   (`known_marketplaces.json`, `installed_plugins.json`, `settings.json`) with
   the host. MemHub recorded a whole-file hash at install time and then refused
   to uninstall whenever the host (or a reformat) changed any byte anywhere in
   the file, even in entries MemHub does not own.

What changes

Domain errors (new `packages/host-bridge/src/domain-error.ts`)
- Closed allowlist `DOMAIN_CODES`: grant_required, source_identity_changed,
  host_uninstall_modified_managed_value, host_uninstall_unknown_owned_file,
  host_uninstall_needs_review, host_install_journal_invalid,
  host_install_recovery_required, host_permission_storage_unsafe.
- `DomainError` + `domainCodeOf()` so callers stop string-matching on
  `Error.message`. `diagnosticDomainError()` emits only the code and a
  repo-relative safe stack, never message text, so diagnostics cannot leak
  paths or payloads.
- Re-exported from `runtime-control` (host-bridge owns the type to avoid a
  dependency cycle); desktop `observeDesktopAction` now uses
  `domainCodeOf(error) ?? fallbackCode`.

Source authorization state
- `SourceAuthorizationState` enum: ok / missing_grant / revoked_grant /
  source_identity_changed / source_root_unavailable / candidate_changed, plus
  `SourceGrantStore.authorization_state()` which is read-only and fails closed.
- `/api/v1/sources` now returns `authorization_state` per source; contracts and
  the web app render a concrete next action next to the sync switch instead of
  a generic failure.
- `_change_source_sync` distinguishes sync_prepare_failed,
  sync_config_write_failed, sync_resume_failed, sync_watcher_failed (409 with
  the code as detail) while keeping the existing rollback behaviour.
- Capture refuses silently but now records a fail-open rejection marker
  (`sync_disabled` / grant_missing / grant_revoked) that counts occurrences in a
  time window and carries no payload, path, or session content.

WorkBuddy registry ownership (journal v1 -> v2)
- Ownership is now the value at MemHub's own key, not the whole file. Added
  `canonicalJson`, `registryManagedDigest`, `registryWithoutOurs`,
  `registryHoldsOnlyOurs`.
- Install: the plan records `keyPath` + managed digest; apply compares the
  managed value (CAS) instead of the whole file; the journal writes both the v1
  `installed_hash` and the v2 `installed_managed`.
- Uninstall: `resolveRegistryOwnership()` compares the v2 digest, falls back to
  the v1 hash and `isManagedRegistryEntry`, and otherwise produces a review
  finding instead of throwing. `managedRegistryRemoval()` deletes a file MemHub
  created when it still holds only our entry; removes just our key when the host
  has added its own content; and restores recorded bytes only when the rest of
  the document is unchanged, otherwise patches the current document.
- v1 journals migrate: a whole-file hash mismatch no longer blocks uninstall.

Tests
- `packages/host-bridge/src/domain-error.test.ts` (5): code is carried, message
  never reaches diagnostics, no guessing on unclassified errors, refuses to
  parse codes out of free text, allowlist is closed.
- `workbuddy-plugin-installer.test.ts` (34 total): uninstall survives a host
  reformat, removes only our key when the host changed something else, asks for
  review when a managed entry was changed, migrates a v1 journal, keeps a
  registry the host now uses, installs over a post-plan change.
- `apps/core/tests/test_source_authorization_state.py` (12): every state,
  fail-closed ordering, capture rejection names, marker contains no secret or
  path, recording never raises.
- `apps/web`: authorization note rendered for revoked grant and hidden when ok.

Verification
- host-bridge WorkBuddy suite 34/34; domain-error 5/5.
- apps/web 1906 tests across 105 files pass.
- pnpm -r typecheck clean; biome check clean on touched files.
- core-fast: 232 failed / 4131 passed, identical failure count to the clean
  baseline (232 failed / 4119 passed) with +12 from the new tests; the failures
  are pre-existing and unrelated (confirmed by stashing and re-running).

* feat(memhub): add a start/stop switch for model sedimentation

Shipped builds gave the user no way to stop model calls: the model content
permission switch is gone (removed with its endpoint in #20) and there is no
pause or remove entry either, so a configured model keeps turning
conversations into memories and keeps costing money.

Adds a user switch in settings: stopping keeps the model configuration and
the already imported conversations, leaves queued work pending instead of
failing it, and stops new work, enqueueing and backfill so nothing further
reaches the model. Starting again resumes the same work without re-entering
a key.

The flag lives in Core's own user config (config.json), so no schema
migration is needed, and the supervisor takes it as an optional callback so
every existing caller and test keeps its current behaviour.

* test(desktop): keep host-bridge mocks whole and assert the review contract

The memhub CI job failed four desktop tests after the issue #29 change. Both
were test-side contracts that the new ownership model intentionally moves, not
regressions in the fix itself.

1. `host-control-ownership.test.ts` mocked `@one-memory/host-bridge` with a
   whitelist that carried only `RecallBroker`. Main now imports `domainCodeOf`,
   which runtime-control re-exports from host-bridge, so the mock handed back
   undefined and every error path that called it threw a TypeError instead of
   the error the test was asserting on. The mock now spreads the real module
   and overrides only `RecallBroker`, which is the part it was written for.

2. `host-permissions.test.ts` asserted the pre-#29 contract, where a change
   anywhere in a shared registry made uninstall throw
   `host_uninstall_modified_files`. Ownership is now the value at our own key,
   so a change to that value is reported instead of guessed at: the plan
   schedules no removal, names the file in a review finding, and applying it
   refuses with `host_uninstall_needs_review`. The test keeps the guarantee it
   existed for, that those bytes are never overwritten, and now also asserts
   that apply refuses, so the safety is checked at both steps.

Verification: full desktop suite 38 files / 436 tests pass, `pnpm -r typecheck`
clean, biome reports only two pre-existing warnings on lines this change does
not touch.

* fix(logging): keep info diagnostics actionable

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(memhub): register the sedimentation switch test in the core test inventory

New core test files must appear in TEST_INDEX.md and in the classification
inventory's expected count and digest, and the backfill case needs a settled
turn because an unanswered user tail is not backfill material.

* feat(diagnostics): expose build info and fullscreen logs

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(settings): align help controls and tighten spacing

Co-authored-by: Cursor <cursoragent@cursor.com>

* release: prepare MemHub 1.0.4 build 8

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memhub): clear pre-existing dev lint errors and flaky SDK dedup tests

Three things blocking CI that came in with dev and are unrelated to the
sedimentation switch itself:

- biome errors in DebugLogPanel.tsx (import order) and ModelUsageView.tsx
  (refreshToken is an intentional refresh trigger, annotated like the
  existing modelVersion trigger in R6Settings).
- test_extractor_prompt_override.py's fake LLM now accepts keyword args the
  real pipeline passes since the usage audit landed (audit_stage).
- the fake embeddings in the intention dedup tests used the process-random
  hash(), so two different texts collided roughly one run in 32 and were
  misread as duplicates; switched to a deterministic md5 index.

* fix(cli): count recall characters as code points

A recall whose hits contain an emoji outside the BMP was reported as
response_invalid even though the data and the search were fine: the CLI
cross-checked the response by summing String.prototype.length, which counts
UTF-16 code units, against Core's used_characters, which Python's len()
counts in code points. One emoji is two units here and one character there,
so any hit set with an emoji failed the check.

The sibling validator in host-bridge (context-protocol.ts) already counted
code points, so the CLI was the odd one out rather than a deliberate choice.

- validateContextResponse now sums through characterCount(), which counts
  code points, matching Core.
- Two regression tests: an emoji hit whose used_characters follows code
  points must stay grounded, and the same response counted as UTF-16 units
  must still be refused, so the cross-check is not simply loosened.
- The contract now states used_characters counts Unicode code points and is
  not an encoding length, so a client in another language does not repeat
  the comparison.

Verified by reverting the source change: the first test fails on the old
implementation and passes with it.

Closes #30

* fix(web): keep the model recovery entry reachable while a probe is pending

Closes #40

A pending model probe fenced the configuration editor, and the only
recovery entry ("核对测试结果") lived inside the area the fence kept
shut. Reaching it required opening the form; opening the form required
having recovered. People with a stuck probe could not change or repair
their model at all.

The fence itself was doing its job and still is. What was wrong is that
refusal also hid the way out.

R6Settings
- openConfiguration() now opens the model area whether or not the editor
  accepts, so the recovery entry inside it is reachable. editProfile()
  and add() still return false while the fence is up; nothing about the
  protection changed.
- The refusal notice is reworded to match: the configuration cannot be
  changed yet, and the next step is down in the area that just opened.
  Saying "cannot be opened" was both untrue now and unhelpful before.

ModelSettings
- A refused open is remembered instead of dropped. Once the fence clears,
  the intent is replayed, so the person lands in the form they asked for.
  Without this they would get a blank new-configuration form the moment
  the probe reconciled, which invites a duplicate entry.
- Replaying only fills fields in. It never sends a request, so no test is
  re-sent implicitly; "核对测试结果" remains the only thing that does.

Tests
- R6Settings: with a current model and without one, the recovery entry is
  reachable while the editor is fenced, and the refusal is still reported.
- ModelSettings: after reconciling, the form reopens on the profile that
  was asked for, and the probe is sent exactly once.
- Both new R6Settings cases and the ModelSettings case fail on the
  previous code.

* perf(runtime-control): decide runtime reuse before staging the payload

Every App launch copied the whole 373MB bundle into a temp directory and
hashed it twice before asking whether the selected runtime could be reused.
On a machine that already runs this build the answer is yes and all of that
work is thrown away: measured on a release Mac bundle, 6.9s to copy and
about 3.8s of hashing (fileDigests, then verifyInstalledDistribution over
the same tree).

installPackagedRuntime now describes the payload in place first. The staged
tree is a byte for byte copy of resources/{core,host-bridge,runtime,web}
plus two generated launchers, so the same relative paths and digests can be
read without copying anything. reuseSelectedSameBuild takes that description
instead of a staged directory, and only a payload that is going to be
installed is ever staged.

The decision itself is unchanged: it still compares every file digest except
bin/node, still repairs the packaged launcher when the App moved, and still
raises installed_version_conflict or distribution_hash_mismatch exactly as
before. Nothing was replaced by a cheaper fingerprint, because a wrong reuse
is what those errors exist to prevent.

Hashing also runs through a small worker pool now; the digests are identical
and the manifest keeps its sorted key order.

New test: describeBundlePayload must equal the manifest the staged copy
produces, so moving the decision ahead of the copy cannot decide something
different. Verified read-only against the installed release bundle:
1534 files, identical file sets, zero digest mismatches.

* perf(desktop): raise a startup window before the runtime and Core come up

Opening the App currently shows nothing at all until initialize finishes.
On this machine that is 55.6s from desktop.initializing to desktop.ready
(about 19s installing and starting the managed runtime, then about 36s for
Core to become ready), and window.requested is logged after desktop.ready.
A launch that slow with no window reads as a launch that failed, and people
click the icon again.

startDesktopLifecycle now takes two optional hooks. showStartupWindow runs
before initialize and loads a placeholder; revealStartupWindow runs after
initialize and swaps that same window onto the real app. Because the window
is reused rather than replaced, there is no second window and no flash.

The placeholder is served from the app protocol as /__startup, in memory,
not as a file in the web bundle. It performs no IPC: it renders before
initialize has registered a single handler, so anything it asked for would
fail and leave the page in an error state for the whole wait. That route is
also reserved, so a file of the same name inside the bundle can never be
reached through it.

Raising the placeholder is best effort. It only fills a wait that would
otherwise be blank; failing to draw it must not stop the services or the
real window.

Tests: five lifecycle cases cover the ordering (placeholder before
initialize, reveal after, reuse of the same window, nothing for a background
launch, and a real window when the placeholder was closed or could not be
raised). The four suites that mock ./app-protocol now spread the real module
instead of whitelisting it, which is what broke when the protocol gained
exports. host-control-ownership gains the BrowserWindow methods the
placeholder path uses, and its "no window" assertion now names the
placeholder, since a placeholder window is expected there by design.

* fix(core): stop the sedimentation switch failing its own request

Turning the switch off answered "停止未能确认,模型可能仍在整理记忆,请重试"
every time. The value was saved and the error was a lie the endpoint told
afterwards: `put_sedimentation` logged `memory_sedimentation.toggled` with
`details={"enabled": ...}`, `enabled` is not in the diagnostic allow-list, and
`_details` raises. That ValueError escaped the endpoint, so the request is
recorded in this machine's log three times as `api.request.failed` /
`core.request.unhandled` with `diagnostic_detail_key_invalid`, and the UI took
the 500 as an unconfirmed stop.

Because the raise sits between the save and the resume, starting again was
broken the same way: `resume_allowed()` never ran, so work parked by a stop
stayed parked.

Three changes, in the order they matter:

1. `enabled` is now a legal detail key. It is a bounded boolean, the same
   shape as `spawned` and `index_complete`.
2. `diagnostic_event` no longer lets a rejected payload reach the caller. It
   already swallowed `OSError` with the comment "Diagnostics must not become a
   new product outage"; an unlisted key is the same failure seen from the
   caller, and it is exactly what turned every external recall into a 500 and
   then killed the process last time. The payload is still wrong and still
   caught in CI — now by every module rather than five.
3. `test_diagnostic_payload_keys.py` walks every module instead of a
   hand-picked list. The two payloads that caused real outages were in files
   nobody thought to add to that list; the only shape of the check that holds
   the line is "every module, always". This file was already failing on dev
   with `api.py:3673: unlisted details key 'enabled'` — the bug was known to
   the suite and shipped anyway.

`DiagnosticLog.write` still raises, so the rejection itself is unchanged and
still tested; only the callers are protected.

* fix(core): let the drain claim a manual note's job

A memory added by hand stayed on "原话已保存,等待整理" indefinitely, with
`task_id: null`: the note's durable job was never claimed, so no task was ever
created for it. On this machine the job is still `pending` with `attempts=0`
more than eight hours after it was saved, while the write gate admits its
source without complaint.

The drain had two different answers to one question. `source_writes_permitted`
is `_source_writes_on`, which admits `one-memory-manual` by its grant. The
claim gate was `_config_store(app).sync_enabled`, the per-app toggle, and
`one-memory-manual` is not a connected app: it never appears in the
configuration document, so that predicate answers False for it. The job
survived the `enabled_sources` prefilter and was then dropped row by row, on
every tick, forever. Nothing ever failed and nothing ever ran.

The claim gate now asks the same question the write gate asks. Admission is
one decision; giving it two predicates is what parked the work.

Two tests pin it. One builds the drain as the composition root does and shows
the job is claimed, and shows the shipped wiring stranding the same job, so the
regression is visible if the toggle comes back. The other records the
asymmetry itself: `_source_writes_on` is True for the manual source while
`sync_enabled` is False, which is why the pair could disagree at all.

No migration and no reinstall: existing pending jobs are picked up on the next
drain tick.

* fix(memhub): make sedimentation pause stop model dispatch

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(memhub): schedule memory writes by event time

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(core): answer the sedimentation switch before the backlog walk

Turning "用模型沉淀记忆" back on wrote the choice to the store and then
awaited `resume_allowed()` inside the PUT handler. That call is not a nudge:
it runs maintenance over every unfinished task and then a whole-queue scan,
and the bounded scan waited -- holding the scan lock -- for the queue to drop
under its cohort ceiling. With a real backlog the queue never drops, so the
request never returned: one toggle in the field is still open 20+ minutes
later with no `api.request.completed`, while the renderer gave up at its 30s
timeout and showed "启用未能确认,仍显示上次确认的状态;请重试。" for a switch
that had in fact been saved.

The choice is the durable fact; the walk is follow-up work:

- The handler now stores the choice and returns it, and starts the resume as a
  watched background task (same idiom as the other Core background walks).
  Repeated toggles reuse the running walk instead of stacking.
- A bounded scan now stops at its ceiling instead of sleeping under the scan
  lock waiting for capacity, which also un-starves the periodic scan that was
  queued behind it. It reports `queue_saturated` rather than `no_tasks`, so
  the status stops claiming there is nothing to do while hundreds wait.

Verified on the field log first: the failing toggle is the only request in
the window with `api.request.started` and no `api.request.completed`, and its
trace shows `memory_sedimentation.toggled` succeeded 6ms in at
`pending_count` ~500. New tests fail on the old code (timeout) and pass now.

* fix(core): stop a blank author from erasing a whole conversation

A user turn whose host did not say who wrote it carries
`written_by=unknown`. The write binding's allow-list had every other
role/author pair but not that one, so verifying the context raised
`hy_memory_write_verification_context_scope` and the task ended as
`model_content_outcome_unknown` after two attempts: a memory the model may
well have written, that can never be confirmed, and a conversation that is
never sedimented again.

This is not a rare shape. On this machine 1078 user records across 1002
WorkBuddy conversations are `unknown`, and the failures were 42 per 15
minutes in the field log — every one of them a task reporting
`result_count=0` while 402 sat pending.

The author is missing, not the turn: the record is still inside the same
conversation, workspace and source, which is what the binding actually
checks, and the turn assembly already sent it. Accepting it here puts the
verification back in agreement with what was sent, the same reasoning that
added ("user", AGENT) for sub agent questions.

New test fails on the old code at hy_source_context.py:76 — the same frame
as the field stack — and passes now.

* fix(core): stop an abandoned read reporting a failure nobody owns

`in_read_pool` shields the wrapped worker, so a lane that runs out of budget
gives up on the answer without yanking a query out from under SQLite. The
caller cancels; the read runs on. When such an abandoned read then failed,
nothing awaited its future any more: `shield` drops its inner callback as soon
as the outer future is cancelled, so the inner future finished with an
exception that nobody retrieved. asyncio reports that at GC, and the loop
handler files it as `core.loop.task_exception` -- a red error sitting next to a
lane that had already degraded on its own budget and already said so.

On this machine that is the `raw_context_timeout` pair in the field log: a
Future, not a Task, carrying TimeoutError('raw_context_timeout'), reported
three times in half an hour. The lane result itself was right; only the
diagnostics were arguing with themselves.

Retrieving the outcome on completion keeps the diagnostics honest without
hiding anything: awaiting the read still raises for a caller who waited, which
the second case pins down.

The new case fails on the previous code (it reports the TimeoutError) and
passes now.

* fix(core): one task with no inputs no longer strands every unknown task

`MemoryTaskRunner._request` falls back to `chunk[0]` when no record is a
person-written user turn. A task whose inputs are gone yields an empty chunk,
so that fallback raised IndexError -- an error no caller can read. A reconcile
pass catches domain errors per task and moves on, so IndexError did not skip
the task: it ended the pass, and every unknown task behind it stayed unknown
while the retry backed off 1, 2, 4 ... 128 and gave up.

On this machine 68 of 303 unknown tasks had no inputs, which is exactly the
`reconcile_unknown / error_type=IndexError` backing off in the field log. Those
68 cannot be reconciled either way -- there is nothing to ask HY about -- but
the 235 that can must not be held hostage to them, and this is what stands
between a task whose model write may already have landed and its confirmation.

An empty chunk is now a domain error, so the pass skips the task and continues.
Also folds in one formatting hunk left over in this file from the previous
change, so the formatter is clean again.

The new case fails on the previous code at memory_processing.py:1036 with the
same IndexError the log shows, and passes now.

* chore(memhub): bump release build number to 10

The installer refuses a package whose bytes differ while its build number
stays the same, so the four Core fixes on dev need their own number to be
installable over the 1.0.4 / build 9 package already on the machine.

* fix(core): retire a window whose remaining rows FIFO refuses

The event-time window asked only whether its ten-minute range still held
*some* pending row before deciding the anchor was still active. A pending
row that FIFO holds back is not claimable: it may be blocked by an earlier
sibling of the same Harness whose own event time sits outside the range, and
that sibling can never be claimed from inside this window. The anchor then
stayed pinned forever and the whole queue starved.

Live shape on one machine: the anchor sat on 2026-10-07T03:24Z while the four
pending rows inside it were blocked by 929/930/1243/2347 earlier workbuddy
jobs, the earliest of them dated 2026-03-12. Completions stopped at 09:59 and
3.8k queued turns sat at zero output; restarting does not help because the
anchor is persisted.

The contract (docs/contracts/memory-event-time-scheduling-v1.md) retires a
window once every *eligible* pending/running row in the range has settled or
parked, and re-anchors on the next oldest eligible job. Reuse the claim's own
predicate — free lease, no busy Harness, no earlier unsent sibling — so "the
window is busy" and "the window can still be worked" finally agree. Anchor
selection and in-window selection already used it; only the liveness check
did not.

* fix(core): a refused source context is a failure, not an unknown write

Issue #44: an OpenCode import carried `role=user, written_by=unknown`. The
role/author set now admits it, but the row still never moved: `HyWriteBindings`
is built inside `ingest`, and the invocation marker is recorded *before* that
call, so the refusal arrived with the marker already set. The classifier
checked `hy_invoked` first and parked the task as `unknown` — a state that
means "HY may hold a write we cannot confirm" about a request that provably
never reached HY. The reason stayed off the row, the task could not be
re-driven, and the queue behind it stopped.

`hy_memory_write_verification_context_scope` is raised from that construction
alone, and `_check_source_context` (which does run after the call) raises
`hy_memory_evidence_changed` instead, so the code is unambiguous: nothing was
sent. It joins `PRE_SEND_REFUSAL_CODES`, and both classifiers now test that set
before the marker: the durable path settles `failed` with the marker cleared so
reclaim does not treat a row HY never saw as possibly-sent, and the task path
fails with the code instead of marking unknown.

Regression covers both directions: a pre-call refusal must end `failed` with
`hy_invoked=0`, and a post-call refusal (`authority_invalid`) must still be
`unknown` — the fix narrows the unknown set, it does not widen the failure set.

* chore(memhub): bump release build number to 11

Carries the window starvation fix (dc0ba49) and the pre-send refusal
fix (0df84c0). A new build number keeps the package distinguishable from
build 10 and lets it install over the current one.

* refactor(memhub): simplify durable memory scheduling

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(memhub): bump release build number to 12

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(security): parse diagnostic stack paths linearly

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): sync sedimentation switch status to home (#51)

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: kristinhe <kristinhe@tencent.com>
2026-10-18 17:52:59 +08:00
kristinhe1111 0c59413ad8 Merge pull request #43 from Tencent-Hunyuan/kristinhe1111-patch-1
Revise README for clarity and installation instructions
2026-10-18 00:37:45 +08:00
kristinhe1111 625f877b43 Revise README for clarity and installation instructions
Updated README to clarify MemHub's functionality and provide installation guidance.
2026-10-18 00:27:38 +08:00
kristinhe1111 1608a4cfc2 feat(memhub): restore the one-click log export entry in settings (#38)
* feat(memhub): show the help and diagnostics group in settings by default

The settings group that carries the one-click log export was gated behind
VITE_ENABLE_DIAGNOSTICS=true at open-sourcing time, so shipped builds never
showed it and users could not export a diagnostics bundle. Flip the default
to visible and keep VITE_ENABLE_DIAGNOSTICS=false as the opt-out for any
build that must not offer it.

* style(memhub): format rerender call per biome
2026-10-17 22:05:24 +08:00
20b92d8a89 release: prepare MemHub 1.0.3 (#33)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 19:40:33 +08:00
2a4c5e38e7 release: publish Hy Memory 2.0.3 and update MemHub (#31)
* release: publish Hy Memory 2.0.3 and update MemHub

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(memhub): update SDK release documentation contract

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 19:26:56 +08:00
c891a1fd1d fix(runtime): let a notarized bundle replace a same-build runtime (#21)
* fix(runtime): let a notarized bundle replace a same-build runtime

Re-cutting one release keeps its build_number, so a rebuilt package met an
installed runtime carrying the same number and different bytes and was
refused with installed_version_conflict. The app then exited on every
launch, for anyone upgrading or reinstalling without first clearing the
managed install root.

- runtime-control: planLocalInstall takes allowSameBuildReplace, off by
  default so the contract's refusal still holds for every other caller. When
  it is on and the bytes differ, the incoming build lands in its own version
  directory keyed by a payload fingerprint and current moves atomically, so
  the superseded tree stays available as a rollback. Downgrade refusal and
  tamper detection are unchanged.
- desktop: opts in, because the bundle it carries has passed notarization and
  its payload is trusted by construction. A conflict that still cannot be
  reused is answered once by moving the selected tree into quarantine-<ts>/
  and installing again; memory data lives on a different path and is never
  read or written here.
- desktop: the startup dialog explains the failure and says what happens to
  the user's memories instead of printing an internal error code. The code
  stays in the diagnostic event, where support can still match it.
- core + cli: POST /api/v1/onboarding/reset and `memhub onboarding reset`
  send onboarding back to its first-run state and touch nothing else, so the
  cold-start screens can be seen again without deleting a single memory.

* fix(runtime): tighten trusted same-build takeover

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: kristinhe1111 <kristinhe1111@users.noreply.github.com>
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 18:59:29 +08:00
7741e17f09 fix(memhub): keep unknown writes from blocking queues (#27)
* fix(memhub): keep unknown writes from blocking queues

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memhub): retry unknown writes after ten seconds

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 18:42:24 +08:00
2a4dc2ffa2 feat(memhub): add product memory pipeline diagnostics (#25)
* feat(memhub): add product memory pipeline diagnostics

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(memhub): clarify debug log privacy

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:35:41 +08:00
61b5ef30a9 refactor(memhub): remove HyPermission and trust embedded SDK (#20)
* refactor(memhub): trust embedded SDK native stores

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(memhub): remove model content permission UX

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:13:08 +08:00
f79770cdb0 release: prepare MemHub 1.0.2 (#19)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 01:54:14 +08:00
9ba602ffd2 ci: upload tagged Linux runtimes to draft releases (#16)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 01:51:58 +08:00
842fbe6044 fix(memhub): relaunch safely with existing runtimes (#18)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 01:50:23 +08:00
268ad16d26 release: prepare Hy Memory 2.0.2 and MemHub 1.0.1 (#15)
* release: prepare Hy Memory 2.0.2 and MemHub 1.0.1

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memhub): retry grant reads across atomic replacement

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 00:25:43 +08:00
8903872b47 fix(memhub): degrade disabled LaunchAgent to app-lifetime runtime (#13)
* fix(desktop): classify the durable disabled-service install error

installUserService reports a persistent `launchctl disable` override with
the exact code `runtime_service_disabled_by_system`. Expose a single
place that recognises it, so the startup path can tell a durable OS state
apart from an ordinary install failure.

* fix(desktop): keep starting when the runtime service is disabled by system

A durable `launchctl disable` override outlives a re-install, and the
installer deliberately refuses to re-enable a service the user or the
system disabled. Turning that refusal into a fatal startup error leaves
the app unable to open at all, with no surface left to re-enable it.

Degrade instead: bring the Runtime up as a child process, the same path
the non-managed mode already uses, report the durable state, and leave
the login-time service disabled. Every other install failure still fails
closed.

* test(desktop): cover the disabled-service install error classification

Pin both directions of the classification: the durable disable state
degrades, and every other install failure keeps failing closed. The
non-Error case matters because the guard must not match a bare string.

* fix(memhub): limit disabled-service fallback to app lifetime

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: kristinhe <kristinhe@tencent.com>
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 23:53:18 +08:00
1aed8d7507 fix: restore public MemHub CI tooling (#12)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 23:00:44 +08:00
1c31295e13 fix: keep local Qdrant on an owner thread (#10)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 22:48:00 +08:00
feitianxiang 55edf051e5 Merge pull request #5 from DURUII/codex/fix-guarded-future-timeout
fix: propagate task timeout errors from guarded waits
2026-10-16 21:27:00 +08:00
安如衫 210e24f8b0 fix: propagate timeout errors from completed guarded futures 2026-10-16 20:16:11 +08:00
503 changed files with 24006 additions and 26006 deletions

No files matched your search

+27 -1
View File
@@ -59,4 +59,30 @@ jobs:
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: uv sync --directory apps/core --all-groups --locked
- run: pnpm check
- name: Verify public source structure and privacy boundaries
run: |
python3 scripts/verify_structure.py
python3 scripts/generate_core_test_index.py --check
python3 scripts/verify_zero_collection.py
python3 scripts/verify_zero_collection.py --self-test
- name: Lint Node workspaces
run: pnpm lint
- name: Typecheck Node workspaces
run: pnpm typecheck
- name: Test Node workspaces
run: pnpm test
- name: Test release tooling
run: pnpm test:release-scripts
- name: Fetch and verify the pinned embedding model
run: |
pnpm fetch:embedding-model
uv run --directory apps/core python -c \
"from one_memory.embedding_assets import assets_present, verify_assets; assert assets_present(); verify_assets()"
- name: Test public Core smoke suite
run: >-
bash scripts/run-tests-isolated.sh core-pr
tests/test_core_test_reporting.py
tests/test_import_api.py
tests/test_recent_first_import.py
tests/test_source_session_count_api.py
tests/test_schema_migrations.py
+62
View File
@@ -102,3 +102,65 @@ jobs:
if-no-files-found: error
compression-level: 0
retention-days: 14
publish-draft-release:
if: ${{ inputs.run_lifecycle && startsWith(github.ref, 'refs/tags/v') }}
needs: matrix
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
pattern: memhub-runtime-linux-*
path: memhub/release-assets
merge-multiple: true
- name: Verify tag and Linux release assets
shell: bash
run: |
set -euo pipefail
version=$(node -p 'require("./package.json").version')
node scripts/verify-release-version.mjs \
--channel stable \
--version "$version" \
--build-number "$(node -p 'require("./package.json").release.build_number')" \
--tag "$GITHUB_REF_NAME"
for arch in x64 arm64; do
archive="release-assets/memhub-runtime-v${version}-linux-${arch}.tar.gz"
test -f "$archive"
test -f "$archive.sha256"
(
cd release-assets
sha256sum -c "$(basename "$archive.sha256")"
)
done
- name: Upload Linux assets to a draft GitHub Release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
if gh release view "$GITHUB_REF_NAME" \
--repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
test "$(
gh release view "$GITHUB_REF_NAME" \
--repo "$GITHUB_REPOSITORY" \
--json isDraft \
--jq .isDraft
)" = "true"
else
gh release create "$GITHUB_REF_NAME" \
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--draft \
--generate-notes \
--title "$GITHUB_REF_NAME"
fi
gh release upload "$GITHUB_REF_NAME" \
release-assets/memhub-runtime-v*-linux-*.tar.gz \
release-assets/memhub-runtime-v*-linux-*.tar.gz.sha256 \
--repo "$GITHUB_REPOSITORY" \
--clobber
+76 -8
View File
@@ -2,6 +2,51 @@
Hy Memory and MemHub are versioned and released independently.
## Hy Memory 2.0.3 — 2026-10-17
- Added an optional runtime HTTP transport factory so embedded products can
enforce request budgets without wrapping or restricting SDK local-store
access.
- Published
[`hy-memory==2.0.3`](https://pypi.org/project/hy-memory/2.0.3/) with
unchanged memory extraction, reconciliation, storage, and Coding/Working
behavior.
## Unreleased
- Fixed governed-memory revisions and repeated evidence links failing when the
visibility dirty-marker trigger encounters the same memory twice. Startup
now replaces the affected legacy trigger without changing schema versions
or governed and visibility data.
- Serialized each SDK SQLite store's shared connection lifecycle so concurrent
history/cache operations cannot race with `close()` and crash the process.
- Scoped Coding reconciliation targets to the current HY authority,
conversation, and workspace, preventing near-duplicate sessions from
mutating an experience or working fact owned by another evidence context.
- Kept post-write authority verification failures available for read-only
reconciliation while preventing automatic or generic confirmed retries from
invoking the model again. Automatic reconciliation skips those sticky rows
before applying its batch limit, and linked durable jobs exclusively own
retry scheduling for their memory tasks.
- Clarified that `add(metadata=...)` carries legacy SDK processing options,
while persistent caller business context belongs in `meta`.
- Added an opt-in `get(..., include_raw=True)` path for reading `L1_RAW`
memories, including memories written in lite mode. The default remains
hidden for backward compatibility.
- Removed MemHub's task-scoped HyPermission store wrappers and global
model-content consent switch. The trusted embedded Hy Memory SDK now uses
its native local stores directly.
- Existing installations ignore the retired config flag, readmit previously
parked pre-invocation work, and drop only the obsolete
`hy_node_permissions` proof table. Evidence, governed memories, model
profiles, SDK stores, and source/host grants are preserved.
## Hy Memory 2.0.2 — 2026-10-17
- Published
[`hy-memory==2.0.2`](https://pypi.org/project/hy-memory/2.0.2/) with the
current SDK fixes and Apache-2.0 package metadata.
## Hy Memory 2.0.1 — 2026-10-16
- Published the Python distribution as
@@ -10,13 +55,36 @@ Hy Memory and MemHub are versioned and released independently.
Because PyPI release metadata is immutable, the next SDK patch release must
carry the updated license metadata; `2.0.1` retains its published metadata.
## MemHub
## MemHub 1.0.3 — Unreleased
MemHub has no public release yet. The source version is `1.0.0` (build 4), the
selected npm package is `memhub-cli`, and both npm publication and GitHub
Release artifacts are still pending. Internal release-candidate builds are not
public releases.
- Updated the source version to `1.0.3` (build 7).
- Pinned the public SDK to `hy-memory==2.0.3`.
- Removed the HyPermission store wrappers and global model-content consent
switch; the trusted embedded SDK now uses native local stores.
- Prevented uncertain writes from blocking later source work and added one
reconciled automatic retry after ten seconds.
- Added product-facing memory pipeline counters and the log troubleshooting
runbook.
- Allowed a verified packaged Desktop Runtime from a different commit to take
over an equal-build install atomically while preserving downgrade and
tamper refusal.
No compatibility or deprecation policy has been adopted beyond the explicit
CLI/Runtime protocol ranges stored in the release manifests. Future public
releases must record user-visible changes and supported platforms here.
## MemHub 1.0.2 — 2026-10-17
- Updated the source version to `1.0.2` (build 6).
- Pinned the public SDK to `hy-memory==2.0.2`.
- Pinned `onnxruntime==1.23.2` to retain macOS x64 packaging support.
- Fixed relaunch and upgrade failures when a prior managed Runtime exists.
- Embedded canonical build identity when an external packaging pipeline omits
release environment fields.
- Added automatic Linux Runtime upload to tagged Draft Releases.
## MemHub 1.0.1 — Superseded before release
- The candidate tag was not published as a GitHub Release; `1.0.2` replaces it
after the managed-Runtime relaunch fix.
## MemHub 1.0.0
- Published the initial GitHub Release with macOS arm64 and Linux Runtime
artifacts.
+10 -8
View File
@@ -32,7 +32,9 @@ English | [中文](README.zh.md)
## Latest News
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.1`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.1/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** on Hugging Face along with its [technical report](https://arxiv.org/abs/2610.22196) on attributed reinforcement learning for long-term memory writing!
* *<mark>[new]</mark>* 🔥🔥 **[2026-09]**: 📊 **Released** **[CMB: A Coding Memory Benchmark for Long-Horizon and Interleaved Coding Agents](docs/papers/CMB_A_Coding_Memory_Benchmark_for_Long_Horizon_and_Interleaved_Coding_Agents.pdf)** (submitted to **ICLR 2027**).
* *<mark>[new]</mark>* 🔥🔥 **[2026-08]**: 🎉 **Accepted** at **EMNLP 2026**: **[Memory Beyond Recall: A Dual-Process Cognitive Memory System for Self-Evolving LLM Agents](https://arxiv.org/abs/2606.09483)**!
@@ -63,13 +65,13 @@ Welcome to the **Hy Memory Research Family**, covering cognitive chat memory, lo
### 1. Installation
```bash
pip install hy-memory==2.0.1 # core (includes ChromaDB and Kuzu)
pip install "hy-memory[qdrant]==2.0.1" # + Qdrant
pip install "hy-memory[faiss]==2.0.1" # + FAISS
pip install "hy-memory[graph]==2.0.1" # + Neo4j
pip install "hy-memory[mysql]==2.0.1" # + MySQL cache
pip install "hy-memory[rerank]==2.0.1" # + local multilingual cross-encoder rerank
pip install "hy-memory[all]==2.0.1" # everything
pip install hy-memory==2.0.3 # core (includes ChromaDB and Kuzu)
pip install "hy-memory[qdrant]==2.0.3" # + Qdrant
pip install "hy-memory[faiss]==2.0.3" # + FAISS
pip install "hy-memory[graph]==2.0.3" # + Neo4j
pip install "hy-memory[mysql]==2.0.3" # + MySQL cache
pip install "hy-memory[rerank]==2.0.3" # + local multilingual cross-encoder rerank
pip install "hy-memory[all]==2.0.3" # everything
```
### 2. Configuration
+10 -8
View File
@@ -32,7 +32,9 @@
## 最新动态
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.1`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.1/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** 长期记忆写入模型及[技术报告](https://arxiv.org/abs/2610.22196)!
* *<mark>[new]</mark>* 🔥🔥 **[2026-09]**: 📊 **基准与方法发布** 推出长周期与交错式编程记忆评测基准 **[CMB: A Coding Memory Benchmark for Long-Horizon and Interleaved Coding Agents](docs/papers/CMB_A_Coding_Memory_Benchmark_for_Long_Horizon_and_Interleaved_Coding_Agents.pdf)**(已投稿 **ICLR 2027**)。
* *<mark>[new]</mark>* 🔥🔥 **[2026-08]**: 🎉 **论文录用** 双系统认知记忆架构论文 **[Memory Beyond Recall: A Dual-Process Cognitive Memory System for Self-Evolving LLM Agents](https://arxiv.org/abs/2606.09483)** 被 **EMNLP 2026** 接收!
@@ -63,13 +65,13 @@
### 1. 安装
```bash
pip install hy-memory==2.0.1 # 核心(含 ChromaDB 与 Kuzu)
pip install "hy-memory[qdrant]==2.0.1" # + Qdrant
pip install "hy-memory[faiss]==2.0.1" # + FAISS
pip install "hy-memory[graph]==2.0.1" # + Neo4j
pip install "hy-memory[mysql]==2.0.1" # + MySQL 缓存
pip install "hy-memory[rerank]==2.0.1" # + 本地多语言 cross-encoder rerank
pip install "hy-memory[all]==2.0.1" # 全部
pip install hy-memory==2.0.3 # 核心(含 ChromaDB 与 Kuzu)
pip install "hy-memory[qdrant]==2.0.3" # + Qdrant
pip install "hy-memory[faiss]==2.0.3" # + FAISS
pip install "hy-memory[graph]==2.0.3" # + Neo4j
pip install "hy-memory[mysql]==2.0.3" # + MySQL 缓存
pip install "hy-memory[rerank]==2.0.3" # + 本地多语言 cross-encoder rerank
pip install "hy-memory[all]==2.0.3" # 全部
```
### 2. 配置
+1 -1
View File
@@ -37,7 +37,7 @@ do not belong in the public source tree.
- Root GitHub workflows run SDK and MemHub checks from their respective working
directories. Native lifecycle workflows produce reviewable Actions artifacts;
tag releases remain fail-closed on public identity, signing and notarization.
- Hy Memory is published as `hy-memory==2.0.1`. MemHub pins that public
- Hy Memory is published as `hy-memory==2.0.3`. MemHub pins that public
distribution; its selected npm package is `memhub-cli`, which is not yet
published.
- Hy-Memory source uses Apache-2.0 at the repository root. Third-party
+16 -1
View File
@@ -5,7 +5,7 @@
## 安装与快速开始
```bash
pip install hy-memory==2.0.1
pip install hy-memory==2.0.3
# 可选后端
pip install 'hy-memory[qdrant]==2.0.1'
@@ -106,6 +106,15 @@ result = client.add(
)
```
`metadata` 与 `meta` 不是同一字段:
- `metadata` 是兼容旧调用方保留的 SDK 处理选项,会进入内部
`WriteRequest.extra`,不保证持久化或随查询结果返回。
- `meta` 是调用方的业务上下文,会持久化并在 `search()`、`get()` 等结果中返回。
同一记忆被多次合并时,各次 `meta` 会按写入顺序保存在列表中。
需要以后读回的数据必须放在 `meta`,不要放在 `metadata`。
`scene` 的公开取值:
- `None`:普通文本直接走 chat;messages 含 tool 时执行快速场景判断。
@@ -263,6 +272,8 @@ catalog 也可以在没有基础 L0 时形成只读的合成画像。
```python
memory = client.get("memory_id")
# lite 模式写入的是 L1_RAW;按 ID 读取时必须显式选择返回原文。
raw_memory = client.get("memory_id", include_raw=True)
result = client.update("memory_id", "更新后的内容")
result = client.delete("memory_id")
result = client.delete_all(user_id="user_001")
@@ -282,6 +293,10 @@ result = client.clone_user("user_001", "user_002", agent_id=None)
对应异步方法为 `async_get()`、`async_update()`、`async_delete()`、
`async_delete_all()`、`async_list_memories()`、`async_clone_user()`。
`get()` / `async_get()` 默认继续隐藏 `L1_RAW`,因此不会改变现有调用方的原文访问
边界。只有显式传入 `include_raw=True` 才返回该层;这也是 lite 模式按 ID 读取
写入内容及其 `meta` 的方式。MemHub 的 HTTP 读取接口不开放此参数。
普通 `list_memories()` 返回 `vdb` 桶,并在 Graph 有结果时附带 `graph`;传入
`workspace_id` 时返回生产力记忆的 `scene="productivity"` 和 `experiences`。
+2
View File
@@ -1123,6 +1123,7 @@ class Extractor:
max_tokens=self._llm_config.agent_max_tokens,
tools=None,
tool_choice=None,
audit_stage="extract",
)
self._call_count += 1
total_tokens += response.tokens_used
@@ -1233,6 +1234,7 @@ class Extractor:
max_tokens=self._llm_config.agent_max_tokens,
tools=None,
tool_choice=None,
audit_stage="extract",
)
self._call_count += 1
total_tokens += response.tokens_used
+35 -20
View File
@@ -34,6 +34,7 @@ import os
import weakref
from ..config import MemoryConfig
from ..utils.model_call_context import model_call_stage
from ..core.workload import llm_slot
logger = logging.getLogger(__name__)
@@ -348,6 +349,7 @@ class LLMProvider:
stop: List[str] = None,
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Any] = None,
audit_stage: Optional[str] = None,
**kwargs
) -> LLMResponse:
"""
@@ -386,15 +388,16 @@ class LLMProvider:
len(prompt), _content_fingerprint(prompt),
)
response = await self._call_llm(
prompt=prompt,
max_tokens=max_tokens,
temperature=temperature,
stop=stop,
tools=tools,
tool_choice=tool_choice,
**kwargs
)
with model_call_stage(audit_stage):
response = await self._call_llm(
prompt=prompt,
max_tokens=max_tokens,
temperature=temperature,
stop=stop,
tools=tools,
tool_choice=tool_choice,
**kwargs
)
self._total_calls += 1
self._total_tokens += response.tokens_used
@@ -496,11 +499,21 @@ class LLMProvider:
clients = self._clients.setdefault(loop, {})
client = clients.get(client_key)
if client is None:
client_options: Dict[str, Any] = {
"api_key": base_api_key,
"base_url": base_url,
"timeout": self._llm_config.timeout,
"max_retries": 0,
}
transport_factory = getattr(self.config, "http_transport_factory", None)
if callable(transport_factory):
import httpx
client_options["http_client"] = httpx.AsyncClient(
transport=transport_factory()
)
client = AsyncOpenAI(
api_key=base_api_key,
base_url=base_url,
timeout=self._llm_config.timeout,
max_retries=0,
**client_options,
)
clients[client_key] = client
@@ -585,6 +598,7 @@ class LLMProvider:
temperature: Optional[float] = None,
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Any] = None,
audit_stage: Optional[str] = None,
) -> LLMResponse:
"""
多轮 messages 调用(支持 tool results 回传)。
@@ -612,13 +626,14 @@ class LLMProvider:
f"msgs={len(messages)} tools={len(tools) if tools else 0}"
)
response = await self._chat_completions(
messages,
max_tokens=max_tokens,
temperature=temperature,
tools=tools,
tool_choice=tool_choice,
)
with model_call_stage(audit_stage):
response = await self._chat_completions(
messages,
max_tokens=max_tokens,
temperature=temperature,
tools=tools,
tool_choice=tool_choice,
)
self._total_calls += 1
self._total_tokens += response.tokens_used
+2
View File
@@ -669,6 +669,7 @@ class MemoryReconciler:
response = await llm.complete(
prompt=prompt,
max_tokens=500,
audit_stage="reconcile",
)
text = response.content.strip()
if "```json" in text:
@@ -1383,6 +1384,7 @@ class MemoryReconciler:
response = await llm.complete(
prompt=prompt,
max_tokens=dyn_max_tokens,
audit_stage="reconcile",
)
except Exception as e:
# LLM 彻底不可用(provider 已按 max_retries 重试过,网关 500 /
+39 -12
View File
@@ -1597,7 +1597,9 @@ class HyMemoryClient:
session_id: Session ID(三级隔离 key)
source: 调用来源,例如 openclaw / cursor / codex;与 agent_id 正交。
写入前执行 strip().lower(),默认 "default"。
metadata: 自定义元数据
metadata: 旧版 SDK 处理选项,会并入内部 WriteRequest.extra。
不保证持久化或随查询返回;调用方需要保存并读回的业务字段
请使用 meta。
memory_at: 记忆时间戳(不传则用当前时间),用于导入历史记忆
scene: 显式指定写入路由,跳过 LLM 自动判定。
- None(默认):含 tool 时 LLM 判 chat/working/coding;
@@ -1623,8 +1625,8 @@ class HyMemoryClient:
branch: Coding memory 路径专用。分支名。同 workspace_id。chat 路径忽略。
meta: 调用方自定义业务 meta,随本次产出的记忆存储、查询时原样返回。
不 embed、不参与召回、不作过滤条件。
与 metadata 不同:metadata 会并入 extra(含 SDK 内部控制键),
meta 独立存放,只做透传。
与 metadata 不同:metadata 是旧版内部处理选项,
meta 独立持久化,只做业务透传。
语义为「该次 write 时刻的上下文」,故存为 list——同一节点被
多次写入合并时会追加而非覆盖,详见 get() 返回的 meta 字段。
@@ -1791,17 +1793,26 @@ class HyMemoryClient:
query_embedding=query_embedding,
))
def get(self, memory_id: str) -> Optional[Dict[str, Any]]:
def get(
self,
memory_id: str,
*,
include_raw: bool = False,
) -> Optional[Dict[str, Any]]:
"""
获取单条记忆。
Args:
memory_id: 记忆 ID
include_raw: 是否允许返回 L1_RAW 原始记忆。默认 False,保持原有隐藏行为;
lite 模式的记忆均为 L1_RAW,需显式设为 True 才能按 ID 读取。
Returns:
{"memory_id", "content", "layer", "request_id", "elapsed_ms"} 或 None(不存在时)
"""
return self._loop_thread.run(self.async_get(memory_id))
return self._loop_thread.run(
self.async_get(memory_id, include_raw=include_raw)
)
def update(
self,
@@ -3650,12 +3661,21 @@ class HyMemoryClient:
# ================================================================
@_ensure_internal_loop
async def async_get(self, memory_id: str) -> Optional[Dict[str, Any]]:
async def async_get(
self,
memory_id: str,
*,
include_raw: bool = False,
) -> Optional[Dict[str, Any]]:
"""
获取单条记忆(异步)。
VDB + Graph 双路并行查询,哪个有结果返回哪个。
Args:
memory_id: 记忆 ID
include_raw: 是否允许返回 L1_RAW 原始记忆,默认 False。
Returns:
{"memory_id", "content", "layer", "source", "request_id", "elapsed_ms"} 或 None
"""
@@ -3683,17 +3703,24 @@ class HyMemoryClient:
vdb_node, graph_node = await _asyncio_get.gather(_get_vdb(), _get_graph())
# L1_RAW 不对外可见:按 ID 直取走主键查询、绕过向量库的 layer filter,
# 故在此统一挡掉。async_get 是 GET /v1/memories/{id} 与
# /v1/memories/batch 两个对外接口的共同必经处,而内部写链路不经过它
# (writer 写后校验直接调 vector_store.get_by_id),挡在此处不影响内部。
# L1_RAW 默认不对外可见:按 ID 直取走主键查询、绕过向量库的 layer
# filter,故在此统一挡掉。SDK 调用方可通过 include_raw=True 显式读取;
# HTTP 接口不传该参数,继续保持隐藏。内部写链路不经过此方法。
#
# 置于 gather 之后、埋点之前:命中 raw 仍照常落 GET 埋点(保留可观测性,
# 便于发现有调用方拿 raw id 试探),只是不把内容返回给调用方。
# 对调用方的表现与"该 id 不存在"完全一致,不额外暴露"存在但被挡"的信息。
if vdb_node is not None and vdb_node.layer == MemoryLayer.L1_RAW:
if (
not include_raw
and vdb_node is not None
and vdb_node.layer == MemoryLayer.L1_RAW
):
vdb_node = None
if graph_node is not None and getattr(graph_node, "layer", None) == MemoryLayer.L1_RAW:
if (
not include_raw
and graph_node is not None
and getattr(graph_node, "layer", None) == MemoryLayer.L1_RAW
):
graph_node = None
elapsed = round((time.perf_counter() - t0) * 1000, 2)
+1
View File
@@ -919,6 +919,7 @@ class CodingMemoryExtractor:
resp = await self.llm_provider.complete(
prompt=prompt,
max_tokens=self.max_tokens,
audit_stage="coding_extract",
)
except Exception as e:
logger.warning(f"[coding-extract] LLM call failed: {e}")
+35 -2
View File
@@ -466,7 +466,13 @@ class WorkingFactReconciler:
return [], []
candidates = await self._retrieve_candidates(
drafts, user_id=user_id, source=source,
drafts,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
workspace_id=workspace_id,
branch=branch,
source=source,
)
try:
@@ -499,7 +505,15 @@ class WorkingFactReconciler:
# ------------------------------------------------------------
async def _retrieve_candidates(
self, drafts: List[WorkingFactDraft], *, user_id: str, source: str
self,
drafts: List[WorkingFactDraft],
*,
user_id: str,
agent_id: str,
session_id: Optional[str],
workspace_id: Optional[str],
branch: Optional[str],
source: str,
) -> List[List[WorkingFact]]:
"""每条 draft 用 statement 召回同 user 的 top-N active 事实。
@@ -512,6 +526,10 @@ class WorkingFactReconciler:
关掉时整份召回原样交给 LLM。适用于 subject 不是稳定实体名的数据集(自由文本、
位置标签),那里精确字符串相等会把同实体判成不同主体,反而拦掉真实更新。
"""
# Empty/legacy session metadata remains readable, but cannot prove
# conversation authority for a destructive SUPERSEDE.
if not session_id:
return [[] for _ in drafts]
try:
embeddings = await self.embed_service.embed_batch(
[d.statement for d in drafts]
@@ -525,6 +543,10 @@ class WorkingFactReconciler:
try:
hits = await self.store.search_facts(
emb, user_id=user_id, top=self.candidates_per_draft,
agent_id=agent_id,
session_id=session_id,
workspace_id=workspace_id,
branch=branch,
# 写入侧同样接词面通道:候选选错了 LLM 就没有机会判对。这里不传
# min_score——写入侧要的是"有哪些可能的前身",让 LLM 看更宽的名单,
# 阈值过滤是读取侧的事。
@@ -571,6 +593,7 @@ class WorkingFactReconciler:
resp = await self.llm_provider.complete(
prompt=prompt,
max_tokens=self.max_tokens,
audit_stage="coding_fact_reconcile",
)
return self._parse_ops(resp.content)
@@ -637,6 +660,7 @@ class WorkingFactReconciler:
resp = await self.llm_provider.complete(
prompt=prompt,
max_tokens=_CHAIN_SUMMARY_MAX_TOKENS,
audit_stage="coding_fact_summary",
)
except Exception as e:
logger.warning(f"[fact-reconcile] chain summary failed: {e}")
@@ -792,6 +816,15 @@ class WorkingFactReconciler:
old = await self.store.get_fact(old_id, user_id=user_id)
if old is None:
continue
if (
old.agent_id != agent_id
or old.session_id != session_id
or old.workspace_id != workspace_id
):
logger.warning(
f"[fact-reconcile] refused cross-authority SUPERSEDE: {old.fact_id}"
)
continue
# 闸门在候选阶段已经拦过一道,这里再拦一次:候选是按 statement 召回的,
# 而 LLM 只能覆盖候选名单里的 id,理论上到不了这儿。真到了就说明前面
# 哪一环漏了,宁可让这次覆盖不生效——多留一条旧事实是可恢复的,删掉
+11 -3
View File
@@ -29,6 +29,9 @@ logger = logging.getLogger(__name__)
_JUDGE_MAX_TURNS = 20
_JUDGE_HEAD_TURNS = 5
_JUDGE_USER_CONTENT_LIMIT = 1_000
_JUDGE_USER_CONTENT_HEAD = 200
_JUDGE_USER_CONTENT_TAIL = 200
_JUDGE_MAX_QUERY_CHARS = 300
_JUDGE_MAX_QUERIES = 20
@@ -186,6 +189,7 @@ async def classify_messages_scene(
prompt=prompt,
max_tokens=max_tokens,
temperature=temperature,
audit_stage="coding_judge",
)
_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
result = _parse_scene_json(resp.content)
@@ -631,9 +635,13 @@ def _format_turns(summary: List[Dict[str, Any]]) -> str:
rows = []
for t in summary:
user = t.get("user", "") or ""
# 单 turn 用户文本截到 200 字符
if len(user) > 200:
user = user[:200] + "..."
if len(user) > _JUDGE_USER_CONTENT_LIMIT:
omitted = len(user) - _JUDGE_USER_CONTENT_HEAD - _JUDGE_USER_CONTENT_TAIL
user = (
user[:_JUDGE_USER_CONTENT_HEAD]
+ f"\n[...{omitted} chars omitted...]\n"
+ user[-_JUDGE_USER_CONTENT_TAIL:]
)
tools = t.get("tools", []) or []
rows.append(
f"- turn {t.get('turn', '?')}: user={user!r}, tools={tools}"
+34 -1
View File
@@ -240,7 +240,7 @@ class CodingMemoryReconciler:
)
# 3. 校验 + 执行
executed = await self._execute(ops, drafts)
executed = await self._execute(ops, drafts, candidates_per_draft)
logger.info(
f"[coding-reconcile] drafts={len(drafts)} ops={len(executed)} "
+ ", ".join(f"{a}={sum(1 for o in executed if o.action == a)}"
@@ -263,6 +263,12 @@ class CodingMemoryReconciler:
"""
result: List[List[CodingMemory]] = []
for d in drafts:
# A coding mutation must be attributable to one conversation.
# Empty/legacy session metadata stays readable through ordinary
# search, but it cannot authorize UPDATE/DELETE.
if not d.session_id:
result.append([])
continue
try:
# task 为主入口,search_keys 为附加入口(去重、去空)
queries = [d.task] + [k for k in (d.search_keys or []) if k and k != d.task]
@@ -274,6 +280,8 @@ class CodingMemoryReconciler:
hits = await self.store.search_by_query_embedding(
emb,
user_id=d.user_id,
agent_id=d.agent_id,
session_id=d.session_id,
workspace_id=d.workspace_id,
branch=d.branch,
top=self.candidates_per_draft * 4,
@@ -310,6 +318,7 @@ class CodingMemoryReconciler:
resp = await self.llm_provider.complete(
prompt=prompt,
max_tokens=self.max_tokens,
audit_stage="coding_reconcile",
)
return self._parse_ops(resp.content), prompt, resp.content, resp
@@ -414,6 +423,7 @@ class CodingMemoryReconciler:
self,
ops: List[ReconcileOp],
drafts: List[CodingMemoryDraft],
candidates_per_draft: List[List[CodingMemory]],
) -> List[ReconcileOp]:
"""
校验 + 执行 ops。返回成功执行的 ops(filter 掉无效 op)。
@@ -453,6 +463,11 @@ class CodingMemoryReconciler:
if not op.target_experience_id:
logger.warning(f"[coding-reconcile] drop UPDATE missing target: {op.to_dict()}")
continue
if not self._target_was_offered(op, candidates_per_draft):
logger.warning(
f"[coding-reconcile] drop UPDATE outside authority scope: {op.to_dict()}"
)
continue
if op.draft_idx in consumed:
logger.warning(f"[coding-reconcile] drop UPDATE on consumed draft: {op.to_dict()}")
continue
@@ -475,6 +490,11 @@ class CodingMemoryReconciler:
if not op.target_experience_id:
logger.warning(f"[coding-reconcile] drop DELETE missing target: {op.to_dict()}")
continue
if not self._target_was_offered(op, candidates_per_draft):
logger.warning(
f"[coding-reconcile] drop DELETE outside authority scope: {op.to_dict()}"
)
continue
# DELETE 需要知道 user_id;通过 draft_idx(如有)或第一个 draft 推断
user_id = self._infer_user_id(op, drafts)
if not user_id:
@@ -511,6 +531,19 @@ class CodingMemoryReconciler:
return executed
@staticmethod
def _target_was_offered(
op: ReconcileOp,
candidates_per_draft: List[List[CodingMemory]],
) -> bool:
"""Only mutate a target frozen into this draft's scoped candidate set."""
if op.draft_idx is None or not (0 <= op.draft_idx < len(candidates_per_draft)):
return False
return any(
candidate.experience_id == op.target_experience_id
for candidate in candidates_per_draft[op.draft_idx]
)
@staticmethod
def _infer_user_id(
op: ReconcileOp, drafts: List[CodingMemoryDraft]
+67 -15
View File
@@ -529,10 +529,20 @@ class CodingMemoryStore:
"""覆盖式更新一条 coding memory。"""
# 拿旧 keys 长度,先删旧 VDB rows
prev = await self.get_by_id(memory.experience_id, user_id=memory.user_id)
prev_key_count = (1 + len(prev.search_keys)) if prev else 0
if prev is None:
raise ValueError("coding_memory_update_target_missing")
if (
not prev.session_id
or not memory.session_id
or prev.agent_id != memory.agent_id
or prev.session_id != memory.session_id
or prev.workspace_id != memory.workspace_id
):
raise ValueError("coding_memory_update_authority_mismatch")
prev_key_count = 1 + len(prev.search_keys)
memory.updated_at = datetime.utcnow()
if memory.created_at is None and prev is not None:
if memory.created_at is None:
memory.created_at = prev.created_at
await _run_in_pool(self._upsert_sql_sync, memory)
# 删旧 VDB
@@ -671,6 +681,8 @@ class CodingMemoryStore:
query_embedding: List[float],
user_id: str,
*,
agent_id: Optional[str] = None,
session_id: Optional[str] = None,
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
top: int = 40,
@@ -688,21 +700,28 @@ class CodingMemoryStore:
},
...
]
workspace_id / branch 是作用域过滤,规则见 _scope_where:不传则纯语义召回
(行为与加过滤前完全一致);传了则只保留 SQLite 里落在作用域内的经验,并按
作用域的选择率放大向量取数,避免"先取 top-N 再过滤"把召回吃掉。
agent_id / session_id 是写授权作用域,传入时精确匹配;workspace_id / branch
的规则见 _scope_where。不传任何作用域则维持纯语义召回;传了则只保留 SQLite
里落在作用域内的经验,并按选择率放大向量取数,避免"先取 top-N 再过滤"把
召回吃掉。
"""
if self._vec is None:
return []
allowed: Optional[Set[str]] = None
if workspace_id:
if agent_id or session_id or workspace_id:
allowed, total = await _run_in_pool(
self._scoped_experience_ids_sync, user_id, workspace_id, branch
self._authority_scoped_experience_ids_sync,
user_id,
agent_id,
session_id,
workspace_id,
branch,
)
if not allowed:
logger.info(
f"[coding-store] no experience in scope: "
f"agent_id={agent_id!r} session_id={session_id!r} "
f"workspace_id={workspace_id!r} branch={branch!r} "
f"(user has {total}); skip vdb search"
)
@@ -778,6 +797,8 @@ class CodingMemoryStore:
query_embedding: List[float],
user_id: str,
*,
agent_id: Optional[str] = None,
session_id: Optional[str] = None,
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
top: int = 5,
@@ -791,8 +812,9 @@ class CodingMemoryStore:
SUPERSEDE 时向量行已被删掉,理论上召不到 superseded;这里再按 SQLite 的
status 过一道,避免两边不一致时把过期结论放出去。
workspace_id / branch 是作用域过滤,规则见 _scope_where,语义与经验层一致:
不传不过滤,传了按 SQLite 里的值筛,并按选择率放大向量取数。
agent_id / session_id 是写授权作用域,传入时精确匹配;workspace_id / branch
的规则见 _scope_where。不传任何作用域则不过滤,传了按 SQLite 里的值筛,并
按选择率放大向量取数。
向量库按 top 的 4 倍过采样:向量行和 SQLite 行不是一个事务里写的,中间挂掉
就会留下"向量还在、SQLite 没了"的孤儿,软删时删向量失败也会留下 superseded
@@ -809,13 +831,19 @@ class CodingMemoryStore:
return []
fetch = top * 4
if workspace_id:
if agent_id or session_id or workspace_id:
scoped, total = await _run_in_pool(
self._count_active_facts_sync, user_id, workspace_id, branch
self._count_active_facts_sync,
user_id,
agent_id,
session_id,
workspace_id,
branch,
)
if not scoped:
logger.info(
f"[coding-store] no active fact in scope: "
f"agent_id={agent_id!r} session_id={session_id!r} "
f"workspace_id={workspace_id!r} branch={branch!r} "
f"(user has {total}); skip fact vdb search"
)
@@ -852,6 +880,8 @@ class CodingMemoryStore:
alive = [
(f, scores[f.fact_id]) for f in facts
if f.status == "active"
and (not agent_id or f.agent_id == agent_id)
and (not session_id or f.session_id == session_id)
and _scope_matches(f.workspace_id, f.branch, workspace_id, branch)
]
kept = [(f, s) for f, s in alive if s >= min_score]
@@ -1050,14 +1080,25 @@ class CodingMemoryStore:
by_id = {r["experience_id"]: _row_to_memory(r) for r in rows}
return [by_id[eid] for eid in experience_ids if eid in by_id]
def _scoped_experience_ids_sync(
self, user_id: str, workspace_id: str, branch: Optional[str]
def _authority_scoped_experience_ids_sync(
self,
user_id: str,
agent_id: Optional[str],
session_id: Optional[str],
workspace_id: Optional[str],
branch: Optional[str],
) -> Tuple[Set[str], int]:
"""返回 (落在作用域内的 experience_id 集合, 该 user 的经验总数)。
"""返回 (落在授权作用域内的 experience_id 集合, 该 user 的经验总数)。
一次查询同时拿到过滤结果和选择率分母:前者供向量命中做交集,后者定放大倍数。
"""
scope_sql, scope_params = _scope_where(workspace_id, branch)
if agent_id:
scope_sql = f"agent_id = ? AND ({scope_sql})"
scope_params = [agent_id, *scope_params]
if session_id:
scope_sql = f"session_id = ? AND ({scope_sql})"
scope_params = [session_id, *scope_params]
with self._lock:
assert self._conn is not None
cur = self._conn.execute(
@@ -1069,13 +1110,24 @@ class CodingMemoryStore:
return {r["experience_id"] for r in rows if r["in_scope"]}, len(rows)
def _count_active_facts_sync(
self, user_id: str, workspace_id: str, branch: Optional[str]
self,
user_id: str,
agent_id: Optional[str],
session_id: Optional[str],
workspace_id: Optional[str],
branch: Optional[str],
) -> Tuple[int, int]:
"""返回 (作用域内 active 事实数, 该 user 的 active 事实总数)。
事实层的过滤在 get_facts 拿到对象之后用 _scope_matches 做,这里只要选择率。
"""
scope_sql, scope_params = _scope_where(workspace_id, branch)
if agent_id:
scope_sql = f"agent_id = ? AND ({scope_sql})"
scope_params = [agent_id, *scope_params]
if session_id:
scope_sql = f"session_id = ? AND ({scope_sql})"
scope_params = [session_id, *scope_params]
with self._lock:
assert self._conn is not None
row = self._conn.execute(
+8 -1
View File
@@ -6,7 +6,7 @@ Agent Memory - 配置管理
import os
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
from typing import Optional, Dict, Any, Callable, List
from pathlib import Path
@@ -1252,6 +1252,13 @@ class MemoryConfig:
enable_agent: bool = False
debug: bool = False
metrics_enabled: bool = True
# Runtime-only HTTP transport factory. MemHub uses this for request-cost
# budgeting without restricting the embedded SDK's local store access.
http_transport_factory: Optional[Callable[[], Any]] = field(
default=None,
repr=False,
compare=False,
)
def __post_init__(self):
# enable_graph 跟随 mode 走:mode=ultra 时自动启用,无需独立环境变量
+14 -4
View File
@@ -356,11 +356,21 @@ class EmbedService:
key = self._client_cache_key(base_url, api_key, timeout)
client = self._client_cache.get(key)
if client is None:
client_options = {
"api_key": api_key,
"base_url": base_url or None,
"timeout": timeout,
"max_retries": 0,
}
transport_factory = getattr(self.config, "http_transport_factory", None)
if callable(transport_factory):
import httpx
client_options["http_client"] = httpx.AsyncClient(
transport=transport_factory()
)
client = AsyncOpenAI(
api_key=api_key,
base_url=base_url or None,
timeout=timeout,
max_retries=0,
**client_options,
)
self._client_cache[key] = client
return client
+136 -125
View File
@@ -7,7 +7,7 @@ Agent Memory V2 - SqliteCache
设计模式跟随 history_store.py:
- 标准库 sqlite3 + _run_in_sqlite_pool()
- WAL 模式 + check_same_thread=False
- threading.Lock 保护写操作
- 每实例 threading.RLock 保护共享连接的全部操作与关闭
三张表:
- memory_operations / pipeline_logs / system_metrics: 审计与观测
@@ -122,7 +122,10 @@ class SqliteCache(CacheBase):
def __init__(self, config: MemoryConfig):
self.config = config
self._conn: Optional[sqlite3.Connection] = None
self._lock = threading.Lock()
# One connection is shared by the executor workers. Use the same lock
# for reads, writes, initialization and close so close() cannot free
# the connection while another worker is inside sqlite3.
self._lock = threading.RLock()
# 读取 db_path
cache_cfg = getattr(config, "cache", None)
@@ -144,35 +147,37 @@ class SqliteCache(CacheBase):
logger.debug(f"SqliteCache initialized: {self._db_path}")
def _init_sync(self) -> None:
db_dir = os.path.dirname(self._db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL;")
self._conn.executescript(_CREATE_TABLES_SQL)
self._migrate_pipeline_logs_columns()
self._conn.commit()
with self._lock:
db_dir = os.path.dirname(self._db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL;")
self._conn.executescript(_CREATE_TABLES_SQL)
self._migrate_pipeline_logs_columns()
self._conn.commit()
def _migrate_pipeline_logs_columns(self) -> None:
"""给旧库的 pipeline_logs_v2 补 namespace/session/event time 列。"""
try:
cols = {
row["name"]
for row in self._conn.execute("PRAGMA table_info(pipeline_logs_v2)")
}
_text_cols = ("namespace", "session_id")
for col in _text_cols:
if col not in cols:
with self._lock:
try:
cols = {
row["name"]
for row in self._conn.execute("PRAGMA table_info(pipeline_logs_v2)")
}
_text_cols = ("namespace", "session_id")
for col in _text_cols:
if col not in cols:
self._conn.execute(
f"ALTER TABLE pipeline_logs_v2 ADD COLUMN {col} TEXT NOT NULL DEFAULT ''"
)
if "event_at_ms" not in cols:
self._conn.execute(
f"ALTER TABLE pipeline_logs_v2 ADD COLUMN {col} TEXT NOT NULL DEFAULT ''"
"ALTER TABLE pipeline_logs_v2 ADD COLUMN event_at_ms INTEGER NOT NULL DEFAULT 0"
)
if "event_at_ms" not in cols:
self._conn.execute(
"ALTER TABLE pipeline_logs_v2 ADD COLUMN event_at_ms INTEGER NOT NULL DEFAULT 0"
)
except Exception as e:
logger.debug(f"[sqlite] pipeline_logs_v2 column migration skipped: {e}")
except Exception as e:
logger.debug(f"[sqlite] pipeline_logs_v2 column migration skipped: {e}")
async def close(self) -> None:
if self._conn is not None:
@@ -180,9 +185,10 @@ class SqliteCache(CacheBase):
logger.info("SqliteCache closed")
def _close_sync(self) -> None:
if self._conn:
self._conn.close()
self._conn = None
with self._lock:
if self._conn:
self._conn.close()
self._conn = None
# ================================================================
# 统计
@@ -255,39 +261,40 @@ class SqliteCache(CacheBase):
"""
try:
def _query():
conditions = []
params = []
if request_id:
conditions.append("request_id = ?")
params.append(request_id)
if memory_id:
conditions.append("memory_id = ?")
params.append(memory_id)
if user_id:
conditions.append("user_id = ?")
params.append(user_id)
with self._lock:
conditions = []
params = []
if request_id:
conditions.append("request_id = ?")
params.append(request_id)
if memory_id:
conditions.append("memory_id = ?")
params.append(memory_id)
if user_id:
conditions.append("user_id = ?")
params.append(user_id)
if not conditions:
return []
if not conditions:
return []
where = " AND ".join(conditions)
rows = self._conn.execute(
f"SELECT * FROM memory_operations_v2 WHERE {where} "
f"ORDER BY id DESC LIMIT ?",
params + [limit],
).fetchall()
result = []
for r in rows:
row = dict(r)
# 反序列化 supersedes JSON 字符串 → list
import json as _json
raw_sup = row.get("supersedes", "")
try:
row["supersedes"] = _json.loads(raw_sup) if raw_sup else []
except Exception:
row["supersedes"] = []
result.append(row)
return result
where = " AND ".join(conditions)
rows = self._conn.execute(
f"SELECT * FROM memory_operations_v2 WHERE {where} "
f"ORDER BY id DESC LIMIT ?",
params + [limit],
).fetchall()
result = []
for r in rows:
row = dict(r)
# 反序列化 supersedes JSON 字符串 → list
import json as _json
raw_sup = row.get("supersedes", "")
try:
row["supersedes"] = _json.loads(raw_sup) if raw_sup else []
except Exception:
row["supersedes"] = []
result.append(row)
return result
return await _run_in_sqlite_pool(_query)
except Exception as e:
@@ -369,37 +376,38 @@ class SqliteCache(CacheBase):
"""
try:
def _query():
conditions = []
params = []
if request_id:
conditions.append("request_id = ?")
params.append(request_id)
if user_id:
conditions.append("user_id = ?")
params.append(user_id)
if step:
conditions.append("step = ?")
params.append(step)
with self._lock:
conditions = []
params = []
if request_id:
conditions.append("request_id = ?")
params.append(request_id)
if user_id:
conditions.append("user_id = ?")
params.append(user_id)
if step:
conditions.append("step = ?")
params.append(step)
if not conditions:
return []
if not conditions:
return []
where = " AND ".join(conditions)
rows = self._conn.execute(
f"SELECT * FROM pipeline_logs_v2 WHERE {where} "
f"ORDER BY id ASC LIMIT ?",
params + [limit],
).fetchall()
out = [dict(r) for r in rows]
# 派生可读的 event_at(ISO ms)供查询接口直接用;event_at_ms 为 0 时留空
from datetime import datetime as _dt
for _r in out:
_ms = _r.get("event_at_ms") or 0
_r["event_at"] = (
_dt.fromtimestamp(_ms / 1000.0).isoformat(timespec="milliseconds")
if _ms else ""
)
return out
where = " AND ".join(conditions)
rows = self._conn.execute(
f"SELECT * FROM pipeline_logs_v2 WHERE {where} "
f"ORDER BY id ASC LIMIT ?",
params + [limit],
).fetchall()
out = [dict(r) for r in rows]
# 派生可读的 event_at(ISO ms)供查询接口直接用;event_at_ms 为 0 时留空
from datetime import datetime as _dt
for _r in out:
_ms = _r.get("event_at_ms") or 0
_r["event_at"] = (
_dt.fromtimestamp(_ms / 1000.0).isoformat(timespec="milliseconds")
if _ms else ""
)
return out
return await _run_in_sqlite_pool(_query)
except Exception as e:
@@ -414,26 +422,27 @@ class SqliteCache(CacheBase):
"""存储一个分钟桶的增量指标数据(UPSERT: 同一分钟多次写入会合并)"""
try:
def _store():
now = datetime.now().isoformat()
# UPSERT: 如果同一分钟已存在,合并数据
existing = self._conn.execute(
"SELECT data FROM system_metrics WHERE minute_ts = ?",
(minute_ts,),
).fetchone()
if existing:
import json as _json
old_data = _json.loads(existing["data"])
merged = self._merge_metric_buckets(old_data, data)
self._conn.execute(
"UPDATE system_metrics SET data = ?, created_at = ? WHERE minute_ts = ?",
(_json.dumps(merged, default=str), now, minute_ts),
)
else:
self._conn.execute(
"INSERT INTO system_metrics (minute_ts, data, created_at) VALUES (?, ?, ?)",
(minute_ts, json.dumps(data, default=str), now),
)
self._conn.commit()
with self._lock:
now = datetime.now().isoformat()
# UPSERT: 如果同一分钟已存在,合并数据
existing = self._conn.execute(
"SELECT data FROM system_metrics WHERE minute_ts = ?",
(minute_ts,),
).fetchone()
if existing:
import json as _json
old_data = _json.loads(existing["data"])
merged = self._merge_metric_buckets(old_data, data)
self._conn.execute(
"UPDATE system_metrics SET data = ?, created_at = ? WHERE minute_ts = ?",
(_json.dumps(merged, default=str), now, minute_ts),
)
else:
self._conn.execute(
"INSERT INTO system_metrics (minute_ts, data, created_at) VALUES (?, ?, ?)",
(minute_ts, json.dumps(data, default=str), now),
)
self._conn.commit()
await _run_in_sqlite_pool(_store)
except Exception as e:
logger.warning(f"store_metrics_minute failed: {e}")
@@ -442,17 +451,18 @@ class SqliteCache(CacheBase):
"""读取指定时间范围内的分钟指标数据"""
try:
def _load():
rows = self._conn.execute(
"SELECT data FROM system_metrics WHERE minute_ts >= ? AND minute_ts <= ? ORDER BY minute_ts",
(start_ts, end_ts),
).fetchall()
results = []
for row in rows:
try:
results.append(json.loads(row["data"]))
except (json.JSONDecodeError, TypeError):
pass
return results
with self._lock:
rows = self._conn.execute(
"SELECT data FROM system_metrics WHERE minute_ts >= ? AND minute_ts <= ? ORDER BY minute_ts",
(start_ts, end_ts),
).fetchall()
results = []
for row in rows:
try:
results.append(json.loads(row["data"]))
except (json.JSONDecodeError, TypeError):
pass
return results
return await _run_in_sqlite_pool(_load)
except Exception as e:
logger.warning(f"load_metrics_range failed: {e}")
@@ -462,11 +472,12 @@ class SqliteCache(CacheBase):
"""删除指定时间之前的 metrics 数据"""
try:
def _cleanup():
self._conn.execute(
"DELETE FROM system_metrics WHERE minute_ts < ?",
(before_ts,),
)
self._conn.commit()
with self._lock:
self._conn.execute(
"DELETE FROM system_metrics WHERE minute_ts < ?",
(before_ts,),
)
self._conn.commit()
await _run_in_sqlite_pool(_cleanup)
except Exception as e:
logger.warning(f"cleanup_old_metrics failed: {e}")
+81 -70
View File
@@ -16,6 +16,7 @@ import json
import logging
import os
import sqlite3
import threading
from datetime import datetime, timezone
from typing import Dict, List, Any, Optional
@@ -89,6 +90,10 @@ class HistoryStore:
"""
self._db_path = config.history.db_path
self._conn: Optional[sqlite3.Connection] = None
# sqlite3.Connection.close() must never race with an execute() on the
# same connection. The lock also serializes operations on this shared
# connection; separate HistoryStore instances remain independent.
self._conn_lock = threading.RLock()
async def initialize(self) -> None:
"""创建表和索引(如果不存在)"""
@@ -97,20 +102,21 @@ class HistoryStore:
def _init_sync(self) -> None:
"""同步初始化:创建目录、连接数据库、建表建索引"""
# 确保目录存在
db_dir = os.path.dirname(self._db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
with self._conn_lock:
# 确保目录存在
db_dir = os.path.dirname(self._db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
# 开启 WAL 模式,提高并发读写性能
self._conn.execute("PRAGMA journal_mode=WAL;")
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
# 开启 WAL 模式,提高并发读写性能
self._conn.execute("PRAGMA journal_mode=WAL;")
self._conn.execute(_CREATE_TABLE_SQL)
for idx_sql in _CREATE_INDEXES_SQL:
self._conn.execute(idx_sql)
self._conn.commit()
self._conn.execute(_CREATE_TABLE_SQL)
for idx_sql in _CREATE_INDEXES_SQL:
self._conn.execute(idx_sql)
self._conn.commit()
# ================================================================
# 记录操作
@@ -218,13 +224,14 @@ class HistoryStore:
return await _run_in_sqlite_pool(self._get_history_sync, memory_id)
def _get_history_sync(self, memory_id: str) -> List[Dict[str, Any]]:
if self._conn is None:
return []
cursor = self._conn.execute(
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY created_at ASC",
(memory_id,),
)
return [dict(row) for row in cursor.fetchall()]
with self._conn_lock:
if self._conn is None:
return []
cursor = self._conn.execute(
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY created_at ASC",
(memory_id,),
)
return [dict(row) for row in cursor.fetchall()]
async def get_recent(
self,
@@ -244,14 +251,15 @@ class HistoryStore:
return await _run_in_sqlite_pool(self._get_recent_sync, isolation_key, limit)
def _get_recent_sync(self, isolation_key: str, limit: int) -> List[Dict[str, Any]]:
if self._conn is None:
return []
cursor = self._conn.execute(
"SELECT * FROM memory_history WHERE isolation_key = ? "
"ORDER BY created_at DESC LIMIT ?",
(isolation_key, limit),
)
return [dict(row) for row in cursor.fetchall()]
with self._conn_lock:
if self._conn is None:
return []
cursor = self._conn.execute(
"SELECT * FROM memory_history WHERE isolation_key = ? "
"ORDER BY created_at DESC LIMIT ?",
(isolation_key, limit),
)
return [dict(row) for row in cursor.fetchall()]
async def count(self, isolation_key: str = "") -> int:
"""
@@ -266,16 +274,17 @@ class HistoryStore:
return await _run_in_sqlite_pool(self._count_sync, isolation_key)
def _count_sync(self, isolation_key: str) -> int:
if self._conn is None:
return 0
if isolation_key:
cursor = self._conn.execute(
"SELECT COUNT(*) FROM memory_history WHERE isolation_key = ?",
(isolation_key,),
)
else:
cursor = self._conn.execute("SELECT COUNT(*) FROM memory_history")
return cursor.fetchone()[0]
with self._conn_lock:
if self._conn is None:
return 0
if isolation_key:
cursor = self._conn.execute(
"SELECT COUNT(*) FROM memory_history WHERE isolation_key = ?",
(isolation_key,),
)
else:
cursor = self._conn.execute("SELECT COUNT(*) FROM memory_history")
return cursor.fetchone()[0]
async def close(self) -> None:
"""关闭数据库连接"""
@@ -284,9 +293,10 @@ class HistoryStore:
logger.info("[HistoryStore] closed")
def _close_sync(self) -> None:
if self._conn:
self._conn.close()
self._conn = None
with self._conn_lock:
if self._conn:
self._conn.close()
self._conn = None
# ================================================================
# 内部
@@ -339,33 +349,34 @@ class HistoryStore:
role: str = "",
extra: Optional[Dict[str, Any]] = None,
) -> int:
if self._conn is None:
return -1
now = _now_iso()
extra_json = json.dumps(extra or {}, ensure_ascii=False)
cursor = self._conn.execute(
"""INSERT INTO memory_history
(memory_id, isolation_key, event,
old_memory, new_memory, old_status, new_status,
change_reason, layer, actor_id, role, extra,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
memory_id,
isolation_key,
event,
old_memory,
new_memory,
old_status,
new_status,
change_reason,
layer,
actor_id,
role,
extra_json,
now,
now,
),
)
self._conn.commit()
return cursor.lastrowid
with self._conn_lock:
if self._conn is None:
return -1
now = _now_iso()
extra_json = json.dumps(extra or {}, ensure_ascii=False)
cursor = self._conn.execute(
"""INSERT INTO memory_history
(memory_id, isolation_key, event,
old_memory, new_memory, old_status, new_status,
change_reason, layer, actor_id, role, extra,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
memory_id,
isolation_key,
event,
old_memory,
new_memory,
old_status,
new_status,
change_reason,
layer,
actor_id,
role,
extra_json,
now,
now,
),
)
self._conn.commit()
return cursor.lastrowid
+90 -17
View File
@@ -16,11 +16,10 @@ Agent Memory V2 - VectorStore (Qdrant)
- 简化运维,后续可按需拆分
"""
import asyncio
import concurrent.futures
import os
import threading
from typing import Optional, List, Dict, Any, Tuple
from typing import Optional, List, Dict, Any, Tuple, Callable
from pathlib import Path
import logging
@@ -30,12 +29,10 @@ from ..models.memory import (
from ..config import MemoryConfig
from ..utils.async_executor import run_in_executor_guarded
from .vector_store_base import VectorStoreBase
from .vector_store_chroma import _vdb_executor
logger = logging.getLogger(__name__)
# VDB 独立线程池(与 Chroma 共享同一策略)
from .vector_store_chroma import _vdb_executor
def _run_in_vdb_pool(func, *args, **kwargs):
"""在 VDB 独立线程池中执行同步函数"""
@@ -44,6 +41,70 @@ def _run_in_vdb_pool(func, *args, **kwargs):
return run_in_executor_guarded(_vdb_executor, call)
class _LocalQdrantOwner:
"""Keep one local Qdrant client and its SQLite state on one owner thread.
Local Qdrant may enable SQLite's ``check_same_thread`` on environments
compiled with ``THREADSAFE=2``. The public async surface still uses the
shared VDB pool, but every actual client access is forwarded to this
instance's single owner thread.
"""
def __init__(self, factory: Callable[[], Any]):
self._executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="qdrant-local-owner",
)
self._lifecycle_lock = threading.Lock()
self._closed = False
try:
self._client = self._executor.submit(factory).result()
except BaseException:
self._closed = True
self._executor.shutdown(wait=True, cancel_futures=True)
raise
def _submit(self, callback: Callable[[], Any]) -> Any:
with self._lifecycle_lock:
if self._closed:
raise RuntimeError("Local Qdrant client is closed")
future = self._executor.submit(callback)
return future.result()
def __getattr__(self, name: str) -> Any:
is_callable, value = self._submit(
lambda: (
callable(attribute := getattr(self._client, name)),
None if callable(attribute) else attribute,
)
)
if not is_callable:
return value
def invoke(*args: Any, **kwargs: Any) -> Any:
return self._submit(
lambda: getattr(self._client, name)(*args, **kwargs)
)
return invoke
def close(self) -> None:
def close_owned_client() -> None:
client, self._client = self._client, None
if client is not None:
client.close()
with self._lifecycle_lock:
if self._closed:
return
self._closed = True
future = self._executor.submit(close_owned_client)
try:
future.result()
finally:
self._executor.shutdown(wait=True, cancel_futures=True)
class QdrantVectorStore(VectorStoreBase):
"""
Qdrant 向量存储实现
@@ -113,8 +174,8 @@ class QdrantVectorStore(VectorStoreBase):
self._is_local = True
# 兼容性处理: 清理高版本 qdrant-client 写入的 meta.json 中的未知字段
self._patch_local_meta(vs_config.persist_directory)
self._client = QdrantClient(
path=vs_config.persist_directory,
self._client = _LocalQdrantOwner(
lambda: QdrantClient(path=vs_config.persist_directory)
)
# 确保集合存在(同步调用,在 to_thread 中执行)
@@ -217,7 +278,13 @@ class QdrantVectorStore(VectorStoreBase):
has_sparse = False
return has_sparse
has_sparse = await _run_in_vdb_pool(_ensure_collection)
try:
has_sparse = await _run_in_vdb_pool(_ensure_collection)
except BaseException:
client, self._client = self._client, None
if client is not None:
await _run_in_vdb_pool(client.close)
raise
self._supports_fulltext = bool(has_sparse and bm25_fastembed.is_available())
logger.info(
f"[qdrant] fulltext(BM25 sparse)={'on' if self._supports_fulltext else 'off'}"
@@ -1043,7 +1110,10 @@ class QdrantVectorStore(VectorStoreBase):
FieldCondition(key="status", match=MatchAny(any=status_values))
)
if layers:
layer_values = [l.value if hasattr(l, 'value') else str(l) for l in layers]
layer_values = [
layer.value if hasattr(layer, "value") else str(layer)
for layer in layers
]
if not allow_raw_layer:
layer_values = [v for v in layer_values if v != MemoryLayer.L1_RAW.value]
if layer_values:
@@ -1372,9 +1442,9 @@ class QdrantVectorStore(VectorStoreBase):
async def close(self) -> None:
"""关闭客户端"""
if self._client:
self._client.close()
self._client = None
client, self._client = self._client, None
if client is not None:
await _run_in_vdb_pool(client.close)
# ================================================================
# Keyword Search (Full-text)
@@ -1412,8 +1482,6 @@ class QdrantVectorStore(VectorStoreBase):
except Exception as e:
logger.debug(f"[qdrant] inspect search_text index failed (will try create anyway): {e}")
target_tokenizer_value = getattr(TokenizerType.WHITESPACE, "value", "whitespace")
if existing_tokenizer is not None:
if str(existing_tokenizer).lower().endswith("whitespace") or str(existing_tokenizer).lower() == "whitespace":
return # 已经是新格式,啥都不做
@@ -1436,7 +1504,9 @@ class QdrantVectorStore(VectorStoreBase):
max_token_len=20,
),
)
logger.info(f"[qdrant] Created search_text text index (whitespace) on existing collection")
logger.info(
"[qdrant] Created search_text text index (whitespace) on existing collection"
)
except Exception as e:
# 兜底:未支持或并发竞争,静默
logger.debug(f"[qdrant] _ensure_text_index swallowed: {e}")
@@ -1510,7 +1580,10 @@ class QdrantVectorStore(VectorStoreBase):
)
if layers:
must_conditions.append(
FieldCondition(key="layer", match=MatchAny(any=[l.value for l in layers]))
FieldCondition(
key="layer",
match=MatchAny(any=[layer.value for layer in layers]),
)
)
if status_filter:
must_conditions.append(
@@ -1729,7 +1802,7 @@ class QdrantVectorStore(VectorStoreBase):
if changed:
meta_path.write_text(_json.dumps(meta), encoding="utf-8")
logger.info(f"Patched Qdrant meta.json: removed unsupported fields")
logger.info("Patched Qdrant meta.json: removed unsupported fields")
# ================================================================
# Tag Index(per-user tag embedding,供 reader_hybrid_tag 使用)
+4
View File
@@ -54,6 +54,8 @@ def concurrent_result_guarded(
try:
return future.result(timeout=interval)
except concurrent.futures.TimeoutError:
if future.done():
return future.result()
target_loop.call_soon_threadsafe(lambda: None)
@@ -70,4 +72,6 @@ async def await_concurrent_future_guarded(
try:
return await asyncio.wait_for(asyncio.shield(wrapped), timeout=interval)
except asyncio.TimeoutError:
if wrapped.done():
return wrapped.result()
target_loop.call_soon_threadsafe(lambda: None)
+24
View File
@@ -0,0 +1,24 @@
"""Context propagated to host-provided HTTP transports for cost accounting."""
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar, Token
_model_call_stage: ContextVar[str] = ContextVar(
"hy_memory_model_call_stage",
default="other",
)
def current_model_call_stage() -> str:
return _model_call_stage.get()
@contextmanager
def model_call_stage(stage: str | None) -> Iterator[None]:
normalized = (stage or "other").strip().lower() or "other"
token: Token[str] = _model_call_stage.set(normalized)
try:
yield
finally:
_model_call_stage.reset(token)
+5 -1
View File
@@ -80,7 +80,11 @@ class PipelineLogWriter:
split_namespace_user,
)
tokens = (prompt_tokens or 0) + (completion_tokens or 0) + (total_tokens or 0)
# Providers define total_tokens as prompt+completion. Adding all
# three fields doubled every cost estimate in this JSONL.
tokens = (total_tokens or 0) or (
(prompt_tokens or 0) + (completion_tokens or 0)
)
namespace, uid = split_namespace_user(user_id or "")
if not is_llm_content_logging_enabled():
prompt = redact_content(prompt)
+44 -7
View File
@@ -15,12 +15,12 @@ write、search,并把搜索结果格式化为可交给模型的 memory text ch
```text
distribution: hy-memory
version: 2.0.1
version: 2.0.3
mode: 按运行时配置选择
reader: hybrid
```
仓库 pin、lockfile 和运行时版本守卫均固定到公开的 `hy-memory==2.0.1`。
仓库 pin、lockfile 和运行时版本守卫均固定到公开的 `hy-memory==2.0.3`。
MemHub 在本地 Runtime 内嵌入 SDK,但不绕过自身的来源授权、模型授权、Evidence、
Governance 和撤权边界。
@@ -37,6 +37,40 @@ MemHub 对 SDK 使用以下公开、可复现的本地策略:
- API Key 进入系统安全存储,不写入仓库、安装包、日志或 HY metadata;
- 服务端数据库、租户队列及宿主 shell 中的 ambient 凭据不会进入 SDK config。
发布包固定应用以下 SDK 行为配置:
| 环境变量 | 值 |
|---|---|
| `MEMORY_MODE` | `ultra` |
| `MEMORY_ENABLE_GRAPH` | `true` |
| `MEMORY_GRAPH_RERANK_ENABLED` | `false` |
| `MEMORY_GRAPH_TEMPORAL_SPINE` | `true` |
| `MEMORY_TOPIC_SUMMARY_ENABLED` | `false` |
| `MEMORY_EPISODE_SUMMARY_ENABLED` | `false` |
| `MEMORY_EPISODE_RECALL_ENABLED` | `true` |
| `MEMORY_DYNAMIC_EPISODE_ENABLED` | `true` |
| `MEMORY_SESSION_EPISODE_ENABLED` | `false` |
| `MEMORY_SESSION_EPISODE_CONTEXT_ENABLED` | `false` |
| `MEMORY_EPISODE_CAPSULE_CONTEXT_ENABLED` | `true` |
| `MEMORY_EPISODE_CAPSULE_QUERY_MIN` | `0.65` |
| `MEMORY_EPISODE_CAPSULE_ASSIGNMENT_MIN` | `0.60` |
| `MEMORY_EPISODE_CAPSULE_FIRST_RANK_MAX` | `3` |
| `MEMORY_EPISODE_CAPSULE_MEMBER_LIMIT` | `3` |
| `MEMORY_ENABLE_INTENTION` | `false` |
| `MEMORY_ENABLE_QUERY_REWRITE` | `false` |
| `MEMORY_FEW_SHOT_ENABLED` | `true` |
| `MEMORY_HYBRID_V2_QUERY_EXPAND` | `1` |
| `MEMORY_HYBRID_V2_GEO_INTENT_PRIOR` | `0.6` |
| `MEMORY_HYBRID_V2_DIVERSITY` | `1` |
| `MEMORY_HYBRID_V2_TEMPORAL_ADMIT_PRIOR` | `0.55` |
| `MEMORY_HYBRID_V2_TEMPORAL_BOOST` | `0.18` |
| `MEMORY_CROSS_DOMAIN_CON_THRESHOLD` | `0.5` |
| `MEMORY_READER_ENABLE_SUMMARY` | `false` |
| `MEMORY_SUMMARY_ENABLED_IN_SYS2` | `false` |
| `MEMORY_LOCAL_RERANK_ENABLED` | `false` |
| `RECONCILE_ENABLED` | `true` |
| `HY_MEMORY_READER` | `hybrid` |
这些默认值集中在
`apps/core/src/one_memory/hy_runtime_config.py`,并由发布测试和 lockfile 共同约束。
独立脚本若绕过 MemHub 直接导入 SDK,应自行显式配置存储目录和模型端点。
@@ -271,9 +305,9 @@ Runtime 应长期复用 client,不为每次请求重新初始化数据库和
```text
Evidence
-> ModelProcessingPermissionLease
-> HyPermissionTransport
-> HyMemoryBridge.ingest / ingest_extracted / search_ids
-> HyMemoryBridge
-> embedded Hy Memory SDK
-> native Vector / Coding / Graph stores
-> write result verification
-> Governance
-> Recall Broker / UI
@@ -282,8 +316,11 @@ Evidence
不要在新业务模块里直接创建 `HyMemoryClient`,否则会绕过:
- 来源读取授权;
- 模型内容处理授权;
- 发送前与响应后的撤权复核;
- 共享 Runtime、模型配置、API Key 安全存储与请求预算;
- Evidence 引用与作者归属;
- 写后回读验证;
- Governance 状态、版本、更正和删除级联。
Hy Memory SDK 是 MemHub 的内置可信组件,可自由访问和管理本机的全部
Hy Memory 数据。来源授权仍决定哪些宿主数据可以进入本机;宿主 Recall Grant
仍决定哪些外部 AI 应用可以读取治理后的记忆。这两类边界不限制内置 SDK 的本地 CRUD。
+22 -8
View File
@@ -1,18 +1,30 @@
# MemHub / One Memory
# MemHub
MemHub 是一个本地优先的共享记忆产品:把用户授权的多个 AI 产品中的历史对话和新增内容
汇总到同一份记忆,并让这些 AI 在独立授权后复用它。Hy Memory 是 MemHub 使用的 Python
SDK 和记忆引擎。One Memory / `one-memory` 是历史模块、数据目录、服务和兼容命令名称。
MemHub是一个本地优先的共享记忆产品。它把用户明确授权的AI对话和编码记录汇总到一份本地记忆中,再由用户分别授权给支持的AI工具调用。你可以继续使用熟悉的客户端,同时保留跨工具延续的上下文。Hy Memory是MemHub使用的Python SDK和记忆引擎。
# 💡❤️ MemHub 刚刚开源,欢迎一起优化共建! ❤️💡
**# 💭 MemHub处于preview阶段,当前公开版本可以下载和体验,部分平台制品、安装路径与宿主体验仍在持续优化验证。安装前请先查看Release里的实际制品和版本说明。**
> 🙋 如果你遇到问题/有建议:欢迎提交Issue,并附上MemHub版本、操作系统与芯片架构、复现步骤、预期结果、实际结果和经过脱敏的日志。请不要上传APIKey、真实对话、个人路径或其他敏感信息。
> 我们会公开跟进可复现的问题,持续发布修复,并根据真实反馈完善安装、同步和召回体验。
**# 感谢每一条反馈,期待你和我们一起打磨MemHub的体验 :)**
> 💗 MemHub has just been open-sourced and is under active development. The current public release is available for download andevaluation. Some platform packages, installation paths, and host integrations are still being completed and verified. Check the assets
and release notes before installing.
If you run into a problem, please open an Issue with the version, platform,reproduction steps, and sanitized logs, We wilreviewreports, publish fixes, and keep the support matrix up to date.
## 发行与平台合同
公共 Python SDK 为 `hy-memory==2.0.1`。MemHub 源码版本为 `1.0.0`
(build 4),GitHub 仓库为
公共 Python SDK 为 `hy-memory==2.0.3`。MemHub 源码版本为 `1.0.3`
(build 7),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
`v1.0.0` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
`v1.0.3` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
@@ -42,6 +54,8 @@ Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命
架构详情见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) 和
[`docs/contracts/runtime-unification.md`](docs/contracts/runtime-unification.md)。
产品、测试和一线支持排查导入与记忆沉淀时,使用
[`docs/PRODUCT_LOG_TROUBLESHOOTING.md`](docs/PRODUCT_LOG_TROUBLESHOOTING.md)。
## 开发环境
@@ -99,7 +113,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
## Hy Memory
当前开发依赖固定为公开 PyPI 包 `hy-memory==2.0.1`。SDK 配置、权限边界及 search
当前开发依赖固定为公开 PyPI 包 `hy-memory==2.0.3`。SDK 配置、权限边界及 search
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
+96 -8
View File
@@ -3,9 +3,9 @@
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "one-memory",
"documentNamespace": "https://one-memory.local/spdx/2026-10-15T17:26:47Z",
"documentNamespace": "https://one-memory.local/spdx/2026-10-17T13:52:48Z",
"creationInfo": {
"created": "2026-10-15T17:26:47Z",
"created": "2026-10-17T13:52:48Z",
"creators": [
"Tool: one-memory-generate-sbom"
]
@@ -479,6 +479,23 @@
}
]
},
{
"SPDXID": "SPDXRef-Package-pypi-coloredlogs",
"name": "coloredlogs",
"versionInfo": "15.0.1",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"copyrightText": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:pypi/coloredlogs@15.0.1"
}
]
},
{
"SPDXID": "SPDXRef-Package-pypi-distro",
"name": "distro",
@@ -751,6 +768,23 @@
}
]
},
{
"SPDXID": "SPDXRef-Package-pypi-humanfriendly",
"name": "humanfriendly",
"versionInfo": "10.0",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"copyrightText": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:pypi/humanfriendly@10.0"
}
]
},
{
"SPDXID": "SPDXRef-Package-pypi-idna",
"name": "idna",
@@ -1125,6 +1159,23 @@
}
]
},
{
"SPDXID": "SPDXRef-Package-pypi-mpmath",
"name": "mpmath",
"versionInfo": "1.3.0",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"copyrightText": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:pypi/mpmath@1.3.0"
}
]
},
{
"SPDXID": "SPDXRef-Package-pypi-multidict",
"name": "multidict",
@@ -1230,7 +1281,7 @@
{
"SPDXID": "SPDXRef-Package-pypi-onnxruntime",
"name": "onnxruntime",
"versionInfo": "1.29.0",
"versionInfo": "1.23.2",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "NOASSERTION",
@@ -1240,7 +1291,7 @@
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:pypi/onnxruntime@1.29.0"
"referenceLocator": "pkg:pypi/onnxruntime@1.23.2"
}
]
},
@@ -1992,6 +2043,23 @@
}
]
},
{
"SPDXID": "SPDXRef-Package-pypi-sympy",
"name": "sympy",
"versionInfo": "1.14.0",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"copyrightText": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:pypi/sympy@1.14.0"
}
]
},
{
"SPDXID": "SPDXRef-Package-pypi-tenacity",
"name": "tenacity",
@@ -2267,12 +2335,12 @@
{
"SPDXID": "SPDXRef-Package-hy-memory",
"name": "hy-memory",
"versionInfo": "2.0.1",
"versionInfo": "2.0.4",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "MIT",
"licenseDeclared": "MIT",
"copyrightText": "Copyright (c) 2025 HY Memory Contributors"
"licenseConcluded": "Apache-2.0",
"licenseDeclared": "Apache-2.0",
"copyrightText": "Copyright (C) 2026 Tencent. All rights reserved."
},
{
"SPDXID": "SPDXRef-Package-memmy-agent-derived",
@@ -2427,6 +2495,11 @@
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Package-pypi-cloudpickle"
},
{
"spdxElementId": "SPDXRef-Package-one-memory",
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Package-pypi-coloredlogs"
},
{
"spdxElementId": "SPDXRef-Package-one-memory",
"relationshipType": "DEPENDS_ON",
@@ -2507,6 +2580,11 @@
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Package-pypi-huggingface-hub"
},
{
"spdxElementId": "SPDXRef-Package-one-memory",
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Package-pypi-humanfriendly"
},
{
"spdxElementId": "SPDXRef-Package-one-memory",
"relationshipType": "DEPENDS_ON",
@@ -2617,6 +2695,11 @@
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Package-pypi-more-itertools"
},
{
"spdxElementId": "SPDXRef-Package-one-memory",
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Package-pypi-mpmath"
},
{
"spdxElementId": "SPDXRef-Package-one-memory",
"relationshipType": "DEPENDS_ON",
@@ -2872,6 +2955,11 @@
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Package-pypi-starlette"
},
{
"spdxElementId": "SPDXRef-Package-one-memory",
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Package-pypi-sympy"
},
{
"spdxElementId": "SPDXRef-Package-one-memory",
"relationshipType": "DEPENDS_ON",
+66 -22
View File
@@ -2,39 +2,79 @@
MemHub itself is licensed under Apache-2.0 (see `LICENSE`).
It bundles the runtime dependencies listed below.
Generated from the installed dependency tree on 2026-10-15T17:26:47Z
Generated from the installed dependency tree on 2026-10-17T13:52:48Z
by `python3 scripts/generate_sbom.py`. Do not edit by hand.
Per-package license texts are not reproduced here yet; the license
column is filled in as part of release review, and the SBOM records
`NOASSERTION` until then rather than guessing.
## hy-memory 2.0.1
## hy-memory 2.0.4
License: MIT
License: Apache-2.0
```text
MIT License
Tencent is pleased to support the community by making Hy-Memory available.
Copyright (c) 2025 HY Memory Contributors
Copyright (C) 2026 Tencent. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Hy-Memory is licensed under Apache-2.0. Hy-Memory does not impose any additional restrictions beyond those specified in the license.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
For the avoidance of doubt, Hy-Memory refers solely to code, parameters, and weights made publicly available by Tencent in accordance with Apache-2.0.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Terms of the Apache-2.0 License:
--------------------------------------------------------------------
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
You must give any other recipients of the Work or Derivative Works a copy of this License; and
You must cause any modified files to carry prominent notices stating that You changed the files; and
You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
```
## MemTensor memmy-agent (adapted)
@@ -85,7 +125,7 @@ No Memmy brand, icon, font or mascot assets are distributed.
- tar 7.5.22
- three 0.185.1
## Python (PyPI) — 122 packages
## Python (PyPI) — 126 packages
- aiohappyeyeballs 2.7.1
- aiohttp 3.14.3
@@ -104,6 +144,7 @@ No Memmy brand, icon, font or mascot assets are distributed.
- chromadb 1.5.9
- click 8.5.0
- cloudpickle 3.1.2
- coloredlogs 15.0.1
- distro 1.9.0
- durationpy 0.11
- execnet 2.1.2
@@ -120,6 +161,7 @@ No Memmy brand, icon, font or mascot assets are distributed.
- httptools 0.8.0
- httpx 0.28.1
- huggingface-hub 1.29.0
- humanfriendly 10.0
- idna 3.19
- importlib-resources 7.1.0
- iniconfig 2.3.0
@@ -142,13 +184,14 @@ No Memmy brand, icon, font or mascot assets are distributed.
- mdurl 0.1.2
- mmh3 5.3.0
- more-itertools 11.1.0
- mpmath 1.3.0
- multidict 6.7.1
- mypy 2.3.1
- mypy-extensions 1.1.0
- narwhals 2.25.0
- numpy 2.5.2
- oauthlib 3.3.1
- onnxruntime 1.29.0
- onnxruntime 1.23.2
- openai 2.54.0
- opentelemetry-api 1.44.0
- opentelemetry-exporter-otlp-proto-common 1.44.0
@@ -193,6 +236,7 @@ No Memmy brand, icon, font or mascot assets are distributed.
- six 1.17.0
- sniffio 1.3.1
- starlette 1.6.0
- sympy 1.14.0
- tenacity 9.1.4
- threadpoolctl 3.6.0
- tiktoken 0.14.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/cli",
"version": "1.0.0",
"version": "1.0.4",
"description": "MemHub bootstrap CLI for installing and managing the local-first shared memory runtime",
"license": "Apache-2.0",
"repository": {
@@ -12,25 +12,23 @@
* It is generated rather than hand-written so the two cannot drift: this reads
* the real package.json and only strips what is provably not needed at runtime.
*/
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, "..");
const repositoryRoot = resolve(root, "../..");
const require = createRequire(import.meta.url);
const { resolveBuildVersion } = require("../../../scripts/release-version.cjs");
const releaseVersion = resolveBuildVersion(process.env, repositoryRoot);
const { assertSemver } = require("../../../scripts/release-version.cjs");
const pkgPath = resolve(root, "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
const publishVersion = assertSemver(
process.env.MEMHUB_NPM_VERSION?.trim() || pkg.version,
"MEMHUB_NPM_VERSION",
);
const publishName = process.env.MEMHUB_NPM_PACKAGE?.trim();
if (
!publishName ||
!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(publishName)
) {
if (!publishName || !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(publishName)) {
throw new Error(
"MEMHUB_NPM_PACKAGE must name the explicit npm publish identity",
);
@@ -38,8 +36,12 @@ if (
const bundle = resolve(root, "dist/one-memory.cjs");
const source = readFileSync(bundle, "utf8");
const runtimeRequires = [...source.matchAll(/require\(["']([^"']+)["']\)/g)].map((m) => m[1]);
const workspaceLeaked = runtimeRequires.filter((name) => name.startsWith("@one-memory/"));
const runtimeRequires = [
...source.matchAll(/require\(["']([^"']+)["']\)/g),
].map((m) => m[1]);
const workspaceLeaked = runtimeRequires.filter((name) =>
name.startsWith("@one-memory/"),
);
if (workspaceLeaked.length > 0) {
throw new Error(
`bundle still requires workspace packages at runtime: ${workspaceLeaked.join(", ")}. ` +
@@ -52,7 +54,7 @@ mkdirSync(outDir, { recursive: true });
const published = { ...pkg };
published.name = publishName;
published.version = releaseVersion;
published.version = publishVersion;
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
if (!published[section]) continue;
published[section] = Object.fromEntries(
@@ -61,9 +63,7 @@ for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
// else is build-time only. The bundle is inlined (tsup noExternal), so
// in practice only Node builtins remain — but compute it rather than
// assume, so a future non-inlined dep is not silently dropped.
runtimeRequires.some(
(req) => req === name || req.startsWith(`${name}/`),
),
runtimeRequires.some((req) => req === name || req.startsWith(`${name}/`)),
),
);
if (Object.keys(published[section]).length === 0) delete published[section];
@@ -77,8 +77,15 @@ published.files = ["dist", "scripts", "package.json"];
// in the staging dir overrides that and makes the manifest authoritative.
writeFileSync(
resolve(outDir, ".npmignore"),
["# Ship the built bundle: the repo root .gitignore excludes dist/.", "!dist/", ""].join("\n"),
[
"# Ship the built bundle: the repo root .gitignore excludes dist/.",
"!dist/",
"",
].join("\n"),
);
writeFileSync(resolve(outDir, "package.json"), `${JSON.stringify(published, null, 2)}\n`);
writeFileSync(
resolve(outDir, "package.json"),
`${JSON.stringify(published, null, 2)}\n`,
);
console.log("wrote", resolve(outDir, "package.json"));
@@ -20,11 +20,7 @@ import { pathToFileURL } from "node:url";
const REQUIRED_ALIASES = {
daemon: ["start", "stop", "restart", "status"],
"startup-service": [
"service-plan",
"service-install",
"service-uninstall",
],
"startup-service": ["service-plan", "service-install", "service-uninstall"],
};
const REQUIRED_COMMAND_SURFACE = [
@@ -36,7 +32,6 @@ const REQUIRED_COMMAND_SURFACE = [
"calls",
"import-file",
"notes",
"permission",
"extraction",
"graph",
"models",
@@ -81,7 +76,7 @@ const REQUIRED_MARKERS = [
/**
* Sub-commands the UI-write parity work added. A package can declare the
* parent command (`permission`, `models`) while still missing the new verb, so
* parent command (`models`) while still missing the new verb, so
* checking command names alone would pass a stale bundle. The marker is the
* exact literal the catalog usage string contains.
*/
@@ -92,9 +87,6 @@ const REQUIRED_SUBCOMMANDS = [
{ command: "import-file", marker: "memhub import-file discard" },
{ command: "notes", marker: "memhub notes add" },
{ command: "notes", marker: "memhub notes process" },
{ command: "permission", marker: "memhub permission status" },
{ command: "permission", marker: "memhub permission allow" },
{ command: "permission", marker: "memhub permission deny" },
{ command: "extraction", marker: "memhub extraction set" },
{ command: "graph", marker: "memhub graph width" },
{ command: "models", marker: "memhub models edit" },
@@ -112,19 +104,83 @@ const REQUIRED_SUBCOMMANDS = [
* These run without `--lifecycle`, so they are safe against any package.
*/
const STOPPED_DESTRUCTIVE_PROBES = [
{ name: "calls_clear_preview", args: ["calls", "clear", "--event", "11111111-1111-4111-8111-111111111111"] },
{ name: "calls_clear_apply", args: ["calls", "clear", "--event", "11111111-1111-4111-8111-111111111111", "--confirm"] },
{ name: "calls_clear_all_apply", args: ["calls", "clear", "--all", "--confirm"] },
{ name: "import_file_commit_apply", args: ["import-file", "commit", "22222222-2222-4222-8222-222222222222", "--confirm"] },
{ name: "import_file_discard_apply", args: ["import-file", "discard", "22222222-2222-4222-8222-222222222222", "--confirm"] },
{ name: "notes_process_apply", args: ["notes", "process", "33333333-3333-4333-8333-333333333333", "--confirm"] },
{ name: "permission_allow", args: ["permission", "allow"] },
{ name: "permission_deny", args: ["permission", "deny"] },
{ name: "extraction_set_apply", args: ["extraction", "set", "--window", "6m", "--confirm"] },
{ name: "graph_width_apply", args: ["graph", "width", "--decision", "keep_existing", "--confirm"] },
{ name: "processing_extract_apply", args: ["processing", "extract", "--confirm"] },
{ name: "models_activate_apply", args: ["models", "activate", "44444444-4444-4444-8444-444444444444", "--confirm"] },
{ name: "models_edit_apply", args: ["models", "edit", "44444444-4444-4444-8444-444444444444", "--config-file", "/nonexistent/patch.json", "--confirm"] },
{
name: "calls_clear_preview",
args: ["calls", "clear", "--event", "11111111-1111-4111-8111-111111111111"],
},
{
name: "calls_clear_apply",
args: [
"calls",
"clear",
"--event",
"11111111-1111-4111-8111-111111111111",
"--confirm",
],
},
{
name: "calls_clear_all_apply",
args: ["calls", "clear", "--all", "--confirm"],
},
{
name: "import_file_commit_apply",
args: [
"import-file",
"commit",
"22222222-2222-4222-8222-222222222222",
"--confirm",
],
},
{
name: "import_file_discard_apply",
args: [
"import-file",
"discard",
"22222222-2222-4222-8222-222222222222",
"--confirm",
],
},
{
name: "notes_process_apply",
args: [
"notes",
"process",
"33333333-3333-4333-8333-333333333333",
"--confirm",
],
},
{
name: "extraction_set_apply",
args: ["extraction", "set", "--window", "6m", "--confirm"],
},
{
name: "graph_width_apply",
args: ["graph", "width", "--decision", "keep_existing", "--confirm"],
},
{
name: "processing_extract_apply",
args: ["processing", "extract", "--confirm"],
},
{
name: "models_activate_apply",
args: [
"models",
"activate",
"44444444-4444-4444-8444-444444444444",
"--confirm",
],
},
{
name: "models_edit_apply",
args: [
"models",
"edit",
"44444444-4444-4444-8444-444444444444",
"--config-file",
"/nonexistent/patch.json",
"--confirm",
],
},
];
export function validateCatalog(document) {
@@ -201,7 +257,9 @@ export async function runPackageMatrix(runtimeDist, options = {}) {
const markers = REQUIRED_MARKERS.filter(
(marker) => !bundle.toString("utf8").includes(marker),
);
const isolatedRoot = await mkdtemp(path.join(tmpdir(), "memhub-package-matrix-"));
const isolatedRoot = await mkdtemp(
path.join(tmpdir(), "memhub-package-matrix-"),
);
const home = path.join(isolatedRoot, "home");
const dataDir = path.join(isolatedRoot, "data");
const hostHome = path.join(isolatedRoot, "host-home");
@@ -343,7 +401,10 @@ async function runStoppedDestructive(node, cli, environment) {
`${probe.name} exit ${result.code}, expected 3 while Runtime is stopped`,
);
}
if (document?.state !== "unknown" || document?.code !== "runtime_not_running") {
if (
document?.state !== "unknown" ||
document?.code !== "runtime_not_running"
) {
failures.push(
`${probe.name} did not fail closed with unknown/runtime_not_running`,
);
@@ -451,8 +512,7 @@ async function main() {
}
const asJson = rest.includes("--json");
const expectShaIndex = rest.indexOf("--expect-sha");
const expectSha =
expectShaIndex >= 0 ? rest[expectShaIndex + 1] : undefined;
const expectSha = expectShaIndex >= 0 ? rest[expectShaIndex + 1] : undefined;
if (expectSha !== undefined && !/^[0-9a-fA-F]{64}$/.test(expectSha)) {
process.stderr.write("--expect-sha must be a 64-character SHA-256\n");
process.exitCode = 2;
+23 -6
View File
@@ -30,12 +30,16 @@ describe("managed restore over HTTP", { timeout: 15_000 }, () => {
}
if (request.headers.authorization === "Bearer r409") {
response.statusCode = 409;
response.end(JSON.stringify({ detail: "backup_restore_runtime_running" }));
response.end(
JSON.stringify({ detail: "backup_restore_runtime_running" }),
);
return;
}
if (request.headers.authorization === "Bearer r422") {
response.statusCode = 422;
response.end(JSON.stringify({ detail: "backup_restore_source_outside_home" }));
response.end(
JSON.stringify({ detail: "backup_restore_source_outside_home" }),
);
return;
}
response.end(
@@ -48,9 +52,12 @@ describe("managed restore over HTTP", { timeout: 15_000 }, () => {
}),
);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
const address = server.address();
if (address === null || typeof address === "string") throw new Error("no port");
if (address === null || typeof address === "string")
throw new Error("no port");
port = address.port;
});
@@ -137,8 +144,18 @@ describe("maintenance Core identity", { timeout: 15_000 }, () => {
it("rejects a squatter, wrong service, wrong instance, and non-200", async () => {
const cases = [
{ status: 200, data: { status: "ok", service: "other", instance_id: "x" } },
{ status: 200, data: { status: "ok", service: "one-memory-core", instance_id: "wrong" } },
{
status: 200,
data: { status: "ok", service: "other", instance_id: "x" },
},
{
status: 200,
data: {
status: "ok",
service: "one-memory-core",
instance_id: "wrong",
},
},
{ status: 503, data: {} },
{ status: 200, data: null },
];
+2 -1
View File
@@ -91,7 +91,8 @@ export async function callRestore(
source: string,
request: typeof defaultPost = defaultPost,
): Promise<
{ state: "restored"; result: RestoreResult } | { state: "rejected"; code: string }
| { state: "restored"; result: RestoreResult }
| { state: "rejected"; code: string }
> {
const response = await request(
`http://${options.host}:${options.port}/api/v1/backup/restore`,
+54 -18
View File
@@ -1,19 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import type { CoreReader, ReadOutcome } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import {
batchScanExitCode,
executeBatchImport,
planBatchImport,
renderBatchScanText,
} from "./batch-import-flow.js";
import type { CoreReader, ReadOutcome } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
function readerFor(catalog: unknown): CoreReader {
return (async (path: string) =>
path === "/api/v1/sources"
? ({ state: "ok", data: catalog } as ReadOutcome<unknown>)
: ({ state: "not_found", code: "http_404" } as ReadOutcome<unknown>)) as CoreReader;
: ({
state: "not_found",
code: "http_404",
} as ReadOutcome<unknown>)) as CoreReader;
}
function found(source_id: string, extra: Record<string, unknown> = {}) {
@@ -81,18 +83,36 @@ describe("A19 batch execution", () => {
readerFor([found("codex"), found("cursor")]),
);
if (planned.state !== "preview") throw new Error("expected preview");
const post = vi.fn(async (_path: string, body: Record<string, unknown>) => ({
state: "committed" as const,
data: {
request_id: body.request_id,
accepted_source_ids: ["codex", "cursor"],
failed_sources: {},
jobs: [
{ job_id: "j1", source_id: "codex", status: "pending", discovered: 3, stored: 3, errors: 0, operation: "history" },
{ job_id: "j2", source_id: "cursor", status: "pending", discovered: 5, stored: 5, errors: 0, operation: "history" },
],
},
}));
const post = vi.fn(
async (_path: string, body: Record<string, unknown>) => ({
state: "committed" as const,
data: {
request_id: body.request_id,
accepted_source_ids: ["codex", "cursor"],
failed_sources: {},
jobs: [
{
job_id: "j1",
source_id: "codex",
status: "pending",
discovered: 3,
stored: 3,
errors: 0,
operation: "history",
},
{
job_id: "j2",
source_id: "cursor",
status: "pending",
discovered: 5,
stored: 5,
errors: 0,
operation: "history",
},
],
},
}),
);
const operations = { post } as unknown as CoreOperations;
const document = await executeBatchImport(
planned.candidates,
@@ -127,7 +147,15 @@ describe("A19 batch execution", () => {
accepted_source_ids: ["codex"],
failed_sources: { cursor: "source_identity_changed" },
jobs: [
{ job_id: "j1", source_id: "codex", status: "pending", discovered: 1, stored: 1, errors: 0, operation: "history" },
{
job_id: "j1",
source_id: "codex",
status: "pending",
discovered: 1,
stored: 1,
errors: 0,
operation: "history",
},
],
},
})),
@@ -242,7 +270,15 @@ describe("A19 batch execution", () => {
accepted_source_ids: ["codex"],
failed_sources: {},
jobs: [
{ job_id: "j1", source_id: "codex", status: "pending", discovered: 1, stored: 1, errors: 0, operation: "history" },
{
job_id: "j1",
source_id: "codex",
status: "pending",
discovered: 1,
stored: 1,
errors: 0,
operation: "history",
},
],
},
})),
+28 -15
View File
@@ -14,15 +14,14 @@
*/
import { randomUUID } from "node:crypto";
import { buildSourceImportRequest } from "@one-memory/operations";
import type { ImportJob } from "@one-memory/contracts";
import { buildSourceImportRequest } from "@one-memory/operations";
import type { CoreReader } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import {
type SourceConnectionCandidate,
validateSourceConnectionCandidates,
} from "./core-responses.js";
import type { CoreReader } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import { PRODUCT } from "./version.js";
export interface BatchSourceRow {
@@ -57,19 +56,34 @@ export function detectedSourceIds(
return candidates.map((candidate) => candidate.source_id);
}
export async function planBatchImport(
reader: CoreReader,
): Promise<
| { state: "preview"; candidates: SourceConnectionCandidate[]; rows: BatchSourceRow[]; notFound: string[] }
| { state: "rejected" | "unknown" | "no_sources"; code: string; notFound: string[] }
export async function planBatchImport(reader: CoreReader): Promise<
| {
state: "preview";
candidates: SourceConnectionCandidate[];
rows: BatchSourceRow[];
notFound: string[];
}
| {
state: "rejected" | "unknown" | "no_sources";
code: string;
notFound: string[];
}
> {
const outcome = await reader<unknown>("/api/v1/sources");
if (outcome.state !== "ok") {
return { state: outcome.state === "unavailable" ? "unknown" : "rejected", code: outcome.code, notFound: [] };
return {
state: outcome.state === "unavailable" ? "unknown" : "rejected",
code: outcome.code,
notFound: [],
};
}
const catalog = outcome.data;
if (!Array.isArray(catalog) || catalog.length > SOURCE_LIST_LIMIT) {
return { state: "rejected", code: "sources_response_invalid", notFound: [] };
return {
state: "rejected",
code: "sources_response_invalid",
notFound: [],
};
}
const candidates = validateSourceConnectionCandidates(catalog);
if (!candidates) {
@@ -165,9 +179,7 @@ export async function executeBatchImport(
receipt.failed_sources && typeof receipt.failed_sources === "object"
? (receipt.failed_sources as Record<string, string>)
: {};
const jobs = Array.isArray(receipt.jobs)
? (receipt.jobs as ImportJob[])
: [];
const jobs = Array.isArray(receipt.jobs) ? (receipt.jobs as ImportJob[]) : [];
const firstSeen = jobs.map((job) => ({
source_id: job.source_id,
discovered: job.discovered,
@@ -262,7 +274,8 @@ export async function executeBatchImport(
}
export function batchScanExitCode(document: BatchScanDocument): number {
if (document.state === "committed" || document.state === "no_sources") return 0;
if (document.state === "committed" || document.state === "no_sources")
return 0;
if (document.state === "partial") return 1;
return 1;
}
+37 -76
View File
@@ -81,6 +81,7 @@ import {
readMemoryWrite,
readScanRoots,
removeScanRoot,
resetOnboarding,
submitBackup,
submitMemoryWrite,
submitScan,
@@ -190,13 +191,6 @@ import {
renderMemoryDetailText,
renderMemoryPageText,
} from "./memory-report.js";
import {
type ModelContentPermissionDocument,
modelContentPermissionExitCode,
readModelContentPermission,
renderModelContentPermissionText,
setModelContentPermission,
} from "./model-content-permission-flow.js";
import {
executeModelProbe,
inspectModelProbePlan,
@@ -217,12 +211,10 @@ import {
executeModelProfileActivate,
executeModelProfileEdit,
type ModelWriteDocument,
type ModelWritePlan,
modelWriteExitCode,
planModelProfileActivate,
planModelProfileEdit,
renderModelWriteText,
validateModelWritePlan,
} from "./model-write-flows.js";
import { claimPlanAttempt } from "./operation-journal.js";
import {
@@ -339,7 +331,7 @@ import {
renderVersion,
renderVersionDocument,
} from "./usage.js";
import { CLI_VERSION, PRODUCT } from "./version.js";
import { CLI_VERSION, PRODUCT, RUNTIME_RELEASE_VERSION } from "./version.js";
import { runWatchLoop, type WatchEvent, type WatchRead } from "./watch-loop.js";
export interface RunControls {
@@ -425,7 +417,8 @@ async function selectInstallSource(
throw new Error("release_manifest_url_unavailable");
}
const manifestUrl =
explicitManifestUrl ?? template.replaceAll("{version}", CLI_VERSION);
explicitManifestUrl ??
template.replaceAll("{version}", RUNTIME_RELEASE_VERSION);
const allowedHosts = parseAllowedReleaseHosts(
environment.MEMHUB_RELEASE_ALLOWED_HOSTS,
);
@@ -470,7 +463,7 @@ async function resolveRemoteInstallPreview(
): Promise<ResolvedRuntimeRelease> {
return (controls.resolveRuntimeRelease ?? resolveRuntimeRelease)({
manifestUrl: selection.manifestUrl,
expectedVersion: CLI_VERSION,
expectedVersion: RUNTIME_RELEASE_VERSION,
channel: selection.channel,
platform: process.platform,
arch: process.arch,
@@ -484,7 +477,7 @@ async function acquireRemoteInstallSource(
): Promise<AcquiredRuntimeDistribution> {
return (controls.acquireRuntimeDistribution ?? acquireRuntimeDistribution)({
manifestUrl: selection.manifestUrl,
expectedVersion: CLI_VERSION,
expectedVersion: RUNTIME_RELEASE_VERSION,
channel: selection.channel,
platform: process.platform,
arch: process.arch,
@@ -697,6 +690,36 @@ async function runCommand(
// `path list` and `adapter list` are read-only: they have no plan to confirm,
// so they are answered here rather than falling into the two-phase branch
// (which would reject them for having nothing to apply).
// Back to the cold-start screens on purpose. Deleting the app cannot do this
// (the completion flag lives with the memories, by design), and deleting the
// data to get there would cost the user everything they have stored.
if (named === "onboarding" && arguments_.includes("reset")) {
if (
arguments_.filter((item) => item === "--json").length > 1 ||
arguments_.filter((item) => item !== "--json").join(" ") !==
"onboarding reset"
) {
return usage();
}
const attachment = await attachToRuntime(
resolveRuntimeCliConfig(environment, process.argv[1]).dataDir,
);
if (attachment === null) {
return runtimeUnavailable(descriptor, asJson);
}
const state = await resetOnboarding(attachment);
if (state === null) {
return reject(descriptor, asJson, "引导状态端点不可用。");
}
if (asJson) {
process.stdout.write(`${JSON.stringify(state)}\n`);
return EXIT_OK;
}
process.stdout.write(
`引导流程已回到首次运行(status=${state.status}),记忆数据未改动。\n`,
);
return EXIT_OK;
}
if (named === "path" && arguments_.includes("list")) {
if (
arguments_.filter((item) => item === "--json").length > 1 ||
@@ -2462,45 +2485,6 @@ async function runCommand(
);
return uiWriteExitCode(document);
}
if (named === "permission") {
const parsed = parsePermissionCommand(arguments_.slice(1));
if (!parsed) return usage();
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
const runtime = await inspectRuntimeStatus({
dataDir: config.dataDir,
probeIdentity,
requireServiceReceipt: true,
});
const attached =
runtime.state === "running"
? await attachClients(config.dataDir)
: { reader: null, operations: null, failure: runtime.code };
if (
runtime.state !== "running" ||
(parsed === "status" ? !attached.reader : !attached.operations)
) {
const document: ModelContentPermissionDocument = {
schema_version: 1,
product: PRODUCT,
state: "unknown",
code:
runtime.state !== "running"
? runtime.code
: (attached.failure ?? "runtime_attach_failed"),
};
emitModelContentPermissionDocument(document, asJson);
return runtime.state === "stopped" ? 3 : 1;
}
const document =
parsed === "status"
? await readModelContentPermission(attached.reader!)
: await setModelContentPermission(
attached.operations!,
parsed === "allow",
);
emitModelContentPermissionDocument(document, asJson);
return modelContentPermissionExitCode(document);
}
if (named === "notes" || named === "extraction" || named === "graph") {
const parsed = parseExtendedWriteCommand(
named as "notes" | "extraction" | "graph",
@@ -5124,18 +5108,6 @@ function parseHeadlessCommand(
: null;
}
function parsePermissionCommand(
rest: readonly string[],
): "status" | "allow" | "deny" | null {
if (rest.filter((item) => item === "--json").length > 1) return null;
const tokens = rest.filter((item) => item !== "--json");
const action = tokens[0];
return tokens.length === 1 &&
(action === "status" || action === "allow" || action === "deny")
? action
: null;
}
function unwrapPlan(value: unknown): unknown {
if (
value &&
@@ -5323,17 +5295,6 @@ function emitModelReportDocument(
);
}
function emitModelContentPermissionDocument(
document: ModelContentPermissionDocument,
asJson: boolean,
): void {
process.stdout.write(
asJson
? `${JSON.stringify(document)}\n`
: renderModelContentPermissionText(document),
);
}
function emitSourceSyncDocument(
document: SourceSyncFlowDocument,
asJson: boolean,
@@ -6560,7 +6521,7 @@ function parseInstallOptions(
...(releaseManifestUrl ? { releaseManifestUrl } : {}),
releaseChannel:
releaseChannel ??
(CLI_VERSION.includes("-") ? "prerelease" : "stable"),
(RUNTIME_RELEASE_VERSION.includes("-") ? "prerelease" : "stable"),
installRoot: resolvedInstallRoot,
home: resolvedHome,
uid: uid ?? process.getuid?.(),
+9 -13
View File
@@ -282,11 +282,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
summary: "列出来源、执行已确认的导入计划。",
usage:
"memhub sources list [--json]\n memhub sources import --plan-file <path> --confirm [--json]\n memhub sources receipt <request-id> [--json]",
options: [
PLAN_FILE_OPTION,
EXPLICIT_CONFIRM_OPTION,
JSON_OPTION,
],
options: [PLAN_FILE_OPTION, EXPLICIT_CONFIRM_OPTION, JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
},
{
@@ -497,14 +493,6 @@ export const COMMANDS: readonly CommandDescriptor[] = [
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
},
{
name: "permission",
summary: "读取或直接设置全局模型内容外发许可。",
usage:
"memhub permission status [--json]\n memhub permission allow [--json]\n memhub permission deny [--json]",
options: [JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
},
{
name: "extraction",
summary: "设置提取窗口;默认只出计划,--confirm 后才写入。",
@@ -663,6 +651,14 @@ export const COMMANDS: readonly CommandDescriptor[] = [
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
},
{
name: "onboarding",
summary:
"onboarding reset 回到首次运行的引导流程。只清除引导完成状态:不删记忆数据,也不需要卸载重装。",
usage: "memhub onboarding reset [--json]",
options: [JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE],
},
{
name: "help",
summary: "显示总帮助或单个命令的用法。",
+11 -13
View File
@@ -81,7 +81,6 @@ const COMMANDS = [
"calls",
"import-file",
"notes",
"permission",
"extraction",
"graph",
"host",
@@ -93,6 +92,7 @@ const COMMANDS = [
"uninstall",
"upgrade",
"delete-data",
"onboarding",
"help",
"version",
// A6 product commands. Appended, not inserted: the catalog is a stable
@@ -259,7 +259,14 @@ describe("CLI-22 shared output contract", () => {
describe("argument validation", () => {
it("fails unknown commands without printing data or running anything", async () => {
for (const argv of [["unknown"], ["unknown", "--help"], ["--typo"]]) {
for (const argv of [
["unknown"],
["unknown", "--help"],
["--typo"],
["permission", "status"],
["permission", "allow"],
["permission", "deny"],
]) {
const streams = captureStreams();
try {
expect(await run(argv, {})).toBe(2);
@@ -300,11 +307,7 @@ describe("CLI-22 shared output contract", () => {
`${PRODUCT} sync`,
],
[
[
"sync",
"receipt",
"123e4567-e89b-12d3-a456-426614174000",
],
["sync", "receipt", "123e4567-e89b-12d3-a456-426614174000"],
`${PRODUCT} sync`,
],
[["sync", "pause", "--source"], `${PRODUCT} sync`],
@@ -555,12 +558,7 @@ describe("documented commands", () => {
// Source processing is one Freeze/Thaw switch under `sync`; the obsolete
// independent read/write `sources access` command must not return here.
sources: ["list", "import", "receipt"],
jobs: [
"list",
"show",
"start",
"<pause|resume|cancel>",
],
jobs: ["list", "show", "start", "<pause|resume|cancel>"],
// `sync <pause|resume>` is documented as one alternation, so it is
// checked as an entry rather than as two literal subcommands.
sync: ["status", "<pause|resume>"],
+109 -11
View File
@@ -342,17 +342,20 @@ describe("unified context search", () => {
["--limit", "1", "2"],
["--character-budget", "200", "300"],
["--timeout-ms", "100", "200"],
])("rejects a repeated %s before Runtime attach", async (flag, first, second) => {
const streams = captureStreams();
try {
expect(
await run(["recall", "q", flag, first, flag, second, "--json"], {}),
).toBe(2);
} finally {
streams.restore();
}
expect(streams.stdout()).toBe("");
});
])(
"rejects a repeated %s before Runtime attach",
async (flag, first, second) => {
const streams = captureStreams();
try {
expect(
await run(["recall", "q", flag, first, flag, second, "--json"], {}),
).toBe(2);
} finally {
streams.restore();
}
expect(streams.stdout()).toBe("");
},
);
it("refuses malformed success bodies instead of leaking nested values", async () => {
transport.post.mockResolvedValue({
@@ -381,6 +384,101 @@ describe("unified context search", () => {
expect(streams.stdout()).not.toContain("must-not-print");
});
it("counts characters as code points so an emoji hit stays grounded", async () => {
// Core reports used_characters with Python's len(), which counts code
// points: this text is 10 characters there and 12 UTF-16 units here.
const text = "消费保 📍 扩展 📡";
expect(text.length).toBe(12);
expect(Array.from(text).length).toBe(10);
transport.post.mockResolvedValue({
status: 200,
data: {
...RESPONSE,
paths: {
...RESPONSE.paths,
memory: {
...RESPONSE.paths.memory,
status: "empty",
returned_count: 0,
},
},
items: [
{
...(RESPONSE.items[0] as Record<string, unknown>),
messages: [
{
...(
RESPONSE.items[0] as {
messages: Array<Record<string, unknown>>;
}
).messages[0],
text,
},
],
},
],
used_characters: 10,
},
});
const streams = captureStreams();
try {
expect(await run(["recall", "test", "--json"])).toBe(0);
} finally {
streams.restore();
}
expect(JSON.parse(streams.stdout())).toMatchObject({
state: "grounded",
used_characters: 10,
items: [{ kind: "raw", messages: [{ text }] }],
});
});
it("still rejects a response whose character total does not match", async () => {
// The same text counted as UTF-16 units is exactly what Core does not mean,
// so the cross-check must keep failing instead of accepting both totals.
const text = "消费保 📍 扩展 📡";
transport.post.mockResolvedValue({
status: 200,
data: {
...RESPONSE,
paths: {
...RESPONSE.paths,
memory: {
...RESPONSE.paths.memory,
status: "empty",
returned_count: 0,
},
},
items: [
{
...(RESPONSE.items[0] as Record<string, unknown>),
messages: [
{
...(
RESPONSE.items[0] as {
messages: Array<Record<string, unknown>>;
}
).messages[0],
text,
},
],
},
],
used_characters: 12,
},
});
const streams = captureStreams();
try {
expect(await run(["recall", "test", "--json"])).toBe(1);
} finally {
streams.restore();
}
expect(JSON.parse(streams.stdout())).toMatchObject({
state: "invalid",
code: "response_invalid",
});
});
it("reports endpoint denial without exposing the response body", async () => {
transport.post.mockResolvedValue({
status: 403,
+13 -2
View File
@@ -181,9 +181,9 @@ function validateContextResponse(
items.push(item);
characters +=
item.kind === "memory"
? String(item.content).length
? characterCount(item.content)
: (item.messages as Array<Record<string, unknown>>).reduce(
(sum, message) => sum + String(message.text).length,
(sum, message) => sum + characterCount(message.text),
0,
);
}
@@ -330,6 +330,17 @@ function record(value: unknown): Record<string, unknown> | null {
: null;
}
/**
* Count characters the way Core does. Python's len() counts Unicode code
* points; String.prototype.length counts UTF-16 code units, so an emoji outside
* the BMP is one character to Core and two here. Comparing the two totals made
* every response containing an emoji fail the used_characters cross-check and
* surface as response_invalid.
*/
function characterCount(value: unknown): number {
return Array.from(String(value)).length;
}
function boundedText(
value: unknown,
maximum: number,
+79 -30
View File
@@ -1,5 +1,5 @@
import axios from "axios";
import { classifyModelRemove } from "@one-memory/operations";
import axios from "axios";
/**
* Only paths proven side-effect free by reading the Core handlers belong here.
@@ -8,7 +8,6 @@ import { classifyModelRemove } from "@one-memory/operations";
*/
const READ_ONLY_PATHS = [
"/api/v1/call-events",
"/api/v1/model-content-permission",
"/api/v1/home-summary",
"/api/v1/import-jobs",
"/api/v1/live-sync",
@@ -56,7 +55,10 @@ export function createUiSessionRequest(attachment: {
return { state: "ok" as const, data: response.data };
}
if (response.status === 503) {
return { state: "unavailable" as const, code: "ui_web_assets_unavailable" };
return {
state: "unavailable" as const,
code: "ui_web_assets_unavailable",
};
}
return { state: "unavailable" as const, code: `http_${response.status}` };
};
@@ -395,15 +397,30 @@ export async function readMemoryWrite(
}
}
export async function readScanRoots(
export interface OnboardingState {
status: string;
selected_source_ids: string[];
started_at: string | null;
completed_at: string | null;
}
/** Send onboarding back to its first-run state.
*
* Returns null when the endpoint is not there, so an older runtime degrades to
* "not available" instead of failing the command. Only the completion flag
* moves: memories, sources and settings live in other tables, which is what
* makes this the honest way to see the cold-start screens again.
*/
export async function resetOnboarding(
attachment: RuntimeAttachmentLike,
): Promise<{ schema_version: number; revision: number; roots: unknown[] } | null> {
): Promise<OnboardingState | null> {
try {
const response = await axios.get<{ schema_version: number; revision: number; roots: unknown[] }>(
`${baseUrlOf(attachment)}/api/v1/scan-roots`,
const response = await axios.post<OnboardingState>(
`${baseUrlOf(attachment)}/api/v1/onboarding/reset`,
undefined,
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 10_000,
timeout: 15_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
@@ -415,12 +432,19 @@ export async function readScanRoots(
}
}
export async function addScanRoot(
export async function readScanRoots(
attachment: RuntimeAttachmentLike,
body: { root: string; source?: string; recursive?: boolean },
): Promise<unknown | null> {
): Promise<{
schema_version: number;
revision: number;
roots: unknown[];
} | null> {
try {
const response = await axios.post<unknown>(`${baseUrlOf(attachment)}/api/v1/scan-roots`, body, {
const response = await axios.get<{
schema_version: number;
revision: number;
roots: unknown[];
}>(`${baseUrlOf(attachment)}/api/v1/scan-roots`, {
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 10_000,
proxy: false,
@@ -428,6 +452,28 @@ export async function addScanRoot(
maxContentLength: MAX_RESPONSE_BYTES,
});
return response.data;
} catch {
return null;
}
}
export async function addScanRoot(
attachment: RuntimeAttachmentLike,
body: { root: string; source?: string; recursive?: boolean },
): Promise<unknown | null> {
try {
const response = await axios.post<unknown>(
`${baseUrlOf(attachment)}/api/v1/scan-roots`,
body,
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 10_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
},
);
return response.data;
} catch (error) {
return { _error: error instanceof Error ? error.message : String(error) };
}
@@ -438,14 +484,17 @@ export async function removeScanRoot(
body: { root: string },
): Promise<unknown | null> {
try {
const response = await axios.delete<unknown>(`${baseUrlOf(attachment)}/api/v1/scan-roots`, {
headers: { Authorization: `Bearer ${attachment.apiToken}` },
data: body,
timeout: 10_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
});
const response = await axios.delete<unknown>(
`${baseUrlOf(attachment)}/api/v1/scan-roots`,
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
data: body,
timeout: 10_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
},
);
return response.data;
} catch (error) {
return { _error: error instanceof Error ? error.message : String(error) };
@@ -456,16 +505,16 @@ export async function readAdapters(
attachment: RuntimeAttachmentLike,
): Promise<{ schema_version: number; adapters: unknown[] } | null> {
try {
const response = await axios.get<{ schema_version: number; adapters: unknown[] }>(
`${baseUrlOf(attachment)}/api/v1/adapters`,
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 10_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
},
);
const response = await axios.get<{
schema_version: number;
adapters: unknown[];
}>(`${baseUrlOf(attachment)}/api/v1/adapters`, {
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 10_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
});
return response.data;
} catch {
return null;
-2
View File
@@ -30,7 +30,6 @@ const IMPORT_FILE =
const MEMORY_NOTES_PATH = "/api/v1/memory-notes";
const MEMORY_NOTE_PROCESS =
/^\/api\/v1\/memory-notes\/[A-Za-z0-9][\w.:-]{0,127}\/process$/;
const MODEL_CONTENT_PERMISSION_PATH = "/api/v1/model-content-permission";
const EXTRACTION_SETTINGS_PATH = "/api/v1/extraction-settings";
const GRAPH_WIDTH_PATH = "/api/v1/graph-width";
const MANUAL_EXTRACT_PATH = "/api/v1/memory-processing/manual-extract";
@@ -134,7 +133,6 @@ export function createCoreOperations(attachment: {
!IMPORT_FILE_COMMIT.test(path) &&
path !== MEMORY_NOTES_PATH &&
!MEMORY_NOTE_PROCESS.test(path) &&
path !== MODEL_CONTENT_PERMISSION_PATH &&
path !== GRAPH_WIDTH_PATH &&
path !== MANUAL_EXTRACT_PATH &&
path !== ONBOARDING_COMPLETE_PATH &&
+28 -9
View File
@@ -1,4 +1,4 @@
import { mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
@@ -23,14 +23,22 @@ describe("explicit data deletion", () => {
"one-memory.sqlite3": "db",
"runtime-service.json": "{}",
});
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: false });
const plan = await planDataDeletion({
dataDir,
home,
runtimeRunning: false,
});
expect(plan.state).toBe("preview");
expect(plan.entries).toContain(path.join(dataDir, "one-memory.sqlite3"));
});
it("refuses while the Runtime is still running", async () => {
const { home, dataDir } = await homeWith();
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: true });
const plan = await planDataDeletion({
dataDir,
home,
runtimeRunning: true,
});
expect(plan).toMatchObject({
state: "blocked",
code: "runtime_still_running",
@@ -66,16 +74,23 @@ describe("explicit data deletion", () => {
home,
runtimeRunning: false,
});
expect(plan).toMatchObject({ state: "blocked", code: "data_dir_refused" });
expect(plan).toMatchObject({
state: "blocked",
code: "data_dir_refused",
});
}
});
it("requires the second confirmation to name the exact directory", async () => {
const { home, dataDir } = await homeWith();
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: false });
await expect(applyDataDeletion(plan, path.join(home, "other"))).rejects.toThrow(
"data_deletion_confirmation_mismatch",
);
const plan = await planDataDeletion({
dataDir,
home,
runtimeRunning: false,
});
await expect(
applyDataDeletion(plan, path.join(home, "other")),
).rejects.toThrow("data_deletion_confirmation_mismatch");
// The data survived a mismatched confirmation.
await expect(
stat(path.join(dataDir, "one-memory.sqlite3")),
@@ -84,7 +99,11 @@ describe("explicit data deletion", () => {
it("deletes an owned Home only when both confirmations match", async () => {
const { home, dataDir } = await homeWith({ "one-memory.sqlite3": "db" });
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: false });
const plan = await planDataDeletion({
dataDir,
home,
runtimeRunning: false,
});
const receipt = await applyDataDeletion(plan, dataDir);
expect(receipt.state).toBe("deleted");
await expect(stat(dataDir)).rejects.toMatchObject({ code: "ENOENT" });
+2 -1
View File
@@ -68,7 +68,8 @@ export function renderDoctorText(document: DoctorDocument): string {
return [
`MemHub 检查:${doctorState(document.state)}`,
...document.checks.map(
(check) => `${symbol(check.state)} ${labels[check.id]}:${checkState(check.state)}`,
(check) =>
`${symbol(check.state)} ${labels[check.id]}:${checkState(check.state)}`,
),
...(document.state === "healthy"
? []
+1 -5
View File
@@ -28,11 +28,7 @@ export function resolveEntryDiagnostics(input: {
// comparison would misread that as isolated and silently drop diagnostics.
const home = resolve(input.home);
const accountHome = resolve(input.accountHome);
if (
home === undefined ||
accountHome === undefined ||
home === accountHome
) {
if (home === undefined || accountHome === undefined || home === accountHome) {
return { kind: "log" };
}
// Isolated HOME with no explicit data dir: the fallback path would write
+1 -1
View File
@@ -9,8 +9,8 @@ import {
resolveRuntimeCliConfig,
} from "@one-memory/runtime-control";
import { run } from "./cli.js";
import { resolveDiagnosticsDataDir } from "./install-receipt.js";
import { resolveEntryDiagnostics } from "./entry-diagnostics.js";
import { resolveDiagnosticsDataDir } from "./install-receipt.js";
function accountHomeDirectory(): string | undefined {
try {
+4 -6
View File
@@ -1,12 +1,12 @@
import { createHash, randomUUID } from "node:crypto";
import type { ImportJob, ModelProfile } from "@one-memory/contracts";
import {
ModelProfileInputError,
SourceImportInputError,
buildModelProfileCreateRequest,
buildSourceImportRequest,
ModelProfileInputError,
parseModelProfileReceipt,
SourceImportInputError,
} from "@one-memory/operations";
import type { ImportJob, ModelProfile } from "@one-memory/contracts";
import type { CoreReader, ReadOutcome } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import {
@@ -117,9 +117,7 @@ function classifySourceImport(
product: PRODUCT,
state: "rejected",
operation_id: operationId,
code: onlyBusy
? "source_import_all_busy"
: "source_import_all_rejected",
code: onlyBusy ? "source_import_all_busy" : "source_import_all_rejected",
receipt,
};
}
+3 -1
View File
@@ -266,7 +266,9 @@ export function renderHostText(document: HostFlowDocument): string {
}
if (review.length > 12) lines.push(` … 另有 ${review.length - 12} 处`);
lines.push("");
lines.push("这些不是我们安装的内容,卸载不会替你删除;需自行处理后才能卸载。");
lines.push(
"这些不是我们安装的内容,卸载不会替你删除;需自行处理后才能卸载。",
);
}
}
return `${lines.join("\n")}\n`;
+5 -12
View File
@@ -259,7 +259,7 @@ describe("A2 dual-mode init", () => {
expect(streams.value()).toContain("处理:已开启");
});
it("continues the TTY wizard through model probe and content permission", async () => {
it("continues the TTY wizard through model setup and probe", async () => {
const profileId = "22222222-2222-4222-8222-222222222222";
const profile = {
profile_id: profileId,
@@ -361,12 +361,6 @@ describe("A2 dual-mode init", () => {
};
}
if (url.endsWith("/activate")) return { status: 200, data: profile };
if (url === "/api/v1/model-content-permission") {
return {
status: 200,
data: { allowed: body.allowed },
};
}
if (url === "/api/v1/onboarding/complete") {
return {
status: 200,
@@ -383,7 +377,7 @@ describe("A2 dual-mode init", () => {
},
);
const prompt = extendedPrompter(
["", "", "", "", "", "", "", "", "n"],
["", "", "", "", "", "", "", "n"],
"sk-never-print-this",
);
const streams = captureStdout();
@@ -402,7 +396,6 @@ describe("A2 dual-mode init", () => {
streams.restore();
}
expect(streams.value()).toContain("已接入来源:codex");
expect(prompt.written.join("\n")).toContain("模型内容外发:已允许");
expect(streams.value()).toContain("首次设置完成。");
expect(`${streams.value()}\n${prompt.written.join("\n")}`).not.toContain(
"sk-never-print-this",
@@ -412,10 +405,10 @@ describe("A2 dual-mode init", () => {
expect.objectContaining({ api_key: "sk-never-print-this" }),
undefined,
);
expect(transport.post).toHaveBeenCalledWith(
expect(transport.post).not.toHaveBeenCalledWith(
"/api/v1/model-content-permission",
{ allowed: true },
undefined,
expect.anything(),
expect.anything(),
);
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/onboarding/complete",
+3 -1
View File
@@ -7,7 +7,9 @@ import {
type WizardPrompter,
} from "./init-wizard.js";
function scripted(lines: string[]): WizardPrompter & { asked: string[]; wrote: string[] } {
function scripted(
lines: string[],
): WizardPrompter & { asked: string[]; wrote: string[] } {
let index = 0;
const asked: string[] = [];
const wrote: string[] = [];
+1 -2
View File
@@ -99,8 +99,7 @@ export async function runInitWizard(
"开启处理?开启后会处理历史未完成会话并持续接收新会话;输入 n 保持冻结。[Y/n]: ",
);
const processing = parseYesNo(processingAnswer, true);
if (processing === null)
return { state: "invalid", code: "invalid_answer" };
if (processing === null) return { state: "invalid", code: "invalid_answer" };
return {
state: "answered",
+5 -6
View File
@@ -1,15 +1,15 @@
import {
mkdir,
mkdtemp,
readFile,
readdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveEntryDiagnostics } from "./entry-diagnostics.js";
import {
defaultInstallReceiptPath,
readInstallReceipt,
@@ -18,7 +18,6 @@ import {
resolveInstalledDataDir,
writeInstallReceipt,
} from "./install-receipt.js";
import { resolveEntryDiagnostics } from "./entry-diagnostics.js";
async function root(): Promise<string> {
return mkdtemp(path.join(tmpdir(), "memhub-install-receipt-"));
@@ -230,9 +229,9 @@ describe("managed install receipt", () => {
}),
).toBe("/tmp/env data");
// Receipt beats default.
expect(
await resolveDiagnosticsDataDir(["install"], { HOME: home }),
).toBe(path.join(home, "receipt data"));
expect(await resolveDiagnosticsDataDir(["install"], { HOME: home })).toBe(
path.join(home, "receipt data"),
);
// Invalid explicit flag must not silently fall back to the receipt.
expect(
await resolveDiagnosticsDataDir(
@@ -132,12 +132,6 @@ function clients(options: { failedProbes?: number } = {}) {
if (path.endsWith("/activate")) {
return { state: "committed" as const, data: profile(true) };
}
if (path === "/api/v1/model-content-permission") {
return {
state: "committed" as const,
data: { allowed: body.allowed },
};
}
return {
state: "rejected" as const,
code: "unexpected_path",
@@ -160,8 +154,8 @@ function clients(options: { failedProbes?: number } = {}) {
}
describe("interactive model setup", () => {
it("uses App defaults, keeps the key out of output, and allows model content", async () => {
const prompt = prompter(["", "", "", "", "", ""]);
it("uses App defaults and keeps the key out of output", async () => {
const prompt = prompter(["", "", ""]);
const api = clients();
const result = await runInteractiveModelSetup(prompt.value, api, {
@@ -170,10 +164,7 @@ describe("interactive model setup", () => {
installHosts: false,
});
expect(result).toMatchObject({
state: "configured",
model_content_allowed: true,
});
expect(result).toMatchObject({ state: "configured" });
expect(api.post).toHaveBeenCalledWith(
"/api/v1/model-profiles",
expect.objectContaining({
@@ -184,9 +175,9 @@ describe("interactive model setup", () => {
embedding_provider_kind: "local_onnx",
}),
);
expect(api.post).toHaveBeenCalledWith(
expect(api.post).not.toHaveBeenCalledWith(
"/api/v1/model-content-permission",
{ allowed: true },
expect.anything(),
);
expect(prompt.written.join("\n")).not.toContain("sk-do-not-print");
});
@@ -206,7 +197,7 @@ describe("interactive model setup", () => {
});
it("removes an unverified profile before skipping after a failed probe", async () => {
const prompt = prompter(["", "", "", "n", "0"]);
const prompt = prompter(["", "", "", "0"]);
const api = clients();
api.post.mockImplementation(
async (path: string, body: Record<string, unknown>) => {
@@ -281,7 +272,7 @@ describe("interactive model setup", () => {
});
it("cleans a failed probe and lets the user re-enter the model settings", async () => {
const prompt = prompter(["", "", "", "", "", "", "", "", "", "", ""]);
const prompt = prompter(["", "", "", "", "", "", ""]);
const api = clients({ failedProbes: 1 });
const result = await runInteractiveModelSetup(prompt.value, api, {
@@ -290,10 +281,7 @@ describe("interactive model setup", () => {
installHosts: false,
});
expect(result).toMatchObject({
state: "configured",
model_content_allowed: true,
});
expect(result).toMatchObject({ state: "configured" });
expect(api.remove).toHaveBeenCalledTimes(1);
expect(
api.post.mock.calls.filter(([path]) => path === "/api/v1/model-profiles"),
@@ -306,7 +294,7 @@ describe("interactive model setup", () => {
const prompt = prompter(["", "", ""]);
prompt.value.ask = async () => {
asked += 1;
if (asked === 4) throw new Error("interrupted");
if (asked === 3) throw new Error("interrupted");
return "";
};
const api = clients();
@@ -323,7 +311,7 @@ describe("interactive model setup", () => {
});
it("installs selected detected hosts through preview and fingerprint apply", async () => {
const prompt = prompter(["", "", "", "n", "", ""]);
const prompt = prompter(["", "", "", "", ""]);
const api = clients();
const read = vi
.fn()
@@ -361,7 +349,6 @@ describe("interactive model setup", () => {
expect(result).toMatchObject({
state: "configured",
model_content_allowed: false,
installed_hosts: ["codex"],
});
expect(read).toHaveBeenNthCalledWith(
@@ -374,7 +361,7 @@ describe("interactive model setup", () => {
});
it("lists and installs only hosts that are actually present", async () => {
const prompt = prompter(["", "", "", "n", "", ""]);
const prompt = prompter(["", "", "", "", ""]);
const api = clients();
const read = vi
.fn()
@@ -421,7 +408,7 @@ describe("interactive model setup", () => {
});
it("completes the wizard and keeps onboarding reachable when one host install fails", async () => {
const prompt = prompter(["", "", "", "n", "", ""]);
const prompt = prompter(["", "", "", "", ""]);
const api = clients();
const read = vi
.fn()
@@ -470,7 +457,6 @@ describe("interactive model setup", () => {
// calling the install a failure.
expect(result).toMatchObject({
state: "configured",
model_content_allowed: false,
installed_hosts: ["codex"],
});
const summary = prompt.written.join("\n");
@@ -480,7 +466,7 @@ describe("interactive model setup", () => {
});
it("tells the user to install App-only hosts in the App instead of calling them failed", async () => {
const prompt = prompter(["", "", "", "n", "", ""]);
const prompt = prompter(["", "", "", "", ""]);
const api = clients();
const read = vi
.fn()
@@ -507,7 +493,6 @@ describe("interactive model setup", () => {
// only the App can install, so nothing counts as "installed".
expect(result).toMatchObject({
state: "configured",
model_content_allowed: false,
installed_hosts: [],
});
const summary = prompt.written.join("\n");
@@ -16,7 +16,6 @@ import {
executeModelProfileActivate,
planModelProfileActivate,
} from "./model-write-flows.js";
import { setModelContentPermission } from "./model-content-permission-flow.js";
type ProviderPreset = {
id: string;
@@ -122,7 +121,6 @@ export type InteractiveModelSetupState =
| {
state: "configured";
profile: ModelProfile;
model_content_allowed: boolean;
installed_hosts: string[];
}
| { state: "skipped" }
@@ -289,15 +287,11 @@ async function installHosts(
function writeSummary(
prompter: WizardPrompter,
profile: ModelProfile,
modelContentAllowed: boolean,
installedHosts: readonly string[],
failedHosts: readonly { hostId: string; label: string }[],
): void {
prompter.write("配置摘要:");
prompter.write(` 模型:${profile.name} / ${profile.model}`);
prompter.write(
` 模型内容外发:${modelContentAllowed ? "已允许" : "已禁止"}`,
);
prompter.write(
` 应用接入:${installedHosts.length > 0 ? installedHosts.join("、") : "已跳过"}`,
);
@@ -353,14 +347,6 @@ export async function runInteractiveModelSetup(
prompter.write("未填写 API Key。输入 back 返回,或重新选择模型服务。");
continue;
}
const permissionAnswer = (
await prompter.ask("允许将对话内容发送给模型以整理记忆?[Y/n]: ")
)
.trim()
.toLowerCase();
const modelContentAllowed = !["n", "no", "否", "0"].includes(
permissionAnswer,
);
const hostApi = options.hostApi ?? { list: listHosts, read: readHost };
const selectedHosts = options.installHosts
? await chooseDetectedHosts(prompter, options.hostHome, hostApi)
@@ -452,13 +438,6 @@ export async function runInteractiveModelSetup(
return { state: "failed", code: "model_activate_failed" };
}
const permission = await setModelContentPermission(
clients.operations,
modelContentAllowed,
);
if (permission.state !== "ok") {
return { state: "failed", code: "model_content_permission_set_failed" };
}
const { installed, failed } = await installHosts(
options.dataDir,
selectedHosts,
@@ -467,7 +446,6 @@ export async function runInteractiveModelSetup(
writeSummary(
prompter,
profile,
modelContentAllowed,
installed,
failed.map((hostId) => ({
hostId,
@@ -477,7 +455,6 @@ export async function runInteractiveModelSetup(
return {
state: "configured",
profile,
model_content_allowed: modelContentAllowed,
installed_hosts: installed,
};
}
+4 -1
View File
@@ -17,6 +17,7 @@ vi.mock("axios", () => ({
default: { create: vi.fn(() => transport), get: vi.fn() },
}));
import type { CoreOperations } from "./core-operations.js";
import { createCoreOperations } from "./core-operations.js";
import {
executeJobAction,
@@ -25,7 +26,6 @@ import {
planJobAction,
planJobStart,
} from "./job-flow.js";
import type { CoreOperations } from "./core-operations.js";
function operations(
post: (path: string, body: Record<string, unknown>) => Promise<unknown>,
@@ -180,5 +180,8 @@ describe("import-job write allow-list", () => {
await expect(
operations.post("/api/v1/import-jobs/job-1/delete", {}),
).rejects.toThrow("core_operation_path_not_allowed");
await expect(
operations.post("/api/v1/model-content-permission", { allowed: true }),
).rejects.toThrow("core_operation_path_not_allowed");
});
});
+1 -3
View File
@@ -145,9 +145,7 @@ function jobMutationResult(
export function jobExitCode(document: JobFlowDocument): number {
if (document.state === "preview" || document.state === "committed") return 0;
if (document.state === "recovered") return 0;
return document.state === "rejected" || document.state === "unknown"
? 1
: 2;
return document.state === "rejected" || document.state === "unknown" ? 1 : 2;
}
function invalid(code: string): JobFlowDocument {
+2 -8
View File
@@ -105,10 +105,7 @@ describe("memhub jobs", () => {
const output = captureStdout();
let code: number;
try {
code = await run(
["jobs", "start", "--source", "codex", "--json"],
{},
);
code = await run(["jobs", "start", "--source", "codex", "--json"], {});
} finally {
output.restore();
}
@@ -154,10 +151,7 @@ describe("memhub jobs", () => {
const output = captureStdout();
let code: number;
try {
code = await run(
["jobs", action, "--job", "job-1", "--json"],
{},
);
code = await run(["jobs", action, "--job", "job-1", "--json"], {});
} finally {
output.restore();
}
+37 -11
View File
@@ -1,7 +1,10 @@
import type { DiagnosticEventV1 } from "@one-memory/runtime-control";
import { diagnosticLogFiles, readDiagnosticEvents } from "@one-memory/runtime-control";
import { lstat } from "node:fs/promises";
import path from "node:path";
import type { DiagnosticEventV1 } from "@one-memory/runtime-control";
import {
diagnosticLogFiles,
readDiagnosticEvents,
} from "@one-memory/runtime-control";
export interface LogQuery {
since?: Date;
@@ -160,10 +163,21 @@ export async function readLogReport(
const logsDirectory = path.join(path.resolve(dataDir), "logs");
const info = await lstat(logsDirectory).catch(() => null);
if (info !== null && (!info.isDirectory() || info.isSymbolicLink())) {
return { events: [], invalid_lines: 0, failure: "log_directory_unreadable" };
return {
events: [],
invalid_lines: 0,
failure: "log_directory_unreadable",
};
}
if (info !== null && (await diagnosticLogFiles(dataDir).catch(() => null)) === null) {
return { events: [], invalid_lines: 0, failure: "log_directory_unreadable" };
if (
info !== null &&
(await diagnosticLogFiles(dataDir).catch(() => null)) === null
) {
return {
events: [],
invalid_lines: 0,
failure: "log_directory_unreadable",
};
}
const explicit =
query.since ||
@@ -239,15 +253,27 @@ export function redactDiagnosticEvent(
outcome: event.outcome,
session_id: event.session_id,
...(event.trace_id === undefined ? {} : { trace_id: event.trace_id }),
...(event.incident_id === undefined ? {} : { incident_id: event.incident_id }),
...(event.instance_id === undefined ? {} : { instance_id: event.instance_id }),
...(event.operation_id === undefined ? {} : { operation_id: event.operation_id }),
...(event.incident_id === undefined
? {}
: { incident_id: event.incident_id }),
...(event.instance_id === undefined
? {}
: { instance_id: event.instance_id }),
...(event.operation_id === undefined
? {}
: { operation_id: event.operation_id }),
...(event.request_id === undefined ? {} : { request_id: event.request_id }),
...(event.job_id === undefined ? {} : { job_id: event.job_id }),
...(event.task_id === undefined ? {} : { task_id: event.task_id }),
...(event.duration_ms === undefined ? {} : { duration_ms: event.duration_ms }),
...(event.state_before === undefined ? {} : { state_before: event.state_before }),
...(event.state_after === undefined ? {} : { state_after: event.state_after }),
...(event.duration_ms === undefined
? {}
: { duration_ms: event.duration_ms }),
...(event.state_before === undefined
? {}
: { state_before: event.state_before }),
...(event.state_after === undefined
? {}
: { state_after: event.state_after }),
...(event.error_code === undefined ? {} : { error_code: event.error_code }),
...(event.error_type === undefined ? {} : { error_type: event.error_type }),
...(event.safe_stack === undefined
+12 -5
View File
@@ -11,9 +11,8 @@ import {
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { CliLinkPlan } from "@one-memory/runtime-control";
import { describe, expect, it } from "vitest";
import {
installManagedCliAlias,
installManagedCliLink,
@@ -107,7 +106,9 @@ describe("managed memhub CLI alias", () => {
await installManagedCliAlias(plan);
await expect(removeManagedCliAlias(plan)).resolves.toBe("removed");
await expect(lstat(plan.link_path)).rejects.toMatchObject({ code: "ENOENT" });
await expect(lstat(plan.link_path)).rejects.toMatchObject({
code: "ENOENT",
});
});
// C8 P1 (2026-09-29): `runtime-control.applyLocalUninstall` deletes
@@ -156,7 +157,10 @@ describe("managed memhub CLI alias", () => {
it("manages the one-memory companion when memhub is already present", async () => {
if (process.platform === "win32") return;
const f = await fixture();
const primary = { ...f.base, link_path: path.join(f.base.bin_directory, "memhub") };
const primary = {
...f.base,
link_path: path.join(f.base.bin_directory, "memhub"),
};
await mkdir(f.base.bin_directory, { recursive: true, mode: 0o700 });
await (await import("node:fs/promises")).chmod(f.base.bin_directory, 0o700);
await (await import("node:fs/promises")).symlink(
@@ -168,7 +172,10 @@ describe("managed memhub CLI alias", () => {
const plan = await planManagedCliLink(f.base, "one-memory");
// The companion does not exist yet, so it is ours to create; crucially it
// targets the compat name, not the package's `memhub`.
expect(plan).toMatchObject({ status: "create", link_path: f.base.link_path });
expect(plan).toMatchObject({
status: "create",
link_path: f.base.link_path,
});
// The package's memhub link is untouched.
expect(await readlink(primary.link_path)).toBe(f.target);
+9 -1
View File
@@ -1,4 +1,12 @@
import { access, chmod, lstat, mkdir, readlink, rm, symlink } from "node:fs/promises";
import {
access,
chmod,
lstat,
mkdir,
readlink,
rm,
symlink,
} from "node:fs/promises";
import path from "node:path";
import type { CliLinkPlan, CliLinkReceipt } from "@one-memory/runtime-control";
@@ -1,152 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { run } from "./cli.js";
const transport = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
}));
vi.mock("axios", () => ({
default: { create: vi.fn(() => transport), get: vi.fn() },
}));
const runtime = vi.hoisted(() => ({
inspectRuntimeStatus: vi.fn(),
discoverRuntimeAttachment: vi.fn(),
}));
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
...runtime,
}));
beforeEach(() => {
vi.clearAllMocks();
runtime.inspectRuntimeStatus.mockResolvedValue({
state: "running",
code: "runtime_verified",
instanceId: "instance-1",
host: "127.0.0.1",
port: 8765,
version: "1.2.3",
});
runtime.discoverRuntimeAttachment.mockResolvedValue({
instanceId: "instance-1",
apiToken: "synthetic-runtime-token",
host: "127.0.0.1",
port: 8765,
servicePid: 4242,
health: {
status: "ok",
service: "one-memory-core",
version: "1.2.3",
instance_id: "instance-1",
},
});
});
describe("model content permission CLI", () => {
it("documents only final-state permission and manual extraction commands", async () => {
const permission = await capturedRun(["help", "permission"]);
expect(permission.code).toBe(0);
expect(permission.stdout).toContain("memhub permission allow");
expect(permission.stdout).toContain("memhub permission deny");
expect(permission.stdout).not.toMatch(/\b(preview|confirm|process|revoke)\b/);
expect(permission.stdout).not.toContain("revision");
expect(permission.stdout).not.toContain("receipt");
const processing = await capturedRun(["help", "processing"]);
expect(processing.code).toBe(0);
expect(processing.stdout).toContain("memhub processing extract");
expect(processing.stdout).not.toMatch(/\b(plan|apply|receipt)\b/);
expect(processing.stdout).not.toContain("backlog");
});
it("reads the final state", async () => {
transport.get.mockResolvedValue({ status: 200, data: { allowed: true } });
const result = await capturedRun(["permission", "status", "--json"]);
expect(result.code).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
state: "ok",
allowed: true,
});
expect(transport.get).toHaveBeenCalledWith(
"/api/v1/model-content-permission",
undefined,
);
});
it.each([
["allow", true],
["deny", false],
] as const)("sets %s directly with only allowed", async (verb, allowed) => {
transport.post.mockResolvedValue({ status: 200, data: { allowed } });
const result = await capturedRun(["permission", verb, "--json"]);
expect(result.code).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({ state: "ok", allowed });
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/model-content-permission",
{ allowed },
undefined,
);
});
it.each(["preview", "confirm", "process", "revoke"])(
"rejects removed %s commands before runtime access",
async (verb) => {
const result = await capturedRun(["permission", verb, "--json"]);
expect(result.code).toBe(2);
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
expect(transport.get).not.toHaveBeenCalled();
expect(transport.post).not.toHaveBeenCalled();
},
);
it.each(["plan", "apply", "receipt"])(
"rejects removed processing %s commands before runtime access",
async (verb) => {
const result = await capturedRun(["processing", verb, "--json"]);
expect(result.code).toBe(2);
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
expect(transport.get).not.toHaveBeenCalled();
expect(transport.post).not.toHaveBeenCalled();
},
);
});
async function capturedRun(arguments_: string[]) {
let stdout = "";
let stderr = "";
const stdoutSpy = vi
.spyOn(process.stdout, "write")
.mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation((chunk) => {
stderr += String(chunk);
return true;
});
try {
return {
code: await run(arguments_, {
ONE_MEMORY_DATA_DIR: "/synthetic/data",
}),
stdout,
stderr,
};
} finally {
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
}
}
@@ -1,63 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { CoreReader } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import {
readModelContentPermission,
setModelContentPermission,
} from "./model-content-permission-flow.js";
describe("model content permission flow", () => {
it("reads the single final-state boolean", async () => {
const reader = vi.fn().mockResolvedValue({
state: "ok",
data: { allowed: true },
});
await expect(
readModelContentPermission(reader as CoreReader),
).resolves.toMatchObject({ state: "ok", allowed: true });
expect(reader).toHaveBeenCalledWith("/api/v1/model-content-permission");
});
it.each([
["allow", true],
["deny", false],
] as const)("sets %s directly without revision or receipt fields", async (_, allowed) => {
const post = vi.fn().mockResolvedValue({
state: "committed",
data: { allowed },
});
await expect(
setModelContentPermission({ post } as CoreOperations, allowed),
).resolves.toMatchObject({ state: "ok", allowed });
expect(post).toHaveBeenCalledWith("/api/v1/model-content-permission", {
allowed,
});
});
it("does not accept a malformed or mismatched success response", async () => {
const malformed = vi.fn().mockResolvedValue({
state: "committed",
data: { allowed: true, revision: 1 },
});
await expect(
setModelContentPermission({ post: malformed } as CoreOperations, true),
).resolves.toMatchObject({
state: "unknown",
code: "model_content_permission_response_invalid",
});
const mismatched = vi.fn().mockResolvedValue({
state: "committed",
data: { allowed: false },
});
await expect(
setModelContentPermission({ post: mismatched } as CoreOperations, true),
).resolves.toMatchObject({
state: "unknown",
code: "model_content_permission_state_mismatch",
});
});
});
@@ -1,93 +0,0 @@
import type { CoreReader } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import { PRODUCT } from "./version.js";
export type ModelContentPermissionDocument =
| {
schema_version: 1;
product: string;
state: "ok";
allowed: boolean;
}
| {
schema_version: 1;
product: string;
state: "rejected" | "unknown";
code: string;
};
export async function readModelContentPermission(
reader: CoreReader,
): Promise<ModelContentPermissionDocument> {
const outcome = await reader<unknown>("/api/v1/model-content-permission");
if (outcome.state !== "ok") {
return failure("unknown", outcome.code);
}
return permissionState(outcome.data, "model_content_permission_response_invalid");
}
export async function setModelContentPermission(
operations: CoreOperations,
allowed: boolean,
): Promise<ModelContentPermissionDocument> {
const outcome = await operations.post<unknown>(
"/api/v1/model-content-permission",
{ allowed },
);
if (outcome.state === "rejected") {
return failure("rejected", outcome.code);
}
if (outcome.state === "unknown") {
return failure("unknown", outcome.code);
}
const document = permissionState(
outcome.data,
"model_content_permission_response_invalid",
);
return document.state === "ok" && document.allowed !== allowed
? failure("unknown", "model_content_permission_state_mismatch")
: document;
}
export function modelContentPermissionExitCode(
document: ModelContentPermissionDocument,
): number {
return document.state === "ok" ? 0 : 1;
}
export function renderModelContentPermissionText(
document: ModelContentPermissionDocument,
): string {
if (document.state === "ok") {
return `模型内容外发:${document.allowed ? "允许" : "禁止"}\n`;
}
return `模型内容权限未更新:${document.state}(${document.code})\n`;
}
function permissionState(
value: unknown,
invalidCode: string,
): ModelContentPermissionDocument {
if (
value === null ||
typeof value !== "object" ||
Array.isArray(value) ||
Object.keys(value).length !== 1 ||
typeof (value as Record<string, unknown>).allowed !== "boolean"
) {
return failure("unknown", invalidCode);
}
return {
schema_version: 1,
product: PRODUCT,
state: "ok",
allowed: (value as { allowed: boolean }).allowed,
};
}
function failure(
state: "rejected" | "unknown",
code: string,
): ModelContentPermissionDocument {
return { schema_version: 1, product: PRODUCT, state, code };
}
+8 -5
View File
@@ -13,8 +13,8 @@
import { createHash, randomUUID } from "node:crypto";
import type { ModelProfile } from "@one-memory/contracts";
import {
ModelProfileInputError,
buildModelProfileUpdateRequest,
ModelProfileInputError,
parseModelProfileReceipt,
} from "@one-memory/operations";
import type { CoreOperations, CorePatchOperations } from "./core-operations.js";
@@ -78,7 +78,10 @@ function digestOf(value: unknown): string {
function preview(
plan:
| Omit<Extract<ModelWritePlan, { kind: "model_profile_activate" }>, "digest">
| Omit<
Extract<ModelWritePlan, { kind: "model_profile_activate" }>,
"digest"
>
| Omit<Extract<ModelWritePlan, { kind: "model_profile_edit" }>, "digest">,
): ModelWriteDocument {
return {
@@ -198,9 +201,9 @@ export async function executeModelProfileEdit(
let request: ReturnType<typeof buildModelProfileUpdateRequest>;
try {
request = buildModelProfileUpdateRequest(plan.profile_id, {
...plan.patch,
expected_revision: plan.expected_revision,
...(apiKey === undefined ? {} : { api_key: apiKey }),
...plan.patch,
expected_revision: plan.expected_revision,
...(apiKey === undefined ? {} : { api_key: apiKey }),
});
} catch (error) {
if (error instanceof ModelProfileInputError) {
+7 -12
View File
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir, homedir } from "node:os";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
@@ -137,8 +137,8 @@ describe("package matrix harness", () => {
};
expect(document.state).toBe("verified");
const results = document.commands.stopped_destructive.results;
// 13 destructive commands, each unknown/runtime_not_running with exit 3.
expect(Object.keys(results)).toHaveLength(13);
// 11 destructive commands, each unknown/runtime_not_running with exit 3.
expect(Object.keys(results)).toHaveLength(11);
for (const [name, result] of Object.entries(results)) {
expect(result, `${name} did not fail closed`).toEqual({
exit: 3,
@@ -162,7 +162,7 @@ describe("package matrix harness", () => {
const shim = [
"#!/usr/bin/env node",
"const a=process.argv.slice(2);",
"if(['calls','import-file','notes','permission','extraction','graph','models'].includes(a[0])||(a[0]==='processing'&&a[1]==='extract')){",
"if(['calls','import-file','notes','extraction','graph','models'].includes(a[0])||(a[0]==='processing'&&a[1]==='extract')){",
' process.stdout.write(\'{"schema_version":1,"product":"memhub","state":"committed","operation_id":"x","receipt":{}}\\n\');',
" process.exit(0);",
"}",
@@ -188,7 +188,7 @@ describe("package matrix harness", () => {
expect(failure?.state).toBe("failed");
expect(
failure?.failures.filter((item) => item.includes("did not fail closed")),
).toHaveLength(13);
).toHaveLength(11);
});
it("can require an expected bundle SHA and reports bundle metadata", {
@@ -280,9 +280,8 @@ describe("package matrix harness", () => {
});
it("rejects a catalog that declares the parent command but not the new verb", async () => {
// A bundle can still declare `permission`/`models` while missing
// `permission allow` or `models activate`. Names alone would pass it; the
// usage marker must not.
// A bundle can still declare `models` while missing `models activate`.
// Names alone would pass it; the usage marker must not.
const declared = [
"install",
"init",
@@ -292,7 +291,6 @@ describe("package matrix harness", () => {
"calls",
"import-file",
"notes",
"permission",
"extraction",
"graph",
"models",
@@ -342,9 +340,6 @@ describe("package matrix harness", () => {
},
};
const failures = harness.validateCatalog(catalog);
expect(failures).toContain(
'command "permission" is missing usage "memhub permission allow"',
);
expect(failures).toContain(
'command "models" is missing usage "memhub models activate"',
);
@@ -1,6 +1,6 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -151,9 +151,6 @@ describe("memhub short-command contract", () => {
"memhub import-file discard",
"memhub notes add",
"memhub notes process",
"memhub permission status",
"memhub permission allow",
"memhub permission deny",
"memhub models setup",
"memhub models activate",
"memhub models edit",
@@ -163,6 +160,9 @@ describe("memhub short-command contract", () => {
]) {
expect(flattened).toContain(shortForm);
}
expect(COMMANDS.some((command) => command.name === "permission")).toBe(
false,
);
});
it("ships a memhub bin entry pointing at a real bundle path", () => {
+50 -18
View File
@@ -103,26 +103,45 @@ describe("unknown flags are rejected", () => {
});
it("still accepts the documented verbless scan", () => {
expect(parseProductCommand("scan", ["--all", "--yes"])?.plan.all).toBe(true);
expect(parseProductCommand("scan", ["--source", "codex"])?.plan.sourceId).toBe(
"codex",
expect(parseProductCommand("scan", ["--all", "--yes"])?.plan.all).toBe(
true,
);
expect(
parseProductCommand("scan", ["--source", "codex"])?.plan.sourceId,
).toBe("codex");
});
it("rejects flags that the named verb does not consume", () => {
// These were accepted and then silently dropped. An operator who typed
// `path remove --source codex` would believe the source filter applied.
expect(
parseProductCommand("path", ["remove", "--root", "/tmp/x", "--source", "codex"]),
parseProductCommand("path", [
"remove",
"--root",
"/tmp/x",
"--source",
"codex",
]),
).toBeNull();
expect(
parseProductCommand("path", ["remove", "--root", "/tmp/x", "--no-recursive"]),
parseProductCommand("path", [
"remove",
"--root",
"/tmp/x",
"--no-recursive",
]),
).toBeNull();
expect(
parseProductCommand("path", ["add", "--root", "/tmp/x", "--full"]),
).toBeNull();
expect(
parseProductCommand("backup", ["create", "--target", "/tmp/b", "--source", "/tmp/s"]),
parseProductCommand("backup", [
"create",
"--target",
"/tmp/b",
"--source",
"/tmp/s",
]),
).toBeNull();
expect(
parseProductCommand("backup", [
@@ -163,9 +182,7 @@ describe("unknown flags are rejected", () => {
});
it("rejects repeated boolean flags instead of treating them as idempotent", () => {
expect(
parseProductCommand("scan", ["--all", "--all", "--yes"]),
).toBeNull();
expect(parseProductCommand("scan", ["--all", "--all", "--yes"])).toBeNull();
expect(
parseProductCommand("scan", ["--full", "--full", "--yes"]),
).toBeNull();
@@ -179,7 +196,13 @@ describe("unknown flags are rejected", () => {
]),
).toBeNull();
expect(
parseProductCommand("backup", ["create", "--target", "/tmp/a", "--yes", "--yes"]),
parseProductCommand("backup", [
"create",
"--target",
"/tmp/a",
"--yes",
"--yes",
]),
).toBeNull();
});
@@ -215,12 +238,12 @@ describe("unknown flags are rejected", () => {
]);
expect(parsed).not.toBeNull();
expect(parsed?.confirmPath).toBe("/tmp/backups/a.sqlite3");
expect(restoreConfirmationMatches("/tmp/backups/a.sqlite3", parsed?.confirmPath)).toBe(
true,
);
expect(restoreConfirmationMatches("/tmp/backups/a.sqlite3", undefined)).toBe(
false,
);
expect(
restoreConfirmationMatches("/tmp/backups/a.sqlite3", parsed?.confirmPath),
).toBe(true);
expect(
restoreConfirmationMatches("/tmp/backups/a.sqlite3", undefined),
).toBe(false);
expect(
restoreConfirmationMatches(
"/tmp/backups/a.sqlite3",
@@ -365,7 +388,11 @@ describe("path add and remove carry what the endpoint needs", () => {
});
it("distinguishes remove from add", () => {
const parsed = parseProductCommand("path", ["remove", "--root", "/tmp/scanme"]);
const parsed = parseProductCommand("path", [
"remove",
"--root",
"/tmp/scanme",
]);
if (parsed === null) {
throw new Error("path remove must parse");
}
@@ -375,7 +402,12 @@ describe("path add and remove carry what the endpoint needs", () => {
});
it("does not silently drop --yes", () => {
const parsed = parseProductCommand("path", ["add", "--root", "/tmp/a", "--yes"]);
const parsed = parseProductCommand("path", [
"add",
"--root",
"/tmp/a",
"--yes",
]);
expect(parsed?.confirmed).toBe(true);
// Without this, a typo'd `--yes` would fall through to a plan and the user
// would believe the change had been made.
+16 -14
View File
@@ -6,10 +6,7 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/** The app root (one level above this test's `src/`). */
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const bundle = path.join(root, "dist", "one-memory.cjs");
// The published npm package must be self-contained. The bundle inlines
@@ -20,9 +17,10 @@ const bundle = path.join(root, "dist", "one-memory.cjs");
describe("publish manifest is production-safe", () => {
it("declares no npm runtime dependency once dependencies are inlined", () => {
// `test` builds with tsup first, so a missing bundle is a real failure.
expect(existsSync(bundle), "run `pnpm --filter @one-memory/cli build`").toBe(
true,
);
expect(
existsSync(bundle),
"run `pnpm --filter @one-memory/cli build`",
).toBe(true);
const source = readFileSync(bundle, "utf8");
const external = [
...new Set(
@@ -37,21 +35,24 @@ describe("publish manifest is production-safe", () => {
),
];
// Every one of these must be a Node builtin, i.e. no npm package.
const npmDeps = external.filter(
(name) => !builtinModules.includes(name),
);
const npmDeps = external.filter((name) => !builtinModules.includes(name));
expect(npmDeps).toEqual([]);
});
it("strip-out rule keeps only what the bundle requires", () => {
expect(existsSync(bundle), "run `pnpm --filter @one-memory/cli build`").toBe(
true,
);
expect(
existsSync(bundle),
"run `pnpm --filter @one-memory/cli build`",
).toBe(true);
// Run the real generator, then inspect its output manifest.
execFileSync("node", ["scripts/prepare-publish-manifest.mjs"], {
cwd: root,
stdio: "pipe",
env: { ...process.env, MEMHUB_NPM_PACKAGE: "@example/memhub-cli" },
env: {
...process.env,
MEMHUB_NPM_PACKAGE: "@example/memhub-cli",
MEMHUB_NPM_VERSION: "1.0.5",
},
});
const published = JSON.parse(
readFileSync(path.join(root, "dist-publish", "package.json"), "utf8"),
@@ -59,6 +60,7 @@ describe("publish manifest is production-safe", () => {
expect(published.dependencies).toBeUndefined();
expect(published.private).toBe(false);
expect(published.name).toBe("@example/memhub-cli");
expect(published.version).toBe("1.0.5");
expect(published.bin).toMatchObject({
memhub: "dist/one-memory.cjs",
"memhub-cli": "dist/one-memory.cjs",
@@ -840,6 +840,7 @@ describe("CLI-21 memory reads", () => {
expect(isReadOnlyPath("/api/v1/memories/graph")).toBe(false);
expect(isReadOnlyPath("/api/v1/memories/ask")).toBe(false);
expect(isReadOnlyPath("/api/v1/memories/m1")).toBe(true);
expect(isReadOnlyPath("/api/v1/model-content-permission")).toBe(false);
expect(
isReadOnlyPath(
"/api/v1/memory-mutations/123e4567-e89b-12d3-a456-426614174000",
@@ -49,9 +49,9 @@ describe("runtime service lock guard", () => {
JSON.stringify({ pid: 4321 }),
{ mode: 0o600 },
);
await expect(
inspectRuntimeServiceLock(dataDir, () => false),
).resolves.toBe("clear");
await expect(inspectRuntimeServiceLock(dataDir, () => false)).resolves.toBe(
"clear",
);
});
it("fails closed on a malformed lease", async () => {
+46 -12
View File
@@ -1,15 +1,15 @@
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
type CommandResult,
inspectLaunchdService,
restartManagedService,
type ServiceCommandRunner,
startManagedService,
stopManagedService,
type CommandResult,
type ServiceCommandRunner,
} from "./service-session.js";
const SERVICE_ID = "com.tencent.onememory.runtime";
@@ -35,7 +35,11 @@ describe("service-aware launchd session", () => {
it("reports running with the launchd pid and plist", async () => {
const run = vi.fn<ServiceCommandRunner>(async () => running(84237));
await expect(
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
inspectLaunchdService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toEqual({
state: "running",
pid: 84237,
@@ -46,7 +50,11 @@ describe("service-aware launchd session", () => {
it("treats 'could not find service' as absent", async () => {
const run = vi.fn<ServiceCommandRunner>(async () => absent());
await expect(
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
inspectLaunchdService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toEqual({ state: "absent" });
});
@@ -57,7 +65,11 @@ describe("service-aware launchd session", () => {
stderr: "Input/output error",
}));
await expect(
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
inspectLaunchdService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toEqual({ state: "unknown", code: "launchd_inspection_failed" });
});
@@ -146,13 +158,21 @@ describe("service-aware launchd session", () => {
if (command[1] === "print") {
printed += 1;
return printed === 1
? { code: 0, stdout: `path = /tmp/x.plist\nstate = not running\n`, stderr: "" }
? {
code: 0,
stdout: `path = /tmp/x.plist\nstate = not running\n`,
stderr: "",
}
: running(84237);
}
return running(84237);
});
await expect(
startManagedService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
startManagedService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toBe("started");
expect(calls).toContainEqual([
"launchctl",
@@ -169,7 +189,11 @@ describe("service-aware launchd session", () => {
return running(84237);
});
await expect(
startManagedService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
startManagedService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toBe("started");
// Already running -> no kickstart needed.
expect(calls.some((c) => c[1] === "kickstart")).toBe(false);
@@ -214,7 +238,11 @@ describe("service-aware launchd session", () => {
stderr: "",
}));
await expect(
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
inspectLaunchdService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toEqual({ state: "loaded", plistPath: "/tmp/x.plist" });
});
@@ -225,7 +253,11 @@ describe("service-aware launchd session", () => {
return running(101);
});
await expect(
restartManagedService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
restartManagedService({
serviceId: SERVICE_ID,
uid: UID,
runCaptured: run,
}),
).resolves.toBe("restarted");
expect(calls).toContainEqual([
"launchctl",
@@ -286,7 +318,9 @@ describe("service-aware launchd session", () => {
stdout: JSON.stringify({
Label: SERVICE_ID,
ProgramArguments: ["/tmp/node", "/tmp/runtime.cjs", "serve"],
EnvironmentVariables: { ONE_MEMORY_DATA_DIR: "/tmp/this-install/data" },
EnvironmentVariables: {
ONE_MEMORY_DATA_DIR: "/tmp/this-install/data",
},
}),
stderr: "",
};
+9 -5
View File
@@ -49,7 +49,10 @@ function launchdDomain(uid: number): string {
/** Deterministic location of the managed LaunchAgent plist. Needed because
* after `bootout` launchd no longer reports a path, yet `start` must be able to
* bootstrap the very same service back. */
export function defaultLaunchAgentPath(home: string, serviceId: string): string {
export function defaultLaunchAgentPath(
home: string,
serviceId: string,
): string {
return path.posix.join(home, "Library", "LaunchAgents", `${serviceId}.plist`);
}
@@ -162,7 +165,10 @@ export async function stopManagedService(options: {
? (["launchctl", "bootout", domain, before.plistPath] as const)
: (["launchctl", "bootout", `${domain}/${options.serviceId}`] as const);
const result = await options.runCaptured([...target]);
if (result.code !== 0 && !/could not find service|service not found/i.test(result.stderr)) {
if (
result.code !== 0 &&
!/could not find service|service not found/i.test(result.stderr)
) {
throw new Error("launchd_bootout_failed");
}
// "Stopped" must mean the label stays gone, not that we saw it gone once.
@@ -295,9 +301,7 @@ export async function runLaunchctlCaptured(
stderr += String(chunk);
});
child.once("error", () => reject(new Error("launchd_command_failed")));
child.once("close", (code) =>
resolve({ code: code ?? 1, stdout, stderr }),
);
child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
});
}
+5 -1
View File
@@ -3,7 +3,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { applyShellPath, planShellPath, removeShellPath } from "./shell-path.js";
import {
applyShellPath,
planShellPath,
removeShellPath,
} from "./shell-path.js";
async function home(): Promise<string> {
return mkdtemp(path.join(tmpdir(), "memhub-shell-path-"));
+8 -12
View File
@@ -72,9 +72,7 @@ function blockFor(binDirectory: string): string {
* original file byte-for-byte regardless of the original's trailing newline. */
function ownedBlock(existing: string | null, binDirectory: string): string {
const separator =
existing === null || existing === "" || existing.endsWith("\n")
? ""
: "\n";
existing === null || existing === "" || existing.endsWith("\n") ? "" : "\n";
return `${separator}${blockFor(binDirectory)}`;
}
@@ -104,10 +102,7 @@ export async function planShellPath(options: {
disabled: true,
};
}
const blocked = (
reason: string,
target: string,
): ShellPathPlan => ({
const blocked = (reason: string, target: string): ShellPathPlan => ({
schema_version: 1,
status: "blocked",
blocked_reason: reason,
@@ -229,11 +224,12 @@ async function writeProfileAtomically(
// A fresh `--home` may not exist yet; create it (owner-only) before writing
// the temp profile, otherwise the write fails with a raw ENOENT. Surface a
// structured code for any other failure.
await mkdir(path.dirname(profilePath), { recursive: true, mode: 0o700 }).catch(
() => {
throw new Error("shell_path_home_unwritable");
},
);
await mkdir(path.dirname(profilePath), {
recursive: true,
mode: 0o700,
}).catch(() => {
throw new Error("shell_path_home_unwritable");
});
const temporary = `${profilePath}.memhub.${process.pid}.tmp`;
try {
await writeFile(temporary, content, { encoding: "utf8", mode: 0o644 });
+3 -1
View File
@@ -468,7 +468,9 @@ describe("CLI-19 progress and issue reads", () => {
);
expect(output).toContain("[15:04:05.067] progress #1");
expect(output).toContain("[15:04:05.067] progress #2");
expect(output.match(/\x1b\[2J\x1b\[H/g)).toHaveLength(2);
expect(output.match(new RegExp("\\x1b\\[2J\\x1b\\[H", "g"))).toHaveLength(
2,
);
});
it("keeps non-terminal watch output append-only", async () => {
+1 -5
View File
@@ -1,9 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { CoreReader, ReadOutcome } from "./core-client.js";
import type {
CoreOperations,
OperationOutcome,
} from "./core-operations.js";
import type { CoreOperations, OperationOutcome } from "./core-operations.js";
import {
renderSourceSyncText,
setSourceSync,
@@ -320,4 +317,3 @@ describe("setSourceSync", () => {
expect(renderSourceSyncText(invalid)).toBe("未变更:invalid_source_id\n");
});
});
+5 -2
View File
@@ -1,7 +1,7 @@
import {
SourceSyncInputError,
buildSourceSyncRequest,
hasSourceSyncTarget,
SourceSyncInputError,
} from "@one-memory/operations";
import type { CoreReader } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
@@ -36,7 +36,10 @@ async function current(
reader: CoreReader,
sourceId: string,
): Promise<
| { state: "ok"; source: NonNullable<ReturnType<typeof validateSources>>[number] }
| {
state: "ok";
source: NonNullable<ReturnType<typeof validateSources>>[number];
}
| { state: "invalid" | "unknown"; code: string }
> {
const outcome = await reader<unknown>("/api/v1/sources");
+3 -1
View File
@@ -30,6 +30,8 @@ describe("status default output", () => {
expect(text).not.toContain("[object Object]");
expect(text).toContain("卡住 类型 整理对话/原因 等待模型/数量 2");
expect(text).not.toMatch(/background_verified|additions_only|error_codes=/);
expect(text).not.toMatch(/provider_kind|has_api_key|credential_state|observed_at/);
expect(text).not.toMatch(
/provider_kind|has_api_key|credential_state|observed_at/,
);
});
});
+22 -8
View File
@@ -85,12 +85,16 @@ describe("memhub ui command wiring", () => {
const output = capture();
let code: number;
try {
code = await run(["ui", "--json"], {}, {
openBrowser: async (url) => {
opened.push(url);
return true;
code = await run(
["ui", "--json"],
{},
{
openBrowser: async (url) => {
opened.push(url);
return true;
},
},
});
);
} finally {
output.restore();
}
@@ -102,7 +106,9 @@ describe("memhub ui command wiring", () => {
state: "opened",
url: "http://127.0.0.1:8765/ui/login?code=one-time-code",
});
expect(opened).toEqual(["http://127.0.0.1:8765/ui/login?code=one-time-code"]);
expect(opened).toEqual([
"http://127.0.0.1:8765/ui/login?code=one-time-code",
]);
// The bearer token was sent to Core in the request header, never in output.
expect(output.value()).not.toMatch(/synthetic-runtime-token/);
});
@@ -159,7 +165,11 @@ describe("memhub ui command wiring", () => {
const output = capture();
let code: number;
try {
code = await run(["ui", "--json"], {}, { openBrowser: async () => false });
code = await run(
["ui", "--json"],
{},
{ openBrowser: async () => false },
);
} finally {
output.restore();
}
@@ -185,7 +195,11 @@ describe("memhub ui command wiring", () => {
const output = capture();
let code: number;
try {
code = await run(["ui", "--json"], {}, { openBrowser: async () => true });
code = await run(
["ui", "--json"],
{},
{ openBrowser: async () => true },
);
} finally {
output.restore();
}
+2 -1
View File
@@ -9,7 +9,8 @@ import { parseUiSessionExchange } from "./ui-session.js";
describe("ui session exchange over HTTP", () => {
let server: Server;
let port: number;
const seen: Array<{ authorization?: string; method?: string; url?: string }> = [];
const seen: Array<{ authorization?: string; method?: string; url?: string }> =
[];
let status = 200;
beforeAll(async () => {
+36 -7
View File
@@ -45,11 +45,31 @@ describe("memhub ui session wiring", () => {
for (const doc of [
null,
{},
{ schema_version: 2, exchange_code: "x", expires_in_seconds: 60, url: "http://127.0.0.1:1/ui/login?code=x" },
{
schema_version: 2,
exchange_code: "x",
expires_in_seconds: 60,
url: "http://127.0.0.1:1/ui/login?code=x",
},
{ schema_version: 1, exchange_code: "x", expires_in_seconds: 60 },
{ schema_version: 1, exchange_code: "", expires_in_seconds: 60, url: "http://127.0.0.1:1/ui/login?code=x" },
{ schema_version: 1, exchange_code: "x", expires_in_seconds: 0, url: "http://127.0.0.1:1/ui/login?code=x" },
{ schema_version: 1, exchange_code: "x", expires_in_seconds: 60, url: "http://127.0.0.1:1/" },
{
schema_version: 1,
exchange_code: "",
expires_in_seconds: 60,
url: "http://127.0.0.1:1/ui/login?code=x",
},
{
schema_version: 1,
exchange_code: "x",
expires_in_seconds: 0,
url: "http://127.0.0.1:1/ui/login?code=x",
},
{
schema_version: 1,
exchange_code: "x",
expires_in_seconds: 60,
url: "http://127.0.0.1:1/",
},
]) {
expect(parseUiSessionExchange(doc)).toBeNull();
}
@@ -59,9 +79,18 @@ describe("memhub ui session wiring", () => {
expect(uiSessionFailure("ui_web_assets_unavailable")).toMatchObject({
code: "ui_web_assets_unavailable",
});
expect(uiExitCode({ state: "opened", url: "http://127.0.0.1:1/ui/login?code=x" })).toBe(0);
expect(uiExitCode({ state: "blocked", code: "ui_session_contract_unavailable" })).toBe(4);
expect(uiExitCode({ state: "failed", code: "ui_session_code_invalid" })).toBe(1);
expect(
uiExitCode({
state: "opened",
url: "http://127.0.0.1:1/ui/login?code=x",
}),
).toBe(0);
expect(
uiExitCode({ state: "blocked", code: "ui_session_contract_unavailable" }),
).toBe(4);
expect(
uiExitCode({ state: "failed", code: "ui_session_code_invalid" }),
).toBe(1);
});
it("uses a platform-appropriate opener and reports missing openers", async () => {
+6 -1
View File
@@ -42,7 +42,12 @@ function isLoopbackHttpUrl(value: string): boolean {
}
if (parsed.protocol !== "http:") return false;
const host = parsed.hostname.toLowerCase();
return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
return (
host === "localhost" ||
host === "127.0.0.1" ||
host === "[::1]" ||
host === "::1"
);
}
/** Validate the Core exchange document; return null when it does not match the
+11 -2
View File
@@ -80,7 +80,13 @@ describe("calls clear", () => {
});
it("previews without contacting Core", async () => {
const result = await capturedRun(["calls", "clear", "--event", EVENT, "--json"]);
const result = await capturedRun([
"calls",
"clear",
"--event",
EVENT,
"--json",
]);
expect(result.code).toBe(0);
const document = JSON.parse(result.stdout);
expect(document.state).toBe("preview");
@@ -341,7 +347,10 @@ describe("notes / extraction / graph / manual extract", () => {
"--json",
]);
expect(applied.code).toBe(0);
const body = transport.post.mock.calls.at(-1)?.[1] as Record<string, unknown>;
const body = transport.post.mock.calls.at(-1)?.[1] as Record<
string,
unknown
>;
expect(body.text).toBe("记住这个决定");
expect(body.scope).toBe("all-apps");
expect(JSON.parse(applied.stdout).state).toBe("committed");
+23 -14
View File
@@ -10,18 +10,17 @@
import { createHash, randomUUID } from "node:crypto";
import {
MemoryNoteInputError,
buildMemoryNoteSaveRequest,
parseMemoryNoteReceipt,
MemoryNoteInputError,
parseExtractionSettingsReceipt,
parseGraphWidthDecisionReceipt,
parseManualExtractReceipt,
parseMemoryNoteReceipt,
} from "@one-memory/operations";
import type { CoreOperations, CorePutOperations } from "./core-operations.js";
import { PRODUCT } from "./version.js";
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const DIGEST = /^[0-9a-f]{64}$/;
const NOTE_MAX_CHARACTERS = 12_000;
const EXTRACTION_PRESETS = ["3m", "6m", "1y", "all"] as const;
@@ -117,7 +116,10 @@ function digestOf(value: unknown): string {
type Draft =
| Omit<Extract<ExtendedWritePlan, { kind: "memory_note_add" }>, "digest">
| Omit<Extract<ExtendedWritePlan, { kind: "memory_note_process" }>, "digest">
| Omit<Extract<ExtendedWritePlan, { kind: "extraction_window_set" }>, "digest">
| Omit<
Extract<ExtendedWritePlan, { kind: "extraction_window_set" }>,
"digest"
>
| Omit<Extract<ExtendedWritePlan, { kind: "graph_width_decide" }>, "digest">
| Omit<Extract<ExtendedWritePlan, { kind: "manual_extract" }>, "digest">;
@@ -148,7 +150,10 @@ function committed(
};
}
function rejected(operationId: string | undefined, code: string): ExtendedWriteOutcome {
function rejected(
operationId: string | undefined,
code: string,
): ExtendedWriteOutcome {
return {
schema_version: 1,
product: PRODUCT,
@@ -158,7 +163,10 @@ function rejected(operationId: string | undefined, code: string): ExtendedWriteO
};
}
function unknown(operationId: string | undefined, code: string): ExtendedWriteOutcome {
function unknown(
operationId: string | undefined,
code: string,
): ExtendedWriteOutcome {
return {
schema_version: 1,
product: PRODUCT,
@@ -197,7 +205,9 @@ export function validNoteText(text: string): boolean {
);
}
export function planMemoryNoteProcess(evidenceId: string): ExtendedWriteOutcome {
export function planMemoryNoteProcess(
evidenceId: string,
): ExtendedWriteOutcome {
return UUID.test(evidenceId)
? preview({
schema_version: 1,
@@ -313,8 +323,7 @@ export function planGraphWidthDecide(decision: string): ExtendedWriteOutcome {
export function planManualExtract(evidenceIds: string[]): ExtendedWriteOutcome {
const ids = [...new Set(evidenceIds)];
return ids.length === evidenceIds.length &&
ids.every((id) => UUID.test(id))
return ids.length === evidenceIds.length && ids.every((id) => UUID.test(id))
? preview({
schema_version: 1,
kind: "manual_extract",
@@ -455,12 +464,12 @@ export function validateExtendedWritePlan(
}
export function extendedWriteExitCode(document: ExtendedWriteOutcome): number {
return ["preview", "committed", "recovered"].includes(document.state)
? 0
: 1;
return ["preview", "committed", "recovered"].includes(document.state) ? 0 : 1;
}
export function renderExtendedWriteText(document: ExtendedWriteOutcome): string {
export function renderExtendedWriteText(
document: ExtendedWriteOutcome,
): string {
if (document.state === "preview") {
const lines = ["以下操作尚未执行;加 --confirm 后才会写入:"];
const plan = document.plan;
+16 -22
View File
@@ -61,10 +61,9 @@ describe("UI write execution", () => {
}));
const plan = planOf(planCallEventsClear([EVENT]));
if (plan.kind !== "call_events_clear") throw new Error("kind");
const result = await executeCallEventsClear(
plan,
{ post } as unknown as CoreOperations,
);
const result = await executeCallEventsClear(plan, {
post,
} as unknown as CoreOperations);
expect(result.state).toBe("committed");
expect(post).toHaveBeenCalledWith("/api/v1/call-events/clear", {
event_ids: [EVENT],
@@ -79,10 +78,9 @@ describe("UI write execution", () => {
}));
const plan = planOf(planCallEventsClearAll());
if (plan.kind !== "call_events_clear_all") throw new Error("kind");
await executeCallEventsClearAll(
plan,
{ post } as unknown as CoreOperations,
);
await executeCallEventsClearAll(plan, {
post,
} as unknown as CoreOperations);
expect(post).toHaveBeenCalledWith("/api/v1/call-events/clear-all", {
user_confirmed: true,
});
@@ -107,7 +105,7 @@ describe("UI write execution", () => {
committed: true,
},
}));
const del = vi.fn(async <T,>() => ({
const del = vi.fn(async <T>() => ({
state: "committed" as const,
data: {} as T,
}));
@@ -118,10 +116,9 @@ describe("UI write execution", () => {
const commit = planOf(planImportFileCommit(UPLOAD));
if (commit.kind !== "import_file_commit") throw new Error("kind");
await executeImportFileCommit(commit, operations);
expect(post).toHaveBeenCalledWith(
`/api/v1/import-files/${UPLOAD}/commit`,
{ confirmed: true },
);
expect(post).toHaveBeenCalledWith(`/api/v1/import-files/${UPLOAD}/commit`, {
confirmed: true,
});
const discard = planOf(planImportFileDiscard(UPLOAD));
if (discard.kind !== "import_file_discard") throw new Error("kind");
expect((await executeImportFileDiscard(discard, operations)).state).toBe(
@@ -133,15 +130,12 @@ describe("UI write execution", () => {
it("treats a malformed success receipt as unknown", async () => {
const plan = planOf(planCallEventsClear([EVENT]));
if (plan.kind !== "call_events_clear") throw new Error("kind");
const result = await executeCallEventsClear(
plan,
{
post: vi.fn(async () => ({
state: "committed" as const,
data: { wrong: true },
})),
} as unknown as CoreOperations,
);
const result = await executeCallEventsClear(plan, {
post: vi.fn(async () => ({
state: "committed" as const,
data: { wrong: true },
})),
} as unknown as CoreOperations);
expect(result).toMatchObject({
state: "unknown",
code: "ui_write_receipt_invalid",
+13 -17
View File
@@ -14,8 +14,7 @@ import type {
} from "./core-operations.js";
import { PRODUCT } from "./version.js";
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const INPUT_KINDS = ["raw_conversation", "memory_content"] as const;
const MAX_UPLOAD_BYTES = 128 * 1024 * 1024;
@@ -139,7 +138,11 @@ export function planCallEventsClear(
) {
return invalid("call_event_ids_invalid");
}
return preview({ schema_version: 1, kind: "call_events_clear", event_ids: [...eventIds] });
return preview({
schema_version: 1,
kind: "call_events_clear",
event_ids: [...eventIds],
});
}
export function planCallEventsClearAll(): UiWriteOutcome {
@@ -224,11 +227,7 @@ export async function readImportFileUpload(
}
try {
const stats = await handle.stat();
if (
!stats.isFile() ||
stats.nlink !== 1 ||
stats.size > MAX_UPLOAD_BYTES
) {
if (!stats.isFile() || stats.nlink !== 1 || stats.size > MAX_UPLOAD_BYTES) {
return null;
}
const buffer = Buffer.alloc(Number(stats.size));
@@ -273,9 +272,7 @@ function fileUploadOperationId(
checksum: string,
): string {
const hex = createHash("sha256")
.update(
JSON.stringify([filename, inputKind, checksum]),
)
.update(JSON.stringify([filename, inputKind, checksum]))
.digest("hex");
return [
hex.slice(0, 8),
@@ -325,8 +322,7 @@ export function validateUiWritePlan(value: unknown): UiWritePlan | null {
UUID.test(candidate.operation_id) &&
typeof candidate.path === "string" &&
typeof candidate.filename === "string" &&
candidate.filename ===
candidate.path.split(/[/\\]/).pop() &&
candidate.filename === candidate.path.split(/[/\\]/).pop() &&
/^[0-9a-f]{64}$/.test(String(candidate.sha256)) &&
INPUT_KINDS.includes(
candidate.input_kind as (typeof INPUT_KINDS)[number],
@@ -364,9 +360,7 @@ export async function executeCallEventsClear(
event_ids: plan.event_ids,
user_confirmed: true,
});
return outcome(result, plan.event_ids.join(","), [
"removed",
]);
return outcome(result, plan.event_ids.join(","), ["removed"]);
}
export async function executeCallEventsClearAll(
@@ -543,7 +537,9 @@ function outcome(
return committed(operationId, {});
}
const data =
result.data !== null && typeof result.data === "object" && !Array.isArray(result.data)
result.data !== null &&
typeof result.data === "object" &&
!Array.isArray(result.data)
? (result.data as Record<string, unknown>)
: null;
if (data === null || required.some((key) => !Object.hasOwn(data, key))) {
+15 -6
View File
@@ -1,5 +1,9 @@
declare const __ONE_MEMORY_VERSION__: string;
declare const __ONE_MEMORY_BUILD_NUMBER__: string;
declare const __MEMHUB_DEFAULT_RUNTIME_VERSION__: string;
const SEMVER =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
const embeddedVersion =
typeof __ONE_MEMORY_VERSION__ === "string"
@@ -9,13 +13,18 @@ const embeddedBuildNumber =
typeof __ONE_MEMORY_BUILD_NUMBER__ === "string"
? __ONE_MEMORY_BUILD_NUMBER__.trim()
: "";
const embeddedRuntimeVersion =
typeof __MEMHUB_DEFAULT_RUNTIME_VERSION__ === "string"
? __MEMHUB_DEFAULT_RUNTIME_VERSION__.trim()
: "";
export const CLI_VERSION =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(
embeddedVersion,
)
? embeddedVersion
: "development";
export const CLI_VERSION = SEMVER.test(embeddedVersion)
? embeddedVersion
: "development";
export const RUNTIME_RELEASE_VERSION = SEMVER.test(embeddedRuntimeVersion)
? embeddedRuntimeVersion
: CLI_VERSION;
export const CLI_BUILD_NUMBER = /^\d+$/.test(embeddedBuildNumber)
? Number(embeddedBuildNumber)
+21 -6
View File
@@ -1,11 +1,23 @@
import { defineConfig } from "tsup";
import { createRequire } from "node:module";
import { defineConfig } from "tsup";
const require = createRequire(import.meta.url);
const { resolveBuildVersion } = require("../../scripts/release-version.cjs") as {
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
};
const { assertSemver, resolveBuildVersion } =
require("../../scripts/release-version.cjs") as {
assertSemver(value: string, label?: string): string;
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
};
const releaseVersion = resolveBuildVersion(process.env);
const packageVersion = (require("./package.json") as { version: string })
.version;
const cliVersion = assertSemver(
process.env.MEMHUB_NPM_VERSION?.trim() || packageVersion,
"MEMHUB_NPM_VERSION",
);
const runtimeReleaseVersion = assertSemver(
process.env.MEMHUB_DEFAULT_RUNTIME_VERSION?.trim() || releaseVersion,
"MEMHUB_DEFAULT_RUNTIME_VERSION",
);
export default defineConfig({
entry: { "one-memory": "src/entry.ts" },
@@ -14,14 +26,17 @@ export default defineConfig({
outDir: "dist",
clean: true,
define: {
__ONE_MEMORY_VERSION__: JSON.stringify(releaseVersion),
__ONE_MEMORY_VERSION__: JSON.stringify(cliVersion),
__ONE_MEMORY_BUILD_NUMBER__: JSON.stringify(
process.env.ONE_MEMORY_BUILD_NUMBER ?? "unknown",
),
__ONE_MEMORY_COMMIT__: JSON.stringify(process.env.ONE_MEMORY_COMMIT ?? "unknown"),
__ONE_MEMORY_COMMIT__: JSON.stringify(
process.env.ONE_MEMORY_COMMIT ?? "unknown",
),
__MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: JSON.stringify(
process.env.MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE ?? "",
),
__MEMHUB_DEFAULT_RUNTIME_VERSION__: JSON.stringify(runtimeReleaseVersion),
},
// The shared operations package ships TypeScript sources, so it must be
// inlined into the CJS bundle the same way runtime-control is; otherwise the
+5 -3
View File
@@ -1,8 +1,9 @@
import { defineConfig } from "vitest/config";
import { readFileSync } from "node:fs";
import { defineConfig } from "vitest/config";
const version = JSON.parse(readFileSync(new URL("package.json", import.meta.url), "utf8"))
.version as string;
const version = JSON.parse(
readFileSync(new URL("package.json", import.meta.url), "utf8"),
).version as string;
export default defineConfig({
define: {
@@ -10,6 +11,7 @@ export default defineConfig({
__ONE_MEMORY_BUILD_NUMBER__: JSON.stringify("unknown"),
__ONE_MEMORY_COMMIT__: JSON.stringify("unknown"),
__MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: JSON.stringify(""),
__MEMHUB_DEFAULT_RUNTIME_VERSION__: JSON.stringify(version),
},
test: {
testTimeout: 120_000,
+1 -1
View File
@@ -11,7 +11,7 @@ folder_layout = layout == 'folder'
datas = [('src/one_memory/embedding_assets', 'one_memory/embedding_assets')]
binaries = []
# Loaded lazily by the API; static dependency analysis cannot discover it.
hiddenimports = ['one_memory.hy_permission_context']
hiddenimports = []
tmp_ret = collect_all('hy_memory')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('chromadb')
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "uv_build"
[project]
name = "one-memory"
version = "1.0.0"
version = "1.0.4"
description = "MemHub local-first memory core"
readme = "README.md"
requires-python = ">=3.12"
@@ -12,7 +12,7 @@ dependencies = [
"fastapi>=0.141.1",
"filelock>=3.32.4",
# Public SDK version validated against MemHub's permission and recall contracts.
"hy-memory==2.0.1",
"hy-memory==2.0.4",
# Retrieval's derived vector index directly consumes these locked versions.
"chromadb==1.5.9",
"numpy==2.5.2",
@@ -24,7 +24,7 @@ dependencies = [
# transitive version we do not ask for is one an unrelated upgrade could
# drop. Deliberately NOT sentence-transformers or torch: those would take
# the installer past 2GB, and onnxruntime alone runs the exported model.
"onnxruntime>=1.22.0",
"onnxruntime==1.23.2",
"tokenizers>=0.21.0",
"pydantic-settings>=2.15.0",
"uvicorn[standard]>=0.52.4",
+344 -80
View File
@@ -20,7 +20,6 @@ from copy import copy
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from functools import partial
from importlib import import_module
from pathlib import Path
from typing import Any, Literal, cast
from uuid import UUID
@@ -101,7 +100,13 @@ from one_memory.imports.access import (
SourceAccessError,
root_identity_matches,
)
from one_memory.imports.adapters import SourceAdapter, SyncCompleteness
from one_memory.imports.adapters import (
ScanFailure,
ScannedEvidence,
ScanOptions,
SourceAdapter,
SyncCompleteness,
)
from one_memory.imports.candidates import (
SourceCandidateError,
SourceCandidateSelection,
@@ -112,7 +117,11 @@ from one_memory.imports.connections import (
ConnectionIntentReceipt,
SourceConnectionStore,
)
from one_memory.imports.grants import SourceGrantReceipt, SourceGrantStore
from one_memory.imports.grants import (
SourceAuthorizationState,
SourceGrantReceipt,
SourceGrantStore,
)
from one_memory.imports.host_packages import CapabilityState, HostPackageCapabilities
from one_memory.imports.jobs import (
ImportIssueRecord,
@@ -164,7 +173,6 @@ from one_memory.memory_library import (
)
from one_memory.memory_notes import MemoryNoteStore, memory_note_router
from one_memory.memory_processing import (
MemoryBridgeProtocol,
MemoryProcessingSupervisor,
_conversation_chunks,
memory_task_timeout_seconds,
@@ -177,11 +185,14 @@ from one_memory.memory_queue import (
resolve_extraction_state,
)
from one_memory.memory_server.config_state import ConfigStore
from one_memory.memory_tasks import MemoryTask, MemoryTaskStatus, MemoryTaskStore
from one_memory.model_content_routes import model_content_router
from one_memory.memory_tasks import (
NON_RETRYABLE_POST_WRITE_CODES,
MemoryTask,
MemoryTaskStatus,
MemoryTaskStore,
)
from one_memory.model_presets import seed_preset_profile
from one_memory.model_probe import ModelProber, ModelProbeResult
from one_memory.model_processing_permission_guard import ModelProcessingPermissionLease
from one_memory.model_profiles import (
ActiveModelProfileError,
ModelCreationOperationConflictError,
@@ -194,6 +205,7 @@ from one_memory.model_profiles import (
ModelProfileStore,
ModelProfileUnavailableError,
)
from one_memory.model_request_audit import AuditedModelTransport, ModelRequestAuditStore
from one_memory.model_request_budget import BudgetedModelTransport, ModelRequestBudget
from one_memory.onboarding import OnboardingState, OnboardingStore
from one_memory.recall import GovernedRecallTarget, RecallReceipt, RecallRequest
@@ -285,6 +297,9 @@ class SourceResponse(BaseModel):
candidate_revision: int = 0
candidate_change_allowed: bool = False
candidate_change_reason: str | None = None
#: Why `authorized` is false, so the UI can ask for the one thing that
#: would fix it instead of offering every recovery at once.
authorization_state: SourceAuthorizationState = SourceAuthorizationState.OK
class PreviewSourceCandidateRequest(BaseModel):
@@ -317,6 +332,22 @@ class SourceSyncState(BaseModel):
sync_enabled: bool
class SedimentationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: StrictBool
class SedimentationState(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
enabled: bool
#: What the switch means for the model bill and for what still runs. Read
#: by the app so the setting can say so without hardcoding the wording.
pending_retained: bool = True
imports_unaffected: bool = True
class DetectionResponse(BaseModel):
source_id: str
detected: bool
@@ -343,7 +374,7 @@ class ConnectSourcesRequest(BaseModel):
import_upload_id: UUID | None = None
ONBOARDING_LARGE_HISTORY_SESSIONS = 200
ONBOARDING_LARGE_HISTORY_USER_TURNS = 200
ONBOARDING_HISTORY_WINDOW = timedelta(days=3)
@@ -818,6 +849,13 @@ def create_app(
evidence=app.state.evidence_store,
)
app.state.memory_write_queue = memory_write_queue
# Builds through 1.0.2 parked pre-invocation jobs behind the
# retired model-content consent bit. The embedded SDK is trusted
# now, so make those durable jobs claimable on the first startup.
await asyncio.to_thread(
memory_write_queue.readmit_parked,
states=("waiting_for_permission",),
)
repaired_identity_failures = await asyncio.to_thread(
memory_write_queue.repair_pre_send_identity_failures
)
@@ -883,6 +921,7 @@ def create_app(
)
app.state.import_job_store = ImportJobStore(database_path)
app.state.onboarding_store = OnboardingStore(database_path)
app.state.history_import_gate_released = False
app.state.extraction_settings_store = ExtractionSettingsStore(database_path)
app.state.source_grant_store = SourceGrantStore(
database_path,
@@ -905,6 +944,7 @@ def create_app(
app.state.source_permission_locks = {
source_id: asyncio.Lock() for source_id in app.state.source_catalog
}
app.state.sedimentation_lock = asyncio.Lock()
_follow_workbuddy_home(app, runtime_settings.source_home)
app.state.source_detection = {}
app.state.secret_store = secret_store or KeyringSecretStore()
@@ -960,7 +1000,7 @@ def create_app(
# Not fatal: without the tables every read checks exactly, as
# it does today.
app.state.memory_visibility_error = str(error)
app.state.model_request_budget = (
model_request_budget = (
ModelRequestBudget(
database_path,
request_limit=runtime_settings.model_request_limit,
@@ -970,6 +1010,8 @@ def create_app(
if runtime_settings.model_request_limit is not None
else None
)
app.state.model_request_budget = model_request_budget
app.state.model_request_audit_store = ModelRequestAuditStore(database_path)
app.state.memory_answerer = MemoryAnswerer(
governance=app.state.memory_governance_store,
filter_memories=app.state.source_read_access.memories,
@@ -1008,6 +1050,23 @@ def create_app(
governance=app.state.memory_governance_store,
data_dir=runtime_settings.data_dir,
extraction_settings=app.state.extraction_settings_store,
bridge_factory=lambda profile, api_key, data_dir: HyMemoryBridge(
profile=profile,
api_key=api_key,
data_dir=data_dir,
http_transport_factory=lambda: AuditedModelTransport(
app.state.model_request_audit_store,
profile_id=profile.profile_id,
profile_revision=profile.revision,
provider_kind=str(profile.provider_kind),
model=profile.model,
inner=(
BudgetedModelTransport(model_request_budget)
if model_request_budget is not None
else None
),
),
),
# `None` means "whatever the active profile allows". Pinned to
# 1 in `a59f4b5` as temporary load-shedding for an acceptance
# package, and left pinned after it: a hardcoded 1 is rule 9's
@@ -1016,12 +1075,9 @@ def create_app(
# profile concurrency 4, recall latency is unchanged while
# precipitation goes 4x.
max_concurrent_tasks=runtime_settings.precipitation_max_concurrent_tasks,
model_content_config=app.state.config_store,
permission_bridge_factory=lambda lease, secret, directory: _permission_context(
app, lease, secret, directory
),
source_writes_permitted=lambda source_id: _source_writes_on(app, source_id),
service_levels=app.state.service_levels,
sedimentation_enabled=lambda: _config_store(app).sedimentation_enabled(),
)
# C2 owns the single production budget (handle 5s + reserve 250ms,
# total 5.25s, lease 10s, renew 1s, claim 250ms). The composition
@@ -1031,26 +1087,41 @@ def create_app(
drain_budget = production_drain_budget()
async def reconcile_unknown_writes() -> None:
await app.state.memory_processing_supervisor.reconcile_unknown_outputs()
unknown = await asyncio.to_thread(memory_write_queue.unknown_jobs, limit=128)
await app.state.memory_processing_supervisor.reconcile_unknown_outputs(
exclude_error_codes=NON_RETRYABLE_POST_WRITE_CODES,
)
unknown = await asyncio.to_thread(
memory_write_queue.unknown_jobs,
limit=128,
exclude_error_codes=NON_RETRYABLE_POST_WRITE_CODES,
)
for job in unknown:
await asyncio.to_thread(
memory_write_queue.resolve_unknown_from_linked_output,
job.job_id,
)
await asyncio.to_thread(
memory_write_queue.retry_timed_out_unknown_after_reconcile,
older_than=datetime.now(UTC) - timedelta(minutes=5),
memory_write_queue.retry_first_unknown_after_reconcile,
older_than=datetime.now(UTC) - timedelta(seconds=10),
limit=32,
)
await asyncio.to_thread(
app.state.memory_task_store.retry_first_unknown_after_reconcile,
older_than=datetime.now(UTC) - timedelta(seconds=10),
limit=128,
)
async def admit_hy_invocation(
source_id: str,
mark_invoked: Callable[[], Awaitable[bool]],
) -> bool:
"""Serialize pause with the last boundary before an HY call."""
lock = app.state.source_permission_locks.setdefault(source_id, asyncio.Lock())
async with lock:
source_lock = app.state.source_permission_locks.setdefault(
source_id, asyncio.Lock()
)
async with app.state.sedimentation_lock, source_lock:
if not _config_store(app).sedimentation_enabled():
raise RuntimeError("model_sedimentation_disabled")
if not _source_writes_on(app, source_id):
raise RuntimeError("model_content_source_disabled")
return await mark_invoked()
@@ -1088,8 +1159,18 @@ def create_app(
+ 10
),
reconcile_unknown=reconcile_unknown_writes,
source_writes_permitted=lambda source_id: _source_writes_on(app, source_id),
source_claim_enabled=lambda source_id: _config_store(app).sync_enabled(source_id),
source_writes_permitted=lambda source_id: _sedimentation_claim_enabled(
app, source_id
),
# The claim gate has to be the same question the write gate
# asks, not a second answer to it. It used to be
# `sync_enabled`, the per-app toggle, and `one-memory-manual` is
# not a connected app: it has no entry in the configuration
# document, so that predicate answers False for it while the
# write gate admits it by its grant. Every manual note's job was
# therefore skipped on every drain tick — the note stayed
# "saved, waiting" with no task behind it, forever.
source_claim_enabled=lambda source_id: _sedimentation_claim_enabled(app, source_id),
)
app.state.memory_write_drain = memory_write_drain
app.state.memory_write_drain_task = watched_task(
@@ -1118,10 +1199,7 @@ def create_app(
for chunk in _conversation_chunks(conversation, max_turns=12, completed_only=True):
if (
record not in chunk
or not any(
item.role == "user" and item.written_by.value == "person"
for item in chunk
)
or not any(item.role == "user" for item in chunk)
or not any(item.role == "assistant" for item in chunk)
):
continue
@@ -1134,7 +1212,6 @@ def create_app(
app.state.matter_discovery.processor = app.state.memory_processing_supervisor
app.state.matter_discovery.write_intake = app.state.memory_write_intake
app.state.matter_discovery.write_queue = memory_write_queue
app.state.processing_coordinator = None
app.state.memory_note_store = MemoryNoteStore(
app.state.evidence_store,
app.state.memory_processing_supervisor,
@@ -1446,7 +1523,6 @@ def create_app(
app.include_router(matter_router(protected))
app.include_router(memory_note_router(protected))
app.include_router(file_import_router(protected))
app.include_router(model_content_router(protected))
app.include_router(context_router(protected))
def require_workbuddy_boundary(request: Request) -> None:
@@ -1736,6 +1812,30 @@ def create_app(
"events": events,
}
@app.get("/api/v1/diagnostics/model-usage", dependencies=protected)
def diagnostic_model_usage(
since: datetime | None = Query(default=None), # noqa: B008
request_id: str | None = Query( # noqa: B008
default=None, min_length=1, max_length=512
),
limit: int = Query(default=200, ge=1, le=2_000), # noqa: B008
) -> dict[str, object]:
"""Return content-free precipitation usage for provider reconciliation."""
normalized_since = (
(since if since.tzinfo is not None else since.replace(tzinfo=UTC))
if since is not None
else None
)
report = app.state.model_request_audit_store.report(
since=normalized_since.astimezone(UTC).isoformat()
if normalized_since is not None
else None,
request_id=request_id,
limit=limit,
)
return {"schema_version": 1, **report}
@app.get(
"/api/v1/runtime/identity",
response_model=HealthResponse,
@@ -1873,6 +1973,20 @@ def create_app(
"""Record UI completion only; finishing or skipping is never model consent."""
return await asyncio.to_thread(_onboarding_store(app).complete)
@app.post(
"/api/v1/onboarding/reset",
response_model=OnboardingState,
dependencies=protected,
)
async def reset_onboarding() -> OnboardingState:
"""Go back to the first-run screens. Only the completion flag moves.
Memories, sources and settings live in other tables and are untouched,
which is the point: someone who wants to see the cold-start flow again
should not have to delete anything to do it.
"""
return await asyncio.to_thread(_onboarding_store(app).reset)
@app.get(
"/api/v1/sources",
response_model=list[SourceResponse],
@@ -1910,6 +2024,11 @@ def create_app(
candidate_change_reason=app.state.source_candidate_store.change_reason(
registration
),
authorization_state=grant_store.authorization_state(
user_id="local",
source_id=source_id,
candidate=registration.candidate.path,
),
)
for source_id, registration in _catalog(app).items()
]
@@ -2221,24 +2340,39 @@ def create_app(
raise HTTPException(status_code=409, detail=error.code) from None
if grant is None:
raise HTTPException(status_code=409, detail="grant_required")
await _prepare_source_resume(source_id)
try:
await _prepare_source_resume(source_id)
except BaseException as error:
raise _sync_refused("sync_prepare_failed") from error
else:
await _live_sync(app).stop_source(source_id)
try:
await _live_sync(app).stop_source(source_id)
except BaseException as error:
raise _sync_refused("sync_prepare_failed") from error
try:
await asyncio.to_thread(_config_store(app).set_sync, source_id, request.enabled)
except BaseException:
except BaseException as error:
if request.enabled:
await _live_sync(app).stop_source(source_id)
else:
await _prepare_source_resume(source_id)
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
raise
raise _sync_refused("sync_config_write_failed") from error
try:
if request.enabled:
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
try:
await _activate_source_resume(source_id)
except BaseException as error:
raise _sync_refused("sync_resume_failed") from error
try:
await _refresh_live_watch(app)
except BaseException as error:
raise _sync_refused("sync_watcher_failed") from error
except BaseException:
# Rolls back the desired state and settles the watcher exactly as
# before; the only change is that the raise above now carries a
# code saying which stage failed.
await asyncio.to_thread(_config_store(app).set_sync, source_id, previous)
if request.enabled:
await _live_sync(app).stop_source(source_id)
@@ -2328,7 +2462,7 @@ def create_app(
await asyncio.to_thread(
app.state.memory_task_store.park_source_for_permission,
source_id=source_id,
error_code="model_content_permission_required",
error_code="hy_memory_source_revoked",
)
await _live_sync(app).stop_source(source_id)
_supervisor(app).unregister_adapter(source_id)
@@ -2369,6 +2503,8 @@ def create_app(
async def connect_sources(
request: ConnectSourcesRequest,
) -> ConnectSourcesResponse:
if request.onboarding:
app.state.history_import_gate_released = False
source_ids = tuple(dict.fromkeys(request.source_ids))
registrations = [_require_registration(app, source_id) for source_id in source_ids]
if request.request_id is None:
@@ -2668,6 +2804,12 @@ def create_app(
# D75: work queued for the previous model continues with this one.
# Only tasks that never reached the model are re-bound.
await asyncio.to_thread(app.state.memory_task_store.rebind_unstarted_profile, profile)
# A missing profile is an event-driven wait, not a provider outage.
# Release those durable rows only now that activation has changed the
# condition; the ordinary drain poll will claim them without a restart.
await asyncio.to_thread(
app.state.memory_write_queue.readmit_model_profile_waiters
)
# Not awaited: the catch-up reads every conversation ever imported, and
# the answer to "which profile is active" does not depend on it.
_start_backfill(app)
@@ -3534,6 +3676,48 @@ def create_app(
def put_extraction_settings(request: ExtractionWindowUpdate) -> ExtractionSettings:
return _memory_processing_supervisor(app).set_extraction_window(request.window_preset)
@app.get(
"/api/v1/sedimentation",
response_model=SedimentationState,
dependencies=protected,
)
def get_sedimentation() -> SedimentationState:
"""Whether the model may still turn conversations into memories."""
return SedimentationState(enabled=_config_store(app).sedimentation_enabled())
@app.put(
"/api/v1/sedimentation",
response_model=SedimentationState,
dependencies=protected,
)
async def put_sedimentation(request: SedimentationRequest) -> SedimentationState:
"""Start or stop model-driven sedimentation, keeping the configuration.
Stopping is the user's cost control: nothing further goes to the model
and already queued work waits rather than failing, so starting again
resumes it. Importing conversations is not affected either way.
"""
store = _config_store(app)
changed = False
async with app.state.sedimentation_lock:
previous = store.sedimentation_enabled()
if previous is not request.enabled:
await asyncio.to_thread(store.set_sedimentation_enabled, request.enabled)
changed = True
diagnostic_event(
level="info",
component="memory.sedimentation",
event="memory_sedimentation.toggled",
outcome="succeeded",
details={"enabled": request.enabled},
)
if changed and request.enabled:
# Work parked by the stop is picked up again rather than waiting
# for the next natural dispatch, but not on this request's time:
# see _resume_sedimentation.
_resume_sedimentation(app)
return SedimentationState(enabled=store.sedimentation_enabled())
@app.post(
"/api/v1/memory-processing/manual-extract",
response_model=ManualExtractReceipt,
@@ -3894,12 +4078,8 @@ async def _authorize_answer_generation(
# The explicit question is this operation's input, not a standing permission for history.
if not question.strip() or not grounding:
raise MemoryAnswerError("memory_answer_generation_not_authorized")
if not await asyncio.to_thread(_config_store(app).model_content_allowed):
raise MemoryAnswerError("memory_answer_generation_not_authorized")
def check() -> None:
if not _config_store(app).model_content_allowed():
raise MemoryAnswerError("memory_answer_generation_not_authorized")
if _model_profile_store(app).active_identity() != (
profile.profile_id,
profile.revision,
@@ -3923,29 +4103,6 @@ async def _authorize_answer_generation(
return recheck
@asynccontextmanager
async def _permission_context(
app: FastAPI,
lease: ModelProcessingPermissionLease,
secret: str | None,
directory: Path,
) -> AsyncIterator[MemoryBridgeProtocol]:
module = await asyncio.to_thread(import_module, "one_memory.hy_permission_context")
budget = cast(ModelRequestBudget | None, app.state.model_request_budget)
source_id = lease.records[0].source_id if lease.records else "manual"
factory = module.permission_bridge_factory(
lambda profile, api_key, data_dir: _memory_processing_supervisor(app)._bridge(
profile,
api_key,
data_dir,
scope_key=source_id,
),
**({"http_transport": BudgetedModelTransport(budget)} if budget else {}),
)
async with factory(lease, secret, directory) as bridge:
yield cast(MemoryBridgeProtocol, bridge)
class _LazyASGIApp:
"""Compatibility ASGI app that is built only when a server imports it.
@@ -3977,6 +4134,16 @@ class _LazyASGIApp:
app = _LazyASGIApp()
def _sync_refused(code: str) -> HTTPException:
"""A sync refusal that says which stage refused.
Turning the switch on takes four steps: authorize, prepare, write, resume.
The UI used to be told only that the switch had sprung back, and every
stage needs a different fix, so each one gets its own code.
"""
return HTTPException(status_code=409, detail=code)
def _granted_roots(
source_ids: Iterable[str],
grant_store: SourceGrantStore,
@@ -4028,6 +4195,7 @@ def _source_response(
totals: tuple[int, int],
*,
candidate_change_reason: str | None = None,
authorization_state: SourceAuthorizationState = SourceAuthorizationState.OK,
) -> SourceResponse:
location = inspect_source_location(
registration.descriptor.source_id, registration.candidate.path
@@ -4240,7 +4408,7 @@ async def _onboarding_history_after(
home: Path,
now: datetime | None = None,
) -> datetime | None:
"""Return one shared cutoff when any selected harness has large history.
"""Return one shared cutoff when any selected harness has large, old history.
The cutoff is decided before any history job is created, then persisted on
every accepted job. This is deliberately global: independently limiting
@@ -4249,6 +4417,8 @@ async def _onboarding_history_after(
"""
if not request.onboarding:
return None
anchor = now or datetime.now(UTC)
cutoff = anchor - ONBOARDING_HISTORY_WINDOW
revisions = request.source_candidate_revisions or {}
for registration in registrations:
source_id = registration.descriptor.source_id
@@ -4276,29 +4446,52 @@ async def _onboarding_history_after(
home=home,
)
adapter = registration.create_adapter(grant)
count_sessions = cast(
Callable[..., Awaitable[int]] | None,
getattr(adapter, "count_sessions", None),
)
if count_sessions is not None:
session_count = await count_sessions(limit=ONBOARDING_LARGE_HISTORY_SESSIONS + 1)
else:
pending = _install_state(adapter)
if pending is None:
continue
session_count = (await pending).session_count
if await _has_large_old_history(adapter, cutoff=cutoff):
return cutoff
except (OSError, SourceAccessError):
# A supported source whose metadata count cannot be completed is
# treated conservatively. The later connection still owns the
# precise authorization error, while onboarding never falls back
# to an unbounded history import.
session_count = ONBOARDING_LARGE_HISTORY_SESSIONS + 1
if session_count > ONBOARDING_LARGE_HISTORY_SESSIONS:
anchor = now or datetime.now(UTC)
return anchor - ONBOARDING_HISTORY_WINDOW
return cutoff
return None
async def _has_large_old_history(adapter: SourceAdapter, *, cutoff: datetime) -> bool:
"""Probe one authorized Harness with the same parsers the import will use.
Stop as soon as both policy facts are proven: more than 200 genuine person
turns and at least one record older than the three-day cutoff. Counting
parsed Evidence instead of transcript files prevents one giant coding
session from bypassing the limit, while reusing the adapters keeps host
envelopes and tool-shaped user records out of the count.
"""
user_turns = 0
has_old_data = False
async for event in adapter.scan(ScanOptions()):
if isinstance(event, ScanFailure):
# A partial probe must never turn into an unbounded first import.
return True
if not isinstance(event, ScannedEvidence):
continue
observed = event.evidence.created_at
if observed is None and event.cursor.observed_modified_ns > 0:
observed = datetime.fromtimestamp(
event.cursor.observed_modified_ns / 1_000_000_000,
tz=UTC,
)
if observed is not None:
if observed.tzinfo is None:
observed = observed.replace(tzinfo=UTC)
has_old_data = has_old_data or observed.astimezone(UTC) < cutoff
if event.evidence.role == "user":
user_turns += 1
if user_turns > ONBOARDING_LARGE_HISTORY_USER_TURNS and has_old_data:
return True
return False
def _install_state(adapter: object) -> Awaitable[SourceInstallState] | None:
"""Ask an adapter for the two-level answer when it has one.
@@ -4590,7 +4783,7 @@ async def _keep_loop_heartbeat(watchdog: LoopWatchdog) -> None:
async def _emit_resource_snapshots(interval_seconds: float = 60.0) -> None:
while True:
diagnostic_event(
level="info",
level="debug",
component="core.resources",
event="core.resource_snapshot",
outcome="observed",
@@ -4615,6 +4808,33 @@ def _start_backfill(app: FastAPI) -> None:
)
def _resume_sedimentation(app: FastAPI) -> None:
"""Pick the work parked by a stop back up, off the caller's clock.
Turning the switch back on is a decision, not a job the caller waits for:
the choice is already stored, and what follows it is maintenance over every
unfinished task followed by a scan of the whole queue. On a real backlog
that runs for many minutes, so awaiting it here left the PUT unanswered
until the client gave up — the renderer showed "启用未能确认" for a switch
that had in fact been saved, and the switch only appeared to turn on once
the follow-up read came back. Measured: one toggle, twenty-plus minutes,
no response, client timeout at thirty seconds.
Repeated toggles reuse the running walk; it reads the switch as it goes, so
a stop part-way through is honoured on the next batch instead of needing a
cancel.
"""
running = cast("asyncio.Task[None] | None", getattr(app.state, "sedimentation_resume", None))
if running is not None and not running.done():
return
app.state.sedimentation_resume = watched_task(
asyncio.get_running_loop(),
lambda: _memory_processing_supervisor(app).resume_allowed(),
component="memory.sedimentation",
name="one-memory-sedimentation-resume",
)
def _start_visibility_recompute(app: FastAPI) -> None:
"""Keep the stored visibility conclusions current, in the background.
@@ -4723,8 +4943,6 @@ async def _run_backfill(app: FastAPI) -> None:
app.state.memory_backfill_error = None
if not app.state.settings.automatic_memory_processing:
return
if not await asyncio.to_thread(_config_store(app).model_content_allowed):
return
try:
queue = app.state.memory_write_queue
intake = app.state.memory_write_intake
@@ -4860,6 +5078,52 @@ def _source_writes_on(app: FastAPI, source_id: str) -> bool:
return _source_processing_enabled(app, source_id)
def _sedimentation_claim_enabled(app: FastAPI, source_id: str) -> bool:
"""Claim queued model work only when policy, source and model all admit it."""
return (
_config_store(app).sedimentation_enabled()
and _model_profile_store(app).active_identity() is not None
and _history_import_gate_released(app)
and _source_writes_on(app, source_id)
)
def _history_import_gate_released(app: FastAPI) -> bool:
"""Hold first sedimentation until every accepted history import is terminal."""
if getattr(app.state, "history_import_gate_released", False):
return True
onboarding = getattr(app.state, "onboarding_store", None)
jobs = getattr(app.state, "import_job_store", None)
if onboarding is None or jobs is None:
return True
state = onboarding.get()
if state.status != "importing" or not state.selected_source_ids:
app.state.history_import_gate_released = True
return True
selected = set(state.selected_source_ids)
matching = [
job
for job in jobs.list_jobs()
if job.operation == "history"
and job.source_id in selected
and (state.started_at is None or job.created_at >= state.started_at)
]
if {job.source_id for job in matching} != selected:
return False
active = {
ImportStatus.PENDING,
ImportStatus.RUNNING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
}
released = all(job.status not in active for job in matching)
if released:
app.state.history_import_gate_released = True
return released
def _model_prober(app: FastAPI) -> ModelProber:
return cast(ModelProber, app.state.model_prober)
@@ -175,6 +175,22 @@ async def in_interactive_read[T](
return await in_read_pool(interactive_read_pool(), work, *args, **kwargs)
def _retrieve[T](future: "asyncio.Future[T]") -> None:
"""Mark a shielded read's outcome as seen.
`in_read_pool` shields the wrapped worker, so a caller that gives up and
cancels leaves the read running to completion. When that read then fails,
nothing awaits the future any more: asyncio reports the orphan at GC as
"Future exception was never retrieved", the loop handler turns it into a
`core.loop.task_exception` error diagnostic, and a lane that degraded on
its own budget -- correctly, and already reported -- looks like a crashed
task on top of it. Retrieving here keeps the diagnostics honest. Awaiting
still raises for callers that are still there, so no failure is hidden.
"""
if not future.cancelled():
future.exception()
async def in_read_pool[T](
pool: ThreadPoolExecutor,
work: Callable[..., T],
@@ -193,7 +209,9 @@ async def in_read_pool[T](
_interactive_reads -= 1
worker.add_done_callback(released)
return await asyncio.shield(asyncio.wrap_future(worker))
pending = asyncio.wrap_future(worker)
pending.add_done_callback(_retrieve)
return await asyncio.shield(pending)
async def wait_for_interactive_reads() -> None:
+21 -3
View File
@@ -266,7 +266,13 @@ def serve(*, maintenance: bool = False, reader: bool = False) -> None:
)
application = create_reader_app(reader_settings)
print(f"reader_port:{port}", file=sys.stderr, flush=True)
uvicorn.run(application, host=settings.host, port=port, reload=False)
uvicorn.run(
application,
host=settings.host,
port=port,
reload=False,
access_log=False,
)
return
from one_memory.api import create_app
@@ -297,9 +303,21 @@ def serve(*, maintenance: bool = False, reader: bool = False) -> None:
)
if maintenance:
with CoreRuntime(settings, publish_receipt=False):
uvicorn.run(application, host=settings.host, port=settings.port, reload=False)
uvicorn.run(
application,
host=settings.host,
port=settings.port,
reload=False,
access_log=False,
)
else:
uvicorn.run(application, host=settings.host, port=settings.port, reload=False)
uvicorn.run(
application,
host=settings.host,
port=settings.port,
reload=False,
access_log=False,
)
if __name__ == "__main__":
@@ -196,7 +196,7 @@ def read_raw_context(
if _prefers_declared_facts(request.query)
else "1"
)
person_authored = "CASE WHEN e.written_by='person' THEN 1 ELSE 0 END"
person_authored = "CASE WHEN e.role='user' THEN 1 ELSE 0 END"
query_echo = (
"CASE WHEN trim(e.verbatim)=? OR trim(e.verbatim)=? THEN 0 ELSE 1 END"
if _prefers_declared_facts(request.query)
@@ -470,9 +470,8 @@ def revalidate_raw_context(
)
except (ValueError, TypeError):
break
if (row["role"], row["written_by"], created) != (
if (row["role"], created) != (
message.role,
message.written_by,
message.created_at,
):
break
@@ -66,12 +66,12 @@ def _field(text: str) -> ResumeField | None:
return None
def _raw_class(written_by: str, evidence_id: str, accepted: frozenset[str]) -> EvidenceClass:
if written_by == "person":
def _raw_class(role: str, evidence_id: str, accepted: frozenset[str]) -> EvidenceClass:
if role == "user":
return "person_statement"
if written_by == "agent":
if role == "assistant":
return "agent_report"
if written_by == "tool" and evidence_id in accepted:
if role == "tool" and evidence_id in accepted:
return "system_acceptance"
return "tool_output"
@@ -118,7 +118,7 @@ def assemble_resume_packet(
field=_field(message.text) or "unclassified",
text=message.text,
evidence_class=_raw_class(
message.written_by,
message.role,
message.evidence_id,
verified_system_evidence_ids,
),
@@ -345,7 +345,7 @@ def _lexical_raw_rows(
if context_raw._prefers_declared_facts(query.query)
else "1"
)
person_authored = "CASE WHEN e.written_by='person' THEN 1 ELSE 0 END"
person_authored = "CASE WHEN e.role='user' THEN 1 ELSE 0 END"
query_echo = (
"CASE WHEN trim(e.verbatim)=? OR trim(e.verbatim)=? THEN 0 ELSE 1 END"
if context_raw._prefers_declared_facts(query.query)
@@ -507,7 +507,7 @@ def _raw_item(row: sqlite3.Row, start1: int, matched_by: tuple[str, ...]) -> Raw
"source_id": row["source_id"],
"conversation_id": row["conversation_id"],
"role": row["role"],
"written_by": "person" if row["written_by"] == "person" else "agent",
"written_by": "person" if row["role"] == "user" else "agent",
"event_time": event_time.isoformat() if event_time else None,
"spans": [{"start_cp": start, "end_cp": start + len(text), "text": text}],
"matched_by": list(matched_by),
@@ -1039,7 +1039,7 @@ def close_main_databases() -> None:
def log_main_database_stats(moment: str) -> None:
"""One line in core.log: how many connections are open and who waited."""
stats = main_database_stats()
logger.info(
logger.debug(
"main_database_connections moment=%s %s",
moment,
" ".join(f"{key}={value}" for key, value in stats.items()),
@@ -19,6 +19,7 @@ from __future__ import annotations
import atexit
import json
import logging
import os
import re
import sys
@@ -139,6 +140,10 @@ _DETAIL_KEYS = frozenset(
"accounted_ms",
"unaccounted_ms",
"conversation_hash",
# The sedimentation switch: a bounded boolean, the same shape as
# `spawned` and `index_complete`. Left out of this list it did not
# drop a log line, it turned the switch's own request into a 500.
"enabled",
}
)
_trace_id: ContextVar[str | None] = ContextVar("one_memory_trace_id", default=None)
@@ -649,12 +654,24 @@ def diagnostic_event(**values: object) -> None:
active = _active
if active is None:
return
if values.get("level") == "debug" and not logging.getLogger().isEnabledFor(
logging.DEBUG
):
return
try:
active.write(**values) # type: ignore[arg-type]
except OSError:
# Diagnostics must not become a new product outage when a disk is full
# or the log directory is temporarily unavailable.
return
except ValueError:
# Same promise, same reason, for a rejected payload instead of a failed
# write. An unlisted `details` key once turned every external recall
# into a 500 and then killed the process, and the sedimentation switch
# into a permanent failure. The payload itself is still wrong and is
# still caught in CI by tests/test_diagnostic_payload_keys.py; what
# must not happen is the caller paying for it.
return
def current_session_id() -> str | None:
@@ -21,7 +21,6 @@ from pydantic import BaseModel, ConfigDict, Field
from one_memory.diagnostic_log import diagnostic_event, diagnostic_exception, elapsed_ms
from one_memory.hy_lifecycle import HyLifecycle, HyLifecycleUnreadable, read_hy_lifecycle
from one_memory.hy_permission_store import native_store_read_slot
from one_memory.hy_presence import HyPresence, confirm_deleted
from one_memory.hy_runtime_config import (
HY_MEMORY_MODE,
@@ -32,7 +31,9 @@ from one_memory.hy_runtime_config import (
diagnostic_log_level,
)
from one_memory.hy_source_context import HySourceContext, HyWriteBindings
from one_memory.hy_store_concurrency import native_store_read_slot
from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind, attach_disable_thinking
from one_memory.model_request_audit import audit_precipitation_request
from one_memory.semantic_deadline import SemanticMemoryHit
#: D102: how relevant HY has to call a hit before it counts. Raised from 0.25
@@ -143,6 +144,22 @@ class HyMemoryBridgeError(RuntimeError):
pass
MEMHUB_DEFAULT_CODING_TOOL_RESULT_MAX_BYTES = 512
def coding_tool_result_max_bytes() -> int:
"""Return the per-tool payload ceiling used by MemHub's HY client."""
raw = os.environ.get("MEMORY_CODING_TOOL_RESULT_MAX_BYTES")
if raw is None:
return MEMHUB_DEFAULT_CODING_TOOL_RESULT_MAX_BYTES
try:
value = int(raw)
except ValueError:
return MEMHUB_DEFAULT_CODING_TOOL_RESULT_MAX_BYTES
return value if value > 0 else MEMHUB_DEFAULT_CODING_TOOL_RESULT_MAX_BYTES
def _exclusive[**P, T](
method: Callable[P, Coroutine[Any, Any, T]],
) -> Callable[P, Coroutine[Any, Any, T]]:
@@ -376,11 +393,13 @@ class HyMemoryBridge:
api_key: str | None,
data_dir: Path,
client: HyMemoryClientProtocol | None = None,
http_transport_factory: Callable[[], Any] | None = None,
) -> None:
self.profile = profile
self.api_key = api_key
self.data_dir = data_dir
self._client = client
self._http_transport_factory = http_transport_factory
self._client_lock = asyncio.Lock()
self._write_lock = asyncio.Lock()
self._last_bindings: HyWriteBindings | None = None
@@ -412,8 +431,9 @@ class HyMemoryBridge:
@_exclusive
async def ingest(self, request: HyMemoryIngestRequest) -> HyMemoryIngestResult:
async with self._write_lock:
return await self._ingest(request)
with audit_precipitation_request(request.request_id):
async with self._write_lock:
return await self._ingest(request)
async def _ingest(self, request: HyMemoryIngestRequest) -> HyMemoryIngestResult:
started = monotonic()
@@ -427,6 +447,11 @@ class HyMemoryBridge:
)
client = await self._get_client()
guards = getattr(client, "_one_memory_write_guards", ())
from one_memory.hy_response_guard import HyReconcileGuard
reconcile_guards = tuple(guard for guard in guards if isinstance(guard, HyReconcileGuard))
for guard in reconcile_guards:
guard.begin_write()
before = [len(guard.failures) for guard in guards]
bindings = HyWriteBindings(request.source_context) if request.source_context else None
if bindings:
@@ -500,6 +525,12 @@ class HyMemoryBridge:
if len(guard.failures) > count:
raise HyMemoryBridgeError(guard.failures[count])
result = await self._result(client, request, raw, expected_contents=None, bindings=bindings)
result = await _with_native_mutation_delta(
client,
result,
raw,
reconcile_guards,
)
if bindings:
outputs = []
for memory in result.memories:
@@ -529,8 +560,9 @@ class HyMemoryBridge:
request: HyMemoryIngestRequest,
contents: Sequence[str],
) -> HyMemoryIngestResult:
async with self._write_lock:
return await self._ingest_extracted(request, contents)
with audit_precipitation_request(request.request_id):
async with self._write_lock:
return await self._ingest_extracted(request, contents)
async def _ingest_extracted(
self,
@@ -553,6 +585,8 @@ class HyMemoryBridge:
from one_memory.hy_response_guard import HyReconcileGuard
report_guards = tuple(guard for guard in guards if isinstance(guard, HyReconcileGuard))
for guard in report_guards:
guard.begin_write()
expected_contents = frozenset(content.strip() for content in contents if content.strip())
for guard in report_guards:
guard.imported_contents = expected_contents
@@ -599,6 +633,12 @@ class HyMemoryBridge:
raw,
expected_contents=expected_contents,
)
result = await _with_native_mutation_delta(
client,
result,
raw,
report_guards,
)
diagnostic_event(
level="info",
component="hy.bridge",
@@ -1087,6 +1127,7 @@ class HyMemoryBridge:
self.profile,
api_key=self.api_key,
data_dir=self.data_dir,
http_transport_factory=self._http_transport_factory,
)
await _joined_thread(initialize)
@@ -1113,7 +1154,7 @@ class HyMemoryBridge:
)
raise HyMemoryBridgeError("hy_memory_init_failed") from error
diagnostic_event(
level="info",
level="debug",
component="hy.client",
event="hy.client_initialization.completed",
outcome="succeeded",
@@ -1136,6 +1177,7 @@ def create_hy_memory_client(
*,
api_key: str | None,
data_dir: Path,
http_transport_factory: Callable[[], Any] | None = None,
) -> HyMemoryClientProtocol:
configure_hy_memory_environment(data_dir)
config = build_hy_memory_config(
@@ -1143,6 +1185,7 @@ def create_hy_memory_client(
api_key=api_key,
data_dir=data_dir,
)
config.http_transport_factory = http_transport_factory
reattach_legacy_vector_collection(profile, config)
mismatch = inspect_graph_width_mismatch(config)
if mismatch is not None:
@@ -1287,6 +1330,72 @@ async def _coding_result(
return HyMemoryIngestResult(success=True, memories=tuple(outputs))
async def _with_native_mutation_delta(
client: HyMemoryClientProtocol,
result: HyMemoryIngestResult,
raw: Mapping[str, object],
reconcile_guards: Sequence[Any],
) -> HyMemoryIngestResult:
"""Map native SDK mutations back into MemHub governance.
Store permission wrappers used to discover this by intercepting every
native CRUD call. The embedded SDK now owns unrestricted local CRUD, so
chat targets come from the reconcile observer and coding targets come from
the SDK's public result document.
"""
retired = set(result.retired_memory_ids)
superseded = {key: tuple(values) for key, values in result.superseded_memory_ids.items()}
chat_targets = {
key
for guard in reconcile_guards
for key in getattr(guard, "mutation_target_ids", ())
if isinstance(key, str) and key
}
operations = raw.get("ops")
for item in operations if isinstance(operations, list) else ():
if not isinstance(item, Mapping):
continue
if item.get("action") == "DELETE":
key = item.get("target_experience_id")
if isinstance(key, str) and key:
retired.add(key)
fact_operations = raw.get("fact_ops")
for item in fact_operations if isinstance(fact_operations, list) else ():
if not isinstance(item, Mapping) or item.get("action") != "SUPERSEDE":
continue
old, new = item.get("fact_id"), item.get("superseded_by")
if isinstance(old, str) and old:
retired.add(old)
if isinstance(new, str) and new:
superseded[old] = (new,)
for key in chat_targets:
record = await _joined_thread(_read_hy_record, client, key)
if record is None:
retired.add(key)
continue
status = str(record.get("status", "")).lower()
if status in {"deleted", "shadow", "superseded"}:
retired.add(key)
replacement = record.get("superseded_by")
if isinstance(replacement, str) and replacement:
superseded[key] = (replacement,)
elif isinstance(replacement, list):
values = tuple(value for value in replacement if isinstance(value, str) and value)
if values:
superseded[key] = values
return result.model_copy(
update={
"retired_memory_ids": tuple(sorted(retired)),
"superseded_memory_ids": superseded,
}
)
def _read_hy_record(
client: HyMemoryClientProtocol,
memory_id: str,
@@ -1603,6 +1712,7 @@ def build_hy_memory_config(
config.history.db_path = str(memory_root / "history.sqlite3")
config.coding.enable = True
config.coding.db_path = str(memory_root / "coding.sqlite3")
config.coding.tool_result_max_bytes = coding_tool_result_max_bytes()
config.recall.episode_recall_enabled = True
config.recall.episode_summary_enabled = False
config.recall.dynamic_episode_enabled = True
Loaded 100 of 503 files, more files were not shown because too many files have changed in this diff. Show more