Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59090f3af8 | ||
|
|
ebb1306030 | ||
|
|
941dd042da | ||
|
|
3316f74226 | ||
|
|
9d9b92162b | ||
|
|
34b07f0eb3 | ||
|
|
55d73dc7f4 |
No files matched your search
@@ -40,7 +40,6 @@ MEMORY_EMBEDDING_DIMS=1024
|
||||
# MEMORY_EMBEDDER_TIMEOUT=15
|
||||
# MEMORY_EMBEDDER_MAX_RETRIES=2
|
||||
# MEMORY_EMBEDDER_RETRY_DELAY=0.3
|
||||
# MEMORY_EMBEDDER_BATCH_SIZE=64
|
||||
# MEMORY_EMBEDDER_EXTRA_HEADERS={}
|
||||
# MEMORY_EMBEDDER_EXTRA_BODY={}
|
||||
|
||||
|
||||
+53
-323
@@ -2,312 +2,6 @@
|
||||
|
||||
Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## Hy Memory 2.0.10 — 2026-11-01
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.10`](https://pypi.org/project/hy-memory/2.0.10/).
|
||||
- New `hy_memory.reembed` module for switching the embedding
|
||||
model. Writers must be paused for the whole run. Nothing is derived from
|
||||
the provider or endpoint: source and target are named explicitly, and the
|
||||
identity of a space is the model name plus the width (`space_identity`).
|
||||
Only Chroma and Kuzu are supported.
|
||||
- `vector_space_names(config, base_name=None)` returns every physical
|
||||
collection a client opens for the six vector roles: memory, entities,
|
||||
coding keys and working facts, each in the working and coding variants.
|
||||
- `reembed_vector_space(source, target, embedding=...)` pages every raw
|
||||
source collection, covering all users and all layers including L1_RAW. It
|
||||
embeds the text the write path embedded (`reembed_text`: the canonical
|
||||
state's `state_retrieval_text`, a schema's `retrieval_text`, otherwise the
|
||||
document). It then writes each record into the target with the same id,
|
||||
document and metadata. Each batch's widths are checked before anything is
|
||||
written. A cancel stops at a batch boundary (`ReembedCancelled`). A rerun
|
||||
skips the ids the target already holds, so it resumes, and a merge never
|
||||
overwrites the target. The source is never written.
|
||||
- `plan_reembed`, `verify_reembed` and `drop_vector_space` complete the
|
||||
vector side. `verify_reembed` checks ids, metadata, per-user counts and
|
||||
the width, and runs a self-recall sample. `drop_vector_space` refuses
|
||||
collections that a live client in this process holds open.
|
||||
- `scan_vector_spaces(persist_directory)` is read-only. It lists every
|
||||
space on disk with its roles, counts, stored width, per-user counts and
|
||||
the models recorded in metadata. `diff_spaces(source, target)` returns the
|
||||
ids that the source holds and the target lacks, whatever their status.
|
||||
- `reembed_graph(source_path, target_path, embedding=..., target_dims=...)`
|
||||
builds a new Kuzu graph at the new width. It copies every node and edge
|
||||
and recomputes `Memory.embedding`, `Memory.beh_embedding`, and the Topic
|
||||
and HierarchyTopic embeddings, then rebuilds the HNSW index. The source
|
||||
graph is opened read-only and kept for rollback. A run that fails or is
|
||||
cancelled leaves no target behind, and a rerun starts over.
|
||||
`verify_graph_reembed` and `drop_graph` complete the graph side.
|
||||
- `EmbedService.embed_batch` now splits large batches into
|
||||
requests of `embedder.batch_size` texts each (`MEMORY_EMBEDDER_BATCH_SIZE`,
|
||||
default 64); before, it sent them all in one request. On a 429 it waits for
|
||||
the `Retry-After` time plus jitter, capped at 60 s. It takes a per-call
|
||||
`max_retries`. A returned vector whose width differs from the configured
|
||||
`embedding_dims` now raises `EmbeddingDimensionMismatchError` and is not
|
||||
retried.
|
||||
- Raw conversations are stored already trimmed. MemHub now keeps every raw
|
||||
forever and lite mode stores nothing else, so tool output and long pasted
|
||||
logs no longer stay in full. One rule (`hy_memory/utils/raw_trim.py`) is
|
||||
applied to each message when the raw is written:
|
||||
- `user` and `system` turns are kept in full;
|
||||
- `assistant` turns and other speakers (`[John]:`) longer than 500
|
||||
characters, after stripping, are cut to the first 500 plus `...`. This is
|
||||
the limit history injection used before;
|
||||
- `tool` turns keep their head and tail, and the middle becomes
|
||||
`[...N bytes omitted by truncate...]`, with the coding limit
|
||||
`coding.tool_result_max_bytes` (`MEMORY_CODING_TOOL_RESULT_MAX_BYTES`,
|
||||
default 2048).
|
||||
|
||||
The rule is idempotent. It covers chat `L1_RAW` in lite, pro and ultra (lite
|
||||
keeps tool turns, because it has no coding route) and the `memory_history`
|
||||
ADD record. The coding/working `coding_trace` was already trimmed when it
|
||||
was written. Its tool and assistant truncations are now idempotent, so
|
||||
reading the history back no longer rewrites the omitted-count markers. The
|
||||
text embedded for `L1_RAW` is the trimmed text. New raws carry
|
||||
`custom.raw_trimmed=true`.
|
||||
- Extractor history ("last k messages") now joins the stored raws directly
|
||||
and does not trim them again. Raws written before this change have no
|
||||
`raw_trimmed` flag, so they are still trimmed with the same rule when they
|
||||
are read. The extractor still receives the current batch in full (tool
|
||||
turns are still removed on the chat route).
|
||||
|
||||
## MemHub 1.0.5 — 2026-10-28
|
||||
|
||||
Build 36, on `hy-memory==2.0.8`. Linux x64/arm64 Runtime and macOS arm64
|
||||
App and Runtime. Upgrading from 1.0.4 migrates the existing library to SDK
|
||||
authority on first launch.
|
||||
|
||||
- MemHub turns off the HY pipeline log and trace at every diagnostic level
|
||||
(hy-memory 2.0.8 `MEMORY_PIPELINE_LOG_ENABLED=false`,
|
||||
`MEMORY_PIPELINE_TRACE_ENABLED=false`), so the per-step JSONL files under
|
||||
`hy-memory/governed-v2/logs/` and the `pipeline_logs` rows stop growing.
|
||||
The Debug panel no longer shows the HY pipeline section. Existing files are
|
||||
left in place.
|
||||
- HY SDK text logs are written once, to `hy_memory.log`. They are no longer
|
||||
copied to `service.log` and `service-error.log` through the SDK's
|
||||
stdout/stderr streams. SDK warnings and errors also go to `core.log`, so the
|
||||
Debug panel still shows them. A live switch of the diagnostic level now also
|
||||
changes the level of `hy_memory.log`.
|
||||
|
||||
- MemHub shows when working and coding experiences were talked about. After
|
||||
the SDK upgrade every one of them read "时间未知" (time unknown) because the
|
||||
SDK never stored their conversation time; hy-memory 2.0.7 stores it and
|
||||
Core now passes it through the memory list, detail, search and home
|
||||
summary. An experience stored before 2.0.7 shows its write time, labelled
|
||||
"写入于" / "写入时间", instead of "时间未知". Metadata repair also writes
|
||||
the conversation time it recovers from the migration bundle into the SDK's
|
||||
coding/working stores when they already have the column.
|
||||
- The SDK migration no longer fails its first run on a home with legacy
|
||||
vectors: reattaching them left a Chroma instance open with its own settings,
|
||||
and the readback that followed could not open the same store
|
||||
("An instance of Chroma already exists ... with different settings").
|
||||
- MemHub "添加记忆" (add a memory) no longer waits behind unprocessed history.
|
||||
Notes are claimed ahead of the event-time window that keeps history strictly
|
||||
old to new; they still run one at a time and in order within their session.
|
||||
- The SDK memory page now reads its list, count, source and time labels and
|
||||
graph from one page response for one source filter, so rows, totals and the
|
||||
map no longer disagree. Memories with no recorded source are shown and
|
||||
filterable as "unidentified source"; library search keeps only memories
|
||||
that contain the words within the SDK's relevance window.
|
||||
- Metadata repair v2 follows `governed_memory_evidence` across every
|
||||
pre-cutover bundle, which restores the source of memories the cutover
|
||||
backfilled without one, and moves impossible future conversation times back
|
||||
to the write time.
|
||||
- Reused the existing three-column memory map for SDK-authority list/detail
|
||||
views and removed the retired "original evidence" surface. SDK-native status,
|
||||
source, conversation time and creation time now render without fabricated
|
||||
revisions; source and text filters are applied by the paged Core endpoint.
|
||||
- SDK memory search now calls the SDK search contract instead of listing the
|
||||
entire library in Core. Corrupt empty records are skipped and diagnosed
|
||||
without mutating data or failing a page, and post-cutover metadata repair
|
||||
restores source and original times from the verified migration bundle.
|
||||
- Kept Cursor's WAL change token separate from the file mtime so a hash can no
|
||||
longer appear as a conversation date centuries in the future.
|
||||
- A trusted packaged Runtime now repairs a dirty same-build installation by
|
||||
staging a clean replacement, and `runtime_service_not_ready` is rendered as
|
||||
user-facing startup guidance instead of an internal code.
|
||||
- Fixed legacy-to-SDK startup migration to adopt native Coding Experience and
|
||||
Working Fact IDs instead of importing them as Chat layers. Raw migration now
|
||||
rebuilds bounded user/assistant turns from the latest three days, excludes
|
||||
tools, and enforces the same rolling retention after cutover.
|
||||
- Paused host capture writes with a live Desktop migration fence; Cursor
|
||||
History catch-up resumes from its existing checkpoint after cutover.
|
||||
- Removed MemHub's private `hy_memory_bridge` implementation. MemHub now calls
|
||||
public `HyMemoryClient` methods at runtime; prompt/reconcile/response
|
||||
monkey-patches and private VDB/Coding runtime access were deleted. Kuzu Graph
|
||||
projection/admin and stopped-writer App migration are the documented product
|
||||
exceptions.
|
||||
- Added a MemHub-managed native Hermes MemoryProvider with automatic prefetch,
|
||||
persistent per-session memory-ID deduplication, authorized Broker reads, and
|
||||
recall-context stripping to prevent memory recursion.
|
||||
- Added an extensible MemHub interface-localization layer with Simplified
|
||||
Chinese, English, and Spanish catalogs shared by Web and Electron-native
|
||||
surfaces. Interface language remains independent from memory content and
|
||||
model processing language.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.9 — 2026-11-01
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.9`](https://pypi.org/project/hy-memory/2.0.9/).
|
||||
- Placeholder values no longer overwrite the user's basic profile (L0). A
|
||||
small extraction model filled `basic_info` with `{"name": "用户"}`,
|
||||
`The user` or `N/A` when the user had said nothing about themselves, and
|
||||
each placeholder superseded the real value, so the L0 chain read
|
||||
费天翔 → Fei → 用户 → The user. Two defences:
|
||||
- `upsert_basic_profile` drops placeholder values: empty, `用户`,
|
||||
`the user`, `user`, `n/a`, `na`, `none`, `null`, `unknown`, `未提供`,
|
||||
`未知`, `不详`, `-`, `?` and the field's own name, compared
|
||||
case-insensitively with whitespace and punctuation stripped. When nothing
|
||||
is left it writes no new version and no new L0 head. Placeholders already
|
||||
stored in the chain no longer override earlier real values when the
|
||||
profile is assembled, so `get_user_profile` of an affected user shows the
|
||||
last real value again without any migration. This also stops an
|
||||
assistant quoting an old memory ("The user's name is 用户.") from being
|
||||
written back.
|
||||
- The extraction prompts (zh and en) state the rule: only what the user
|
||||
said in a `user` turn, never the field name or a placeholder, `{}`
|
||||
otherwise, with a small-talk counter-example.
|
||||
|
||||
## Hy Memory 2.0.8 — 2026-10-28
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.8`](https://pypi.org/project/hy-memory/2.0.8/).
|
||||
- New `MEMORY_PIPELINE_LOG_ENABLED` (default `true`) turns the pipeline JSONL
|
||||
log (`{MEMORY_LOG_DIR}/<subdir>/<date>.log`) off. With `false` no JSONL
|
||||
file or directory is written. When `MEMORY_PIPELINE_TRACE_ENABLED` is also
|
||||
`false`, `store_pipeline_log` becomes a no-op that returns `True` at once:
|
||||
no timing, redaction, background task or database write. The init log line
|
||||
now states whether the JSONL log is on or off.
|
||||
- Conversation and memory text no longer appears in INFO or WARNING logs. The
|
||||
`[add]` input, search queries and rewrites, coding experience tasks, fact
|
||||
statements and subjects, reconcile op details, extracted info, embedding
|
||||
inputs and unparsable model output moved to DEBUG. INFO lines keep ids,
|
||||
counts and character lengths; parse-failure warnings remain but report only
|
||||
the length of the output, e.g. `failed to parse output (812 chars)`. The
|
||||
`[embed]` INFO line now carries `request.input_chars` instead of
|
||||
`request.input`. The `JSON_PARSE_FAILED` error message from extraction no
|
||||
longer embeds the model output either.
|
||||
|
||||
## Hy Memory 2.0.7 — 2026-10-28
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.7`](https://pypi.org/project/hy-memory/2.0.7/).
|
||||
- Coding and working experiences and facts now keep their conversation time.
|
||||
`add(..., memory_at=...)` reached only the chat path before; the
|
||||
productivity path dropped it, so every experience had only its write time
|
||||
and MemHub showed them as "时间未知" (time unknown). `coding_memory_meta`
|
||||
and `working_fact` gain a nullable `memory_at` column (epoch seconds) in
|
||||
both the working and coding stores; existing databases get it on open, and
|
||||
their old rows stay `None`. Without `memory_at` a write uses the time the
|
||||
request arrived, as chat does. When an experience is updated by a later
|
||||
conversation it keeps the later of the two times.
|
||||
- `list_memories` (`scene="coding"`, `"working"` and `"all"`), `get` and
|
||||
`search` return `memory_at` for experiences and facts in the same field and
|
||||
format as chat (epoch seconds, `None` when unknown). Experience search
|
||||
results now also carry `created_at`.
|
||||
|
||||
## Hy Memory 2.0.6 — 2026-10-27
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.6`](https://pypi.org/project/hy-memory/2.0.6/).
|
||||
- `search(..., rerank=False)` skips every reranking stage for that one
|
||||
call (the local cross-encoder, the memory-graph rerank, and the BM25 rerank of
|
||||
working/coding facts), so results come back in retrieval-score order. The
|
||||
flag travels on the read request, so concurrent searches do not affect each
|
||||
other. The default (`True`) keeps current behavior; `/api/v1/search` accepts
|
||||
it too.
|
||||
- MMR diversity selection now runs only when the requested `limit` is
|
||||
below 20 (`MMR_MAX_TOP_K`) and is skipped for larger limits, where its
|
||||
quadratic token-overlap pass is slow and gains little. Previously it ran for
|
||||
every limit. `MEMORY_HYBRID_V2_MMR_LAMBDA` still sets the weight.
|
||||
Near-duplicate demotion follows the same gate: it compares each candidate
|
||||
with every earlier non-duplicate, which is quadratic in a pool that grows
|
||||
with `limit`, and large result pages should show similar items anyway.
|
||||
- `search(scene="all")` searches chat, working and coding at the same time
|
||||
and merges them with Reciprocal Rank Fusion (k=60) over each scene's own
|
||||
ranking, so no scene is crowded out by the other scenes' score scales. Each
|
||||
result says which scene and kind it came from. A scene that fails or is turned
|
||||
off is reported in `errors` and does not fail the search. Source filters apply
|
||||
to all three.
|
||||
- `list_memories(scene="all")` lists chat, working and coding memories
|
||||
together, newest update first, with `limit`/`offset` paging over the merged
|
||||
order and `total` summing all three. `scene="coding"` and `"working"` now
|
||||
honor `include_sources` / `exclude_sources`, and `total` counts only the
|
||||
filtered memories.
|
||||
- `update()` and `delete()` now work on working/coding experiences and
|
||||
facts, not just chat memories. They take the same optional `kind` / `variant`
|
||||
as `get()`, and an ID found in more than one place raises
|
||||
`AmbiguousMemoryIdError` instead of guessing. Updating an experience replaces
|
||||
its solution and keeps its task, unless a new `task=` is also passed; updating
|
||||
a fact replaces its statement. Deleting removes the stored row and its vectors.
|
||||
- `list_memories(scene="chat" | "coding" | "working")` lists one kind of
|
||||
memory. `coding` and `working` list that scene's whole store, newest update
|
||||
first (`workspace_id` still narrows it), and `total` is now the real count
|
||||
rather than the page size. The new `memory_stats()` returns the count and the
|
||||
latest created/updated time for each scene and their total, without reading
|
||||
any memory text. Previously coding and working memories could not be listed
|
||||
or counted without a workspace, so a library of 45 memories listed 22.
|
||||
- Reconcile keeps time order. Both reconcile prompts now state that only
|
||||
`memory_at` decides which memory is newer, that an older memory must never
|
||||
supersede or overwrite a newer one, and that an unknown time is not newer;
|
||||
the Chinese prompt now lists `memory_at` among the new-memory fields. The
|
||||
reconciler also enforces this in code: a SUPERSEDE or UPDATE aimed at a memory
|
||||
with a later `memory_at` becomes an ADD, so the newer memory stays current.
|
||||
- The HTTP server returns 409 `ambiguous_memory_id` and 400 for a bad
|
||||
`kind`/`variant` instead of 500, passes `kind`/`variant` (and `task` on PUT)
|
||||
to get/update/delete, and no longer reads a PUT/DELETE query string as part
|
||||
of the memory id.
|
||||
- `memory_stats()` reads working/coding times, which are stored as naive UTC,
|
||||
as UTC rather than local time.
|
||||
|
||||
## Hy Memory 2.0.5 — 2026-10-22
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.5`](https://pypi.org/project/hy-memory/2.0.5/).
|
||||
- Extended `get()` across Chat, Coding Experience, Working Fact, and raw IDs;
|
||||
the returned `layer`, `kind`, and `variant` identify the actual record.
|
||||
- Added explicit raw retrieval through the existing `search()` and
|
||||
`list_memories()` contracts, including user, agent, session, and source
|
||||
filters.
|
||||
- Added public embedding-service injection and corrected extraction language
|
||||
detection for structured OpenAI-style messages.
|
||||
- Added lifecycle status filters, durable idempotent write outcomes, and
|
||||
quiesced local data-plane snapshot/restore.
|
||||
- Kept legacy migration mechanics out of the public Client API; separate
|
||||
migration-only raw/import methods from development previews were removed.
|
||||
- Physical delete now reports partial VDB/Graph cleanup as failure.
|
||||
- Coding search no longer injects the version-evolution diff block by default;
|
||||
opt in with `MEMORY_CODING_CHAIN_BLOCK_ENABLED=true`.
|
||||
- Serialized SDK SQLite cache/history connection lifecycle and tightened
|
||||
Coding reconciliation to its agent/session/workspace authority.
|
||||
- Clarified persistent caller context through `meta`, while `metadata` remains
|
||||
legacy processing configuration.
|
||||
|
||||
## Hy Memory 2.0.5.dev4 — 2026-10-21
|
||||
|
||||
- Development preview used for SDK-authority migration validation; not the
|
||||
final `2.0.5` release.
|
||||
- Published as
|
||||
[`hy-memory==2.0.5.dev4`](https://pypi.org/project/hy-memory/2.0.5.dev4/).
|
||||
|
||||
## Hy Memory 2.0.5.dev3 — 2026-10-21
|
||||
|
||||
- Development preview; not the final `2.0.5` release.
|
||||
@@ -334,12 +28,6 @@ authority on first launch.
|
||||
APIs, offline local data-plane snapshot/restore, and fail-closed physical
|
||||
delete receipts.
|
||||
|
||||
## Hy Memory 2.0.4 — 2026-10-17
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.4`](https://pypi.org/project/hy-memory/2.0.4/) as a
|
||||
maintenance release.
|
||||
|
||||
## Hy Memory 2.0.3 — 2026-10-17
|
||||
|
||||
- Added an optional runtime HTTP transport factory so embedded products can
|
||||
@@ -350,6 +38,58 @@ authority on first launch.
|
||||
unchanged memory extraction, reconciliation, storage, and Coding/Working
|
||||
behavior.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Added a MemHub-managed native Hermes MemoryProvider with automatic prefetch,
|
||||
persistent per-session memory-ID deduplication, authorized Broker reads, and
|
||||
recall-context stripping to prevent memory recursion.
|
||||
- Added an extensible MemHub interface-localization layer with Simplified
|
||||
Chinese, English, and Spanish catalogs shared by Web and Electron-native
|
||||
surfaces. Interface language remains independent from memory content and
|
||||
model processing language.
|
||||
- `search(..., layers=["l1_raw"])` now explicitly searches SDK raw records in
|
||||
pro/ultra mode and keeps existing user/agent/session/source filters; default
|
||||
search continues to hide raw.
|
||||
- Added `import_memory_exact` for idempotent migration of an existing semantic
|
||||
memory with its canonical ID, lifecycle and supersession links, without
|
||||
extraction or reconciliation.
|
||||
- Added SDK-authoritative lifecycle listing with explicit status filters,
|
||||
idempotent durable write outcomes, exact raw import/get/list APIs, and
|
||||
quiesced local data-plane snapshot/restore contracts for the MemHub
|
||||
authority migration.
|
||||
- Physical delete now reports incomplete VDB/Graph cleanup as a partial failure
|
||||
instead of claiming success after only one store was changed.
|
||||
- Coding search no longer injects the version-evolution diff block
|
||||
(`chain_block`) by default. Enable with
|
||||
`MEMORY_CODING_CHAIN_BLOCK_ENABLED=true` or
|
||||
`CodingConfig.chain_block_enabled=True` (write-side observe unchanged).
|
||||
- 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
|
||||
@@ -364,17 +104,7 @@ authority on first launch.
|
||||
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 1.0.5 — Unreleased
|
||||
|
||||
- Updated the source version to `1.0.5` (build 14).
|
||||
- Pinned the public SDK to the stable `hy-memory==2.0.5` release.
|
||||
|
||||
## MemHub 1.0.4 — 2026-10-18
|
||||
|
||||
- Updated the source version to `1.0.4` (build 13).
|
||||
- Published the `v1.0.4` source and release metadata.
|
||||
|
||||
## MemHub 1.0.3 — 2026-10-17
|
||||
## MemHub 1.0.3 — Unreleased
|
||||
|
||||
- Updated the source version to `1.0.3` (build 7).
|
||||
- Pinned the public SDK to `hy-memory==2.0.3`.
|
||||
|
||||
@@ -33,7 +33,6 @@ English | [中文](README.zh.md)
|
||||
## Latest News
|
||||
|
||||
* *<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.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details.
|
||||
* *<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!
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
## 最新动态
|
||||
|
||||
* *<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.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。
|
||||
* *<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)!
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
| `MEMORY_EMBEDDER_TIMEOUT` | `15` | 单次超时,秒 |
|
||||
| `MEMORY_EMBEDDER_MAX_RETRIES` | `2` | 最大重试次数 |
|
||||
| `MEMORY_EMBEDDER_RETRY_DELAY` | `0.3` | 重试间隔,秒 |
|
||||
| `MEMORY_EMBEDDER_BATCH_SIZE` | `64` | `embed_batch` 单次请求最多发送的文本条数,超出按此切块 |
|
||||
| `MEMORY_EMBEDDER_EXTRA_HEADERS` | — | JSON object,附加 HTTP headers |
|
||||
| `MEMORY_EMBEDDER_EXTRA_BODY` | — | JSON object,附加请求 body |
|
||||
|
||||
@@ -192,7 +191,6 @@ SDK 不设置 Rerank Search 并发上限。开启后模型在 `HyMemoryClient`
|
||||
| `MEMORY_LOG_FORMAT` | `text` | `text` / `json` |
|
||||
| `MEMORY_LOG_DIR` | — | 统一日志根目录 |
|
||||
| `MEMORY_TRACE_LOG_DIR` | — | JSONL 日志目录的兼容旧名;优先使用 `MEMORY_LOG_DIR` |
|
||||
| `MEMORY_PIPELINE_LOG_ENABLED` | `true` | 是否写 pipeline JSONL 日志(`{MEMORY_LOG_DIR}/<subdir>/<date>.log`);`false` 完全不写 |
|
||||
| `MEMORY_PIPELINE_TRACE_ENABLED` | `true` | 是否写入 cache 的 pipeline trace 表 |
|
||||
| `MEMORY_MEMORY_OPERATIONS_ENABLED` | `false` | 是否写入 memory operation 审计记录 |
|
||||
| `MEMORY_HISTORY_ENABLE` | `true` | 是否启用历史记录 |
|
||||
|
||||
+6
-7
@@ -273,8 +273,8 @@ catalog 也可以在没有基础 L0 时形成只读的合成画像。
|
||||
|
||||
```python
|
||||
memory = client.get("memory_id")
|
||||
# raw ID 返回 L1_RAW,fact ID 返回对应事实层。
|
||||
raw_memory = client.get("raw_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")
|
||||
@@ -284,7 +284,7 @@ page = client.list_memories(
|
||||
limit=100,
|
||||
offset=0,
|
||||
order="desc",
|
||||
layers=["l1_raw"], # 显式列出 raw;不传仍沿用默认语义层列表
|
||||
layers=None,
|
||||
with_evolution=False,
|
||||
)
|
||||
|
||||
@@ -294,10 +294,9 @@ 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()` 是统一的 ID 精确读取:返回结果的 `layer`、`kind` 和
|
||||
`variant` 表明实际类型。授权由调用 SDK 前的产品边界完成,不由 SDK 根据 layer
|
||||
猜测。`list_memories()` 默认仍不包含 `L1_RAW`;显式传
|
||||
`layers=["l1_raw"]` 时按同一列表合同返回 raw。
|
||||
`get()` / `async_get()` 默认继续隐藏 `L1_RAW`,因此不会改变现有调用方的原文访问
|
||||
边界。只有显式传入 `include_raw=True` 才返回该层;这也是 lite 模式按 ID 读取
|
||||
写入内容及其 `meta` 的方式。MemHub 的 HTTP 读取接口不开放此参数。
|
||||
|
||||
普通 `list_memories()` 返回 `vdb` 桶,并在 Graph 有结果时附带 `graph`;传入
|
||||
`workspace_id` 时返回生产力记忆的 `scene="productivity"` 和 `experiences`。
|
||||
|
||||
@@ -55,33 +55,6 @@ except Exception:
|
||||
from .client import HyMemoryClient
|
||||
from .inspector import MemoryInspector
|
||||
from .runtime import SharedRuntime
|
||||
from .reembed import (
|
||||
EmbeddingSpaceIdentity,
|
||||
GraphReembedPlan,
|
||||
GraphReembedProgress,
|
||||
GraphReembedReport,
|
||||
GraphVerification,
|
||||
ReembedCancelled,
|
||||
ReembedPlan,
|
||||
ReembedProgress,
|
||||
ReembedReport,
|
||||
ReembedVerification,
|
||||
ScannedSpace,
|
||||
SpaceDiff,
|
||||
VectorSpace,
|
||||
diff_spaces,
|
||||
drop_graph,
|
||||
drop_vector_space,
|
||||
plan_reembed,
|
||||
reembed_graph,
|
||||
reembed_text,
|
||||
reembed_vector_space,
|
||||
scan_vector_spaces,
|
||||
space_identity,
|
||||
vector_space_names,
|
||||
verify_graph_reembed,
|
||||
verify_reembed,
|
||||
)
|
||||
from .snapshot import (
|
||||
DataPlaneRestore,
|
||||
DataPlaneSnapshot,
|
||||
@@ -92,12 +65,6 @@ from .snapshot import (
|
||||
|
||||
# ====== 配置 ======
|
||||
from .config import MemoryConfig
|
||||
from .embedding import (
|
||||
EmbeddingDimensionMismatchError,
|
||||
EmbeddingService,
|
||||
EmbeddingServiceProtocol,
|
||||
)
|
||||
from .retrieval import AmbiguousMemoryIdError, MemoryKind, MemoryVariant
|
||||
|
||||
# ====== Embedding 端点覆盖(per-call,不改 model/dims,只换接入端点)======
|
||||
from .core.embed_service import embed_endpoint_override
|
||||
@@ -156,41 +123,8 @@ __all__ = [
|
||||
"DataPlaneSnapshotRejected",
|
||||
"restore_local_data_plane",
|
||||
"snapshot_local_data_plane",
|
||||
# 换 embedding 模型:把向量库和图重新 embed 进新的 embedding 空间
|
||||
"EmbeddingSpaceIdentity",
|
||||
"GraphReembedPlan",
|
||||
"GraphReembedProgress",
|
||||
"GraphReembedReport",
|
||||
"GraphVerification",
|
||||
"ReembedCancelled",
|
||||
"ReembedPlan",
|
||||
"ReembedProgress",
|
||||
"ReembedReport",
|
||||
"ReembedVerification",
|
||||
"ScannedSpace",
|
||||
"SpaceDiff",
|
||||
"VectorSpace",
|
||||
"diff_spaces",
|
||||
"drop_graph",
|
||||
"drop_vector_space",
|
||||
"plan_reembed",
|
||||
"reembed_graph",
|
||||
"reembed_text",
|
||||
"reembed_vector_space",
|
||||
"scan_vector_spaces",
|
||||
"space_identity",
|
||||
"vector_space_names",
|
||||
"verify_graph_reembed",
|
||||
"verify_reembed",
|
||||
# 配置
|
||||
"MemoryConfig",
|
||||
# 公共扩展协议 / 精确读取类型
|
||||
"EmbeddingService",
|
||||
"EmbeddingServiceProtocol",
|
||||
"EmbeddingDimensionMismatchError",
|
||||
"MemoryKind",
|
||||
"MemoryVariant",
|
||||
"AmbiguousMemoryIdError",
|
||||
# Embedding 端点覆盖
|
||||
"embed_endpoint_override",
|
||||
# 高级 API
|
||||
|
||||
@@ -682,23 +682,6 @@ Output:
|
||||
```
|
||||
John's occupation and location are HIS attributes, so they go into `memory` with "John" as the subject. They must NEVER land in `basic_info.occupation` / `basic_info.location` — `basic_info` is exclusively the user's own profile. Only `name` (Tim, from the user message's `name` field) belongs there.
|
||||
|
||||
## Example 11 — Counter-example: small talk has no basic_info
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Hi"},
|
||||
{"role": "assistant", "content": "Hello! The user's name is The user. How can I help?"}]
|
||||
Memory Date: 2025-09-03
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
The user stated nothing about themselves. The assistant's line is not a user turn, and "The user" is a placeholder, not a name. Output `{}` — never `{"name": "The user"}` or `{"name": "user"}`.
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
@@ -766,14 +749,6 @@ Rules:
|
||||
fields (e.g. name, age, location, occupation, employer) **explicitly stated**
|
||||
in the NEW user messages. Never infer, never invent. If no field is explicitly
|
||||
stated, output `{{}}`.
|
||||
* HARD RULE: fill a field ONLY if the user stated it in a `user` turn.
|
||||
Anything said only by the assistant (including the assistant quoting an
|
||||
earlier memory such as "The user's name is X") does NOT count. If nothing
|
||||
qualifies, output `"basic_info": {{}}`.
|
||||
* NEVER write the field name itself or a placeholder as a value ("user",
|
||||
"The user", "用户", "name", "N/A", "unknown", "none", "-"). Omit the field.
|
||||
* Negative example: small talk such as `[user]: Hi` / `[assistant]: How can
|
||||
I help?`, or `[user]: Reply with exactly: PONG` → `"basic_info": {{}}`.
|
||||
* Exception for `name`: the `name` field on the user's own message, or a leading
|
||||
line "The user's name is X" / "用户名叫X", is an authoritative source for
|
||||
`name` — even though the user never said it out loud.
|
||||
@@ -842,14 +817,6 @@ Rules:
|
||||
fields (e.g. name, age, location, occupation, employer) **explicitly stated**
|
||||
in the NEW user messages. Never infer, never invent. If no field is explicitly
|
||||
stated, output `{{}}`.
|
||||
* HARD RULE: fill a field ONLY if the user stated it in a `user` turn.
|
||||
Anything said only by the assistant (including the assistant quoting an
|
||||
earlier memory such as "The user's name is X") does NOT count. If nothing
|
||||
qualifies, output `"basic_info": {{}}`.
|
||||
* NEVER write the field name itself or a placeholder as a value ("user",
|
||||
"The user", "用户", "name", "N/A", "unknown", "none", "-"). Omit the field.
|
||||
* Negative example: small talk such as `[user]: Hi` / `[assistant]: How can
|
||||
I help?`, or `[user]: Reply with exactly: PONG` → `"basic_info": {{}}`.
|
||||
* Exception for `name`: the `name` field on the user's own message, or a leading
|
||||
line "The user's name is X" / "用户名叫X", is an authoritative source for
|
||||
`name` — even though the user never said it out loud.
|
||||
@@ -1210,7 +1177,7 @@ class Extractor:
|
||||
if not tc_id:
|
||||
logger.error(
|
||||
f"[extractor] tool_call_id is empty! "
|
||||
f"raw_keys={list(tc_raw.keys())}"
|
||||
f"raw tc: {json.dumps(tc_raw, ensure_ascii=False, default=str)[:500]}"
|
||||
)
|
||||
try:
|
||||
t_res = await tool_registry.dispatch(
|
||||
@@ -1344,12 +1311,11 @@ class Extractor:
|
||||
if parsed is None:
|
||||
if not all_tool_calls:
|
||||
logger.warning(
|
||||
f"[extractor] JSON parse failed ({len(raw_text)} chars)"
|
||||
f"[extractor] JSON parse failed: {raw_text[:200]}"
|
||||
)
|
||||
logger.debug(f"[extractor] unparsable output: {raw_text[:200]!r}")
|
||||
return ExtractResult(
|
||||
success=False,
|
||||
error=f"JSON parse failed ({len(raw_text)} chars)",
|
||||
error=f"JSON parse failed: {raw_text[:200]}",
|
||||
error_code="JSON_PARSE_FAILED",
|
||||
raw_response=raw_text,
|
||||
tokens_used=total_tokens,
|
||||
|
||||
@@ -129,7 +129,8 @@ def _parse_tool_calls_list(raw_calls: list, _json) -> List[Dict[str, Any]]:
|
||||
tc_id = tc.get("id") or ""
|
||||
if not tc_id:
|
||||
logger.error(
|
||||
f"[llm] tool_call missing 'id'! keys={list(tc.keys())}"
|
||||
f"[llm] tool_call missing 'id'! raw: "
|
||||
f"{_json.dumps(tc, ensure_ascii=False, default=str)[:500]}"
|
||||
)
|
||||
|
||||
# 保留原始 tc 完整结构,只规范化 arguments
|
||||
|
||||
@@ -234,7 +234,7 @@ class MemAgent:
|
||||
f"identity={len(identity)} facts={len(facts)} "
|
||||
f"basic_info={basic_field_count}fields"
|
||||
)
|
||||
logger.debug(f"MemAgent: extracted_info={extract_result.info}")
|
||||
logger.info(f"MemAgent: extracted_info={extract_result.info}")
|
||||
|
||||
# 3. 冲突检测(如果有已有记忆)
|
||||
if mode == ProcessMode.FULL and existing_memories:
|
||||
|
||||
@@ -365,13 +365,6 @@ EXTRACT_PROMPT_ZH = """## 最近 k 条消息
|
||||
* basic_info:**只装用户本人的画像**。
|
||||
仅当 NEW user messages 中**明确**陈述稳定画像字段(如 姓名、年龄、所在地、职业、雇主)时才填写。
|
||||
绝不推断、绝不编造。没有任何字段被明确陈述时输出 `{{}}`。
|
||||
* **硬性规则**:只有用户在 `user` 回合里**明确说出**的字段才能填写。只在
|
||||
assistant 回合出现的内容(包括 assistant 引用旧记忆,如"The user's name is X"
|
||||
/ "用户名是X")一律不算。没有符合条件的字段时,必须输出 `"basic_info": {{}}`。
|
||||
* **绝不**把字段名本身或占位符当值写入("用户"、"The user"、"user"、"name"、
|
||||
"姓名"、"N/A"、"unknown"、"未知"、"未提供"、"-"),宁可省略该字段。
|
||||
* 负例:寒暄如 `[user]: 你好` / `[assistant]: 我可以帮你。`,或
|
||||
`[user]: Reply with exactly: PONG` → `"basic_info": {{}}`。
|
||||
* `name` 的例外:用户自己那条消息上的 `name` 字段,或文首形如
|
||||
"用户名叫X" / "The user's name is X" 的那一行,都是 `name` 的权威来源
|
||||
——即使用户本人从未开口自报姓名。
|
||||
@@ -447,13 +440,6 @@ EXTRACT_PROMPT_ZH_NO_INTENTION = """## 最近 k 条消息
|
||||
* basic_info:**只装用户本人的画像**。
|
||||
仅当 NEW user messages 中**明确**陈述稳定画像字段(如 姓名、年龄、所在地、职业、雇主)时才填写。
|
||||
绝不推断、绝不编造。没有任何字段被明确陈述时输出 `{{}}`。
|
||||
* **硬性规则**:只有用户在 `user` 回合里**明确说出**的字段才能填写。只在
|
||||
assistant 回合出现的内容(包括 assistant 引用旧记忆,如"The user's name is X"
|
||||
/ "用户名是X")一律不算。没有符合条件的字段时,必须输出 `"basic_info": {{}}`。
|
||||
* **绝不**把字段名本身或占位符当值写入("用户"、"The user"、"user"、"name"、
|
||||
"姓名"、"N/A"、"unknown"、"未知"、"未提供"、"-"),宁可省略该字段。
|
||||
* 负例:寒暄如 `[user]: 你好` / `[assistant]: 我可以帮你。`,或
|
||||
`[user]: Reply with exactly: PONG` → `"basic_info": {{}}`。
|
||||
* `name` 的例外:用户自己那条消息上的 `name` 字段,或文首形如
|
||||
"用户名叫X" / "The user's name is X" 的那一行,都是 `name` 的权威来源
|
||||
——即使用户本人从未开口自报姓名。
|
||||
@@ -683,23 +669,6 @@ Memory Date:2025-09-02
|
||||
```
|
||||
职业和所在地是 **John 的**属性,所以进 `memory` 并以"John"作主体,**绝不能**落到 `basic_info` 的 `occupation` / `location` —— `basic_info` 专属于用户本人。只有 `name`(Tim,来自用户消息的 `name` 字段)才该放进去。注意输出语言随输入语言(此例为英文)。
|
||||
|
||||
## 示例 11 —— 反例:寒暄没有 basic_info
|
||||
|
||||
新消息:
|
||||
[{"role": "user", "content": "你好"},
|
||||
{"role": "assistant", "content": "你好!我可以帮你。之前记得 The user's name is 用户."}]
|
||||
Memory Date:2025-09-03
|
||||
|
||||
输出:
|
||||
```json
|
||||
{
|
||||
"memory": [],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
用户没有说任何关于自己的信息。assistant 那句不是 user 回合,"用户" 也是占位符而不是名字。必须输出 `{}`——**绝不能**输出 `{"name": "用户"}`。
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
@@ -774,7 +743,6 @@ RECONCILE_PROMPT_ZH = """你是一个记忆整合系统(Memory Reconciler)
|
||||
|
||||
* content
|
||||
* owner
|
||||
* memory_at(发生时间)
|
||||
* tags
|
||||
|
||||
new memories 没有 ID。
|
||||
@@ -823,7 +791,6 @@ new memories 没有 ID。
|
||||
* 无损合并
|
||||
* 保留所有有效信息
|
||||
* 不要产生重复 memory
|
||||
* 两者在某个值上不一致时,保留 memory_at 更晚的那个值;绝不用更早的 memory 覆盖更新的值
|
||||
|
||||
### 同一次事件的补充 vs 另一次发生
|
||||
|
||||
@@ -856,7 +823,6 @@ new memories 没有 ID。
|
||||
* 新 memory 与旧 memory 在同一维度冲突
|
||||
* 两者不能同时为真
|
||||
* 旧 memory 已不再代表当前状态
|
||||
* 新 memory 的 memory_at 不早于旧 memory 的 memory_at
|
||||
|
||||
例如:
|
||||
|
||||
@@ -871,7 +837,6 @@ new memories 没有 ID。
|
||||
* wording 差异
|
||||
* 同义表达
|
||||
* 可共存偏好
|
||||
* 新 memory 的 memory_at 早于旧 memory:它是更早的状态,绝不能取代更新的状态(见「时间规则」)
|
||||
|
||||
要求:
|
||||
|
||||
@@ -894,7 +859,7 @@ new memories 没有 ID。
|
||||
|
||||
### 把多条旧记忆折叠进同一条链
|
||||
|
||||
如果**两条或更多**已有记忆描述的是**同一维度**,而新记忆是该维度上的最新变化(它的 memory_at 晚于所有被折叠的记忆),
|
||||
如果**两条或更多**已有记忆描述的是**同一维度**,而新记忆是该维度上的最新变化,
|
||||
你可以把它们折叠进同一条演化链。此时用 `memory_ids`(有序列表,**旧→新**)
|
||||
代替单个 `memory_id`:
|
||||
|
||||
@@ -986,16 +951,13 @@ SUPERSEDE 不需要复制旧内容,因为旧节点仍存在。
|
||||
|
||||
## 时间规则
|
||||
|
||||
每条 memory(新的和已有的)都带 memory_at,表示它发生的时间。记忆**不一定按发生顺序写入**:
|
||||
历史可能晚导入,导入还没处理完时用户也可能手动添加记忆。所以一条 new memory 可能**比已有记忆更早**。
|
||||
`memory_at: null`
|
||||
|
||||
* 新旧**只看 memory_at**。"New memories" 只表示"正在写入",不表示"更近"。
|
||||
* 更早的 memory 绝不能取代更新的 memory:不能 SUPERSEDE 一条更新的记忆,也不能用 UPDATE 拿更早的值覆盖更新的值。
|
||||
* 如果 new memory 比与它冲突的已有记忆更早:保留已有记忆作为当前状态;把 new memory 以"过去状态"ADD
|
||||
(用过去时,并带上时间,例如"2023 年用户住在上海"),没有新信息就不输出。
|
||||
* `memory_at: null` 表示时间未知。未知**不算更新**:不要用 memory_at 为 null 的 memory 去 SUPERSEDE 一条有时间的记忆。
|
||||
* 不要编造时间顺序。
|
||||
* 不要仅因为“看起来更新”就 supersede。
|
||||
表示时间未知。
|
||||
|
||||
不要编造时间顺序。
|
||||
|
||||
不要仅因为“看起来更新”就 supersede。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+12
-116
@@ -50,7 +50,7 @@ def is_canonical_state_node(node: Any) -> bool:
|
||||
|
||||
|
||||
def _log_ops_detail(ops: List["ReconcileOp"]) -> None:
|
||||
"""以单条 DEBUG(JSON list)输出 reconcile 的逐条结果;含记忆正文,不进 INFO。"""
|
||||
"""以单条 INFO(JSON list)输出 reconcile 的逐条结果,便于线上 debug。"""
|
||||
if not ops:
|
||||
return
|
||||
view = [
|
||||
@@ -68,7 +68,7 @@ def _log_ops_detail(ops: List["ReconcileOp"]) -> None:
|
||||
}
|
||||
for o in ops
|
||||
]
|
||||
logger.debug(
|
||||
logger.info(
|
||||
f"[reconciler] ops detail: {json.dumps(view, ensure_ascii=False, default=str)}"
|
||||
)
|
||||
|
||||
@@ -221,8 +221,6 @@ Rules:
|
||||
* merge losslessly
|
||||
* preserve all useful information
|
||||
* do NOT create duplicates
|
||||
* if the two disagree on a value, keep the value from the memory with the later
|
||||
`memory_at`; never let an older memory overwrite a newer value
|
||||
|
||||
### Same occurrence vs. another occurrence
|
||||
|
||||
@@ -257,7 +255,6 @@ Use ONLY when:
|
||||
* the new memory contradicts an existing memory
|
||||
* both cannot be true at the same time
|
||||
* the old memory would now give the wrong current-state answer
|
||||
* the new memory's `memory_at` is the same as or LATER than the existing memory's `memory_at`
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -271,8 +268,6 @@ Do NOT use for:
|
||||
* compatible preferences
|
||||
* wording differences
|
||||
* semantic equivalence
|
||||
* a new memory whose `memory_at` is EARLIER than the existing memory's — it is an
|
||||
older state and must never replace a newer one (see "Time order")
|
||||
|
||||
Rules:
|
||||
|
||||
@@ -294,8 +289,7 @@ Rules:
|
||||
### Folding several existing memories into one chain
|
||||
|
||||
If TWO OR MORE existing memories track the SAME dimension and the new memory is
|
||||
the latest change on that dimension — its `memory_at` is later than every folded
|
||||
memory's `memory_at` — you may fold them into a single evolution
|
||||
the latest change on that dimension, you may fold them into a single evolution
|
||||
chain. Use `memory_ids` (an ordered list, OLDEST → NEWEST) instead of a single
|
||||
`memory_id`:
|
||||
|
||||
@@ -324,7 +318,8 @@ older memories' text into it; they remain queryable in the chain history.
|
||||
* Separate dated occurrences stay separate ADDs; use `same_series` only for the
|
||||
same subject and same concrete activity/event type.
|
||||
* Keep each memory focused on one coherent topic.
|
||||
* Follow "Time order" below.
|
||||
* Do not invent chronology.
|
||||
* `memory_at: null` means unknown time.
|
||||
* **NEVER merge or supersede across different subjects.** The subject is whoever
|
||||
the content is about: "The user ..." vs "John ..." vs "Mary ...". Memories about
|
||||
different people are independent facts even when the sentence is otherwise
|
||||
@@ -337,27 +332,6 @@ older memories' text into it; they remain queryable in the chain history.
|
||||
|
||||
---
|
||||
|
||||
# Time order
|
||||
|
||||
Every memory, new or existing, has `memory_at`: when it happened. Memories are
|
||||
NOT always written in the order they happened — history can be imported late,
|
||||
and a memory can be added while older history is still being processed. A new
|
||||
memory can therefore be OLDER than an existing one.
|
||||
|
||||
* Decide which memory is newer ONLY by `memory_at`. "New memories" means "being
|
||||
written now", not "more recent".
|
||||
* An older memory must never replace a newer one: no SUPERSEDE of a newer
|
||||
memory, and no UPDATE that overwrites a newer value with an older one.
|
||||
* If a new memory is older than an existing memory it conflicts with, keep the
|
||||
existing memory as the current state. ADD the new one as a past state, worded
|
||||
in the past with its time (for example "In 2023 the user lived in Shanghai"),
|
||||
or output nothing if it adds no information.
|
||||
* `memory_at: null` means unknown time. Unknown is NOT newer: never SUPERSEDE a
|
||||
memory that has a known `memory_at` with one whose `memory_at` is null.
|
||||
* Do not invent chronology.
|
||||
|
||||
---
|
||||
|
||||
# Optional: `related`
|
||||
|
||||
On any op you MAY add a `related` array recording how the memory being written
|
||||
@@ -467,81 +441,6 @@ def append_reconcile_structure_prompt(
|
||||
return prompt + RECONCILE_STRUCTURE_METADATA_PROMPT_EN
|
||||
|
||||
|
||||
def _as_aware(value: Any) -> Optional[datetime]:
|
||||
"""memory_at as a comparable aware datetime; None when unknown or unreadable."""
|
||||
if isinstance(value, str) and value:
|
||||
try:
|
||||
value = datetime.fromisoformat(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(value, datetime):
|
||||
return None
|
||||
# Stored nodes rebuild memory_at from epoch seconds as naive local time
|
||||
# (MemoryNode.from_dict), so a naive value is local, not UTC.
|
||||
return value if value.tzinfo is not None else value.astimezone()
|
||||
|
||||
|
||||
def enforce_time_order(
|
||||
ops: List["ReconcileOp"],
|
||||
new_memory_at: Any,
|
||||
existing: Dict[str, Any],
|
||||
) -> List["ReconcileOp"]:
|
||||
"""An older memory must never replace a newer one, whatever the model says.
|
||||
|
||||
Memories are not always written in the order they happened: history is
|
||||
imported late, and a person can add a memory while older history is still
|
||||
being processed. The prompt tells the model to compare `memory_at`; this
|
||||
makes it hold. A SUPERSEDE or UPDATE aimed at an existing memory whose
|
||||
`memory_at` is later than the memory being written is turned into an ADD,
|
||||
so the newer memory stays the current state. Unknown times are not
|
||||
compared. A content-less UPDATE (the legacy DELETE mapping) is dropped.
|
||||
"""
|
||||
written_at = _as_aware(new_memory_at)
|
||||
if written_at is None:
|
||||
return ops
|
||||
kept: List[ReconcileOp] = []
|
||||
for op in ops:
|
||||
if op.op not in ("SUPERSEDE", "UPDATE"):
|
||||
kept.append(op)
|
||||
continue
|
||||
targets = list(op.memory_ids) if op.memory_ids else [op.memory_id]
|
||||
newer = [
|
||||
target for target in targets
|
||||
if target and (node := existing.get(target)) is not None
|
||||
and (target_at := _as_aware(getattr(node, "memory_at", None))) is not None
|
||||
and target_at > written_at
|
||||
]
|
||||
if not newer:
|
||||
kept.append(op)
|
||||
continue
|
||||
logger.warning(
|
||||
"[reconciler] %s blocked: memory_at %s is older than existing %s; "
|
||||
"kept the newer memory",
|
||||
op.op, written_at.isoformat(), ",".join(newer),
|
||||
)
|
||||
if not op.content:
|
||||
continue
|
||||
kept.append(ReconcileOp(
|
||||
op="ADD",
|
||||
content=op.content,
|
||||
layer=op.layer,
|
||||
owner=op.owner,
|
||||
tags=list(op.tags),
|
||||
tag_ners=list(op.tag_ners),
|
||||
topic_paths=list(op.topic_paths),
|
||||
event_facet=dict(op.event_facet),
|
||||
topic_assignments=list(op.topic_assignments),
|
||||
structure_references=list(op.structure_references),
|
||||
topic_paths_provided=op.topic_paths_provided,
|
||||
event_facet_provided=op.event_facet_provided,
|
||||
topic_assignments_provided=op.topic_assignments_provided,
|
||||
speculate=op.speculate,
|
||||
related=list(op.related),
|
||||
reason=f"time_order: older than {','.join(newer)}; {op.reason}"[:500],
|
||||
))
|
||||
return kept
|
||||
|
||||
|
||||
def build_reconcile_prompt(
|
||||
*,
|
||||
new_memories: List[str],
|
||||
@@ -1500,7 +1399,6 @@ class MemoryReconciler:
|
||||
_tok_total += response.tokens_used
|
||||
raw_response = response.content
|
||||
ops = self._parse_ops(raw_response)
|
||||
ops = enforce_time_order(ops, current_time, candidate_map)
|
||||
ops = self._inherit_exact_input_structure(
|
||||
ops, new_memories_with_meta,
|
||||
)
|
||||
@@ -1514,10 +1412,9 @@ class MemoryReconciler:
|
||||
logger.warning(
|
||||
f"[reconciler] RECONCILE JSON parse failed "
|
||||
f"(attempt {_attempt+1}/{_MAX_PARSE_ATTEMPTS}), "
|
||||
f"retrying: raw({len(raw_response or '')} chars)"
|
||||
f"retrying: raw="
|
||||
f"{raw_response[:200] if _log_llm_content_enabled() else '<redacted>'}"
|
||||
)
|
||||
if _log_llm_content_enabled():
|
||||
logger.debug(f"[reconciler] unparsable raw={raw_response[:200]!r}")
|
||||
|
||||
recon_elapsed = (_time.perf_counter() - t_recon) * 1000
|
||||
|
||||
@@ -1529,8 +1426,8 @@ class MemoryReconciler:
|
||||
error_code = "LLM_UNAVAILABLE"
|
||||
elif raw_response.strip():
|
||||
error_msg = (
|
||||
f"RECONCILE JSON parse failed after {_n_attempts} attempts "
|
||||
f"({len(raw_response)} chars)"
|
||||
f"RECONCILE JSON parse failed after {_n_attempts} attempts: "
|
||||
f"{raw_response[:200] if _log_llm_content_enabled() else '<redacted>'}"
|
||||
)
|
||||
error_code = "JSON_PARSE_FAILED"
|
||||
else:
|
||||
@@ -1807,8 +1704,7 @@ class MemoryReconciler:
|
||||
except Exception as _re:
|
||||
logger.debug(f"[reconciler] json_repair failed: {_re}")
|
||||
if data is None:
|
||||
logger.warning(f"[reconciler] failed to parse ops JSON ({len(text)} chars)")
|
||||
logger.debug(f"[reconciler] unparsable ops output: {text[:200]!r}")
|
||||
logger.warning(f"[reconciler] failed to parse ops JSON: {text[:200]}")
|
||||
return []
|
||||
|
||||
if not isinstance(data, list):
|
||||
@@ -2020,11 +1916,11 @@ class MemoryReconciler:
|
||||
if clash is not None:
|
||||
logger.warning(
|
||||
f"[reconciler] memory_id {clash} already touched by '{touched[clash]}', "
|
||||
f"current op '{op.op}' dropped"
|
||||
f"current op '{op.op} ({op.reason[:60]})' dropped"
|
||||
)
|
||||
continue
|
||||
for t in target_ids:
|
||||
touched[t] = op.op
|
||||
touched[t] = f"{op.op}({op.reason[:40]})"
|
||||
validated.append(op)
|
||||
|
||||
return validated
|
||||
@@ -211,9 +211,7 @@ def parse_tool_calls_from_json(raw: Any) -> List[ToolCall]:
|
||||
try:
|
||||
args = json.loads(args_raw) if args_raw.strip() else {}
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
f"[tools] failed to parse arguments as JSON ({len(args_raw)} chars)"
|
||||
)
|
||||
logger.warning(f"[tools] failed to parse arguments as JSON: {args_raw[:120]}")
|
||||
args = {}
|
||||
elif isinstance(args_raw, dict):
|
||||
args = args_raw
|
||||
|
||||
@@ -26,8 +26,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
import uuid
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
@@ -210,76 +208,6 @@ def _sanitize_kv(
|
||||
return result
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 占位符守卫(P0-1)
|
||||
# ================================================================
|
||||
# 弱模型(如 qwen3.5-2b)在用户什么都没说时,会把 schema 字段说明里的词当值
|
||||
# 填进 basic_info:{"name": "用户"} / "The user" / "N/A"。每个占位符都会
|
||||
# supersede 掉真实值,演化链变成 费天翔 → Fei → 用户 → The user。
|
||||
# 规则:占位符永远不入库;读侧组装 KV 时占位符也不覆盖已有真实值。
|
||||
|
||||
_PLACEHOLDER_VALUES = frozenset({
|
||||
"用户", "该用户", "本用户", "用户本人",
|
||||
"the user", "the user's", "user", "a user", "this user",
|
||||
"n/a", "na", "none", "null", "nil", "unknown", "unspecified",
|
||||
"not provided", "not specified", "not mentioned",
|
||||
"未提供", "未知", "不详", "未说明", "未提及", "无",
|
||||
"-", "?",
|
||||
})
|
||||
|
||||
# 内置字段的中文标签:弱模型也会把字段说明的中文词当值。
|
||||
_FIELD_LABELS: Dict[str, tuple] = {
|
||||
"name": ("姓名", "名字", "用户名", "用户姓名", "用户名字"),
|
||||
"age": ("年龄", "用户年龄"),
|
||||
"location": ("所在地", "地点", "城市", "用户所在地"),
|
||||
"occupation": ("职业", "工作", "用户职业"),
|
||||
"employer": ("雇主", "公司", "用户雇主"),
|
||||
}
|
||||
|
||||
# 去掉标点/符号后再比较,让 "The user." / "用户。" / "N/A" 都命中。
|
||||
# "-" / "?" 本身就是纯标点,剥完为空串,同样判为占位符。
|
||||
_PUNCT_RE = re.compile(r"[\s\.,;:!?'\"`~()\[\]{}<>/\\|_\-\u3000-\u303f\uff00-\uff0f\uff1a-\uff20]+")
|
||||
|
||||
|
||||
def _placeholder_key(value: str) -> str:
|
||||
s = unicodedata.normalize("NFKC", str(value or "")).strip().lower()
|
||||
s = s.replace("\u2019", "'")
|
||||
s = _PUNCT_RE.sub(" ", s)
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
_PLACEHOLDER_KEYS = frozenset(_placeholder_key(v) for v in _PLACEHOLDER_VALUES)
|
||||
|
||||
|
||||
def is_placeholder_value(field_name: str, value: Any) -> bool:
|
||||
"""value 是否为占位符(空 / 用户 / The user / N/A / 字段名本身 等)。
|
||||
|
||||
大小写不敏感、首尾空白与标点不敏感。数字(如 age=30)永远不是占位符。
|
||||
"""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, bool):
|
||||
return False
|
||||
if isinstance(value, (int, float)):
|
||||
return False
|
||||
key = _placeholder_key(value)
|
||||
if not key:
|
||||
return True
|
||||
if key in _PLACEHOLDER_KEYS:
|
||||
return True
|
||||
field_key = _placeholder_key(field_name)
|
||||
if field_key and key in (field_key, field_key.replace("_", " ")):
|
||||
return True
|
||||
for label in _FIELD_LABELS.get(field_name, ()):
|
||||
if key == _placeholder_key(label):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _drop_placeholders(kv: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {k: v for k, v in kv.items() if not is_placeholder_value(k, v)}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 公共入口:upsert_basic_profile
|
||||
# ================================================================
|
||||
@@ -359,8 +287,6 @@ async def _upsert_basic_profile_unlocked(
|
||||
# L0 Basic Info 从输入、唯一性到落盘都只属于 user_id。
|
||||
|
||||
new_kv = _sanitize_kv(kv or {}, allowed_fields=allowed_fields)
|
||||
# 单调性守卫:占位符永远不覆盖已有值,过滤后为空就不写新版本、不产生新链头。
|
||||
new_kv = _drop_placeholders(new_kv)
|
||||
if not new_kv:
|
||||
return BasicProfileUpsertResult(
|
||||
success=True, changed=False, reason="no valid fields after sanitize"
|
||||
@@ -587,8 +513,5 @@ def _assemble_full_kv_from_nodes(
|
||||
for k, v in kv.items():
|
||||
if allowed_set is not None and k not in allowed_set:
|
||||
continue
|
||||
# 历史上已落盘的占位符版本(如 name=用户)不覆盖更早的真实值。
|
||||
if is_placeholder_value(k, v):
|
||||
continue
|
||||
full_kv[k] = v
|
||||
return full_kv
|
||||
+502
-1325
File diff suppressed because it is too large.
Load diff
@@ -1166,10 +1166,7 @@ class CodingMemoryExtractor:
|
||||
obj = None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
logger.warning(
|
||||
f"[coding-extract] failed to parse output ({len(text or '')} chars)"
|
||||
)
|
||||
logger.debug(f"[coding-extract] unparsable output: {text!r}")
|
||||
logger.warning(f"[coding-extract] failed to parse output: {text!r}")
|
||||
return [], []
|
||||
|
||||
# "memories" 是双层化之前的 key,老 prompt 缓存 / 旧模型可能还这么回
|
||||
|
||||
@@ -454,10 +454,9 @@ class WorkingFactReconciler:
|
||||
workspace_id: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||
"""
|
||||
执行 reconcile + 持久化。memory_at 是这段对话的时间,记到新事实上。
|
||||
执行 reconcile + 持久化。
|
||||
|
||||
Returns:
|
||||
(真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖,
|
||||
@@ -494,7 +493,6 @@ class WorkingFactReconciler:
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
source=source,
|
||||
memory_at=memory_at,
|
||||
)
|
||||
logger.info(
|
||||
f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} "
|
||||
@@ -570,12 +568,9 @@ class WorkingFactReconciler:
|
||||
kept.append(fact)
|
||||
if blocked:
|
||||
logger.info(
|
||||
f"[fact-reconcile] subject gate blocked {len(blocked)} candidate(s): "
|
||||
+ ", ".join(f.fact_id for f in blocked)
|
||||
)
|
||||
logger.debug(
|
||||
f"[fact-reconcile] subject gate: draft subject={draft.subject!r} "
|
||||
f"blocked subjects={[f.subject for f in blocked]!r}"
|
||||
f"[fact-reconcile] subject gate blocked {len(blocked)} candidate(s) "
|
||||
f"for subject={draft.subject!r}: "
|
||||
+ ", ".join(f"{f.fact_id}({f.subject!r})" for f in blocked)
|
||||
)
|
||||
result.append(kept)
|
||||
return result
|
||||
@@ -748,10 +743,7 @@ class WorkingFactReconciler:
|
||||
obj = None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
logger.warning(
|
||||
f"[fact-reconcile] failed to parse ops output ({len(text or '')} chars)"
|
||||
)
|
||||
logger.debug(f"[fact-reconcile] unparsable ops output: {text!r}")
|
||||
logger.warning(f"[fact-reconcile] failed to parse ops output: {text!r}")
|
||||
return {}
|
||||
ops_raw = obj.get("ops")
|
||||
if not isinstance(ops_raw, list):
|
||||
@@ -789,7 +781,6 @@ class WorkingFactReconciler:
|
||||
workspace_id: Optional[str],
|
||||
branch: Optional[str] = None,
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||
inserted: List[WorkingFact] = []
|
||||
ops: List[FactReconcileOp] = []
|
||||
@@ -810,7 +801,6 @@ class WorkingFactReconciler:
|
||||
subject=draft.subject,
|
||||
details=draft.details,
|
||||
status="active",
|
||||
memory_at=memory_at,
|
||||
)
|
||||
|
||||
allowed = {c.fact_id for c in (candidates[idx] if idx < len(candidates) else [])}
|
||||
@@ -841,11 +831,8 @@ class WorkingFactReconciler:
|
||||
# 别的主体的状态不是。闸门关掉时这道复查一并跳过,否则闸门就没关干净。
|
||||
if self.subject_gate and _subject_conflict(draft.subject, old.subject):
|
||||
logger.warning(
|
||||
f"[fact-reconcile] refused cross-subject SUPERSEDE: {old.fact_id}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[fact-reconcile] cross-subject: old subject={old.subject!r} "
|
||||
f"draft subject={draft.subject!r}"
|
||||
f"[fact-reconcile] refused cross-subject SUPERSEDE: "
|
||||
f"{old.fact_id}({old.subject!r}) by draft subject {draft.subject!r}"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
|
||||
+10
-21
@@ -195,10 +195,8 @@ async def classify_messages_scene(
|
||||
result = _parse_scene_json(resp.content)
|
||||
if result is None:
|
||||
logger.warning(
|
||||
f"[coding-judge] failed to parse classify_messages output "
|
||||
f"({len(resp.content or '')} chars); defaulting to chat"
|
||||
f"[coding-judge] failed to parse classify_messages output: {resp.content!r}; defaulting to chat"
|
||||
)
|
||||
logger.debug(f"[coding-judge] unparsable classify_messages output: {resp.content!r}")
|
||||
await _store_coding_log(
|
||||
cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||||
session_id=session_id, step="CODING_JUDGE", prompt=prompt,
|
||||
@@ -209,8 +207,9 @@ async def classify_messages_scene(
|
||||
return "chat"
|
||||
scene = result["scene"]
|
||||
parsed = {**result, "is_coding": scene != "chat"}
|
||||
logger.info(f"[coding-judge] scene={scene}")
|
||||
logger.debug(f"[coding-judge] scene={scene} reason={result.get('reason', '')!r}")
|
||||
logger.info(
|
||||
f"[coding-judge] scene={scene} reason={result.get('reason', '')!r}"
|
||||
)
|
||||
await _store_coding_log(
|
||||
cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||||
session_id=session_id, step="CODING_JUDGE", prompt=prompt,
|
||||
@@ -296,10 +295,8 @@ async def classify_queries_scene(
|
||||
result = _parse_scene_json(resp.content)
|
||||
if result is None:
|
||||
logger.warning(
|
||||
f"[coding-judge] failed to parse classify_queries output "
|
||||
f"({len(resp.content or '')} chars); defaulting to chat"
|
||||
f"[coding-judge] failed to parse classify_queries output: {resp.content!r}; defaulting to chat"
|
||||
)
|
||||
logger.debug(f"[coding-judge] unparsable classify_queries output: {resp.content!r}")
|
||||
return "chat"
|
||||
scene = result["scene"]
|
||||
logger.info(f"[coding-judge] queries scene={scene}")
|
||||
@@ -425,18 +422,15 @@ async def classify_and_rewrite_queries(
|
||||
scene = _parse_scene_label(head)
|
||||
if scene is None:
|
||||
logger.warning(
|
||||
f"[coding-judge] classify_and_rewrite unrecognized head "
|
||||
f"({len(head)} chars); falling back to chat"
|
||||
f"[coding-judge] classify_and_rewrite unrecognized head={head!r}; falling back to chat"
|
||||
)
|
||||
logger.debug(f"[coding-judge] classify_and_rewrite unrecognized head={head!r}")
|
||||
return {"scene": "chat", "is_coding": False, "rewrite_query": rewrite, "ok": False}
|
||||
is_coding = scene != "chat"
|
||||
|
||||
logger.info(
|
||||
f"[coding-judge] classify_and_rewrite scene={scene} "
|
||||
f"rewrite({len(rewrite)} chars)"
|
||||
f"rewrite={rewrite!r}"
|
||||
)
|
||||
logger.debug(f"[coding-judge] classify_and_rewrite rewrite={rewrite!r}")
|
||||
return {"scene": scene, "is_coding": is_coding, "rewrite_query": rewrite, "ok": True}
|
||||
|
||||
|
||||
@@ -575,9 +569,8 @@ async def analyze_queries(
|
||||
parsed_scene = _parse_scene_label(head)
|
||||
if parsed_scene is None:
|
||||
logger.warning(
|
||||
f"[coding-judge] analyze_queries unrecognized head ({len(head)} chars); ok=False"
|
||||
f"[coding-judge] analyze_queries unrecognized head={head!r}; ok=False"
|
||||
)
|
||||
logger.debug(f"[coding-judge] analyze_queries unrecognized head={head!r}")
|
||||
return {**fallback, "since_raw": ""}
|
||||
scene = parsed_scene
|
||||
idx = 1
|
||||
@@ -596,10 +589,9 @@ async def analyze_queries(
|
||||
created_after = _since_to_created_after(since_raw, current_time)
|
||||
|
||||
logger.info(
|
||||
f"[coding-judge] analyze scene={scene} rewrite({len(rewrite)} chars) "
|
||||
f"[coding-judge] analyze scene={scene} rewrite={rewrite!r} "
|
||||
f"since={since_raw!r} created_after={created_after}"
|
||||
)
|
||||
logger.debug(f"[coding-judge] analyze rewrite={rewrite!r}")
|
||||
return {
|
||||
"scene": scene, "is_coding": scene != "chat", "rewrite_query": rewrite,
|
||||
"created_after": created_after, "since_raw": since_raw, "ok": True,
|
||||
@@ -622,10 +614,7 @@ def _since_to_created_after(since_raw: str, current_time: str) -> Optional[float
|
||||
return dt.timestamp()
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
logger.warning(
|
||||
f"[coding-judge] analyze_queries unparsable SINCE ({len(since_raw)} chars)"
|
||||
)
|
||||
logger.debug(f"[coding-judge] analyze_queries unparsable SINCE={since_raw!r}")
|
||||
logger.warning(f"[coding-judge] analyze_queries unparsable SINCE={since_raw!r}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ Coding Memory 预处理工具(规则,零 LLM)
|
||||
使用方式与 scene 路由见 docs/scene_coding_usage.md。
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ..pipelines.base import ChatMessage
|
||||
@@ -64,13 +63,6 @@ DEFAULT_MAX_TOOL_RESULT_BYTES = 2048
|
||||
DEFAULT_HEAD_BYTES = 1024
|
||||
DEFAULT_TAIL_BYTES = 512
|
||||
|
||||
# 本模块自己产出的省略标记。截断结果会再次经过截断(落库前一次、读历史时又一次),
|
||||
# 认出自己的标记才能保证幂等:已截断的文本再截一次原样返回,而不是把省略计数改写。
|
||||
_TOOL_OMIT_MARKER_RE = re.compile(r"\n\n\[\.\.\.\d+ bytes omitted by truncate\.\.\.\]\n\n")
|
||||
# 标记本身的字节数上限(数字位数有限),用于判断「已截断且只超出一个标记的长度」。
|
||||
_TOOL_OMIT_MARKER_MAX_BYTES = 128
|
||||
_ASSISTANT_OMIT_MARKER_RE = re.compile(r" \[\.\.\.\d+ chars omitted\.\.\.\] ")
|
||||
|
||||
|
||||
def truncate_tool_result_text(
|
||||
text: str,
|
||||
@@ -89,12 +81,6 @@ def truncate_tool_result_text(
|
||||
raw = text.encode("utf-8")
|
||||
if len(raw) <= max_bytes:
|
||||
return text
|
||||
# 已截断过(max_bytes 很小时标记本身会让结果略超上限):原样返回,保证幂等。
|
||||
if (
|
||||
len(raw) <= max_bytes + _TOOL_OMIT_MARKER_MAX_BYTES
|
||||
and _TOOL_OMIT_MARKER_RE.search(text)
|
||||
):
|
||||
return text
|
||||
# 自定义 max_bytes 可能小于默认 head+tail(1024+512)。按原比例缩放内容窗口,
|
||||
# 给省略标记预留空间,不能因为窗口和过大而把超限原文直接返回。
|
||||
marker_budget = min(96, max(24, max_bytes // 4))
|
||||
@@ -175,10 +161,6 @@ def truncate_assistant_text(
|
||||
text = "" if text is None else str(text)
|
||||
if len(text) <= head_chars + tail_chars:
|
||||
return text
|
||||
# 已截断过:中段恰好是本函数的省略标记 → 原样返回,保证幂等(否则会把
|
||||
# 省略计数改写成标记自身的长度)。
|
||||
if _ASSISTANT_OMIT_MARKER_RE.fullmatch(text[head_chars:len(text) - tail_chars]):
|
||||
return text
|
||||
omitted = len(text) - head_chars - tail_chars
|
||||
return (
|
||||
text[:head_chars]
|
||||
|
||||
@@ -388,10 +388,7 @@ class CodingMemoryReconciler:
|
||||
obj = None
|
||||
|
||||
if not obj or not isinstance(obj, dict):
|
||||
logger.warning(
|
||||
f"[coding-reconcile] failed to parse ops output ({len(text or '')} chars)"
|
||||
)
|
||||
logger.debug(f"[coding-reconcile] unparsable ops output: {text!r}")
|
||||
logger.warning(f"[coding-reconcile] failed to parse ops output: {text!r}")
|
||||
return []
|
||||
ops_raw = obj.get("ops") or []
|
||||
if not isinstance(ops_raw, list):
|
||||
@@ -444,10 +441,10 @@ class CodingMemoryReconciler:
|
||||
for op in ops:
|
||||
if op.action == "ADD":
|
||||
if op.draft_idx is None or not (0 <= op.draft_idx < len(drafts)):
|
||||
logger.warning(f"[coding-reconcile] drop invalid ADD: {_op_ref(op)}")
|
||||
logger.warning(f"[coding-reconcile] drop invalid ADD: {op.to_dict()}")
|
||||
continue
|
||||
if op.draft_idx in consumed:
|
||||
logger.warning(f"[coding-reconcile] drop ADD on consumed draft: {_op_ref(op)}")
|
||||
logger.warning(f"[coding-reconcile] drop ADD on consumed draft: {op.to_dict()}")
|
||||
continue
|
||||
d = drafts[op.draft_idx]
|
||||
memory = self._draft_to_new_memory(d)
|
||||
@@ -461,18 +458,18 @@ class CodingMemoryReconciler:
|
||||
|
||||
elif op.action == "UPDATE":
|
||||
if op.draft_idx is None or not (0 <= op.draft_idx < len(drafts)):
|
||||
logger.warning(f"[coding-reconcile] drop invalid UPDATE: {_op_ref(op)}")
|
||||
logger.warning(f"[coding-reconcile] drop invalid UPDATE: {op.to_dict()}")
|
||||
continue
|
||||
if not op.target_experience_id:
|
||||
logger.warning(f"[coding-reconcile] drop UPDATE missing target: {_op_ref(op)}")
|
||||
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_ref(op)}"
|
||||
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_ref(op)}")
|
||||
logger.warning(f"[coding-reconcile] drop UPDATE on consumed draft: {op.to_dict()}")
|
||||
continue
|
||||
d = drafts[op.draft_idx]
|
||||
memory = self._draft_to_new_memory(d, experience_id=op.target_experience_id)
|
||||
@@ -491,17 +488,17 @@ class CodingMemoryReconciler:
|
||||
|
||||
elif op.action == "DELETE":
|
||||
if not op.target_experience_id:
|
||||
logger.warning(f"[coding-reconcile] drop DELETE missing target: {_op_ref(op)}")
|
||||
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_ref(op)}"
|
||||
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:
|
||||
logger.warning(f"[coding-reconcile] drop DELETE no user_id context: {_op_ref(op)}")
|
||||
logger.warning(f"[coding-reconcile] drop DELETE no user_id context: {op.to_dict()}")
|
||||
continue
|
||||
try:
|
||||
ok = await self.store.delete(op.target_experience_id, user_id=user_id)
|
||||
@@ -580,7 +577,6 @@ class CodingMemoryReconciler:
|
||||
confidence=d.confidence,
|
||||
source=d.source,
|
||||
extraction_source=d.extraction_source,
|
||||
memory_at=d.memory_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -588,14 +584,6 @@ class CodingMemoryReconciler:
|
||||
# Helpers
|
||||
# ================================================================
|
||||
|
||||
def _op_ref(op: ReconcileOp) -> str:
|
||||
"""op 的日志引用:只带动作与编号,不带 LLM 写的 reason。"""
|
||||
return (
|
||||
f"action={op.action} draft_idx={op.draft_idx} "
|
||||
f"target={op.target_experience_id}"
|
||||
)
|
||||
|
||||
|
||||
def _truncate(s: str, n: int) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
|
||||
+11
-240
@@ -86,7 +86,6 @@ CREATE TABLE IF NOT EXISTS coding_memory_meta (
|
||||
confidence REAL NOT NULL DEFAULT 0.7,
|
||||
source TEXT NOT NULL DEFAULT 'default',
|
||||
extraction_source TEXT NOT NULL DEFAULT 'auto_extract',
|
||||
memory_at INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -110,7 +109,6 @@ CREATE TABLE IF NOT EXISTS working_fact (
|
||||
derived_experience_ids TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
fact_chain_summary TEXT NOT NULL DEFAULT '',
|
||||
memory_at INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
-- 反查前驱(谁的 fact_link 指向我)走这个索引,写演变总结时要顺着链往回走
|
||||
@@ -166,8 +164,6 @@ _ADDED_COLUMNS = {
|
||||
"coding_memory_meta": {
|
||||
"source_fact_ids": "TEXT NOT NULL DEFAULT '[]'",
|
||||
"extraction_source": "TEXT NOT NULL DEFAULT 'auto_extract'",
|
||||
# 对话时间,秒级 epoch(同 chat)。老库里的行是 NULL:那时没存过。
|
||||
"memory_at": "INTEGER",
|
||||
},
|
||||
# 早期版本存过一列 fact_chain(机械累积的历代说法),已废弃:同样的信息顺着
|
||||
# fact_link 反查就有。老库里那一列留着不管,有默认值不影响插入。
|
||||
@@ -181,7 +177,6 @@ _ADDED_COLUMNS = {
|
||||
"subject": "TEXT NOT NULL DEFAULT ''",
|
||||
"details": "TEXT NOT NULL DEFAULT ''",
|
||||
"source": "TEXT NOT NULL DEFAULT 'default'",
|
||||
"memory_at": "INTEGER",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -222,21 +217,6 @@ def _in_clause(column: str, values: Optional[List[str]]) -> Tuple[str, List[Any]
|
||||
return f"{column} IN ({placeholders})", list(values)
|
||||
|
||||
|
||||
def _source_where(
|
||||
include_sources: Optional[List[str]], exclude_sources: Optional[List[str]],
|
||||
) -> Tuple[str, List[Any]]:
|
||||
"""Caller-source filter as SQL (``1`` when unfiltered); exclusion wins."""
|
||||
clauses: List[str] = []
|
||||
params: List[Any] = []
|
||||
if include_sources:
|
||||
clauses.append(f"source IN ({','.join('?' for _ in include_sources)})")
|
||||
params.extend(include_sources)
|
||||
if exclude_sources:
|
||||
clauses.append(f"source NOT IN ({','.join('?' for _ in exclude_sources)})")
|
||||
params.extend(exclude_sources)
|
||||
return (" AND ".join(clauses) or "1"), params
|
||||
|
||||
|
||||
def _scope_matches(
|
||||
row_workspace_id: Optional[str],
|
||||
row_branch: Optional[str],
|
||||
@@ -295,29 +275,6 @@ def _parse_dt(s: Optional[str]) -> Optional[datetime]:
|
||||
return None
|
||||
|
||||
|
||||
def _from_epoch(value: Any) -> Optional[datetime]:
|
||||
"""memory_at 列(秒级 epoch)→ datetime;NULL 或坏值返回 None。"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(int(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _to_epoch(value: Optional[datetime]) -> Optional[int]:
|
||||
return int(value.timestamp()) if isinstance(value, datetime) else None
|
||||
|
||||
|
||||
def _later(a: Optional[datetime], b: Optional[datetime]) -> Optional[datetime]:
|
||||
"""两个对话时间取较晚的;一边缺就取另一边。"""
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
return a if _to_epoch(a) >= _to_epoch(b) else b
|
||||
|
||||
|
||||
def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
|
||||
"""SQLite row → CodingMemory"""
|
||||
return CodingMemory(
|
||||
@@ -336,7 +293,6 @@ def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
|
||||
confidence=row["confidence"],
|
||||
source=row["source"],
|
||||
extraction_source=row["extraction_source"],
|
||||
memory_at=_from_epoch(row["memory_at"]),
|
||||
created_at=_parse_dt(row["created_at"]),
|
||||
updated_at=_parse_dt(row["updated_at"]),
|
||||
)
|
||||
@@ -363,7 +319,6 @@ def _row_to_fact(row: sqlite3.Row) -> WorkingFact:
|
||||
# 老库刚 ADD COLUMN 时可能取不到这一列,用 keys() 兜一下
|
||||
fact_chain_summary=(row["fact_chain_summary"] or ""
|
||||
if "fact_chain_summary" in row.keys() else ""),
|
||||
memory_at=_from_epoch(row["memory_at"]),
|
||||
created_at=_parse_dt(row["created_at"]),
|
||||
)
|
||||
|
||||
@@ -461,36 +416,6 @@ class CodingMemoryStore:
|
||||
|
||||
self._initialized = False
|
||||
|
||||
@staticmethod
|
||||
def database_path(
|
||||
config: MemoryConfig,
|
||||
db_path: Optional[str] = None,
|
||||
variant: str = "working",
|
||||
) -> str:
|
||||
"""Return the authoritative SQLite path without creating anything."""
|
||||
if db_path is None:
|
||||
data_dir = (
|
||||
os.path.dirname(config.history.db_path)
|
||||
if config.history.db_path else "./data"
|
||||
)
|
||||
db_path = os.path.join(data_dir, "coding_memory.db")
|
||||
normalized = (
|
||||
"coding"
|
||||
if str(variant or "").strip().lower() == "coding"
|
||||
else "working"
|
||||
)
|
||||
return _variant_db_path(db_path, "_cv" if normalized == "coding" else "")
|
||||
|
||||
@classmethod
|
||||
def database_exists(
|
||||
cls,
|
||||
config: MemoryConfig,
|
||||
db_path: Optional[str] = None,
|
||||
variant: str = "working",
|
||||
) -> bool:
|
||||
"""Whether a coding namespace has an existing SQLite database."""
|
||||
return os.path.isfile(cls.database_path(config, db_path, variant))
|
||||
|
||||
async def initialize(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
@@ -605,11 +530,7 @@ class CodingMemoryStore:
|
||||
await self._upsert_vec_keys(memory)
|
||||
logger.info(
|
||||
f"[coding-store] inserted experience_id={memory.experience_id} "
|
||||
f"keys={1 + len(memory.search_keys)}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[coding-store] inserted experience_id={memory.experience_id} "
|
||||
f"task={memory.task!r}"
|
||||
f"task={memory.task!r} keys={1 + len(memory.search_keys)}"
|
||||
)
|
||||
|
||||
async def update(self, memory: CodingMemory) -> None:
|
||||
@@ -631,8 +552,6 @@ class CodingMemoryStore:
|
||||
memory.updated_at = datetime.utcnow()
|
||||
if memory.created_at is None:
|
||||
memory.created_at = prev.created_at
|
||||
# 合并后的经验讲的是两段对话,对话时间取较晚的那段
|
||||
memory.memory_at = _later(prev.memory_at, memory.memory_at)
|
||||
await _run_in_pool(self._upsert_sql_sync, memory)
|
||||
# 删旧 VDB
|
||||
await self._delete_vec_keys_by_count(memory.experience_id, prev_key_count)
|
||||
@@ -654,86 +573,6 @@ class CodingMemoryStore:
|
||||
logger.info(f"[coding-store] deleted experience_id={experience_id}")
|
||||
return True
|
||||
|
||||
async def edit_experience(
|
||||
self,
|
||||
experience_id: str,
|
||||
*,
|
||||
user_id: Optional[str] = None,
|
||||
task: Optional[str] = None,
|
||||
solution: Optional[str] = None,
|
||||
search_keys: Optional[List[str]] = None,
|
||||
) -> Optional[CodingMemory]:
|
||||
"""调用方手动改一条经验(client.update 用)。返回改后的经验;不存在返回 None。
|
||||
|
||||
与 update() 的区别:update 是写入链路的覆盖式重写,带写授权校验(同
|
||||
agent/session/workspace 才能改);这里是用户对已有条目的直接编辑,只动
|
||||
给定的字段,其余原样保留,因此不做授权校验。
|
||||
|
||||
向量行只嵌 task + search_keys(solution 不进向量),所以只改 solution 时
|
||||
不重嵌、不碰向量库;改了 task / search_keys 才删旧 key 行重写。
|
||||
updated_at 总是刷新。
|
||||
"""
|
||||
prev = await self.get_by_id(experience_id, user_id=user_id)
|
||||
if prev is None:
|
||||
return None
|
||||
updated = deepcopy(prev)
|
||||
if task is not None:
|
||||
updated.task = task
|
||||
if solution is not None:
|
||||
updated.solution = solution
|
||||
if search_keys is not None:
|
||||
updated.search_keys = list(search_keys)
|
||||
updated.updated_at = datetime.utcnow()
|
||||
await _run_in_pool(self._upsert_sql_sync, updated)
|
||||
if updated.task != prev.task or updated.search_keys != prev.search_keys:
|
||||
await self._delete_vec_keys_by_count(experience_id, 1 + len(prev.search_keys))
|
||||
await self._upsert_vec_keys(updated)
|
||||
logger.info(f"[coding-store] edited experience_id={experience_id}")
|
||||
return updated
|
||||
|
||||
async def edit_fact(
|
||||
self,
|
||||
fact_id: str,
|
||||
*,
|
||||
statement: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional[WorkingFact]:
|
||||
"""调用方手动改一条事实的 statement。返回改后的事实;不存在返回 None。
|
||||
|
||||
statement 是事实层的检索文本,所以 active 事实会重嵌并覆盖它那一行向量
|
||||
(node_id 就是 fact_id,upsert 即覆盖)。superseded 事实的向量行早已撤掉,
|
||||
这里不把它加回去,否则会让过期结论重新占召回名额。
|
||||
"""
|
||||
prev = await self.get_fact(fact_id, user_id=user_id)
|
||||
if prev is None:
|
||||
return None
|
||||
updated = deepcopy(prev)
|
||||
updated.statement = statement
|
||||
await _run_in_pool(self._upsert_fact_sql_sync, updated)
|
||||
if updated.status == "active":
|
||||
await self._upsert_fact_vec(updated)
|
||||
logger.info(f"[coding-store] edited fact_id={fact_id}")
|
||||
return updated
|
||||
|
||||
async def delete_fact(self, fact_id: str, user_id: Optional[str] = None) -> bool:
|
||||
"""物理删除一条事实(SQLite 行 + 事实向量行),返回是否真有删到。
|
||||
|
||||
别处留下的引用(经验的 source_fact_ids、后继事实的 fact_link)不清:
|
||||
召回侧本来就按存在性过滤死链(get_many / get_facts 查不到自然掉队)。
|
||||
"""
|
||||
prev = await self.get_fact(fact_id, user_id=user_id)
|
||||
if prev is None:
|
||||
logger.info(f"[coding-store] delete fact skipped (not found): {fact_id}")
|
||||
return False
|
||||
if self._fact_vec is not None:
|
||||
try:
|
||||
await self._fact_vec.delete(fact_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"[coding-store] delete fact vec {fact_id} ignored: {e}")
|
||||
await _run_in_pool(self._delete_fact_sql_sync, fact_id, prev.user_id)
|
||||
logger.info(f"[coding-store] deleted fact_id={fact_id}")
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 读取
|
||||
# ------------------------------------------------------------
|
||||
@@ -818,84 +657,29 @@ class CodingMemoryStore:
|
||||
branch: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
) -> List["CodingMemory"]:
|
||||
"""按 workspace_id(+可选 branch)纯过滤列出该 user 的完整 coding memory。
|
||||
|
||||
作用域规则见 _scope_where(与 search 路径共用同一套)。无语义排序,按
|
||||
updated_at 倒序(最近修改优先)。include_sources / exclude_sources 按
|
||||
source 列在 SQL 里过滤(排除优先),所以 LIMIT/OFFSET 在过滤之后生效。
|
||||
updated_at 倒序(最近修改优先)。
|
||||
"""
|
||||
return await _run_in_pool(
|
||||
self._list_by_workspace_sync, user_id, workspace_id, branch, limit, offset,
|
||||
include_sources, exclude_sources,
|
||||
self._list_by_workspace_sync, user_id, workspace_id, branch, limit, offset
|
||||
)
|
||||
|
||||
def _list_by_workspace_sync(
|
||||
self, user_id: str, workspace_id: str, branch: Optional[str],
|
||||
limit: int, offset: int,
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
) -> List["CodingMemory"]:
|
||||
scope_sql, scope_params = _scope_where(workspace_id, branch)
|
||||
source_sql, source_params = _source_where(include_sources, exclude_sources)
|
||||
with self._lock:
|
||||
assert self._conn is not None
|
||||
sql = (f"SELECT * FROM coding_memory_meta WHERE user_id = ? AND {scope_sql} "
|
||||
f"AND {source_sql} "
|
||||
f"ORDER BY updated_at DESC, experience_id ASC LIMIT ? OFFSET ?")
|
||||
params: List[Any] = ([user_id] + scope_params + source_params
|
||||
+ [limit, offset])
|
||||
f"ORDER BY updated_at DESC LIMIT ? OFFSET ?")
|
||||
params: List[Any] = [user_id] + scope_params + [limit, offset]
|
||||
cur = self._conn.execute(sql, params)
|
||||
return [_row_to_memory(r) for r in cur.fetchall()]
|
||||
|
||||
async def stats(
|
||||
self,
|
||||
user_id: str,
|
||||
workspace_id: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""经验数、最新创建/修改时间、active 事实数。纯聚合,不读正文。
|
||||
|
||||
作用域与 list_by_workspace 相同:workspace_id 为空表示该 user 全部;
|
||||
来源过滤同样与 list_by_workspace 一致,count 因此恒等于分页可翻到的总数。
|
||||
"""
|
||||
return await _run_in_pool(
|
||||
self._stats_sync, user_id, workspace_id, branch,
|
||||
include_sources, exclude_sources,
|
||||
)
|
||||
|
||||
def _stats_sync(
|
||||
self, user_id: str, workspace_id: Optional[str], branch: Optional[str],
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
scope_sql, scope_params = _scope_where(workspace_id, branch)
|
||||
source_sql, source_params = _source_where(include_sources, exclude_sources)
|
||||
with self._lock:
|
||||
assert self._conn is not None
|
||||
row = self._conn.execute(
|
||||
f"SELECT COUNT(*) AS n, MAX(created_at) AS created, "
|
||||
f"MAX(updated_at) AS updated FROM coding_memory_meta "
|
||||
f"WHERE user_id = ? AND {scope_sql} AND {source_sql}",
|
||||
[user_id] + scope_params + source_params,
|
||||
).fetchone()
|
||||
facts = self._conn.execute(
|
||||
f"SELECT COUNT(*) AS n FROM working_fact "
|
||||
f"WHERE user_id = ? AND status = 'active' AND {scope_sql} "
|
||||
f"AND {source_sql}",
|
||||
[user_id] + scope_params + source_params,
|
||||
).fetchone()
|
||||
return {
|
||||
"count": int(row["n"] or 0),
|
||||
"latest_created_at": _parse_dt(row["created"]) if row["created"] else None,
|
||||
"latest_updated_at": _parse_dt(row["updated"]) if row["updated"] else None,
|
||||
"facts": int(facts["n"] or 0),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 召回
|
||||
# ------------------------------------------------------------
|
||||
@@ -1006,8 +790,7 @@ class CodingMemoryStore:
|
||||
fact.created_at = datetime.utcnow()
|
||||
await _run_in_pool(self._upsert_fact_sql_sync, fact)
|
||||
await self._upsert_fact_vec(fact)
|
||||
logger.info(f"[coding-store] inserted fact_id={fact.fact_id}")
|
||||
logger.debug(
|
||||
logger.info(
|
||||
f"[coding-store] inserted fact_id={fact.fact_id} "
|
||||
f"statement={fact.statement!r}"
|
||||
)
|
||||
@@ -1236,8 +1019,8 @@ class CodingMemoryStore:
|
||||
experience_id, user_id, agent_id, task, search_keys, solution,
|
||||
boundary_envs, workspace_id, branch, session_id,
|
||||
files, source_fact_ids, confidence, source, extraction_source,
|
||||
memory_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(experience_id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
agent_id = excluded.agent_id,
|
||||
@@ -1253,7 +1036,6 @@ class CodingMemoryStore:
|
||||
confidence = excluded.confidence,
|
||||
source = excluded.source,
|
||||
extraction_source = excluded.extraction_source,
|
||||
memory_at = excluded.memory_at,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
@@ -1264,7 +1046,6 @@ class CodingMemoryStore:
|
||||
json.dumps(memory.files, ensure_ascii=False),
|
||||
json.dumps(memory.source_fact_ids, ensure_ascii=False),
|
||||
memory.confidence, memory.source, memory.extraction_source,
|
||||
_to_epoch(memory.memory_at),
|
||||
(memory.created_at or datetime.utcnow()).isoformat(),
|
||||
(memory.updated_at or datetime.utcnow()).isoformat(),
|
||||
),
|
||||
@@ -1277,14 +1058,6 @@ class CodingMemoryStore:
|
||||
(experience_id, user_id),
|
||||
)
|
||||
|
||||
def _delete_fact_sql_sync(self, fact_id: str, user_id: str) -> None:
|
||||
with self._lock:
|
||||
assert self._conn is not None
|
||||
self._conn.execute(
|
||||
"DELETE FROM working_fact WHERE fact_id = ? AND user_id = ?",
|
||||
(fact_id, user_id),
|
||||
)
|
||||
|
||||
def _get_by_id_sync(
|
||||
self, experience_id: str, user_id: Optional[str]
|
||||
) -> Optional[CodingMemory]:
|
||||
@@ -1405,8 +1178,8 @@ class CodingMemoryStore:
|
||||
fact_id, user_id, agent_id, session_id, workspace_id, branch,
|
||||
source, statement, files, subject, details,
|
||||
fact_link, derived_experience_ids,
|
||||
status, fact_chain_summary, memory_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
status, fact_chain_summary, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(fact_id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
agent_id = excluded.agent_id,
|
||||
@@ -1421,8 +1194,7 @@ class CodingMemoryStore:
|
||||
fact_link = excluded.fact_link,
|
||||
derived_experience_ids = excluded.derived_experience_ids,
|
||||
status = excluded.status,
|
||||
fact_chain_summary = excluded.fact_chain_summary,
|
||||
memory_at = excluded.memory_at
|
||||
fact_chain_summary = excluded.fact_chain_summary
|
||||
""",
|
||||
(
|
||||
fact.fact_id, fact.user_id, fact.agent_id,
|
||||
@@ -1435,7 +1207,6 @@ class CodingMemoryStore:
|
||||
json.dumps(fact.derived_experience_ids, ensure_ascii=False),
|
||||
fact.status,
|
||||
fact.fact_chain_summary or "",
|
||||
_to_epoch(fact.memory_at),
|
||||
(fact.created_at or datetime.utcnow()).isoformat(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -25,11 +25,6 @@ FactStatus = Literal["active", "superseded"]
|
||||
MAX_FACT_CHAIN_ENTRIES = 8
|
||||
|
||||
|
||||
def _epoch(value: Optional[datetime]) -> Optional[int]:
|
||||
"""memory_at 对外是秒级 epoch,与 chat 一致。"""
|
||||
return int(value.timestamp()) if isinstance(value, datetime) else None
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 草稿(LLM extract 阶段输出)
|
||||
# ================================================================
|
||||
@@ -58,8 +53,6 @@ class CodingMemoryDraft:
|
||||
files: List[str] = field(default_factory=list)
|
||||
source: str = "default"
|
||||
extraction_source: str = "auto_extract"
|
||||
# 对话时间(SDK 注入,同 chat 的 memory_at)
|
||||
memory_at: Optional[datetime] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
from ..utils.source_filter import normalize_source
|
||||
@@ -80,7 +73,6 @@ class CodingMemoryDraft:
|
||||
"files": list(self.files),
|
||||
"source": self.source,
|
||||
"extraction_source": self.extraction_source,
|
||||
"memory_at": _epoch(self.memory_at),
|
||||
}
|
||||
|
||||
|
||||
@@ -119,9 +111,6 @@ class CodingMemory:
|
||||
extraction_source: str = "auto_extract"
|
||||
|
||||
# 时间
|
||||
# memory_at 是对话时间(同 chat),created_at / updated_at 是写库时间。
|
||||
# UPDATE 合并时取两者中较晚的那个对话时间,见 store.update。
|
||||
memory_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@@ -146,7 +135,6 @@ class CodingMemory:
|
||||
"confidence": self.confidence,
|
||||
"source": self.source,
|
||||
"extraction_source": self.extraction_source,
|
||||
"memory_at": _epoch(self.memory_at),
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -261,7 +249,6 @@ class WorkingFact:
|
||||
fact_chain_summary: str = ""
|
||||
|
||||
# 时间
|
||||
memory_at: Optional[datetime] = None # 对话时间(同 chat),没传时是收到请求的时间
|
||||
created_at: Optional[datetime] = None # 产生时间,新的优先
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@@ -285,7 +272,6 @@ class WorkingFact:
|
||||
"derived_experience_ids": list(self.derived_experience_ids),
|
||||
"status": self.status,
|
||||
"fact_chain_summary": self.fact_chain_summary,
|
||||
"memory_at": _epoch(self.memory_at),
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ CodingWriter - 写入端协调器
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..agent.llm_provider import LLMProvider
|
||||
@@ -51,7 +50,6 @@ class CodingWriter:
|
||||
embed_service: EmbedService,
|
||||
fact_reconciler: Optional[WorkingFactReconciler] = None,
|
||||
cache: Any = None,
|
||||
variant: str = "working",
|
||||
):
|
||||
self.store = store
|
||||
self.extractor = extractor
|
||||
@@ -61,11 +59,6 @@ class CodingWriter:
|
||||
self.fact_reconciler = fact_reconciler or WorkingFactReconciler(
|
||||
store, embed_service, llm_provider
|
||||
)
|
||||
self.variant = (
|
||||
"coding"
|
||||
if str(variant or "").strip().lower() == "coding"
|
||||
else "working"
|
||||
)
|
||||
# cache 仅用于把 request_id 串给各阶段(各阶段自己打点);writer 不集中打点。
|
||||
self.cache = cache
|
||||
|
||||
@@ -80,11 +73,9 @@ class CodingWriter:
|
||||
session_id: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
working/coding 段写入端到端。memory_at 是对话时间,记到这次写出的经验和
|
||||
事实上;不传则用收到请求的时间(与 chat 链路一致)。返回响应 dict(兼容 client.add 返回结构):
|
||||
working/coding 段写入端到端。返回响应 dict(兼容 client.add 返回结构):
|
||||
{
|
||||
"success": True,
|
||||
"scene": "productivity",
|
||||
@@ -104,8 +95,6 @@ class CodingWriter:
|
||||
"""
|
||||
request_id = request_id or str(uuid.uuid4())
|
||||
t0 = time.perf_counter()
|
||||
if memory_at is None:
|
||||
memory_at = datetime.now()
|
||||
|
||||
if not messages:
|
||||
return self._empty_response(request_id, t0)
|
||||
@@ -177,7 +166,6 @@ class CodingWriter:
|
||||
# extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。
|
||||
for draft in drafts:
|
||||
draft.source = source
|
||||
draft.memory_at = memory_at
|
||||
|
||||
# 3. 经验层 reconcile + 持久化
|
||||
ops: List[ReconcileOp] = (
|
||||
@@ -203,7 +191,6 @@ class CodingWriter:
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
source=source,
|
||||
memory_at=memory_at,
|
||||
)
|
||||
|
||||
experience_ids = [op.target_experience_id for op in ops
|
||||
@@ -230,7 +217,6 @@ class CodingWriter:
|
||||
return {
|
||||
"success": True,
|
||||
"scene": "productivity",
|
||||
"variant": self.variant,
|
||||
"request_id": request_id,
|
||||
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
|
||||
"ops": [op.to_dict() for op in ops],
|
||||
@@ -292,11 +278,11 @@ class CodingWriter:
|
||||
f"[coding-write] crosslink fact {f.fact_id} failed: {e}"
|
||||
)
|
||||
|
||||
def _empty_response(self, request_id: str, t0: float) -> Dict[str, Any]:
|
||||
@staticmethod
|
||||
def _empty_response(request_id: str, t0: float) -> Dict[str, Any]:
|
||||
return {
|
||||
"success": True,
|
||||
"scene": "productivity",
|
||||
"variant": self.variant,
|
||||
"request_id": request_id,
|
||||
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
|
||||
"ops": [],
|
||||
|
||||
@@ -260,7 +260,6 @@ class EmbedderConfig:
|
||||
max_retries: int = None # 最大重试次数
|
||||
retry_delay: float = None # 重试间隔(秒)
|
||||
timeout: int = None # 请求超时(秒)
|
||||
batch_size: int = None # 单次 HTTP 请求最多带多少条 input(embed_batch 按此切块)
|
||||
|
||||
# 内部平台扩展(extra_headers / extra_body 会透传给 OpenAI client)
|
||||
extra_headers: Optional[Dict[str, str]] = None
|
||||
@@ -295,10 +294,6 @@ class EmbedderConfig:
|
||||
# 请求切断并制造重试风暴。SDK 内层重试已关闭,15s 是每个显式 attempt
|
||||
# 的真实上限;部署可通过 MEMORY_EMBEDDER_TIMEOUT 覆盖。
|
||||
self.timeout = _get_env_int("MEMORY_EMBEDDER_TIMEOUT", 15)
|
||||
if self.batch_size is None:
|
||||
# 大批量(如重新 embed 整个库)不能一次 HTTP 发几千条:多数服务对
|
||||
# input 数组有上限,超了直接 400。按 batch_size 切块逐块发送。
|
||||
self.batch_size = _get_env_int("MEMORY_EMBEDDER_BATCH_SIZE", 64)
|
||||
if self.extra_headers is None:
|
||||
val = os.getenv("MEMORY_EMBEDDER_EXTRA_HEADERS")
|
||||
if val:
|
||||
|
||||
+23
-120
@@ -38,12 +38,10 @@ import importlib.util
|
||||
import json
|
||||
from json import JSONDecodeError
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
|
||||
from ..config import MemoryConfig
|
||||
from ..embedding import EmbeddingDimensionMismatchError
|
||||
from .workload import embedding_slot
|
||||
from ..utils.log_setup import get_request_id
|
||||
|
||||
@@ -82,7 +80,7 @@ def _count_tokens(text: str) -> int:
|
||||
try:
|
||||
import tiktoken
|
||||
|
||||
return len(tiktoken.get_encoding("cl100k_base").encode(text, disallowed_special=()))
|
||||
return len(tiktoken.get_encoding("cl100k_base").encode(text))
|
||||
except Exception:
|
||||
return len(text) // _CHARS_PER_TOKEN_FALLBACK + 1
|
||||
|
||||
@@ -100,8 +98,7 @@ def _truncate_for_embedding(text: str, limit: int = _EMBED_MAX_TOKENS) -> str:
|
||||
import tiktoken
|
||||
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
# Input is ordinary user text, not a tokenizer control-token stream.
|
||||
ids = enc.encode(text, disallowed_special=())
|
||||
ids = enc.encode(text)
|
||||
if len(ids) <= limit:
|
||||
return text
|
||||
dropped = len(ids) - limit
|
||||
@@ -189,41 +186,6 @@ def _redact_headers(headers: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any
|
||||
return out
|
||||
|
||||
|
||||
# Retry-After 的上限:服务端给出超长等待(或恶意值)时不让单次调用挂几十分钟。
|
||||
_RETRY_AFTER_MAX_S = 60.0
|
||||
|
||||
|
||||
def _retry_after_seconds(exc: Exception) -> Optional[float]:
|
||||
"""从 429 异常的响应头里取 Retry-After(秒数或 HTTP 日期),取不到返回 None。"""
|
||||
response = getattr(exc, "response", None)
|
||||
headers = getattr(response, "headers", None)
|
||||
if not headers:
|
||||
return None
|
||||
try:
|
||||
raw = headers.get("retry-after") or headers.get("Retry-After")
|
||||
except Exception:
|
||||
return None
|
||||
if raw is None:
|
||||
return None
|
||||
raw = str(raw).strip()
|
||||
try:
|
||||
seconds = float(raw)
|
||||
except ValueError:
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
when = parsedate_to_datetime(raw)
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=timezone.utc)
|
||||
seconds = (when - datetime.now(timezone.utc)).total_seconds()
|
||||
except Exception:
|
||||
return None
|
||||
if seconds != seconds: # NaN
|
||||
return None
|
||||
return min(max(0.0, seconds), _RETRY_AFTER_MAX_S)
|
||||
|
||||
|
||||
def _embeddings_api_url(base_url: str) -> str:
|
||||
"""OpenAI SDK posts to ``{base_url}/embeddings`` (base_url usually ends with /v1)."""
|
||||
base = (base_url or "").rstrip("/")
|
||||
@@ -242,8 +204,7 @@ class EmbedServiceConfig:
|
||||
api_key: str = ""
|
||||
base_url: str = ""
|
||||
embedding_dims: int = 1536
|
||||
# embed_batch 单次 HTTP 最多带多少条 input;超出按块顺序发送
|
||||
batch_size: int = 64
|
||||
batch_size: int = 100
|
||||
enable_cache: bool = True
|
||||
cache_size: int = 10000
|
||||
max_retries: int = 5
|
||||
@@ -325,7 +286,6 @@ class EmbedService:
|
||||
max_retries=embedder.max_retries if embedder.max_retries is not None else 5,
|
||||
retry_delay=embedder.retry_delay if embedder.retry_delay is not None else 3.0,
|
||||
timeout=embedder.timeout if embedder.timeout is not None else 60,
|
||||
batch_size=max(1, int(getattr(embedder, "batch_size", None) or 64)),
|
||||
queue_batch_size=int(os.getenv("MEMORY_EMBED_BATCH_SIZE", "8")),
|
||||
queue_batch_window_ms=float(os.getenv("MEMORY_EMBED_BATCH_WINDOW_MS", "100")),
|
||||
extra_headers=embedder.extra_headers,
|
||||
@@ -679,8 +639,6 @@ class EmbedService:
|
||||
self,
|
||||
texts: List[str],
|
||||
use_cache: bool = True,
|
||||
*,
|
||||
max_retries: Optional[int] = None,
|
||||
) -> List[List[float]]:
|
||||
"""
|
||||
批量向量化
|
||||
@@ -688,23 +646,17 @@ class EmbedService:
|
||||
Args:
|
||||
texts: 文本列表
|
||||
use_cache: 是否使用缓存
|
||||
max_retries: 本次调用的重试次数;None 用配置值。长批量任务(重新
|
||||
embed 整个库)可以给更宽的重试预算,不影响在线路径。
|
||||
|
||||
Returns:
|
||||
向量列表
|
||||
"""
|
||||
vectors, _metas = await self.embed_batch_with_meta(
|
||||
texts, use_cache=use_cache, max_retries=max_retries,
|
||||
)
|
||||
vectors, _metas = await self.embed_batch_with_meta(texts, use_cache=use_cache)
|
||||
return vectors
|
||||
|
||||
async def embed_batch_with_meta(
|
||||
self,
|
||||
texts: List[str],
|
||||
use_cache: bool = True,
|
||||
*,
|
||||
max_retries: Optional[int] = None,
|
||||
) -> Tuple[List[List[float]], List[Dict[str, Any]]]:
|
||||
"""批量向量化,同时返回每条的 provider metadata(非 vector)。"""
|
||||
if not texts:
|
||||
@@ -729,12 +681,7 @@ class EmbedService:
|
||||
indices_to_embed.append(i)
|
||||
|
||||
if texts_to_embed:
|
||||
# max_retries 只在显式给出时透传:保持 `_embed_texts_with_meta(texts)`
|
||||
# 这个既有调用形状不变(测试与下游替身按它打桩)。
|
||||
retry_kwargs = {} if max_retries is None else {"max_retries": max_retries}
|
||||
vectors, batch_metas = await self._embed_texts_with_meta(
|
||||
texts_to_embed, **retry_kwargs,
|
||||
)
|
||||
vectors, batch_metas = await self._embed_texts_with_meta(texts_to_embed)
|
||||
|
||||
for j, vector in enumerate(vectors):
|
||||
idx = indices_to_embed[j]
|
||||
@@ -752,9 +699,9 @@ class EmbedService:
|
||||
return results, metas
|
||||
|
||||
async def _embed_texts_with_meta(
|
||||
self, texts: List[str], *, max_retries: Optional[int] = None,
|
||||
self, texts: List[str]
|
||||
) -> Tuple[List[List[float]], List[Dict[str, Any]]]:
|
||||
"""实际的向量化调用:**一次 HTTP 带走一块**;返回 (vectors, metas)。
|
||||
"""实际的向量化调用:**一次 HTTP 带走整批**;返回 (vectors, metas)。
|
||||
|
||||
早期实现是 `for text in texts: await _embed_openai([text])` 逐条串行,
|
||||
原意是"避免 batch size 限制",但代价极大:线上一批 8 条 × ~5.6s 串行
|
||||
@@ -771,34 +718,21 @@ class EmbedService:
|
||||
截断在这里做(发送前逐条过 `_truncate_for_embedding`):所有 embedding
|
||||
路径(embed / embed_batch / embed_queued / *_with_meta)最终都落到这个
|
||||
方法 —— 放在各入口会漏。理由与阈值见模块头部 `_EMBED_MAX_TOKENS`。
|
||||
|
||||
切块:一次带走整批只适用于小批。几千条的 input 数组会被多数服务直接
|
||||
400(input 上限),所以按 `batch_size`(默认 64)切块,块之间顺序发送。
|
||||
不超过一块时行为与切块前逐字相同(`batch_mode="single_request"`)。
|
||||
"""
|
||||
if not texts:
|
||||
return [], []
|
||||
|
||||
safe_texts = [_truncate_for_embedding(t) for t in texts]
|
||||
chunk = max(1, int(self._embed_config.batch_size or 64))
|
||||
vectors: List[List[float]] = []
|
||||
vectors, meta = await self._embed_openai(safe_texts)
|
||||
base_meta = dict(meta or {})
|
||||
base_meta["batch_mode"] = "single_request"
|
||||
base_meta["batch_input_count"] = len(safe_texts)
|
||||
|
||||
all_metas: List[Dict[str, Any]] = []
|
||||
chunk_count = (len(safe_texts) + chunk - 1) // chunk
|
||||
for start in range(0, len(safe_texts), chunk):
|
||||
part = safe_texts[start:start + chunk]
|
||||
retry_kwargs = {} if max_retries is None else {"max_retries": max_retries}
|
||||
part_vectors, meta = await self._embed_openai(part, **retry_kwargs)
|
||||
base_meta = dict(meta or {})
|
||||
base_meta["batch_mode"] = "single_request" if chunk_count == 1 else "chunked"
|
||||
base_meta["batch_input_count"] = len(part)
|
||||
if chunk_count > 1:
|
||||
base_meta["batch_chunk_index"] = start // chunk
|
||||
base_meta["batch_chunk_count"] = chunk_count
|
||||
for idx in range(len(part)):
|
||||
m = dict(base_meta)
|
||||
m["batch_index"] = start + idx
|
||||
all_metas.append(m)
|
||||
vectors.extend(part_vectors)
|
||||
for idx in range(len(safe_texts)):
|
||||
m = dict(base_meta)
|
||||
m["batch_index"] = idx
|
||||
all_metas.append(m)
|
||||
|
||||
self._last_embed_meta = all_metas[-1] if all_metas else {}
|
||||
return vectors, all_metas
|
||||
@@ -819,22 +753,16 @@ class EmbedService:
|
||||
"""
|
||||
打一条 INFO 级 embedding 请求日志(含 request_id / provider / model / api / 入参 / 出参)。
|
||||
|
||||
出参只含 vector 维度,不含向量数值。入参的 input 是待向量化的原文
|
||||
(对话 / 记忆 / 查询),INFO 只记每条的字符数(input_chars),原文仅在
|
||||
DEBUG 单独输出。request_id 同时写入 message,
|
||||
出参只含 vector 维度,不含向量数值。request_id 同时写入 message,
|
||||
并依赖 log formatter 的 %(request_id)s(setup_logging 后生效)。
|
||||
"""
|
||||
texts = request_payload.get("input")
|
||||
info_request = {k: v for k, v in request_payload.items() if k != "input"}
|
||||
if isinstance(texts, list):
|
||||
info_request["input_chars"] = [len(str(t)) for t in texts]
|
||||
payload = {
|
||||
"request_id": get_request_id(),
|
||||
"status": status,
|
||||
"provider": self._embed_config.provider,
|
||||
"model": self._embed_config.model,
|
||||
"api": api_url,
|
||||
"request": info_request,
|
||||
"request": request_payload,
|
||||
"response": response_payload,
|
||||
}
|
||||
try:
|
||||
@@ -842,13 +770,9 @@ class EmbedService:
|
||||
except Exception:
|
||||
body = str(payload)
|
||||
logger.info(f"[embed] {body}")
|
||||
if texts is not None and logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
f"[embed] input: {json.dumps(texts, ensure_ascii=False, default=str)}"
|
||||
)
|
||||
|
||||
async def _embed_openai(
|
||||
self, texts: List[str], *, max_retries: Optional[int] = None,
|
||||
self, texts: List[str]
|
||||
) -> Tuple[List[List[float]], Dict[str, Any]]:
|
||||
"""
|
||||
使用 OpenAI 兼容接口向量化
|
||||
@@ -938,9 +862,7 @@ class EmbedService:
|
||||
"endpoint_overridden": bool(_ov),
|
||||
}
|
||||
|
||||
_max_retries = max(0, int(
|
||||
(self._embed_config.max_retries or 0) if max_retries is None else max_retries
|
||||
))
|
||||
_max_retries = max(0, int(self._embed_config.max_retries or 0))
|
||||
_retry_delay = max(0.0, float(self._embed_config.retry_delay or 0.0))
|
||||
last_exc: Optional[Exception] = None
|
||||
|
||||
@@ -986,17 +908,6 @@ class EmbedService:
|
||||
f"got={len(vectors)}"
|
||||
)
|
||||
dims = [len(v) for v in vectors]
|
||||
expected_dims = self._embed_config.embedding_dims
|
||||
if expected_dims:
|
||||
bad = [d for d in dims if d != int(expected_dims)]
|
||||
if bad:
|
||||
# 宽度不对的向量写进集合会把集合的维度钉死在错误值上(Chroma
|
||||
# 首次 add 定维),或在已有集合上整批失败。确定性错误,不重试。
|
||||
raise EmbeddingDimensionMismatchError(
|
||||
"embedding dimension mismatch: "
|
||||
f"expected={int(expected_dims)} got={bad[0]} "
|
||||
f"model={self._embed_config.model}"
|
||||
)
|
||||
meta = {
|
||||
"http_status": raw.status_code,
|
||||
"response_headers": resp_headers,
|
||||
@@ -1032,8 +943,6 @@ class EmbedService:
|
||||
},
|
||||
)
|
||||
return vectors, meta
|
||||
except EmbeddingDimensionMismatchError:
|
||||
raise
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
_err_name = type(e).__name__
|
||||
@@ -1067,18 +976,12 @@ class EmbedService:
|
||||
if _retryable and attempt < _max_retries:
|
||||
# 逐出可能卡死的 client,下个 attempt 重建干净连接(恢复原防御语义)
|
||||
self._evict_client(_eff_base_url, _eff_api_key, _eff_timeout)
|
||||
_delay = _retry_delay
|
||||
_retry_after = _retry_after_seconds(e) if _status == 429 else None
|
||||
if _retry_after is not None:
|
||||
# 限流时服务端明确告诉了要等多久:按它等,再加一点抖动,
|
||||
# 避免多个 worker 在同一时刻一起醒来再次撞限流。
|
||||
_delay = max(_delay, _retry_after) + random.uniform(0, 0.25 * _retry_after)
|
||||
logger.warning(
|
||||
f"[embed] attempt {attempt + 1}/{_max_retries + 1} failed "
|
||||
f"({_err_name}): {_err_msg[:200]}; retry in {_delay:.2f}s"
|
||||
f"({_err_name}): {_err_msg[:200]}; retry in {_retry_delay}s"
|
||||
)
|
||||
if _delay > 0:
|
||||
await asyncio.sleep(_delay)
|
||||
if _retry_delay > 0:
|
||||
await asyncio.sleep(_retry_delay)
|
||||
continue
|
||||
self._log_embed_call(
|
||||
status="error",
|
||||
|
||||
@@ -22,7 +22,6 @@ import logging
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
|
||||
from ..models.memory import MemoryNode, MemoryLayer, MemoryStatus
|
||||
from ..config import MemoryConfig
|
||||
@@ -78,21 +77,6 @@ async def _run_in_kuzu_pool(func, *args, **kwargs):
|
||||
return await run_in_executor_guarded(_kuzu_executor, _measured)
|
||||
|
||||
|
||||
# 本进程里已打开、尚未 close 的 store。drop_graph 据此拒绝删掉一个正被
|
||||
# 活着的 client 使用的图库。WeakSet:store 被回收后自动消失。
|
||||
_LIVE_STORES: "weakref.WeakSet[KuzuGraphStore]" = weakref.WeakSet()
|
||||
|
||||
|
||||
def open_graph_paths() -> List[str]:
|
||||
"""Real paths of the graph databases live stores in this process hold open."""
|
||||
import os
|
||||
return [
|
||||
os.path.realpath(str(store._db_path))
|
||||
for store in list(_LIVE_STORES)
|
||||
if store._db is not None
|
||||
]
|
||||
|
||||
|
||||
def kuzu_executor_snapshot() -> Dict[str, Any]:
|
||||
with _kuzu_stats_lock:
|
||||
return {
|
||||
@@ -366,7 +350,6 @@ class KuzuGraphStore(GraphStoreBase):
|
||||
)
|
||||
self._db = db
|
||||
self._conn = conn
|
||||
_LIVE_STORES.add(self)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"GraphStore init timed out after 5s (Kuzu file lock contention). "
|
||||
@@ -2458,7 +2441,6 @@ class KuzuGraphStore(GraphStoreBase):
|
||||
async def close(self) -> None:
|
||||
"""关闭连接"""
|
||||
conn, db = self._conn, self._db
|
||||
_LIVE_STORES.discard(self)
|
||||
self._conn = None
|
||||
self._db = None
|
||||
self._available = False
|
||||
|
||||
@@ -89,17 +89,6 @@ def passes_created_after(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def physical_collection_name(base_name: Optional[str], dims: Optional[int]) -> str:
|
||||
"""The collection name a store opens for ``base_name`` at ``dims``.
|
||||
|
||||
The dims suffix keeps two embedding widths from sharing one collection.
|
||||
`hy_memory.reembed.vector_space_names` uses this too, so callers never
|
||||
re-derive the rule.
|
||||
"""
|
||||
base = base_name or "agent_memories"
|
||||
return f"{base}_{dims}" if dims else base
|
||||
|
||||
|
||||
class VectorStoreBase(ABC):
|
||||
"""
|
||||
向量存储抽象基类
|
||||
@@ -109,10 +98,13 @@ class VectorStoreBase(ABC):
|
||||
|
||||
def __init__(self, config: MemoryConfig):
|
||||
self.config = config
|
||||
base_name = config.vector_store.collection_name or "agent_memories"
|
||||
dims = config.vector_store.embedding_dims
|
||||
# 自动拼接维度后缀,避免不同 embedding 模型写入同一 collection 导致维度冲突
|
||||
self._collection_name = physical_collection_name(
|
||||
config.vector_store.collection_name, config.vector_store.embedding_dims,
|
||||
)
|
||||
if dims:
|
||||
self._collection_name = f"{base_name}_{dims}"
|
||||
else:
|
||||
self._collection_name = base_name
|
||||
|
||||
_supports_retrieval_clauses: bool = False
|
||||
_supports_narrow_episode_queries: bool = False
|
||||
|
||||
@@ -9,12 +9,10 @@ Agent Memory V2 - VectorStore (Chroma)
|
||||
- 同步 API → _run_in_vdb_pool() 包装为异步
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any, Set, Tuple
|
||||
from typing import Optional, List, Dict, Any
|
||||
import concurrent.futures
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import weakref
|
||||
|
||||
from ..models.memory import (
|
||||
MemoryNode, MemoryLayer, MemoryStatus,
|
||||
@@ -35,30 +33,6 @@ _vdb_executor = concurrent.futures.ThreadPoolExecutor(
|
||||
)
|
||||
|
||||
|
||||
# 本进程里已 initialize、尚未 close 的 store。drop_vector_space 据此拒绝删掉
|
||||
# 一个正被活着的 client 使用的集合。WeakSet:store 被回收后自动消失。
|
||||
_LIVE_STORES: "weakref.WeakSet[ChromaVectorStore]" = weakref.WeakSet()
|
||||
|
||||
|
||||
def _location_key(vs_config) -> Tuple[str, str]:
|
||||
if getattr(vs_config, "host", None):
|
||||
return ("http", f"{vs_config.host}:{vs_config.port or 8000}")
|
||||
return ("local", os.path.realpath(str(vs_config.persist_directory or "")))
|
||||
|
||||
|
||||
def open_collection_names(location: Tuple[str, str]) -> Set[str]:
|
||||
"""Collections that live stores in this process hold open at ``location``."""
|
||||
names: Set[str] = set()
|
||||
for store in list(_LIVE_STORES):
|
||||
if store._client is None or _location_key(store.config.vector_store) != location:
|
||||
continue
|
||||
names.add(store._collection_name)
|
||||
if store._entity_collection is not None:
|
||||
from ..pipelines._retrieval.config import entity_collection_name
|
||||
names.add(entity_collection_name(store._collection_name))
|
||||
return names
|
||||
|
||||
|
||||
async def _run_in_vdb_pool(func, *args, **kwargs):
|
||||
"""Run a synchronous Chroma call in the dedicated VDB executor."""
|
||||
call = functools.partial(func, *args, **kwargs) if args or kwargs else func
|
||||
@@ -114,7 +88,6 @@ class ChromaVectorStore(VectorStoreBase):
|
||||
return client, collection
|
||||
|
||||
self._client, self._collection = await _run_in_vdb_pool(_init)
|
||||
_LIVE_STORES.add(self)
|
||||
logger.debug(f"VectorStore initialized (Chroma), collection={self._collection_name}")
|
||||
|
||||
# ================================================================
|
||||
@@ -588,7 +561,6 @@ class ChromaVectorStore(VectorStoreBase):
|
||||
async def close(self) -> None:
|
||||
"""关闭客户端"""
|
||||
# Chroma PersistentClient 无需显式关闭
|
||||
_LIVE_STORES.discard(self)
|
||||
self._client = None
|
||||
self._collection = None
|
||||
self._entity_collection = None
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Public structural contract for injected embedding services."""
|
||||
|
||||
from typing import Any, Dict, List, Protocol, Tuple, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmbeddingService(Protocol):
|
||||
"""Embedding service accepted by :class:`HyMemoryClient`.
|
||||
|
||||
Implementations are borrowed by the client: their lifecycle remains owned
|
||||
by the caller and ``HyMemoryClient.close()`` will not close them.
|
||||
"""
|
||||
|
||||
async def embed(self, text: str, use_cache: bool = True) -> List[float]:
|
||||
...
|
||||
|
||||
async def embed_with_meta(
|
||||
self, text: str, use_cache: bool = True
|
||||
) -> Tuple[List[float], Dict[str, Any]]:
|
||||
...
|
||||
|
||||
async def embed_batch(
|
||||
self, texts: List[str], use_cache: bool = True
|
||||
) -> List[List[float]]:
|
||||
...
|
||||
|
||||
async def embed_queued(
|
||||
self, text: str, use_cache: bool = True
|
||||
) -> List[float]:
|
||||
...
|
||||
|
||||
async def embed_queued_with_meta(
|
||||
self, text: str, use_cache: bool = True
|
||||
) -> Tuple[List[float], Dict[str, Any]]:
|
||||
...
|
||||
|
||||
def get_embedding_dims(self) -> int:
|
||||
...
|
||||
|
||||
|
||||
# Explicit alias for callers that prefer the conventional Protocol suffix.
|
||||
EmbeddingServiceProtocol = EmbeddingService
|
||||
|
||||
|
||||
class EmbeddingDimensionMismatchError(ValueError):
|
||||
"""Raised before initialization when service and store dimensions differ."""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EmbeddingService",
|
||||
"EmbeddingServiceProtocol",
|
||||
"EmbeddingDimensionMismatchError",
|
||||
]
|
||||
@@ -160,28 +160,6 @@ def near_dup_demote(
|
||||
return out
|
||||
|
||||
|
||||
# MMR 只在调用方请求的 top-k(search 的 limit)小于这个值时自动启用。
|
||||
# mmr_select 的贪心是 O(k^2 * pool) 次 Jaccard,k 大时代价陡增,而大 k 本身已
|
||||
# 足够容纳多样性,收益很小。权重仍由 MEMORY_HYBRID_V2_MMR_LAMBDA 控制。
|
||||
MMR_MAX_TOP_K = 20
|
||||
|
||||
|
||||
def mmr_applies(top_k: int, lambda_: float) -> bool:
|
||||
"""本次检索是否走 MMR:λ 在有效区间内,且 top_k < MMR_MAX_TOP_K。"""
|
||||
return 0.0 < float(lambda_) < 0.999 and int(top_k) < MMR_MAX_TOP_K
|
||||
|
||||
|
||||
def near_dup_applies(top_k: int) -> bool:
|
||||
"""本次检索是否走近重复降权:与 MMR 同一个 top-k 门槛。
|
||||
|
||||
near_dup_demote 让每个候选和此前所有"非重复"候选两两算 Jaccard,候选
|
||||
互不相似时是 O(pool^2);池子随 limit 放大(约 max(3*limit, 30) + 关键词
|
||||
命中),limit 达到 MMR_MAX_TOP_K 时代价同样陡增。大 k 的调用方(如记忆
|
||||
面板翻页)要的是全部相关项,相似项也应照常出现,降权没有收益。
|
||||
"""
|
||||
return int(top_k) < MMR_MAX_TOP_K
|
||||
|
||||
|
||||
def mmr_select(
|
||||
hits: Sequence[Dict[str, Any]],
|
||||
limit: int,
|
||||
|
||||
@@ -115,16 +115,11 @@ class ReconcileHybridRetriever:
|
||||
fused_max = max(fused_all) if fused_all else 0.0
|
||||
|
||||
base = (
|
||||
f"[reconcile-hybrid][{req}] pool={pool_size} q_terms={len(q_terms)} "
|
||||
f"[reconcile-hybrid][{req}] pool={pool_size} q_terms={q_terms!r} "
|
||||
f"raw_bm25 max={raw_max:.4f} nonzero={raw_nonzero}/{len(raw_bm25)} "
|
||||
f"bm25_norm max={norm_max:.4f} nonzero={norm_nonzero}/{len(bm25_norms)} "
|
||||
f"sem_max={sem_max:.4f} fused_max={fused_max:.4f} "
|
||||
f"kept={kept}/{len(fused_all)} min_score={self._cfg.min_score} "
|
||||
f"query({len(query_text or '')} chars)"
|
||||
)
|
||||
# query 是待对账的新记忆原文,词项与预览只进 DEBUG
|
||||
logger.debug(
|
||||
f"[reconcile-hybrid][{req}] q_terms={q_terms!r} "
|
||||
f"query={q_preview!r} lemmatized={lem_preview!r}"
|
||||
)
|
||||
|
||||
|
||||
@@ -401,11 +401,6 @@ class ReadRequest:
|
||||
intention_limit: int = 0 # Intention 路的 topk,默认 0 关闭
|
||||
|
||||
|
||||
# 重排开关(按次生效)。False 时跳过全部重排阶段:本地 cross-encoder
|
||||
# (recall.local_rerank_enabled) 与记忆关系图重排 (_apply_graph_rerank),
|
||||
# 结果按召回分排序。走请求对象而非环境变量,并发请求互不影响。
|
||||
rerank: bool = True
|
||||
|
||||
# 时间过滤
|
||||
created_after: Optional[float] = None # Unix timestamp (float),只返回 gmt_created >= 此值的记忆
|
||||
|
||||
|
||||
@@ -692,10 +692,9 @@ class CrossDomainSweeper:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[sweeper] Created L6 core {core_id[:12]} "
|
||||
f"[sweeper] Created L6 core {core_id[:12]}: '{core_content[:60]}' "
|
||||
f"from {len(cluster_ids)} basics, evidence_inherited={len(inherited_evidence_ids)}"
|
||||
)
|
||||
logger.debug(f"[sweeper] L6 core {core_id[:12]} content={core_content[:60]!r}")
|
||||
return 1, 0, [core_id]
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -57,9 +57,7 @@ from ._retrieval.ner import (
|
||||
memory_matches_time_hints,
|
||||
)
|
||||
from ._retrieval.diversity import (
|
||||
near_dup_applies,
|
||||
near_dup_demote,
|
||||
mmr_applies,
|
||||
mmr_select,
|
||||
social_boilerplate_demote,
|
||||
pool_df_demote,
|
||||
@@ -1281,10 +1279,7 @@ class BaseReadPipeline(ReadPipeline):
|
||||
SAME_SERIES_TIMELINE_ON = bool(
|
||||
getattr(self.config.recall, "same_series_timeline_enabled", False)
|
||||
)
|
||||
# 按次的重排开关(search(rerank=False)):关掉本地 cross-encoder 与图重排,
|
||||
# 结果按召回分排序。只读请求对象,不碰共享状态,并发请求互不影响。
|
||||
RERANK_ON = bool(getattr(request, "rerank", True))
|
||||
LOCAL_RERANK_ON = RERANK_ON and bool(
|
||||
LOCAL_RERANK_ON = bool(
|
||||
getattr(self.config.recall, "local_rerank_enabled", False)
|
||||
)
|
||||
LOCAL_RERANK_POOL_FACTOR = max(1, int(
|
||||
@@ -1436,11 +1431,6 @@ class BaseReadPipeline(ReadPipeline):
|
||||
MIN_SCORE = _resolve_normal_min_score(request.min_score)
|
||||
|
||||
final_limit = request.limit if request.limit > 0 else 10
|
||||
# MMR 只在调用方的 top-k(final_limit,即 search 的 limit)< MMR_MAX_TOP_K
|
||||
# 时启用;池子(LOCAL_RERANK 下是 final_limit 的若干倍)不参与判定。
|
||||
MMR_ON = mmr_applies(final_limit, MMR_LAMBDA)
|
||||
# 近重复降权同样是候选两两比较,用同一个 top-k 门槛。
|
||||
NEAR_DUP_ON = near_dup_applies(final_limit)
|
||||
vdb_sem_limit = max(final_limit * 3, 30)
|
||||
vdb_kw_limit = max(final_limit * 5, 80)
|
||||
graph_limit = max(
|
||||
@@ -2472,7 +2462,7 @@ class BaseReadPipeline(ReadPipeline):
|
||||
# 只把已选出的 top-N 换个顺序收益很小。
|
||||
relation_paths: List[Dict[str, Any]] = []
|
||||
_graph_stats: Dict[str, Any] = {}
|
||||
if RERANK_ON and self._expansion_on():
|
||||
if self._expansion_on():
|
||||
_t_graph = datetime.now()
|
||||
_pool_before = len(normal_results)
|
||||
normal_results, relation_paths, _graph_stats = await self._apply_graph_rerank(
|
||||
@@ -2535,7 +2525,7 @@ class BaseReadPipeline(ReadPipeline):
|
||||
df_thr=POOL_DF_THR,
|
||||
penalty=POOL_DF_PENALTY,
|
||||
)
|
||||
if NEAR_DUP_ON and NEAR_DUP_THR > 0 and NEAR_DUP_PENALTY > 0:
|
||||
if NEAR_DUP_THR > 0 and NEAR_DUP_PENALTY > 0:
|
||||
normal_results = near_dup_demote(
|
||||
normal_results,
|
||||
jaccard_thr=NEAR_DUP_THR,
|
||||
@@ -2548,7 +2538,7 @@ class BaseReadPipeline(ReadPipeline):
|
||||
))
|
||||
if LOCAL_RERANK_ON else final_limit
|
||||
)
|
||||
if MMR_ON and len(normal_results) > _pre_rerank_limit:
|
||||
if 0.0 < MMR_LAMBDA < 0.999 and len(normal_results) > _pre_rerank_limit:
|
||||
normal_results = mmr_select(
|
||||
normal_results,
|
||||
_pre_rerank_limit,
|
||||
@@ -2725,7 +2715,7 @@ class BaseReadPipeline(ReadPipeline):
|
||||
reverse=True,
|
||||
)
|
||||
if (
|
||||
MMR_ON
|
||||
0.0 < MMR_LAMBDA < 0.999
|
||||
and len(multi_seed_pool) > _pre_rerank_limit
|
||||
):
|
||||
multi_seed_pool = mmr_select(
|
||||
@@ -3554,9 +3544,6 @@ class BaseReadPipeline(ReadPipeline):
|
||||
"episode_capsule_render_mode": EPISODE_CAPSULE_RENDER_MODE,
|
||||
"same_series_timeline_enabled": SAME_SERIES_TIMELINE_ON,
|
||||
"local_rerank_enabled": LOCAL_RERANK_ON,
|
||||
"rerank": RERANK_ON,
|
||||
"mmr_applied": MMR_ON,
|
||||
"near_dup_applied": NEAR_DUP_ON,
|
||||
"local_rerank_model": getattr(
|
||||
self.config.recall,
|
||||
"local_rerank_model",
|
||||
@@ -3587,9 +3574,8 @@ class BaseReadPipeline(ReadPipeline):
|
||||
f"hidden_l0={len(hidden_l0_hits)} fwd_l6={len(forward_l6)} rev_l6={len(reverse_l6)} "
|
||||
f"fused_l6={len(fused_l6)} graph_intent={len(graph_intention_hits)} "
|
||||
f"returned={len(final_results)} "
|
||||
f"query({len(request.query or '')} chars)"
|
||||
f"query='{request.query[:80]}'"
|
||||
)
|
||||
logger.debug(f"[read/{self.VERSION}] query={request.query[:80]!r}")
|
||||
|
||||
response.success = True
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import logging
|
||||
from .base import WritePipeline, ReadPipeline
|
||||
from ..config import MemoryConfig
|
||||
from ..core.embed_service import EmbedService
|
||||
from ..embedding import EmbeddingService
|
||||
from ..data.vector_store import create_vector_store
|
||||
from ..data.vector_store_base import VectorStoreBase
|
||||
|
||||
@@ -51,12 +50,7 @@ class ComponentFactory:
|
||||
s2 = await factory.get_system2_writer() # System2Writer (ultra mode)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[MemoryConfig] = None,
|
||||
*,
|
||||
embedding_service: Optional[EmbeddingService] = None,
|
||||
):
|
||||
def __init__(self, config: Optional[MemoryConfig] = None):
|
||||
self.config = config or MemoryConfig.from_env()
|
||||
|
||||
# 实例缓存(单例)
|
||||
@@ -68,7 +62,7 @@ class ComponentFactory:
|
||||
self._readers: Dict[str, ReadPipeline] = {}
|
||||
|
||||
# 共享资源
|
||||
self._shared_embed_service: Optional[EmbeddingService] = embedding_service
|
||||
self._shared_embed_service: Optional[EmbedService] = None
|
||||
self._shared_vector_store: Optional[VectorStoreBase] = None
|
||||
self._shared_vector_store_initialized: bool = False
|
||||
self._shared_graph_store = None
|
||||
@@ -78,7 +72,7 @@ class ComponentFactory:
|
||||
# 共享资源
|
||||
# ================================================================
|
||||
|
||||
def _get_shared_embed_service(self) -> EmbeddingService:
|
||||
def _get_shared_embed_service(self) -> EmbedService:
|
||||
if self._shared_embed_service is None:
|
||||
self._shared_embed_service = EmbedService(self.config)
|
||||
return self._shared_embed_service
|
||||
|
||||
@@ -2516,8 +2516,7 @@ def _parse_operations_json(text: str) -> Optional[List[Dict[str, Any]]]:
|
||||
return result
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"[S2-agent] JSON parse failed ({len(json_str)} chars)")
|
||||
logger.debug(f"[S2-agent] unparsable output: {json_str[:200]!r}")
|
||||
logger.warning(f"[S2-agent] JSON parse failed: {json_str[:200]}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -280,22 +280,6 @@ class MemoryWriter(WritePipeline):
|
||||
custom["source_raw_memory_ids"] = source_ids
|
||||
return custom
|
||||
|
||||
def _flat_trimmed_raw(self, request: WriteRequest) -> str:
|
||||
"""把 request.messages 按 raw 统一规则裁剪后拼成 L1_RAW 文本。
|
||||
|
||||
只裁落库副本;request.messages 不动(本次 extractor 输入仍用完整 messages)。
|
||||
"""
|
||||
import copy as _copy
|
||||
from ..utils.raw_trim import trim_raw_messages
|
||||
|
||||
_coding_cfg = getattr(getattr(self, "config", None), "coding", None)
|
||||
_trimmed_req = _copy.copy(request)
|
||||
_trimmed_req.messages = trim_raw_messages(
|
||||
request.messages,
|
||||
tool_max_bytes=getattr(_coding_cfg, "tool_result_max_bytes", None),
|
||||
)
|
||||
return _trimmed_req.get_flat_content()
|
||||
|
||||
@staticmethod
|
||||
def _attach_client_meta(custom: Dict[str, Any], request: Optional[WriteRequest]) -> None:
|
||||
"""给新建节点挂上调用方 meta(单份 list)。
|
||||
@@ -2570,14 +2554,9 @@ class MemoryWriter(WritePipeline):
|
||||
try:
|
||||
# 参数校验
|
||||
content = request.content
|
||||
# raw 在写入时就按统一规则裁好(见 utils/raw_trim.py):工具输出、长
|
||||
# assistant 回复不再原样长期存储;历史拼接直接用存下来的文本,不再二次裁剪。
|
||||
# request.messages 保持原样——本次 extractor 输入仍是完整 messages。
|
||||
_raw_trimmed = False
|
||||
if not content and request.has_messages():
|
||||
content = self._flat_trimmed_raw(request)
|
||||
content = request.get_flat_content()
|
||||
request.content = content
|
||||
_raw_trimmed = True
|
||||
|
||||
if not content:
|
||||
response.error_code = 400
|
||||
@@ -2648,9 +2627,6 @@ class MemoryWriter(WritePipeline):
|
||||
vector_store = await self._get_vector_store()
|
||||
layer_enum = MemoryLayer.from_string(suggested_layer)
|
||||
_raw_custom: Dict[str, Any] = {}
|
||||
if _raw_trimmed:
|
||||
from ..utils.raw_trim import RAW_TRIMMED_KEY
|
||||
_raw_custom[RAW_TRIMMED_KEY] = True
|
||||
self._attach_client_meta(_raw_custom, request)
|
||||
mem_node = MemoryNode(
|
||||
user_id=request.user_id,
|
||||
@@ -2953,16 +2929,13 @@ class MemoryWriter(WritePipeline):
|
||||
agent_id: Optional[str] = None,
|
||||
exclude_memory_id: str = "",
|
||||
max_turns: int = 20,
|
||||
max_chars_assistant: Optional[int] = None,
|
||||
max_chars_assistant: int = 500,
|
||||
) -> str:
|
||||
"""
|
||||
获取该用户最近的 L1_RAW 对话记录,按轮次拆分后拼成 history context。
|
||||
|
||||
每条 L1_RAW 的 content 是多轮对话([user]: ...\n[assistant]: ...)。
|
||||
raw 在写入时已按 utils/raw_trim.py 的规则裁好(custom[RAW_TRIMMED_KEY]),
|
||||
这里拆成单条 message 后直接拼接,不再二次裁剪。该规则之前写入的旧 raw
|
||||
没有这个标记,仍按同一(幂等)规则裁剪后再拼,避免旧的长回复 / 工具输出
|
||||
撑大 prompt。max_chars_assistant 只作用于这些旧 raw,默认同写入规则。
|
||||
拆成单条 message 后,user 消息完整保留,assistant 消息截取前 max_chars_assistant 字符。
|
||||
最终取最近 max_turns 条 message(注意单位是 message 条数,非对话轮数;
|
||||
调用方按 k 轮 × 2 传入)。
|
||||
|
||||
@@ -3004,33 +2977,23 @@ class MemoryWriter(WritePipeline):
|
||||
# 避免把正文里形如 "[注]: xxx" 的普通文本误判成消息边界。
|
||||
import re as _re
|
||||
from .base import parse_user_name_line
|
||||
_KNOWN_ROLES = {"user", "assistant", "system", "tool"}
|
||||
# 带方括号的任意 label(含对方真人名字)
|
||||
_labeled_prefix = _re.compile(r"^\[([^\]\n]{1,32})\]:\s?")
|
||||
# 无方括号的已知 role(历史数据容错,沿用旧正则的 `\[?...\]?` 宽容度)
|
||||
_bare_role_prefix = _re.compile(r"^(user|assistant|system|tool):\s?")
|
||||
|
||||
from ..utils.raw_trim import (
|
||||
DEFAULT_RAW_ASSISTANT_MAX_CHARS,
|
||||
RAW_TRIMMED_KEY,
|
||||
trim_raw_body,
|
||||
)
|
||||
if max_chars_assistant is None:
|
||||
max_chars_assistant = DEFAULT_RAW_ASSISTANT_MAX_CHARS
|
||||
_coding_cfg = getattr(getattr(self, "config", None), "coding", None)
|
||||
_tool_max_bytes = getattr(_coding_cfg, "tool_result_max_bytes", None)
|
||||
|
||||
# (label, content, 是否旧 raw 需兼容裁剪) 元组列表;label 为 role 或对方真人名字
|
||||
parsed: List[Tuple[str, str, bool]] = []
|
||||
# (label, content) 元组列表;label 为 role 或对方真人名字
|
||||
parsed: List[Tuple[str, str]] = []
|
||||
user_name: str = ""
|
||||
for node in nodes:
|
||||
content = node.content or ""
|
||||
_legacy = not bool((node.custom or {}).get(RAW_TRIMMED_KEY))
|
||||
for _line_idx, line in enumerate(content.split("\n")):
|
||||
m = _labeled_prefix.match(line) or _bare_role_prefix.match(line)
|
||||
if m:
|
||||
label = m.group(1)
|
||||
body = line[m.end():]
|
||||
parsed.append((label, body, _legacy))
|
||||
parsed.append((label, body))
|
||||
continue
|
||||
# 名字行只可能是每个 node content 的第一行(get_flat_content 插在文首)。
|
||||
# 逐 node 判断而非用 `not parsed`:parsed 跨 node 累积,第二个 node
|
||||
@@ -3043,18 +3006,18 @@ class MemoryWriter(WritePipeline):
|
||||
continue
|
||||
if parsed:
|
||||
# 续行:拼回上一条 message(保留换行)
|
||||
prev_label, prev_body, prev_legacy = parsed[-1]
|
||||
parsed[-1] = (prev_label, prev_body + "\n" + line, prev_legacy)
|
||||
prev_label, prev_body = parsed[-1]
|
||||
parsed[-1] = (prev_label, prev_body + "\n" + line)
|
||||
else:
|
||||
# 开头就是无前缀行(异常/老数据):当成 user 消息兜底
|
||||
parsed.append(("user", line, _legacy))
|
||||
parsed.append(("user", line))
|
||||
|
||||
# 取最近 max_turns 条 message(按 message 截断,绝不从消息中间切开)
|
||||
recent = parsed[-max_turns:] if len(parsed) > max_turns else parsed
|
||||
if not recent:
|
||||
return ""
|
||||
|
||||
# 格式化:新 raw 直接拼接;旧 raw 按写入同一规则兼容裁剪
|
||||
# 格式化:非 user 侧整条截断(含对方真人),user 完整保留
|
||||
all_messages: List[str] = []
|
||||
if user_name:
|
||||
from .base import render_user_name_line
|
||||
@@ -3062,18 +3025,17 @@ class MemoryWriter(WritePipeline):
|
||||
|
||||
_name_line = render_user_name_line(
|
||||
user_name,
|
||||
is_chinese("\n".join(b for _, b, _ in recent)),
|
||||
is_chinese("\n".join(b for _, b in recent)),
|
||||
)
|
||||
if _name_line:
|
||||
all_messages.append(_name_line)
|
||||
for label, body, legacy in recent:
|
||||
if legacy:
|
||||
body = trim_raw_body(
|
||||
label, body,
|
||||
assistant_max_chars=max_chars_assistant,
|
||||
tool_max_bytes=_tool_max_bytes,
|
||||
)
|
||||
for label, body in recent:
|
||||
body = body.strip()
|
||||
# 截断口径:assistant + 对方真人(非已知 role 的 label)。
|
||||
# user 完整保留;system / tool 沿用历史行为(不截断)。
|
||||
_truncate = label == "assistant" or label not in _KNOWN_ROLES
|
||||
if _truncate and len(body) > max_chars_assistant:
|
||||
body = body[:max_chars_assistant] + "..."
|
||||
all_messages.append(f"[{label}]: {body}")
|
||||
|
||||
return "\n".join(all_messages)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,23 +0,0 @@
|
||||
"""Public types and errors for exact memory-ID lookup."""
|
||||
|
||||
from typing import Literal, Sequence
|
||||
|
||||
|
||||
MemoryKind = Literal["chat", "experience", "fact"]
|
||||
MemoryVariant = Literal["working", "coding"]
|
||||
|
||||
|
||||
class AmbiguousMemoryIdError(LookupError):
|
||||
"""An ID exists in more than one public memory namespace."""
|
||||
|
||||
def __init__(self, memory_id: str, namespaces: Sequence[str]):
|
||||
self.memory_id = memory_id
|
||||
self.namespaces = tuple(namespaces)
|
||||
joined = ", ".join(self.namespaces)
|
||||
super().__init__(
|
||||
f"memory ID {memory_id!r} is ambiguous across namespaces: {joined}; "
|
||||
"pass kind and/or variant to disambiguate"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MemoryKind", "MemoryVariant", "AmbiguousMemoryIdError"]
|
||||
+7
-45
@@ -33,28 +33,9 @@ import threading
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from .retrieval import AmbiguousMemoryIdError
|
||||
|
||||
logger = logging.getLogger("hy_memory.server")
|
||||
|
||||
|
||||
def _namespace_args(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""kind / variant from a query string (lists) or a JSON body (scalars).
|
||||
|
||||
They disambiguate an id that exists in more than one namespace (chat,
|
||||
experience, fact; working or coding). Absent means "resolve it".
|
||||
"""
|
||||
args: Dict[str, Any] = {}
|
||||
for key in ("kind", "variant"):
|
||||
value = values.get(key)
|
||||
if isinstance(value, list):
|
||||
value = value[0] if value else None
|
||||
if value:
|
||||
args[key] = str(value)
|
||||
return args
|
||||
|
||||
# Lazy-init global client
|
||||
_client = None
|
||||
_client_lock = None
|
||||
@@ -167,6 +148,7 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
if path == "/api/v1/metrics":
|
||||
minutes = 5
|
||||
if "?" in self.path:
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
minutes = int(qs.get("minutes", ["5"])[0])
|
||||
client = _get_client()
|
||||
@@ -179,8 +161,7 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
if m:
|
||||
memory_id = m.group(1)
|
||||
client = _get_client()
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
result = client.get(memory_id, **_namespace_args(query))
|
||||
result = client.get(memory_id)
|
||||
if result is None:
|
||||
_json_response(self, 404, {"error": "not_found", "memory_id": memory_id})
|
||||
else:
|
||||
@@ -189,10 +170,6 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
|
||||
_json_response(self, 404, {"error": "not_found", "path": path})
|
||||
|
||||
except AmbiguousMemoryIdError as e:
|
||||
_json_response(self, 409, {"error": "ambiguous_memory_id", "detail": str(e)})
|
||||
except ValueError as e:
|
||||
_json_response(self, 400, {"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"[server] GET {path} error: {e}", exc_info=True)
|
||||
_json_response(self, 500, {"error": str(e)})
|
||||
@@ -236,7 +213,7 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
_json_response(self, 500, {"error": str(e)})
|
||||
|
||||
def do_PUT(self):
|
||||
path = self.path.split("?")[0].rstrip("/")
|
||||
path = self.path.rstrip("/")
|
||||
body = _read_json_body(self)
|
||||
if body is None:
|
||||
_json_response(self, 400, {"error": "invalid JSON body"})
|
||||
@@ -251,43 +228,29 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
_json_response(self, 400, {"error": "content is required"})
|
||||
return
|
||||
client = _get_client()
|
||||
result = client.update(
|
||||
memory_id,
|
||||
content,
|
||||
**_namespace_args(body),
|
||||
**({"task": body["task"]} if body.get("task") else {}),
|
||||
)
|
||||
result = client.update(memory_id, content)
|
||||
_json_response(self, 200, result)
|
||||
return
|
||||
|
||||
_json_response(self, 404, {"error": "not_found", "path": path})
|
||||
|
||||
except AmbiguousMemoryIdError as e:
|
||||
_json_response(self, 409, {"error": "ambiguous_memory_id", "detail": str(e)})
|
||||
except ValueError as e:
|
||||
_json_response(self, 400, {"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"[server] PUT {path} error: {e}", exc_info=True)
|
||||
_json_response(self, 500, {"error": str(e)})
|
||||
|
||||
def do_DELETE(self):
|
||||
path = self.path.split("?")[0].rstrip("/")
|
||||
path = self.path.rstrip("/")
|
||||
try:
|
||||
m = re.match(r"^/api/v1/memories/([^/]+)$", path)
|
||||
if m:
|
||||
memory_id = m.group(1)
|
||||
client = _get_client()
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
result = client.delete(memory_id, **_namespace_args(query))
|
||||
result = client.delete(memory_id)
|
||||
_json_response(self, 200, result)
|
||||
return
|
||||
|
||||
_json_response(self, 404, {"error": "not_found", "path": path})
|
||||
|
||||
except AmbiguousMemoryIdError as e:
|
||||
_json_response(self, 409, {"error": "ambiguous_memory_id", "detail": str(e)})
|
||||
except ValueError as e:
|
||||
_json_response(self, 400, {"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"[server] DELETE {path} error: {e}", exc_info=True)
|
||||
_json_response(self, 500, {"error": str(e)})
|
||||
@@ -492,8 +455,7 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
if key in body:
|
||||
kwargs[key] = body[key]
|
||||
|
||||
for key in ("limit", "min_score", "schema_min_score", "schema_limit", "reader",
|
||||
"rerank"):
|
||||
for key in ("limit", "min_score", "schema_min_score", "schema_limit", "reader"):
|
||||
if key in body:
|
||||
kwargs[key] = body[key]
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import contextvars
|
||||
import functools
|
||||
from concurrent.futures import Executor
|
||||
from typing import Any, Callable, Optional
|
||||
@@ -40,9 +39,7 @@ async def run_in_executor_guarded(
|
||||
|
||||
async def to_thread_guarded(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
"""Guarded equivalent of ``asyncio.to_thread`` using the default pool."""
|
||||
context = contextvars.copy_context()
|
||||
call = functools.partial(func, *args, **kwargs)
|
||||
return await run_in_executor_guarded(None, context.run, call)
|
||||
return await run_in_executor_guarded(None, func, *args, **kwargs)
|
||||
|
||||
|
||||
def concurrent_result_guarded(
|
||||
|
||||
@@ -8,7 +8,6 @@ Uses a two-tier strategy:
|
||||
This avoids langdetect's known weakness with short Chinese text.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Optional
|
||||
@@ -136,30 +135,6 @@ def detect_language(text: str) -> str:
|
||||
return "zh" if ratio > 0.1 else "en"
|
||||
|
||||
|
||||
def _spoken_text(value: str) -> str:
|
||||
"""Ignore text-block metadata when a message content is structured JSON."""
|
||||
text = value.strip()
|
||||
if text.startswith("["):
|
||||
try:
|
||||
blocks = json.loads(text)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
if (
|
||||
isinstance(blocks, list)
|
||||
and blocks
|
||||
and all(
|
||||
isinstance(block, dict)
|
||||
and isinstance(block.get("type"), str)
|
||||
and block["type"] in {"text", "input_text", "output_text"}
|
||||
and isinstance(block.get("text"), str)
|
||||
for block in blocks
|
||||
)
|
||||
):
|
||||
return "\n".join(block["text"] for block in blocks)
|
||||
return text
|
||||
|
||||
|
||||
def extract_content_for_detection(
|
||||
content: str = "",
|
||||
messages: Optional[List] = None,
|
||||
@@ -178,57 +153,20 @@ def extract_content_for_detection(
|
||||
Concatenated content text suitable for language detection.
|
||||
"""
|
||||
if content and content.strip():
|
||||
value = content.strip()
|
||||
# Writer serializes message batches to JSON before calling Extractor.
|
||||
# Detect the spoken text, not the leading ASCII keys (role/content),
|
||||
# without changing the raw evidence sent to the model.
|
||||
if value.startswith("["):
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
if (
|
||||
isinstance(decoded, list)
|
||||
and decoded
|
||||
and all(
|
||||
isinstance(item, dict)
|
||||
and isinstance(item.get("role"), str)
|
||||
and isinstance(item.get("content"), str)
|
||||
for item in decoded
|
||||
)
|
||||
):
|
||||
# In a mixed batch, the latest user turn is the clearest
|
||||
# instruction for the language; assistant/tool metadata
|
||||
# must not decide it first.
|
||||
user_text = [
|
||||
_spoken_text(item["content"])
|
||||
for item in decoded
|
||||
if item["role"] == "user" and item["content"].strip()
|
||||
]
|
||||
if user_text:
|
||||
return user_text[-1]
|
||||
spoken = "\n".join(_spoken_text(item["content"]) for item in decoded)
|
||||
if spoken.strip():
|
||||
return spoken
|
||||
return _spoken_text(value)
|
||||
return content.strip()
|
||||
|
||||
if messages:
|
||||
parts = []
|
||||
user_parts = []
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict):
|
||||
c, role = msg.get("content", ""), msg.get("role")
|
||||
c = msg.get("content", "")
|
||||
elif hasattr(msg, "content"):
|
||||
c, role = msg.content, getattr(msg, "role", None)
|
||||
c = msg.content
|
||||
else:
|
||||
continue
|
||||
if isinstance(c, str) and c.strip():
|
||||
text = _spoken_text(c)
|
||||
parts.append(text)
|
||||
if role == "user":
|
||||
user_parts.append(text)
|
||||
return user_parts[-1] if user_parts else "\n".join(parts)
|
||||
if c and c.strip():
|
||||
parts.append(c.strip())
|
||||
return "\n".join(parts)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Pipeline Log Writer — 环节级 JSONL 日志(默认开启,MEMORY_PIPELINE_LOG_ENABLED=false 关闭;
|
||||
与 SQLite Trace 分离)。
|
||||
Pipeline Log Writer — 环节级 JSONL 日志(始终开启,与 SQLite Trace 分离)。
|
||||
|
||||
目录: {log_dir}/{subdir}/{date}.log
|
||||
每行: request_id, namespace, user_id, agent_id, step, prompt, content, parsed,
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Pipeline 可观测性:Log(文件 JSONL,默认开启,可关闭)与 Trace(SQLite pipeline_logs,可关闭)。
|
||||
Pipeline 可观测性:Log(文件 JSONL,始终开启)与 Trace(SQLite pipeline_logs,可关闭)。
|
||||
|
||||
- Log:运维 tail/grep,每个环节即时一行,不依赖后续步骤成功与否。
|
||||
- Trace:Inspector / 调试,落库 pipeline_logs(需 MEMORY_CACHE_BACKEND=sqlite 等持久化后端)。
|
||||
|
||||
环境变量:
|
||||
MEMORY_PIPELINE_LOG_ENABLED — Log 写 JSONL 文件,默认 true;false 时完全不写
|
||||
MEMORY_PIPELINE_TRACE_ENABLED — Trace 落库,默认 true;OpenClaw C 端可设 false
|
||||
MEMORY_MEMORY_OPERATIONS_ENABLED — memory_operations 审计表落库,默认 false
|
||||
MEMORY_LOG_DIR — Log 目录(优先)
|
||||
MEMORY_TRACE_LOG_DIR — Log 目录(兼容旧名)
|
||||
|
||||
Log 与 Trace 都关闭时不包装 store_pipeline_log,而是换成立即返回 True 的
|
||||
空操作:热路径不做任何计时、脱敏或后台任务,也不写库。
|
||||
|
||||
子目录({log_dir}/{subdir}/{date}.log)由 PipelineLogWriter 从每条记录的
|
||||
user_id 前缀(第一个 "__" 左侧)切分;无前缀回落 "default"。该前缀是
|
||||
通用 namespace,可用于项目、部署或数据分桶。
|
||||
@@ -98,16 +94,6 @@ def is_pipeline_trace_enabled() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def is_pipeline_log_enabled() -> bool:
|
||||
"""是否把 pipeline step 写成 JSONL 文件({log_dir}/<subdir>/<date>.log)。默认开启。"""
|
||||
return os.getenv("MEMORY_PIPELINE_LOG_ENABLED", "true").strip().lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
|
||||
|
||||
def is_memory_operations_enabled() -> bool:
|
||||
"""是否将知识库变动写入 memory_operations 审计表。默认关闭(false)。"""
|
||||
return os.getenv("MEMORY_MEMORY_OPERATIONS_ENABLED", "false").lower() in (
|
||||
@@ -139,7 +125,7 @@ def redact_content(value: str) -> str:
|
||||
|
||||
def resolve_pipeline_log_dir() -> str:
|
||||
"""
|
||||
JSONL pipeline log 根目录(MEMORY_PIPELINE_LOG_ENABLED 开启时使用,有默认值)。
|
||||
JSONL pipeline log 根目录(始终使用,有默认值)。
|
||||
|
||||
优先级: MEMORY_LOG_DIR > MEMORY_TRACE_LOG_DIR > {MEMORY_DATA_DIR}/logs/pipeline
|
||||
"""
|
||||
@@ -242,7 +228,6 @@ def wrap_store_pipeline_log(
|
||||
) -> Callable[..., Any]:
|
||||
"""包装 cache.store_pipeline_log:计时同步、JSONL/DB 异步落盘,不堵主请求。
|
||||
|
||||
- log_writer 为 None 时不写 JSONL(MEMORY_PIPELINE_LOG_ENABLED=false)
|
||||
- step-to-step elapsed_ms / event_at_ms 在调用瞬间打点(与业务墙钟对齐)
|
||||
- event_at_ms:调用方传入非 0 值则直接复用(App 已在自己被调用的那一刻
|
||||
同步打点,比这里更接近真实事件时刻);传 0 / 不传才用本地 time.time()
|
||||
@@ -425,8 +410,7 @@ def wrap_store_pipeline_log(
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# JSONL 用线程池,与 loop 无亲和,留在当前 loop。
|
||||
if log_writer is not None:
|
||||
_track_task(loop.create_task(_persist_jsonl()))
|
||||
_track_task(loop.create_task(_persist_jsonl()))
|
||||
# DB trace 用 cache pool(可能 aiomysql,绑定 owner_loop)。若当前不在
|
||||
# owner_loop(如 search 走独立 loop),派发回 owner_loop 执行,避免跨 loop。
|
||||
if trace_enabled:
|
||||
@@ -462,26 +446,25 @@ def wrap_store_pipeline_log(
|
||||
)
|
||||
except RuntimeError:
|
||||
# 无 running loop(极少见):退化为同步写,保证不丢日志
|
||||
if log_writer is not None:
|
||||
try:
|
||||
log_writer.write_step(
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
step=step,
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
parsed=parsed,
|
||||
memory_ids=mem_ids,
|
||||
elapsed_ms=step_elapsed_ms,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
event_at_ms=event_at_ms,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
log_writer.write_step(
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
step=step,
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
parsed=parsed,
|
||||
memory_ids=mem_ids,
|
||||
elapsed_ms=step_elapsed_ms,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
event_at_ms=event_at_ms,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if trace_enabled:
|
||||
try:
|
||||
result = original_store(
|
||||
@@ -513,11 +496,6 @@ def wrap_store_pipeline_log(
|
||||
return _store_pipeline_observability
|
||||
|
||||
|
||||
async def _noop_store_pipeline_log(*args: Any, **kwargs: Any) -> bool:
|
||||
"""Log 与 Trace 都关闭时的 store_pipeline_log:不计时、不写文件、不写库。"""
|
||||
return True
|
||||
|
||||
|
||||
def install_pipeline_observability(
|
||||
cache: Any,
|
||||
*,
|
||||
@@ -525,9 +503,6 @@ def install_pipeline_observability(
|
||||
) -> None:
|
||||
"""给 cache 安装 JSONL + Trace 包装(fire-and-forget)。
|
||||
|
||||
MEMORY_PIPELINE_LOG_ENABLED=false 时不写 JSONL;若 Trace 也关闭,
|
||||
store_pipeline_log 被换成空操作(不包装、不写库)。
|
||||
|
||||
在 cache 所属 loop(write loop / _LoopThread)上调用,本函数会捕获该 loop
|
||||
作为 owner_loop 传给包装器:search 走独立 loop 后,从 search loop 触发的 DB
|
||||
落库会被派发回这个 owner_loop,避免跨 loop 用 aiomysql 污染连接池。
|
||||
@@ -539,31 +514,15 @@ def install_pipeline_observability(
|
||||
except RuntimeError:
|
||||
owner_loop = None
|
||||
|
||||
log_enabled = is_pipeline_log_enabled()
|
||||
log_dir = resolve_pipeline_log_dir()
|
||||
log_writer = PipelineLogWriter(log_dir)
|
||||
trace_enabled = is_pipeline_trace_enabled()
|
||||
if not log_enabled and not trace_enabled:
|
||||
logger.info(
|
||||
f"[{log_label}] Pipeline log (JSONL) disabled ; pipeline trace (DB)=off ; "
|
||||
f"store_pipeline_log=no-op"
|
||||
)
|
||||
cache.store_pipeline_log = _noop_store_pipeline_log
|
||||
return
|
||||
|
||||
log_writer = None
|
||||
if log_enabled:
|
||||
log_dir = resolve_pipeline_log_dir()
|
||||
log_writer = PipelineLogWriter(log_dir)
|
||||
logger.info(
|
||||
f"[{log_label}] Pipeline log (JSONL) enabled: {log_dir}/<subdir>/<date>.log "
|
||||
f"(subdir from user_id prefix before '__', else default) ; "
|
||||
f"pipeline trace (DB)={'on' if trace_enabled else 'off'} ; "
|
||||
f"persist=async(fire-and-forget)"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"[{log_label}] Pipeline log (JSONL) disabled ; "
|
||||
f"pipeline trace (DB)=on ; persist=async(fire-and-forget)"
|
||||
)
|
||||
logger.info(
|
||||
f"[{log_label}] Pipeline log (JSONL) enabled: {log_dir}/<subdir>/<date>.log "
|
||||
f"(subdir from user_id prefix before '__', else default) ; "
|
||||
f"pipeline trace (DB)={'on' if trace_enabled else 'off'} ; "
|
||||
f"persist=async(fire-and-forget)"
|
||||
)
|
||||
cache.store_pipeline_log = wrap_store_pipeline_log(
|
||||
cache.store_pipeline_log,
|
||||
log_writer=log_writer,
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
原始对话(raw)落库前的统一裁剪规则(规则,零 LLM)。
|
||||
|
||||
raw 会被长期保存(MemHub 已取消 raw 的保留期限;lite 模式下 raw 是唯一存储),
|
||||
不裁剪会把工具输出、大段粘贴日志原样存下来。因此 raw 在**写入时**就裁好,
|
||||
读取方(extractor 的「最近 k 条」历史注入)直接拼接,不再二次裁剪。
|
||||
|
||||
规则按说话人标签(ChatMessage.speaker_label() / L1_RAW 里的 ``[label]``):
|
||||
|
||||
- ``user``:完整保留;
|
||||
- ``system``:完整保留(与历史注入原口径一致);
|
||||
- ``tool``:头尾保留、中段替换为 ``[...N bytes omitted by truncate...]``,
|
||||
上限为 ``coding.tool_result_max_bytes``(env ``MEMORY_CODING_TOOL_RESULT_MAX_BYTES``,
|
||||
默认 2048),与 coding 链路同一个函数;
|
||||
- 其余(``assistant``、对方真人名字等非已知 role 的 label):strip 后超过
|
||||
``assistant_max_chars``(默认 500,即历史注入原来的 ``max_chars_assistant``)
|
||||
时截成前 N 个字符 + ``...``。
|
||||
|
||||
所有规则确定且幂等:对已裁剪的文本再裁一次结果不变。
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from ..pipelines.base import ChatMessage
|
||||
|
||||
# 历史注入原 `_get_recent_history(max_chars_assistant=500)` 的默认值,现为唯一来源。
|
||||
DEFAULT_RAW_ASSISTANT_MAX_CHARS = 500
|
||||
|
||||
# L1_RAW 节点 custom 上的标记:内容已按本模块规则在写入时裁剪。
|
||||
# 历史拼接据此区分「新 raw 直接拼接」与「本规则之前写入的旧 raw 需兼容裁剪」。
|
||||
RAW_TRIMMED_KEY = "raw_trimmed"
|
||||
|
||||
# 不裁剪的标签(tool 单独处理)
|
||||
_KEEP_LABELS = frozenset({"user", "system"})
|
||||
|
||||
|
||||
def trim_raw_body(
|
||||
label: str,
|
||||
body: str,
|
||||
*,
|
||||
assistant_max_chars: int = DEFAULT_RAW_ASSISTANT_MAX_CHARS,
|
||||
tool_max_bytes: Optional[int] = None,
|
||||
) -> str:
|
||||
"""按说话人标签裁剪单条消息正文。幂等。"""
|
||||
if not isinstance(body, str):
|
||||
body = "" if body is None else str(body)
|
||||
if label in _KEEP_LABELS:
|
||||
return body
|
||||
if label == "tool":
|
||||
from ..coding.preproc import (
|
||||
DEFAULT_MAX_TOOL_RESULT_BYTES,
|
||||
truncate_tool_result_text,
|
||||
)
|
||||
|
||||
return truncate_tool_result_text(
|
||||
body, max_bytes=int(tool_max_bytes or DEFAULT_MAX_TOOL_RESULT_BYTES),
|
||||
)
|
||||
stripped = body.strip()
|
||||
if len(stripped) > assistant_max_chars:
|
||||
return stripped[:assistant_max_chars] + "..."
|
||||
return body
|
||||
|
||||
|
||||
def trim_raw_messages(
|
||||
messages: List[ChatMessage],
|
||||
*,
|
||||
assistant_max_chars: int = DEFAULT_RAW_ASSISTANT_MAX_CHARS,
|
||||
tool_max_bytes: Optional[int] = None,
|
||||
) -> List[ChatMessage]:
|
||||
"""返回裁剪后的新列表;不修改入参(messages 仍要原样给 extractor)。"""
|
||||
out: List[ChatMessage] = []
|
||||
for m in messages:
|
||||
new_content = trim_raw_body(
|
||||
m.speaker_label(),
|
||||
m.content,
|
||||
assistant_max_chars=assistant_max_chars,
|
||||
tool_max_bytes=tool_max_bytes,
|
||||
)
|
||||
if new_content == m.content:
|
||||
out.append(m)
|
||||
continue
|
||||
out.append(ChatMessage(
|
||||
role=m.role,
|
||||
content=new_content,
|
||||
name=m.name,
|
||||
tool_calls=list(m.tool_calls),
|
||||
tool_call_id=m.tool_call_id,
|
||||
tool_name=m.tool_name,
|
||||
))
|
||||
return out
|
||||
@@ -36,8 +36,7 @@ def count_tokens(text: str) -> int:
|
||||
enc = _get_encoder()
|
||||
if enc:
|
||||
try:
|
||||
# Quoted control-token spellings are ordinary input text.
|
||||
return len(enc.encode(text, disallowed_special=()))
|
||||
return len(enc.encode(text))
|
||||
except Exception as e:
|
||||
logger.debug(f"[token_count] encode failed, char fallback: {e}")
|
||||
return max(1, len(text) // 4)
|
||||
|
||||
+41
-31
@@ -9,17 +9,10 @@
|
||||
## Architecture Principles
|
||||
|
||||
1. Memory 读写与存储 backend **只经 SDK**(config / env)。
|
||||
2. MemHub 运行时对 memory 的原子操作 **只经 SDK 公共 Client**;缺正常业务
|
||||
接口先补 SDK。Runtime writers 停止后的 App 启动迁移可直接操作底层存储,
|
||||
但不得把迁移专用方法加入公共 Client。
|
||||
2. MemHub 对 memory 的原子操作 **只经 SDK**;缺接口先补 SDK。
|
||||
3. MemHub **不存 memory 实体**;**可以存 `memory_id`** 做组织/回执。原文进入 **SDK raw**。
|
||||
4. SDK raw 全部保留,任何模式(lite / pro / ultra)都不删除;一条 raw 对应一个
|
||||
完整 turn,只含真人 user 与 assistant,tool 不持久化。
|
||||
5. `source_raw_ids` 是 SDK 内部溯源关系,不是产品 Evidence 合同;记忆详情不提供
|
||||
“原始依据”或按 raw ID 展开原文的入口。
|
||||
|
||||
生产入口通过共享 Runtime 租约取得公开 `HyMemoryClient`;MemHub 只在调用前执行
|
||||
模型配置、密钥和 harness/model 门禁,不维护第二套 memory 正文权威。
|
||||
生产入口仍建议经 `HyMemoryBridge`(共享 Runtime、模型配置、密钥、harness/model 门禁),但 bridge **不得**再把 SDK 结果镜像成第二套 memory 正文权威。
|
||||
|
||||
来源授权、模型授权和宿主召回授权仍是 MemHub 管理面门禁;memory
|
||||
数据平面的权威在 SDK。
|
||||
@@ -37,15 +30,46 @@ reader: hybrid
|
||||
|
||||
## MemHub 运行时边界
|
||||
|
||||
MemHub 通过公开 `HyMemoryClient` 配置使用可复现的本地策略:
|
||||
MemHub 对 SDK 使用公开、可复现的本地策略(经 bridge / `hy_runtime_config` 注入):
|
||||
|
||||
- vector / graph / cache / history / coding 等 **一律 SDK config/env**;
|
||||
- 模型与 embedding 来自版本化 Model Profile;密钥进安全存储;
|
||||
- MemHub 管理库可存 harness/model/队列状态与 **`memory_id` 引用**。
|
||||
|
||||
MemHub 不再通过 import-time 环境变量覆盖 SDK 的提取、reconcile、episode、
|
||||
intention、few-shot、summary 或去重策略。产品只配置模型/密钥/endpoint、本地
|
||||
backend 路径、公开 embedding service,以及每次公开 Client 调用的参数。
|
||||
发布包默认 SDK 行为环境变量仍集中在
|
||||
`apps/core/src/one_memory/hy_runtime_config.py`,并由下表锁定:
|
||||
|
||||
| 变量 | 值 |
|
||||
|---|---|
|
||||
| `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` |
|
||||
|
||||
独立脚本直连 SDK 时须自配存储目录与模型端点。
|
||||
|
||||
@@ -53,7 +77,7 @@ backend 路径、公开 embedding service,以及每次公开 Client 调用的
|
||||
|
||||
下面是独立调试示例。
|
||||
|
||||
产品代码经 `hy_sdk_runtime.SdkClientHandle` 获取已配置的公开 client;memory I/O
|
||||
产品代码经 `HyMemoryBridge` 获取已配置的 client(管理面门禁),memory I/O
|
||||
由 SDK 完成。
|
||||
|
||||
```python
|
||||
@@ -172,20 +196,6 @@ result = client.add_extracted(
|
||||
该入口仍走 SDK reconcile/持久化;MemHub 只做管理面门禁与可选
|
||||
`memory_id` 记录。
|
||||
|
||||
应用的「添加记忆」、MCP `MemHub_write`(经 broker `POST /memories` 以本机可信身份转发)
|
||||
共用同一个本机 HTTP 合同,直接新增一条记忆:
|
||||
|
||||
```text
|
||||
POST /api/v2/memories {request_id, text, scope: "all-apps"}
|
||||
GET /api/v2/memories/additions/{request_id} # 该次新增的状态
|
||||
```
|
||||
|
||||
两者返回同一回执(`evidence_id`、`text`、`scope`、`created_at`、`state`、`task_id`、
|
||||
`memory_ids`、`error`)。同一 `request_id` 重放返回原回执;内容不同返回 409
|
||||
`memory_add_conflict`,未知 `request_id` 返回 404 `memory_add_not_found`。新增走持久
|
||||
写入队列的优先通道,最终调用 `add_extracted` + reconcile。带外部读取信封的请求
|
||||
一律拒绝(403 `external_endpoint_denied`)。
|
||||
|
||||
## Search:搜索记忆
|
||||
|
||||
```python
|
||||
@@ -218,8 +228,7 @@ result = client.search(
|
||||
- 区分空结果、权限拒绝、不可用、超时、部分结果;
|
||||
- 未发布的 Matter 占位功能不保留表或 API;未来组织功能另立合同。
|
||||
|
||||
生产入口:`hy_sdk_runtime.py`、`sdk_write_processor.py`、`recall.py` 和
|
||||
`packages/host-bridge`。
|
||||
生产入口:`hy_memory_bridge.py`、`recall.py`、`packages/host-bridge`。
|
||||
|
||||
## 关闭客户端
|
||||
|
||||
@@ -236,7 +245,8 @@ Runtime 长期复用 client;仅在停止、配置切换或受控恢复时重
|
||||
|
||||
```text
|
||||
harness / user action (+ authz, model, queue)
|
||||
-> public HyMemoryClient
|
||||
-> HyMemoryBridge
|
||||
-> Hy Memory SDK
|
||||
-> SDK stores (config/env)
|
||||
-> optional MemHub persist memory_id / org metadata
|
||||
-> Recall Broker / UI (SDK read + MemHub authz)
|
||||
|
||||
+4
-6
@@ -29,12 +29,10 @@ prompt/response、密钥、访问令牌或完整本地路径。结构化日志
|
||||
文件。详细合同见
|
||||
[`docs/contracts/diagnostic-logging.md`](docs/contracts/diagnostic-logging.md)。
|
||||
|
||||
HY pipeline 日志与 trace 在任何级别下都关闭。HY SDK 的文本日志只写入
|
||||
`hy-memory/governed-v2/logs/hy_memory.log`,其中的 WARNING 及以上同时写入 `core.log`;
|
||||
SDK 不在 INFO 及以上级别记录对话或记忆正文。用户显式把诊断级别切换为 `debug` 时,Core 将
|
||||
`MEMORY_LOG_LLM_CONTENT` 设为 `true`。该模式会把完整模型 prompt/response 写入
|
||||
`hy_memory.log` 的 DEBUG 行,其中可能包含获授权的对话正文和模型输出;它只应用于有意识的
|
||||
故障排查,不应长期开启,也不应在未经检查和脱敏时分享相关日志。切回 `info` 会停止后续正文记录,但不会
|
||||
用户显式把诊断级别切换为 `debug` 时,Core 会启用 HY pipeline trace,并将
|
||||
`MEMORY_LOG_LLM_CONTENT` 设为 `true`。该模式会把完整模型 prompt/response 写入本地调试
|
||||
日志,其中可能包含获授权的对话正文和模型输出;它只应用于有意识的故障排查,不应长期
|
||||
开启,也不应在未经检查和脱敏时分享相关日志。切回 `info` 会停止后续正文记录,但不会
|
||||
自动删除此前已写入的调试文件。
|
||||
|
||||
具体行为以当前源码、网络请求实现和发布说明为准。发现实现与本说明不一致时,请按
|
||||
|
||||
+7
-8
@@ -18,13 +18,13 @@ If you run into a problem, please open an Issue with the version, platform,repro
|
||||
|
||||
## 发行与平台合同
|
||||
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.10`。MemHub 源码版本为 `1.0.5`
|
||||
(build 36),GitHub 仓库为
|
||||
源码当前固定使用公开 PyPI 开发预览 `hy-memory==2.0.5.dev3`。MemHub 源码版本为 `1.0.4`
|
||||
(build 13),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
|
||||
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
|
||||
`v1.0.4` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
|
||||
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
|
||||
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
|
||||
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
|
||||
@@ -45,7 +45,7 @@ Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命
|
||||
|
||||
## 仓库结构
|
||||
|
||||
- `apps/core`:Python / FastAPI Kernel,负责授权、模型、队列、SDK Runtime 边界与 API。
|
||||
- `apps/core`:Python / FastAPI Kernel,负责授权、模型、队列、SDK bridge 与 API。
|
||||
- `apps/cli`:安装、控制和诊断共享 Runtime 的 `one-memory` CLI。
|
||||
- `apps/web`:React / TypeScript 管理界面。
|
||||
- `apps/desktop`:Electron 桌面壳与图形安装入口。
|
||||
@@ -109,16 +109,15 @@ Node、Host Bridge 或模型。用户显式执行 `memhub install` 后,CLI 才
|
||||
Python Core 和运行所需的 Python 依赖位于 MemHub 受管 Runtime,不写入系统 Python。
|
||||
MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime 版本目录中。
|
||||
`npm uninstall` 只移除 npm CLI;`memhub uninstall` 移除服务、受管 Runtime 和 CLI 链接,
|
||||
默认保留 Memory。只有独立的 `memhub delete-data` 在 Runtime 停止、显式加 `--yes` 并用
|
||||
`--confirm <数据目录>` 再写一遍同一个目录后才会删除数据(不加参数只预览要删除的目录)。`memhub stop` 停止受管服务时也会关闭
|
||||
登录自动启动,再次 `memhub start` 会恢复。
|
||||
默认保留 Memory。只有独立的 `memhub delete-data` 在 Runtime 停止且二次确认精确路径后
|
||||
才会删除数据。
|
||||
|
||||
当前平台能力和安装边界以源码、CI 与发布说明为准。未完成真机验收的平台不会仅凭跨平台
|
||||
测试宣称可用。
|
||||
|
||||
## Hy Memory
|
||||
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.10`。SDK 配置、权限边界及 search
|
||||
SDK 依赖固定为公开 PyPI 开发预览 `hy-memory==2.0.5.dev3`。SDK 配置、权限边界及 search
|
||||
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
||||
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "one-memory",
|
||||
"documentNamespace": "https://one-memory.local/spdx/2026-10-22T13:18:16Z",
|
||||
"documentNamespace": "https://one-memory.local/spdx/2026-10-22T08:09:31Z",
|
||||
"creationInfo": {
|
||||
"created": "2026-10-22T13:18:16Z",
|
||||
"created": "2026-10-22T08:09:31Z",
|
||||
"creators": [
|
||||
"Tool: one-memory-generate-sbom"
|
||||
]
|
||||
@@ -2352,7 +2352,7 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-hy-memory",
|
||||
"name": "hy-memory",
|
||||
"versionInfo": "2.0.10",
|
||||
"versionInfo": "2.0.5.dev3",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "Apache-2.0",
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
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-22T13:18:16Z
|
||||
Generated from the installed dependency tree on 2026-10-22T08:09:31Z
|
||||
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.10
|
||||
## hy-memory 2.0.5.dev3
|
||||
|
||||
License: Apache-2.0
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/cli",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.4",
|
||||
"description": "MemHub bootstrap CLI for installing and managing the local-first shared memory runtime",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -21,21 +21,18 @@ const TOKEN = "probe-token-not-a-real-credential";
|
||||
|
||||
const MEMORY = {
|
||||
memory_id: "probe-1",
|
||||
content: "部署脚本要先跑 verify_merge_plan。",
|
||||
layer: "l2_fact",
|
||||
status: "active",
|
||||
is_latest: true,
|
||||
supersedes: [],
|
||||
superseded_by: [],
|
||||
tags: [],
|
||||
owner: null,
|
||||
source_raw_ids: ["probe-raw-1"],
|
||||
user_id: "local",
|
||||
agent_id: "codex",
|
||||
session_id: "probe-session",
|
||||
source: "codex",
|
||||
memory_at: 1758240000,
|
||||
gmt_created: 1758240000,
|
||||
kind: "chat",
|
||||
status: "confirmed",
|
||||
content: "部署脚本要先跑 verify_merge_plan。",
|
||||
scopes: ["all-apps"],
|
||||
evidence_ids: ["probe-e1"],
|
||||
origin: "extracted",
|
||||
tags: [],
|
||||
revision: 2,
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
updated_at: "2026-09-19T00:00:00Z",
|
||||
deleted_at: null,
|
||||
};
|
||||
|
||||
const ROUTES = new Map([
|
||||
@@ -127,6 +124,26 @@ const ROUTES = new Map([
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
"/api/v1/memory-tasks",
|
||||
[
|
||||
{
|
||||
task_id: "probe-task",
|
||||
request_id: "probe-request",
|
||||
user_id: "local",
|
||||
kind: "extract",
|
||||
input_ref: "probe",
|
||||
status: "waiting_for_model",
|
||||
profile_id: "probe-profile",
|
||||
profile_revision: 1,
|
||||
profile_snapshot: {},
|
||||
attempts: 0,
|
||||
last_error_code: "model_missing",
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
updated_at: "2026-09-19T00:00:01Z",
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
"/api/v1/model-profiles",
|
||||
[
|
||||
@@ -142,20 +159,28 @@ const ROUTES = new Map([
|
||||
},
|
||||
],
|
||||
],
|
||||
["/api/v1/home-summary", { memory_count: 1, latest: null, observed_at: "T" }],
|
||||
[
|
||||
"/api/v2/home-summary",
|
||||
"/api/v1/memory-library",
|
||||
{ total: 1, offset: 0, limit: 100, items: [MEMORY] },
|
||||
],
|
||||
[
|
||||
"/api/v1/memories/probe-1",
|
||||
{
|
||||
schema_version: 2,
|
||||
memory_count: 1,
|
||||
latest: MEMORY,
|
||||
observed_at: "2026-09-19T00:00:01Z",
|
||||
memory: MEMORY,
|
||||
evidence: [
|
||||
{
|
||||
evidence_id: "probe-e1",
|
||||
available: true,
|
||||
source_id: "codex",
|
||||
written_by: "user",
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
verbatim: "部署前先跑 verify_merge_plan,别靠读 diff 猜冲突。",
|
||||
},
|
||||
],
|
||||
history: [{ memory_id: "probe-1", revision: 2, action: "confirm" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"/api/v2/memory-library",
|
||||
{ memories: [MEMORY], total: 1, offset: 0, limit: 100 },
|
||||
],
|
||||
["/api/v2/memories/probe-1", MEMORY],
|
||||
]);
|
||||
|
||||
const seen = [];
|
||||
|
||||
@@ -30,7 +30,9 @@ const REQUIRED_COMMAND_SURFACE = [
|
||||
"sync",
|
||||
"memories",
|
||||
"profile",
|
||||
"graph-width",
|
||||
"calls",
|
||||
"notes",
|
||||
"graph",
|
||||
"models",
|
||||
"host",
|
||||
"doctor",
|
||||
@@ -48,17 +50,19 @@ const REQUIRED_COMMAND_SURFACE = [
|
||||
"uninstall",
|
||||
"delete-data",
|
||||
"recall",
|
||||
"setup",
|
||||
"scan",
|
||||
"progress",
|
||||
"jobs",
|
||||
"scan-roots",
|
||||
"path",
|
||||
"adapter",
|
||||
"write",
|
||||
"task",
|
||||
"serve",
|
||||
];
|
||||
|
||||
const REQUIRED_MARKERS = [
|
||||
"backup_restore_confirmation_mismatch",
|
||||
"backup_restore_confirmation_required",
|
||||
"backup_restore_runtime_state_unknown",
|
||||
"backup_restore_maintenance_identity_invalid",
|
||||
"runtime_service_activation_unverified",
|
||||
@@ -75,18 +79,16 @@ const REQUIRED_MARKERS = [
|
||||
* exact literal the catalog usage string contains.
|
||||
*/
|
||||
const REQUIRED_SUBCOMMANDS = [
|
||||
{ command: "memories", marker: "memhub memories add" },
|
||||
{ command: "memories", marker: "memhub memories add-status" },
|
||||
{ command: "calls", marker: "memhub calls clear" },
|
||||
{ command: "notes", marker: "memhub notes add" },
|
||||
{ command: "notes", marker: "memhub notes process" },
|
||||
{ command: "memories", marker: "memhub memories update" },
|
||||
{ command: "memories", marker: "memhub memories delete" },
|
||||
{ command: "write", marker: "memhub write submit" },
|
||||
{ command: "write", marker: "memhub write status" },
|
||||
{ command: "graph-width", marker: "memhub graph-width --decision" },
|
||||
{ command: "models", marker: "memhub models add" },
|
||||
{ command: "models", marker: "memhub models test" },
|
||||
{ command: "graph", marker: "memhub graph width" },
|
||||
{ command: "models", marker: "memhub models edit" },
|
||||
{ command: "models", marker: "memhub models activate" },
|
||||
{ command: "sources", marker: "memhub sources connect" },
|
||||
{ command: "jobs", marker: "memhub jobs start" },
|
||||
{ command: "jobs", marker: "memhub jobs <pause|resume|cancel>" },
|
||||
];
|
||||
@@ -98,30 +100,57 @@ const REQUIRED_SUBCOMMANDS = [
|
||||
* counterexamples on the packaged Runtime") had no machine-checkable probe.
|
||||
* These run without `--lifecycle`, so they are safe against any package.
|
||||
*/
|
||||
const PROFILE_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const STOPPED_DESTRUCTIVE_PROBES = [
|
||||
{ name: "memories_add", args: ["memories", "add", "synthetic memory"] },
|
||||
{
|
||||
name: "memories_delete_apply",
|
||||
args: ["memories", "delete", "m-1", "--yes"],
|
||||
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: "notes_process_apply",
|
||||
args: [
|
||||
"notes",
|
||||
"process",
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "graph_width_apply",
|
||||
args: ["graph-width", "--decision", "keep_existing"],
|
||||
args: ["graph", "width", "--decision", "keep_existing", "--confirm"],
|
||||
},
|
||||
{
|
||||
name: "graph_width_rebuild_apply",
|
||||
args: ["graph-width", "--decision", "rebuild_with_new", "--yes"],
|
||||
},
|
||||
{ name: "models_activate_apply", args: ["models", "activate", PROFILE_ID] },
|
||||
{ name: "models_test", args: ["models", "test", PROFILE_ID] },
|
||||
{
|
||||
name: "models_remove_apply",
|
||||
args: ["models", "remove", PROFILE_ID, "--yes"],
|
||||
name: "models_activate_apply",
|
||||
args: [
|
||||
"models",
|
||||
"activate",
|
||||
"44444444-4444-4444-8444-444444444444",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "sources_connect_import",
|
||||
args: ["sources", "connect", "codex", "--import"],
|
||||
name: "models_edit_apply",
|
||||
args: [
|
||||
"models",
|
||||
"edit",
|
||||
"44444444-4444-4444-8444-444444444444",
|
||||
"--config-file",
|
||||
"/nonexistent/patch.json",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -119,7 +119,6 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
// Exactly one request for two sources — the batch endpoint, per contract.
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
@@ -166,7 +165,6 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
expect(document.state).toBe("partial");
|
||||
const byId = Object.fromEntries(
|
||||
@@ -208,7 +206,6 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
[],
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
expect(document.sources.every((row) => row.status === "failed")).toBe(true);
|
||||
expect(
|
||||
@@ -252,7 +249,6 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
[],
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
const busy = document.sources.find((row) => row.source_id === "cursor");
|
||||
expect(document.state).toBe("partial");
|
||||
@@ -292,7 +288,6 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
const notFoundRow = document.sources.find(
|
||||
(row) => row.source_id === "cursor",
|
||||
@@ -301,82 +296,3 @@ describe("A19 batch execution", () => {
|
||||
expect(document.not_found_source_ids).toContain("cursor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment-override sources need --confirm-overrides", () => {
|
||||
it("leaves them out and names them unless confirmed", async () => {
|
||||
const planned = await planBatchImport(
|
||||
readerFor([
|
||||
found("codex"),
|
||||
found("dsh", {
|
||||
candidate_kind: "environment_override",
|
||||
requires_override_confirmation: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
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: body.source_ids,
|
||||
failed_sources: {},
|
||||
jobs: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const operations = { post } as unknown as CoreOperations;
|
||||
const skipped = await executeBatchImport(
|
||||
planned.candidates,
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
expect(post.mock.calls[0]![1]).toMatchObject({
|
||||
source_ids: ["codex"],
|
||||
confirm_environment_overrides: false,
|
||||
});
|
||||
expect(skipped.override_required_source_ids).toEqual(["dsh"]);
|
||||
expect(renderBatchScanText(skipped)).toContain("--confirm-overrides");
|
||||
|
||||
const confirmed = await executeBatchImport(
|
||||
planned.candidates,
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: true },
|
||||
);
|
||||
expect(post.mock.calls[1]![1]).toMatchObject({
|
||||
source_ids: ["codex", "dsh"],
|
||||
confirm_environment_overrides: true,
|
||||
});
|
||||
expect(confirmed.override_required_source_ids).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses without a request when every detected source needs confirmation", async () => {
|
||||
const planned = await planBatchImport(
|
||||
readerFor([
|
||||
found("dsh", {
|
||||
candidate_kind: "environment_override",
|
||||
requires_override_confirmation: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
if (planned.state !== "preview") throw new Error("expected preview");
|
||||
const post = vi.fn();
|
||||
const document = await executeBatchImport(
|
||||
planned.candidates,
|
||||
readerFor([]),
|
||||
{ post } as unknown as CoreOperations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(document).toMatchObject({
|
||||
state: "rejected",
|
||||
code: "environment_override_confirmation_required",
|
||||
});
|
||||
expect(batchScanExitCode(document)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
/** A19: one-shot connect/import of every *detected* source
|
||||
* (`sources connect --all-detected --import`).
|
||||
/** A19: one-shot scan/import of every *detected* source.
|
||||
*
|
||||
* The engine already takes many sources in a single `POST /api/v1/sources/connect`
|
||||
* (`source_ids` is a 1..32 tuple) and answers per source. The CLI used to reject
|
||||
@@ -38,16 +37,13 @@ export interface BatchSourceRow {
|
||||
export interface BatchScanDocument {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "sources connect";
|
||||
command: "scan" | "init";
|
||||
state: "committed" | "partial" | "rejected" | "unknown" | "no_sources";
|
||||
request_id: string | null;
|
||||
accepted_source_ids: string[];
|
||||
sources: BatchSourceRow[];
|
||||
/** `location_state != found` rows are listed so the user can see what was skipped. */
|
||||
not_found_source_ids: string[];
|
||||
/** Detected sources whose location comes from an environment override and
|
||||
* that were left out because `--confirm-overrides` was not given. */
|
||||
override_required_source_ids?: string[];
|
||||
code?: string;
|
||||
}
|
||||
|
||||
@@ -122,39 +118,12 @@ export async function planBatchImport(reader: CoreReader): Promise<
|
||||
|
||||
/** Run the batch connect once and fold the per-source receipt into rows. */
|
||||
export async function executeBatchImport(
|
||||
detected: readonly SourceConnectionCandidate[],
|
||||
candidates: readonly SourceConnectionCandidate[],
|
||||
reader: CoreReader,
|
||||
operations: CoreOperations,
|
||||
notFound: readonly string[],
|
||||
options: { confirmOverrides: boolean },
|
||||
): Promise<BatchScanDocument> {
|
||||
const requestId = randomUUID();
|
||||
// An environment-override location is a choice the user has to confirm:
|
||||
// without `--confirm-overrides` those sources are left out and named.
|
||||
const skipped = options.confirmOverrides
|
||||
? []
|
||||
: detected
|
||||
.filter((candidate) => candidate.requires_override_confirmation)
|
||||
.map((candidate) => candidate.source_id);
|
||||
const overrideFields =
|
||||
skipped.length > 0 ? { override_required_source_ids: skipped } : {};
|
||||
const candidates = detected.filter(
|
||||
(candidate) => !skipped.includes(candidate.source_id),
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "sources connect",
|
||||
state: "rejected",
|
||||
request_id: null,
|
||||
accepted_source_ids: [],
|
||||
sources: [],
|
||||
not_found_source_ids: [...notFound],
|
||||
...overrideFields,
|
||||
code: "environment_override_confirmation_required",
|
||||
};
|
||||
}
|
||||
// Same shared builder as the single-source import and the Web console, so a
|
||||
// batch connect cannot drift from either.
|
||||
const request = buildSourceImportRequest({
|
||||
@@ -172,9 +141,9 @@ export async function executeBatchImport(
|
||||
: [[candidate.source_id, candidate.candidate_revision]],
|
||||
),
|
||||
),
|
||||
confirmEnvironmentOverrides:
|
||||
options.confirmOverrides &&
|
||||
candidates.some((candidate) => candidate.requires_override_confirmation),
|
||||
confirmEnvironmentOverrides: candidates.some(
|
||||
(candidate) => candidate.requires_override_confirmation,
|
||||
),
|
||||
fullRescan: false,
|
||||
onboarding: true,
|
||||
enableSync: true,
|
||||
@@ -185,7 +154,7 @@ export async function executeBatchImport(
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "sources connect",
|
||||
command: "scan",
|
||||
state: result.state,
|
||||
request_id: requestId,
|
||||
accepted_source_ids: [],
|
||||
@@ -199,7 +168,6 @@ export async function executeBatchImport(
|
||||
failure_reason: result.code,
|
||||
})),
|
||||
not_found_source_ids: [...notFound],
|
||||
...overrideFields,
|
||||
code: result.code,
|
||||
};
|
||||
}
|
||||
@@ -286,7 +254,7 @@ export async function executeBatchImport(
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "sources connect",
|
||||
command: "scan",
|
||||
state:
|
||||
failedCount === 0
|
||||
? "committed"
|
||||
@@ -302,7 +270,6 @@ export async function executeBatchImport(
|
||||
accepted_source_ids: accepted,
|
||||
sources: rows,
|
||||
not_found_source_ids: [...notFound],
|
||||
...overrideFields,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,10 +301,5 @@ export function renderBatchScanText(document: BatchScanDocument): string {
|
||||
: document.state === "partial"
|
||||
? `部分完成:${document.accepted_source_ids.length} 个成功,其余见上方`
|
||||
: `未完成:${document.code ?? "unknown"}`;
|
||||
const overrides = document.override_required_source_ids ?? [];
|
||||
const hint =
|
||||
overrides.length > 0
|
||||
? `\n未接入(目录来自环境变量覆盖,需确认):${overrides.join("、")}\n确认目录无误后加 --confirm-overrides 重新运行。`
|
||||
: "";
|
||||
return `${summary}\n${lines.join("\n")}${hint}\n`;
|
||||
return `${summary}\n${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
// Contract fixes from the CLI review: exit codes, where JSON goes, the global
|
||||
// `--data-dir`, and the write idempotency key.
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
import { COMMANDS } from "./command-catalog.js";
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
request: vi.fn(),
|
||||
}));
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
create: vi.fn(() => transport),
|
||||
get: transport.get,
|
||||
post: transport.post,
|
||||
request: transport.request,
|
||||
isAxiosError: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
inspectRuntimeStatus: vi.fn(),
|
||||
discoverRuntimeAttachment: vi.fn(),
|
||||
inspectBackgroundHealth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
|
||||
...runtime,
|
||||
}));
|
||||
|
||||
const ATTACHED = {
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const method of Object.values(transport)) method.mockReset();
|
||||
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(ATTACHED);
|
||||
runtime.inspectBackgroundHealth.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("--data-dir is a global option", () => {
|
||||
it("points a Runtime-attached command at that data directory", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { request_id: "r", profile: null, elapsed_ms: 1 },
|
||||
});
|
||||
const result = await capturedRun(
|
||||
["profile", "--data-dir", "/synthetic/other", "--json"],
|
||||
{ ONE_MEMORY_DATA_DIR: "/synthetic/default" },
|
||||
);
|
||||
expect(result.code).toBe(0);
|
||||
expect(runtime.inspectRuntimeStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dataDir: "/synthetic/other" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("is accepted by every command the catalog used to advertise it on", async () => {
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "stopped",
|
||||
code: "runtime_not_running",
|
||||
});
|
||||
runtime.discoverRuntimeAttachment.mockResolvedValue(null);
|
||||
for (const argv of [
|
||||
["profile"],
|
||||
["scan-roots", "list"],
|
||||
["adapter", "list"],
|
||||
["scan", "--source", "codex"],
|
||||
["write", "status", "r-1"],
|
||||
["backup", "create", "--target", "/synthetic/data/backups/a"],
|
||||
["memories", "list"],
|
||||
]) {
|
||||
const result = await capturedRun([
|
||||
...argv,
|
||||
"--data-dir",
|
||||
"/synthetic/other",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code, argv.join(" ")).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a relative or repeated --data-dir", async () => {
|
||||
for (const argv of [
|
||||
["profile", "--data-dir", "relative"],
|
||||
["profile", "--data-dir", "/a", "--data-dir", "/b"],
|
||||
["profile", "--data-dir"],
|
||||
]) {
|
||||
expect((await capturedRun(argv)).code).toBe(2);
|
||||
}
|
||||
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Core failures are exit 1, not usage errors", () => {
|
||||
it("adapter probe reports a Core refusal with its code on stdout", async () => {
|
||||
transport.request.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "source_not_authorized" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"adapter",
|
||||
"probe",
|
||||
"--source",
|
||||
"codex",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
command: "adapter probe",
|
||||
state: "failed",
|
||||
code: "source_not_authorized",
|
||||
exit_code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("adapter probe prints readable text without --json", async () => {
|
||||
transport.request.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { source: "codex", available: true },
|
||||
});
|
||||
const result = await capturedRun(["adapter", "probe", "--source", "codex"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("来源 codex 的探测结果");
|
||||
expect(result.stdout).toContain("available: true");
|
||||
});
|
||||
|
||||
it("scan-roots add posts directly and reports a refusal as exit 1", async () => {
|
||||
transport.request.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "scan_root_conflict" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"scan-roots",
|
||||
"add",
|
||||
"--root",
|
||||
"/synthetic/root",
|
||||
"--source",
|
||||
"codex",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(transport.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "post",
|
||||
url: "http://127.0.0.1:8765/api/v1/scan-roots",
|
||||
data: { root: "/synthetic/root", source: "codex", recursive: true },
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(result.stdout).code).toBe("scan_root_conflict");
|
||||
});
|
||||
|
||||
it("the old `path add` spelling still works", async () => {
|
||||
transport.request.mockResolvedValue({ status: 200, data: { ok: true } });
|
||||
const result = await capturedRun([
|
||||
"path",
|
||||
"remove",
|
||||
"--root",
|
||||
"/synthetic/root",
|
||||
"--yes",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.request.mock.calls[0][0]).toMatchObject({
|
||||
method: "delete",
|
||||
data: { root: "/synthetic/root" },
|
||||
});
|
||||
});
|
||||
|
||||
it("scan --source runs directly", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { job_id: "j1", source_id: "codex", status: "pending" },
|
||||
});
|
||||
const result = await capturedRun(["scan", "--source", "codex", "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
command: "scan",
|
||||
state: "committed",
|
||||
scan: { job_id: "j1" },
|
||||
});
|
||||
// A scan without a source is a usage error, not a preview.
|
||||
expect((await capturedRun(["scan"])).code).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("write submit keeps its idempotency key", () => {
|
||||
async function messagesFile(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "memhub-write-"));
|
||||
const file = path.join(directory, "messages.json");
|
||||
await writeFile(file, JSON.stringify([{ role: "user", content: "hi" }]));
|
||||
return file;
|
||||
}
|
||||
|
||||
it("generates a request id, sends it and prints it with a retry hint", async () => {
|
||||
transport.post.mockImplementation(
|
||||
async (_url: string, body: { request_id: string }) => ({
|
||||
status: 200,
|
||||
data: {
|
||||
request_id: body.request_id,
|
||||
state: "accepted",
|
||||
created_at: "T",
|
||||
updated_at: "T",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await capturedRun([
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s1",
|
||||
"--messages-file",
|
||||
await messagesFile(),
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
const sent = transport.post.mock.calls[0][1].request_id as string;
|
||||
expect(sent).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(result.stdout).toContain(`request_id: ${sent}`);
|
||||
expect(result.stdout).toContain(`--request-id ${sent}`);
|
||||
});
|
||||
|
||||
it("reports a 409 conflict as JSON on stdout with the request id", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "memory_write_conflict" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s1",
|
||||
"--messages-file",
|
||||
await messagesFile(),
|
||||
"--request-id",
|
||||
"req-1",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
command: "write submit",
|
||||
state: "rejected",
|
||||
request_id: "req-1",
|
||||
code: "memory_write_conflict",
|
||||
});
|
||||
});
|
||||
|
||||
it("write status of an unknown id is exit 1 with state not_found", async () => {
|
||||
transport.get.mockResolvedValue({ status: 404, data: {} });
|
||||
const error = Object.assign(new Error("404"), {
|
||||
response: { status: 404 },
|
||||
});
|
||||
transport.get.mockRejectedValue(error);
|
||||
const result = await capturedRun(["task", "--id", "nope", "--json"]);
|
||||
// `axios.isAxiosError` is mocked false, so the read error surfaces as a
|
||||
// failed read; either way it is exit 1, never a silent exit 0.
|
||||
expect(result.code).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor and ui use exit 3 for a stopped Runtime", () => {
|
||||
it("doctor exits 3 when the Runtime is stopped", async () => {
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "stopped",
|
||||
code: "runtime_not_running",
|
||||
});
|
||||
const result = await capturedRun(["doctor", "--json"]);
|
||||
expect(result.code).toBe(3);
|
||||
expect(JSON.parse(result.stdout).schema_version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog exit codes", () => {
|
||||
it("lists exit 2 for every command, since every parser can reject", () => {
|
||||
for (const command of COMMANDS) {
|
||||
expect(command.exit_codes, command.name).toContain(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists exit 3 for every command that attaches to the Runtime", () => {
|
||||
for (const name of [
|
||||
"status",
|
||||
"doctor",
|
||||
"init",
|
||||
"sources",
|
||||
"sync",
|
||||
"progress",
|
||||
"jobs",
|
||||
"models",
|
||||
"memories",
|
||||
"profile",
|
||||
"settings",
|
||||
"evolve",
|
||||
"graph-width",
|
||||
"host",
|
||||
"recall",
|
||||
"scan-roots",
|
||||
"adapter",
|
||||
"scan",
|
||||
"write",
|
||||
"backup",
|
||||
"ui",
|
||||
"onboarding",
|
||||
]) {
|
||||
const command = COMMANDS.find((entry) => entry.name === name);
|
||||
expect(command?.exit_codes, name).toContain(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not claim exit 3 or 4 for lifecycle commands that never return them", () => {
|
||||
for (const name of ["start", "stop", "restart"]) {
|
||||
const command = COMMANDS.find((entry) => entry.name === name);
|
||||
expect(command?.exit_codes).toEqual([0, 1, 2]);
|
||||
}
|
||||
});
|
||||
|
||||
it("says stop turns login auto-start off for a managed service", () => {
|
||||
const stop = COMMANDS.find((entry) => entry.name === "stop");
|
||||
expect(stop?.summary).toContain("登录时也不会自动启动");
|
||||
});
|
||||
});
|
||||
|
||||
async function capturedRun(
|
||||
arguments_: string[],
|
||||
environment: NodeJS.ProcessEnv = { ONE_MEMORY_DATA_DIR: "/synthetic/data" },
|
||||
) {
|
||||
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_, environment), stdout, stderr };
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
import { DiagnosticLogger } from "@one-memory/runtime-control";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -30,27 +29,6 @@ async function fixture() {
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
function readZipEntry(archive: Buffer, expected: string): string {
|
||||
let offset = 0;
|
||||
while (archive.readUInt32LE(offset) === 0x04034b50) {
|
||||
const method = archive.readUInt16LE(offset + 8);
|
||||
const compressedSize = archive.readUInt32LE(offset + 18);
|
||||
const nameLength = archive.readUInt16LE(offset + 26);
|
||||
const extraLength = archive.readUInt16LE(offset + 28);
|
||||
const nameStart = offset + 30;
|
||||
const dataStart = nameStart + nameLength + extraLength;
|
||||
const name = archive.subarray(nameStart, nameStart + nameLength).toString();
|
||||
const compressed = archive.subarray(dataStart, dataStart + compressedSize);
|
||||
if (name === expected) {
|
||||
if (method === 0) return compressed.toString();
|
||||
if (method === 8) return inflateRawSync(compressed).toString();
|
||||
throw new Error("unsupported zip compression");
|
||||
}
|
||||
offset = dataStart + compressedSize;
|
||||
}
|
||||
throw new Error(`missing ${expected}`);
|
||||
}
|
||||
|
||||
it("prints matching diagnostic events as NDJSON without starting Runtime", async () => {
|
||||
const dataDir = await fixture();
|
||||
let stdout = "";
|
||||
@@ -96,25 +74,6 @@ it("exports one support zip to the requested downloads directory", async () => {
|
||||
});
|
||||
expect(receipt.path).toMatch(/MemHub-日志-3800-\d{8}-\d{4}\.zip$/);
|
||||
expect(await readdir(downloads)).toEqual([path.basename(receipt.path)]);
|
||||
const archive = await readFile(receipt.path);
|
||||
expect(JSON.parse(readZipEntry(archive, "summary.json")).commit).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed for an explicitly supplied invalid commit", async () => {
|
||||
const dataDir = await fixture();
|
||||
const downloads = await mkdtemp(path.join(os.tmpdir(), "om-cli-downloads-"));
|
||||
roots.push(downloads);
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
await expect(
|
||||
run(["logs", "export", "--json"], {
|
||||
ONE_MEMORY_DATA_DIR: dataDir,
|
||||
ONE_MEMORY_EXPORT_DIR: downloads,
|
||||
ONE_MEMORY_BUILD_NUMBER: "3800",
|
||||
ONE_MEMORY_COMMIT: "unknown",
|
||||
}),
|
||||
).rejects.toThrow("diagnostic_commit_invalid");
|
||||
expect(await readdir(downloads)).toEqual([]);
|
||||
});
|
||||
|
||||
it("never emits a secret from a malformed manually written event", async () => {
|
||||
@@ -162,54 +121,10 @@ it("never emits a secret from a malformed manually written event", async () => {
|
||||
command: "logs",
|
||||
state: "empty",
|
||||
events: 0,
|
||||
invalid_lines: 0,
|
||||
unrecognized_lines: 1,
|
||||
invalid_lines: 1,
|
||||
});
|
||||
});
|
||||
|
||||
// After an upgrade the files still hold an older build's records. They are
|
||||
// whole JSON lines the reader leaves out, not damage, so the text output must
|
||||
// not warn about them; only a line that is not JSON is called damaged.
|
||||
it("warns only about lines that are not JSON, not about older records", async () => {
|
||||
const dataDir = await fixture();
|
||||
const older = {
|
||||
schema_version: 0,
|
||||
service: "desktop",
|
||||
component: "desktop.lifecycle",
|
||||
event: "desktop.started",
|
||||
};
|
||||
await writeFile(
|
||||
path.join(dataDir, "logs", "diagnostic-desktop.jsonl"),
|
||||
`${JSON.stringify(older)}\n${JSON.stringify({ ...older, schema_version: 1 })}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((value) => {
|
||||
stdout += String(value);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((value) => {
|
||||
stderr += String(value);
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(await run(["logs"], { ONE_MEMORY_DATA_DIR: dataDir })).toBe(0);
|
||||
expect(stdout).toContain("core.start_failed");
|
||||
expect(stdout).not.toContain("损坏");
|
||||
expect(stderr).toBe("");
|
||||
|
||||
await writeFile(
|
||||
path.join(dataDir, "logs", "diagnostic-cli.jsonl"),
|
||||
'{"schema_version": 1, "event": \n',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
stdout = "";
|
||||
expect(await run(["logs"], { ONE_MEMORY_DATA_DIR: dataDir })).toBe(0);
|
||||
expect(stdout).toContain("跳过 1 行损坏的诊断记录。");
|
||||
expect(stderr).toBe("memhub: 跳过 1 行损坏的诊断记录。\n");
|
||||
});
|
||||
|
||||
it("rejects invalid filters without touching the log directory", async () => {
|
||||
const dataDir = path.join(
|
||||
await mkdtemp(path.join(os.tmpdir(), "om-cli-no-log-")),
|
||||
|
||||
+57
-726
@@ -1,18 +1,9 @@
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import { mkdtemp, readdir, readFile, realpath, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
claimRuntimeServiceForApp,
|
||||
clearSdkMigrationBlock,
|
||||
readRuntimeServiceOwner,
|
||||
recordSdkMigrationBlock,
|
||||
resolveRuntimeCliConfig,
|
||||
} from "@one-memory/runtime-control";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -66,11 +57,6 @@ const runtimeMocks = vi.hoisted(() => ({
|
||||
})),
|
||||
uninstallUserService: vi.fn(async (..._args: unknown[]) => "removed"),
|
||||
restartUserService: vi.fn(async () => undefined),
|
||||
runSdkMigration: vi.fn(
|
||||
async (): Promise<{ state: string; code?: string }> => ({
|
||||
state: "not_required",
|
||||
}),
|
||||
),
|
||||
rollbackLocalInstall: vi.fn(async () => undefined),
|
||||
planCliLink: vi.fn(async ({ home, installRoot }) => ({
|
||||
schema_version: 1,
|
||||
@@ -140,7 +126,6 @@ vi.mock("@one-memory/runtime-control", async (importOriginal) => {
|
||||
installUserService: runtimeMocks.installUserService,
|
||||
uninstallUserService: runtimeMocks.uninstallUserService,
|
||||
restartUserService: runtimeMocks.restartUserService,
|
||||
runSdkMigration: runtimeMocks.runSdkMigration,
|
||||
rollbackLocalInstall: runtimeMocks.rollbackLocalInstall,
|
||||
planCliLink: runtimeMocks.planCliLink,
|
||||
installCliLink: runtimeMocks.installCliLink,
|
||||
@@ -799,7 +784,7 @@ describe("public One Memory CLI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("upgrade restart failure keeps the new version and never restarts the old one", async () => {
|
||||
it("upgrade failure restarts the restored artifact with the same data identity", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce({
|
||||
schema_version: 1,
|
||||
@@ -841,27 +826,25 @@ describe("public One Memory CLI", () => {
|
||||
command: "upgrade",
|
||||
code: "restart failed",
|
||||
});
|
||||
expect(JSON.parse(streams.stderr())).toMatchObject({
|
||||
rolled_back: false,
|
||||
});
|
||||
// Product rule: the previous Runtime may predate the SDK migration gate,
|
||||
// so it is never restored after the migration was handed the home.
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(aliasMocks.rollbackManagedCliLink).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.restartUserService).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
(runtimeMocks.restartUserService.mock.calls as unknown[][])[0]?.[0],
|
||||
).toMatchObject({
|
||||
dataDir: "/tmp/custom data",
|
||||
installRoot: "/tmp/install/current",
|
||||
});
|
||||
expect(runtimeMocks.rollbackLocalInstall).toHaveBeenCalledOnce();
|
||||
// The CLI manages the objective aliases the package does not own
|
||||
// (`memhub` + `memhub-cli`); both are rolled back.
|
||||
expect(aliasMocks.rollbackManagedCliLink).toHaveBeenCalledTimes(2);
|
||||
expect(runtimeMocks.restartUserService).toHaveBeenCalledTimes(2);
|
||||
for (const call of runtimeMocks.restartUserService.mock
|
||||
.calls as unknown[][]) {
|
||||
expect(call[0]).toMatchObject({
|
||||
dataDir: "/tmp/custom data",
|
||||
installRoot: "/tmp/install/current",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// O-22 (2026-09-29 C8 H17): `upgrade --yes` on a manifest-valid but
|
||||
// crash-looping version reported exit 0 and left `current` on the bad
|
||||
// version. A restart that merely returns is not proof the new Runtime came
|
||||
// up, so the CLI now verifies health and rolls back if it never does.
|
||||
it("fails without rolling back when the upgraded Runtime never becomes healthy", async () => {
|
||||
it("rolls back and fails when the upgraded Runtime never becomes healthy", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce({
|
||||
schema_version: 1,
|
||||
@@ -901,219 +884,9 @@ describe("public One Memory CLI", () => {
|
||||
command: "upgrade",
|
||||
code: "upgrade_health_check_failed",
|
||||
});
|
||||
// Kept on the new version; the old Runtime is not restarted.
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.restartUserService).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const upgradeArguments = [
|
||||
"upgrade",
|
||||
"--source",
|
||||
"/tmp/dist",
|
||||
"--home",
|
||||
"/tmp/home",
|
||||
"--install-root",
|
||||
"/tmp/install",
|
||||
"--data-dir",
|
||||
"/tmp/custom data",
|
||||
"--yes",
|
||||
];
|
||||
const presentCliLink = {
|
||||
schema_version: 1 as const,
|
||||
platform: "linux" as const,
|
||||
home: "/tmp/home",
|
||||
install_root: "/tmp/install",
|
||||
bin_directory: "/tmp/bin",
|
||||
status: "present" as const,
|
||||
blocked_reason: null,
|
||||
link_path: "/tmp/bin/one-memory",
|
||||
target_path: "/tmp/install/current/bin/one-memory",
|
||||
};
|
||||
|
||||
it("says the data was restored when a failed migration put it back", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
runtimeMocks.runSdkMigration.mockResolvedValueOnce({
|
||||
state: "deferred",
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
dataRestored: true,
|
||||
} as never);
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
const failure = JSON.parse(streams.stderr());
|
||||
expect(failure).toMatchObject({
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
data_restored: true,
|
||||
log_file: "/tmp/custom data/logs/sdk-migration.log",
|
||||
});
|
||||
expect(failure.message).toContain("记忆数据已恢复到升级前的状态。");
|
||||
expect(failure.message).toContain(
|
||||
"/tmp/custom data/logs/sdk-migration.log",
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates the home with the new Core before restarting the Runtime", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
const order: string[] = [];
|
||||
runtimeMocks.runSdkMigration.mockImplementationOnce(async () => {
|
||||
order.push("migrate");
|
||||
return { state: "cutover" };
|
||||
});
|
||||
runtimeMocks.restartUserService.mockImplementationOnce(async () => {
|
||||
order.push("restart");
|
||||
});
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(order).toEqual(["migrate", "restart"]);
|
||||
expect(runtimeMocks.runSdkMigration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dataDir: "/tmp/custom data" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays on the new version, not serving, when the migration does not finish", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
runtimeMocks.runSdkMigration.mockResolvedValueOnce({
|
||||
state: "deferred",
|
||||
code: "sdk_migration_disk_space_insufficient",
|
||||
});
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
const failure = JSON.parse(streams.stderr());
|
||||
expect(failure).toMatchObject({
|
||||
command: "upgrade",
|
||||
code: "sdk_migration_disk_space_insufficient",
|
||||
// The one file support needs, and where to send it.
|
||||
log_file: "/tmp/custom data/logs/sdk-migration.log",
|
||||
issues_url: "https://github.com/Tencent-Hunyuan/Hy-Memory/issues/new",
|
||||
});
|
||||
expect(failure.message).toContain(
|
||||
"/tmp/custom data/logs/sdk-migration.log",
|
||||
);
|
||||
expect(failure.message).toContain("GitHub");
|
||||
expect(failure.message).not.toContain("回滚到上一版本");
|
||||
expect(runtimeMocks.runSdkMigration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ caller: "cli-upgrade" }),
|
||||
);
|
||||
// No rollback: the old Runtime has no migration gate. The new service is
|
||||
// (re)started so its gate, not the old Runtime, holds the data dir.
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(aliasMocks.rollbackManagedCliLink).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.restartUserService).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
(runtimeMocks.restartUserService.mock.calls as unknown[][])[0]?.[0],
|
||||
).toMatchObject({
|
||||
installRoot: "/tmp/install/current",
|
||||
});
|
||||
});
|
||||
|
||||
it("stays on the new version when the migration itself throws", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
runtimeMocks.runSdkMigration.mockRejectedValueOnce(
|
||||
new Error("sdk_migration_fence_failed"),
|
||||
);
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(JSON.parse(streams.stderr())).toMatchObject({
|
||||
command: "upgrade",
|
||||
code: "sdk_migration_fence_failed",
|
||||
log_file: "/tmp/custom data/logs/sdk-migration.log",
|
||||
issues_url: "https://github.com/Tencent-Hunyuan/Hy-Memory/issues/new",
|
||||
});
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still rolls back a failure before the migration touched the home", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
aliasMocks.installManagedCliLink.mockRejectedValueOnce(
|
||||
new Error("cli_link_failed"),
|
||||
);
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(JSON.parse(streams.stderr())).toMatchObject({
|
||||
command: "upgrade",
|
||||
code: "cli_link_failed",
|
||||
});
|
||||
expect(runtimeMocks.runSdkMigration).not.toHaveBeenCalled();
|
||||
// Rolled back to the previous version and restarted it.
|
||||
expect(runtimeMocks.rollbackLocalInstall).toHaveBeenCalledOnce();
|
||||
// The previous Runtime was never stopped; nothing is (re)started.
|
||||
expect(runtimeMocks.restartUserService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the new version when a migrated home is slow to come up", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
runtimeMocks.runSdkMigration.mockResolvedValueOnce({ state: "cutover" });
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => false },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(JSON.parse(streams.stderr())).toMatchObject({
|
||||
command: "upgrade",
|
||||
code: "runtime_not_ready_after_sdk_migration",
|
||||
});
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.restartUserService).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports success once the upgraded Runtime is healthy", async () => {
|
||||
@@ -1611,7 +1384,7 @@ describe("public One Memory CLI", () => {
|
||||
expect(JSON.parse(output.value()).apply).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the distribution when service installation fails after the migration ran", async () => {
|
||||
it("rolls back the distribution when service installation fails", async () => {
|
||||
runtimeMocks.applyLocalInstall.mockResolvedValueOnce({
|
||||
schema_version: 1,
|
||||
source: "/tmp/distribution",
|
||||
@@ -1652,14 +1425,13 @@ describe("public One Memory CLI", () => {
|
||||
command: "install",
|
||||
status: "error",
|
||||
code: "service failed",
|
||||
rolled_back: false,
|
||||
});
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.rollbackCliLink).not.toHaveBeenCalled();
|
||||
expect(aliasMocks.rollbackManagedCliLink).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.rollbackLocalInstall).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.rollbackCliLink).toHaveBeenCalledOnce();
|
||||
expect(aliasMocks.rollbackManagedCliLink).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("upgrades only an existing managed CLI and keeps it after a failed restart", async () => {
|
||||
it("upgrades only an existing managed CLI and rolls back a failed restart", async () => {
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce({
|
||||
schema_version: 1,
|
||||
platform: "linux",
|
||||
@@ -1699,7 +1471,7 @@ describe("public One Memory CLI", () => {
|
||||
status: "error",
|
||||
code: "restart failed",
|
||||
});
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.rollbackLocalInstall).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.installCliLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2011,11 +1783,16 @@ describe("stopped Runtime is exit 3, not a usage error", () => {
|
||||
["path", "list", "--json"],
|
||||
["adapter", "list", "--json"],
|
||||
["task", "--id", "nope", "--json"],
|
||||
["write", "status", "nope", "--json"],
|
||||
["backup", "create", "--target", "/tmp/x", "--json"],
|
||||
["scan-roots", "add", "--root", "/tmp/x", "--json"],
|
||||
["scan", "--source", "codex", "--json"],
|
||||
["onboarding", "reset", "--json"],
|
||||
[
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s",
|
||||
"--messages-file",
|
||||
"/tmp/messages.json",
|
||||
"--json",
|
||||
],
|
||||
["backup", "create", "--target", "/tmp/x", "--yes", "--json"],
|
||||
])("%s ... --json exits 3 with runtime_not_running", async (...argv) => {
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
@@ -2025,9 +1802,7 @@ describe("stopped Runtime is exit 3, not a usage error", () => {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(3);
|
||||
// A result, not an error: on stdout like every other --json document.
|
||||
expect(streams.stderr()).toBe("");
|
||||
const document = JSON.parse(streams.stdout());
|
||||
const document = JSON.parse(streams.stderr());
|
||||
expect(document).toMatchObject({
|
||||
code: "runtime_not_running",
|
||||
exit_code: 3,
|
||||
@@ -2036,48 +1811,6 @@ describe("stopped Runtime is exit 3, not a usage error", () => {
|
||||
expect(document.status).not.toBe("usage_error");
|
||||
});
|
||||
|
||||
it("write submit validates its messages file before attaching, then exits 3", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "memhub-write-"));
|
||||
const file = path.join(directory, "messages.json");
|
||||
await import("node:fs/promises").then(({ writeFile }) =>
|
||||
writeFile(file, JSON.stringify([{ role: "user", content: "hi" }])),
|
||||
);
|
||||
const streams = captureStreams();
|
||||
let missing: number;
|
||||
let code: number;
|
||||
try {
|
||||
missing = await run(
|
||||
[
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s",
|
||||
"--messages-file",
|
||||
path.join(directory, "absent.json"),
|
||||
"--json",
|
||||
],
|
||||
{},
|
||||
);
|
||||
code = await run(
|
||||
[
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s",
|
||||
"--messages-file",
|
||||
file,
|
||||
"--json",
|
||||
],
|
||||
{},
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
expect(missing!).toBe(2);
|
||||
expect(code!).toBe(3);
|
||||
});
|
||||
|
||||
it.each(["pause", "resume"] as const)(
|
||||
"parses sync %s as the direct sync-set command",
|
||||
async (action) => {
|
||||
@@ -2162,189 +1895,6 @@ describe("lifecycle commands accept --json", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A Runtime service that refuses to serve an unmigrated home must be reported
|
||||
// as that, with the migration log and where to send it, not as a generic
|
||||
// "not ready within budget".
|
||||
describe("lifecycle commands report a migration block", () => {
|
||||
async function blockedHome() {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "memhub-blocked-"));
|
||||
await recordSdkMigrationBlock(
|
||||
dataDir,
|
||||
"sdk_migration_disk_space_insufficient",
|
||||
"runtime-service",
|
||||
);
|
||||
return dataDir;
|
||||
}
|
||||
// darwin: kickstart "restarts" but never becomes ready. Elsewhere: no
|
||||
// service, so the direct spawn fails. Both must surface the block.
|
||||
const blockedControls = () => ({
|
||||
restartManagedRuntime: vi.fn(async () => "restarted" as const),
|
||||
waitForRuntimeReady: vi.fn(async () => false),
|
||||
stopRuntime: vi.fn(async () => "stopped" as const),
|
||||
ensureRuntime: vi.fn(async () => {
|
||||
throw new Error("Timed out waiting for MemHub Runtime");
|
||||
}),
|
||||
});
|
||||
|
||||
it("restart --json prints the block code, log file and issues URL", async () => {
|
||||
const dataDir = await blockedHome();
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["restart", "--json"],
|
||||
{ ONE_MEMORY_DATA_DIR: dataDir },
|
||||
blockedControls(),
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
const lines = output.value().trim().split("\n");
|
||||
const document = JSON.parse(lines[lines.length - 1]!);
|
||||
expect(document).toMatchObject({
|
||||
command: "restart",
|
||||
state: "blocked",
|
||||
code: "sdk_migration_disk_space_insufficient",
|
||||
log_file: path.join(dataDir, "logs", "sdk-migration.log"),
|
||||
issues_url: "https://github.com/Tencent-Hunyuan/Hy-Memory/issues/new",
|
||||
});
|
||||
expect(output.value()).not.toContain("runtime_not_ready_within_budget");
|
||||
});
|
||||
|
||||
it("restart on a frozen home retries the migration in the foreground first", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "memhub-frozen-"));
|
||||
await recordSdkMigrationBlock(
|
||||
dataDir,
|
||||
"sdk_migration_memory_unreadable",
|
||||
"desktop",
|
||||
{ frozen: { service_was_enabled: true }, data_restored: true },
|
||||
);
|
||||
const controls = {
|
||||
...blockedControls(),
|
||||
migrateSdkData: vi.fn(async () => ({
|
||||
state: "deferred" as const,
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
dataRestored: true,
|
||||
})),
|
||||
};
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["restart", "--json"],
|
||||
{ ONE_MEMORY_DATA_DIR: dataDir },
|
||||
controls,
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(controls.migrateSdkData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dataDir, caller: "cli-restart" }),
|
||||
);
|
||||
// Still frozen: the service is not started to retry on its own.
|
||||
expect(controls.restartManagedRuntime).not.toHaveBeenCalled();
|
||||
expect(controls.ensureRuntime).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
command: "restart",
|
||||
state: "blocked",
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
data_restored: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("restart on a frozen home restarts the Runtime once the retry succeeds", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "memhub-frozen-"));
|
||||
await recordSdkMigrationBlock(
|
||||
dataDir,
|
||||
"sdk_migration_memory_unreadable",
|
||||
"desktop",
|
||||
{ frozen: { service_was_enabled: false } },
|
||||
);
|
||||
const order: string[] = [];
|
||||
const migrateSdkData = vi.fn(async () => {
|
||||
order.push("migrate");
|
||||
await clearSdkMigrationBlock(dataDir);
|
||||
return { state: "cutover" as const };
|
||||
});
|
||||
const output = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["restart", "--json"],
|
||||
{ ONE_MEMORY_DATA_DIR: dataDir },
|
||||
{
|
||||
migrateSdkData,
|
||||
waitForRuntimeReady: vi.fn(async () => true),
|
||||
restartManagedRuntime: vi.fn(async () => {
|
||||
order.push("restart");
|
||||
return "restarted" as const;
|
||||
}),
|
||||
stopRuntime: vi.fn(async () => "stopped" as const),
|
||||
ensureRuntime: vi.fn(async () => {
|
||||
order.push("restart");
|
||||
return { pid: 1 } as never;
|
||||
}),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(migrateSdkData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ caller: "cli-restart" }),
|
||||
);
|
||||
expect(order).toEqual(["migrate", "restart"]);
|
||||
});
|
||||
|
||||
it("restart in text mode names the log file and GitHub issues", async () => {
|
||||
const dataDir = await blockedHome();
|
||||
const output = captureStdout();
|
||||
try {
|
||||
expect(
|
||||
await run(
|
||||
["restart"],
|
||||
{ ONE_MEMORY_DATA_DIR: dataDir },
|
||||
blockedControls(),
|
||||
),
|
||||
).toBe(1);
|
||||
} finally {
|
||||
output.restore();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(output.value()).toContain("sdk_migration_disk_space_insufficient");
|
||||
expect(output.value()).toContain(
|
||||
path.join(dataDir, "logs", "sdk-migration.log"),
|
||||
);
|
||||
expect(output.value()).toContain(
|
||||
"https://github.com/Tencent-Hunyuan/Hy-Memory/issues/new",
|
||||
);
|
||||
});
|
||||
|
||||
it("ui --json reports the block instead of runtime_not_running", async () => {
|
||||
const dataDir = await blockedHome();
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(["ui", "--json"], { ONE_MEMORY_DATA_DIR: dataDir });
|
||||
} finally {
|
||||
output.restore();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(code!).not.toBe(0);
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
command: "ui",
|
||||
state: "blocked",
|
||||
code: "sdk_migration_disk_space_insufficient",
|
||||
log_file: path.join(dataDir, "logs", "sdk-migration.log"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The objective names `memhub host install <host>` (host as a positional). Both
|
||||
// that and the `--host <id>` flag form must reach the same command; naming both
|
||||
// at once is ambiguous and refused rather than guessed.
|
||||
@@ -2454,7 +2004,7 @@ describe("install gates on Runtime readiness", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("fails without rolling back when the installed Runtime never becomes ready", async () => {
|
||||
it("fails and rolls back when the installed Runtime never becomes ready", async () => {
|
||||
vi.clearAllMocks();
|
||||
stubInstallPlan();
|
||||
const streams = captureStreams();
|
||||
@@ -2483,10 +2033,10 @@ describe("install gates on Runtime readiness", () => {
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(streams.stderr()).toContain("install_runtime_not_ready");
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.rollbackLocalInstall).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
lifecycleMocks.rollbackPreparedServiceInstall,
|
||||
).not.toHaveBeenCalled();
|
||||
).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("takes CLI ownership after a successful install over an App-owned Runtime", async () => {
|
||||
@@ -2531,7 +2081,7 @@ describe("install gates on Runtime readiness", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("never hands the home back to the App Runtime after the migration ran", async () => {
|
||||
it("restores App ownership before relaunching the old Runtime after install failure", async () => {
|
||||
vi.clearAllMocks();
|
||||
stubInstallPlan();
|
||||
const root = await mkdtemp(path.join(tmpdir(), "memhub-cli-owner-"));
|
||||
@@ -2541,6 +2091,14 @@ describe("install gates on Runtime readiness", () => {
|
||||
appBundle: "/Applications/MemHub.app",
|
||||
appExecutable: "/Applications/MemHub.app/Contents/MacOS/MemHub",
|
||||
});
|
||||
lifecycleMocks.rollbackPreparedServiceInstall.mockImplementationOnce(
|
||||
async (_prepared: unknown, rollback: () => Promise<void>) => {
|
||||
await rollback();
|
||||
await expect(readRuntimeServiceOwner(dataDir)).resolves.toMatchObject({
|
||||
owner: "app",
|
||||
});
|
||||
},
|
||||
);
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(
|
||||
@@ -2563,114 +2121,15 @@ describe("install gates on Runtime readiness", () => {
|
||||
{ waitForRuntimeReady: async () => false },
|
||||
),
|
||||
).toBe(1);
|
||||
// The App Runtime may predate the SDK migration gate: CLI keeps it.
|
||||
await expect(readRuntimeServiceOwner(dataDir)).resolves.toMatchObject({
|
||||
owner: "cli",
|
||||
owner: "app",
|
||||
});
|
||||
expect(
|
||||
lifecycleMocks.rollbackPreparedServiceInstall,
|
||||
).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
streams.restore();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the install, not serving, when its migration is deferred", async () => {
|
||||
vi.clearAllMocks();
|
||||
stubInstallPlan();
|
||||
runtimeMocks.runSdkMigration.mockResolvedValueOnce({
|
||||
state: "deferred",
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
});
|
||||
const waitForRuntimeReady = vi.fn(async () => true);
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
[
|
||||
"install",
|
||||
"--source",
|
||||
"/tmp/dist",
|
||||
"--home",
|
||||
"/tmp/home",
|
||||
"--install-root",
|
||||
"/tmp/install",
|
||||
"--bin-dir",
|
||||
"/tmp/bin",
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"--yes",
|
||||
],
|
||||
{},
|
||||
{ waitForRuntimeReady },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
const failure = JSON.parse(streams.stderr());
|
||||
expect(failure).toMatchObject({
|
||||
command: "install",
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
log_file: "/tmp/data/logs/sdk-migration.log",
|
||||
issues_url: "https://github.com/Tencent-Hunyuan/Hy-Memory/issues/new",
|
||||
});
|
||||
expect(failure.message).toContain("/tmp/data/logs/sdk-migration.log");
|
||||
expect(runtimeMocks.runSdkMigration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ caller: "cli-install" }),
|
||||
);
|
||||
// The new (gated) service owns the data dir; the previous registration is
|
||||
// never re-bootstrapped and nothing is rolled back.
|
||||
expect(lifecycleMocks.applyPreparedServiceInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ restorePreviousOnFailure: false }),
|
||||
);
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(
|
||||
lifecycleMocks.rollbackPreparedServiceInstall,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(waitForRuntimeReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still rolls back an install that failed before its migration ran", async () => {
|
||||
vi.clearAllMocks();
|
||||
stubInstallPlan();
|
||||
runtimeMocks.installCliLink.mockRejectedValueOnce(
|
||||
new Error("cli_link_failed"),
|
||||
);
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
[
|
||||
"install",
|
||||
"--source",
|
||||
"/tmp/dist",
|
||||
"--home",
|
||||
"/tmp/home",
|
||||
"--install-root",
|
||||
"/tmp/install",
|
||||
"--bin-dir",
|
||||
"/tmp/bin",
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"--yes",
|
||||
],
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(JSON.parse(streams.stderr())).toMatchObject({
|
||||
command: "install",
|
||||
code: "cli_link_failed",
|
||||
});
|
||||
expect(runtimeMocks.runSdkMigration).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.rollbackLocalInstall).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("succeeds when the installed Runtime becomes ready", async () => {
|
||||
vi.clearAllMocks();
|
||||
stubInstallPlan();
|
||||
@@ -2750,8 +2209,7 @@ describe("ui wires the Core session contract without token leakage", () => {
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
// A stopped Runtime is exit 3, like every other command.
|
||||
expect(code!).toBe(3);
|
||||
expect(code!).toBe(4);
|
||||
const document = JSON.parse(output.value());
|
||||
expect(document).toMatchObject({
|
||||
schema_version: 1,
|
||||
@@ -2780,120 +2238,6 @@ describe("ui wires the Core session contract without token leakage", () => {
|
||||
// refuse while the Runtime owns the data dir, stop+call Core from a
|
||||
// maintenance process, and never report success from a malformed response.
|
||||
describe("backup restore drives the Core managed-restore contract", () => {
|
||||
it("previews without --yes, exits 0 and starts nothing", async () => {
|
||||
const maintenance = vi.fn();
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["backup", "restore", "--source", "/tmp/x", "--json"],
|
||||
{},
|
||||
{ runMaintenanceRestore: maintenance },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(maintenance).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
command: "backup restore",
|
||||
state: "preview",
|
||||
target: "/tmp/x",
|
||||
applied: false,
|
||||
next_step: "memhub backup restore --source /tmp/x --yes --confirm /tmp/x",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes data only when --confirm repeats the data directory", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "memhub-delete-home-"));
|
||||
const dataDir = path.join(home, ".one-memory");
|
||||
await mkdir(path.join(dataDir, "logs"), { recursive: true });
|
||||
const deleteData = async (...extra: string[]) => {
|
||||
const output = captureStdout();
|
||||
try {
|
||||
const code = await run(
|
||||
["delete-data", "--data-dir", dataDir, ...extra, "--json"],
|
||||
{ HOME: home },
|
||||
);
|
||||
return { code, document: JSON.parse(output.value()) };
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
};
|
||||
try {
|
||||
const refused = await deleteData("--yes");
|
||||
expect(refused.code).toBe(4);
|
||||
expect(refused.document).toMatchObject({
|
||||
state: "blocked",
|
||||
code: "data_deletion_confirmation_required",
|
||||
});
|
||||
expect(await readdir(dataDir)).toEqual(["logs"]);
|
||||
|
||||
const deleted = await deleteData("--yes", "--confirm", dataDir);
|
||||
expect(deleted.code).toBe(0);
|
||||
expect(await readdir(dataDir).catch(() => [])).toEqual([]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses --yes without repeating the backup path", async () => {
|
||||
const maintenance = vi.fn();
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["backup", "restore", "--source", "/tmp/x", "--yes", "--json"],
|
||||
{},
|
||||
{ runMaintenanceRestore: maintenance },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(4);
|
||||
expect(maintenance).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
state: "rejected",
|
||||
code: "backup_restore_confirmation_required",
|
||||
next_step:
|
||||
"恢复需要再写一遍备份路径:memhub backup restore --source /tmp/x --yes --confirm /tmp/x",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores with --yes and the repeated backup path", async () => {
|
||||
const maintenance = vi.fn(async () => ({
|
||||
state: "restored" as const,
|
||||
result: {
|
||||
state: "restored" as const,
|
||||
restoredBytes: 1,
|
||||
previousBackupPath: "/tmp/pre.sqlite3",
|
||||
sourceSchemaVersion: 34,
|
||||
},
|
||||
}));
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
[
|
||||
"backup",
|
||||
"restore",
|
||||
"--source",
|
||||
"/tmp/x",
|
||||
"--yes",
|
||||
"--confirm",
|
||||
"/tmp/x",
|
||||
"--json",
|
||||
],
|
||||
{},
|
||||
{ runMaintenanceRestore: maintenance },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(maintenance).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects while the Runtime is running, with a distinct exit code", async () => {
|
||||
runtimeMocks.inspectRuntimeStatus.mockResolvedValueOnce({
|
||||
state: "running",
|
||||
@@ -3010,7 +2354,7 @@ describe("backup restore drives the Core managed-restore contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a legacy --confirm that does not repeat the source path", async () => {
|
||||
it("requires --confirm to repeat the exact source path", async () => {
|
||||
const maintenance = vi.fn();
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
@@ -3038,7 +2382,7 @@ describe("backup restore drives the Core managed-restore contract", () => {
|
||||
command: "backup",
|
||||
verb: "restore",
|
||||
state: "rejected",
|
||||
code: "backup_restore_confirmation_mismatch",
|
||||
code: "backup_restore_confirmation_required",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3190,7 +2534,7 @@ describe("init onboarding", () => {
|
||||
|
||||
// Boolean flags were idempotent, but accepting them twice hid a duplicated
|
||||
// argv fragment that could also have duplicated a value-bearing flag.
|
||||
it.each(["--import", "--confirm-overrides"])(
|
||||
it.each(["--import", "--full-rescan", "--confirm-overrides", "--dry-run"])(
|
||||
"refuses a repeated init %s flag",
|
||||
async (flag) => {
|
||||
expect(await run(["init", "--source", "codex", flag, flag], {})).toBe(2);
|
||||
@@ -3263,25 +2607,12 @@ describe("init onboarding", () => {
|
||||
},
|
||||
);
|
||||
|
||||
// `--dry-run` and `--full-rescan` never did anything (P0-2/P0-3): they are
|
||||
// gone rather than accepted as no-ops.
|
||||
it.each(["--dry-run", "--full-rescan"])(
|
||||
"refuses the removed no-op %s flag",
|
||||
async (flag) => {
|
||||
expect(
|
||||
await run(["init", "--source", "codex", "--import", flag], {}),
|
||||
).toBe(2);
|
||||
},
|
||||
);
|
||||
|
||||
it("still parses the old `init --source --import` spelling", async () => {
|
||||
// It stops at the Runtime check (exit 3), which is not a usage failure.
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["init", "--source", "codex", "--import"], {})).toBe(3);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
it("accepts --dry-run together with --import", async () => {
|
||||
// Parsing must accept the pair; it stops at the Runtime check, which is
|
||||
// not a usage failure.
|
||||
expect(
|
||||
await run(["init", "--source", "codex", "--import", "--dry-run"], {}),
|
||||
).not.toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2261
-2567
File diff suppressed because it is too large.
Load diff
@@ -58,22 +58,10 @@ const CONFIRM_OPTION: CommandOption = {
|
||||
flag: "--yes",
|
||||
summary: "确认执行;不加该参数时只输出计划,不做任何改动。",
|
||||
};
|
||||
/** The one confirmation convention, for irreversible operations only. */
|
||||
const DESTRUCTIVE_CONFIRM_OPTION: CommandOption = {
|
||||
flag: "--yes",
|
||||
summary:
|
||||
"确认这次不可逆操作;不加时只说明将要做什么,不做任何改动,exit 0。(旧的 --confirm 仍作为别名接受。)",
|
||||
};
|
||||
const JSON_OPTION: CommandOption = {
|
||||
flag: "--json",
|
||||
summary: "以 JSON 输出到 stdout,供脚本与 Agent 消费。",
|
||||
};
|
||||
/** Global to the memory commands: whose memories are read or written. */
|
||||
const USER_ID_OPTION: CommandOption = {
|
||||
flag: "--user-id <id>",
|
||||
summary:
|
||||
"读写哪个用户的记忆,默认 local(App 用的就是 local)。1–64 位字母、数字、点、下划线或连字符;其他用户的记忆来自用 sources connect hermes --user-id 接入的 Hermes profile。",
|
||||
};
|
||||
const WATCH_OPTION: CommandOption = {
|
||||
flag: "--watch",
|
||||
summary: "串行持续刷新;Ctrl-C 只停止观察,不停止 Runtime。",
|
||||
@@ -82,6 +70,21 @@ const INTERVAL_OPTION: CommandOption = {
|
||||
flag: "--interval <seconds>",
|
||||
summary: "刷新间隔,默认 5 秒,范围 1..300;必须与 --watch 一起使用。",
|
||||
};
|
||||
const PLAN_FILE_OPTION: CommandOption = {
|
||||
flag: "--plan-file <path>",
|
||||
summary: "读取先前 --json 保存的有界计划文件。",
|
||||
};
|
||||
const EXPLICIT_CONFIRM_OPTION: CommandOption = {
|
||||
flag: "--confirm",
|
||||
summary: "确认执行计划文件逐字描述的操作;缺少时绝不写入。",
|
||||
};
|
||||
// Same flag, but for commands that name their target directly instead of
|
||||
// going through a saved plan file; the plan-file wording would be wrong here.
|
||||
const DIRECT_CONFIRM_OPTION: CommandOption = {
|
||||
flag: "--confirm",
|
||||
summary: "确认执行上面预览的这次操作;缺少时绝不写入。",
|
||||
};
|
||||
|
||||
const SERVICE_OPTIONS: readonly CommandOption[] = [
|
||||
PLATFORM_OPTION,
|
||||
HOME_OPTION,
|
||||
@@ -151,77 +154,55 @@ const INSTALL_OPTIONS: readonly CommandOption[] = [
|
||||
const INSTALL_USAGE =
|
||||
"[--source <path> | --release-manifest-url <https-url>] [--channel <stable|prerelease>] [--install-root <path>] [--home <path>] [--bin-dir <path>] [--data-dir <path>] [--uid <id>] [--xdg-config-home <path>] [--no-shell-path] [--shell-profile <path>] [--preserve-data-dir] [--allow-downgrade] [--yes]";
|
||||
|
||||
const USAGE_EXITS = [EXIT_OK, EXIT_FAILED, EXIT_USAGE] as const;
|
||||
const RUNTIME_EXITS = [
|
||||
EXIT_OK,
|
||||
EXIT_FAILED,
|
||||
EXIT_USAGE,
|
||||
EXIT_RUNTIME_STOPPED,
|
||||
] as const;
|
||||
|
||||
const KIND_OPTION: CommandOption = {
|
||||
flag: "--kind <chat|experience|fact>",
|
||||
summary:
|
||||
"记忆所在的命名空间;工作/编码场景的经验和事实需要同时给 --variant。list 输出里每条都带 kind/variant。",
|
||||
};
|
||||
const VARIANT_OPTION: CommandOption = {
|
||||
flag: "--variant <working|coding>",
|
||||
summary:
|
||||
"经验或事实属于工作还是编码场景;必须和 --kind experience|fact 一起用。",
|
||||
};
|
||||
|
||||
export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "serve",
|
||||
summary: "在前台启动完整 Runtime(Core 与 Host Gateway),不注册系统服务。",
|
||||
usage: "memhub serve",
|
||||
options: [],
|
||||
exit_codes: USAGE_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED],
|
||||
},
|
||||
{
|
||||
name: "start",
|
||||
summary:
|
||||
"启动本机 Runtime;已有通过身份校验的实例时只连接,不重复启动。受管服务会同时恢复登录时自动启动。",
|
||||
summary: "启动本机 Runtime;已有通过身份校验的实例时只连接,不重复启动。",
|
||||
usage: "memhub start [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: USAGE_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "stop",
|
||||
summary:
|
||||
"停止通过身份校验的本机 Runtime;身份不明确时不发送信号。受管服务(launchd/systemd、App)停止后登录时也不会自动启动,直到再次运行 memhub start。",
|
||||
summary: "停止通过身份校验的本机 Runtime;身份不明确时不发送信号。",
|
||||
usage: "memhub stop [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: USAGE_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED],
|
||||
},
|
||||
{
|
||||
name: "restart",
|
||||
summary: "先停止再启动本机 Runtime,两步串行执行。",
|
||||
usage: "memhub restart [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: USAGE_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
summary: "只读查询 Runtime 运行状态;不启动服务、不迁移数据、不改配置。",
|
||||
usage: "memhub status [--watch] [--interval <seconds>] [--json]",
|
||||
options: [WATCH_OPTION, INTERVAL_OPTION, JSON_OPTION],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "doctor",
|
||||
summary:
|
||||
"按诊断口径做只读检查并给出修复建议;默认不修复任何东西。Runtime 未运行时 exit 3。",
|
||||
summary: "按诊断口径做同样的只读检查;默认不修复任何东西。",
|
||||
usage: "memhub doctor [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "logs",
|
||||
summary:
|
||||
"读取本机脱敏诊断日志;logs export 会把排查包(含系统崩溃报告)直接存到下载目录。默认显示最近一次会话的 200 条事件。",
|
||||
"读取本机脱敏诊断日志;logs export 会把排查包直接存到下载目录。默认显示最近一次会话的 200 条事件。",
|
||||
usage:
|
||||
"memhub logs export [--json]\n memhub logs [--since <15m|2h|7d|RFC3339>] [--session <id>] [--trace <id>] [--incident <id>] [--operation <id>] [--request <id>] [--job <id>] [--task <id>] [--component <name>] [--event <name>] [--errors-only] [--tail <1..5000>] [--follow] [--json]",
|
||||
"memhub logs export [--json]\nmemhub logs [--since <15m|2h|7d|RFC3339>] [--session <id>] [--trace <id>] [--incident <id>] [--operation <id>] [--request <id>] [--job <id>] [--task <id>] [--component <name>] [--event <name>] [--errors-only] [--tail <1..5000>] [--follow] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--since <time>",
|
||||
@@ -245,56 +226,64 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
"以 NDJSON 输出到 stdout(每行一个脱敏事件;非单 JSON 文档),供脚本与 Agent 消费。",
|
||||
},
|
||||
],
|
||||
exit_codes: USAGE_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE],
|
||||
},
|
||||
{
|
||||
name: "init",
|
||||
summary:
|
||||
"首次接入引导。在终端里(未加 --json)进入交互向导:选择来源 → 是否开启处理 → 配置模型。非终端或加 --json 时不读 stdin,只列出可接入的来源和下一步命令。脚本接入请用 sources connect。",
|
||||
usage: "memhub init [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "sources",
|
||||
summary:
|
||||
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。Hermes 的每个 profile 是一个独立用户:用 --user-id 和 --root 接入,授权并导入历史(会自动排除 ~/.hermes/profiles/**,各用户互不读取)。",
|
||||
"首次接入:选择来源 → 选择是否导入历史。接入即开启该 Harness 的处理;后续可用 sync pause/resume 冻结或恢复。在终端且不带 --source 时进入交互引导(默认全选);非终端或加 --json 时不读 stdin、只列出可用来源和下一步。--all-detected 一次接入所有已检测来源。",
|
||||
usage:
|
||||
"memhub sources list [--json]\n memhub sources connect <id> [<id> ...] [--import] [--confirm-overrides] [--request-id <uuid>] [--json]\n memhub sources connect hermes --user-id <id> --root <path> [--import] [--request-id <uuid>] [--json]\n memhub sources connect --all-detected [--import] [--confirm-overrides] [--json]\n memhub sources receipt <request-id> [--json]",
|
||||
"memhub init [--json]\n memhub init --source <id> [--source <id> ...] [--import] [--dry-run] [--full-rescan] [--confirm-overrides] [--json]\n memhub init --all-detected [--import] [--dry-run] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--import",
|
||||
summary:
|
||||
"接入后立即导入历史会话并开启持续同步;不加时只授权,处理保持冻结。",
|
||||
},
|
||||
{
|
||||
flag: "--user-id <id>",
|
||||
summary:
|
||||
"只用于 hermes:这条接入属于哪个用户(默认 local)。和 --root 一起接入一个 Hermes profile,例如 --user-id wife --root ~/.hermes/profiles/wife;其他来源只能是 local。",
|
||||
},
|
||||
{
|
||||
flag: "--root <path>",
|
||||
summary:
|
||||
"只用于 hermes:读取的根目录(~ 会展开为主目录)。默认是 Hermes 自己的目录 ~/.hermes。",
|
||||
flag: "--source <id>",
|
||||
summary: "要接入的来源,可重复;终端下不给则进入交互引导。",
|
||||
},
|
||||
{
|
||||
flag: "--all-detected",
|
||||
summary:
|
||||
"接入全部已检测到(location_state=found)的来源;与具体来源互斥。",
|
||||
"接入并(加 --import 时)导入全部 location_state=found 的来源。",
|
||||
},
|
||||
{
|
||||
flag: "--import",
|
||||
summary: "接入后立即导入该来源的历史会话(默认只接入不导入)。",
|
||||
},
|
||||
{
|
||||
flag: "--dry-run",
|
||||
summary: "只出计划,不接入也不导入;用于先看会做什么。",
|
||||
},
|
||||
{
|
||||
flag: "--full-rescan",
|
||||
summary: "导入时忽略既有游标做全量重扫。",
|
||||
},
|
||||
{
|
||||
flag: "--confirm-overrides",
|
||||
summary:
|
||||
"确认接受目录来自环境变量覆盖的来源(如 DSH_HOME 指定的目录);不加时这些来源被拒绝(指定来源时)或跳过并列出(--all-detected 时)。",
|
||||
},
|
||||
{
|
||||
flag: "--request-id <uuid>",
|
||||
summary:
|
||||
"--import 的幂等键;用同一个 id 重试只会读回第一次的回执,不会重复导入。",
|
||||
summary: "确认接受环境覆盖类来源(如 DSH_HOME 指定的目录)。",
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "setup",
|
||||
summary: "生成无头首次导入计划;只读取已有来源,不探测目录、不写授权。",
|
||||
usage: "memhub setup plan --source <id> [--source <id> ...] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--source <id>",
|
||||
summary: "计划导入的来源,可重复 1..32 次。",
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "sources",
|
||||
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],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "sync",
|
||||
@@ -308,7 +297,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "progress",
|
||||
@@ -329,17 +318,17 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
INTERVAL_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "jobs",
|
||||
summary:
|
||||
"查看或控制导入任务:start 创建 history/sync 任务;pause/resume/cancel 控制已有任务。都直接执行。",
|
||||
"启动或控制单个导入任务:start 创建 history/sync 作业;pause/resume/cancel 控制已有作业。所有写操作先出计划,必须显式 --confirm;list/show 为只读。",
|
||||
usage:
|
||||
"memhub jobs list [--json]\n" +
|
||||
" memhub jobs show <job-id> [--json]\n" +
|
||||
" memhub jobs start --source <id> [--operation <history|sync>] [--json]\n" +
|
||||
" memhub jobs <pause|resume|cancel> --job <id> [--json]",
|
||||
" memhub jobs start --source <id> [--operation <history|sync>] [--confirm] [--json]\n" +
|
||||
" memhub jobs <pause|resume|cancel> --job <id> [--confirm] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--source <id>",
|
||||
@@ -347,186 +336,123 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
{
|
||||
flag: "--operation <history|sync>",
|
||||
summary:
|
||||
"start 的导入类型,默认 history;sync 与 scan --source 相同,立即增量同步一次。",
|
||||
summary: "start 的导入类型,默认 history。",
|
||||
},
|
||||
{
|
||||
flag: "--job <id>",
|
||||
summary: "pause/resume/cancel 的目标导入任务。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "models",
|
||||
summary:
|
||||
"管理生成模型配置。add/test 直接提交并返回操作 ID(异步,用 receipt 查结果);edit 直接执行;activate 换了向量模型(模型或维度)时先显示切换计划,需 --yes 确认后重新生成全部向量,switch 查看或取消、spaces 查看或清理旧向量数据;remove 不可逆,需 --yes。<profile-id>/<operation-id> 都是 UUID;脚本模式的凭据只从受控文件读取。",
|
||||
"交互配置模型,或生成配置/探测计划、执行已确认操作与查询回执;脚本模式的凭据只从受控文件读取。",
|
||||
usage:
|
||||
"memhub models list [--json]\n memhub models show <profile-id> [--json]\n memhub models add --config-file <path> [--api-key-file <path>] [--activate [--yes] [--wait|--no-wait]] [--json]\n memhub models test <profile-id> [--json]\n memhub models receipt <operation-id> [--json]\n memhub models edit <profile-id> --config-file <path> [--api-key-file <path>] [--json]\n memhub models activate <profile-id> [--yes] [--wait|--no-wait] [--json]\n memhub models switch <status|cancel> [--json]\n memhub models spaces [--cleanup <space-id> --yes] [--json]\n memhub models remove <profile-id> [--yes] [--json]",
|
||||
"memhub models list [--json]\n memhub models show <profile-id> [--json]\n memhub models plan --config-file <path> [--json]\n memhub models configure --plan-file <path> --confirm [--api-key-file <path>] [--json]\n memhub models receipt <operation-id> [--json]\n memhub models edit <profile-id> --config-file <path> [--confirm] [--api-key-file <path>] [--json]\n memhub models activate <profile-id> [--confirm] [--json]\n memhub models probe-plan <profile-id> [--json]\n memhub models probe --plan-file <path> --confirm [--json]\n memhub models probe-receipt <operation-id> [--json]\n memhub models remove <profile-id> [--confirm] [--json]",
|
||||
alternative_usage: "memhub models setup",
|
||||
options: [
|
||||
{
|
||||
flag: "--config-file <path>",
|
||||
summary:
|
||||
"有界 JSON 模型配置,不能含 api_key;edit 的补丁必须带 expected_revision。",
|
||||
summary: "不含 api_key 的有界 JSON 模型配置。",
|
||||
},
|
||||
PLAN_FILE_OPTION,
|
||||
EXPLICIT_CONFIRM_OPTION,
|
||||
{
|
||||
flag: "--api-key-file <path>",
|
||||
summary: "从属主专用的普通文件读取凭据;内容不进入参数、输出或错误。",
|
||||
summary: "从 regular file 读取凭据;内容不进入参数、输出或错误。",
|
||||
},
|
||||
{
|
||||
flag: "--activate",
|
||||
summary:
|
||||
"add 成功后先探测,再按 activate 的规则设为当前使用(向量模型变化时同样要确认切换)。",
|
||||
},
|
||||
{
|
||||
flag: "--wait | --no-wait",
|
||||
summary:
|
||||
"activate 开始向量模型切换后是否跟随进度;终端里默认跟随,Ctrl-C 只停止显示。",
|
||||
},
|
||||
{
|
||||
flag: "--cleanup <space-id>",
|
||||
summary: "spaces:立即删除一个已停用的向量空间(不可恢复,需 --yes)。",
|
||||
},
|
||||
DESTRUCTIVE_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "memories",
|
||||
summary:
|
||||
"列出、搜索、查看、添加、修改或物理删除记忆(对话、工作、编码三个场景)。add 和 App 的「添加记忆」是同一个入口;delete 不可逆,需 --yes。",
|
||||
summary: "通过 SDK 数据面列出、查看、修改或物理删除记忆。",
|
||||
usage:
|
||||
"memhub memories list [--scene <all|chat|working|coding>] [--source <id>] [--query <text>] [--status <state>] [--offset <n>] [--limit <n>] [--user-id <id>] [--json]\n memhub memories show <memory-id> [--kind <kind>] [--variant <variant>] [--user-id <id>] [--json]\n memhub memories add <text> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add --content-file <path> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add-status <request-id> [--user-id <id>] [--json]\n memhub memories update <memory-id> --content-file <path> [--kind <kind>] [--variant <variant>] [--task <text>] [--user-id <id>] [--json]\n memhub memories delete <memory-id> [--kind <kind>] [--variant <variant>] [--yes] [--user-id <id>] [--json]",
|
||||
"memhub memories list [--status <state>] [--offset <n>] [--limit <n>] [--json]\n memhub memories show <memory-id> [--json]\n memhub memories update <memory-id> --content-file <path> [--confirm] [--json]\n memhub memories delete <memory-id> [--confirm] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--scene <all|chat|working|coding>",
|
||||
summary: "只列一个场景,默认 all。working/coding 列的是该场景的经验。",
|
||||
},
|
||||
{ flag: "--source <id>", summary: "只列某个来源写入的记忆。" },
|
||||
{
|
||||
flag: "--query <text>",
|
||||
summary:
|
||||
"关键词搜索(只搜 active 记忆,在最相关的一批里匹配所有关键词,输出 search_window 时表示可能少计)。",
|
||||
},
|
||||
{
|
||||
flag: "--status <state>",
|
||||
summary: `记忆状态,取值 ${MEMORY_STATUSES.join("、")},默认 active。`,
|
||||
summary: `记忆状态,取值 ${MEMORY_STATUSES.join("、")}。`,
|
||||
},
|
||||
{ flag: "--offset <n>", summary: "跳过前 n 条,默认 0。" },
|
||||
{
|
||||
flag: "--limit <n>",
|
||||
summary: "本页最多返回多少条,默认 100,上限 500。",
|
||||
},
|
||||
KIND_OPTION,
|
||||
VARIANT_OPTION,
|
||||
{
|
||||
flag: "--content-file <path>",
|
||||
summary:
|
||||
"从有界普通文件读取正文:add 的新记忆(≤12000 字),或 update 的新内容(经验为 solution)。",
|
||||
"从有界普通文件读取新增/纠正正文;计划只保存摘要,执行时再次校验。",
|
||||
},
|
||||
{
|
||||
flag: "--task <text>",
|
||||
summary: "update 经验(--kind experience)时同时改写 task。",
|
||||
},
|
||||
{
|
||||
flag: "--request-id <uuid>",
|
||||
summary:
|
||||
"add 的幂等键;用同一个 id 重试只会读回第一次的结果,不会重复添加。不给时自动生成并输出。",
|
||||
},
|
||||
DESTRUCTIVE_CONFIRM_OPTION,
|
||||
USER_ID_OPTION,
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "profile",
|
||||
summary:
|
||||
"读取 SDK 组装后的用户长期画像(L0 + 已启用的长期画像补充);--versions 列出 L0 画像的版本链(最新在前,* 为当前版本)。",
|
||||
usage: "memhub profile [--versions] [--user-id <id>] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--versions",
|
||||
summary:
|
||||
"列出 L0 基本信息的版本链(get_user_basic_info),用于找出覆盖了正确值的错误版本。",
|
||||
},
|
||||
USER_ID_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
summary: "读取 SDK 组装后的用户长期画像(L0 + 已启用的长期画像补充)。",
|
||||
usage: "memhub profile [--json]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
name: "calls",
|
||||
summary:
|
||||
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。settings features 查看或开关 SDK 的可选检索与提取功能(全局设置,默认全部保持 SDK 默认值):graph_rerank 图谱重排(仅 ultra)、same_series_timeline 同系列时间线(需 graph_rerank)、episode_recall 片段召回(pro / ultra,需先开 session_episodes 写入片段)、session_episodes 片段写入、episode_summary 片段摘要(每个片段一次模型调用)、intention 意图提取(默认开)、few_shot 提取示例;set 需 --yes,不加时只显示计划;reset 恢复默认值。",
|
||||
"清除本机调用记录;不可逆删除,默认只出计划,必须显式 --confirm 才执行。",
|
||||
usage:
|
||||
"memhub settings mode [lite|pro|ultra] [--yes] [--json]\n" +
|
||||
" memhub settings features [--json]\n" +
|
||||
" memhub settings features set <name>=<true|false> [...] [--yes] [--json]\n" +
|
||||
" memhub settings features reset [<name> ...] [--json]",
|
||||
"memhub calls clear --event <id> [--event <id> ...] [--confirm] [--json]\n memhub calls clear --all [--confirm] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "mode [lite|pro|ultra]",
|
||||
summary: "不带模式只显示当前模式;带模式时先显示切换计划。",
|
||||
flag: "--event <id>",
|
||||
summary: "要清除的调用记录事件 ID,可重复,最多 100 条。",
|
||||
},
|
||||
{
|
||||
flag: "features set <name>=<true|false>",
|
||||
summary:
|
||||
"开关一个或多个 SDK 功能;功能在当前模式下不生效时会注明原因(例如 graph_rerank 需要 ultra)。",
|
||||
flag: "--all",
|
||||
summary: "清除全部调用记录;与 --event 互斥。",
|
||||
},
|
||||
{
|
||||
flag: "features reset [<name> ...]",
|
||||
summary: "把指定功能(不写则全部)恢复为 SDK 默认值。",
|
||||
},
|
||||
CONFIRM_OPTION,
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "evolve",
|
||||
name: "notes",
|
||||
summary:
|
||||
"立即为一个用户运行记忆进化(System 2,仅 ultra 模式)。在后台运行并返回运行 ID;--wait 等待结果。同一用户同时只运行一次;每晚的自动进化由 Runtime 按设置安排。",
|
||||
"端内新增记忆(memory-notes):从普通文件新增,或显式确认后处理已有笔记。",
|
||||
usage:
|
||||
"memhub evolve [--user-id <id>] [--agent-id <id>] [--max-batches <n>] [--wait] [--json]",
|
||||
"memhub notes add --content-file <path> [--json]\n memhub notes apply --plan-file <path> --content-file <path> --confirm [--json]\n memhub notes process <note-id> [--confirm] [--json]",
|
||||
options: [
|
||||
USER_ID_OPTION,
|
||||
{
|
||||
flag: "--agent-id <id>",
|
||||
summary:
|
||||
"进化哪个 agent 的记忆,默认 all-apps(MemHub 写入记忆用的 agent)。",
|
||||
flag: "--content-file <path>",
|
||||
summary: "从有界普通文件读取新增正文;计划只保存摘要,执行时重新校验。",
|
||||
},
|
||||
{
|
||||
flag: "--max-batches <n>",
|
||||
summary: "每轮最多处理的批次数(1–1000),默认由 SDK 决定。",
|
||||
},
|
||||
{ flag: "--wait", summary: "等待这次进化结束再返回。" },
|
||||
PLAN_FILE_OPTION,
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "graph-width",
|
||||
summary:
|
||||
"嵌入维度变化、图谱宽度不匹配时的处置决定。keep_existing 与 export_first 直接执行;rebuild_with_new 会归档旧图谱并重建,需 --yes。",
|
||||
name: "graph",
|
||||
summary: "图谱宽度不匹配时的用户决定;默认只出计划,--confirm 后才执行。",
|
||||
usage:
|
||||
"memhub graph-width --decision <keep_existing|export_first|rebuild_with_new> [--yes] [--json]",
|
||||
"memhub graph width --decision <keep_existing|rebuild_with_new|export_first> [--confirm] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--decision <decision>",
|
||||
summary:
|
||||
"keep_existing(保持现有图谱)、export_first(先导出)、rebuild_with_new(导出后归档旧图谱并按新维度重建)。",
|
||||
},
|
||||
{
|
||||
...DESTRUCTIVE_CONFIRM_OPTION,
|
||||
summary: "rebuild_with_new 必须加;其它决定不需要。",
|
||||
"keep_existing(保持)、export_first(先导出)、rebuild_with_new(导出后重建)。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "host",
|
||||
@@ -554,14 +480,14 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "recall",
|
||||
summary:
|
||||
"从原始对话与已沉淀记忆统一检索上下文;本地只读,不调用模型、不触发补索引。文本输出标明每条是「原始对话摘录」还是「记忆」(JSON 里是 kind: raw|memory)。",
|
||||
"从当前授权的原文与治理记忆统一检索上下文;本地只读,不调用模型、不触发补索引。",
|
||||
usage:
|
||||
"memhub recall <query> [--kind <raw|memory>] [--source <id>] [--limit <n>] [--character-budget <n>] [--timeout-ms <n>] [--user-id <id>] [--json]\n memhub recall --query-file <path> [...]\n memhub recall --stdin [...]",
|
||||
"memhub recall <query> [--kind <raw|memory>] [--source <id>] [--limit <n>] [--character-budget <n>] [--timeout-ms <n>] [--json]\n memhub recall --query-file <path> [...]\n memhub recall --stdin [...]",
|
||||
options: [
|
||||
{
|
||||
flag: "--query-file <path>",
|
||||
@@ -588,24 +514,23 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
flag: "--timeout-ms <n>",
|
||||
summary: "Core 查询时限 50..30000 毫秒,默认 2000。",
|
||||
},
|
||||
USER_ID_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "service-plan",
|
||||
summary: "生成用户级服务计划;只输出计划,不写文件也不执行命令。",
|
||||
usage: `memhub service-plan ${SERVICE_USAGE}`,
|
||||
options: SERVICE_OPTIONS,
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "service-install",
|
||||
summary: "预览用户级服务安装;只有 --yes 才写入服务文件并注册。",
|
||||
usage: `memhub service-install ${SERVICE_USAGE} [--yes]`,
|
||||
options: [...SERVICE_OPTIONS, CONFIRM_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "service-uninstall",
|
||||
@@ -613,15 +538,15 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
"预览用户级服务卸载;只有 --yes 才注销并删除逐字匹配的受管服务文件,不删数据。",
|
||||
usage: `memhub service-uninstall ${SERVICE_USAGE} [--yes]`,
|
||||
options: [...SERVICE_OPTIONS, CONFIRM_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "install",
|
||||
summary:
|
||||
"预览发行目录安装、memhub/memhub-cli/one-memory 短链接、PATH 条目与用户服务计划;只有 --yes 才执行。记忆数据迁移开始前失败会回滚;迁移开始后失败保留新版本、拒绝启动,并输出迁移日志与反馈地址。",
|
||||
"预览发行目录安装、memhub/memhub-cli/one-memory 短链接、PATH 条目与用户服务计划;只有 --yes 才执行并在失败时回滚。",
|
||||
usage: `memhub install ${INSTALL_USAGE}`,
|
||||
options: INSTALL_OPTIONS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "uninstall",
|
||||
@@ -634,31 +559,24 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "upgrade",
|
||||
summary:
|
||||
"预览升级;只有 --yes 才切换受管版本并重启服务,不删数据。记忆数据迁移开始前失败会恢复旧版本;迁移开始后失败保留新版本、拒绝启动,并输出迁移日志与反馈地址(不会回滚到旧版本)。",
|
||||
"预览升级;只有 --yes 才切换受管版本并重启服务,失败时恢复旧版本,不删数据。",
|
||||
usage: `memhub upgrade ${INSTALL_USAGE}`,
|
||||
options: INSTALL_OPTIONS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "delete-data",
|
||||
summary:
|
||||
"删除 MemHub 数据目录(记忆、来源、配置),不可逆。这是唯一会删除数据的命令;uninstall 不会。需要 Runtime 已停止;不加参数时只预览要删除的目录;删除需要 --yes 并用 --confirm 再写一遍同一个目录。",
|
||||
"独立、二次确认地删除 One Memory Home(数据)。这是唯一会删除数据的命令;uninstall 不会。默认只出预览;需 Runtime 已停止,并显式 --confirm <path> 复述该目录。",
|
||||
usage:
|
||||
"memhub delete-data [--data-dir <path>] [--yes --confirm <path>] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--data-dir <path>",
|
||||
summary: "要删除的数据目录绝对路径;默认当前使用的数据目录。",
|
||||
},
|
||||
{
|
||||
...DESTRUCTIVE_CONFIRM_OPTION,
|
||||
summary:
|
||||
"删除时必须加,且要同时给出 --confirm <path>;不加时只预览,exit 0。",
|
||||
},
|
||||
DATA_DIR_OPTION,
|
||||
CONFIRM_OPTION,
|
||||
{
|
||||
flag: "--confirm <path>",
|
||||
summary:
|
||||
"删除时必须加:再写一遍要删除的数据目录,与预览中的目录完全一致才执行。",
|
||||
"第二次确认:逐字复述预览里的数据目录;缺少它时即使 --yes 也不删除。",
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
@@ -670,150 +588,113 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
"onboarding reset 回到首次运行的引导流程。只清除引导完成状态:不删记忆数据,也不需要卸载重装。",
|
||||
usage: "memhub onboarding reset [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE],
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
summary: "显示总帮助或单个命令的用法。",
|
||||
usage: "memhub help [command] [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE],
|
||||
exit_codes: [EXIT_OK],
|
||||
},
|
||||
{
|
||||
name: "version",
|
||||
summary: "显示 CLI 版本。",
|
||||
usage: "memhub version [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE],
|
||||
exit_codes: [EXIT_OK],
|
||||
},
|
||||
{
|
||||
name: "scan-roots",
|
||||
name: "path",
|
||||
summary:
|
||||
"查看或登记被扫描的目录(scan roots)。add/remove 直接执行。(旧名 path 仍可用。)",
|
||||
"查看或登记被扫描的目录(scan roots)。list 只读;add/remove 预览后加 --yes 才提交;均已接入 Runtime 的 scan-roots 端点。",
|
||||
usage:
|
||||
"memhub scan-roots list [--json]\n" +
|
||||
" memhub scan-roots add --root <path> [--source <id>] [--no-recursive] [--json]\n" +
|
||||
" memhub scan-roots remove --root <path> [--json]",
|
||||
options: [
|
||||
{ flag: "--root <path>", summary: "要登记或移除的目录。" },
|
||||
{
|
||||
flag: "--source <id>",
|
||||
summary: "把目录绑定到某个来源;不给时由 Runtime 自动识别。",
|
||||
},
|
||||
{ flag: "--no-recursive", summary: "只扫描该目录本身,不进入子目录。" },
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
"memhub path list [--json]\n" +
|
||||
" memhub path add --root <path> [--source <id>] [--no-recursive] [--yes]\n" +
|
||||
" memhub path remove --root <path> [--yes]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION, CONFIRM_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "adapter",
|
||||
summary:
|
||||
"查看 Harness Adapter 的就绪状态与最近一次探测结果(list),或现场探测某个来源(probe --source <id>);两者均为只读。",
|
||||
"查看 Harness Adapter 的就绪状态与最近一次探测结果(list),或现场探测某个来源(probe --source <id>);两者均为只读,已接入 Runtime 的 adapters/探测端点。",
|
||||
usage:
|
||||
"memhub adapter list [--json]\n" +
|
||||
" memhub adapter probe --source <id> [--json]",
|
||||
options: [
|
||||
{ flag: "--source <id>", summary: "probe 的目标来源。" },
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
|
||||
},
|
||||
{
|
||||
name: "scan",
|
||||
summary:
|
||||
"立即增量扫描一个已连接并开启同步的来源,返回导入任务(与 jobs start --operation sync 相同)。接入全部来源请用 sources connect --all-detected --import。",
|
||||
usage: "memhub scan --source <id> [--json]",
|
||||
"立即扫描;默认只输出计划,加 --yes 才真正执行。指定 --source 扫描单个来源,--all 一次扫描/导入全部已检测来源(Runtime 按来源寻址,不接受裸路径)。",
|
||||
usage:
|
||||
"memhub scan [--source <id>] [--full] [--yes] [--json]\n memhub scan --all [--yes] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--source <id>",
|
||||
summary: "要扫描的来源。",
|
||||
summary: "按单个来源扫描;与 --all 互斥。",
|
||||
},
|
||||
{
|
||||
flag: "--all",
|
||||
summary:
|
||||
"处理全部 location_state=found 的来源,按来源逐条回执;部分成功返回非 0。",
|
||||
},
|
||||
{
|
||||
flag: "--full",
|
||||
summary: "忽略既有游标做全量重扫;默认只读取增量。",
|
||||
},
|
||||
DATA_DIR_OPTION,
|
||||
JSON_OPTION,
|
||||
CONFIRM_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "write",
|
||||
summary:
|
||||
"提交一段 OpenAI 格式的对话(messages)到写入队列,异步加工成记忆;或按 request id 查询状态。request id 是幂等键:不给时自动生成并输出,重试请带上同一个。新增一条结论式记忆请用 memories add。",
|
||||
summary: "提交 OpenAI messages 到 SDK 写入队列,或按 request id 查询状态。",
|
||||
usage:
|
||||
"memhub write submit --messages-file <path> --session <id> [--source <id>] [--request-id <id>] [--memory-at <RFC3339>] [--live] [--user-id <id>] [--json]\n memhub write status <request-id> [--user-id <id>] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--messages-file <path>",
|
||||
summary:
|
||||
"OpenAI messages 数组(或 {messages:[…]}),1..200 条,role 为 user/assistant/tool,至少一条 user。",
|
||||
},
|
||||
{ flag: "--session <id>", summary: "会话 ID。" },
|
||||
{ flag: "--source <id>", summary: "来源应用,默认 memhub-cli。" },
|
||||
{
|
||||
flag: "--request-id <id>",
|
||||
summary:
|
||||
"幂等键:同 id 同内容不会重复入队,同 id 不同内容被拒绝(409)。",
|
||||
},
|
||||
{ flag: "--memory-at <RFC3339>", summary: "对话发生时间,必须带时区。" },
|
||||
{ flag: "--live", summary: "优先处理。" },
|
||||
USER_ID_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
"memhub write submit --messages-file <path> --session <id> [--source <id>] [--request-id <id>] [--memory-at <RFC3339>] [--live] [--json]\n memhub write status --id <request-id> [--json]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
|
||||
},
|
||||
{
|
||||
name: "task",
|
||||
summary:
|
||||
"兼容别名:按 request id 查询写入状态;新脚本请使用 write status。",
|
||||
usage: "memhub task --id <request-id> [--json]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
|
||||
},
|
||||
{
|
||||
name: "backup",
|
||||
summary:
|
||||
"备份与恢复主库(来源、写入队列、配置)。注意:不包含记忆本身——SDK 记忆库 hy-memory/ 不在备份里,restore 也不会替换它。create 的目标要放在 <数据目录>/backups/ 下才能被 restore 使用;restore 覆盖现有主库,需 Runtime 已停止,并加 --yes 与 --confirm <同一个备份路径>。",
|
||||
"备份与恢复 MemHub 数据(两阶段)。create 调用 Runtime 的备份端点;restore 会覆盖现有数据,要求 Runtime 已停止且 `--confirm` 逐字重复 `--source`,随后由非 Runtime 维护态 Core 完成受管校验、保护性备份和原子替换。",
|
||||
usage:
|
||||
"memhub backup create --target <path> [--json]\n" +
|
||||
" memhub backup restore --source <path> [--yes --confirm <path>] [--json]",
|
||||
"memhub backup create --target <path> [--yes] [--json]\n" +
|
||||
" memhub backup restore --source <path> --yes --confirm <path> [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--target <path>",
|
||||
summary: "create 的备份文件路径,必须在数据目录内。",
|
||||
},
|
||||
{
|
||||
flag: "--source <path>",
|
||||
summary:
|
||||
"restore 的备份文件,必须是 <数据目录>/backups/ 下的受管备份。",
|
||||
},
|
||||
{
|
||||
...DESTRUCTIVE_CONFIRM_OPTION,
|
||||
summary:
|
||||
"restore 必须加,且要同时给出 --confirm <path>;create 不需要。",
|
||||
},
|
||||
DATA_DIR_OPTION,
|
||||
JSON_OPTION,
|
||||
CONFIRM_OPTION,
|
||||
{
|
||||
flag: "--confirm <path>",
|
||||
summary:
|
||||
"restore 必须加:再写一遍 --source 的备份路径,完全一致才执行。",
|
||||
summary: "restore 要求逐字重复 --source 路径;不一致时不做任何改动。",
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [...RUNTIME_EXITS, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "ui",
|
||||
summary:
|
||||
"用系统默认浏览器打开 Runtime 托管的 Web Console:先向 Core 换取一次性 loopback 登录码,再交给系统浏览器由 Core 置 HttpOnly/SameSite=Strict session;CLI 不向浏览器暴露裸 Core token。Runtime 未运行时 exit 3;Core 未提供该合同时如实返回 ui_session_contract_unavailable(exit 4)。",
|
||||
"用系统默认浏览器打开 Runtime 托管的 Web Console:先向 Core 换取一次性 loopback 登录码,再交给系统浏览器由 Core 置 HttpOnly/SameSite=Strict session;CLI 不向浏览器暴露裸 Core token。Core 未提供该合同时如实返回 ui_session_contract_unavailable(exit 4)。",
|
||||
usage: "memhub ui [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [...RUNTIME_EXITS, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
];
|
||||
|
||||
/** Old names that still run but are not listed: `path` → `scan-roots`,
|
||||
* `task` → `write status`, `graph width` → `graph-width`. */
|
||||
export const HIDDEN_COMMAND_ALIASES: Readonly<Record<string, string>> = {
|
||||
path: "scan-roots",
|
||||
task: "write",
|
||||
graph: "graph-width",
|
||||
};
|
||||
|
||||
export function findCommand(name: string): CommandDescriptor | undefined {
|
||||
return COMMANDS.find((command) => command.name === name);
|
||||
}
|
||||
|
||||
/** A listed command, or the command a hidden alias now runs as. */
|
||||
export function findCommandOrAlias(
|
||||
name: string,
|
||||
): CommandDescriptor | undefined {
|
||||
return findCommand(HIDDEN_COMMAND_ALIASES[name] ?? name);
|
||||
}
|
||||
@@ -69,6 +69,7 @@ const COMMANDS = [
|
||||
"doctor",
|
||||
"logs",
|
||||
"init",
|
||||
"setup",
|
||||
"sources",
|
||||
"sync",
|
||||
"progress",
|
||||
@@ -76,9 +77,10 @@ const COMMANDS = [
|
||||
"models",
|
||||
"memories",
|
||||
"profile",
|
||||
"settings",
|
||||
"evolve",
|
||||
"graph-width",
|
||||
// C6 App BLOCK (25f4ecf): UI write operations that had no CLI equivalent.
|
||||
"calls",
|
||||
"notes",
|
||||
"graph",
|
||||
"host",
|
||||
"recall",
|
||||
"service-plan",
|
||||
@@ -91,20 +93,19 @@ const COMMANDS = [
|
||||
"onboarding",
|
||||
"help",
|
||||
"version",
|
||||
"scan-roots",
|
||||
// A6 product commands. Appended, not inserted: the catalog is a stable
|
||||
// contract, so existing names must not shift position.
|
||||
"path",
|
||||
"adapter",
|
||||
"scan",
|
||||
"write",
|
||||
"task",
|
||||
"backup",
|
||||
// `ui` is declared but blocked on a Core session contract; it is listed so
|
||||
// `help` documents it and the blocked shape is machine-readable.
|
||||
"ui",
|
||||
] as const;
|
||||
|
||||
/** Old names that still run and still answer `--help`, but are not listed. */
|
||||
const HIDDEN_ALIASES = [
|
||||
["path", "scan-roots"],
|
||||
["task", "write"],
|
||||
] as const;
|
||||
|
||||
describe("CLI-22 shared output contract", () => {
|
||||
describe("help", () => {
|
||||
it.each([["help"], ["--help"], ["-h"]])(
|
||||
@@ -145,56 +146,6 @@ describe("CLI-22 shared output contract", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(HIDDEN_ALIASES)(
|
||||
"documents the hidden alias %s as %s and keeps it out of the listing",
|
||||
async (alias, target) => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", alias], {})).toBe(0);
|
||||
expect(await run([alias, "--help"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toContain(`${PRODUCT} ${target}`);
|
||||
const listing = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", "--json"], {})).toBe(0);
|
||||
} finally {
|
||||
listing.restore();
|
||||
}
|
||||
const names = JSON.parse(listing.stdout()).commands.map(
|
||||
(entry: { name: string }) => entry.name,
|
||||
);
|
||||
expect(names).not.toContain(alias);
|
||||
},
|
||||
);
|
||||
|
||||
it("lists no removed command", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", "--json"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
const catalog = streams.stdout();
|
||||
for (const removed of [
|
||||
'"name":"setup"',
|
||||
'"name":"notes"',
|
||||
'"name":"calls"',
|
||||
'"name":"graph"',
|
||||
"--plan-file",
|
||||
"probe-plan",
|
||||
"probe-receipt",
|
||||
"models configure",
|
||||
"sources import",
|
||||
"--full-rescan",
|
||||
"--dry-run",
|
||||
"--full]",
|
||||
]) {
|
||||
expect(catalog).not.toContain(removed);
|
||||
}
|
||||
});
|
||||
|
||||
it("never executes the documented command", async () => {
|
||||
for (const command of COMMANDS) {
|
||||
for (const argv of [
|
||||
@@ -250,63 +201,27 @@ describe("CLI-22 shared output contract", () => {
|
||||
expect(payload.commands).toBeUndefined();
|
||||
});
|
||||
|
||||
it("documents the one --yes convention for destructive commands", async () => {
|
||||
const general = captureStreams();
|
||||
try {
|
||||
expect(await run(["help"], {})).toBe(0);
|
||||
} finally {
|
||||
general.restore();
|
||||
}
|
||||
expect(general.stdout()).toContain("确认约定");
|
||||
for (const [command, usage] of [
|
||||
[
|
||||
"backup",
|
||||
"memhub backup restore --source <path> [--yes --confirm <path>]",
|
||||
],
|
||||
["memories", "memhub memories delete <memory-id>"],
|
||||
["models", "memhub models remove <profile-id> [--yes]"],
|
||||
[
|
||||
"delete-data",
|
||||
"memhub delete-data [--data-dir <path>] [--yes --confirm <path>]",
|
||||
],
|
||||
["graph-width", "[--yes]"],
|
||||
] as const) {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", command], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toContain(usage);
|
||||
// Only the two commands with no way back repeat the target path.
|
||||
if (command !== "backup" && command !== "delete-data") {
|
||||
expect(streams.stdout()).not.toContain("--confirm <path>");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("says backup does not include the memories themselves", async () => {
|
||||
it("documents the destructive restore confirmation explicitly", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", "backup"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toContain("不包含记忆本身");
|
||||
expect(streams.stdout()).toContain(
|
||||
"memhub backup restore --source <path> --yes --confirm <path>",
|
||||
);
|
||||
expect(streams.stdout()).toContain("逐字重复 --source");
|
||||
});
|
||||
|
||||
it("does not promise a rollback after the migration started", async () => {
|
||||
for (const command of ["install", "upgrade"]) {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", command], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toContain("迁移开始后失败保留新版本");
|
||||
expect(streams.stdout()).not.toContain("失败时恢复旧版本");
|
||||
expect(streams.stdout()).not.toContain("失败时回滚");
|
||||
it("does not attach restore's confirmation flag to path", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", "path"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).not.toContain("--confirm <path>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -630,25 +545,22 @@ describe("documented commands", () => {
|
||||
models: [
|
||||
"list",
|
||||
"show",
|
||||
"add",
|
||||
"test",
|
||||
"plan",
|
||||
"configure",
|
||||
"receipt",
|
||||
"edit",
|
||||
"activate",
|
||||
"switch",
|
||||
"spaces",
|
||||
"probe-plan",
|
||||
"probe",
|
||||
"probe-receipt",
|
||||
"remove",
|
||||
],
|
||||
// Source processing is one Freeze/Thaw switch under `sync`; the obsolete
|
||||
// independent read/write `sources access` command must not return here.
|
||||
sources: ["list", "connect", "receipt"],
|
||||
sources: ["list", "import", "receipt"],
|
||||
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>"],
|
||||
memories: ["list", "show", "add", "add-status", "update", "delete"],
|
||||
"scan-roots": ["list", "add", "remove"],
|
||||
write: ["submit", "status"],
|
||||
memories: ["list", "show", "update", "delete"],
|
||||
host: ["list", "status", "install", "uninstall", "sync"],
|
||||
};
|
||||
|
||||
|
||||
@@ -43,35 +43,61 @@ const ATTACHED = {
|
||||
};
|
||||
|
||||
const RESPONSE = {
|
||||
schema_version: 2,
|
||||
schema_version: 1,
|
||||
request_id: "context-request-1",
|
||||
state: "grounded",
|
||||
paths: {
|
||||
raw: {
|
||||
status: "ok",
|
||||
reason: null,
|
||||
elapsed_ms: 2,
|
||||
returned_count: 1,
|
||||
truncated: false,
|
||||
truncated_by: [],
|
||||
},
|
||||
memory: {
|
||||
status: "ok",
|
||||
reason: null,
|
||||
elapsed_ms: 3,
|
||||
returned_count: 1,
|
||||
truncated: false,
|
||||
truncated_by: [],
|
||||
},
|
||||
},
|
||||
items: [
|
||||
{
|
||||
kind: "raw",
|
||||
raw_id: "raw-1",
|
||||
content: "Synthetic raw citation.",
|
||||
source: "codex",
|
||||
session_id: "session-1",
|
||||
memory_at: "2026-09-19T00:00:00Z",
|
||||
score: 0.8,
|
||||
source_id: "codex",
|
||||
conversation_id: "conversation-1",
|
||||
anchor_evidence_id: "evidence-1",
|
||||
messages: [
|
||||
{
|
||||
evidence_id: "evidence-1",
|
||||
revision: 1,
|
||||
role: "user",
|
||||
written_by: "user",
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
text: "Synthetic raw citation.",
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
kind: "memory",
|
||||
memory_id: "memory-1",
|
||||
content: "Synthetic SDK memory.",
|
||||
layer: "l2_fact",
|
||||
status: "active",
|
||||
source: "codex",
|
||||
revision: 2,
|
||||
content: "Synthetic governed memory.",
|
||||
memory_kind: "working",
|
||||
updated_at: "2026-09-19T00:00:01Z",
|
||||
score: 0.7,
|
||||
evidence_ids: ["evidence-1"],
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
used_characters: 44,
|
||||
used_characters: 49,
|
||||
truncated: false,
|
||||
total_ms: 5,
|
||||
adoption: "unknown",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -89,17 +115,42 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("unified context search", () => {
|
||||
it("accepts an empty raw item in a valid partial context response", async () => {
|
||||
it("accepts an empty tool message in a valid partial context response", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
...RESPONSE,
|
||||
state: "partial",
|
||||
paths: {
|
||||
raw: {
|
||||
...RESPONSE.paths.raw,
|
||||
status: "degraded",
|
||||
reason: "lexical_only",
|
||||
returned_count: 1,
|
||||
},
|
||||
memory: {
|
||||
...RESPONSE.paths.memory,
|
||||
status: "empty",
|
||||
returned_count: 0,
|
||||
},
|
||||
},
|
||||
items: [
|
||||
{
|
||||
...RESPONSE.items[0],
|
||||
source: "workbuddy",
|
||||
content: "",
|
||||
kind: "raw",
|
||||
source_id: "workbuddy",
|
||||
conversation_id: "conversation-1",
|
||||
anchor_evidence_id: "evidence-1",
|
||||
messages: [
|
||||
{
|
||||
evidence_id: "evidence-1",
|
||||
revision: 1,
|
||||
role: "tool",
|
||||
written_by: "tool",
|
||||
created_at: "2026-10-05T20:48:12Z",
|
||||
text: "",
|
||||
truncated: true,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
},
|
||||
],
|
||||
@@ -114,9 +165,8 @@ describe("unified context search", () => {
|
||||
streams.restore();
|
||||
}
|
||||
expect(JSON.parse(streams.stdout())).toMatchObject({
|
||||
schema_version: 2,
|
||||
state: "partial",
|
||||
items: [{ kind: "raw", content: "" }],
|
||||
items: [{ kind: "raw", messages: [{ text: "" }] }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,26 +178,12 @@ describe("unified context search", () => {
|
||||
streams.restore();
|
||||
}
|
||||
const output = streams.stdout();
|
||||
expect(output).toContain("Synthetic SDK memory.");
|
||||
expect(output).toContain("Synthetic governed memory.");
|
||||
expect(output).toContain("Synthetic raw citation.");
|
||||
expect(output).not.toMatch(/^- raw |memory-1|raw-1|采用状态/m);
|
||||
expect(output).not.toMatch(/^- raw |memory-1|evidence-1|采用状态/m);
|
||||
});
|
||||
|
||||
it("labels raw hits as original-conversation excerpts, not memories", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["recall", "synthetic question"])).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
const output = streams.stdout();
|
||||
expect(output).toContain(
|
||||
"- [原始对话摘录 · codex] Synthetic raw citation.",
|
||||
);
|
||||
expect(output).toContain("- [记忆] Synthetic SDK memory.");
|
||||
});
|
||||
|
||||
it("queries the SDK context endpoint directly and preserves item ids", async () => {
|
||||
it("queries the current read-only context endpoint and preserves citations", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(
|
||||
@@ -173,7 +209,7 @@ describe("unified context search", () => {
|
||||
streams.restore();
|
||||
}
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v2/context/search",
|
||||
"/api/v1/context/search",
|
||||
{
|
||||
query: "synthetic question",
|
||||
kinds: ["raw", "memory"],
|
||||
@@ -186,18 +222,12 @@ describe("unified context search", () => {
|
||||
);
|
||||
const output = JSON.parse(streams.stdout());
|
||||
expect(output).toMatchObject({
|
||||
schema_version: 2,
|
||||
state: "grounded",
|
||||
request_id: "context-request-1",
|
||||
adoption: "unknown",
|
||||
});
|
||||
expect(output).not.toHaveProperty("paths");
|
||||
expect(output.items[0]).toMatchObject({ kind: "raw", raw_id: "raw-1" });
|
||||
expect(output.items[1]).toMatchObject({
|
||||
kind: "memory",
|
||||
memory_id: "memory-1",
|
||||
layer: "l2_fact",
|
||||
});
|
||||
// No authority pre-query: released Core always serves the SDK data plane.
|
||||
expect(output.items[0].messages[0].evidence_id).toBe("evidence-1");
|
||||
expect(output.items[1].evidence_ids).toEqual(["evidence-1"]);
|
||||
expect(transport.get).not.toHaveBeenCalled();
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
expect(transport.patch).not.toHaveBeenCalled();
|
||||
@@ -212,8 +242,19 @@ describe("unified context search", () => {
|
||||
status: 200,
|
||||
data: {
|
||||
...RESPONSE,
|
||||
paths: {
|
||||
...RESPONSE.paths,
|
||||
raw: {
|
||||
status: "not_requested",
|
||||
reason: "not_requested",
|
||||
elapsed_ms: 0,
|
||||
returned_count: 0,
|
||||
truncated: false,
|
||||
truncated_by: [],
|
||||
},
|
||||
},
|
||||
items: [RESPONSE.items[1]],
|
||||
used_characters: 21,
|
||||
used_characters: 26,
|
||||
},
|
||||
});
|
||||
const streams = captureStreams();
|
||||
@@ -353,7 +394,29 @@ describe("unified context search", () => {
|
||||
status: 200,
|
||||
data: {
|
||||
...RESPONSE,
|
||||
items: [{ ...RESPONSE.items[0], content: text }],
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -366,7 +429,7 @@ describe("unified context search", () => {
|
||||
expect(JSON.parse(streams.stdout())).toMatchObject({
|
||||
state: "grounded",
|
||||
used_characters: 10,
|
||||
items: [{ kind: "raw", content: text }],
|
||||
items: [{ kind: "raw", messages: [{ text }] }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -378,7 +441,29 @@ describe("unified context search", () => {
|
||||
status: 200,
|
||||
data: {
|
||||
...RESPONSE,
|
||||
items: [{ ...RESPONSE.items[0], content: text }],
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -394,40 +479,6 @@ describe("unified context search", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a raw item from a source that was not asked for",
|
||||
["--source", "codex"],
|
||||
{ ...RESPONSE.items[0], source: "cursor" },
|
||||
],
|
||||
[
|
||||
"a raw item when only memory was asked for",
|
||||
["--kind", "memory"],
|
||||
RESPONSE.items[0],
|
||||
],
|
||||
["a score outside 0..1", [], { ...RESPONSE.items[1], score: 1.5 }],
|
||||
])("rejects %s as an invalid response", async (_label, flags, item) => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
...RESPONSE,
|
||||
items: [item],
|
||||
used_characters: Array.from(String(item.content)).length,
|
||||
},
|
||||
});
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["recall", "query", ...flags, "--json"])).toBe(1);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(JSON.parse(streams.stdout())).toMatchObject({
|
||||
schema_version: 2,
|
||||
state: "invalid",
|
||||
code: "response_invalid",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports endpoint denial without exposing the response body", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 403,
|
||||
|
||||
@@ -3,6 +3,31 @@ import { PRODUCT } from "./version.js";
|
||||
|
||||
export const CONTEXT_QUERY_MAX_CHARACTERS = 2_000;
|
||||
const CONTEXT_KINDS = ["raw", "memory"] as const;
|
||||
const PATH_STATES = [
|
||||
"ok",
|
||||
"empty",
|
||||
"not_requested",
|
||||
"not_authorized",
|
||||
"unavailable",
|
||||
"degraded",
|
||||
] as const;
|
||||
const PATH_REASONS = [
|
||||
"not_requested",
|
||||
"permission_required",
|
||||
"source_revoked",
|
||||
"read_unavailable",
|
||||
"timeout_budget",
|
||||
"evidence_changed",
|
||||
"memory_changed",
|
||||
"lexical_only",
|
||||
] as const;
|
||||
const LIMIT_REASONS = [
|
||||
"count_budget",
|
||||
"character_budget",
|
||||
"timeout_budget",
|
||||
"metadata_budget",
|
||||
] as const;
|
||||
|
||||
export interface ContextSearchInput {
|
||||
query: string;
|
||||
kinds: Array<(typeof CONTEXT_KINDS)[number]>;
|
||||
@@ -10,13 +35,10 @@ export interface ContextSearchInput {
|
||||
limit: number;
|
||||
character_budget: number;
|
||||
timeout_ms: number;
|
||||
/** Whose memories to search. Omitted for the default user (`local`). */
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
/** The CLI's JSON document mirrors Core's SDK-native context schema v2. */
|
||||
export interface ContextSearchDocument {
|
||||
schema_version: 2;
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state:
|
||||
| "grounded"
|
||||
@@ -25,14 +47,15 @@ export interface ContextSearchDocument {
|
||||
| "unavailable"
|
||||
| "denied"
|
||||
| "not_found"
|
||||
| "rejected"
|
||||
| "invalid";
|
||||
code?: string;
|
||||
request_id?: string;
|
||||
paths?: Record<string, unknown>;
|
||||
items: Array<Record<string, unknown>>;
|
||||
used_characters?: number;
|
||||
truncated?: boolean;
|
||||
total_ms?: number;
|
||||
adoption?: "unknown";
|
||||
}
|
||||
|
||||
export async function searchContext(
|
||||
@@ -44,7 +67,7 @@ export async function searchContext(
|
||||
const validated = validateContextResponse(outcome.data, input);
|
||||
return (
|
||||
validated ?? {
|
||||
schema_version: 2,
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "invalid",
|
||||
code: "response_invalid",
|
||||
@@ -97,17 +120,9 @@ export function contextExitCode(document: ContextSearchDocument): number {
|
||||
}
|
||||
|
||||
export function renderContextText(document: ContextSearchDocument): string {
|
||||
if (document.state === "unavailable") {
|
||||
// No lexical fallback: without the embedding service nothing is searched.
|
||||
return document.code === "embedding_unavailable"
|
||||
? "向量服务不可用,无法检索;请检查向量模型配置。\n"
|
||||
: "暂时无法查记忆,请稍后重试。\n";
|
||||
}
|
||||
if (document.state === "unavailable") return "暂时无法查记忆,请稍后重试。\n";
|
||||
if (document.state === "denied") return "当前应用没有查记忆权限。\n";
|
||||
if (document.state === "invalid") return "查记忆返回了无法识别的结果。\n";
|
||||
if (document.state === "rejected") {
|
||||
return `查记忆被拒绝(${document.code}),请检查参数。\n`;
|
||||
}
|
||||
const lines = [
|
||||
document.state === "empty"
|
||||
? "没有查到相关记忆。"
|
||||
@@ -115,14 +130,14 @@ export function renderContextText(document: ContextSearchDocument): string {
|
||||
? "查到以下内容,部分内容还在整理中:"
|
||||
: "查到以下相关内容:",
|
||||
];
|
||||
// A raw hit is an excerpt of an original conversation, not a distilled
|
||||
// memory; say which one each line is so it is not read as a conclusion.
|
||||
for (const item of document.items) {
|
||||
const label =
|
||||
item.kind === "raw"
|
||||
? `[原始对话摘录 · ${String(item.source)}]`
|
||||
: "[记忆]";
|
||||
lines.push(`- ${label} ${String(item.content)}`);
|
||||
if (item.kind === "memory") {
|
||||
lines.push(`- ${String(item.content)}`);
|
||||
} else {
|
||||
for (const message of item.messages as Array<Record<string, unknown>>) {
|
||||
lines.push(`- ${String(message.text)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (document.truncated) lines.push("结果已按请求预算截断。");
|
||||
return `${lines.join("\n")}\n`;
|
||||
@@ -134,37 +149,84 @@ function validateContextResponse(
|
||||
): ContextSearchDocument | null {
|
||||
const response = record(value);
|
||||
if (
|
||||
response?.schema_version !== 2 ||
|
||||
response?.schema_version !== 1 ||
|
||||
!oneOf(response.state, ["grounded", "empty", "partial", "unavailable"]) ||
|
||||
!boundedText(response.request_id, 64) ||
|
||||
!record(response.paths) ||
|
||||
!Array.isArray(response.items) ||
|
||||
response.items.length > request.limit ||
|
||||
!integer(response.used_characters, 0, request.character_budget) ||
|
||||
typeof response.truncated !== "boolean" ||
|
||||
!number(response.total_ms, 0)
|
||||
!number(response.total_ms, 0) ||
|
||||
response.adoption !== "unknown"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const paths = response.paths as Record<string, unknown>;
|
||||
const rawPath = validatePath(paths.raw);
|
||||
const memoryPath = validatePath(paths.memory);
|
||||
if (!rawPath || !memoryPath) return null;
|
||||
const requested = new Set(request.kinds);
|
||||
if (
|
||||
(!requested.has("raw") && rawPath.status !== "not_requested") ||
|
||||
(!requested.has("memory") && memoryPath.status !== "not_requested")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const items: Array<Record<string, unknown>> = [];
|
||||
let characters = 0;
|
||||
for (const itemValue of response.items) {
|
||||
const item = validateItem(itemValue, request);
|
||||
if (!item || !requested.has(item.kind as "raw" | "memory")) return null;
|
||||
items.push(item);
|
||||
characters += characterCount(item.content);
|
||||
characters +=
|
||||
item.kind === "memory"
|
||||
? characterCount(item.content)
|
||||
: (item.messages as Array<Record<string, unknown>>).reduce(
|
||||
(sum, message) => sum + characterCount(message.text),
|
||||
0,
|
||||
);
|
||||
}
|
||||
if (characters !== response.used_characters) return null;
|
||||
if (response.state === "empty" && items.length !== 0) return null;
|
||||
return {
|
||||
schema_version: 2,
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: response.state,
|
||||
request_id: response.request_id as string,
|
||||
paths: { raw: rawPath, memory: memoryPath },
|
||||
items,
|
||||
used_characters: response.used_characters as number,
|
||||
truncated: response.truncated,
|
||||
total_ms: response.total_ms as number,
|
||||
adoption: "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
function validatePath(value: unknown): Record<string, unknown> | null {
|
||||
const path = record(value);
|
||||
if (
|
||||
!path ||
|
||||
!oneOf(path.status, PATH_STATES) ||
|
||||
!number(path.elapsed_ms, 0) ||
|
||||
!integer(path.returned_count, 0, 20) ||
|
||||
typeof path.truncated !== "boolean" ||
|
||||
!enumList(path.truncated_by, LIMIT_REASONS) ||
|
||||
!(
|
||||
path.reason === null ||
|
||||
path.reason === undefined ||
|
||||
oneOf(path.reason, PATH_REASONS)
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
status: path.status,
|
||||
reason: path.reason ?? null,
|
||||
elapsed_ms: path.elapsed_ms,
|
||||
returned_count: path.returned_count,
|
||||
truncated: path.truncated,
|
||||
truncated_by: path.truncated_by,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,57 +235,80 @@ function validateItem(
|
||||
request: ContextSearchInput,
|
||||
): Record<string, unknown> | null {
|
||||
const item = record(value);
|
||||
if (
|
||||
!item ||
|
||||
!oneOf(item.kind, CONTEXT_KINDS) ||
|
||||
boundedText(item.content, 20_000, true) === null ||
|
||||
!number(item.score, 0) ||
|
||||
(item.score as number) > 1 ||
|
||||
typeof item.truncated !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!item || !oneOf(item.kind, CONTEXT_KINDS)) return null;
|
||||
if (item.kind === "memory") {
|
||||
if (
|
||||
!boundedText(item.memory_id, 512) ||
|
||||
boundedText(item.layer, 64, true) === null ||
|
||||
boundedText(item.status, 32, true) === null ||
|
||||
!optionalText(item.source, 128) ||
|
||||
!optionalText(item.updated_at, 64)
|
||||
!integer(item.revision, 1) ||
|
||||
boundedText(item.content, 20_000, true) === null ||
|
||||
boundedText(item.memory_kind, 64, true) === null ||
|
||||
!boundedText(item.updated_at, 64) ||
|
||||
!textList(item.evidence_ids, 128, 512) ||
|
||||
typeof item.truncated !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "memory",
|
||||
memory_id: item.memory_id,
|
||||
revision: item.revision,
|
||||
content: item.content,
|
||||
layer: item.layer,
|
||||
status: item.status,
|
||||
source: item.source ?? null,
|
||||
updated_at: item.updated_at ?? null,
|
||||
score: item.score,
|
||||
memory_kind: item.memory_kind,
|
||||
updated_at: item.updated_at,
|
||||
evidence_ids: item.evidence_ids,
|
||||
truncated: item.truncated,
|
||||
};
|
||||
}
|
||||
if (
|
||||
!boundedText(item.raw_id, 512) ||
|
||||
!boundedText(item.source, 128) ||
|
||||
!boundedText(item.source_id, 128) ||
|
||||
(request.raw_source_ids !== undefined &&
|
||||
!request.raw_source_ids.includes(item.source as string)) ||
|
||||
!boundedText(item.session_id, 512) ||
|
||||
!optionalText(item.memory_at, 64)
|
||||
!request.raw_source_ids.includes(item.source_id as string)) ||
|
||||
!boundedText(item.conversation_id, 512) ||
|
||||
!boundedText(item.anchor_evidence_id, 512) ||
|
||||
!Array.isArray(item.messages) ||
|
||||
item.messages.length < 1 ||
|
||||
item.messages.length > 3 ||
|
||||
typeof item.truncated !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const messages = item.messages.map(validateMessage);
|
||||
if (messages.some((message) => message === null)) return null;
|
||||
return {
|
||||
kind: "raw",
|
||||
source_id: item.source_id,
|
||||
conversation_id: item.conversation_id,
|
||||
anchor_evidence_id: item.anchor_evidence_id,
|
||||
messages,
|
||||
truncated: item.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
function validateMessage(value: unknown): Record<string, unknown> | null {
|
||||
const message = record(value);
|
||||
if (
|
||||
!message ||
|
||||
!boundedText(message.evidence_id, 512) ||
|
||||
!integer(message.revision, 1) ||
|
||||
boundedText(message.role, 32, true) === null ||
|
||||
boundedText(message.written_by, 32, true) === null ||
|
||||
!(
|
||||
message.created_at === null ||
|
||||
boundedText(message.created_at, 64) !== null
|
||||
) ||
|
||||
boundedText(message.text, 20_000, true) === null ||
|
||||
typeof message.truncated !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "raw",
|
||||
raw_id: item.raw_id,
|
||||
content: item.content,
|
||||
source: item.source,
|
||||
session_id: item.session_id,
|
||||
memory_at: item.memory_at ?? null,
|
||||
score: item.score,
|
||||
truncated: item.truncated,
|
||||
evidence_id: message.evidence_id,
|
||||
revision: message.revision,
|
||||
role: message.role,
|
||||
written_by: message.written_by,
|
||||
created_at: message.created_at,
|
||||
text: message.text,
|
||||
truncated: message.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,7 +316,7 @@ function failed(
|
||||
outcome: Exclude<ReadOutcome<unknown>, { state: "ok" }>,
|
||||
): ContextSearchDocument {
|
||||
return {
|
||||
schema_version: 2,
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: outcome.state,
|
||||
code: outcome.code,
|
||||
@@ -289,10 +374,27 @@ function oneOf<const T extends readonly string[]>(
|
||||
return typeof value === "string" && values.includes(value as T[number]);
|
||||
}
|
||||
|
||||
function optionalText(value: unknown, maximum: number): boolean {
|
||||
function enumList(
|
||||
value: unknown,
|
||||
values: readonly string[],
|
||||
): value is string[] {
|
||||
return (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
boundedText(value, maximum) !== null
|
||||
Array.isArray(value) &&
|
||||
value.length <= 4 &&
|
||||
new Set(value).size === value.length &&
|
||||
value.every((item) => oneOf(item, values))
|
||||
);
|
||||
}
|
||||
|
||||
function textList(
|
||||
value: unknown,
|
||||
maximumItems: number,
|
||||
maximumText: number,
|
||||
): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length <= maximumItems &&
|
||||
new Set(value).size === value.length &&
|
||||
value.every((item) => boundedText(item, maximumText) !== null)
|
||||
);
|
||||
}
|
||||
@@ -8,30 +8,18 @@ import axios from "axios";
|
||||
*/
|
||||
const READ_ONLY_PATHS = [
|
||||
"/api/v1/call-events",
|
||||
// Read-only: the registry plus a scan of the vector store's collection names.
|
||||
"/api/v1/embedding-spaces",
|
||||
"/api/v1/embedding-switches/current",
|
||||
"/api/v1/home-summary",
|
||||
"/api/v1/import-jobs",
|
||||
"/api/v1/live-sync",
|
||||
"/api/v1/memory-library",
|
||||
"/api/v1/memory-processing",
|
||||
"/api/v1/memory-tasks",
|
||||
"/api/v1/model-profiles",
|
||||
"/api/v1/source-grants",
|
||||
"/api/v1/sources",
|
||||
"/api/v2/home-summary",
|
||||
"/api/v2/memory-library",
|
||||
"/api/v2/user-profile",
|
||||
"/api/v2/user-profile/versions",
|
||||
"/api/v1/settings/memory-mode",
|
||||
"/api/v1/settings/memory-features",
|
||||
"/api/v2/evolve",
|
||||
] as const;
|
||||
/** One evolve run's receipt (`memhub evolve --wait`). */
|
||||
const EVOLVE_RUN =
|
||||
/^\/api\/v2\/evolve\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const SDK_MEMORY_DETAIL = /^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}$/;
|
||||
/** Status of one `POST /api/v2/memories` addition, by its request id. */
|
||||
const MEMORY_ADDITION =
|
||||
/^\/api\/v2\/memories\/additions\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
/** Contract (C0, H25): the one-time loopback code exchange for `memhub ui`.
|
||||
* It is a POST because it mints a short-lived code; the CLI sends the bearer
|
||||
@@ -79,10 +67,13 @@ export function createUiSessionRequest(attachment: {
|
||||
};
|
||||
}
|
||||
|
||||
const MEMORY_DETAIL = /^\/api\/v1\/memories\/[A-Za-z0-9][\w.-]{0,127}$/;
|
||||
const IMPORT_JOB =
|
||||
/^\/api\/v1\/import-jobs\/[A-Za-z0-9][\w.-]{0,127}(?:\/issues)?$/;
|
||||
const OPERATION_RECEIPT =
|
||||
/^\/api\/v1\/(?:source-connections|model-profile-creations|model-probe-operations)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const MEMORY_MUTATION_RECEIPT =
|
||||
/^\/api\/v1\/memory-mutations\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
/**
|
||||
* Core registers these static routes before `/{memory_id}`, so a memory whose
|
||||
* id happens to be one of them would reach a different handler: `graph`
|
||||
@@ -93,15 +84,9 @@ export const RESERVED_MEMORY_SEGMENTS = ["ask", "graph"] as const;
|
||||
/** A response larger than this is refused before it is buffered. */
|
||||
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/** `rejected` is Core refusing the request itself (400/409/422: a bad filter,
|
||||
* an ambiguous id). It is not "Core is busy" — only a transport failure or a
|
||||
* 5xx is `unavailable`. */
|
||||
export type ReadOutcome<T> =
|
||||
| { state: "ok"; data: T }
|
||||
| {
|
||||
state: "denied" | "unavailable" | "not_found" | "rejected";
|
||||
code: string;
|
||||
};
|
||||
| { state: "denied" | "unavailable" | "not_found"; code: string };
|
||||
|
||||
export type CoreReader = <T>(
|
||||
path: string,
|
||||
@@ -160,40 +145,10 @@ export function createCoreReader(attachment: {
|
||||
if (response.status === 404) {
|
||||
return { state: "not_found" as const, code: "http_404" };
|
||||
}
|
||||
if ([400, 409, 422].includes(response.status)) {
|
||||
return {
|
||||
state: "rejected" as const,
|
||||
code: detailCode(response.data, response.status),
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: "unavailable" as const,
|
||||
code: unavailableCode(response.data, response.status),
|
||||
};
|
||||
return { state: "unavailable" as const, code: `http_${response.status}` };
|
||||
};
|
||||
}
|
||||
|
||||
/** A 503 that names `embedding_unavailable` keeps it: the search could not
|
||||
* embed its query, which needs another fix than "try again later". */
|
||||
function unavailableCode(value: unknown, status: number): string {
|
||||
return status === 503 && detailCode(value, status) === EMBEDDING_UNAVAILABLE
|
||||
? EMBEDDING_UNAVAILABLE
|
||||
: `http_${status}`;
|
||||
}
|
||||
|
||||
export const EMBEDDING_UNAVAILABLE = "embedding_unavailable";
|
||||
|
||||
/** Core's stable `detail` code when it sent one, else the bare status. */
|
||||
function detailCode(value: unknown, status: number): string {
|
||||
const detail =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as { detail?: unknown }).detail
|
||||
: undefined;
|
||||
return typeof detail === "string" && /^[a-z0-9_.:-]{1,128}$/.test(detail)
|
||||
? detail
|
||||
: `http_${status}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context search is semantically read-only but intentionally uses POST so the
|
||||
* private query and filters are carried in a bounded body, never in a URL.
|
||||
@@ -218,7 +173,7 @@ export function createCoreContextQuery(attachment: {
|
||||
let response: { status: number; data: unknown };
|
||||
try {
|
||||
response = await client.post(
|
||||
"/api/v2/context/search",
|
||||
"/api/v1/context/search",
|
||||
body,
|
||||
options?.signal ? { signal: options.signal } : undefined,
|
||||
);
|
||||
@@ -234,10 +189,7 @@ export function createCoreContextQuery(attachment: {
|
||||
if (response.status === 404) {
|
||||
return { state: "not_found" as const, code: "http_404" };
|
||||
}
|
||||
return {
|
||||
state: "unavailable" as const,
|
||||
code: unavailableCode(response.data, response.status),
|
||||
};
|
||||
return { state: "unavailable" as const, code: `http_${response.status}` };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -245,19 +197,17 @@ export function isReadOnlyPath(path: string): boolean {
|
||||
if ((READ_ONLY_PATHS as readonly string[]).includes(path)) return true;
|
||||
if (IMPORT_JOB.test(path)) return true;
|
||||
if (OPERATION_RECEIPT.test(path)) return true;
|
||||
if (MEMORY_ADDITION.test(path)) return true;
|
||||
if (EVOLVE_RUN.test(path)) return true;
|
||||
if (!SDK_MEMORY_DETAIL.test(path)) return false;
|
||||
const segment = path.slice("/api/v2/memories/".length);
|
||||
if (MEMORY_MUTATION_RECEIPT.test(path)) return true;
|
||||
if (SDK_MEMORY_DETAIL.test(path)) return true;
|
||||
if (!MEMORY_DETAIL.test(path)) return false;
|
||||
const segment = path.slice("/api/v1/memories/".length);
|
||||
return !(RESERVED_MEMORY_SEGMENTS as readonly string[]).includes(segment);
|
||||
}
|
||||
|
||||
/** A write accepted by the unified Memory Server entry.
|
||||
*
|
||||
* `request_id` is the idempotency key: Core keys the write on it, so a retry
|
||||
* with the same id and the same messages is answered from the first write, and
|
||||
* the same id with different messages is refused (409). A retry without the
|
||||
* original id is a new write.
|
||||
* `request_id` is what the caller keeps and queries later. Retrying is unsafe:
|
||||
* HY has no idempotency key, so a resubmit duplicates the memory.
|
||||
*/
|
||||
export interface MemoryWriteReceipt {
|
||||
request_id: string;
|
||||
@@ -324,11 +274,6 @@ function baseUrlOf(attachment: RuntimeAttachmentLike): string {
|
||||
return `http://${attachment.host}:${attachment.port}`;
|
||||
}
|
||||
|
||||
export type MemoryWriteOutcome =
|
||||
| { state: "accepted"; receipt: MemoryWriteReceipt }
|
||||
| { state: "rejected"; code: string; http_status: number }
|
||||
| { state: "unknown"; code: string };
|
||||
|
||||
export async function submitMemoryWrite(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
request: {
|
||||
@@ -338,54 +283,29 @@ export async function submitMemoryWrite(
|
||||
messages: readonly OpenAIMessage[];
|
||||
memory_at?: string;
|
||||
priority?: "live" | "normal";
|
||||
/** Whose memory this is. Omitted for the default user (`local`). */
|
||||
user_id?: string;
|
||||
},
|
||||
): Promise<MemoryWriteOutcome> {
|
||||
let response: { status: number; data: unknown };
|
||||
try {
|
||||
response = await axios.post<MemoryWriteReceipt>(
|
||||
`${baseUrlOf(attachment)}/api/v2/memory-writes`,
|
||||
{
|
||||
request_id: request.request_id,
|
||||
session_id: request.session,
|
||||
source_id: request.source,
|
||||
messages: [...request.messages],
|
||||
...(request.memory_at === undefined
|
||||
? {}
|
||||
: { memory_at: request.memory_at }),
|
||||
...(request.priority === undefined
|
||||
? {}
|
||||
: { priority: request.priority }),
|
||||
...(request.user_id === undefined || request.user_id === "local"
|
||||
? {}
|
||||
: { user_id: request.user_id }),
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 15_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
validateStatus: () => true,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// The request may or may not have reached Core; the same request id makes
|
||||
// a retry safe.
|
||||
return { state: "unknown", code: "transport_result_unknown" };
|
||||
}
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return { state: "accepted", receipt: response.data as MemoryWriteReceipt };
|
||||
}
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
return {
|
||||
state: "rejected",
|
||||
code: detailCode(response.data, response.status),
|
||||
http_status: response.status,
|
||||
};
|
||||
}
|
||||
return { state: "unknown", code: `http_${response.status}_result_unknown` };
|
||||
): Promise<MemoryWriteReceipt> {
|
||||
const response = await axios.post<MemoryWriteReceipt>(
|
||||
`${baseUrlOf(attachment)}/api/v2/memory-writes`,
|
||||
{
|
||||
request_id: request.request_id,
|
||||
session_id: request.session,
|
||||
source_id: request.source,
|
||||
messages: [...request.messages],
|
||||
...(request.memory_at === undefined
|
||||
? {}
|
||||
: { memory_at: request.memory_at }),
|
||||
...(request.priority === undefined ? {} : { priority: request.priority }),
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 15_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Ask Runtime to scan one source now, returning the import job it started.
|
||||
@@ -502,16 +422,11 @@ export async function deleteModelProfile(
|
||||
export async function readMemoryWrite(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
requestId: string,
|
||||
/** A receipt belongs to the user that submitted it; omit for `local`. */
|
||||
userId?: string,
|
||||
): Promise<MemoryWriteReceipt | null> {
|
||||
try {
|
||||
const response = await axios.get<MemoryWriteReceipt>(
|
||||
`${baseUrlOf(attachment)}/api/v2/memory-writes/${encodeURIComponent(requestId)}`,
|
||||
{
|
||||
...(userId === undefined || userId === "local"
|
||||
? {}
|
||||
: { params: { user_id: userId } }),
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
@@ -587,52 +502,48 @@ export async function readScanRoots(
|
||||
}
|
||||
}
|
||||
|
||||
/** A direct Core write whose refusal carries Core's stable code. */
|
||||
export type CoreWriteResult =
|
||||
| { ok: true; data: unknown }
|
||||
| { ok: false; code: string };
|
||||
|
||||
async function coreWrite(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
method: "post" | "delete",
|
||||
pathname: string,
|
||||
body: Record<string, unknown>,
|
||||
timeout = 10_000,
|
||||
): Promise<CoreWriteResult> {
|
||||
let response: { status: number; data: unknown };
|
||||
try {
|
||||
response = await axios.request({
|
||||
method,
|
||||
url: `${baseUrlOf(attachment)}${pathname}`,
|
||||
data: body,
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, code: "transport_failed" };
|
||||
}
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return { ok: true, data: response.data };
|
||||
}
|
||||
return { ok: false, code: detailCode(response.data, response.status) };
|
||||
}
|
||||
|
||||
export async function addScanRoot(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
body: { root: string; source?: string; recursive?: boolean },
|
||||
): Promise<CoreWriteResult> {
|
||||
return coreWrite(attachment, "post", "/api/v1/scan-roots", body);
|
||||
): 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) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeScanRoot(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
body: { root: string },
|
||||
): Promise<CoreWriteResult> {
|
||||
return coreWrite(attachment, "delete", "/api/v1/scan-roots", body);
|
||||
): 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,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return { _error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAdapters(
|
||||
@@ -658,12 +569,21 @@ export async function readAdapters(
|
||||
export async function probeAdapter(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
source: string,
|
||||
): Promise<CoreWriteResult> {
|
||||
return coreWrite(
|
||||
attachment,
|
||||
"post",
|
||||
"/api/v1/adapters/probe",
|
||||
{ source },
|
||||
15_000,
|
||||
);
|
||||
): Promise<unknown | null> {
|
||||
try {
|
||||
const response = await axios.post<unknown>(
|
||||
`${baseUrlOf(attachment)}/api/v1/adapters/probe`,
|
||||
{ source },
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 15_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return { _error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,9 @@ const WRITE_PATHS = [
|
||||
"/api/v1/sources/connect",
|
||||
"/api/v1/model-profiles",
|
||||
] as const;
|
||||
/** `POST /api/v2/memories` adds one memory (the app's 添加记忆). */
|
||||
const MEMORY_ADD_PATH = "/api/v2/memories";
|
||||
/** One SDK memory; delete names its namespace, and a non-default user, in
|
||||
* the query string. */
|
||||
const SDK_MEMORY_PATH =
|
||||
/^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}(?:\?(?:kind=(?:chat|experience|fact)(?:&variant=(?:working|coding))?(?:&user_id=[A-Za-z0-9._-]{1,64})?|user_id=[A-Za-z0-9._-]{1,64}))?$/;
|
||||
const MEMORY_WRITE_PATH =
|
||||
/^\/api\/v1\/memories(?:\/[A-Za-z0-9][\w.-]{0,127}\/(?:correct|scopes|actions\/delete))?$/;
|
||||
const SDK_MEMORY_PATH = /^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}$/;
|
||||
const MODEL_PROBE_PATH =
|
||||
/^\/api\/v1\/model-profiles\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/probe$/;
|
||||
const MODEL_PROFILE_PATH =
|
||||
@@ -25,33 +22,24 @@ const SOURCE_REVOKE_PATH =
|
||||
const SOURCE_IMPORT_JOB_PATH = "/api/v1/import-jobs";
|
||||
const IMPORT_JOB_ACTION_PATH =
|
||||
/^\/api\/v1\/import-jobs\/[A-Za-z0-9][\w.-]{0,127}\/(?:pause|resume|cancel)$/;
|
||||
const CALL_EVENTS_CLEAR_PATH = "/api/v1/call-events/clear";
|
||||
const CALL_EVENTS_CLEAR_ALL_PATH = "/api/v1/call-events/clear-all";
|
||||
const IMPORT_FILE_COMMIT =
|
||||
/^\/api\/v1\/import-files\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/commit$/;
|
||||
const IMPORT_FILE =
|
||||
/^\/api\/v1\/import-files\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
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 EXTRACTION_SETTINGS_PATH = "/api/v1/extraction-settings";
|
||||
const GRAPH_WIDTH_PATH = "/api/v1/graph-width";
|
||||
/** `memhub evolve` starts a run; `memhub settings mode <m> --yes` sets the mode. */
|
||||
const EVOLVE_PATH = "/api/v2/evolve";
|
||||
const MEMORY_MODE_PATH = "/api/v1/settings/memory-mode";
|
||||
/** `memhub settings features set|reset` sets the SDK features. */
|
||||
const MEMORY_FEATURES_PATH = "/api/v1/settings/memory-features";
|
||||
const MANUAL_EXTRACT_PATH = "/api/v1/memory-processing/manual-extract";
|
||||
const ONBOARDING_COMPLETE_PATH = "/api/v1/onboarding/complete";
|
||||
const EMBEDDING_SWITCH_PATHS = [
|
||||
"/api/v1/embedding-switches",
|
||||
"/api/v1/embedding-switches/plan",
|
||||
] as const;
|
||||
const EMBEDDING_SWITCH_CANCEL =
|
||||
/^\/api\/v1\/embedding-switches\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/cancel$/;
|
||||
const EMBEDDING_SPACE_PATH =
|
||||
/^\/api\/v1\/embedding-spaces\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const MAX_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export type OperationOutcome<T> =
|
||||
| { state: "committed"; data: T }
|
||||
| {
|
||||
state: "rejected";
|
||||
code: string;
|
||||
http_status: number;
|
||||
/** Core's refusal body, when it carries more than the code (a switch
|
||||
* plan rides along with `embedding_switch_required`). */
|
||||
body?: unknown;
|
||||
}
|
||||
| { state: "rejected"; code: string; http_status: number }
|
||||
| { state: "unknown"; code: string };
|
||||
|
||||
export interface CoreOperations {
|
||||
@@ -82,6 +70,18 @@ export interface CorePatchOperations {
|
||||
): Promise<OperationOutcome<T>>;
|
||||
}
|
||||
|
||||
export interface CoreUploadOperations {
|
||||
uploadImportFile<T>(
|
||||
input: {
|
||||
bytes: Uint8Array;
|
||||
filename: string;
|
||||
inputKind: "raw_conversation" | "memory_content";
|
||||
operationId: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<OperationOutcome<T>>;
|
||||
}
|
||||
|
||||
export function createCoreOperations(attachment: {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -90,7 +90,8 @@ export function createCoreOperations(attachment: {
|
||||
}): CoreOperations &
|
||||
CoreDeleteOperations &
|
||||
CorePutOperations &
|
||||
CorePatchOperations {
|
||||
CorePatchOperations &
|
||||
CoreUploadOperations {
|
||||
const client = axios.create({
|
||||
baseURL: `http://${attachment.host}:${attachment.port}`,
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
@@ -101,6 +102,18 @@ export function createCoreOperations(attachment: {
|
||||
maxBodyLength: 128 * 1024,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
// Uploads carry up to 128 MiB of raw octet-stream, so they need a separate
|
||||
// body limit and a deadline that leaves room for parsing on the Core side.
|
||||
const uploadClient = axios.create({
|
||||
baseURL: `http://${attachment.host}:${attachment.port}`,
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 120_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_BYTES,
|
||||
maxBodyLength: 128 * 1024 * 1024,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
return {
|
||||
async post<T>(
|
||||
path: string,
|
||||
@@ -110,17 +123,20 @@ export function createCoreOperations(attachment: {
|
||||
if (
|
||||
!(WRITE_PATHS as readonly string[]).includes(path) &&
|
||||
path !== SOURCE_IMPORT_JOB_PATH &&
|
||||
path !== MEMORY_ADD_PATH &&
|
||||
!MEMORY_WRITE_PATH.test(path) &&
|
||||
!MODEL_PROBE_PATH.test(path) &&
|
||||
!SOURCE_SYNC_PATH.test(path) &&
|
||||
!IMPORT_JOB_ACTION_PATH.test(path) &&
|
||||
!SOURCE_AUTHORIZE_PATH.test(path) &&
|
||||
!SOURCE_REVOKE_PATH.test(path) &&
|
||||
path !== CALL_EVENTS_CLEAR_PATH &&
|
||||
path !== CALL_EVENTS_CLEAR_ALL_PATH &&
|
||||
!IMPORT_FILE_COMMIT.test(path) &&
|
||||
path !== MEMORY_NOTES_PATH &&
|
||||
!MEMORY_NOTE_PROCESS.test(path) &&
|
||||
path !== GRAPH_WIDTH_PATH &&
|
||||
path !== EVOLVE_PATH &&
|
||||
path !== MANUAL_EXTRACT_PATH &&
|
||||
path !== ONBOARDING_COMPLETE_PATH &&
|
||||
!(EMBEDDING_SWITCH_PATHS as readonly string[]).includes(path) &&
|
||||
!EMBEDDING_SWITCH_CANCEL.test(path) &&
|
||||
!MODEL_PROFILE_ACTIVATE.test(path)
|
||||
) {
|
||||
throw new Error("core_operation_path_not_allowed");
|
||||
@@ -138,18 +154,11 @@ export function createCoreOperations(attachment: {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return { state: "committed", data: response.data as T };
|
||||
}
|
||||
if (
|
||||
(response.status >= 400 && response.status < 500) ||
|
||||
// Core answers a refusal it can name with 503 (`sdk_reembed_unavailable`)
|
||||
// or 507 (`insufficient_disk_space`) on the switch routes.
|
||||
([503, 507].includes(response.status) &&
|
||||
(EMBEDDING_SWITCH_PATHS as readonly string[]).includes(path))
|
||||
) {
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
return {
|
||||
state: "rejected",
|
||||
code: safeErrorCode(response.data, response.status),
|
||||
http_status: response.status,
|
||||
body: response.data,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -162,11 +171,7 @@ export function createCoreOperations(attachment: {
|
||||
body: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
if (
|
||||
path !== MEMORY_MODE_PATH &&
|
||||
path !== MEMORY_FEATURES_PATH &&
|
||||
(!SDK_MEMORY_PATH.test(path) || path.includes("?"))
|
||||
) {
|
||||
if (path !== EXTRACTION_SETTINGS_PATH && !SDK_MEMORY_PATH.test(path)) {
|
||||
throw new Error("core_operation_path_not_allowed");
|
||||
}
|
||||
let response: { status: number; data: unknown };
|
||||
@@ -228,11 +233,7 @@ export function createCoreOperations(attachment: {
|
||||
};
|
||||
},
|
||||
async delete<T>(path: string, signal?: AbortSignal) {
|
||||
if (
|
||||
!SDK_MEMORY_PATH.test(path) &&
|
||||
!MODEL_PROFILE_PATH.test(path) &&
|
||||
!EMBEDDING_SPACE_PATH.test(path)
|
||||
) {
|
||||
if (!IMPORT_FILE.test(path) && !SDK_MEMORY_PATH.test(path)) {
|
||||
throw new Error("core_operation_path_not_allowed");
|
||||
}
|
||||
let response: { status: number; data: unknown };
|
||||
@@ -256,6 +257,51 @@ export function createCoreOperations(attachment: {
|
||||
code: `http_${response.status}_result_unknown`,
|
||||
};
|
||||
},
|
||||
async uploadImportFile<T>(
|
||||
input: {
|
||||
bytes: Uint8Array;
|
||||
filename: string;
|
||||
inputKind: "raw_conversation" | "memory_content";
|
||||
operationId: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const query = new URLSearchParams({
|
||||
filename: input.filename,
|
||||
input_kind: input.inputKind,
|
||||
operation_id: input.operationId,
|
||||
});
|
||||
let response: { status: number; data: unknown };
|
||||
try {
|
||||
response = await uploadClient.post(
|
||||
`/api/v1/import-files?${query.toString()}`,
|
||||
input.bytes,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(input.bytes.byteLength),
|
||||
},
|
||||
...(signal ? { signal } : {}),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return { state: "unknown", code: "transport_result_unknown" };
|
||||
}
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return { state: "committed", data: response.data as T };
|
||||
}
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
return {
|
||||
state: "rejected",
|
||||
code: safeErrorCode(response.data, response.status),
|
||||
http_status: response.status,
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: "unknown",
|
||||
code: `http_${response.status}_result_unknown`,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import type {
|
||||
GovernedMemory,
|
||||
HomeSummary,
|
||||
ImportIssue,
|
||||
ImportJob,
|
||||
LiveSyncHealth,
|
||||
MemoryDetail,
|
||||
MemoryEvidenceExcerpt,
|
||||
MemoryLibraryPage,
|
||||
MemoryProcessingSummary,
|
||||
ModelProfile,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
/** SDK memory states `memhub memories list --status` may ask for. */
|
||||
export const MEMORY_STATUSES = [
|
||||
"active",
|
||||
"superseded",
|
||||
"shadow",
|
||||
"negated",
|
||||
"conflicted",
|
||||
"archived",
|
||||
] as const;
|
||||
/** The only memory states a response may carry, and the only ones a filter may ask for. */
|
||||
export const MEMORY_STATUSES = ["confirmed", "deleted", "superseded"] as const;
|
||||
|
||||
/**
|
||||
* HTTP 200 is not a contract. Everything here re-checks the shape and the
|
||||
@@ -25,6 +23,7 @@ export const MEMORY_STATUSES = [
|
||||
|
||||
const ID_MAX = 128;
|
||||
const LABEL_MAX = 256;
|
||||
const TEXT_MAX = 100_000;
|
||||
const LIST_MAX = 500;
|
||||
const LIVE_SYNC_STATES = [
|
||||
"idle",
|
||||
@@ -40,6 +39,14 @@ const PROVIDER_KINDS = [
|
||||
"local_onnx",
|
||||
] as const;
|
||||
const CREDENTIAL_STATES = ["available", "missing", "unavailable"] as const;
|
||||
const MEMORY_KINDS = [
|
||||
"chat",
|
||||
"working",
|
||||
"coding",
|
||||
"schema",
|
||||
"intention",
|
||||
] as const;
|
||||
const MEMORY_ORIGINS = ["extracted", "answered", "agent", "manual"] as const;
|
||||
const IMPORT_STATUSES = [
|
||||
"pending",
|
||||
"running",
|
||||
@@ -50,6 +57,16 @@ const IMPORT_STATUSES = [
|
||||
"cancelled",
|
||||
] as const;
|
||||
const SCAN_PHASES = ["discover", "read", "emit", "done"] as const;
|
||||
const MEMORY_TASK_KINDS = ["extract", "system2", "ultra", "sweeper"] as const;
|
||||
const MEMORY_TASK_STATUSES = [
|
||||
"pending",
|
||||
"running",
|
||||
"waiting_for_model",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"deferred_outside_window",
|
||||
] as const;
|
||||
const CAPABILITY_STATES = ["not_implemented", "implemented"] as const;
|
||||
const VERIFICATION_STATES = ["fixture_only", "real_replayed"] as const;
|
||||
const SYNC_COMPLETENESS = [
|
||||
@@ -70,6 +87,16 @@ const LOCATION_STATES = [
|
||||
] as const;
|
||||
const LIVE_SYNC_MODES = ["polling", "watch_and_polling"] as const;
|
||||
|
||||
export interface ValidatedMemoryTask {
|
||||
task_id: string;
|
||||
kind: (typeof MEMORY_TASK_KINDS)[number];
|
||||
status: (typeof MEMORY_TASK_STATUSES)[number];
|
||||
attempts: number;
|
||||
last_error_code: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SourceConnectionCandidate {
|
||||
source_id: string;
|
||||
display_name: string;
|
||||
@@ -325,6 +352,45 @@ export function validateImportIssues(
|
||||
});
|
||||
}
|
||||
|
||||
export function validateMemoryTasks(
|
||||
value: unknown,
|
||||
): ValidatedMemoryTask[] | null {
|
||||
return list(value, (item) => {
|
||||
const task = record(item);
|
||||
if (!task) return null;
|
||||
const taskId = text(task.task_id, ID_MAX);
|
||||
if (
|
||||
task.last_error_code !== null &&
|
||||
text(task.last_error_code, LABEL_MAX) === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const error = task.last_error_code as string | null;
|
||||
const createdAt = text(task.created_at, LABEL_MAX);
|
||||
const updatedAt = text(task.updated_at, LABEL_MAX);
|
||||
const attempts = count(task.attempts);
|
||||
if (
|
||||
taskId === null ||
|
||||
createdAt === null ||
|
||||
updatedAt === null ||
|
||||
attempts === null ||
|
||||
!oneOf(task.kind, MEMORY_TASK_KINDS) ||
|
||||
!oneOf(task.status, MEMORY_TASK_STATUSES)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
task_id: taskId,
|
||||
kind: task.kind,
|
||||
status: task.status,
|
||||
attempts,
|
||||
last_error_code: error,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function validateProcessing(
|
||||
value: unknown,
|
||||
): MemoryProcessingSummary | null {
|
||||
@@ -378,32 +444,150 @@ export function validateModelProfiles(value: unknown): ModelProfile[] | null {
|
||||
});
|
||||
}
|
||||
|
||||
/** `GET /api/v2/home-summary`: the SDK-authority home card. */
|
||||
export interface SdkHomeSummary {
|
||||
schema_version: 2;
|
||||
memory_count: number;
|
||||
latest: { memory_id: string } | null;
|
||||
observed_at: string;
|
||||
}
|
||||
|
||||
export function validateHomeSummary(value: unknown): SdkHomeSummary | null {
|
||||
export function validateHomeSummary(value: unknown): HomeSummary | null {
|
||||
const summary = record(value);
|
||||
if (
|
||||
!summary ||
|
||||
summary.schema_version !== 2 ||
|
||||
count(summary.memory_count) === null ||
|
||||
text(summary.observed_at, LABEL_MAX) === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const latest = summary.latest;
|
||||
if (latest !== null) {
|
||||
const memory = record(latest);
|
||||
if (!memory || typeof memory.memory_id !== "string" || !memory.memory_id) {
|
||||
return summary as unknown as HomeSummary;
|
||||
}
|
||||
|
||||
/** The page must be the page that was asked for, not merely a page. */
|
||||
export function validateMemoryPage(
|
||||
value: unknown,
|
||||
request: { offset: number; limit: number },
|
||||
): MemoryLibraryPage | null {
|
||||
const page = record(value);
|
||||
if (!page) return null;
|
||||
const total = count(page.total);
|
||||
if (
|
||||
total === null ||
|
||||
page.offset !== request.offset ||
|
||||
page.limit !== request.limit
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const items = list(page.items, validateGovernedMemory);
|
||||
if (items === null || items.length > request.limit) return null;
|
||||
if (items.length > 0 && total < request.offset + items.length) return null;
|
||||
return { items, total, offset: page.offset, limit: page.limit };
|
||||
}
|
||||
|
||||
export function validateMemoryDetail(
|
||||
value: unknown,
|
||||
memoryId: string,
|
||||
): MemoryDetail | null {
|
||||
const detail = record(value);
|
||||
if (!detail) return null;
|
||||
const memory = validateGovernedMemory(detail.memory);
|
||||
if (memory === null || memory.memory_id !== memoryId) return null;
|
||||
const cited = new Set(memory.evidence_ids);
|
||||
const returned = new Set<string>();
|
||||
const evidence = list(detail.evidence ?? [], (item) => {
|
||||
const excerpt = record(item);
|
||||
if (!excerpt) return null;
|
||||
const evidenceId = text(excerpt.evidence_id, ID_MAX);
|
||||
// Core builds this list from the memory's own citations; anything else is
|
||||
// another record's excerpt and must not be shown under this memory.
|
||||
if (
|
||||
evidenceId === null ||
|
||||
!cited.has(evidenceId) ||
|
||||
returned.has(evidenceId) ||
|
||||
typeof excerpt.available !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
returned.add(evidenceId);
|
||||
const available = excerpt.available === true;
|
||||
const verbatim = available ? text(excerpt.verbatim ?? "", TEXT_MAX) : "";
|
||||
if (verbatim === null) return null;
|
||||
return {
|
||||
evidence_id: evidenceId,
|
||||
available,
|
||||
source_id: optional(excerpt.source_id, ID_MAX),
|
||||
written_by: optional(excerpt.written_by, LABEL_MAX),
|
||||
created_at: optional(excerpt.created_at, LABEL_MAX),
|
||||
// An excerpt the user cannot read has no text, whatever the server sent.
|
||||
verbatim,
|
||||
} as unknown as MemoryEvidenceExcerpt;
|
||||
});
|
||||
if (evidence === null || evidence.length !== memory.evidence_ids.length) {
|
||||
return null;
|
||||
}
|
||||
return summary as unknown as SdkHomeSummary;
|
||||
const history = list(detail.history ?? [], (item) => {
|
||||
const version = record(item);
|
||||
if (
|
||||
!version ||
|
||||
typeof version.revision !== "number" ||
|
||||
!Number.isSafeInteger(version.revision) ||
|
||||
version.revision < 1 ||
|
||||
text(version.action, LABEL_MAX) === null ||
|
||||
!oneOf(version.status, MEMORY_STATUSES) ||
|
||||
text(version.content, TEXT_MAX) === null ||
|
||||
identifiers(version.scopes) === null ||
|
||||
text(version.created_at, LABEL_MAX) === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
revision: version.revision,
|
||||
action: version.action,
|
||||
status: version.status,
|
||||
content: version.content,
|
||||
scopes: identifiers(version.scopes) as string[],
|
||||
created_at: version.created_at,
|
||||
};
|
||||
});
|
||||
if (history === null) return null;
|
||||
return {
|
||||
memory,
|
||||
evidence,
|
||||
history,
|
||||
index_sync: null,
|
||||
} as unknown as MemoryDetail;
|
||||
}
|
||||
|
||||
export function validateGovernedMemory(value: unknown): GovernedMemory | null {
|
||||
const memory = record(value);
|
||||
if (
|
||||
!memory ||
|
||||
!text(memory.memory_id, ID_MAX) ||
|
||||
!text(memory.user_id, LABEL_MAX) ||
|
||||
!text(memory.content, TEXT_MAX) ||
|
||||
!oneOf(memory.kind, MEMORY_KINDS) ||
|
||||
!oneOf(memory.origin, MEMORY_ORIGINS) ||
|
||||
typeof memory.revision !== "number" ||
|
||||
!Number.isSafeInteger(memory.revision) ||
|
||||
memory.revision < 1 ||
|
||||
!oneOf(memory.status, MEMORY_STATUSES) ||
|
||||
!text(memory.created_at, LABEL_MAX) ||
|
||||
!text(memory.updated_at, LABEL_MAX)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const scopes = identifiers(memory.scopes);
|
||||
const evidenceIds = identifiers(memory.evidence_ids);
|
||||
const tags = identifiers(memory.tags ?? []);
|
||||
if (
|
||||
scopes === null ||
|
||||
scopes.length < 1 ||
|
||||
evidenceIds === null ||
|
||||
evidenceIds.length < 1 ||
|
||||
tags === null ||
|
||||
tags.length > 32
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...memory,
|
||||
scopes,
|
||||
evidence_ids: evidenceIds,
|
||||
tags,
|
||||
} as unknown as GovernedMemory;
|
||||
}
|
||||
|
||||
function identifiers(value: unknown): string[] | null {
|
||||
@@ -434,6 +618,10 @@ function text(value: unknown, max: number): string | null {
|
||||
return typeof value === "string" && value.length <= max ? value : null;
|
||||
}
|
||||
|
||||
function optional(value: unknown, max: number): string | undefined {
|
||||
return typeof value === "string" && value.length <= max ? value : undefined;
|
||||
}
|
||||
|
||||
function count(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
||||
? value
|
||||
|
||||
@@ -134,6 +134,11 @@ function progress(): ProgressDocument {
|
||||
generation: {
|
||||
summary_state: { state: "ok" },
|
||||
summary: { waiting_for_model: 1, failed: 0 },
|
||||
tasks_state: { state: "ok" },
|
||||
tasks: [{ task_id: "t1", status: "waiting_for_model" }],
|
||||
returned: 1,
|
||||
validated: 1,
|
||||
output_truncated: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -65,9 +65,6 @@ export function renderDoctorText(document: DoctorDocument): string {
|
||||
model: "模型",
|
||||
pending_operations: "待处理操作",
|
||||
};
|
||||
const migration = document.checks.find(
|
||||
(check) => check.id === "core" && check.details?.log_file,
|
||||
);
|
||||
return [
|
||||
`MemHub 检查:${doctorState(document.state)}`,
|
||||
...document.checks.map(
|
||||
@@ -76,13 +73,7 @@ export function renderDoctorText(document: DoctorDocument): string {
|
||||
),
|
||||
...(document.state === "healthy"
|
||||
? []
|
||||
: migration
|
||||
? [
|
||||
`升级迁移没有完成(错误码:${migration.code}),MemHub 不会启动。`,
|
||||
`下一步:把迁移日志附在 GitHub 反馈里发给我们:${migration.details?.log_file}`,
|
||||
`反馈地址:${migration.details?.issues_url}`,
|
||||
]
|
||||
: ["下一步:打开 MemHub 的“帮助与诊断”查看并处理异常。"]),
|
||||
: ["下一步:打开 MemHub 的“帮助与诊断”查看并处理异常。"]),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -116,14 +107,6 @@ function coreCheck(status: StatusDocument): DoctorCheck {
|
||||
id: "core",
|
||||
state: status.runtime.state === "stopped" ? "warn" : "fail",
|
||||
code: status.runtime.code,
|
||||
...(status.runtime.log_file
|
||||
? {
|
||||
details: {
|
||||
log_file: status.runtime.log_file,
|
||||
issues_url: status.runtime.issues_url ?? "",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,7 +200,8 @@ function modelCheck(status: StatusDocument): DoctorCheck {
|
||||
function pendingCheck(progress: ProgressDocument | null): DoctorCheck {
|
||||
if (
|
||||
progress?.imports.state !== "ok" ||
|
||||
progress.generation.summary_state.state !== "ok"
|
||||
progress.generation.summary_state.state !== "ok" ||
|
||||
progress.generation.tasks_state.state !== "ok"
|
||||
) {
|
||||
return {
|
||||
id: "pending_operations",
|
||||
@@ -228,20 +212,27 @@ function pendingCheck(progress: ProgressDocument | null): DoctorCheck {
|
||||
const jobAttention = progress.imports.jobs.filter((item) =>
|
||||
["paused", "interrupted", "failed"].includes(text(item.status) ?? ""),
|
||||
).length;
|
||||
const taskAttention = progress.generation.tasks.filter((item) =>
|
||||
["waiting_for_model", "failed"].includes(text(item.status) ?? ""),
|
||||
).length;
|
||||
const summary = progress.generation.summary;
|
||||
const waiting = summary ? number(summary.waiting_for_model) : 0;
|
||||
const failed = summary ? number(summary.failed) : 0;
|
||||
const attention = jobAttention + waiting + failed;
|
||||
const attention = jobAttention + taskAttention + waiting + failed;
|
||||
return {
|
||||
id: "pending_operations",
|
||||
state: attention > 0 ? "warn" : "pass",
|
||||
code: attention > 0 ? "operations_need_attention" : "pending_operations_ok",
|
||||
details: {
|
||||
import_jobs_validated: progress.imports.validated,
|
||||
generation_tasks_validated: progress.generation.validated,
|
||||
jobs_need_attention: jobAttention,
|
||||
tasks_need_attention: taskAttention,
|
||||
waiting_for_model: waiting,
|
||||
failed,
|
||||
output_truncated: progress.imports.output_truncated,
|
||||
output_truncated:
|
||||
progress.imports.output_truncated ||
|
||||
progress.generation.output_truncated,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,488 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
import { renderContextText } from "./context-search.js";
|
||||
import { describeEmbeddingSwitchSummary } from "./embedding-switch-flow.js";
|
||||
import { composeStatusReport, renderStatusText } from "./status-report.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,
|
||||
}));
|
||||
|
||||
const PROFILE = "44444444-4444-4444-8444-444444444444";
|
||||
const JOB = "55555555-5555-4555-8555-555555555555";
|
||||
const SPACE = "66666666-6666-4666-8666-666666666666";
|
||||
|
||||
function plan(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
needed: true,
|
||||
reason: "embedding_identity_changed",
|
||||
plan_token: "token-1",
|
||||
source: {
|
||||
space_id: "77777777-7777-4777-8777-777777777777",
|
||||
model: "bge-small-zh-v1.5",
|
||||
dims: 512,
|
||||
collection_base: "memhub_aaaaaaaaaaaaaaaa",
|
||||
vectors: 12480,
|
||||
},
|
||||
target: {
|
||||
space_id: null,
|
||||
model: "text-embedding-v3",
|
||||
dims: 1024,
|
||||
collection_base: "",
|
||||
},
|
||||
target_profile_id: PROFILE,
|
||||
target_revision: 5,
|
||||
target_provider_kind: "openai_compatible",
|
||||
target_local: false,
|
||||
sources: [],
|
||||
total_vectors: 12480,
|
||||
users: 2,
|
||||
graph_vectors: null,
|
||||
estimate_s: 120,
|
||||
requests: 195,
|
||||
disk_bytes_needed: 70 * 1024 * 1024,
|
||||
disk_bytes_free: 10 * 1024 * 1024 * 1024,
|
||||
retention_days: 14,
|
||||
blockers: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function job(
|
||||
state: string,
|
||||
percent: number,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
job_id: JOB,
|
||||
kind: "switch",
|
||||
state,
|
||||
source: plan().source,
|
||||
target: { ...plan().target, space_id: SPACE },
|
||||
stray_space_ids: [],
|
||||
target_profile_id: PROFILE,
|
||||
target_revision: 5,
|
||||
requested_by: "cli",
|
||||
progress: {
|
||||
phase: state === "reembedding" ? "vectors" : "prepare",
|
||||
done: Math.round((12480 * percent) / 100),
|
||||
total: 12480,
|
||||
percent,
|
||||
eta_s: 60,
|
||||
},
|
||||
cancel_requested: false,
|
||||
error_code: null,
|
||||
attempt: 1,
|
||||
created_at: "T1",
|
||||
updated_at: "T1",
|
||||
finished_at: state === "done" ? "T2" : null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const method of Object.values(transport)) method.mockReset();
|
||||
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("models activate with an embedding change", () => {
|
||||
it("prints the plan and exits 2 without --yes on a non-terminal", async () => {
|
||||
transport.post.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/embedding-switches/plan"
|
||||
? { status: 200, data: plan() }
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
const result = await capturedRun(["models", "activate", PROFILE]);
|
||||
expect(result.code).toBe(2);
|
||||
expect(result.stdout).toContain("将更换向量模型");
|
||||
expect(result.stdout).toContain("bge-small-zh-v1.5(512 维)");
|
||||
expect(result.stdout).toContain("text-embedding-v3(1024 维)");
|
||||
expect(result.stdout).toContain("12,480");
|
||||
expect(result.stdout).toContain("重新运行并加上 --yes 以确认");
|
||||
expect(transport.post.mock.calls.map(([url]) => url)).toEqual([
|
||||
"/api/v1/embedding-switches/plan",
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts the switch with --yes and emits JSON progress lines", async () => {
|
||||
transport.post.mockImplementation(async (url: string) => {
|
||||
if (url === "/api/v1/embedding-switches/plan") {
|
||||
return { status: 200, data: plan() };
|
||||
}
|
||||
if (url === "/api/v1/embedding-switches") {
|
||||
return { status: 202, data: job("preparing", 0) };
|
||||
}
|
||||
return { status: 404, data: {} };
|
||||
});
|
||||
const answers = [
|
||||
{
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
job: job("reembedding", 50),
|
||||
last_finished: null,
|
||||
},
|
||||
{
|
||||
phase: "idle",
|
||||
writes_paused: false,
|
||||
job: null,
|
||||
last_finished: job("done", 100),
|
||||
},
|
||||
];
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/embedding-switches/current"
|
||||
? { status: 200, data: { schema_version: 1, ...answers.shift() } }
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
const result = await capturedRun(
|
||||
["models", "activate", PROFILE, "--yes", "--wait", "--json"],
|
||||
{ watchWait: async () => undefined },
|
||||
);
|
||||
expect(result.code).toBe(0);
|
||||
const lines = result.stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(lines.map((line) => line.state)).toEqual([
|
||||
"preparing",
|
||||
"reembedding",
|
||||
"done",
|
||||
"done",
|
||||
]);
|
||||
expect(lines[1]).toMatchObject({ percent: 50, job_id: JOB });
|
||||
expect(lines.at(-1)).toMatchObject({ result: "switched" });
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/embedding-switches",
|
||||
{
|
||||
profile_id: PROFILE,
|
||||
expected_revision: 5,
|
||||
plan_token: "token-1",
|
||||
requested_by: "cli",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a plan with blockers and starts nothing", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: plan({ blockers: ["sdk_reembed_unavailable"] }),
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"models",
|
||||
"activate",
|
||||
PROFILE,
|
||||
"--yes",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
state: "blocked",
|
||||
code: "sdk_reembed_unavailable",
|
||||
});
|
||||
expect(transport.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("says nothing is re-embedded when only generation changed", async () => {
|
||||
transport.post.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/embedding-switches/plan"
|
||||
? {
|
||||
status: 200,
|
||||
data: plan({ needed: false, reason: "same_embedding" }),
|
||||
}
|
||||
: { status: 200, data: profileResponse() },
|
||||
);
|
||||
const result = await capturedRun(["models", "activate", PROFILE]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("向量模型未变化,无需重新生成向量。");
|
||||
});
|
||||
|
||||
it("reads the plan carried by a 409 embedding_switch_required", async () => {
|
||||
transport.post.mockImplementation(async (url: string) =>
|
||||
url.endsWith("/activate")
|
||||
? {
|
||||
status: 409,
|
||||
data: { detail: "embedding_switch_required", plan: plan() },
|
||||
}
|
||||
: { status: 404, data: { detail: "Not Found" } },
|
||||
);
|
||||
const result = await capturedRun(["models", "activate", PROFILE, "--json"]);
|
||||
expect(result.code).toBe(2);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
state: "confirmation_required",
|
||||
code: "embedding_switch_required",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("models switch and spaces", () => {
|
||||
it("reports no switch, then cancels the running one", async () => {
|
||||
transport.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
schema_version: 1,
|
||||
phase: "idle",
|
||||
writes_paused: false,
|
||||
job: null,
|
||||
last_finished: null,
|
||||
},
|
||||
});
|
||||
const idle = await capturedRun(["models", "switch", "status"]);
|
||||
expect(idle.code).toBe(0);
|
||||
expect(idle.stdout).toContain("没有进行中的向量模型切换");
|
||||
|
||||
transport.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
schema_version: 1,
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
job: job("reembedding", 20),
|
||||
last_finished: null,
|
||||
},
|
||||
});
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: job("reembedding", 20, { cancel_requested: true }),
|
||||
});
|
||||
const cancelled = await capturedRun([
|
||||
"models",
|
||||
"switch",
|
||||
"cancel",
|
||||
"--json",
|
||||
]);
|
||||
expect(cancelled.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
`/api/v1/embedding-switches/${JOB}/cancel`,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(cancelled.stdout)).toMatchObject({
|
||||
cancel_requested: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("lists spaces and needs --yes before cleaning one up", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
schema_version: 1,
|
||||
live_space_id: "x",
|
||||
spaces: [
|
||||
{
|
||||
space_id: SPACE,
|
||||
state: "retired",
|
||||
model: "bge-small-zh-v1.5",
|
||||
dims: 512,
|
||||
vectors: 10,
|
||||
keep_until: "2026-11-15T00:00:00+00:00",
|
||||
},
|
||||
{
|
||||
space_id: "88888888-8888-4888-8888-888888888888",
|
||||
state: "dropped",
|
||||
model: "m",
|
||||
dims: 8,
|
||||
},
|
||||
],
|
||||
scanned: [],
|
||||
scan_error: null,
|
||||
},
|
||||
});
|
||||
const listed = await capturedRun(["models", "spaces"]);
|
||||
expect(listed.code).toBe(0);
|
||||
expect(listed.stdout).toContain("已停用");
|
||||
expect(listed.stdout).toContain("保留到 2026-11-15");
|
||||
expect(listed.stdout).not.toContain("88888888");
|
||||
|
||||
const preview = await capturedRun(["models", "spaces", "--cleanup", SPACE]);
|
||||
expect(preview.code).toBe(0);
|
||||
expect(preview.stdout).toContain("--yes");
|
||||
expect(transport.delete).not.toHaveBeenCalled();
|
||||
|
||||
transport.delete.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { schema_version: 1 },
|
||||
});
|
||||
const dropped = await capturedRun([
|
||||
"models",
|
||||
"spaces",
|
||||
"--cleanup",
|
||||
SPACE,
|
||||
"--yes",
|
||||
]);
|
||||
expect(dropped.code).toBe(0);
|
||||
expect(transport.delete).toHaveBeenCalledWith(
|
||||
`/api/v1/embedding-spaces/${SPACE}`,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("status and search wording", () => {
|
||||
it("shows the switch line in memhub status", async () => {
|
||||
const reader = async (path: string) =>
|
||||
path === "/api/v1/memory-processing"
|
||||
? {
|
||||
state: "ok" as const,
|
||||
data: {
|
||||
pending: 3,
|
||||
fresh_pending: 1,
|
||||
backlog_pending: 2,
|
||||
outside_window_pending: 0,
|
||||
running: 0,
|
||||
waiting_for_model: 0,
|
||||
completed: 9,
|
||||
failed: 0,
|
||||
embedding_switch: {
|
||||
job_id: JOB,
|
||||
kind: "switch",
|
||||
state: "reembedding",
|
||||
percent: 50,
|
||||
eta_s: 60,
|
||||
},
|
||||
},
|
||||
}
|
||||
: { state: "unavailable" as const, code: "http_503" };
|
||||
const report = await composeStatusReport({
|
||||
dataDir: "/synthetic/data",
|
||||
runtime: {
|
||||
state: "running",
|
||||
code: "runtime_verified",
|
||||
instanceId: "instance-1",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
} as never,
|
||||
background: null,
|
||||
reader: reader as never,
|
||||
});
|
||||
const text = renderStatusText(report, "MemHub");
|
||||
expect(text).toContain("向量模型:切换中 50%,记忆整理已暂停");
|
||||
expect(
|
||||
describeEmbeddingSwitchSummary({
|
||||
kind: "merge",
|
||||
state: "reembedding",
|
||||
percent: 30,
|
||||
}),
|
||||
).toBe("正在合并旧向量数据 30%,记忆整理已暂停");
|
||||
expect(
|
||||
describeEmbeddingSwitchSummary({
|
||||
kind: "merge",
|
||||
state: "scanning",
|
||||
percent: 0,
|
||||
}),
|
||||
).toBe("正在检查向量数据…");
|
||||
});
|
||||
|
||||
it("names an unavailable embedding service instead of 'try later'", () => {
|
||||
expect(
|
||||
renderContextText({
|
||||
schema_version: 2,
|
||||
product: "memhub",
|
||||
state: "unavailable",
|
||||
code: "embedding_unavailable",
|
||||
items: [],
|
||||
}),
|
||||
).toContain("向量服务不可用");
|
||||
});
|
||||
});
|
||||
|
||||
function profileResponse() {
|
||||
return {
|
||||
profile_id: PROFILE,
|
||||
name: "Synthetic",
|
||||
provider_kind: "openai_compatible",
|
||||
base_url: "https://example.invalid/v1",
|
||||
model: "synthetic-model-2",
|
||||
embedding_model: "text-embedding-v3",
|
||||
embedding_provider_kind: "openai_compatible",
|
||||
embedding_base_url: "https://example.invalid/v1",
|
||||
embedding_dimensions: 1024,
|
||||
has_api_key: true,
|
||||
credential_state: "available",
|
||||
timeout_seconds: 30,
|
||||
max_concurrency: 1,
|
||||
context_window: null,
|
||||
disable_thinking: true,
|
||||
capabilities: {
|
||||
endpoint_reachable: true,
|
||||
model_available: true,
|
||||
structured_output: true,
|
||||
embedding_available: true,
|
||||
},
|
||||
revision: 5,
|
||||
is_active: true,
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
updated_at: "2026-09-19T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
async function capturedRun(
|
||||
arguments_: string[],
|
||||
controls: Parameters<typeof run>[2] = {},
|
||||
) {
|
||||
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" },
|
||||
{ stdinIsTTY: false, ...controls },
|
||||
),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -1,847 +0,0 @@
|
||||
/**
|
||||
* Switching the embedding model from the CLI: `models activate` when the
|
||||
* embedding changes, `models switch status|cancel`, `models spaces`.
|
||||
*
|
||||
* Core owns every decision (`/api/v1/embedding-switches*`, contract
|
||||
* `docs/contracts/embedding-switch-v1.md`). A change of embedding model or
|
||||
* width is never applied by activation alone: Core plans a switch, the user
|
||||
* confirms it, Core re-embeds every memory into a new space and only then
|
||||
* makes it live. The CLI shows the plan, asks, starts the job and follows it.
|
||||
*/
|
||||
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type {
|
||||
CoreDeleteOperations,
|
||||
CoreOperations,
|
||||
OperationOutcome,
|
||||
} from "./core-operations.js";
|
||||
import { EXIT_FAILED, EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
|
||||
import {
|
||||
activateModelProfile,
|
||||
type ModelWriteDocument,
|
||||
modelProfileResult,
|
||||
renderModelWriteText,
|
||||
} from "./model-write-flows.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 CODE = /^[a-z0-9_.:-]{1,128}$/;
|
||||
const TERMINAL = new Set(["done", "rolled_back"]);
|
||||
const POLL_MS = 1_000;
|
||||
|
||||
export interface SpaceSummary {
|
||||
space_id: string | null;
|
||||
model: string | null;
|
||||
dims: number | null;
|
||||
collection_base: string;
|
||||
vectors?: number | null;
|
||||
}
|
||||
|
||||
export interface EmbeddingSwitchPlan {
|
||||
needed: boolean;
|
||||
reason: string;
|
||||
plan_token: string;
|
||||
source: SpaceSummary;
|
||||
target: SpaceSummary;
|
||||
target_profile_id: string;
|
||||
target_revision: number;
|
||||
target_provider_kind: string;
|
||||
target_local: boolean;
|
||||
total_vectors: number;
|
||||
users: number | null;
|
||||
graph_vectors: number | null;
|
||||
estimate_s: number | null;
|
||||
requests: number | null;
|
||||
disk_bytes_needed: number;
|
||||
disk_bytes_free: number | null;
|
||||
retention_days: number;
|
||||
blockers: string[];
|
||||
}
|
||||
|
||||
export interface SwitchProgress {
|
||||
phase: string;
|
||||
done: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
eta_s: number | null;
|
||||
}
|
||||
|
||||
export interface EmbeddingSwitchJob {
|
||||
job_id: string;
|
||||
kind: "switch" | "merge";
|
||||
state: string;
|
||||
source: SpaceSummary;
|
||||
target: SpaceSummary;
|
||||
progress: SwitchProgress;
|
||||
cancel_requested: boolean;
|
||||
error_code: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface SwitchCurrent {
|
||||
phase: "idle" | "scanning" | "running";
|
||||
writes_paused: boolean;
|
||||
job: EmbeddingSwitchJob | null;
|
||||
last_finished: EmbeddingSwitchJob | null;
|
||||
}
|
||||
|
||||
export interface SwitchIo {
|
||||
reader: CoreReader;
|
||||
operations: CoreOperations & CoreDeleteOperations;
|
||||
emit: (text: string) => void;
|
||||
asJson: boolean;
|
||||
/** A terminal is attached: may prompt, and follows progress by default. */
|
||||
interactive: boolean;
|
||||
confirm?: (question: string) => Promise<boolean>;
|
||||
wait?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ActivateOptions {
|
||||
confirmed: boolean;
|
||||
/** `--wait` / `--no-wait`; undefined follows on a terminal only. */
|
||||
follow?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing: bounded projections of Core's answers, never passthrough.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function int(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function str(value: unknown, max = 256): string | null {
|
||||
return typeof value === "string" && value.length <= max ? value : null;
|
||||
}
|
||||
|
||||
function space(value: unknown): SpaceSummary | null {
|
||||
const item = record(value);
|
||||
if (!item) return null;
|
||||
return {
|
||||
space_id: str(item.space_id, 64),
|
||||
model: str(item.model),
|
||||
dims: int(item.dims),
|
||||
collection_base: str(item.collection_base) ?? "",
|
||||
vectors: int(item.vectors),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePlan(value: unknown): EmbeddingSwitchPlan | null {
|
||||
const plan = record(value);
|
||||
if (!plan || typeof plan.needed !== "boolean") return null;
|
||||
const source = space(plan.source);
|
||||
const target = space(plan.target);
|
||||
const token = str(plan.plan_token, 128);
|
||||
const profile = str(plan.target_profile_id, 128);
|
||||
const revision = int(plan.target_revision);
|
||||
if (!source || !target || !token || !profile || revision === null) {
|
||||
return null;
|
||||
}
|
||||
const blockers = Array.isArray(plan.blockers)
|
||||
? plan.blockers.filter(
|
||||
(item): item is string => typeof item === "string" && CODE.test(item),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
needed: plan.needed,
|
||||
reason: str(plan.reason, 64) ?? "",
|
||||
plan_token: token,
|
||||
source,
|
||||
target,
|
||||
target_profile_id: profile,
|
||||
target_revision: revision,
|
||||
target_provider_kind: str(plan.target_provider_kind, 64) ?? "",
|
||||
target_local: plan.target_local === true,
|
||||
total_vectors: int(plan.total_vectors) ?? 0,
|
||||
users: int(plan.users),
|
||||
graph_vectors: int(plan.graph_vectors),
|
||||
estimate_s: int(plan.estimate_s),
|
||||
requests: int(plan.requests),
|
||||
disk_bytes_needed: int(plan.disk_bytes_needed) ?? 0,
|
||||
disk_bytes_free: int(plan.disk_bytes_free),
|
||||
retention_days: int(plan.retention_days) ?? 14,
|
||||
blockers,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseJob(value: unknown): EmbeddingSwitchJob | null {
|
||||
const job = record(value);
|
||||
if (!job) return null;
|
||||
const jobId = str(job.job_id, 64);
|
||||
const state = str(job.state, 32);
|
||||
const source = space(job.source);
|
||||
const target = space(job.target);
|
||||
const progress = record(job.progress) ?? {};
|
||||
if (!jobId || !state || !source || !target) return null;
|
||||
if (job.kind !== "switch" && job.kind !== "merge") return null;
|
||||
const percent =
|
||||
typeof progress.percent === "number" && Number.isFinite(progress.percent)
|
||||
? Math.min(100, Math.max(0, progress.percent))
|
||||
: 0;
|
||||
return {
|
||||
job_id: jobId,
|
||||
kind: job.kind,
|
||||
state,
|
||||
source,
|
||||
target,
|
||||
progress: {
|
||||
phase: str(progress.phase, 32) ?? "prepare",
|
||||
done: int(progress.done) ?? 0,
|
||||
total: int(progress.total) ?? 0,
|
||||
percent,
|
||||
eta_s: int(progress.eta_s),
|
||||
},
|
||||
cancel_requested: job.cancel_requested === true,
|
||||
error_code:
|
||||
typeof job.error_code === "string" && CODE.test(job.error_code)
|
||||
? job.error_code
|
||||
: null,
|
||||
finished_at: str(job.finished_at, 64),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCurrent(value: unknown): SwitchCurrent | null {
|
||||
const current = record(value);
|
||||
if (!current) return null;
|
||||
const phase = current.phase;
|
||||
if (phase !== "idle" && phase !== "scanning" && phase !== "running") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
phase,
|
||||
writes_paused: current.writes_paused === true,
|
||||
job: current.job == null ? null : parseJob(current.job),
|
||||
last_finished:
|
||||
current.last_finished == null ? null : parseJob(current.last_finished),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wording
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BLOCKER_TEXT: Record<string, string> = {
|
||||
embedding_switch_running: "上一次向量模型切换还在进行,请等待完成或先取消。",
|
||||
embedding_probe_required: "新配置还没有通过探测;先运行 memhub models test。",
|
||||
sdk_reembed_unavailable:
|
||||
"当前运行时的 SDK 不支持重新生成向量,请先升级 MemHub。",
|
||||
embedding_switch_requires_migration:
|
||||
"还有旧版本的向量数据没有迁移完,请先完成升级迁移。",
|
||||
embedding_dimensions_unknown:
|
||||
"新配置的向量维度未知;先探测或在配置里写明 embedding_dimensions。",
|
||||
insufficient_disk_space: "磁盘空间不足,无法容纳新旧两份向量数据。",
|
||||
};
|
||||
|
||||
const FAILURE_TEXT: Record<string, string> = {
|
||||
writes_did_not_drain: "等待正在进行的记忆整理结束超时",
|
||||
embedding_service_failed: "向量服务连续请求失败",
|
||||
embedding_dimensions_mismatch: "向量服务返回的维度与探测到的不一致",
|
||||
embedding_verification_failed: "新向量没有通过校验",
|
||||
target_profile_changed: "切换期间目标配置被修改",
|
||||
embedding_switch_cancelled: "已取消",
|
||||
model_secret_unavailable: "读不到向量服务的 API Key",
|
||||
secure_storage_unavailable: "系统钥匙串不可用",
|
||||
embedding_switch_clients_busy: "记忆图谱一直被占用,无法重新生成",
|
||||
};
|
||||
|
||||
const STATE_TEXT: Record<string, string> = {
|
||||
preparing: "准备中(等待记忆整理暂停)",
|
||||
reembedding: "重新生成向量",
|
||||
verifying: "校验新向量",
|
||||
committing: "正式切换",
|
||||
failed: "正在回滚",
|
||||
done: "已完成",
|
||||
rolled_back: "已回滚",
|
||||
};
|
||||
|
||||
export function blockerText(code: string): string {
|
||||
return BLOCKER_TEXT[code] ?? code;
|
||||
}
|
||||
|
||||
function spaceLabel(item: SpaceSummary, local?: boolean): string {
|
||||
const dims = item.dims === null ? "维度未知" : `${item.dims} 维`;
|
||||
return `${item.model ?? "未知模型"}(${dims}${local ? ",本地" : ""})`;
|
||||
}
|
||||
|
||||
function duration(seconds: number | null): string {
|
||||
if (seconds === null) return "未知";
|
||||
if (seconds < 60) return `${Math.max(1, seconds)} 秒`;
|
||||
return `${Math.round(seconds / 60)} 分钟`;
|
||||
}
|
||||
|
||||
function megabytes(bytes: number | null): string {
|
||||
return bytes === null ? "未知" : `${Math.ceil(bytes / (1024 * 1024))} MB`;
|
||||
}
|
||||
|
||||
export function renderPlanText(plan: EmbeddingSwitchPlan): string {
|
||||
const lines = [
|
||||
`将更换向量模型:${spaceLabel(plan.source)} → ${spaceLabel(plan.target, plan.target_local)}`,
|
||||
`需要用新模型重新生成 ${plan.total_vectors.toLocaleString("en-US")} 条向量` +
|
||||
`${plan.users === null ? "" : `(${plan.users} 个用户)`}` +
|
||||
`,预计约 ${duration(plan.estimate_s)}` +
|
||||
`${plan.requests === null ? ",在本机计算,不产生费用" : `,约 ${plan.requests} 次请求`}` +
|
||||
`,额外磁盘约 ${megabytes(plan.disk_bytes_needed)}。`,
|
||||
];
|
||||
if (plan.graph_vectors) {
|
||||
lines.push(`记忆图谱另有 ${plan.graph_vectors} 条向量一并重新生成。`);
|
||||
}
|
||||
if (
|
||||
plan.source.dims !== null &&
|
||||
plan.source.dims === plan.target.dims &&
|
||||
plan.source.model !== plan.target.model
|
||||
) {
|
||||
lines.push("新旧模型维度相同,但向量不能通用,仍需重新生成。");
|
||||
}
|
||||
lines.push(
|
||||
"切换期间记忆整理暂停,检索继续使用旧模型;完成并校验通过后才会生效。",
|
||||
`旧的向量数据保留 ${plan.retention_days} 天,可用 memhub models spaces 清理。`,
|
||||
);
|
||||
for (const blocker of plan.blockers) {
|
||||
lines.push(`无法开始:${blockerText(blocker)}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function renderProgressLine(job: EmbeddingSwitchJob): string {
|
||||
const what = job.kind === "merge" ? "正在合并旧向量数据" : "正在切换向量模型";
|
||||
const state = STATE_TEXT[job.state] ?? job.state;
|
||||
if (job.state === "reembedding" && job.progress.total > 0) {
|
||||
const eta =
|
||||
job.progress.eta_s === null
|
||||
? ""
|
||||
: ` 预计还需 ${duration(job.progress.eta_s)}`;
|
||||
return `${what}:${state} ${job.progress.done.toLocaleString("en-US")}/${job.progress.total.toLocaleString("en-US")} ${Math.floor(job.progress.percent)}%${eta}`;
|
||||
}
|
||||
return `${what}:${state}`;
|
||||
}
|
||||
|
||||
export function renderOutcomeText(job: EmbeddingSwitchJob): string {
|
||||
if (job.state === "done") {
|
||||
return job.kind === "merge"
|
||||
? "旧向量数据已合并,记忆整理已恢复。\n"
|
||||
: `已切换到 ${job.target.model ?? "新模型"}。\n`;
|
||||
}
|
||||
const code = job.error_code ?? "embedding_switch_failed";
|
||||
if (code === "embedding_switch_cancelled") {
|
||||
return `已取消切换,继续使用 ${job.source.model ?? "旧模型"}。\n`;
|
||||
}
|
||||
return `切换未完成,已恢复使用 ${job.source.model ?? "旧模型"},数据未受影响。原因:${FAILURE_TEXT[code] ?? code}(${code})\n`;
|
||||
}
|
||||
|
||||
/** One line for `memhub status`, from `memory-processing.embedding_switch`. */
|
||||
export function describeEmbeddingSwitchSummary(value: unknown): string | null {
|
||||
const summary = record(value);
|
||||
if (!summary) return null;
|
||||
const percent =
|
||||
typeof summary.percent === "number" && Number.isFinite(summary.percent)
|
||||
? Math.floor(summary.percent)
|
||||
: 0;
|
||||
if (summary.state === "scanning") return "正在检查向量数据…";
|
||||
if (summary.kind === "merge") {
|
||||
return `正在合并旧向量数据 ${percent}%,记忆整理已暂停`;
|
||||
}
|
||||
if (summary.state === "failed") return "切换未完成,正在恢复旧向量模型";
|
||||
return `切换中 ${percent}%,记忆整理已暂停`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function document(
|
||||
command: string,
|
||||
fields: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return { schema_version: 1, product: PRODUCT, command, ...fields };
|
||||
}
|
||||
|
||||
function emitDocument(io: SwitchIo, value: Record<string, unknown>): void {
|
||||
io.emit(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
async function requestPlan(
|
||||
profileId: string,
|
||||
io: SwitchIo,
|
||||
): Promise<
|
||||
| { state: "plan"; plan: EmbeddingSwitchPlan }
|
||||
| { state: "unsupported" }
|
||||
| { state: "failed"; code: string }
|
||||
> {
|
||||
const outcome = await io.operations.post<unknown>(
|
||||
"/api/v1/embedding-switches/plan",
|
||||
{ profile_id: profileId },
|
||||
);
|
||||
if (outcome.state === "committed") {
|
||||
const plan = parsePlan(outcome.data);
|
||||
// Not a plan: activation itself still refuses an embedding change.
|
||||
return plan ? { state: "plan", plan } : { state: "unsupported" };
|
||||
}
|
||||
if (outcome.state === "rejected") {
|
||||
// A Core before the registry has no plan route: activation as before.
|
||||
if (outcome.http_status === 404 && outcome.code === "http_404") {
|
||||
return { state: "unsupported" };
|
||||
}
|
||||
const plan = parsePlan(record(outcome.body)?.plan);
|
||||
if (plan) return { state: "plan", plan };
|
||||
return { state: "failed", code: outcome.code };
|
||||
}
|
||||
return { state: "failed", code: outcome.code };
|
||||
}
|
||||
|
||||
function emitActivation(io: SwitchIo, result: ModelWriteDocument): number {
|
||||
io.emit(
|
||||
io.asJson ? `${JSON.stringify(result)}\n` : renderModelWriteText(result),
|
||||
);
|
||||
return result.state === "committed" ? EXIT_OK : EXIT_FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* `memhub models activate <id> [--yes] [--wait|--no-wait] [--json]`.
|
||||
*
|
||||
* Asks Core for the plan first. No embedding change: activate as before.
|
||||
* An embedding change: show the plan, confirm (`--yes`, or a prompt on a
|
||||
* terminal; exit 2 otherwise), start the switch and follow it.
|
||||
*/
|
||||
export async function activateWithEmbeddingSwitch(
|
||||
profileId: string,
|
||||
options: ActivateOptions,
|
||||
io: SwitchIo,
|
||||
): Promise<number> {
|
||||
if (!UUID.test(profileId)) {
|
||||
return emitActivation(
|
||||
io,
|
||||
await activateModelProfile(profileId, io.operations),
|
||||
);
|
||||
}
|
||||
const planned = await requestPlan(profileId, io);
|
||||
if (planned.state === "unsupported") {
|
||||
const activated = await activateOrPlan(profileId, io);
|
||||
return activated.state === "plan"
|
||||
? confirmAndSwitch(activated.plan, options, io)
|
||||
: emitActivation(io, activated.result);
|
||||
}
|
||||
if (planned.state === "failed") {
|
||||
return reject(io, "models activate", planned.code);
|
||||
}
|
||||
const plan = planned.plan;
|
||||
if (!plan.needed) {
|
||||
if (!io.asJson && plan.reason === "same_embedding") {
|
||||
io.emit("向量模型未变化,无需重新生成向量。\n");
|
||||
}
|
||||
const activated = await activateOrPlan(profileId, io);
|
||||
if (activated.state === "plan") {
|
||||
return confirmAndSwitch(activated.plan, options, io);
|
||||
}
|
||||
return emitActivation(io, activated.result);
|
||||
}
|
||||
return confirmAndSwitch(plan, options, io);
|
||||
}
|
||||
|
||||
async function activateOrPlan(
|
||||
profileId: string,
|
||||
io: SwitchIo,
|
||||
): Promise<
|
||||
| { state: "plan"; plan: EmbeddingSwitchPlan }
|
||||
| { state: "activated"; result: ModelWriteDocument }
|
||||
> {
|
||||
const result: OperationOutcome<unknown> = await io.operations.post<unknown>(
|
||||
`/api/v1/model-profiles/${encodeURIComponent(profileId)}/activate`,
|
||||
{},
|
||||
);
|
||||
if (
|
||||
result.state === "rejected" &&
|
||||
result.code === "embedding_switch_required"
|
||||
) {
|
||||
const plan = parsePlan(record(result.body)?.plan);
|
||||
if (plan) return { state: "plan", plan };
|
||||
}
|
||||
return { state: "activated", result: modelProfileResult(profileId, result) };
|
||||
}
|
||||
|
||||
function reject(io: SwitchIo, command: string, code: string): number {
|
||||
if (io.asJson) {
|
||||
emitDocument(io, document(command, { state: "rejected", code }));
|
||||
} else {
|
||||
io.emit(`${command} 未完成:${blockerText(code)}(${code})\n`);
|
||||
}
|
||||
return EXIT_FAILED;
|
||||
}
|
||||
|
||||
async function confirmAndSwitch(
|
||||
plan: EmbeddingSwitchPlan,
|
||||
options: ActivateOptions,
|
||||
io: SwitchIo,
|
||||
): Promise<number> {
|
||||
if (plan.blockers.length > 0) {
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document("models activate", {
|
||||
state: "blocked",
|
||||
code: plan.blockers[0],
|
||||
plan,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
io.emit(renderPlanText(plan));
|
||||
}
|
||||
return EXIT_FAILED;
|
||||
}
|
||||
if (!options.confirmed) {
|
||||
const ask = io.interactive && !io.asJson ? io.confirm : undefined;
|
||||
if (ask === undefined) {
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document("models activate", {
|
||||
state: "confirmation_required",
|
||||
code: "embedding_switch_required",
|
||||
plan,
|
||||
next_step: `${PRODUCT} models activate ${plan.target_profile_id} --yes`,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
io.emit(`${renderPlanText(plan)}重新运行并加上 --yes 以确认\n`);
|
||||
}
|
||||
return EXIT_USAGE;
|
||||
}
|
||||
io.emit(renderPlanText(plan));
|
||||
if (!(await ask("继续?[y/N] "))) {
|
||||
io.emit("未开始切换,仍在使用原来的向量模型。\n");
|
||||
return EXIT_OK;
|
||||
}
|
||||
}
|
||||
const started = await io.operations.post<unknown>(
|
||||
"/api/v1/embedding-switches",
|
||||
{
|
||||
profile_id: plan.target_profile_id,
|
||||
expected_revision: plan.target_revision,
|
||||
plan_token: plan.plan_token,
|
||||
requested_by: "cli",
|
||||
},
|
||||
);
|
||||
if (started.state !== "committed") {
|
||||
return reject(io, "models activate", started.code);
|
||||
}
|
||||
const job = parseJob(started.data);
|
||||
if (!job) return reject(io, "models activate", "embedding_switch_invalid");
|
||||
const follow = options.follow ?? io.interactive;
|
||||
if (!follow) {
|
||||
if (io.asJson) {
|
||||
emitDocument(io, document("models activate", progressFields(job)));
|
||||
} else {
|
||||
io.emit(
|
||||
`已开始切换向量模型(${job.job_id})。用 memhub models switch status 查看进度。\n`,
|
||||
);
|
||||
}
|
||||
return EXIT_OK;
|
||||
}
|
||||
return followJob(job, io, "models activate");
|
||||
}
|
||||
|
||||
function progressFields(job: EmbeddingSwitchJob): Record<string, unknown> {
|
||||
return {
|
||||
state: job.state,
|
||||
job_id: job.job_id,
|
||||
kind: job.kind,
|
||||
phase: job.progress.phase,
|
||||
percent: job.progress.percent,
|
||||
done: job.progress.done,
|
||||
total: job.progress.total,
|
||||
eta_s: job.progress.eta_s,
|
||||
...(job.error_code ? { code: job.error_code } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function readCurrent(io: SwitchIo): Promise<SwitchCurrent | null> {
|
||||
const outcome = await io.reader<unknown>(
|
||||
"/api/v1/embedding-switches/current",
|
||||
);
|
||||
return outcome.state === "ok" ? parseCurrent(outcome.data) : null;
|
||||
}
|
||||
|
||||
/** Poll `current` until the job ends or the user stops watching. */
|
||||
async function followJob(
|
||||
started: EmbeddingSwitchJob,
|
||||
io: SwitchIo,
|
||||
command: string,
|
||||
): Promise<number> {
|
||||
const wait = io.wait ?? defaultWait;
|
||||
const signal = io.signal ?? new AbortController().signal;
|
||||
let last = "";
|
||||
let job = started;
|
||||
const show = (value: EmbeddingSwitchJob) => {
|
||||
const line = io.asJson
|
||||
? JSON.stringify(document(command, progressFields(value)))
|
||||
: renderProgressLine(value);
|
||||
if (line !== last) {
|
||||
io.emit(`${line}\n`);
|
||||
last = line;
|
||||
}
|
||||
};
|
||||
show(job);
|
||||
if (!io.asJson) {
|
||||
io.emit(
|
||||
"(Ctrl-C 只停止显示;用 `memhub models switch cancel` 取消切换)\n",
|
||||
);
|
||||
}
|
||||
while (!TERMINAL.has(job.state)) {
|
||||
try {
|
||||
await wait(POLL_MS, signal);
|
||||
} catch {
|
||||
// Aborted below.
|
||||
}
|
||||
if (signal.aborted) {
|
||||
if (!io.asJson) {
|
||||
io.emit(
|
||||
"已停止显示进度;切换仍在后台进行。用 `memhub models switch cancel` 取消。\n",
|
||||
);
|
||||
}
|
||||
return EXIT_OK;
|
||||
}
|
||||
const current = await readCurrent(io);
|
||||
if (current === null) continue;
|
||||
const next =
|
||||
current.job?.job_id === job.job_id
|
||||
? current.job
|
||||
: current.last_finished?.job_id === job.job_id
|
||||
? current.last_finished
|
||||
: null;
|
||||
if (next === null) continue;
|
||||
job = next;
|
||||
show(job);
|
||||
}
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document(command, {
|
||||
...progressFields(job),
|
||||
result: job.state === "done" ? "switched" : "rolled_back",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
io.emit(renderOutcomeText(job));
|
||||
}
|
||||
return job.state === "done" ? EXIT_OK : EXIT_FAILED;
|
||||
}
|
||||
|
||||
function defaultWait(milliseconds: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, milliseconds);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("aborted"));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** `memhub models switch status [--json]`. */
|
||||
export async function switchStatus(io: SwitchIo): Promise<number> {
|
||||
const outcome = await io.reader<unknown>(
|
||||
"/api/v1/embedding-switches/current",
|
||||
);
|
||||
if (outcome.state !== "ok")
|
||||
return reject(io, "models switch status", outcome.code);
|
||||
const current = parseCurrent(outcome.data);
|
||||
if (!current) {
|
||||
return reject(io, "models switch status", "embedding_switch_invalid");
|
||||
}
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document("models switch status", {
|
||||
state: current.phase,
|
||||
writes_paused: current.writes_paused,
|
||||
job: current.job ? progressFields(current.job) : null,
|
||||
last_finished: current.last_finished
|
||||
? progressFields(current.last_finished)
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
return EXIT_OK;
|
||||
}
|
||||
if (current.job) {
|
||||
io.emit(
|
||||
`${renderProgressLine(current.job)}\n记忆整理已暂停,检索不受影响。\n`,
|
||||
);
|
||||
} else if (current.phase === "scanning") {
|
||||
io.emit("正在检查向量数据…\n");
|
||||
} else if (current.last_finished) {
|
||||
io.emit(
|
||||
`没有进行中的向量模型切换。上一次:${renderOutcomeText(current.last_finished)}`,
|
||||
);
|
||||
} else {
|
||||
io.emit("没有进行中的向量模型切换。\n");
|
||||
}
|
||||
return EXIT_OK;
|
||||
}
|
||||
|
||||
/** `memhub models switch cancel [--json]`: cancel the running job, if any. */
|
||||
export async function switchCancel(io: SwitchIo): Promise<number> {
|
||||
const current = await readCurrent(io);
|
||||
if (current === null) {
|
||||
return reject(io, "models switch cancel", "embedding_switch_unavailable");
|
||||
}
|
||||
if (!current.job) {
|
||||
return reject(io, "models switch cancel", "embedding_switch_not_found");
|
||||
}
|
||||
const outcome = await io.operations.post<unknown>(
|
||||
`/api/v1/embedding-switches/${current.job.job_id}/cancel`,
|
||||
{},
|
||||
);
|
||||
if (outcome.state !== "committed") {
|
||||
return reject(io, "models switch cancel", outcome.code);
|
||||
}
|
||||
const job = parseJob(outcome.data) ?? current.job;
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document("models switch cancel", {
|
||||
...progressFields(job),
|
||||
cancel_requested: true,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
io.emit("已请求取消;当前批次完成后回滚,继续使用原来的向量模型。\n");
|
||||
}
|
||||
return EXIT_OK;
|
||||
}
|
||||
|
||||
interface SpaceRow {
|
||||
space_id: string;
|
||||
state: string;
|
||||
model: string | null;
|
||||
dims: number | null;
|
||||
vectors: number | null;
|
||||
keep_until: string | null;
|
||||
}
|
||||
|
||||
function spaceRows(value: unknown): SpaceRow[] | null {
|
||||
const body = record(value);
|
||||
if (!body || !Array.isArray(body.spaces)) return null;
|
||||
const rows: SpaceRow[] = [];
|
||||
for (const item of body.spaces) {
|
||||
const row = record(item);
|
||||
const spaceId = str(row?.space_id, 64);
|
||||
const state = str(row?.state, 16);
|
||||
if (!row || !spaceId || !state) return null;
|
||||
if (state === "dropped") continue;
|
||||
rows.push({
|
||||
space_id: spaceId,
|
||||
state,
|
||||
model: str(row.model),
|
||||
dims: int(row.dims) ?? int(row.name_dims),
|
||||
vectors: int(row.vectors),
|
||||
keep_until: str(row.keep_until, 64),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const SPACE_STATE: Record<string, string> = {
|
||||
live: "使用中",
|
||||
building: "正在生成",
|
||||
stray: "待合并",
|
||||
retired: "已停用",
|
||||
};
|
||||
|
||||
/** `memhub models spaces [--json]`. */
|
||||
export async function listSpaces(io: SwitchIo): Promise<number> {
|
||||
const outcome = await io.reader<unknown>("/api/v1/embedding-spaces");
|
||||
if (outcome.state !== "ok") return reject(io, "models spaces", outcome.code);
|
||||
const rows = spaceRows(outcome.data);
|
||||
if (!rows) return reject(io, "models spaces", "embedding_spaces_invalid");
|
||||
const body = record(outcome.data) ?? {};
|
||||
const unregistered = Array.isArray(body.scanned)
|
||||
? body.scanned
|
||||
.map(record)
|
||||
.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
item !== null && item.state === "unregistered",
|
||||
)
|
||||
.map((item) => ({
|
||||
collection_base: str(item.collection_base) ?? "",
|
||||
vectors: int(item.vectors) ?? 0,
|
||||
}))
|
||||
: [];
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document("models spaces", {
|
||||
state: "ok",
|
||||
spaces: rows,
|
||||
unregistered,
|
||||
scan_error:
|
||||
typeof body.scan_error === "string" && CODE.test(body.scan_error)
|
||||
? body.scan_error
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
return EXIT_OK;
|
||||
}
|
||||
const lines = ["向量空间:"];
|
||||
for (const row of rows) {
|
||||
const dims = row.dims === null ? "维度未知" : `${row.dims} 维`;
|
||||
const keep =
|
||||
row.state === "retired" && row.keep_until
|
||||
? `,保留到 ${row.keep_until.slice(0, 10)}`
|
||||
: "";
|
||||
lines.push(
|
||||
` ${row.space_id} ${SPACE_STATE[row.state] ?? row.state} ${row.model ?? "未知模型"}(${dims}) ${row.vectors ?? "?"} 条${keep}`,
|
||||
);
|
||||
}
|
||||
for (const item of unregistered) {
|
||||
lines.push(
|
||||
` (未登记)${item.collection_base} ${item.vectors} 条,下次启动时合并`,
|
||||
);
|
||||
}
|
||||
if (rows.some((row) => row.state === "retired")) {
|
||||
lines.push(
|
||||
"清理已停用的空间:memhub models spaces --cleanup <space-id> --yes",
|
||||
);
|
||||
}
|
||||
io.emit(`${lines.join("\n")}\n`);
|
||||
return EXIT_OK;
|
||||
}
|
||||
|
||||
/** `memhub models spaces --cleanup <space-id> --yes`: drop a retired space now. */
|
||||
export async function cleanupSpace(
|
||||
spaceId: string,
|
||||
io: SwitchIo,
|
||||
): Promise<number> {
|
||||
if (!UUID.test(spaceId)) {
|
||||
return reject(io, "models spaces", "embedding_space_not_found");
|
||||
}
|
||||
const outcome = await io.operations.delete<unknown>(
|
||||
`/api/v1/embedding-spaces/${spaceId}`,
|
||||
);
|
||||
if (outcome.state !== "committed") {
|
||||
return reject(io, "models spaces", outcome.code);
|
||||
}
|
||||
if (io.asJson) {
|
||||
emitDocument(
|
||||
io,
|
||||
document("models spaces", { state: "dropped", space_id: spaceId }),
|
||||
);
|
||||
} else {
|
||||
io.emit(`已清理旧向量数据:${spaceId}\n`);
|
||||
}
|
||||
return EXIT_OK;
|
||||
}
|
||||
@@ -23,26 +23,39 @@ function accountHomeDirectory(): string | undefined {
|
||||
async function main(): Promise<number> {
|
||||
const arguments_ = process.argv.slice(2);
|
||||
const command = arguments_[0];
|
||||
const action = arguments_[1];
|
||||
// A preview (`--yes` absent) must not create or write any diagnostic file:
|
||||
// the contract is that it only emits a plan. Diagnostics for these commands
|
||||
// are initialized only once the caller has confirmed the mutation.
|
||||
const confirmed = arguments_.includes("--yes");
|
||||
const confirmed = arguments_.includes("--yes")
|
||||
? true
|
||||
: arguments_.includes("--confirm");
|
||||
const installLike = [
|
||||
"install",
|
||||
"upgrade",
|
||||
"service-install",
|
||||
"service-uninstall",
|
||||
].includes(command ?? "");
|
||||
const isPreview = installLike && !confirmed;
|
||||
const mutating = [
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"install",
|
||||
"upgrade",
|
||||
"service-install",
|
||||
"service-uninstall",
|
||||
].includes(command ?? "");
|
||||
const planFileApply =
|
||||
(command === "sources" && action === "import") ||
|
||||
(command === "models" && (action === "configure" || action === "probe")) ||
|
||||
(command === "processing" && action === "apply") ||
|
||||
(command === "memories" && action === "apply");
|
||||
const isPreview = (installLike || planFileApply) && !confirmed;
|
||||
const mutating =
|
||||
[
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"install",
|
||||
"upgrade",
|
||||
"service-install",
|
||||
"service-uninstall",
|
||||
].includes(command ?? "") ||
|
||||
(command === "sources" && action === "import") ||
|
||||
(command === "models" && ["configure", "probe"].includes(action ?? "")) ||
|
||||
(command === "processing" && action === "apply") ||
|
||||
(command === "memories" && action === "apply");
|
||||
let diagnostics: DiagnosticLogger | null = null;
|
||||
if (mutating && !isPreview) {
|
||||
try {
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* `memhub graph-width`: the user's decision when the embedding width changed
|
||||
* and the memory graph no longer matches it (`POST /api/v1/graph-width`).
|
||||
*
|
||||
* `keep_existing` and `export_first` change nothing irreversible and run
|
||||
* directly. `rebuild_with_new` archives the current graph and rebuilds it, so
|
||||
* the CLI asks for `--yes` before sending it.
|
||||
*/
|
||||
|
||||
import { parseGraphWidthDecisionReceipt } from "@one-memory/operations";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
export const GRAPH_WIDTH_DECISIONS = [
|
||||
"keep_existing",
|
||||
"rebuild_with_new",
|
||||
"export_first",
|
||||
] as const;
|
||||
export type GraphWidthDecision = (typeof GRAPH_WIDTH_DECISIONS)[number];
|
||||
|
||||
/** The one decision that cannot be taken back. */
|
||||
export function graphWidthDecisionIsDestructive(
|
||||
decision: GraphWidthDecision,
|
||||
): boolean {
|
||||
return decision === "rebuild_with_new";
|
||||
}
|
||||
|
||||
export type GraphWidthDocument =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "graph-width";
|
||||
state: "committed";
|
||||
decision: GraphWidthDecision;
|
||||
receipt: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "graph-width";
|
||||
state: "rejected" | "unknown";
|
||||
decision: GraphWidthDecision;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export async function decideGraphWidth(
|
||||
decision: GraphWidthDecision,
|
||||
operations: CoreOperations,
|
||||
): Promise<GraphWidthDocument> {
|
||||
const base = {
|
||||
schema_version: 1 as const,
|
||||
product: PRODUCT,
|
||||
command: "graph-width" as const,
|
||||
decision,
|
||||
};
|
||||
const result = await operations.post<unknown>("/api/v1/graph-width", {
|
||||
decision,
|
||||
});
|
||||
if (result.state !== "committed") {
|
||||
return { ...base, state: result.state, code: result.code };
|
||||
}
|
||||
const receipt = parseGraphWidthDecisionReceipt(result.data);
|
||||
if (receipt === null) {
|
||||
return { ...base, state: "unknown", code: "graph_width_receipt_invalid" };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: "committed",
|
||||
receipt: {
|
||||
archived_path: receipt.archived_path ?? null,
|
||||
exported_path: receipt.exported_path ?? null,
|
||||
mismatch: receipt.mismatch ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function graphWidthExitCode(document: GraphWidthDocument): number {
|
||||
return document.state === "committed" ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderGraphWidthText(document: GraphWidthDocument): string {
|
||||
if (document.state === "committed") {
|
||||
const lines = [`已执行图谱宽度决定:${document.decision}`];
|
||||
if (document.receipt.exported_path) {
|
||||
lines.push(`导出:${document.receipt.exported_path}`);
|
||||
}
|
||||
if (document.receipt.archived_path) {
|
||||
lines.push(`旧图谱已归档:${document.receipt.archived_path}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
return `图谱宽度决定未完成:${document.state}(${document.code})\n`;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { ImportJob, ModelProfile } from "@one-memory/contracts";
|
||||
import {
|
||||
buildModelProfileCreateRequest,
|
||||
@@ -26,7 +26,45 @@ const PROVIDERS = [
|
||||
"local_onnx",
|
||||
] as const;
|
||||
|
||||
interface SourceImportPlanBody {
|
||||
schema_version: 1;
|
||||
kind: "source_import";
|
||||
request_id: string;
|
||||
source_ids: string[];
|
||||
source_roots: Record<string, string>;
|
||||
source_candidate_revisions: Record<string, number>;
|
||||
confirm_environment_overrides: boolean;
|
||||
full_rescan: false;
|
||||
onboarding: true;
|
||||
}
|
||||
|
||||
export type SourceImportPlan = SourceImportPlanBody & { digest: string };
|
||||
|
||||
interface ModelCreatePlanBody {
|
||||
schema_version: 1;
|
||||
kind: "model_profile_create";
|
||||
operation_id: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ModelCreatePlan = ModelCreatePlanBody & { digest: string };
|
||||
|
||||
/** A removal only needs an id: there is no config to review, and inventing one
|
||||
* to fit the create shape would misrepresent what the command does.
|
||||
*/
|
||||
export interface ModelRemovePlan {
|
||||
schema_version: 1;
|
||||
kind: "model_profile_remove";
|
||||
target: string;
|
||||
}
|
||||
|
||||
export type FlowDocument =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "preview";
|
||||
plan: SourceImportPlan | ModelCreatePlan | ModelRemovePlan;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
@@ -95,33 +133,10 @@ function classifySourceImport(
|
||||
return committed(operationId, receipt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect sources and import their history in one direct request.
|
||||
*
|
||||
* Reads the current candidates, refuses a source whose location comes from an
|
||||
* environment override unless the caller confirmed overrides, and posts one
|
||||
* `/sources/connect` (grant + history import + live sync). `requestId` is the
|
||||
* idempotency key: re-running with the same id reads the first receipt.
|
||||
*/
|
||||
export async function connectSourcesWithImport(
|
||||
export async function planSourceImport(
|
||||
reader: CoreReader,
|
||||
operations: CoreOperations,
|
||||
input: {
|
||||
sourceIds: readonly string[];
|
||||
confirmOverrides: boolean;
|
||||
requestId?: string;
|
||||
/** Whose grant this is. Omitted for the default user (`local`). */
|
||||
userId?: string;
|
||||
/** An absolute root other than the source's candidate (a Hermes
|
||||
* profile). Core reads its identity itself, so the source's candidate
|
||||
* root id and revision are not sent for it. */
|
||||
root?: string;
|
||||
},
|
||||
sourceIds: readonly string[],
|
||||
): Promise<FlowDocument> {
|
||||
const sourceIds = input.sourceIds;
|
||||
// A second user's grant is not a first run, and a custom root has no
|
||||
// candidate the app showed: both are a plain connect.
|
||||
const profile = input.root !== undefined || input.userId !== undefined;
|
||||
if (
|
||||
sourceIds.length < 1 ||
|
||||
sourceIds.length > 32 ||
|
||||
@@ -130,8 +145,6 @@ export async function connectSourcesWithImport(
|
||||
) {
|
||||
return invalid("invalid_source_selection");
|
||||
}
|
||||
const requestId = input.requestId ?? randomUUID();
|
||||
if (!UUID.test(requestId)) return invalid("invalid_request_id");
|
||||
const outcome = await reader<unknown>("/api/v1/sources");
|
||||
if (outcome.state !== "ok") return fromReadFailure(outcome);
|
||||
const candidates = validateSourceConnectionCandidates(outcome.data);
|
||||
@@ -143,72 +156,69 @@ export async function connectSourcesWithImport(
|
||||
return invalid("source_unavailable");
|
||||
}
|
||||
const items = selected.map(([candidate]) => candidate);
|
||||
// A custom root is the caller's own choice, not the environment override
|
||||
// the confirmation exists for.
|
||||
const needsOverride =
|
||||
input.root !== undefined
|
||||
? []
|
||||
: items
|
||||
.filter((item) => item.requires_override_confirmation)
|
||||
.map((item) => item.source_id);
|
||||
if (needsOverride.length > 0 && !input.confirmOverrides) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "rejected",
|
||||
operation_id: requestId,
|
||||
code: "environment_override_confirmation_required",
|
||||
receipt: { override_required_source_ids: needsOverride },
|
||||
};
|
||||
}
|
||||
const body: SourceImportPlanBody = {
|
||||
schema_version: 1,
|
||||
kind: "source_import",
|
||||
request_id: randomUUID(),
|
||||
source_ids: [...sourceIds],
|
||||
source_roots: Object.fromEntries(
|
||||
items.map((item) => [item.source_id, item.candidate_root_id]),
|
||||
),
|
||||
source_candidate_revisions: Object.fromEntries(
|
||||
items.flatMap((item) =>
|
||||
item.candidate_revision === undefined
|
||||
? []
|
||||
: [[item.source_id, item.candidate_revision]],
|
||||
),
|
||||
),
|
||||
confirm_environment_overrides: items.some(
|
||||
(item) => item.requires_override_confirmation,
|
||||
),
|
||||
full_rescan: false,
|
||||
onboarding: true,
|
||||
};
|
||||
return preview(withDigest(body));
|
||||
}
|
||||
|
||||
export async function executeSourceImport(
|
||||
value: unknown,
|
||||
reader: CoreReader,
|
||||
operations: CoreOperations,
|
||||
): Promise<FlowDocument> {
|
||||
const plan = validateSourcePlan(value);
|
||||
if (!plan) return invalid("source_plan_invalid");
|
||||
// The shared builder owns the connect body's field set and coalescing, so
|
||||
// the CLI and the Web console send the same request by construction.
|
||||
let request: ReturnType<typeof buildSourceImportRequest>;
|
||||
try {
|
||||
request = buildSourceImportRequest({
|
||||
sourceIds: [...sourceIds],
|
||||
sourceRoots: Object.fromEntries(
|
||||
items.flatMap((item) =>
|
||||
input.root === undefined
|
||||
? [[item.source_id, item.candidate_root_id]]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
sourceCandidateRevisions: Object.fromEntries(
|
||||
items.flatMap((item) =>
|
||||
item.candidate_revision === undefined || input.root !== undefined
|
||||
? []
|
||||
: [[item.source_id, item.candidate_revision]],
|
||||
),
|
||||
),
|
||||
confirmEnvironmentOverrides: needsOverride.length > 0,
|
||||
fullRescan: false,
|
||||
onboarding: !profile,
|
||||
sourceIds: plan.source_ids,
|
||||
sourceRoots: plan.source_roots,
|
||||
sourceCandidateRevisions: plan.source_candidate_revisions,
|
||||
confirmEnvironmentOverrides: plan.confirm_environment_overrides,
|
||||
fullRescan: plan.full_rescan,
|
||||
onboarding: plan.onboarding,
|
||||
enableSync: true,
|
||||
requestId,
|
||||
requestId: plan.request_id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof SourceImportInputError) {
|
||||
return invalid("invalid_source_selection");
|
||||
return invalid("source_plan_invalid");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await operations.post<unknown>(request.path, {
|
||||
...request.body,
|
||||
...(input.userId === undefined ? {} : { user_id: input.userId }),
|
||||
...(input.root === undefined ? {} : { root: input.root }),
|
||||
});
|
||||
const result = await operations.post<unknown>(request.path, request.body);
|
||||
if (result.state === "rejected") {
|
||||
return rejected(requestId, result.code);
|
||||
return rejected(plan.request_id, result.code);
|
||||
}
|
||||
if (result.state === "committed") {
|
||||
const receipt = validateSourceReceipt(result.data, requestId);
|
||||
if (!receipt) return unknown(requestId, "source_receipt_invalid");
|
||||
return classifySourceImport(requestId, receipt);
|
||||
const receipt = validateSourceReceipt(result.data, plan.request_id);
|
||||
if (!receipt) return unknown(plan.request_id, "source_receipt_invalid");
|
||||
return classifySourceImport(plan.request_id, receipt);
|
||||
}
|
||||
const recovered = await readSourceReceipt(reader, requestId);
|
||||
const recovered = await readSourceReceipt(reader, plan.request_id);
|
||||
if (recovered.state === "unknown" || recovered.state === "not_found") {
|
||||
return unknown(requestId, result.code);
|
||||
return unknown(plan.request_id, result.code);
|
||||
}
|
||||
// A recovered receipt must be classified the same way as a fresh one: an
|
||||
// all-rejected or all-busy replay is still not success. Only a terminal
|
||||
@@ -252,23 +262,26 @@ export async function readSourceReceipt(
|
||||
return committed(requestId, receipt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a model profile directly from a bounded config (never containing the
|
||||
* key) plus an optional key read from a controlled file.
|
||||
*
|
||||
* `operationId` is Core's creation idempotency key; the receipt can be read
|
||||
* later with `readModelReceipt`.
|
||||
*/
|
||||
export async function createModelProfile(
|
||||
export function planModelProfile(value: unknown): FlowDocument {
|
||||
const config = validateModelConfig(value);
|
||||
if (!config) return invalid("model_config_invalid");
|
||||
const body: ModelCreatePlanBody = {
|
||||
schema_version: 1,
|
||||
kind: "model_profile_create",
|
||||
operation_id: randomUUID(),
|
||||
config,
|
||||
};
|
||||
return preview(withDigest(body));
|
||||
}
|
||||
|
||||
export async function executeModelProfile(
|
||||
value: unknown,
|
||||
apiKey: string | undefined,
|
||||
reader: CoreReader,
|
||||
operations: CoreOperations,
|
||||
operationId: string = randomUUID(),
|
||||
): Promise<FlowDocument> {
|
||||
const config = validateModelConfig(value);
|
||||
if (!config) return invalid("model_config_invalid");
|
||||
if (!UUID.test(operationId)) return invalid("invalid_operation_id");
|
||||
const plan = validateModelPlan(value);
|
||||
if (!plan) return invalid("model_plan_invalid");
|
||||
// The shared create builder owns the body's allow-list, bounds and the
|
||||
// creation_operation_id/api_key handling, so the CLI and the Web console
|
||||
// send the same create request.
|
||||
@@ -276,31 +289,31 @@ export async function createModelProfile(
|
||||
try {
|
||||
request = buildModelProfileCreateRequest(
|
||||
{
|
||||
...config,
|
||||
...(plan.config as Record<string, unknown>),
|
||||
...(apiKey === undefined ? {} : { api_key: apiKey }),
|
||||
} as Parameters<typeof buildModelProfileCreateRequest>[0],
|
||||
operationId,
|
||||
plan.operation_id,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ModelProfileInputError) {
|
||||
return invalid("model_config_invalid");
|
||||
return invalid("model_plan_invalid");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await operations.post<unknown>(request.path, request.body);
|
||||
if (result.state === "rejected") {
|
||||
return rejected(operationId, result.code);
|
||||
return rejected(plan.operation_id, result.code);
|
||||
}
|
||||
if (result.state === "committed") {
|
||||
const profile = validateModelProfile(result.data);
|
||||
return profile
|
||||
? committed(operationId, projectProfile(profile))
|
||||
: unknown(operationId, "model_receipt_invalid");
|
||||
? committed(plan.operation_id, projectProfile(profile))
|
||||
: unknown(plan.operation_id, "model_receipt_invalid");
|
||||
}
|
||||
const recovered = await readModelReceipt(reader, operationId);
|
||||
const recovered = await readModelReceipt(reader, plan.operation_id);
|
||||
return recovered.state === "committed"
|
||||
? { ...recovered, state: "recovered" }
|
||||
: unknown(operationId, result.code);
|
||||
: unknown(plan.operation_id, result.code);
|
||||
}
|
||||
|
||||
export async function readModelReceipt(
|
||||
@@ -349,6 +362,27 @@ export function parseApiKey(bytes: Buffer | null): string | undefined {
|
||||
}
|
||||
|
||||
export function renderFlowText(document: FlowDocument): string {
|
||||
if (document.state === "preview") {
|
||||
const plan = document.plan;
|
||||
// A removal has no config digest or operation id to confirm: the target id
|
||||
// *is* the thing being reviewed. Rendering fields it does not have would
|
||||
// print "undefined".
|
||||
if (plan.kind === "model_profile_remove") {
|
||||
return [
|
||||
`预览:${plan.kind}`,
|
||||
`目标配置:${plan.target}`,
|
||||
"未执行任何写操作。加 --confirm 才会真正删除。",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
`预览:${plan.kind}`,
|
||||
`操作 ID:${"request_id" in plan ? plan.request_id : plan.operation_id}`,
|
||||
`确认摘要:${plan.digest}`,
|
||||
"未执行任何写操作。请把 --json 输出保存为 plan 文件,再用对应命令加 --confirm。",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
const lines = [
|
||||
`操作状态:${document.state}`,
|
||||
`操作 ID:${document.operation_id ?? "无"}`,
|
||||
@@ -373,20 +407,98 @@ export function renderFlowText(document: FlowDocument): string {
|
||||
if (document.state === "partial" && Array.isArray(accepted)) {
|
||||
lines.push(` 已接入:${accepted.join("、")}`);
|
||||
}
|
||||
const overrides = document.receipt.override_required_source_ids;
|
||||
if (Array.isArray(overrides)) {
|
||||
lines.push(
|
||||
` 这些来源的目录来自环境变量覆盖,需要确认:${overrides.join("、")}`,
|
||||
" 确认目录无误后加 --confirm-overrides 重新运行。",
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function flowExitCode(document: FlowDocument): number {
|
||||
return ["committed", "recovered"].includes(document.state) ? 0 : 1;
|
||||
return ["preview", "committed", "recovered"].includes(document.state) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function inspectExecutionPlan(value: unknown): {
|
||||
kind: "source_import" | "model_profile_create";
|
||||
operationId: string;
|
||||
digest: string;
|
||||
} | null {
|
||||
const source = validateSourcePlan(value);
|
||||
if (source) {
|
||||
return {
|
||||
kind: source.kind,
|
||||
operationId: source.request_id,
|
||||
digest: source.digest,
|
||||
};
|
||||
}
|
||||
const model = validateModelPlan(value);
|
||||
return model
|
||||
? {
|
||||
kind: model.kind,
|
||||
operationId: model.operation_id,
|
||||
digest: model.digest,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function validateSourcePlan(value: unknown): SourceImportPlan | null {
|
||||
const plan = record(value);
|
||||
const requestId = asString(plan?.request_id);
|
||||
if (
|
||||
plan?.schema_version !== 1 ||
|
||||
plan.kind !== "source_import" ||
|
||||
!UUID.test(requestId) ||
|
||||
!Array.isArray(plan.source_ids) ||
|
||||
plan.source_ids.length < 1 ||
|
||||
plan.source_ids.length > 32 ||
|
||||
plan.source_ids.some(
|
||||
(id) => typeof id !== "string" || !SOURCE_ID.test(id),
|
||||
) ||
|
||||
new Set(plan.source_ids).size !== plan.source_ids.length ||
|
||||
plan.full_rescan !== false ||
|
||||
plan.onboarding !== true ||
|
||||
typeof plan.confirm_environment_overrides !== "boolean" ||
|
||||
typeof plan.digest !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const sourceIds = plan.source_ids as string[];
|
||||
const roots = stringMap(plan.source_roots, sourceIds);
|
||||
const revisions = numberMap(plan.source_candidate_revisions, sourceIds);
|
||||
if (!roots || !revisions) return null;
|
||||
const body: SourceImportPlanBody = {
|
||||
schema_version: 1,
|
||||
kind: "source_import",
|
||||
request_id: requestId,
|
||||
source_ids: [...sourceIds],
|
||||
source_roots: roots,
|
||||
source_candidate_revisions: revisions,
|
||||
confirm_environment_overrides: plan.confirm_environment_overrides,
|
||||
full_rescan: false,
|
||||
onboarding: true,
|
||||
};
|
||||
const expected = withDigest(body);
|
||||
return timingSafeText(expected.digest, plan.digest) ? expected : null;
|
||||
}
|
||||
|
||||
function validateModelPlan(value: unknown): ModelCreatePlan | null {
|
||||
const plan = record(value);
|
||||
const operationId = asString(plan?.operation_id);
|
||||
if (
|
||||
plan?.schema_version !== 1 ||
|
||||
plan.kind !== "model_profile_create" ||
|
||||
!UUID.test(operationId) ||
|
||||
typeof plan.digest !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const config = validateModelConfig(plan.config);
|
||||
if (!config) return null;
|
||||
const expected = withDigest<ModelCreatePlanBody>({
|
||||
schema_version: 1,
|
||||
kind: "model_profile_create",
|
||||
operation_id: operationId,
|
||||
config,
|
||||
});
|
||||
return timingSafeText(expected.digest, plan.digest) ? expected : null;
|
||||
}
|
||||
|
||||
function validateModelConfig(value: unknown): Record<string, unknown> | null {
|
||||
@@ -532,6 +644,37 @@ function projectJob(job: ImportJob): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function withDigest<T extends object>(body: T): T & { digest: string } {
|
||||
return { ...body, digest: digest(body) };
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return createHash("sha256").update(canonical(value)).digest("hex");
|
||||
}
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map(
|
||||
(key) =>
|
||||
`${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key])}`,
|
||||
)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function preview(plan: SourceImportPlan | ModelCreatePlan): FlowDocument {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "preview",
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
function committed(
|
||||
operationId: string,
|
||||
receipt: Record<string, unknown>,
|
||||
@@ -624,3 +767,43 @@ function optionalInteger(
|
||||
(value as number) <= maximum)
|
||||
);
|
||||
}
|
||||
|
||||
function stringMap(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
): Record<string, string> | null {
|
||||
const map = record(value);
|
||||
if (
|
||||
!map ||
|
||||
Object.keys(map).length !== keys.length ||
|
||||
keys.some((key) => !boundedText(map[key], 256))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.fromEntries(keys.map((key) => [key, map[key] as string]));
|
||||
}
|
||||
|
||||
function numberMap(
|
||||
value: unknown,
|
||||
allowedKeys: readonly string[],
|
||||
): Record<string, number> | null {
|
||||
const map = record(value);
|
||||
if (
|
||||
!map ||
|
||||
Object.entries(map).some(
|
||||
([key, revision]) =>
|
||||
!allowedKeys.includes(key) ||
|
||||
!Number.isSafeInteger(revision) ||
|
||||
(revision as number) < 0,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(map).map(([key, revision]) => [key, revision as number]),
|
||||
);
|
||||
}
|
||||
|
||||
function timingSafeText(expected: string, actual: unknown): boolean {
|
||||
return typeof actual === "string" && expected === actual;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { initPlanAttemptPath } from "./cli.js";
|
||||
import { interactiveInitAttemptPath } from "./init-attempt.js";
|
||||
|
||||
// Regression: `init --source <id> --import` used to pass the literal string
|
||||
// `"init"` to `claimPlanAttempt`, which resolved to `<cwd>/init.one-memory-attempt`.
|
||||
// Two consequences: the fence was shared across unrelated runs in the same
|
||||
// directory, and any unrelated file named `init` could make a valid import
|
||||
// report a misleading usage error (exit 2) instead of reading its receipt.
|
||||
describe("init import attempt fence", () => {
|
||||
it("lives under the data dir, not the current working directory", () => {
|
||||
const fence = initPlanAttemptPath(
|
||||
"/tmp/example data",
|
||||
"11111111-2222-4333-8444-555555555555",
|
||||
);
|
||||
expect(fence).toBe(
|
||||
path.join(
|
||||
"/tmp/example data",
|
||||
".cli-init-attempt-11111111-2222-4333-8444-555555555555",
|
||||
),
|
||||
);
|
||||
expect(path.isAbsolute(fence)).toBe(true);
|
||||
expect(fence).not.toBe("init");
|
||||
// Directly under the data dir: no nested directory that could fail to be
|
||||
// created and turn a valid import into a usage error.
|
||||
expect(path.dirname(fence)).toBe("/tmp/example data");
|
||||
});
|
||||
|
||||
it("is per data dir and per operation, so installs/runs cannot collide", () => {
|
||||
const a = initPlanAttemptPath("/tmp/data-a", "op-1");
|
||||
const b = initPlanAttemptPath("/tmp/data-b", "op-1");
|
||||
const c = initPlanAttemptPath("/tmp/data-a", "op-2");
|
||||
expect(new Set([a, b, c]).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// QA-CLI R3 F1: the interactive wizard passed a CWD-relative singleton
|
||||
// (`init` or `<journalDir>/init`) to `claimPlanAttempt`. A stale
|
||||
// `init.one-memory-attempt` in the working directory then made every later
|
||||
// `memhub init` fail with "交互计划无效 / exit 2".
|
||||
describe("interactive init attempt fence", () => {
|
||||
it("lives under the data dir and is unique per operation", () => {
|
||||
const fence = interactiveInitAttemptPath(
|
||||
"/tmp/data",
|
||||
"11111111-2222-4333-8444-555555555555",
|
||||
);
|
||||
expect(fence).toBe(
|
||||
path.join(
|
||||
"/tmp/data",
|
||||
".cli-init-attempt-11111111-2222-4333-8444-555555555555",
|
||||
),
|
||||
);
|
||||
expect(path.isAbsolute(fence)).toBe(true);
|
||||
expect(fence).not.toBe("init");
|
||||
});
|
||||
|
||||
it("does not reuse one fence for two distinct plans", () => {
|
||||
const first = interactiveInitAttemptPath("/tmp/data", "op-1");
|
||||
const second = interactiveInitAttemptPath("/tmp/data", "op-2");
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from "node:path";
|
||||
|
||||
/** Where the interactive `init` wizard keeps its durable attempt fence.
|
||||
*
|
||||
* The wizard originally passed a CWD-relative singleton (`init`) to
|
||||
* `claimPlanAttempt`. That shared one fence across unrelated runs and let a
|
||||
* stale `init.one-memory-attempt` in the working directory turn every later
|
||||
* wizard run into "交互计划无效 / exit 2". Keep the fence under the Runtime's
|
||||
* data dir, keyed by the operation id, exactly like `init --source --import`.
|
||||
*/
|
||||
export function interactiveInitAttemptPath(
|
||||
dataDir: string,
|
||||
operationId: string,
|
||||
): string {
|
||||
return path.join(dataDir, `.cli-init-attempt-${operationId}`);
|
||||
}
|
||||
@@ -418,9 +418,9 @@ describe("A2 dual-mode init", () => {
|
||||
});
|
||||
|
||||
// QA-CLI R3 F1: a stale CWD `init.one-memory-attempt` used to poison every
|
||||
// later wizard run. There are no plan files or attempt fences any more: the
|
||||
// connect request id is the idempotency key Core keeps.
|
||||
it("ignores a stale CWD init fence and writes no fence files", async () => {
|
||||
// later wizard run. The fence now lives under the isolated data dir and is
|
||||
// keyed by the operation id.
|
||||
it("ignores a stale CWD init fence and keeps its fence under the data dir", async () => {
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/sources"
|
||||
? { status: 200, data: [candidate("codex", "Codex")] }
|
||||
@@ -471,7 +471,7 @@ describe("A2 dual-mode init", () => {
|
||||
const dataEntries = await readdir(dataDir);
|
||||
expect(
|
||||
dataEntries.some((entry) => entry.startsWith(".cli-init-attempt-")),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps selected Harnesses frozen without creating history jobs", async () => {
|
||||
@@ -506,7 +506,7 @@ describe("A2 dual-mode init", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("A19 sources connect --all-detected (old spelling: init --all-detected)", () => {
|
||||
describe("A19 init --all-detected", () => {
|
||||
it("connects every found source in one batch call without prompting", async () => {
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/sources"
|
||||
@@ -556,7 +556,7 @@ describe("A19 sources connect --all-detected (old spelling: init --all-detected)
|
||||
expect(prompter.asked).toBe(0);
|
||||
expect(code).toBe(0);
|
||||
const document = JSON.parse(streams.value());
|
||||
expect(document.command).toBe("sources connect");
|
||||
expect(document.command).toBe("init");
|
||||
expect(document.state).toBe("committed");
|
||||
expect(
|
||||
document.sources.map((row: { source_id: string }) => row.source_id),
|
||||
|
||||
@@ -4,11 +4,18 @@ import type {
|
||||
CoreDeleteOperations,
|
||||
CoreOperations,
|
||||
} from "./core-operations.js";
|
||||
import { createModelProfile, type FlowDocument } from "./headless-flows.js";
|
||||
import {
|
||||
executeModelProfile,
|
||||
type FlowDocument,
|
||||
planModelProfile,
|
||||
} from "./headless-flows.js";
|
||||
import { approvalFingerprint, listHosts, readHost } from "./host-flow.js";
|
||||
import { parseSelection, type WizardPrompter } from "./init-wizard.js";
|
||||
import { startModelProbe } from "./model-probe-flow.js";
|
||||
import { activateModelProfile } from "./model-write-flows.js";
|
||||
import { executeModelProbe, planModelProbe } from "./model-probe-flow.js";
|
||||
import {
|
||||
executeModelProfileActivate,
|
||||
planModelProfileActivate,
|
||||
} from "./model-write-flows.js";
|
||||
|
||||
type ProviderPreset = {
|
||||
id: string;
|
||||
@@ -352,26 +359,30 @@ export async function runInteractiveModelSetup(
|
||||
);
|
||||
})
|
||||
: new Map<string, string>();
|
||||
const created = await createModelProfile(
|
||||
{
|
||||
name: preset.label,
|
||||
provider_kind: preset.provider_kind,
|
||||
base_url: baseUrl,
|
||||
model,
|
||||
embedding_provider_kind: "local_onnx",
|
||||
embedding_model: "bge-small-zh-v1.5",
|
||||
embedding_dimensions: 512,
|
||||
timeout_seconds: 90,
|
||||
max_concurrency: 4,
|
||||
disable_thinking: true,
|
||||
},
|
||||
const planned = planModelProfile({
|
||||
name: preset.label,
|
||||
provider_kind: preset.provider_kind,
|
||||
base_url: baseUrl,
|
||||
model,
|
||||
embedding_provider_kind: "local_onnx",
|
||||
embedding_model: "bge-small-zh-v1.5",
|
||||
embedding_dimensions: 512,
|
||||
timeout_seconds: 90,
|
||||
max_concurrency: 4,
|
||||
disable_thinking: true,
|
||||
});
|
||||
if (
|
||||
planned.state !== "preview" ||
|
||||
planned.plan.kind !== "model_profile_create"
|
||||
) {
|
||||
return { state: "failed", code: "model_config_invalid" };
|
||||
}
|
||||
const created = await executeModelProfile(
|
||||
planned.plan,
|
||||
apiKey,
|
||||
clients.reader,
|
||||
clients.operations,
|
||||
);
|
||||
if (created.state === "invalid") {
|
||||
return { state: "failed", code: "model_config_invalid" };
|
||||
}
|
||||
const profile = receiptProfile(created);
|
||||
if (profile === null) {
|
||||
prompter.write("模型配置未保存。");
|
||||
@@ -380,11 +391,18 @@ export async function runInteractiveModelSetup(
|
||||
}
|
||||
|
||||
try {
|
||||
const probed = await startModelProbe(
|
||||
const probePlan = await planModelProbe(
|
||||
clients.reader,
|
||||
clients.operations,
|
||||
profile.profile_id,
|
||||
);
|
||||
const probed =
|
||||
probePlan.state === "preview"
|
||||
? await executeModelProbe(
|
||||
probePlan.plan,
|
||||
clients.reader,
|
||||
clients.operations,
|
||||
)
|
||||
: probePlan;
|
||||
if (!probePassed(probed)) {
|
||||
await clients.operations.delete(
|
||||
`/api/v1/model-profiles/${encodeURIComponent(profile.profile_id)}`,
|
||||
@@ -403,8 +421,17 @@ export async function runInteractiveModelSetup(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const activated = await activateModelProfile(
|
||||
profile.profile_id,
|
||||
const activation = planModelProfileActivate({
|
||||
profileId: profile.profile_id,
|
||||
});
|
||||
if (
|
||||
activation.state !== "preview" ||
|
||||
activation.plan.kind !== "model_profile_activate"
|
||||
) {
|
||||
return { state: "failed", code: "model_activate_plan_invalid" };
|
||||
}
|
||||
const activated = await executeModelProfileActivate(
|
||||
activation.plan,
|
||||
clients.operations,
|
||||
);
|
||||
if (activated.state !== "committed") {
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("memhub jobs", () => {
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("start posts directly, without a preview step", async () => {
|
||||
it("start previews without a POST", async () => {
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
@@ -110,14 +110,14 @@ describe("memhub jobs", () => {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/import-jobs",
|
||||
{ source_id: "codex", operation: "history" },
|
||||
undefined,
|
||||
);
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
state: "preview",
|
||||
plan: { source_id: "codex", operation: "history" },
|
||||
});
|
||||
});
|
||||
|
||||
it("start still accepts a legacy --confirm", async () => {
|
||||
it("start --confirm posts the exact Core request", async () => {
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
@@ -146,7 +146,7 @@ describe("memhub jobs", () => {
|
||||
});
|
||||
|
||||
it.each(["pause", "resume", "cancel"] as const)(
|
||||
"%s posts the action endpoint directly",
|
||||
"%s previews without a POST",
|
||||
async (action) => {
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
@@ -156,11 +156,11 @@ describe("memhub jobs", () => {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
`/api/v1/import-jobs/job-1/${action}`,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
state: "preview",
|
||||
plan: { job_id: "job-1", action },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm, symlink } from "node:fs/promises";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DiagnosticLogger } from "@one-memory/runtime-control";
|
||||
@@ -41,15 +41,11 @@ it("redacts unsafe details and absolute stack frames from the read surface", ()
|
||||
session_id: "11111111-1111-4111-8111-111111111111",
|
||||
details: {
|
||||
count: 1,
|
||||
reason: "/Users/me/private-query.txt",
|
||||
status: "sk-live-secret-token",
|
||||
source_id: "credential.private",
|
||||
prompt: "secret-token-123",
|
||||
authorization: "Bearer secret-token-123",
|
||||
} as unknown as Record<string, string | number | boolean | null>,
|
||||
safe_stack: [
|
||||
"packages/runtime-control/src/example.ts:42:7",
|
||||
"packages/runtime/sk-live-secret-token.ts:1:1",
|
||||
"/Users/me/private.py:1",
|
||||
"C:\\Users\\me\\private.py:1",
|
||||
],
|
||||
@@ -60,8 +56,6 @@ it("redacts unsafe details and absolute stack frames from the read surface", ()
|
||||
"packages/runtime-control/src/example.ts:42:7",
|
||||
]);
|
||||
expect(JSON.stringify(redacted)).not.toContain("secret-token-123");
|
||||
expect(JSON.stringify(redacted)).not.toContain("sk-live-secret-token");
|
||||
expect(JSON.stringify(redacted)).not.toContain("credential.private");
|
||||
expect(JSON.stringify(redacted)).not.toContain("/Users/me");
|
||||
expect(JSON.stringify(redacted)).not.toContain("C:\\\\Users");
|
||||
});
|
||||
@@ -138,27 +132,3 @@ it("follows a newly appended event without stopping Runtime", async () => {
|
||||
});
|
||||
expect(seen).toEqual(["service.ready", "core.failed"]);
|
||||
});
|
||||
|
||||
it("fails follow mode when the diagnostic directory is unsafe", async () => {
|
||||
const dataDir = await mkdtemp(
|
||||
path.join(os.tmpdir(), "om-cli-follow-unsafe-"),
|
||||
);
|
||||
const external = await mkdtemp(
|
||||
path.join(os.tmpdir(), "om-cli-follow-external-"),
|
||||
);
|
||||
roots.push(dataDir, external);
|
||||
await symlink(external, path.join(dataDir, "logs"));
|
||||
const query = parseLogQuery(["--follow"]);
|
||||
expect(query).not.toBeNull();
|
||||
if (!query) throw new Error("query_missing");
|
||||
await expect(
|
||||
followLogs({
|
||||
dataDir,
|
||||
query,
|
||||
signal: new AbortController().signal,
|
||||
maxEvents: 1,
|
||||
emit: () => undefined,
|
||||
wait: async () => undefined,
|
||||
}),
|
||||
).rejects.toThrow("log_directory_unreadable");
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import type { DiagnosticEventV1 } from "@one-memory/runtime-control";
|
||||
import {
|
||||
diagnosticLogFiles,
|
||||
projectDiagnosticEventForSafeOutput,
|
||||
readDiagnosticEvents,
|
||||
} from "@one-memory/runtime-control";
|
||||
|
||||
@@ -25,6 +24,54 @@ export interface LogQuery {
|
||||
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
||||
const NAME = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+){0,15}$/;
|
||||
const SAFE_DETAIL_KEYS = new Set([
|
||||
"action",
|
||||
"attempt",
|
||||
"count",
|
||||
"method",
|
||||
"mode",
|
||||
"phase",
|
||||
"platform",
|
||||
"reason",
|
||||
"result_count",
|
||||
"source_id",
|
||||
"spawned",
|
||||
"status",
|
||||
"version",
|
||||
"parent_session_id",
|
||||
"invalid_lines",
|
||||
"truncated",
|
||||
"runtime_state",
|
||||
"core_state",
|
||||
"broker_state",
|
||||
"instance_id",
|
||||
"open_file_count",
|
||||
"memory_bytes",
|
||||
"thread_count",
|
||||
"database_error_count",
|
||||
"file_count",
|
||||
"sqlite_file_count",
|
||||
"byte_file_count",
|
||||
"total_bytes",
|
||||
"copied_bytes",
|
||||
"bytes_per_second",
|
||||
"stored_count",
|
||||
"revised_count",
|
||||
"skipped_count",
|
||||
"error_count",
|
||||
"pending_count",
|
||||
"prepared_count",
|
||||
"queue_depth",
|
||||
"in_flight_count",
|
||||
"call_count",
|
||||
"created_count",
|
||||
"updated_count",
|
||||
"superseded_count",
|
||||
"deleted_count",
|
||||
"operation_kind",
|
||||
"file_kind",
|
||||
]);
|
||||
|
||||
export function parseLogQuery(arguments_: readonly string[]): LogQuery | null {
|
||||
if (arguments_.filter((item) => item === "--json").length > 1) return null;
|
||||
const values: Partial<LogQuery> = { errorsOnly: false, follow: false };
|
||||
@@ -107,11 +154,7 @@ export async function readLogReport(
|
||||
query: LogQuery,
|
||||
): Promise<{
|
||||
events: DiagnosticEventV1[];
|
||||
/** Lines that are not JSON: the only ones `memhub logs` calls damaged. */
|
||||
invalid_lines: number;
|
||||
/** Whole JSON lines the reader leaves out, such as an older build's
|
||||
* records. Nothing is wrong with them, so the text output says nothing. */
|
||||
unrecognized_lines: number;
|
||||
failure?: "log_directory_unreadable";
|
||||
}> {
|
||||
// `readDiagnosticEvents` treats an unreadable logs path as "no events". That
|
||||
@@ -123,7 +166,6 @@ export async function readLogReport(
|
||||
return {
|
||||
events: [],
|
||||
invalid_lines: 0,
|
||||
unrecognized_lines: 0,
|
||||
failure: "log_directory_unreadable",
|
||||
};
|
||||
}
|
||||
@@ -134,7 +176,6 @@ export async function readLogReport(
|
||||
return {
|
||||
events: [],
|
||||
invalid_lines: 0,
|
||||
unrecognized_lines: 0,
|
||||
failure: "log_directory_unreadable",
|
||||
};
|
||||
}
|
||||
@@ -196,13 +237,63 @@ export function renderLogText(
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/** Defence in depth for the CLI read surface. Local writers accept arbitrary
|
||||
* bounded scalar keys, so every outward read surface must reuse the same
|
||||
* field-and-value projection as the support-bundle exporter. */
|
||||
/** Defence in depth for the CLI read surface. `DiagnosticLogger` already
|
||||
* allow-lists keys, but a future writer, a manually edited log, or a malformed
|
||||
* line must not turn `memhub logs` into an exfiltration path. */
|
||||
export function redactDiagnosticEvent(
|
||||
event: DiagnosticEventV1,
|
||||
): DiagnosticEventV1 {
|
||||
return projectDiagnosticEventForSafeOutput(event);
|
||||
const redacted: DiagnosticEventV1 = {
|
||||
schema_version: 1,
|
||||
timestamp: event.timestamp,
|
||||
level: event.level,
|
||||
service: event.service,
|
||||
component: event.component,
|
||||
event: event.event,
|
||||
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.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.error_code === undefined ? {} : { error_code: event.error_code }),
|
||||
...(event.error_type === undefined ? {} : { error_type: event.error_type }),
|
||||
...(event.safe_stack === undefined
|
||||
? {}
|
||||
: {
|
||||
safe_stack: event.safe_stack.filter(
|
||||
(frame) =>
|
||||
!frame.startsWith("/") &&
|
||||
!/^[A-Za-z]:\\/.test(frame) &&
|
||||
!frame.includes(".."),
|
||||
),
|
||||
}),
|
||||
};
|
||||
const details = Object.fromEntries(
|
||||
Object.entries(event.details ?? {}).filter(([key]) =>
|
||||
SAFE_DETAIL_KEYS.has(key),
|
||||
),
|
||||
);
|
||||
if (Object.keys(details).length > 0) redacted.details = details;
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export async function followLogs(options: {
|
||||
@@ -224,7 +315,6 @@ export async function followLogs(options: {
|
||||
...options.query,
|
||||
tail: 5_000,
|
||||
});
|
||||
if (report.failure) throw new Error(report.failure);
|
||||
for (const event of report.events) {
|
||||
const identity = JSON.stringify(event);
|
||||
if (seen.has(identity)) continue;
|
||||
|
||||
@@ -1,583 +0,0 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
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,
|
||||
}));
|
||||
|
||||
const REQUEST = "33333333-3333-4333-8333-333333333333";
|
||||
const EVIDENCE = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
function addReceipt(state = "pending", memoryIds: string[] = []) {
|
||||
return {
|
||||
evidence_id: EVIDENCE,
|
||||
text: "Prefers tea",
|
||||
scope: "all-apps",
|
||||
created_at: "2026-10-27T00:00:00Z",
|
||||
state,
|
||||
task_id: null,
|
||||
memory_ids: memoryIds,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function memoryRecord(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
memory_id: "exp-1",
|
||||
content: "deploy\nrun make",
|
||||
layer: "experience",
|
||||
status: "active",
|
||||
is_latest: true,
|
||||
supersedes: [],
|
||||
superseded_by: [],
|
||||
tags: [],
|
||||
owner: null,
|
||||
source_raw_ids: [],
|
||||
user_id: "local",
|
||||
agent_id: "",
|
||||
session_id: null,
|
||||
source: "codebuddy",
|
||||
memory_at: null,
|
||||
gmt_created: 1_790_000_000,
|
||||
scene: "working",
|
||||
kind: "experience",
|
||||
variant: "working",
|
||||
task: "deploy",
|
||||
solution: "run make",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
transport.get.mockReset();
|
||||
transport.post.mockReset();
|
||||
transport.put.mockReset();
|
||||
transport.patch.mockReset();
|
||||
transport.delete.mockReset();
|
||||
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("memories add", () => {
|
||||
it("adds one memory directly and prints the receipt with its request id", async () => {
|
||||
transport.post.mockResolvedValue({ status: 201, data: addReceipt() });
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"add",
|
||||
"Prefers tea",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
const [url, body] = transport.post.mock.calls[0];
|
||||
expect(url).toBe("/api/v2/memories");
|
||||
expect(body).toMatchObject({ text: "Prefers tea", scope: "all-apps" });
|
||||
expect(body.request_id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
schema_version: 2,
|
||||
command: "memories add",
|
||||
state: "accepted",
|
||||
request_id: body.request_id,
|
||||
receipt: { evidence_id: EVIDENCE, state: "pending" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses --request-id so a retry is idempotent, and says so in text", async () => {
|
||||
transport.post.mockResolvedValue({ status: 201, data: addReceipt() });
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"add",
|
||||
"Prefers tea",
|
||||
"--request-id",
|
||||
REQUEST,
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post.mock.calls[0][1].request_id).toBe(REQUEST);
|
||||
expect(result.stdout).toContain(`request_id: ${REQUEST}`);
|
||||
expect(result.stdout).toContain(`memhub memories add-status ${REQUEST}`);
|
||||
});
|
||||
|
||||
it("reads the text from a bounded file", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-memory-add-"));
|
||||
const file = path.join(directory, "memory.txt");
|
||||
await writeFile(file, "Prefers tea\n", { mode: 0o600 });
|
||||
transport.post.mockResolvedValue({ status: 201, data: addReceipt() });
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"add",
|
||||
"--content-file",
|
||||
file,
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post.mock.calls[0][1].text).toBe("Prefers tea");
|
||||
});
|
||||
|
||||
it("refuses bad input before contacting Core", async () => {
|
||||
for (const argv of [
|
||||
["memories", "add"],
|
||||
["memories", "add", "a", "--content-file", "/tmp/x"],
|
||||
["memories", "add", "a", "--request-id", "not-a-uuid"],
|
||||
["memories", "add", " "],
|
||||
["memories", "add", "x".repeat(12_001)],
|
||||
]) {
|
||||
expect((await capturedRun(argv)).code).toBe(2);
|
||||
}
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a Core conflict as rejected, on stdout, exit 1", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "memory_add_conflict" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"add",
|
||||
"Other text",
|
||||
"--request-id",
|
||||
REQUEST,
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
state: "rejected",
|
||||
code: "memory_add_conflict",
|
||||
request_id: REQUEST,
|
||||
});
|
||||
});
|
||||
|
||||
it("never reports a malformed success body as accepted", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 201,
|
||||
data: { evidence_id: "x" },
|
||||
});
|
||||
const result = await capturedRun(["memories", "add", "tea", "--json"]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(JSON.parse(result.stdout).state).toBe("unknown");
|
||||
});
|
||||
|
||||
it("answers a stopped Runtime with exit 3 and writes nothing", async () => {
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "stopped",
|
||||
code: "runtime_not_running",
|
||||
});
|
||||
const result = await capturedRun(["memories", "add", "tea", "--json"]);
|
||||
expect(result.code).toBe(3);
|
||||
expect(JSON.parse(result.stdout).code).toBe("runtime_not_running");
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("memories add-status", () => {
|
||||
it("reads the addition by request id", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: addReceipt("ready", ["m-1"]),
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"add-status",
|
||||
REQUEST,
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.get).toHaveBeenCalledWith(
|
||||
`/api/v2/memories/additions/${REQUEST}`,
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
command: "memories add-status",
|
||||
state: "accepted",
|
||||
receipt: { state: "ready", memory_ids: ["m-1"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unknown request id as not_found, exit 1", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 404,
|
||||
data: { detail: "memory_add_not_found" },
|
||||
});
|
||||
const result = await capturedRun(["memories", "status", REQUEST]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stdout).toContain("未找到该 request id");
|
||||
});
|
||||
|
||||
it("refuses a non-UUID request id", async () => {
|
||||
expect((await capturedRun(["memories", "add-status", "abc"])).code).toBe(2);
|
||||
expect(transport.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("memories update / delete pass the namespace", () => {
|
||||
it("updates an experience with kind, variant and task, directly", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-memory-update-"));
|
||||
const file = path.join(directory, "solution.txt");
|
||||
await writeFile(file, "run make deploy\n", { mode: 0o600 });
|
||||
transport.put.mockResolvedValue({ status: 200, data: memoryRecord() });
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"update",
|
||||
"exp-1",
|
||||
"--content-file",
|
||||
file,
|
||||
"--kind",
|
||||
"experience",
|
||||
"--variant",
|
||||
"working",
|
||||
"--task",
|
||||
"deploy",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.put).toHaveBeenCalledWith(
|
||||
"/api/v2/memories/exp-1",
|
||||
{
|
||||
content: "run make deploy",
|
||||
kind: "experience",
|
||||
variant: "working",
|
||||
task: "deploy",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
schema_version: 2,
|
||||
state: "committed",
|
||||
operation: "update",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses --task on anything but an experience, and a lone --variant", async () => {
|
||||
for (const argv of [
|
||||
["memories", "update", "m-1", "--content-file", "/tmp/x", "--task", "t"],
|
||||
[
|
||||
"memories",
|
||||
"update",
|
||||
"m-1",
|
||||
"--content-file",
|
||||
"/tmp/x",
|
||||
"--kind",
|
||||
"fact",
|
||||
"--variant",
|
||||
"coding",
|
||||
"--task",
|
||||
"t",
|
||||
],
|
||||
["memories", "delete", "m-1", "--variant", "coding"],
|
||||
["memories", "delete", "m-1", "--kind", "chat", "--variant", "coding"],
|
||||
["memories", "show", "m-1", "--kind", "nope"],
|
||||
]) {
|
||||
expect((await capturedRun(argv)).code).toBe(2);
|
||||
}
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
expect(transport.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("previews a delete without --yes and changes nothing", async () => {
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"delete",
|
||||
"fact-1",
|
||||
"--kind",
|
||||
"fact",
|
||||
"--variant",
|
||||
"coding",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
schema_version: 2,
|
||||
command: "memories delete",
|
||||
state: "preview",
|
||||
target: "fact-1",
|
||||
applied: false,
|
||||
});
|
||||
expect(JSON.parse(result.stdout).next_step).toContain(
|
||||
"--kind fact --variant coding --yes",
|
||||
);
|
||||
expect(transport.delete).not.toHaveBeenCalled();
|
||||
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes with --yes (or the legacy --confirm) and names the namespace", async () => {
|
||||
transport.delete.mockResolvedValue({ status: 204, data: "" });
|
||||
for (const flag of ["--yes", "--confirm"]) {
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"delete",
|
||||
"fact-1",
|
||||
"--kind",
|
||||
"fact",
|
||||
"--variant",
|
||||
"coding",
|
||||
flag,
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
schema_version: 2,
|
||||
state: "committed",
|
||||
operation: "delete",
|
||||
});
|
||||
}
|
||||
expect(transport.delete).toHaveBeenCalledWith(
|
||||
"/api/v2/memories/fact-1?kind=fact&variant=coding",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("explains an ambiguous id instead of a bare code", async () => {
|
||||
transport.delete.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "memory_id_ambiguous" },
|
||||
});
|
||||
const result = await capturedRun(["memories", "delete", "dup", "--yes"]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stdout).toContain("--kind");
|
||||
});
|
||||
|
||||
it("shows a memory in its namespace", async () => {
|
||||
transport.get.mockResolvedValue({ status: 200, data: memoryRecord() });
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"show",
|
||||
"exp-1",
|
||||
"--kind",
|
||||
"experience",
|
||||
"--variant",
|
||||
"working",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.get).toHaveBeenCalledWith("/api/v2/memories/exp-1", {
|
||||
params: { kind: "experience", variant: "working" },
|
||||
});
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.schema_version).toBe(2);
|
||||
expect(document).not.toHaveProperty("evidence");
|
||||
expect(document).not.toHaveProperty("history");
|
||||
expect(document.memory).toMatchObject({
|
||||
kind: "experience",
|
||||
variant: "working",
|
||||
task: "deploy",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("memories list filters", () => {
|
||||
const page = {
|
||||
memories: [memoryRecord()],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
search_window: 200,
|
||||
window_id: "w1",
|
||||
window_reset: false,
|
||||
};
|
||||
|
||||
it("sends --scene, --source and --query to the library", async () => {
|
||||
transport.get.mockResolvedValue({ status: 200, data: page });
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"list",
|
||||
"--scene",
|
||||
"working",
|
||||
"--source",
|
||||
"codebuddy",
|
||||
"--query",
|
||||
"deploy",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.get).toHaveBeenCalledWith("/api/v2/memory-library", {
|
||||
params: {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
scene: "working",
|
||||
source: "codebuddy",
|
||||
query: "deploy",
|
||||
},
|
||||
});
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.schema_version).toBe(2);
|
||||
expect(document.page).toMatchObject({
|
||||
state: "ok",
|
||||
scene: "working",
|
||||
query: "deploy",
|
||||
search_window: 200,
|
||||
});
|
||||
expect(document.memories[0]).toMatchObject({
|
||||
kind: "experience",
|
||||
variant: "working",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits scene for the default all", async () => {
|
||||
transport.get.mockResolvedValue({ status: 200, data: page });
|
||||
await capturedRun(["memories", "list", "--scene", "all", "--json"]);
|
||||
expect(transport.get.mock.calls[0][1].params).not.toHaveProperty("scene");
|
||||
});
|
||||
|
||||
it("validates --status, --scene and --query locally instead of reporting a busy backend", async () => {
|
||||
for (const argv of [
|
||||
["memories", "list", "--status", "bogus"],
|
||||
["memories", "list", "--scene", "normal"],
|
||||
["memories", "list", "--query", "x", "--status", "archived"],
|
||||
["memories", "list", "--source", "Bad Source"],
|
||||
]) {
|
||||
expect((await capturedRun(argv)).code).toBe(2);
|
||||
}
|
||||
expect(transport.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a Core 422 as rejected, not as 后台正忙", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 422,
|
||||
data: { detail: "sdk_search_supports_active_status_only" },
|
||||
});
|
||||
const result = await capturedRun(["memories", "list"]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stdout).not.toContain("后台正忙");
|
||||
expect(result.stdout).toContain("sdk_search_supports_active_status_only");
|
||||
});
|
||||
});
|
||||
|
||||
describe("graph-width", () => {
|
||||
it("runs a non-destructive decision directly", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { archived_path: null, exported_path: "/tmp/x", mismatch: null },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"graph-width",
|
||||
"--decision",
|
||||
"export_first",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/graph-width",
|
||||
{ decision: "export_first" },
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(result.stdout).state).toBe("committed");
|
||||
});
|
||||
|
||||
it("previews rebuild_with_new without --yes and applies it with --yes", async () => {
|
||||
const preview = await capturedRun([
|
||||
"graph-width",
|
||||
"--decision",
|
||||
"rebuild_with_new",
|
||||
"--json",
|
||||
]);
|
||||
expect(preview.code).toBe(0);
|
||||
expect(JSON.parse(preview.stdout)).toMatchObject({
|
||||
state: "preview",
|
||||
applied: false,
|
||||
});
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { archived_path: "/tmp/old", exported_path: null, mismatch: null },
|
||||
});
|
||||
const applied = await capturedRun([
|
||||
"graph",
|
||||
"width",
|
||||
"--decision",
|
||||
"rebuild_with_new",
|
||||
"--yes",
|
||||
"--json",
|
||||
]);
|
||||
expect(applied.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/graph-width",
|
||||
{ decision: "rebuild_with_new" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removed commands", () => {
|
||||
it.each([
|
||||
["notes", "add", "--content-file", "/tmp/x"],
|
||||
["notes", "process", REQUEST, "--confirm"],
|
||||
["calls", "clear", "--all", "--confirm"],
|
||||
["processing", "extract", "--evidence", REQUEST, "--confirm"],
|
||||
["extraction", "set", "--window", "6m", "--confirm"],
|
||||
["import-file", "upload", "--file", "/tmp/x", "--confirm"],
|
||||
])("%s is an unknown command that writes nothing", async (...argv) => {
|
||||
const result = await capturedRun(argv as string[]);
|
||||
expect(result.code).toBe(2);
|
||||
expect(result.stderr).toContain("未知命令");
|
||||
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,318 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
import { parseFeaturesCommand } from "./memory-features-flow.js";
|
||||
import { composeStatusReport, renderStatusText } from "./status-report.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(),
|
||||
post: vi.fn(),
|
||||
isAxiosError: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
const DEFAULTS: Record<string, boolean> = {
|
||||
graph_rerank: false,
|
||||
same_series_timeline: false,
|
||||
episode_recall: false,
|
||||
session_episodes: false,
|
||||
episode_summary: false,
|
||||
intention: true,
|
||||
few_shot: false,
|
||||
};
|
||||
|
||||
function featuresState(
|
||||
configured: Record<string, boolean> = {},
|
||||
mode = "pro",
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
const nonDefault: Record<string, boolean> = {};
|
||||
const features = Object.entries(DEFAULTS).map(([name, fallback]) => {
|
||||
const enabled = configured[name] ?? fallback;
|
||||
if (enabled !== fallback) nonDefault[name] = enabled;
|
||||
const needsUltra = name === "graph_rerank";
|
||||
const reason = !enabled
|
||||
? "disabled"
|
||||
: needsUltra && mode !== "ultra"
|
||||
? "requires_ultra"
|
||||
: null;
|
||||
return {
|
||||
name,
|
||||
enabled,
|
||||
configured: name in configured ? configured[name] : null,
|
||||
default: fallback,
|
||||
active: reason === null,
|
||||
reason,
|
||||
side: "read",
|
||||
requires_mode: needsUltra ? "ultra" : "pro",
|
||||
requires_feature: null,
|
||||
cost: "none",
|
||||
sdk_field: `recall.${name}`,
|
||||
env: `MEMORY_${name.toUpperCase()}`,
|
||||
description: name,
|
||||
};
|
||||
});
|
||||
return { mode, features, non_default: nonDefault, ...extra };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const method of Object.values(transport)) method.mockReset();
|
||||
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",
|
||||
},
|
||||
});
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/settings/memory-features"
|
||||
? { status: 200, data: featuresState() }
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
});
|
||||
|
||||
describe("memhub settings features", () => {
|
||||
it("lists every feature with its value and whether it acts", async () => {
|
||||
const result = await capturedRun(["settings", "features", "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.state).toBe("ok");
|
||||
expect(json.features.features).toHaveLength(7);
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
const text = (await capturedRun(["settings", "features"])).stdout;
|
||||
expect(text).toContain("graph_rerank:关(默认)");
|
||||
expect(text).toContain("intention:开(默认)");
|
||||
expect(text).toContain("先开启 session_episodes");
|
||||
});
|
||||
|
||||
it("prints the plan and changes nothing without --yes", async () => {
|
||||
const result = await capturedRun([
|
||||
"settings",
|
||||
"features",
|
||||
"set",
|
||||
"graph_rerank=true",
|
||||
"episode_recall=true",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
expect(result.stdout).toContain(
|
||||
"memhub settings features set graph_rerank=true episode_recall=true --yes",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets features with --yes and reports the ones inactive in this mode", async () => {
|
||||
transport.put.mockResolvedValue({
|
||||
status: 200,
|
||||
data: featuresState({ graph_rerank: true, episode_recall: true }, "pro", {
|
||||
changed: ["graph_rerank", "episode_recall"],
|
||||
}),
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"settings",
|
||||
"features",
|
||||
"set",
|
||||
"graph_rerank=true",
|
||||
"episode_recall=true",
|
||||
"--yes",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.put).toHaveBeenCalledWith(
|
||||
"/api/v1/settings/memory-features",
|
||||
{ graph_rerank: true, episode_recall: true },
|
||||
undefined,
|
||||
);
|
||||
expect(result.stdout).toContain("已更新:graph_rerank、episode_recall");
|
||||
expect(result.stdout).toContain(
|
||||
"graph_rerank:开(已设置,当前模式下不生效(需要 ultra))",
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing when the values are already set", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: featuresState({ few_shot: true }),
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"settings",
|
||||
"features",
|
||||
"set",
|
||||
"few_shot=true",
|
||||
"--yes",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("没有需要改动的设置");
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets the named features, or every set one, to the default", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: featuresState({ few_shot: true, intention: false }),
|
||||
});
|
||||
transport.put.mockResolvedValue({
|
||||
status: 200,
|
||||
data: featuresState({}, "pro", { changed: ["intention", "few_shot"] }),
|
||||
});
|
||||
expect(
|
||||
(await capturedRun(["settings", "features", "reset", "few_shot"])).code,
|
||||
).toBe(0);
|
||||
expect(transport.put).toHaveBeenLastCalledWith(
|
||||
"/api/v1/settings/memory-features",
|
||||
{ few_shot: null },
|
||||
undefined,
|
||||
);
|
||||
const all = await capturedRun(["settings", "features", "reset", "--json"]);
|
||||
expect(JSON.parse(all.stdout).state).toBe("committed");
|
||||
expect(transport.put).toHaveBeenLastCalledWith(
|
||||
"/api/v1/settings/memory-features",
|
||||
{ intention: null, few_shot: null },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses unknown names and malformed values before the Runtime", async () => {
|
||||
for (const bad of [
|
||||
["settings", "features", "set"],
|
||||
["settings", "features", "set", "local_rerank=true"],
|
||||
["settings", "features", "set", "graph_rerank=yes"],
|
||||
["settings", "features", "set", "graph_rerank"],
|
||||
["settings", "features", "set", "few_shot=true", "few_shot=false"],
|
||||
["settings", "features", "reset", "nope"],
|
||||
["settings", "features", "toggle"],
|
||||
]) {
|
||||
const result = await capturedRun(bad);
|
||||
expect(result.code, bad.join(" ")).toBe(2);
|
||||
}
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a 422 from the Runtime as rejected", async () => {
|
||||
transport.put.mockResolvedValue({
|
||||
status: 422,
|
||||
data: { detail: "invalid_request" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"settings",
|
||||
"features",
|
||||
"set",
|
||||
"few_shot=true",
|
||||
"--yes",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(JSON.parse(result.stdout).state).toBe("rejected");
|
||||
});
|
||||
|
||||
it("parses the forms the help shows", () => {
|
||||
expect(parseFeaturesCommand([])).toEqual({ kind: "show" });
|
||||
expect(parseFeaturesCommand(["set", "intention=false", "--yes"])).toEqual({
|
||||
kind: "set",
|
||||
values: { intention: false },
|
||||
confirmed: true,
|
||||
});
|
||||
expect(parseFeaturesCommand(["reset"])).toEqual({
|
||||
kind: "reset",
|
||||
names: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("status pipeline features", () => {
|
||||
it("shows the features that differ from the defaults", async () => {
|
||||
const processing = {
|
||||
pending: 0,
|
||||
fresh_pending: 0,
|
||||
backlog_pending: 0,
|
||||
outside_window_pending: 0,
|
||||
running: 0,
|
||||
waiting_for_model: 0,
|
||||
completed: 3,
|
||||
failed: 0,
|
||||
blocked_stages: [],
|
||||
pipeline: {
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
forced_by_env: false,
|
||||
last_evolve_at: null,
|
||||
graph_build: { state: "idle", users: {} },
|
||||
evolve: { mode: "pro", schedule: null, running: [], users: {} },
|
||||
features: { episode_recall: true, intention: false },
|
||||
},
|
||||
};
|
||||
const report = await composeStatusReport({
|
||||
dataDir: "/tmp/isolated",
|
||||
runtime: { state: "running", code: "ok", host: "127.0.0.1", port: 1 },
|
||||
background: null,
|
||||
reader: (async (path: string) =>
|
||||
path === "/api/v1/memory-processing"
|
||||
? { state: "ok", data: processing }
|
||||
: { state: "unavailable", code: "x" }) as never,
|
||||
});
|
||||
expect(report.pipeline?.features).toEqual({
|
||||
episode_recall: true,
|
||||
intention: false,
|
||||
});
|
||||
const text = renderStatusText(report, "MemHub");
|
||||
expect(text).toContain("SDK 功能:episode_recall=开、intention=关");
|
||||
});
|
||||
});
|
||||
|
||||
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,273 +0,0 @@
|
||||
/**
|
||||
* `memhub settings features [set <name>=<true|false> ... | reset [<name> ...]]`.
|
||||
*
|
||||
* The SDK's optional retrieval and extraction features (graph rerank, episode
|
||||
* recall, the episode write side, intention, few-shot). One global setting in
|
||||
* the Runtime; an unset feature keeps the SDK default, so an install nobody
|
||||
* touched behaves as before. `set` prints its plan first and needs `--yes`,
|
||||
* like `settings mode`, because some features spend model calls; `reset`
|
||||
* only returns features to their defaults and applies at once.
|
||||
*/
|
||||
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CorePutOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
export const MEMORY_FEATURES = [
|
||||
"graph_rerank",
|
||||
"same_series_timeline",
|
||||
"episode_recall",
|
||||
"session_episodes",
|
||||
"episode_summary",
|
||||
"intention",
|
||||
"few_shot",
|
||||
] as const;
|
||||
export type MemoryFeature = (typeof MEMORY_FEATURES)[number];
|
||||
|
||||
const FEATURE_LABEL: Record<MemoryFeature, string> = {
|
||||
graph_rerank: "图谱重排(沿知识图谱的边传播支持度;仅 ultra)",
|
||||
same_series_timeline:
|
||||
"同系列时间线(同一系列的记忆按时间放在一起;需图谱重排)",
|
||||
episode_recall:
|
||||
"片段召回(补入同一片段的记忆并多通道打分;pro / ultra,需片段写入)",
|
||||
session_episodes: "片段写入(同一会话提取的记忆归入同一片段)",
|
||||
episode_summary: "片段摘要(片段结束时总结一次;每个片段一次模型调用)",
|
||||
intention: "意图提取(随事实一起提取用户的计划与意图)",
|
||||
few_shot: "提取示例(在提取提示里附上示例;不加调用,输入更长)",
|
||||
};
|
||||
|
||||
const REASON_TEXT: Record<string, string> = {
|
||||
disabled: "未开启",
|
||||
requires_pro: "lite 模式下不生效(需要 pro 或 ultra)",
|
||||
requires_ultra: "当前模式下不生效(需要 ultra)",
|
||||
requires_graph_rerank: "需要先开启并生效 graph_rerank",
|
||||
};
|
||||
|
||||
export type FeaturesCommand =
|
||||
| { kind: "show" }
|
||||
| {
|
||||
kind: "set";
|
||||
values: Partial<Record<MemoryFeature, boolean>>;
|
||||
confirmed: boolean;
|
||||
}
|
||||
| { kind: "reset"; names: MemoryFeature[] };
|
||||
|
||||
function isFeature(value: string): value is MemoryFeature {
|
||||
return (MEMORY_FEATURES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Arguments after `settings features`; null = usage. */
|
||||
export function parseFeaturesCommand(
|
||||
values: readonly string[],
|
||||
): FeaturesCommand | null {
|
||||
const rest = values.filter((value) => value !== "--json");
|
||||
if (rest.length === 0) return { kind: "show" };
|
||||
const [verb, ...tail] = rest;
|
||||
const yes = tail.filter((value) => value === "--yes").length;
|
||||
if (yes > 1) return null;
|
||||
const positional = tail.filter((value) => value !== "--yes");
|
||||
if (verb === "set") {
|
||||
if (positional.length === 0) return null;
|
||||
const parsed: Partial<Record<MemoryFeature, boolean>> = {};
|
||||
for (const item of positional) {
|
||||
const match = /^([a-z_]+)=(true|false)$/.exec(item);
|
||||
if (!match || !isFeature(match[1]) || match[1] in parsed) return null;
|
||||
parsed[match[1]] = match[2] === "true";
|
||||
}
|
||||
return { kind: "set", values: parsed, confirmed: yes === 1 };
|
||||
}
|
||||
if (verb === "reset") {
|
||||
if (new Set(positional).size !== positional.length) return null;
|
||||
if (!positional.every(isFeature)) return null;
|
||||
return { kind: "reset", names: positional as MemoryFeature[] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface FeatureState {
|
||||
name: MemoryFeature;
|
||||
enabled: boolean;
|
||||
configured: boolean | null;
|
||||
default: boolean;
|
||||
active: boolean;
|
||||
reason: string | null;
|
||||
cost: string;
|
||||
requires_mode: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface FeaturesState {
|
||||
mode: string;
|
||||
features: FeatureState[];
|
||||
non_default: Partial<Record<MemoryFeature, boolean>>;
|
||||
changed?: MemoryFeature[];
|
||||
}
|
||||
|
||||
function featuresState(value: unknown): FeaturesState | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const item = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof item.mode !== "string" ||
|
||||
!Array.isArray(item.features) ||
|
||||
!item.non_default ||
|
||||
typeof item.non_default !== "object"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
for (const feature of item.features) {
|
||||
if (
|
||||
!feature ||
|
||||
typeof feature !== "object" ||
|
||||
typeof (feature as FeatureState).name !== "string" ||
|
||||
typeof (feature as FeatureState).enabled !== "boolean" ||
|
||||
typeof (feature as FeatureState).active !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return item as unknown as FeaturesState;
|
||||
}
|
||||
|
||||
export type FeaturesDocument = {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "settings features";
|
||||
} & (
|
||||
| { state: "ok"; features: FeaturesState }
|
||||
| {
|
||||
state: "preview";
|
||||
features: FeaturesState;
|
||||
target: Partial<Record<MemoryFeature, boolean>>;
|
||||
applied: false;
|
||||
next_step: string;
|
||||
}
|
||||
| { state: "unchanged"; features: FeaturesState }
|
||||
| { state: "committed"; features: FeaturesState; changed: MemoryFeature[] }
|
||||
| { state: "rejected" | "unknown" | "unavailable"; code: string }
|
||||
);
|
||||
|
||||
const BASE = {
|
||||
schema_version: 1 as const,
|
||||
product: PRODUCT,
|
||||
command: "settings features" as const,
|
||||
};
|
||||
|
||||
const PATH = "/api/v1/settings/memory-features";
|
||||
|
||||
export async function runFeaturesCommand(
|
||||
command: FeaturesCommand,
|
||||
reader: CoreReader,
|
||||
operations: CorePutOperations,
|
||||
): Promise<FeaturesDocument> {
|
||||
const read = await reader<unknown>(PATH);
|
||||
if (read.state !== "ok") {
|
||||
return { ...BASE, state: "unavailable", code: read.code };
|
||||
}
|
||||
const current = featuresState(read.data);
|
||||
if (current === null) {
|
||||
return { ...BASE, state: "unavailable", code: "response_invalid" };
|
||||
}
|
||||
if (command.kind === "show") {
|
||||
return { ...BASE, state: "ok", features: current };
|
||||
}
|
||||
let body: Record<string, unknown>;
|
||||
if (command.kind === "set") {
|
||||
const configured = new Map(
|
||||
current.features.map((feature) => [feature.name, feature.configured]),
|
||||
);
|
||||
const differs = Object.entries(command.values).some(
|
||||
([name, value]) => configured.get(name as MemoryFeature) !== value,
|
||||
);
|
||||
if (!differs) return { ...BASE, state: "unchanged", features: current };
|
||||
if (!command.confirmed) {
|
||||
const pairs = Object.entries(command.values)
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join(" ");
|
||||
return {
|
||||
...BASE,
|
||||
state: "preview",
|
||||
features: current,
|
||||
target: command.values,
|
||||
applied: false,
|
||||
next_step: `${PRODUCT} settings features set ${pairs} --yes`,
|
||||
};
|
||||
}
|
||||
body = { ...command.values };
|
||||
} else {
|
||||
const names =
|
||||
command.names.length > 0
|
||||
? command.names
|
||||
: current.features
|
||||
.filter((feature) => feature.configured !== null)
|
||||
.map((feature) => feature.name);
|
||||
if (
|
||||
names.every(
|
||||
(name) =>
|
||||
current.features.find((feature) => feature.name === name)
|
||||
?.configured === null,
|
||||
)
|
||||
) {
|
||||
return { ...BASE, state: "unchanged", features: current };
|
||||
}
|
||||
body = Object.fromEntries(names.map((name) => [name, null]));
|
||||
}
|
||||
const result = await operations.put<unknown>(PATH, body);
|
||||
if (result.state !== "committed") {
|
||||
return { ...BASE, state: result.state, code: result.code };
|
||||
}
|
||||
const changed = featuresState(result.data);
|
||||
if (changed === null) {
|
||||
return { ...BASE, state: "unknown", code: "response_invalid" };
|
||||
}
|
||||
return {
|
||||
...BASE,
|
||||
state: "committed",
|
||||
features: changed,
|
||||
changed: changed.changed ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function featuresExitCode(document: FeaturesDocument): number {
|
||||
return ["ok", "preview", "unchanged", "committed"].includes(document.state)
|
||||
? 0
|
||||
: 1;
|
||||
}
|
||||
|
||||
function featureLine(feature: FeatureState): string {
|
||||
const label = FEATURE_LABEL[feature.name] ?? feature.name;
|
||||
const value = feature.enabled ? "开" : "关";
|
||||
const origin = feature.configured === null ? "默认" : "已设置";
|
||||
const activity =
|
||||
feature.enabled && !feature.active && feature.reason
|
||||
? `,${REASON_TEXT[feature.reason] ?? feature.reason}`
|
||||
: "";
|
||||
return `${feature.name}:${value}(${origin}${activity})— ${label}`;
|
||||
}
|
||||
|
||||
export function renderFeaturesText(document: FeaturesDocument): string {
|
||||
if ("code" in document) {
|
||||
return `SDK 功能未能${document.state === "unavailable" ? "读取" : "修改"}:${document.code}\n`;
|
||||
}
|
||||
const state = document.features;
|
||||
const lines = [`当前模式:${state.mode}`];
|
||||
for (const feature of state.features) lines.push(featureLine(feature));
|
||||
lines.push(
|
||||
"片段召回(episode_recall)只对带片段的记忆有用:先开启 session_episodes,之后提取的记忆才会带片段。",
|
||||
);
|
||||
if (document.state === "unchanged") lines.push("没有需要改动的设置。");
|
||||
if (document.state === "preview") {
|
||||
lines.push("将设置:");
|
||||
for (const [name, value] of Object.entries(document.target)) {
|
||||
lines.push(`- ${name} = ${value ? "开" : "关"}`);
|
||||
}
|
||||
lines.push(`未执行任何改动。确认后运行:${document.next_step}`);
|
||||
}
|
||||
if (document.state === "committed") {
|
||||
lines.push(
|
||||
document.changed.length > 0
|
||||
? `已更新:${document.changed.join("、")}。正在进行的写入按原设置完成,之后的写入和检索使用新设置。`
|
||||
: "已保存,生效的值没有变化。",
|
||||
);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import type { GovernedMemory } from "@one-memory/contracts";
|
||||
import {
|
||||
buildMemoryActionRequest,
|
||||
buildMemoryCorrectRequest,
|
||||
buildMemoryCreateRequest,
|
||||
parseMemoryReceipt,
|
||||
} from "@one-memory/operations";
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import { validateMemoryDetail } from "./core-responses.js";
|
||||
import { isMemoryId } from "./memory-report.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
const MEMORY_KINDS = [
|
||||
"chat",
|
||||
"working",
|
||||
"coding",
|
||||
"schema",
|
||||
"intention",
|
||||
] as const;
|
||||
const OPERATIONS = ["add", "correct", "delete"] as const;
|
||||
const MEMORY_ORIGINS = ["extracted", "answered", "agent", "manual"] as const;
|
||||
const MUTATION_RECEIPT_KEYS = new Set([
|
||||
"schema_version",
|
||||
"request_id",
|
||||
"state",
|
||||
"operation",
|
||||
"memory_id",
|
||||
"result_revision",
|
||||
"result_status",
|
||||
"memory_state",
|
||||
"migrated_from",
|
||||
]);
|
||||
const CONTENT_MAX_CHARACTERS = 100_000;
|
||||
const ID = /^[A-Za-z0-9][\w.-]{0,127}$/;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
interface PlanBody {
|
||||
schema_version: 1;
|
||||
kind: "memory_mutation";
|
||||
operation: (typeof OPERATIONS)[number];
|
||||
request_id: string;
|
||||
memory_id?: string;
|
||||
expected_revision?: number;
|
||||
expected_status?: string;
|
||||
expected_origin?: string;
|
||||
memory_kind?: (typeof MEMORY_KINDS)[number];
|
||||
scopes?: string[];
|
||||
evidence_ids?: string[];
|
||||
content_sha256?: string;
|
||||
content_characters?: number;
|
||||
current?: {
|
||||
status: string;
|
||||
origin: string;
|
||||
revision: number;
|
||||
scopes: string[];
|
||||
evidence: Array<{
|
||||
evidence_id: string;
|
||||
available: boolean;
|
||||
source_id?: string;
|
||||
written_by?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export type MemoryMutationPlan = PlanBody & { digest: string };
|
||||
|
||||
export type MemoryFlowDocument =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "preview";
|
||||
plan: MemoryMutationPlan;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "committed" | "recovered";
|
||||
operation_id: string;
|
||||
receipt: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "rejected" | "unknown" | "invalid";
|
||||
operation_id?: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export function parseMemoryContent(bytes: Buffer): string | null {
|
||||
if (bytes.byteLength > CONTENT_MAX_CHARACTERS * 4 + 2) return null;
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const content = decoded.replace(/\r?\n$/, "");
|
||||
return content.trim().length > 0 &&
|
||||
[...content].length <= CONTENT_MAX_CHARACTERS &&
|
||||
![...content].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code <= 8 || (code >= 11 && code <= 31) || code === 127;
|
||||
})
|
||||
? content
|
||||
: null;
|
||||
}
|
||||
|
||||
export function planMemoryAdd(input: {
|
||||
content: string;
|
||||
memoryKind: string;
|
||||
scopes: string[];
|
||||
evidenceIds: string[];
|
||||
}): MemoryFlowDocument {
|
||||
if (
|
||||
!validContent(input.content) ||
|
||||
!oneOf(input.memoryKind, MEMORY_KINDS) ||
|
||||
!validScopes(input.scopes) ||
|
||||
!validIds(input.evidenceIds, 500)
|
||||
) {
|
||||
return invalid("memory_add_input_invalid");
|
||||
}
|
||||
return preview(
|
||||
withDigest({
|
||||
schema_version: 1,
|
||||
kind: "memory_mutation",
|
||||
operation: "add",
|
||||
request_id: randomUUID(),
|
||||
memory_kind: input.memoryKind,
|
||||
scopes: input.scopes,
|
||||
evidence_ids: input.evidenceIds,
|
||||
...contentIdentity(input.content),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function planExistingMemoryMutation(
|
||||
reader: CoreReader,
|
||||
input: {
|
||||
operation: Exclude<(typeof OPERATIONS)[number], "add">;
|
||||
memoryId: string;
|
||||
content?: string;
|
||||
},
|
||||
): Promise<MemoryFlowDocument> {
|
||||
if (
|
||||
!isMemoryId(input.memoryId) ||
|
||||
(input.operation === "correct" && input.content === undefined) ||
|
||||
(input.content !== undefined && !validContent(input.content))
|
||||
) {
|
||||
return invalid("memory_plan_input_invalid");
|
||||
}
|
||||
const outcome = await reader<unknown>(`/api/v1/memories/${input.memoryId}`);
|
||||
if (outcome.state !== "ok") {
|
||||
return invalid(`memory_${outcome.state}:${outcome.code}`);
|
||||
}
|
||||
const detail = validateMemoryDetail(outcome.data, input.memoryId);
|
||||
if (!detail) return invalid("memory_response_invalid");
|
||||
const memory = detail.memory;
|
||||
if (memory.status !== "confirmed") {
|
||||
return invalid("memory_operation_not_allowed");
|
||||
}
|
||||
const body: PlanBody = {
|
||||
schema_version: 1,
|
||||
kind: "memory_mutation",
|
||||
operation: input.operation,
|
||||
request_id: randomUUID(),
|
||||
memory_id: memory.memory_id,
|
||||
expected_revision: memory.revision,
|
||||
expected_status: memory.status,
|
||||
expected_origin: memory.origin,
|
||||
...(input.content ? contentIdentity(input.content) : {}),
|
||||
current: {
|
||||
status: memory.status,
|
||||
origin: memory.origin,
|
||||
revision: memory.revision,
|
||||
scopes: [...memory.scopes],
|
||||
evidence: detail.evidence.map((item) => ({
|
||||
evidence_id: item.evidence_id,
|
||||
available: item.available,
|
||||
...(item.source_id ? { source_id: item.source_id } : {}),
|
||||
...(item.written_by ? { written_by: item.written_by } : {}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
return preview(withDigest(body));
|
||||
}
|
||||
|
||||
export function inspectMemoryPlan(
|
||||
value: unknown,
|
||||
): { operationId: string; digest: string; needsContent: boolean } | null {
|
||||
const plan = validatePlan(value);
|
||||
return plan
|
||||
? {
|
||||
operationId: plan.request_id,
|
||||
digest: plan.digest,
|
||||
needsContent: plan.operation === "add" || plan.operation === "correct",
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function memoryPlanAcceptsContent(
|
||||
value: unknown,
|
||||
content: string | undefined,
|
||||
): boolean {
|
||||
const plan = validatePlan(value);
|
||||
if (!plan) return false;
|
||||
if (plan.operation !== "add" && plan.operation !== "correct") {
|
||||
return content === undefined;
|
||||
}
|
||||
if (content === undefined) return false;
|
||||
const identity = contentIdentity(content);
|
||||
return (
|
||||
identity.content_sha256 === plan.content_sha256 &&
|
||||
identity.content_characters === plan.content_characters
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeMemoryMutation(
|
||||
value: unknown,
|
||||
content: string | undefined,
|
||||
operations: CoreOperations,
|
||||
reader?: CoreReader,
|
||||
): Promise<MemoryFlowDocument> {
|
||||
const plan = validatePlan(value);
|
||||
if (!plan) return invalid("memory_plan_invalid");
|
||||
if (!memoryPlanAcceptsContent(plan, content)) {
|
||||
return invalid("memory_content_mismatch");
|
||||
}
|
||||
const { path, body } = operationRequest(plan, content);
|
||||
const outcome = await operations.post<unknown>(path, body);
|
||||
if (outcome.state === "rejected") {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "rejected",
|
||||
operation_id: plan.request_id,
|
||||
code: outcome.code,
|
||||
};
|
||||
}
|
||||
if (outcome.state === "unknown") {
|
||||
return reader
|
||||
? readMemoryMutationReceipt(reader, plan)
|
||||
: pending(plan.request_id, outcome.code);
|
||||
}
|
||||
const memory = validateWriteResponse(outcome.data, plan);
|
||||
if (!memory || !matchesReceipt(memory, plan, content)) {
|
||||
return reader
|
||||
? readMemoryMutationReceipt(reader, plan)
|
||||
: pending(plan.request_id, "memory_receipt_invalid");
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "committed",
|
||||
operation_id: plan.request_id,
|
||||
receipt: {
|
||||
memory_id: memory.memory_id,
|
||||
status: memory.status,
|
||||
origin: memory.origin,
|
||||
revision: memory.revision,
|
||||
scopes: memory.scopes,
|
||||
evidence_ids: memory.evidence_ids,
|
||||
content_sha256: contentIdentity(memory.content).content_sha256,
|
||||
content_characters: [...memory.content].length,
|
||||
created_at: memory.created_at,
|
||||
updated_at: memory.updated_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the write response through the shared `parseMemoryReceipt`, which
|
||||
* owns the GovernedMemory shape and the revision/status binding Core must
|
||||
* satisfy. The CLI keeps only its own plan binding (`matchesReceipt`, which
|
||||
* also checks `origin`, `kind` and the echoed content), so the two surfaces
|
||||
* judge a write response identically and no shape rule is duplicated here.
|
||||
*
|
||||
* `add` creates the first revision and expects `confirmed`; the shared parser
|
||||
* derives that from `expectedRevision: 0`. Every other operation advances the
|
||||
* revision the caller acted against and expects `confirmed` (correct/scope)
|
||||
* or `deleted` (delete).
|
||||
*/
|
||||
function validateWriteResponse(
|
||||
value: unknown,
|
||||
plan: MemoryMutationPlan,
|
||||
): GovernedMemory | null {
|
||||
const expectedStatus = plan.operation === "delete" ? "deleted" : "confirmed";
|
||||
// For `add` there is no id or prior revision to bind yet, so the parser's
|
||||
// own receipt is the only source for both; the plan binding that follows is
|
||||
// what proves the response belongs to this request.
|
||||
const memoryId =
|
||||
plan.operation === "add"
|
||||
? String((value as { memory_id?: unknown } | null)?.memory_id ?? "")
|
||||
: (plan.memory_id as string);
|
||||
if (memoryId === "") return null;
|
||||
return parseMemoryReceipt(value, {
|
||||
memoryId,
|
||||
expectedRevision:
|
||||
plan.operation === "add" ? 0 : (plan.expected_revision as number),
|
||||
expectedStatus,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readMemoryMutationReceipt(
|
||||
reader: CoreReader,
|
||||
value: unknown,
|
||||
): Promise<MemoryFlowDocument> {
|
||||
const plan = validatePlan(value);
|
||||
if (!plan) return invalid("memory_plan_invalid");
|
||||
const outcome = await reader<unknown>(
|
||||
`/api/v1/memory-mutations/${encodeURIComponent(plan.request_id)}`,
|
||||
);
|
||||
if (outcome.state !== "ok") {
|
||||
return pending(plan.request_id, `memory_mutation_receipt_${outcome.code}`);
|
||||
}
|
||||
const receipt = validateMutationReceipt(outcome.data, plan);
|
||||
if (receipt === "not_found") {
|
||||
return pending(
|
||||
plan.request_id,
|
||||
"memory_mutation_receipt_not_found_after_attempt",
|
||||
);
|
||||
}
|
||||
if (!receipt) {
|
||||
return pending(plan.request_id, "memory_mutation_receipt_invalid");
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "recovered",
|
||||
operation_id: plan.request_id,
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
|
||||
export function memoryFlowExitCode(document: MemoryFlowDocument): number {
|
||||
return ["preview", "committed", "recovered"].includes(document.state) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderMemoryFlowText(document: MemoryFlowDocument): string {
|
||||
if (document.state === "preview") {
|
||||
const plan = document.plan;
|
||||
const lines = [
|
||||
`记忆操作预览:${plan.operation}`,
|
||||
`request_id:${plan.request_id}`,
|
||||
];
|
||||
if (plan.memory_id) lines.push(`memory_id:${plan.memory_id}`);
|
||||
if (plan.expected_revision) {
|
||||
lines.push(
|
||||
`当前版本:${plan.expected_revision} 状态:${plan.expected_status} 来源方式:${plan.expected_origin}`,
|
||||
);
|
||||
}
|
||||
if (plan.scopes) lines.push(`使用范围:${plan.scopes.join(",")}`);
|
||||
if (plan.current) {
|
||||
for (const evidence of plan.current.evidence) {
|
||||
lines.push(
|
||||
`证据 ${evidence.evidence_id}:source=${evidence.source_id ?? "unknown"} author=${evidence.written_by ?? "unknown"} available=${evidence.available}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (plan.content_characters !== undefined) {
|
||||
lines.push(
|
||||
`正文:${plan.content_characters} 字符,sha256=${plan.content_sha256}`,
|
||||
);
|
||||
}
|
||||
lines.push("尚未写入;保存 JSON 计划后用 memories apply --confirm 执行。");
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
if (document.state === "committed" || document.state === "recovered") {
|
||||
return `记忆操作${document.state === "recovered" ? "已从治理回执恢复" : "已提交"}:${document.operation_id}\n${JSON.stringify(document.receipt)}\n`;
|
||||
}
|
||||
return `记忆操作未完成:${document.state}(${"code" in document ? document.code : "memory_operation_state_invalid"})${document.operation_id ? ` request_id=${document.operation_id}` : ""}\n`;
|
||||
}
|
||||
|
||||
function operationRequest(
|
||||
plan: MemoryMutationPlan,
|
||||
content: string | undefined,
|
||||
): { path: string; body: Record<string, unknown> } {
|
||||
if (plan.operation === "add") {
|
||||
// The shared create builder is the Web console's manual-add body, which
|
||||
// always creates `chat` (the Web has no kind picker). The CLI exposes
|
||||
// `--kind`, so it builds through the same builder and then sets the kind,
|
||||
// keeping every other field (user_id/scope/evidence/origin) shared.
|
||||
const request = buildMemoryCreateRequest(
|
||||
{
|
||||
content: content as string,
|
||||
scopes: plan.scopes ?? [],
|
||||
evidence_ids: plan.evidence_ids ?? [],
|
||||
origin: "manual",
|
||||
},
|
||||
plan.request_id,
|
||||
);
|
||||
const draft = request.body.draft as Record<string, unknown>;
|
||||
draft.kind = plan.memory_kind;
|
||||
return { path: request.path, body: request.body };
|
||||
}
|
||||
if (plan.operation === "correct") {
|
||||
return buildMemoryCorrectRequest(
|
||||
plan.memory_id as string,
|
||||
plan.expected_revision as number,
|
||||
content as string,
|
||||
undefined,
|
||||
plan.request_id,
|
||||
);
|
||||
}
|
||||
return buildMemoryActionRequest(
|
||||
plan.memory_id as string,
|
||||
plan.operation,
|
||||
plan.expected_revision as number,
|
||||
plan.request_id,
|
||||
);
|
||||
}
|
||||
|
||||
function matchesReceipt(
|
||||
memory: GovernedMemory,
|
||||
plan: MemoryMutationPlan,
|
||||
content: string | undefined,
|
||||
): boolean {
|
||||
if (plan.operation === "add") {
|
||||
return (
|
||||
memory.status === "confirmed" &&
|
||||
memory.origin === "manual" &&
|
||||
memory.revision === 1 &&
|
||||
memory.kind === plan.memory_kind &&
|
||||
same(memory.scopes, plan.scopes ?? []) &&
|
||||
same(memory.evidence_ids, plan.evidence_ids ?? []) &&
|
||||
memory.content === content
|
||||
);
|
||||
}
|
||||
if (
|
||||
memory.memory_id !== plan.memory_id ||
|
||||
memory.revision !== (plan.expected_revision as number) + 1 ||
|
||||
memory.origin !== plan.expected_origin
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (plan.operation === "delete" && memory.status !== "deleted") return false;
|
||||
if (plan.operation === "correct" && memory.status !== plan.expected_status) {
|
||||
return false;
|
||||
}
|
||||
if (plan.operation === "correct" && memory.content !== content) return false;
|
||||
const expectedScopes =
|
||||
plan.scopes ?? (plan.current?.scopes as string[] | undefined);
|
||||
return expectedScopes ? same(memory.scopes, expectedScopes) : true;
|
||||
}
|
||||
|
||||
function validateMutationReceipt(
|
||||
value: unknown,
|
||||
plan: MemoryMutationPlan,
|
||||
): Record<string, unknown> | "not_found" | null {
|
||||
const receipt = record(value);
|
||||
if (
|
||||
!receipt ||
|
||||
Object.keys(receipt).length !== MUTATION_RECEIPT_KEYS.size ||
|
||||
Object.keys(receipt).some((key) => !MUTATION_RECEIPT_KEYS.has(key)) ||
|
||||
receipt.schema_version !== 2 ||
|
||||
receipt.request_id !== plan.request_id
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (receipt.state === "not_found") {
|
||||
return [
|
||||
receipt.operation,
|
||||
receipt.memory_id,
|
||||
receipt.result_revision,
|
||||
receipt.result_status,
|
||||
receipt.memory_state,
|
||||
receipt.migrated_from,
|
||||
].every((item) => item === null)
|
||||
? "not_found"
|
||||
: null;
|
||||
}
|
||||
const expectedOperation =
|
||||
plan.operation === "add" ? "create" : plan.operation;
|
||||
const expectedRevision =
|
||||
plan.operation === "add" ? 1 : (plan.expected_revision as number) + 1;
|
||||
const expectedStatus = plan.operation === "delete" ? "deleted" : "confirmed";
|
||||
// A migrated receipt records an operation from before v2; no plan made
|
||||
// today can have produced it.
|
||||
if (
|
||||
receipt.state !== "committed" ||
|
||||
receipt.migrated_from !== null ||
|
||||
receipt.operation !== expectedOperation ||
|
||||
typeof receipt.memory_id !== "string" ||
|
||||
!isMemoryId(receipt.memory_id) ||
|
||||
(plan.memory_id !== undefined && receipt.memory_id !== plan.memory_id) ||
|
||||
receipt.result_revision !== expectedRevision ||
|
||||
receipt.result_status !== expectedStatus ||
|
||||
!["present", "deleted"].includes(String(receipt.memory_state))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
schema_version: 2,
|
||||
request_id: plan.request_id,
|
||||
state: "committed",
|
||||
operation: expectedOperation,
|
||||
memory_id: receipt.memory_id,
|
||||
result_revision: expectedRevision,
|
||||
result_status: expectedStatus,
|
||||
memory_state: receipt.memory_state,
|
||||
proof_scope: "governance_transaction_only",
|
||||
downstream_sync_verified: false,
|
||||
payload_fingerprint_verified: false,
|
||||
};
|
||||
}
|
||||
|
||||
function validatePlan(value: unknown): MemoryMutationPlan | null {
|
||||
const plan = record(value);
|
||||
if (
|
||||
plan?.schema_version !== 1 ||
|
||||
plan.kind !== "memory_mutation" ||
|
||||
!oneOf(plan.operation, OPERATIONS) ||
|
||||
typeof plan.request_id !== "string" ||
|
||||
!UUID.test(plan.request_id) ||
|
||||
typeof plan.digest !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const body = { ...plan };
|
||||
delete body.digest;
|
||||
const expected = withDigest(body as unknown as PlanBody);
|
||||
if (!safeEqual(expected.digest, plan.digest)) return null;
|
||||
if (plan.operation === "add") {
|
||||
if (
|
||||
!oneOf(plan.memory_kind, MEMORY_KINDS) ||
|
||||
!validScopes(plan.scopes) ||
|
||||
!validIds(plan.evidence_ids, 500) ||
|
||||
!contentFields(plan)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
} else if (
|
||||
typeof plan.memory_id !== "string" ||
|
||||
!isMemoryId(plan.memory_id) ||
|
||||
!Number.isSafeInteger(plan.expected_revision) ||
|
||||
(plan.expected_revision as number) < 1 ||
|
||||
plan.expected_status !== "confirmed" ||
|
||||
!oneOf(plan.expected_origin, MEMORY_ORIGINS) ||
|
||||
!validCurrent(plan) ||
|
||||
(plan.operation === "correct" && !contentFields(plan))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return plan as unknown as MemoryMutationPlan;
|
||||
}
|
||||
|
||||
function contentFields(plan: Record<string, unknown>): boolean {
|
||||
return (
|
||||
typeof plan.content_sha256 === "string" &&
|
||||
/^[0-9a-f]{64}$/.test(plan.content_sha256) &&
|
||||
Number.isSafeInteger(plan.content_characters) &&
|
||||
(plan.content_characters as number) >= 1 &&
|
||||
(plan.content_characters as number) <= CONTENT_MAX_CHARACTERS
|
||||
);
|
||||
}
|
||||
|
||||
function validCurrent(plan: Record<string, unknown>): boolean {
|
||||
const current = record(plan.current);
|
||||
if (
|
||||
!current ||
|
||||
current.status !== plan.expected_status ||
|
||||
current.origin !== plan.expected_origin ||
|
||||
current.revision !== plan.expected_revision ||
|
||||
!validScopes(current.scopes) ||
|
||||
!Array.isArray(current.evidence) ||
|
||||
current.evidence.length < 1 ||
|
||||
current.evidence.length > 500
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return current.evidence.every((value) => {
|
||||
const evidence = record(value);
|
||||
return (
|
||||
evidence !== null &&
|
||||
typeof evidence.evidence_id === "string" &&
|
||||
ID.test(evidence.evidence_id) &&
|
||||
typeof evidence.available === "boolean" &&
|
||||
(evidence.source_id === undefined ||
|
||||
(typeof evidence.source_id === "string" &&
|
||||
evidence.source_id.length <= 128)) &&
|
||||
(evidence.written_by === undefined ||
|
||||
(typeof evidence.written_by === "string" &&
|
||||
evidence.written_by.length <= 256))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function contentIdentity(content: string): {
|
||||
content_sha256: string;
|
||||
content_characters: number;
|
||||
} {
|
||||
return {
|
||||
content_sha256: createHash("sha256").update(content, "utf8").digest("hex"),
|
||||
content_characters: [...content].length,
|
||||
};
|
||||
}
|
||||
|
||||
function validContent(content: string): boolean {
|
||||
return (
|
||||
content.trim().length > 0 &&
|
||||
[...content].length <= CONTENT_MAX_CHARACTERS &&
|
||||
![...content].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code <= 8 || (code >= 11 && code <= 31) || code === 127;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function validScopes(value: unknown): value is string[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < 1 ||
|
||||
value.length > 128 ||
|
||||
new Set(value).size !== value.length ||
|
||||
value.some(
|
||||
(scope) =>
|
||||
typeof scope !== "string" ||
|
||||
scope.trim() !== scope ||
|
||||
scope.length < 1 ||
|
||||
scope.length > 512 ||
|
||||
scope === "*",
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return !value.includes("private") || value.length === 1;
|
||||
}
|
||||
|
||||
function validIds(value: unknown, maximum: number): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length >= 1 &&
|
||||
value.length <= maximum &&
|
||||
new Set(value).size === value.length &&
|
||||
value.every((item) => typeof item === "string" && ID.test(item))
|
||||
);
|
||||
}
|
||||
|
||||
function withDigest(body: PlanBody): MemoryMutationPlan {
|
||||
return {
|
||||
...body,
|
||||
digest: createHash("sha256")
|
||||
.update(JSON.stringify(body), "utf8")
|
||||
.digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function preview(plan: MemoryMutationPlan): MemoryFlowDocument {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "preview",
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
function invalid(code: string): MemoryFlowDocument {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "invalid",
|
||||
code,
|
||||
};
|
||||
}
|
||||
|
||||
function pending(operationId: string, code: string): MemoryFlowDocument {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "unknown",
|
||||
operation_id: operationId,
|
||||
code,
|
||||
};
|
||||
}
|
||||
|
||||
function same(left: readonly string[], right: readonly string[]): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((value, index) => value === right[index])
|
||||
);
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
return timingSafeEqual(Buffer.from(left), Buffer.from(right));
|
||||
}
|
||||
|
||||
function oneOf<const T extends readonly string[]>(
|
||||
value: unknown,
|
||||
values: T,
|
||||
): value is T[number] {
|
||||
return typeof value === "string" && values.includes(value as T[number]);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
import { modeEffects, parseSettingsCommand } from "./memory-mode-flow.js";
|
||||
import { composeStatusReport, renderStatusText } from "./status-report.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(),
|
||||
post: vi.fn(),
|
||||
isAxiosError: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
const RUN_ID = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
function modeState(mode: string, configured = mode) {
|
||||
return {
|
||||
mode,
|
||||
configured_mode: configured,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: { state: "idle", users: {} },
|
||||
};
|
||||
}
|
||||
|
||||
function receipt(state: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
run_id: RUN_ID,
|
||||
user_id: "wife",
|
||||
agent_id: "all-apps",
|
||||
state,
|
||||
trigger: "manual",
|
||||
started_at: "2026-11-01T03:00:00Z",
|
||||
rounds: state === "running" ? 0 : 2,
|
||||
tasks_processed: state === "running" ? 0 : 5,
|
||||
batch_failures: 0,
|
||||
capped: false,
|
||||
elapsed_ms: 10,
|
||||
results: { schemas: ["never printed"] },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const method of Object.values(transport)) method.mockReset();
|
||||
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",
|
||||
},
|
||||
});
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/settings/memory-mode"
|
||||
? { status: 200, data: modeState("pro") }
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
});
|
||||
|
||||
describe("memhub settings mode", () => {
|
||||
it("shows the current mode without changing it", async () => {
|
||||
const result = await capturedRun(["settings", "mode", "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout).mode.mode).toBe("pro");
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints the plan and changes nothing without --yes", async () => {
|
||||
const result = await capturedRun(["settings", "mode", "lite"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
expect(result.stdout).toContain("更高模式生成的记忆仍保存着");
|
||||
expect(result.stdout).toContain("memhub settings mode lite --yes");
|
||||
const json = JSON.parse(
|
||||
(await capturedRun(["settings", "mode", "ultra", "--json"])).stdout,
|
||||
);
|
||||
expect(json.state).toBe("preview");
|
||||
expect(json.applied).toBe(false);
|
||||
expect(json.effects).toEqual(["existing_memories_added_to_graph"]);
|
||||
});
|
||||
|
||||
it("changes the mode with --yes and reports what changed", async () => {
|
||||
transport.put.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
...modeState("lite"),
|
||||
previous_mode: "pro",
|
||||
effects: ["higher_mode_memories_hidden"],
|
||||
},
|
||||
});
|
||||
const result = await capturedRun(["settings", "mode", "lite", "--yes"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.put).toHaveBeenCalledWith(
|
||||
"/api/v1/settings/memory-mode",
|
||||
{ mode: "lite" },
|
||||
undefined,
|
||||
);
|
||||
expect(result.stdout).toContain("已切换");
|
||||
expect(result.stdout).toContain("原始对话:全部保留");
|
||||
});
|
||||
|
||||
it("does nothing for the mode already set, and refuses bad input", async () => {
|
||||
const same = await capturedRun(["settings", "mode", "pro", "--yes"]);
|
||||
expect(same.code).toBe(0);
|
||||
expect(same.stdout).toContain("已经是这个模式");
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
for (const bad of [
|
||||
["settings"],
|
||||
["settings", "mode", "max"],
|
||||
["settings", "mode", "--yes"],
|
||||
["settings", "mode", "lite", "pro"],
|
||||
["settings", "language", "en"],
|
||||
]) {
|
||||
expect((await capturedRun(bad)).code, bad.join(" ")).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("plans the same effects Core reports", () => {
|
||||
expect(modeEffects("lite", "pro")).toEqual([
|
||||
"earlier_raw_not_extracted",
|
||||
"lite_search_stops",
|
||||
]);
|
||||
expect(modeEffects("ultra", "lite")).toEqual([
|
||||
"higher_mode_memories_hidden",
|
||||
"evolve_stopped",
|
||||
]);
|
||||
expect(modeEffects("pro", "pro")).toEqual([]);
|
||||
expect(parseSettingsCommand(["mode", "ultra", "--yes"])).toEqual({
|
||||
kind: "set",
|
||||
mode: "ultra",
|
||||
confirmed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("memhub evolve", () => {
|
||||
it("starts a run for the named user and returns at once", async () => {
|
||||
transport.post.mockResolvedValue({ status: 202, data: receipt("running") });
|
||||
const result = await capturedRun([
|
||||
"evolve",
|
||||
"--user-id",
|
||||
"wife",
|
||||
"--agent-id",
|
||||
"all-apps",
|
||||
"--max-batches",
|
||||
"3",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v2/evolve",
|
||||
{ user_id: "wife", agent_id: "all-apps", max_batches: 3 },
|
||||
undefined,
|
||||
);
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.state).toBe("running");
|
||||
expect(document.run.results).toBeUndefined();
|
||||
});
|
||||
|
||||
it("follows the run with --wait until it ends", async () => {
|
||||
transport.post.mockResolvedValue({ status: 202, data: receipt("running") });
|
||||
const reads = [receipt("running"), receipt("succeeded")];
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === `/api/v2/evolve/${RUN_ID}`
|
||||
? { status: 200, data: reads.shift() }
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
const result = await capturedRun(["evolve", "--wait"], {
|
||||
sleep: async () => undefined,
|
||||
});
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v2/evolve",
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
expect(result.stdout).toContain("进化完成");
|
||||
expect(result.stdout).toContain("2 轮");
|
||||
});
|
||||
|
||||
it("names the mode when Core refuses outside ultra", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "memory_mode_not_ultra" },
|
||||
});
|
||||
const result = await capturedRun(["evolve"]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stdout).toContain("memhub settings mode ultra --yes");
|
||||
expect((await capturedRun(["evolve", "--max-batches", "0"])).code).toBe(2);
|
||||
expect((await capturedRun(["evolve", "--now"])).code).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("status pipeline", () => {
|
||||
it("reports pipeline.mode and the last evolve from memory-processing", async () => {
|
||||
const processing = {
|
||||
pending: 0,
|
||||
fresh_pending: 0,
|
||||
backlog_pending: 0,
|
||||
outside_window_pending: 0,
|
||||
running: 0,
|
||||
waiting_for_model: 0,
|
||||
completed: 3,
|
||||
failed: 0,
|
||||
blocked_stages: [],
|
||||
pipeline: {
|
||||
mode: "ultra",
|
||||
configured_mode: "ultra",
|
||||
forced_by_env: false,
|
||||
last_evolve_at: "2026-11-01T03:00:05+08:00",
|
||||
graph_build: { state: "succeeded", users: {} },
|
||||
evolve: {
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
running: [],
|
||||
users: {
|
||||
local: {
|
||||
last_run_at: "2026-11-01T03:00:05+08:00",
|
||||
last_result: { state: "succeeded", tasks_processed: 4 },
|
||||
last_error: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const report = await composeStatusReport({
|
||||
dataDir: "/tmp/isolated",
|
||||
runtime: { state: "running", code: "ok", host: "127.0.0.1", port: 1 },
|
||||
background: null,
|
||||
reader: (async (path: string) =>
|
||||
path === "/api/v1/memory-processing"
|
||||
? { state: "ok", data: processing }
|
||||
: { state: "unavailable", code: "x" }) as never,
|
||||
});
|
||||
expect(report.pipeline?.mode).toBe("ultra");
|
||||
expect(report.pipeline?.last_evolve_at).toBe("2026-11-01T03:00:05+08:00");
|
||||
expect(report.pipeline?.evolve_users.local?.state).toBe("succeeded");
|
||||
const text = renderStatusText(report, "MemHub");
|
||||
expect(text).toContain("记忆模式:ultra");
|
||||
expect(text).toContain("每日 03:00 自动进化");
|
||||
});
|
||||
});
|
||||
|
||||
async function capturedRun(
|
||||
arguments_: string[],
|
||||
controls: Parameters<typeof run>[2] = {},
|
||||
) {
|
||||
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" },
|
||||
controls,
|
||||
),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
/**
|
||||
* `memhub settings mode` and `memhub evolve`.
|
||||
*
|
||||
* The mode (lite / pro / ultra) decides what the SDK does with every
|
||||
* conversation, and switching it migrates nothing: what one mode made is not
|
||||
* shown by a lower one, and raw kept by lite is not extracted until it is
|
||||
* imported again. So a change prints its plan first and needs `--yes`, like
|
||||
* every other command whose effect a person cannot simply take back.
|
||||
*
|
||||
* `memhub evolve` starts System 2 (ultra only) and, with `--wait`, follows
|
||||
* the run until it ends.
|
||||
*/
|
||||
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations, CorePutOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
export const MEMORY_MODES = ["lite", "pro", "ultra"] as const;
|
||||
export type MemoryMode = (typeof MEMORY_MODES)[number];
|
||||
|
||||
export type ModeEffect =
|
||||
| "earlier_raw_not_extracted"
|
||||
| "higher_mode_memories_hidden"
|
||||
| "existing_memories_added_to_graph"
|
||||
| "evolve_stopped"
|
||||
| "lite_search_stops"
|
||||
| "graph_backfill_already_running";
|
||||
|
||||
const MODE_SUMMARY: Record<MemoryMode, string> = {
|
||||
lite: "lite:不调用模型,保留全部原始对话,只在原始对话里检索",
|
||||
pro: "pro:模型从对话中提取记忆(System 1)",
|
||||
ultra: "ultra:pro + 每晚自动进化(System 2,使用知识图谱)",
|
||||
};
|
||||
|
||||
const EFFECT_TEXT: Record<ModeEffect, string> = {
|
||||
earlier_raw_not_extracted:
|
||||
"lite 下已处理完的对话不会再被提取;只有尚未处理的和之后的新对话会由模型整理成记忆,检索立即改为记忆检索",
|
||||
higher_mode_memories_hidden:
|
||||
"更高模式生成的记忆仍保存着,但在这个模式下不会显示",
|
||||
existing_memories_added_to_graph:
|
||||
"已有记忆会在后台加入知识图谱(每个用户一次)",
|
||||
evolve_stopped: "每晚的自动进化(System 2)停止",
|
||||
lite_search_stops: "lite 只在原始对话里的检索立即停止,改为检索记忆",
|
||||
graph_backfill_already_running: "知识图谱的补建已在进行中",
|
||||
};
|
||||
|
||||
/** The same rule Core applies (`memory_mode_effects`), for the plan. */
|
||||
export function modeEffects(
|
||||
previous: MemoryMode,
|
||||
next: MemoryMode,
|
||||
): ModeEffect[] {
|
||||
if (previous === next) return [];
|
||||
const rank: Record<MemoryMode, number> = { lite: 0, pro: 1, ultra: 2 };
|
||||
const effects: ModeEffect[] = [];
|
||||
if (previous === "lite") {
|
||||
effects.push("earlier_raw_not_extracted", "lite_search_stops");
|
||||
}
|
||||
if (rank[next] < rank[previous]) effects.push("higher_mode_memories_hidden");
|
||||
if (previous === "ultra") effects.push("evolve_stopped");
|
||||
if (next === "ultra") effects.push("existing_memories_added_to_graph");
|
||||
return effects;
|
||||
}
|
||||
|
||||
export type ModeCommand =
|
||||
| { kind: "show" }
|
||||
| { kind: "set"; mode: MemoryMode; confirmed: boolean };
|
||||
|
||||
/** `settings mode [lite|pro|ultra] [--yes]` after `settings`; null = usage. */
|
||||
export function parseSettingsCommand(
|
||||
values: readonly string[],
|
||||
): ModeCommand | null {
|
||||
const rest = values.filter((value) => value !== "--json");
|
||||
if (rest[0] !== "mode") return null;
|
||||
const tail = rest.slice(1);
|
||||
const confirmed = tail.includes("--yes");
|
||||
const positional = tail.filter((value) => value !== "--yes");
|
||||
if (tail.filter((value) => value === "--yes").length > 1) return null;
|
||||
if (positional.length === 0) return confirmed ? null : { kind: "show" };
|
||||
if (positional.length !== 1) return null;
|
||||
const mode = positional[0] as MemoryMode;
|
||||
if (!(MEMORY_MODES as readonly string[]).includes(mode)) return null;
|
||||
return { kind: "set", mode, confirmed };
|
||||
}
|
||||
|
||||
export interface ModeState {
|
||||
mode: MemoryMode;
|
||||
configured_mode: MemoryMode;
|
||||
forced_by_env: boolean;
|
||||
graph_build?: { state?: string } | null;
|
||||
previous_mode?: MemoryMode;
|
||||
effects?: ModeEffect[];
|
||||
}
|
||||
|
||||
function modeState(value: unknown): ModeState | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const item = value as Record<string, unknown>;
|
||||
if (
|
||||
!(MEMORY_MODES as readonly unknown[]).includes(item.mode) ||
|
||||
!(MEMORY_MODES as readonly unknown[]).includes(item.configured_mode) ||
|
||||
typeof item.forced_by_env !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return item as unknown as ModeState;
|
||||
}
|
||||
|
||||
export type ModeDocument = {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "settings mode";
|
||||
} & (
|
||||
| { state: "ok"; mode: ModeState }
|
||||
| {
|
||||
state: "preview";
|
||||
mode: ModeState;
|
||||
target: MemoryMode;
|
||||
effects: ModeEffect[];
|
||||
applied: false;
|
||||
next_step: string;
|
||||
}
|
||||
| { state: "unchanged"; mode: ModeState }
|
||||
| { state: "committed"; mode: ModeState; effects: ModeEffect[] }
|
||||
| { state: "rejected" | "unknown" | "unavailable"; code: string }
|
||||
);
|
||||
|
||||
const BASE = {
|
||||
schema_version: 1 as const,
|
||||
product: PRODUCT,
|
||||
command: "settings mode" as const,
|
||||
};
|
||||
|
||||
export async function runModeCommand(
|
||||
command: ModeCommand,
|
||||
reader: CoreReader,
|
||||
operations: CorePutOperations,
|
||||
): Promise<ModeDocument> {
|
||||
const read = await reader<unknown>("/api/v1/settings/memory-mode");
|
||||
if (read.state !== "ok") {
|
||||
return { ...BASE, state: "unavailable", code: read.code };
|
||||
}
|
||||
const current = modeState(read.data);
|
||||
if (current === null) {
|
||||
return { ...BASE, state: "unavailable", code: "response_invalid" };
|
||||
}
|
||||
if (command.kind === "show") return { ...BASE, state: "ok", mode: current };
|
||||
if (current.configured_mode === command.mode) {
|
||||
return { ...BASE, state: "unchanged", mode: current };
|
||||
}
|
||||
if (!command.confirmed) {
|
||||
return {
|
||||
...BASE,
|
||||
state: "preview",
|
||||
mode: current,
|
||||
target: command.mode,
|
||||
effects: modeEffects(current.mode, command.mode),
|
||||
applied: false,
|
||||
next_step: `${PRODUCT} settings mode ${command.mode} --yes`,
|
||||
};
|
||||
}
|
||||
const result = await operations.put<unknown>("/api/v1/settings/memory-mode", {
|
||||
mode: command.mode,
|
||||
});
|
||||
if (result.state !== "committed") {
|
||||
return { ...BASE, state: result.state, code: result.code };
|
||||
}
|
||||
const changed = modeState(result.data);
|
||||
if (changed === null) {
|
||||
return { ...BASE, state: "unknown", code: "response_invalid" };
|
||||
}
|
||||
return {
|
||||
...BASE,
|
||||
state: "committed",
|
||||
mode: changed,
|
||||
effects: changed.effects ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function modeExitCode(document: ModeDocument): number {
|
||||
return ["ok", "preview", "unchanged", "committed"].includes(document.state)
|
||||
? 0
|
||||
: 1;
|
||||
}
|
||||
|
||||
export function renderModeText(document: ModeDocument): string {
|
||||
if ("code" in document) {
|
||||
return `记忆模式未能${document.state === "unavailable" ? "读取" : "修改"}:${document.code}\n`;
|
||||
}
|
||||
const mode = document.mode;
|
||||
const lines = [`当前模式:${MODE_SUMMARY[mode.mode]}`];
|
||||
if (mode.forced_by_env) {
|
||||
lines.push(
|
||||
`设置里是 ${mode.configured_mode};环境变量 ONE_MEMORY_GRAPH_STORE 强制使用 ultra。`,
|
||||
);
|
||||
}
|
||||
lines.push("原始对话:全部保留(任何模式都不删除)");
|
||||
if (document.state === "unchanged") {
|
||||
lines.push("已经是这个模式,未做改动。");
|
||||
}
|
||||
if (document.state === "preview") {
|
||||
lines.push(`将切换到:${MODE_SUMMARY[document.target]}`);
|
||||
for (const effect of document.effects)
|
||||
lines.push(`- ${EFFECT_TEXT[effect]}`);
|
||||
lines.push(`未执行任何改动。确认后运行:${document.next_step}`);
|
||||
}
|
||||
if (document.state === "committed") {
|
||||
lines.push("已切换。");
|
||||
for (const effect of document.effects)
|
||||
lines.push(`- ${EFFECT_TEXT[effect]}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export type EvolveCommand = {
|
||||
agentId?: string;
|
||||
maxBatches?: number;
|
||||
wait: boolean;
|
||||
};
|
||||
|
||||
/** `evolve [--agent-id <id>] [--max-batches <n>] [--wait]`; null = usage. */
|
||||
export function parseEvolveCommand(
|
||||
values: readonly string[],
|
||||
): EvolveCommand | null {
|
||||
const parsed: EvolveCommand = { wait: false };
|
||||
const rest = values.filter((value) => value !== "--json");
|
||||
for (let index = 0; index < rest.length; index += 1) {
|
||||
const flag = rest[index];
|
||||
if (flag === "--wait" && !parsed.wait) {
|
||||
parsed.wait = true;
|
||||
} else if (flag === "--agent-id" && parsed.agentId === undefined) {
|
||||
const value = rest[index + 1];
|
||||
if (!value || !/^[A-Za-z0-9._:-]{1,128}$/.test(value)) return null;
|
||||
parsed.agentId = value;
|
||||
index += 1;
|
||||
} else if (flag === "--max-batches" && parsed.maxBatches === undefined) {
|
||||
const value = Number(rest[index + 1]);
|
||||
if (!Number.isInteger(value) || value < 1 || value > 1000) return null;
|
||||
parsed.maxBatches = value;
|
||||
index += 1;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export type EvolveDocument = {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "evolve";
|
||||
} & (
|
||||
| { state: "running" | "succeeded" | "failed"; run: Record<string, unknown> }
|
||||
| { state: "rejected" | "unknown" | "unavailable"; code: string }
|
||||
);
|
||||
|
||||
const RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
function evolveRun(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const run = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof run.run_id !== "string" ||
|
||||
!RUN_ID.test(run.run_id) ||
|
||||
!["running", "succeeded", "failed"].includes(String(run.state))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function runEvolveCommand(
|
||||
command: EvolveCommand,
|
||||
options: {
|
||||
userId?: string;
|
||||
reader: CoreReader;
|
||||
operations: CoreOperations;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
pollMs?: number;
|
||||
},
|
||||
): Promise<EvolveDocument> {
|
||||
const base = {
|
||||
schema_version: 1 as const,
|
||||
product: PRODUCT,
|
||||
command: "evolve" as const,
|
||||
};
|
||||
const body: Record<string, unknown> = {};
|
||||
if (options.userId !== undefined) body.user_id = options.userId;
|
||||
if (command.agentId !== undefined) body.agent_id = command.agentId;
|
||||
if (command.maxBatches !== undefined) body.max_batches = command.maxBatches;
|
||||
const started = await options.operations.post<unknown>(
|
||||
"/api/v2/evolve",
|
||||
body,
|
||||
);
|
||||
if (started.state !== "committed") {
|
||||
return { ...base, state: started.state, code: started.code };
|
||||
}
|
||||
let run = evolveRun(started.data);
|
||||
if (run === null)
|
||||
return { ...base, state: "unknown", code: "response_invalid" };
|
||||
const sleep =
|
||||
options.sleep ??
|
||||
((ms: number) => new Promise((done) => setTimeout(done, ms)));
|
||||
while (command.wait && run.state === "running") {
|
||||
await sleep(options.pollMs ?? 2_000);
|
||||
const read = await options.reader<unknown>(
|
||||
`/api/v2/evolve/${run.run_id}`,
|
||||
options.userId === undefined ? {} : { user_id: options.userId },
|
||||
);
|
||||
if (read.state !== "ok") {
|
||||
return { ...base, state: "unavailable", code: read.code };
|
||||
}
|
||||
const next = evolveRun(read.data);
|
||||
if (next === null)
|
||||
return { ...base, state: "unknown", code: "response_invalid" };
|
||||
run = next;
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: run.state as "running" | "succeeded" | "failed",
|
||||
run: projectRun(run),
|
||||
};
|
||||
}
|
||||
|
||||
/** Counts and codes; SDK result bodies stay in the Core receipt. */
|
||||
function projectRun(run: Record<string, unknown>): Record<string, unknown> {
|
||||
const keep = [
|
||||
"run_id",
|
||||
"user_id",
|
||||
"agent_id",
|
||||
"state",
|
||||
"trigger",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"rounds",
|
||||
"tasks_processed",
|
||||
"batch_failures",
|
||||
"cycle_status",
|
||||
"should_continue",
|
||||
"continuation_reason",
|
||||
"remaining_clusters",
|
||||
"remaining_actionable_clusters",
|
||||
"remaining_fresh_facts",
|
||||
"capped",
|
||||
"elapsed_ms",
|
||||
"error_code",
|
||||
"failure_kind",
|
||||
];
|
||||
return Object.fromEntries(
|
||||
keep.filter((key) => key in run).map((key) => [key, run[key]]),
|
||||
);
|
||||
}
|
||||
|
||||
export function evolveExitCode(document: EvolveDocument): number {
|
||||
return document.state === "running" || document.state === "succeeded" ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderEvolveText(document: EvolveDocument): string {
|
||||
if ("code" in document) {
|
||||
return document.code === "memory_mode_not_ultra"
|
||||
? "进化只在 ultra 模式下可用。切换:memhub settings mode ultra --yes\n"
|
||||
: `进化未能开始:${document.code}\n`;
|
||||
}
|
||||
const run = document.run;
|
||||
if (document.state === "running") {
|
||||
return `进化已开始(${run.run_id}),在后台运行;加 --wait 等待结果。\n`;
|
||||
}
|
||||
if (document.state === "failed") {
|
||||
return `进化失败(${run.run_id}):${String(run.error_code ?? "unknown")}\n`;
|
||||
}
|
||||
return `进化完成(${run.run_id}):${run.rounds} 轮,处理 ${run.tasks_processed} 项${
|
||||
run.cycle_status ? `,周期 ${run.cycle_status}` : ""
|
||||
}${run.capped ? ",已到轮数上限,可再次运行继续" : ""}\n`;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import {
|
||||
planExistingMemoryMutation,
|
||||
planMemoryAdd,
|
||||
readMemoryMutationReceipt,
|
||||
} from "./memory-flows.js";
|
||||
|
||||
// Written by a real Core and checked against it on the Core side, so these are
|
||||
// the receipts the CLI meets in use rather than ones shaped to fit the parser.
|
||||
const FIXTURE = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
"../../../packages/contracts/fixtures/memory-mutation-receipts.v2.json",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
) as { receipts: Record<"add" | "correct" | "delete", Receipt> };
|
||||
|
||||
interface Receipt {
|
||||
schema_version: number;
|
||||
request_id: string;
|
||||
memory_id: string;
|
||||
result_revision: number;
|
||||
result_status: string;
|
||||
operation: string;
|
||||
}
|
||||
|
||||
function reader(data: unknown): CoreReader {
|
||||
return async <T>() => ({ state: "ok", data: data as T });
|
||||
}
|
||||
|
||||
function detail(sample: Receipt) {
|
||||
const revision = sample.result_revision - 1;
|
||||
return {
|
||||
memory: {
|
||||
memory_id: sample.memory_id,
|
||||
user_id: "local",
|
||||
kind: "chat",
|
||||
status: "confirmed",
|
||||
content: "Synthetic memory.",
|
||||
scopes: ["all-apps"],
|
||||
evidence_ids: ["evidence-1"],
|
||||
origin: "manual",
|
||||
tags: [],
|
||||
revision,
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
updated_at: "2026-09-19T00:00:01Z",
|
||||
deleted_at: null,
|
||||
},
|
||||
evidence: [
|
||||
{
|
||||
evidence_id: "evidence-1",
|
||||
available: true,
|
||||
source_id: "fixture",
|
||||
written_by: "person",
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
verbatim: "Synthetic source text.",
|
||||
},
|
||||
],
|
||||
history: [
|
||||
{
|
||||
revision,
|
||||
action: "create",
|
||||
status: "confirmed",
|
||||
content: "Synthetic memory.",
|
||||
scopes: ["all-apps"],
|
||||
created_at: "2026-09-19T00:00:01Z",
|
||||
},
|
||||
],
|
||||
index_sync: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("memory mutation receipt v2 from Core", () => {
|
||||
it("recovers an add from the receipt Core returns for a create", async () => {
|
||||
const sample = FIXTURE.receipts.add;
|
||||
expect(sample.schema_version).toBe(2);
|
||||
const preview = planMemoryAdd({
|
||||
content: "Synthetic memory.",
|
||||
memoryKind: "chat",
|
||||
scopes: ["all-apps"],
|
||||
evidenceIds: ["evidence-1"],
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
// The request id is the one binding a receipt to its plan; the plan picks it.
|
||||
const result = await readMemoryMutationReceipt(
|
||||
reader({ ...sample, request_id: preview.plan.request_id }),
|
||||
preview.plan,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "recovered",
|
||||
receipt: {
|
||||
schema_version: 2,
|
||||
operation: "create",
|
||||
memory_id: sample.memory_id,
|
||||
result_revision: 1,
|
||||
result_status: "confirmed",
|
||||
memory_state: "present",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each<["correct" | "delete", { content?: string }]>([
|
||||
["correct", { content: "Corrected synthetic memory." }],
|
||||
["delete", {}],
|
||||
])(
|
||||
"recovers %s from the receipt Core returns for it",
|
||||
async (operation, extra) => {
|
||||
const sample = FIXTURE.receipts[operation];
|
||||
const preview = await planExistingMemoryMutation(reader(detail(sample)), {
|
||||
operation,
|
||||
memoryId: sample.memory_id,
|
||||
...extra,
|
||||
});
|
||||
if (preview.state !== "preview") {
|
||||
throw new Error(`preview expected: ${JSON.stringify(preview)}`);
|
||||
}
|
||||
const result = await readMemoryMutationReceipt(
|
||||
reader({ ...sample, request_id: preview.plan.request_id }),
|
||||
preview.plan,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "recovered",
|
||||
receipt: {
|
||||
schema_version: 2,
|
||||
operation: sample.operation,
|
||||
memory_id: sample.memory_id,
|
||||
result_revision: sample.result_revision,
|
||||
result_status: sample.result_status,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not take a v1 receipt for a v2 one", async () => {
|
||||
const sample = FIXTURE.receipts.delete;
|
||||
const preview = await planExistingMemoryMutation(reader(detail(sample)), {
|
||||
operation: "delete",
|
||||
memoryId: sample.memory_id,
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
const { migrated_from: _dropped, ...v1 } = sample as Receipt & {
|
||||
migrated_from: null;
|
||||
};
|
||||
// As v1 was, and with v2's fields but v1's number: either alone is refused.
|
||||
for (const receipt of [
|
||||
{ ...v1, schema_version: 1 },
|
||||
{ ...sample, schema_version: 1 },
|
||||
]) {
|
||||
const result = await readMemoryMutationReceipt(
|
||||
reader({ ...receipt, request_id: preview.plan.request_id }),
|
||||
preview.plan,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "unknown",
|
||||
code: "memory_mutation_receipt_invalid",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not take a not-found receipt that names a migrated operation", async () => {
|
||||
const sample = FIXTURE.receipts.delete;
|
||||
const preview = await planExistingMemoryMutation(reader(detail(sample)), {
|
||||
operation: "delete",
|
||||
memoryId: sample.memory_id,
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
const notFound = {
|
||||
schema_version: 2,
|
||||
request_id: preview.plan.request_id,
|
||||
state: "not_found",
|
||||
operation: null,
|
||||
memory_id: null,
|
||||
result_revision: null,
|
||||
result_status: null,
|
||||
memory_state: null,
|
||||
};
|
||||
expect(
|
||||
await readMemoryMutationReceipt(
|
||||
reader({ ...notFound, migrated_from: null }),
|
||||
preview.plan,
|
||||
),
|
||||
).toMatchObject({
|
||||
code: "memory_mutation_receipt_not_found_after_attempt",
|
||||
});
|
||||
expect(
|
||||
await readMemoryMutationReceipt(
|
||||
reader({ ...notFound, migrated_from: "reject" }),
|
||||
preview.plan,
|
||||
),
|
||||
).toMatchObject({ code: "memory_mutation_receipt_invalid" });
|
||||
});
|
||||
|
||||
it("plans nothing on a memory that is not in effect", async () => {
|
||||
const sample = FIXTURE.receipts.delete;
|
||||
const superseded = detail(sample);
|
||||
superseded.memory.status = "superseded";
|
||||
superseded.history[0].status = "superseded";
|
||||
const result = await planExistingMemoryMutation(reader(superseded), {
|
||||
operation: "delete",
|
||||
memoryId: sample.memory_id,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
state: "invalid",
|
||||
code: "memory_operation_not_allowed",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,38 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
MemoryAddReceipt,
|
||||
SdkMemoryPage,
|
||||
SdkMemoryRecord,
|
||||
} from "@one-memory/contracts";
|
||||
import {
|
||||
buildMemoryAddRequest,
|
||||
MemoryAddInputError,
|
||||
memoryAddStatusPath,
|
||||
parseMemoryAddReceipt,
|
||||
} from "@one-memory/operations";
|
||||
import type { SdkMemoryPage, SdkMemoryRecord } from "@one-memory/contracts";
|
||||
import { type CoreReader, RESERVED_MEMORY_SEGMENTS } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
/** A MemHub user id, the same shape Core accepts. `local` is the default. */
|
||||
const USER_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
||||
export const DEFAULT_USER_ID = "local";
|
||||
|
||||
export function isUserId(value: string): boolean {
|
||||
return USER_ID.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `user_id` to send, or nothing. The default user is never named on the
|
||||
* wire, so a request without `--user-id` stays exactly what it was before
|
||||
* multi-user support and still works against an older Core.
|
||||
*/
|
||||
export function userIdParameters(userId?: string): { user_id?: string } {
|
||||
return userId === undefined || userId === DEFAULT_USER_ID
|
||||
? {}
|
||||
: { user_id: userId };
|
||||
}
|
||||
|
||||
/** Output budget: a long memory is shortened visibly, never dropped silently. */
|
||||
const CONTENT_BUDGET = 240;
|
||||
|
||||
@@ -46,97 +15,42 @@ export function isMemoryId(value: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
const CONTENT_MAX_CHARACTERS = 100_000;
|
||||
|
||||
/** A memory body read from `--content-file`: UTF-8, non-blank, bounded, and
|
||||
* free of control characters other than tab and newline. */
|
||||
export function parseMemoryContent(bytes: Buffer): string | null {
|
||||
if (bytes.byteLength > CONTENT_MAX_CHARACTERS * 4 + 2) return null;
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const content = decoded.replace(/\r?\n$/, "");
|
||||
return content.trim().length > 0 &&
|
||||
[...content].length <= CONTENT_MAX_CHARACTERS &&
|
||||
![...content].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code <= 8 || (code >= 11 && code <= 31) || code === 127;
|
||||
})
|
||||
? content
|
||||
: null;
|
||||
}
|
||||
|
||||
export const MEMORY_SCENES = ["all", "chat", "working", "coding"] as const;
|
||||
export type MemoryScene = (typeof MEMORY_SCENES)[number];
|
||||
export const MEMORY_KINDS = ["chat", "experience", "fact"] as const;
|
||||
export type MemoryKind = (typeof MEMORY_KINDS)[number];
|
||||
export const MEMORY_VARIANTS = ["working", "coding"] as const;
|
||||
export type MemoryVariant = (typeof MEMORY_VARIANTS)[number];
|
||||
|
||||
/** Which SDK namespace a memory lives in. A chat memory needs neither; a
|
||||
* working/coding experience or fact needs both so Core does not have to guess
|
||||
* (an id present in two namespaces is refused as ambiguous). */
|
||||
export interface MemoryNamespace {
|
||||
kind?: MemoryKind;
|
||||
variant?: MemoryVariant;
|
||||
}
|
||||
|
||||
export function namespaceParameters(
|
||||
namespace: MemoryNamespace,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...(namespace.kind === undefined ? {} : { kind: namespace.kind }),
|
||||
...(namespace.variant === undefined ? {} : { variant: namespace.variant }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MemoryQuery {
|
||||
query?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
scene?: MemoryScene;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** Every `memories` document is schema 2: list/show share it with
|
||||
* add/update/delete, and the v1-era `evidence`/`history` fields are gone. */
|
||||
export interface MemoryPageDocument {
|
||||
schema_version: 2;
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
page: Record<string, unknown>;
|
||||
memories: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface MemoryDetailDocument {
|
||||
schema_version: 2;
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
memory: Record<string, unknown>;
|
||||
evidence: Record<string, unknown>[];
|
||||
history: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export async function readMemoryPage(
|
||||
reader: CoreReader,
|
||||
query: MemoryQuery,
|
||||
userId?: string,
|
||||
): Promise<MemoryPageDocument> {
|
||||
const parameters: Record<string, string | number> = {
|
||||
offset: query.offset,
|
||||
limit: query.limit,
|
||||
...userIdParameters(userId),
|
||||
};
|
||||
if (query.status !== undefined) parameters.statuses = query.status;
|
||||
if (query.source !== undefined) parameters.source = query.source;
|
||||
if (query.query !== undefined) parameters.query = query.query;
|
||||
if (query.scene !== undefined && query.scene !== "all") {
|
||||
parameters.scene = query.scene;
|
||||
}
|
||||
const outcome = await reader<unknown>("/api/v2/memory-library", parameters);
|
||||
if (outcome.state !== "ok") {
|
||||
return {
|
||||
schema_version: 2,
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
page: { state: outcome.state, code: outcome.code },
|
||||
memories: [],
|
||||
@@ -145,7 +59,7 @@ export async function readMemoryPage(
|
||||
const page = sdkMemoryPage(outcome.data);
|
||||
if (page === null) {
|
||||
return {
|
||||
schema_version: 2,
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
page: { state: "invalid", code: "response_invalid" },
|
||||
memories: [],
|
||||
@@ -153,21 +67,15 @@ export async function readMemoryPage(
|
||||
}
|
||||
const items = page.memories;
|
||||
return {
|
||||
schema_version: 2,
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
page: {
|
||||
state: "ok",
|
||||
scene: query.scene ?? "all",
|
||||
...(query.query === undefined ? {} : { query: query.query }),
|
||||
total: page.total,
|
||||
offset: page.offset,
|
||||
limit: page.limit,
|
||||
returned: items.length,
|
||||
has_more: page.offset + items.length < page.total,
|
||||
// A keyword search only looks among this many most relevant memories.
|
||||
...(typeof page.search_window === "number"
|
||||
? { search_window: page.search_window }
|
||||
: {}),
|
||||
},
|
||||
memories: items.map((item) => describeMemory(item)),
|
||||
};
|
||||
@@ -176,211 +84,42 @@ export async function readMemoryPage(
|
||||
export async function readMemoryDetail(
|
||||
reader: CoreReader,
|
||||
memoryId: string,
|
||||
namespace: MemoryNamespace = {},
|
||||
userId?: string,
|
||||
): Promise<MemoryDetailDocument> {
|
||||
const parameters = {
|
||||
...namespaceParameters(namespace),
|
||||
...userIdParameters(userId),
|
||||
};
|
||||
const outcome = await reader<unknown>(
|
||||
`/api/v2/memories/${memoryId}`,
|
||||
Object.keys(parameters).length > 0 ? parameters : undefined,
|
||||
);
|
||||
const outcome = await reader<unknown>(`/api/v2/memories/${memoryId}`);
|
||||
if (outcome.state !== "ok") {
|
||||
return {
|
||||
schema_version: 2,
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
memory: { state: outcome.state, code: outcome.code },
|
||||
evidence: [],
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
const memory = sdkMemory(outcome.data);
|
||||
if (memory === null || memory.memory_id !== memoryId) {
|
||||
return {
|
||||
schema_version: 2,
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
memory: { state: "invalid", code: "response_invalid" },
|
||||
evidence: [],
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
schema_version: 2,
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
memory: { state: "ok", ...describeMemory(memory) },
|
||||
evidence: [],
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
/** The same bound the app's 添加记忆 box applies. */
|
||||
export const MEMORY_ADD_MAX_CHARACTERS = 12_000;
|
||||
|
||||
export interface MemoryAddDocument {
|
||||
schema_version: 2;
|
||||
product: string;
|
||||
command: "memories add" | "memories add-status";
|
||||
state: "accepted" | "rejected" | "unknown" | "not_found" | "invalid";
|
||||
request_id: string;
|
||||
code?: string;
|
||||
receipt?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function isRequestId(value: string): boolean {
|
||||
return UUID.test(value);
|
||||
}
|
||||
|
||||
/** A memory text: non-blank, bounded, no control characters except tab and
|
||||
* newline. */
|
||||
export function validMemoryText(text: string): boolean {
|
||||
return (
|
||||
text.trim().length > 0 &&
|
||||
[...text].length <= MEMORY_ADD_MAX_CHARACTERS &&
|
||||
![...text].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code <= 8 || (code >= 11 && code <= 31) || code === 127;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function addReceipt(receipt: MemoryAddReceipt): Record<string, unknown> {
|
||||
return {
|
||||
evidence_id: receipt.evidence_id,
|
||||
state: receipt.state,
|
||||
created_at: receipt.created_at,
|
||||
task_id: receipt.task_id,
|
||||
memory_ids: receipt.memory_ids,
|
||||
error: receipt.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one memory directly, the same request as the app's 添加记忆.
|
||||
*
|
||||
* `requestId` is Core's idempotency key: retrying with the same id and text
|
||||
* returns the first receipt; the same id with other text is refused.
|
||||
*/
|
||||
export async function addMemory(
|
||||
operations: CoreOperations,
|
||||
text: string,
|
||||
requestId: string = randomUUID(),
|
||||
userId?: string,
|
||||
): Promise<MemoryAddDocument> {
|
||||
const base = {
|
||||
schema_version: 2 as const,
|
||||
product: PRODUCT,
|
||||
command: "memories add" as const,
|
||||
request_id: requestId,
|
||||
};
|
||||
if (!validMemoryText(text) || !UUID.test(requestId)) {
|
||||
return { ...base, state: "invalid", code: "memory_add_invalid" };
|
||||
}
|
||||
let request: ReturnType<typeof buildMemoryAddRequest>;
|
||||
try {
|
||||
request = buildMemoryAddRequest({
|
||||
request_id: requestId,
|
||||
text,
|
||||
scope: "all-apps",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof MemoryAddInputError) {
|
||||
return { ...base, state: "invalid", code: "memory_add_invalid" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await operations.post<unknown>(request.path, {
|
||||
...request.body,
|
||||
...userIdParameters(userId),
|
||||
});
|
||||
if (result.state !== "committed") {
|
||||
return { ...base, state: result.state, code: result.code };
|
||||
}
|
||||
const receipt = parseMemoryAddReceipt(result.data);
|
||||
return receipt === null
|
||||
? { ...base, state: "unknown", code: "memory_add_receipt_invalid" }
|
||||
: { ...base, state: "accepted", receipt: addReceipt(receipt) };
|
||||
}
|
||||
|
||||
/** Read how one addition is doing, by the request id it was submitted with. */
|
||||
export async function readMemoryAddStatus(
|
||||
reader: CoreReader,
|
||||
requestId: string,
|
||||
userId?: string,
|
||||
): Promise<MemoryAddDocument> {
|
||||
const base = {
|
||||
schema_version: 2 as const,
|
||||
product: PRODUCT,
|
||||
command: "memories add-status" as const,
|
||||
request_id: requestId,
|
||||
};
|
||||
if (!UUID.test(requestId)) {
|
||||
return { ...base, state: "invalid", code: "invalid_request_id" };
|
||||
}
|
||||
const user = userIdParameters(userId);
|
||||
const outcome =
|
||||
user.user_id === undefined
|
||||
? await reader<unknown>(memoryAddStatusPath(requestId))
|
||||
: await reader<unknown>(memoryAddStatusPath(requestId), user);
|
||||
if (outcome.state === "not_found") {
|
||||
return { ...base, state: "not_found", code: "memory_add_not_found" };
|
||||
}
|
||||
if (outcome.state !== "ok") {
|
||||
return {
|
||||
...base,
|
||||
state: outcome.state === "rejected" ? "rejected" : "unknown",
|
||||
code: outcome.code,
|
||||
};
|
||||
}
|
||||
const receipt = parseMemoryAddReceipt(outcome.data);
|
||||
return receipt === null
|
||||
? { ...base, state: "unknown", code: "memory_add_receipt_invalid" }
|
||||
: { ...base, state: "accepted", receipt: addReceipt(receipt) };
|
||||
}
|
||||
|
||||
export function memoryAddExitCode(document: MemoryAddDocument): number {
|
||||
return document.state === "accepted" ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderMemoryAddText(document: MemoryAddDocument): string {
|
||||
if (document.state === "accepted" && document.receipt) {
|
||||
return [
|
||||
`request_id: ${document.request_id}`,
|
||||
`evidence_id: ${document.receipt.evidence_id}`,
|
||||
`state: ${document.receipt.state}`,
|
||||
...((document.receipt.memory_ids as string[]).length > 0
|
||||
? [`memory_ids: ${(document.receipt.memory_ids as string[]).join(",")}`]
|
||||
: []),
|
||||
document.command === "memories add"
|
||||
? `查询进度:memhub memories add-status ${document.request_id};重试请带上同一个 --request-id,不会重复添加。`
|
||||
: "",
|
||||
"",
|
||||
]
|
||||
.filter((line, index, all) => line !== "" || index === all.length - 1)
|
||||
.join("\n");
|
||||
}
|
||||
if (document.state === "not_found") {
|
||||
return `未找到该 request id:${document.request_id}\n`;
|
||||
}
|
||||
return [
|
||||
`添加记忆未完成:${document.state}(${document.code})`,
|
||||
document.state === "unknown"
|
||||
? `结果未知;可以用同一个 --request-id ${document.request_id} 安全重试。`
|
||||
: "",
|
||||
"",
|
||||
]
|
||||
.filter((line, index, all) => line !== "" || index === all.length - 1)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function renderMemoryPageText(document: MemoryPageDocument): string {
|
||||
const page = document.page;
|
||||
if (page.state !== "ok") {
|
||||
if (page.state === "unavailable" && page.code === "embedding_unavailable") {
|
||||
return "向量服务不可用,无法检索;请检查向量模型配置。\n";
|
||||
}
|
||||
if (page.state === "unavailable") {
|
||||
return "后台正忙,记忆列表这次没查完,不代表没有记忆,请稍后再试\n";
|
||||
}
|
||||
if (page.state === "rejected") {
|
||||
return `记忆查询被拒绝(${page.code}),请检查参数。\n`;
|
||||
}
|
||||
return `记忆查询未完成:${page.state}(${page.code})\n`;
|
||||
}
|
||||
const lines = [
|
||||
@@ -388,16 +127,9 @@ export function renderMemoryPageText(document: MemoryPageDocument): string {
|
||||
page.has_more ? ",还有更多,请用 --offset 继续" : ""
|
||||
}`,
|
||||
];
|
||||
if (typeof page.search_window === "number") {
|
||||
lines.push(`(搜索只在最相关的 ${page.search_window} 条里匹配关键词)`);
|
||||
}
|
||||
for (const memory of document.memories) {
|
||||
const namespace =
|
||||
memory.kind === "chat"
|
||||
? ""
|
||||
: ` kind=${memory.kind} variant=${memory.variant}`;
|
||||
lines.push(
|
||||
`- ${memory.memory_id} [${memory.scene}] ${memory.status}/${memory.layer} source=${memory.source ?? "未知"}${namespace}`,
|
||||
`- ${memory.memory_id} ${memory.status}/${memory.layer} source=${memory.source ?? "未知"}`,
|
||||
` ${memory.content}${memory.content_truncated ? ` …(共 ${memory.content_characters} 字)` : ""}`,
|
||||
);
|
||||
}
|
||||
@@ -412,14 +144,10 @@ export function renderMemoryDetailText(document: MemoryDetailDocument): string {
|
||||
if (memory.state === "unavailable") {
|
||||
return "后台正忙,记忆详情这次没查完,不代表没有这条记忆,请稍后再试\n";
|
||||
}
|
||||
if (memory.code === "memory_id_ambiguous") {
|
||||
return "这个 ID 同时存在于多个场景;请加 --kind(以及 --variant)指明是哪一条。\n";
|
||||
}
|
||||
return `记忆读取未完成:${memory.state}(${memory.code})\n`;
|
||||
}
|
||||
const lines = [
|
||||
`记忆 ${memory.memory_id}`,
|
||||
`场景:${memory.scene} 类型:${memory.kind}${memory.variant ? `(${memory.variant})` : ""}`,
|
||||
`状态:${memory.status} 层:${memory.layer} 来源:${memory.source ?? "未知"}`,
|
||||
`owner:${memory.owner ?? "未知"} latest:${memory.is_latest}`,
|
||||
`raw IDs:${(memory.source_raw_ids as string[]).join(",") || "无"}`,
|
||||
@@ -437,10 +165,6 @@ function describeMemory(memory: SdkMemoryRecord): Record<string, unknown> {
|
||||
const budgeted = budget(memory.content ?? "");
|
||||
return {
|
||||
memory_id: memory.memory_id,
|
||||
// Pre-2.0.6 Core listed chat memories only and named no scene.
|
||||
scene: memory.scene ?? "chat",
|
||||
kind: memory.kind ?? "chat",
|
||||
variant: memory.variant ?? null,
|
||||
status: memory.status,
|
||||
layer: memory.layer,
|
||||
is_latest: memory.is_latest,
|
||||
@@ -452,9 +176,6 @@ function describeMemory(memory: SdkMemoryRecord): Record<string, unknown> {
|
||||
tags: memory.tags ?? [],
|
||||
memory_at: memory.memory_at,
|
||||
gmt_created: memory.gmt_created,
|
||||
...(memory.task === undefined || memory.task === null
|
||||
? {}
|
||||
: { task: memory.task }),
|
||||
content: budgeted.text,
|
||||
content_truncated: budgeted.truncated,
|
||||
content_characters: budgeted.characters,
|
||||
@@ -472,9 +193,6 @@ function sdkMemoryPage(value: unknown): SdkMemoryPage | null {
|
||||
total: value.total,
|
||||
limit: value.limit,
|
||||
offset: value.offset,
|
||||
...(integer(value.search_window)
|
||||
? { search_window: value.search_window }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -490,9 +208,8 @@ function sdkMemory(value: unknown): SdkMemoryRecord | null {
|
||||
!strings(value.superseded_by) ||
|
||||
!strings(value.tags) ||
|
||||
!strings(value.source_raw_ids) ||
|
||||
// Working/coding memories may carry an empty user or agent id.
|
||||
typeof value.user_id !== "string" ||
|
||||
typeof value.agent_id !== "string"
|
||||
!text(value.user_id) ||
|
||||
!text(value.agent_id)
|
||||
)
|
||||
return null;
|
||||
return value as unknown as SdkMemoryRecord;
|
||||
|
||||
@@ -3,9 +3,9 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import {
|
||||
modelProbeExitCode,
|
||||
executeModelProbe,
|
||||
planModelProbe,
|
||||
readModelProbeReceipt,
|
||||
startModelProbe,
|
||||
} from "./model-probe-flow.js";
|
||||
|
||||
const PROFILE_ID = "22222222-2222-4222-8222-222222222222";
|
||||
@@ -83,101 +83,67 @@ function completed(operationId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
const OPERATION_ID = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
/** Reader that answers the profile list, then the queued operation reads. */
|
||||
function readerWith(...operationReads: unknown[]) {
|
||||
const reader = vi.fn(async (path: string) => {
|
||||
if (path === "/api/v1/model-profiles") {
|
||||
return { state: "ok", data: [PROFILE] };
|
||||
}
|
||||
return operationReads.shift() ?? { state: "not_found", code: "http_404" };
|
||||
});
|
||||
return reader;
|
||||
}
|
||||
|
||||
describe("model probe operation flow", () => {
|
||||
it("probes the exact current profile revision once support is proven", async () => {
|
||||
const reader = readerWith({ state: "ok", data: notFound(OPERATION_ID) });
|
||||
it("plans against the exact current profile revision", async () => {
|
||||
const reader = vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] });
|
||||
const document = await planModelProbe(reader as CoreReader, PROFILE_ID);
|
||||
expect(document.state).toBe("preview");
|
||||
if (document.state !== "preview") throw new Error("expected preview");
|
||||
expect(document.plan).toMatchObject({
|
||||
kind: "model_profile_probe",
|
||||
profile_id: PROFILE_ID,
|
||||
expected_profile_revision: 3,
|
||||
});
|
||||
expect(document.plan.operation_id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(document.plan.digest).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("proves operation support with schema-1 not_found before posting", async () => {
|
||||
const planned = await planModelProbe(
|
||||
vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] }) as CoreReader,
|
||||
PROFILE_ID,
|
||||
);
|
||||
if (planned.state !== "preview") throw new Error("expected preview");
|
||||
const operationId = planned.plan.operation_id;
|
||||
const reader = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ state: "ok", data: notFound(operationId) });
|
||||
const post = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ state: "committed", data: completed(OPERATION_ID) });
|
||||
.mockResolvedValue({ state: "committed", data: completed(operationId) });
|
||||
|
||||
const result = await startModelProbe(
|
||||
reader as unknown as CoreReader,
|
||||
const result = await executeModelProbe(
|
||||
planned.plan,
|
||||
reader as CoreReader,
|
||||
{ post } as CoreOperations,
|
||||
PROFILE_ID,
|
||||
OPERATION_ID,
|
||||
);
|
||||
|
||||
expect(result.state).toBe("completed");
|
||||
expect(reader).toHaveBeenCalledWith(
|
||||
`/api/v1/model-probe-operations/${OPERATION_ID}`,
|
||||
`/api/v1/model-probe-operations/${operationId}`,
|
||||
);
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
`/api/v1/model-profiles/${PROFILE_ID}/probe`,
|
||||
{ operation_id: OPERATION_ID, expected_profile_revision: 3 },
|
||||
{ operation_id: operationId, expected_profile_revision: 3 },
|
||||
);
|
||||
});
|
||||
|
||||
it("generates its own operation id and treats a pending probe as submitted", async () => {
|
||||
const post = vi.fn(
|
||||
async (_path: string, body: Record<string, unknown>) => ({
|
||||
state: "committed",
|
||||
data: {
|
||||
...notFound(String(body.operation_id)),
|
||||
profile_id: PROFILE_ID,
|
||||
expected_profile_revision: 3,
|
||||
state: "pending",
|
||||
stale: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const reader = vi.fn(async (path: string) =>
|
||||
path === "/api/v1/model-profiles"
|
||||
? { state: "ok", data: [PROFILE] }
|
||||
: {
|
||||
state: "ok",
|
||||
data: notFound(path.split("/").at(-1) ?? ""),
|
||||
},
|
||||
);
|
||||
const result = await startModelProbe(
|
||||
reader as unknown as CoreReader,
|
||||
{ post } as unknown as CoreOperations,
|
||||
PROFILE_ID,
|
||||
);
|
||||
expect(result.state).toBe("pending");
|
||||
expect(result.operation_id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(modelProbeExitCode(result)).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses an unknown profile without posting", async () => {
|
||||
const post = vi.fn();
|
||||
const result = await startModelProbe(
|
||||
readerWith() as unknown as CoreReader,
|
||||
{ post } as CoreOperations,
|
||||
"44444444-4444-4444-8444-444444444444",
|
||||
OPERATION_ID,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "not_found",
|
||||
code: "model_profile_not_found",
|
||||
});
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not post when the Core lacks the exact operation contract", async () => {
|
||||
const planned = await planModelProbe(
|
||||
vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] }) as CoreReader,
|
||||
PROFILE_ID,
|
||||
);
|
||||
if (planned.state !== "preview") throw new Error("expected preview");
|
||||
const post = vi.fn();
|
||||
const result = await startModelProbe(
|
||||
readerWith({
|
||||
const result = await executeModelProbe(
|
||||
planned.plan,
|
||||
vi.fn().mockResolvedValue({
|
||||
state: "not_found",
|
||||
code: "http_404",
|
||||
}) as unknown as CoreReader,
|
||||
}) as CoreReader,
|
||||
{ post } as CoreOperations,
|
||||
PROFILE_ID,
|
||||
OPERATION_ID,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "disabled",
|
||||
@@ -187,15 +153,19 @@ describe("model probe operation flow", () => {
|
||||
});
|
||||
|
||||
it("does not post when a legacy Core returns a non-operation body", async () => {
|
||||
const planned = await planModelProbe(
|
||||
vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] }) as CoreReader,
|
||||
PROFILE_ID,
|
||||
);
|
||||
if (planned.state !== "preview") throw new Error("expected preview");
|
||||
const post = vi.fn();
|
||||
const result = await startModelProbe(
|
||||
readerWith({
|
||||
const result = await executeModelProbe(
|
||||
planned.plan,
|
||||
vi.fn().mockResolvedValue({
|
||||
state: "ok",
|
||||
data: { detail: "legacy_route_shape" },
|
||||
}) as unknown as CoreReader,
|
||||
}) as CoreReader,
|
||||
{ post } as CoreOperations,
|
||||
PROFILE_ID,
|
||||
OPERATION_ID,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "disabled",
|
||||
@@ -205,19 +175,24 @@ describe("model probe operation flow", () => {
|
||||
});
|
||||
|
||||
it("reconciles an unknown POST by GET and never treats not_found as replay permission", async () => {
|
||||
const reader = readerWith(
|
||||
{ state: "ok", data: notFound(OPERATION_ID) },
|
||||
{ state: "ok", data: notFound(OPERATION_ID) },
|
||||
const planned = await planModelProbe(
|
||||
vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] }) as CoreReader,
|
||||
PROFILE_ID,
|
||||
);
|
||||
if (planned.state !== "preview") throw new Error("expected preview");
|
||||
const operationId = planned.plan.operation_id;
|
||||
const reader = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ state: "ok", data: notFound(operationId) })
|
||||
.mockResolvedValueOnce({ state: "ok", data: notFound(operationId) });
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
state: "unknown",
|
||||
code: "transport_result_unknown",
|
||||
});
|
||||
const result = await startModelProbe(
|
||||
reader as unknown as CoreReader,
|
||||
const result = await executeModelProbe(
|
||||
planned.plan,
|
||||
reader as CoreReader,
|
||||
{ post } as CoreOperations,
|
||||
PROFILE_ID,
|
||||
OPERATION_ID,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "unknown",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import { validateModelProfiles } from "./core-responses.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 DIGEST = /^[0-9a-f]{64}$/;
|
||||
const RECEIPT_KEYS = new Set([
|
||||
"schema_version",
|
||||
"operation_id",
|
||||
@@ -22,10 +23,6 @@ const RESULT_KEYS = new Set([
|
||||
"stages",
|
||||
"capabilities",
|
||||
]);
|
||||
const OPTIONAL_RESULT_KEYS = [
|
||||
"embedding_width",
|
||||
"embedding_switch_required",
|
||||
] as const;
|
||||
const STAGE_KEYS = new Set([
|
||||
"stage",
|
||||
"ok",
|
||||
@@ -62,7 +59,23 @@ const CODES = new Set([
|
||||
"embedding_unavailable",
|
||||
]);
|
||||
|
||||
interface ModelProbePlanBody {
|
||||
schema_version: 1;
|
||||
kind: "model_profile_probe";
|
||||
operation_id: string;
|
||||
profile_id: string;
|
||||
expected_profile_revision: number;
|
||||
}
|
||||
|
||||
export type ModelProbePlan = ModelProbePlanBody & { digest: string };
|
||||
|
||||
export type ModelProbeFlowDocument =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "preview";
|
||||
plan: ModelProbePlan;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
@@ -78,25 +91,11 @@ export type ModelProbeFlowDocument =
|
||||
code: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start one capability probe of a model profile (one real model request).
|
||||
*
|
||||
* Reads the profile's current revision so Core can refuse a probe of a
|
||||
* profile that changed meanwhile, checks that this Core tracks probe
|
||||
* operations (an old Core would start an untracked request), then submits it.
|
||||
* The probe runs asynchronously: the answer is usually `pending`, and
|
||||
* `models receipt <operation-id>` reads the result.
|
||||
*/
|
||||
export async function startModelProbe(
|
||||
export async function planModelProbe(
|
||||
reader: CoreReader,
|
||||
operations: CoreOperations,
|
||||
profileId: string,
|
||||
operationId: string = randomUUID(),
|
||||
): Promise<ModelProbeFlowDocument> {
|
||||
if (!UUID.test(profileId)) return failure("invalid", "invalid_profile_id");
|
||||
if (!UUID.test(operationId)) {
|
||||
return failure("invalid", "invalid_probe_operation_id");
|
||||
}
|
||||
const outcome = await reader<unknown>("/api/v1/model-profiles");
|
||||
if (outcome.state !== "ok") return fromReadFailure(outcome);
|
||||
const profiles = validateModelProfiles(outcome.data);
|
||||
@@ -106,46 +105,71 @@ export async function startModelProbe(
|
||||
);
|
||||
if (matches.length !== 1)
|
||||
return failure("not_found", "model_profile_not_found");
|
||||
const revision = matches[0].revision;
|
||||
if (!Number.isSafeInteger(revision) || revision < 1) {
|
||||
const profile = matches[0];
|
||||
if (!Number.isSafeInteger(profile.revision) || profile.revision < 1) {
|
||||
return failure("invalid", "model_profile_revision_invalid");
|
||||
}
|
||||
const plan = withDigest({
|
||||
schema_version: 1 as const,
|
||||
kind: "model_profile_probe" as const,
|
||||
operation_id: randomUUID(),
|
||||
profile_id: profileId,
|
||||
expected_profile_revision: profile.revision,
|
||||
});
|
||||
return { schema_version: 1, product: PRODUCT, state: "preview", plan };
|
||||
}
|
||||
|
||||
export async function executeModelProbe(
|
||||
value: unknown,
|
||||
reader: CoreReader,
|
||||
operations: CoreOperations,
|
||||
): Promise<ModelProbeFlowDocument> {
|
||||
const plan = validatePlan(value);
|
||||
if (!plan) return failure("invalid", "model_probe_plan_invalid");
|
||||
|
||||
// A 404 or a legacy/malformed body is not support. Some old Core versions
|
||||
// ignore the operation body and start an untracked external model request.
|
||||
const support = await reader<unknown>(operationPath(operationId));
|
||||
const support = await reader<unknown>(operationPath(plan.operation_id));
|
||||
if (support.state !== "ok") {
|
||||
return failure(
|
||||
"disabled",
|
||||
"model_probe_operations_unsupported",
|
||||
operationId,
|
||||
plan.operation_id,
|
||||
);
|
||||
}
|
||||
const supportReceipt = validateReceipt(support.data, operationId);
|
||||
const supportReceipt = validateReceipt(support.data, plan.operation_id);
|
||||
if (supportReceipt?.state !== "not_found") {
|
||||
return failure(
|
||||
"disabled",
|
||||
"model_probe_operations_unsupported",
|
||||
operationId,
|
||||
plan.operation_id,
|
||||
);
|
||||
}
|
||||
|
||||
const posted = await operations.post<unknown>(
|
||||
`/api/v1/model-profiles/${profileId}/probe`,
|
||||
{ operation_id: operationId, expected_profile_revision: revision },
|
||||
const outcome = await operations.post<unknown>(
|
||||
`/api/v1/model-profiles/${plan.profile_id}/probe`,
|
||||
{
|
||||
operation_id: plan.operation_id,
|
||||
expected_profile_revision: plan.expected_profile_revision,
|
||||
},
|
||||
);
|
||||
if (posted.state === "rejected") {
|
||||
return failure("rejected", posted.code, operationId);
|
||||
if (outcome.state === "rejected") {
|
||||
return failure("rejected", outcome.code, plan.operation_id);
|
||||
}
|
||||
if (posted.state === "committed") {
|
||||
return receiptDocument(posted.data, operationId, profileId, revision);
|
||||
if (outcome.state === "committed") {
|
||||
return receiptDocument(
|
||||
outcome.data,
|
||||
plan.operation_id,
|
||||
plan.profile_id,
|
||||
plan.expected_profile_revision,
|
||||
);
|
||||
}
|
||||
const reconciled = await readModelProbeReceipt(reader, operationId);
|
||||
const reconciled = await readModelProbeReceipt(reader, plan.operation_id);
|
||||
return reconciled.state === "not_found"
|
||||
? failure(
|
||||
"unknown",
|
||||
"model_probe_receipt_not_found_after_attempt",
|
||||
operationId,
|
||||
plan.operation_id,
|
||||
)
|
||||
: reconciled;
|
||||
}
|
||||
@@ -166,24 +190,32 @@ export async function readModelProbeReceipt(
|
||||
return receiptDocument(outcome.data, operationId);
|
||||
}
|
||||
|
||||
export function inspectModelProbePlan(value: unknown): ModelProbePlan | null {
|
||||
return validatePlan(value);
|
||||
}
|
||||
|
||||
export function modelProbeExitCode(document: ModelProbeFlowDocument): number {
|
||||
// `pending` is a submitted probe still running: the submit succeeded.
|
||||
return ["completed", "pending"].includes(document.state) ? 0 : 1;
|
||||
return ["preview", "completed"].includes(document.state) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderModelProbeText(document: ModelProbeFlowDocument): string {
|
||||
const lines = [
|
||||
if (document.state === "preview") {
|
||||
return [
|
||||
"预览:模型能力探测",
|
||||
`模型配置:${document.plan.profile_id}`,
|
||||
`预期版本:${document.plan.expected_profile_revision}`,
|
||||
`操作 ID:${document.plan.operation_id}`,
|
||||
`确认摘要:${document.plan.digest}`,
|
||||
"未发起模型请求。请保存 --json 输出,再用 models probe --plan-file <path> --confirm。",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
`探测状态:${document.state}`,
|
||||
`操作 ID:${document.operation_id ?? "无"}`,
|
||||
"code" in document ? `代码:${document.code}` : "已返回有界操作回执。",
|
||||
];
|
||||
if (document.state === "pending" && document.operation_id) {
|
||||
lines.push(
|
||||
`探测在后台进行;用 memhub models receipt ${document.operation_id} 查看结果。`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function receiptDocument(
|
||||
@@ -217,6 +249,31 @@ function receiptDocument(
|
||||
};
|
||||
}
|
||||
|
||||
function validatePlan(value: unknown): ModelProbePlan | null {
|
||||
const plan = record(value);
|
||||
if (
|
||||
plan?.schema_version !== 1 ||
|
||||
plan.kind !== "model_profile_probe" ||
|
||||
typeof plan.operation_id !== "string" ||
|
||||
!UUID.test(plan.operation_id) ||
|
||||
typeof plan.profile_id !== "string" ||
|
||||
!UUID.test(plan.profile_id) ||
|
||||
!positiveInteger(plan.expected_profile_revision) ||
|
||||
typeof plan.digest !== "string" ||
|
||||
!DIGEST.test(plan.digest)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const expected = withDigest({
|
||||
schema_version: 1 as const,
|
||||
kind: "model_profile_probe" as const,
|
||||
operation_id: plan.operation_id,
|
||||
profile_id: plan.profile_id,
|
||||
expected_profile_revision: plan.expected_profile_revision,
|
||||
});
|
||||
return expected.digest === plan.digest ? expected : null;
|
||||
}
|
||||
|
||||
function validateReceipt(
|
||||
value: unknown,
|
||||
operationId: string,
|
||||
@@ -276,34 +333,9 @@ function validateResult(
|
||||
inputRevision: number,
|
||||
): Record<string, unknown> | null {
|
||||
const result = record(value);
|
||||
// A Core with the embedding registry adds what the embedding stage measured
|
||||
// and whether using it needs an embedding switch; older Cores send neither.
|
||||
const optional = Object.fromEntries(
|
||||
OPTIONAL_RESULT_KEYS.filter((key) => key in (result ?? {})).map((key) => [
|
||||
key,
|
||||
result?.[key],
|
||||
]),
|
||||
);
|
||||
const required = result
|
||||
? Object.fromEntries(
|
||||
Object.entries(result).filter(
|
||||
([key]) => !(OPTIONAL_RESULT_KEYS as readonly string[]).includes(key),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
if (
|
||||
!result ||
|
||||
!required ||
|
||||
!exactKeys(required, RESULT_KEYS) ||
|
||||
!(
|
||||
optional.embedding_width === undefined ||
|
||||
optional.embedding_width === null ||
|
||||
positiveInteger(optional.embedding_width)
|
||||
) ||
|
||||
!(
|
||||
optional.embedding_switch_required === undefined ||
|
||||
typeof optional.embedding_switch_required === "boolean"
|
||||
) ||
|
||||
!exactKeys(result, RESULT_KEYS) ||
|
||||
result.profile_id !== profileId ||
|
||||
result.input_profile_revision !== inputRevision ||
|
||||
!positiveInteger(result.profile_revision) ||
|
||||
@@ -377,6 +409,27 @@ function failure(
|
||||
};
|
||||
}
|
||||
|
||||
function withDigest<T extends object>(body: T): T & { digest: string } {
|
||||
return {
|
||||
...body,
|
||||
digest: createHash("sha256").update(canonical(body)).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map(
|
||||
(key) =>
|
||||
`${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key])}`,
|
||||
)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
||||
@@ -41,7 +41,7 @@ export type ModelReportDocument =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "denied" | "unavailable" | "not_found" | "invalid" | "rejected";
|
||||
state: "denied" | "unavailable" | "not_found" | "invalid";
|
||||
code: string;
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ function project(profile: ModelProfile): SafeModelProfile {
|
||||
}
|
||||
|
||||
function failure(
|
||||
state: "denied" | "unavailable" | "not_found" | "invalid" | "rejected",
|
||||
state: "denied" | "unavailable" | "not_found" | "invalid",
|
||||
code: string,
|
||||
): ModelReportDocument {
|
||||
return { schema_version: 1, product: PRODUCT, state, code };
|
||||
|
||||
@@ -126,12 +126,29 @@ describe("models activate", () => {
|
||||
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts the activate endpoint directly and reports the profile", async () => {
|
||||
it("previews without contacting Core", async () => {
|
||||
const result = await capturedRun(["models", "activate", PROFILE, "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.state).toBe("preview");
|
||||
expect(document.plan.kind).toBe("model_profile_activate");
|
||||
// Activation has no CAS body, so the plan must not pretend to carry one.
|
||||
expect(document.plan).not.toHaveProperty("expected_revision");
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts the activate endpoint with --confirm and reports the profile", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: profileResponse(),
|
||||
});
|
||||
const result = await capturedRun(["models", "activate", PROFILE, "--json"]);
|
||||
const result = await capturedRun([
|
||||
"models",
|
||||
"activate",
|
||||
PROFILE,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
`/api/v1/model-profiles/${PROFILE}/activate`,
|
||||
@@ -179,22 +196,31 @@ describe("models activate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("models remove", () => {
|
||||
it("previews without --yes and changes nothing", async () => {
|
||||
const result = await capturedRun(["models", "remove", PROFILE, "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
command: "models remove",
|
||||
state: "preview",
|
||||
target: PROFILE,
|
||||
applied: false,
|
||||
});
|
||||
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("models edit", () => {
|
||||
it("sends the exact patch body with the CAS revision directly", async () => {
|
||||
it("previews a patch from a config file without writing", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-model-edit-"));
|
||||
const config = path.join(directory, "patch.json");
|
||||
await writeFile(
|
||||
config,
|
||||
JSON.stringify({ expected_revision: 4, name: "Renamed" }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const result = await capturedRun([
|
||||
"models",
|
||||
"edit",
|
||||
PROFILE,
|
||||
"--config-file",
|
||||
config,
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.state).toBe("preview");
|
||||
expect(document.plan.patch).toEqual({ name: "Renamed" });
|
||||
expect(transport.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the exact patch body with the CAS revision on confirm", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-model-edit-"));
|
||||
const config = path.join(directory, "patch.json");
|
||||
await writeFile(
|
||||
@@ -216,6 +242,7 @@ describe("models edit", () => {
|
||||
PROFILE,
|
||||
"--config-file",
|
||||
config,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
* activate (`POST /model-profiles/{id}/activate`) and edit
|
||||
* (`PATCH /model-profiles/{id}`).
|
||||
*
|
||||
* Core owns the semantics. Both run directly: activation is reversible (activate
|
||||
* another profile) and an edit is guarded by `expected_revision`, the
|
||||
* compare-and-swap Core requires, mirroring the Web console's
|
||||
* `ModelProfilePatch`.
|
||||
* Core owns the semantics. These flows reuse the same two-phase convention as
|
||||
* `models configure`: plan without `--confirm` (or produce a plan file), then
|
||||
* apply exactly that plan. The edit body mirrors the Web console's
|
||||
* `ModelProfilePatch`, including `expected_revision` as the compare-and-swap
|
||||
* guard Core requires.
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { ModelProfile } from "@one-memory/contracts";
|
||||
import {
|
||||
buildModelProfileUpdateRequest,
|
||||
@@ -20,7 +22,29 @@ import { PRODUCT } from "./version.js";
|
||||
|
||||
const PROFILE_ID = /^[A-Za-z0-9][\w.-]{0,127}$/;
|
||||
|
||||
export type ModelWritePlan =
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "model_profile_activate";
|
||||
profile_id: string;
|
||||
digest: string;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "model_profile_edit";
|
||||
profile_id: string;
|
||||
expected_revision: number;
|
||||
patch: Record<string, unknown>;
|
||||
digest: string;
|
||||
};
|
||||
|
||||
export type ModelWriteDocument =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "preview";
|
||||
plan: ModelWritePlan;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
@@ -36,6 +60,38 @@ export type ModelWriteDocument =
|
||||
code: string;
|
||||
};
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value !== null && typeof value === "object") {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
if (value === undefined) return "null";
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function digestOf(value: unknown): string {
|
||||
return createHash("sha256").update(canonical(value)).digest("hex");
|
||||
}
|
||||
|
||||
function preview(
|
||||
plan:
|
||||
| Omit<
|
||||
Extract<ModelWritePlan, { kind: "model_profile_activate" }>,
|
||||
"digest"
|
||||
>
|
||||
| Omit<Extract<ModelWritePlan, { kind: "model_profile_edit" }>, "digest">,
|
||||
): ModelWriteDocument {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "preview",
|
||||
plan: { ...plan, digest: digestOf(plan) } as ModelWritePlan,
|
||||
};
|
||||
}
|
||||
|
||||
function invalid(code: string): ModelWriteDocument {
|
||||
return { schema_version: 1, product: PRODUCT, state: "invalid", code };
|
||||
}
|
||||
@@ -66,45 +122,87 @@ function projectProfile(profile: ModelProfile): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
/** Make `profileId` the active profile. Activation has no request body, so
|
||||
* there is no revision for Core to check. */
|
||||
export async function activateModelProfile(
|
||||
profileId: string,
|
||||
operations: CoreOperations,
|
||||
): Promise<ModelWriteDocument> {
|
||||
if (!PROFILE_ID.test(profileId)) {
|
||||
return invalid("model_profile_activate_input_invalid");
|
||||
}
|
||||
const result = await operations.post<unknown>(
|
||||
`/api/v1/model-profiles/${encodeURIComponent(profileId)}/activate`,
|
||||
{},
|
||||
);
|
||||
return modelProfileResult(profileId, result);
|
||||
// --- activate -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Activation has no request body, so the plan carries only the target. There
|
||||
* is deliberately no `expected_revision`: Core would never read one, and
|
||||
* recording a check it does not perform would be a fake guard.
|
||||
*/
|
||||
export function planModelProfileActivate(input: {
|
||||
profileId: string;
|
||||
}): ModelWriteDocument {
|
||||
return PROFILE_ID.test(input.profileId)
|
||||
? preview({
|
||||
schema_version: 1,
|
||||
kind: "model_profile_activate",
|
||||
profile_id: input.profileId,
|
||||
})
|
||||
: invalid("model_profile_activate_input_invalid");
|
||||
}
|
||||
|
||||
/** Apply a bounded patch (carrying `expected_revision`) to one profile; the
|
||||
* key, when given, comes from a controlled file and never from the patch. */
|
||||
export async function editModelProfile(
|
||||
profileId: string,
|
||||
patch: unknown,
|
||||
apiKey: string | undefined,
|
||||
operations: CorePatchOperations,
|
||||
export async function executeModelProfileActivate(
|
||||
plan: Extract<ModelWritePlan, { kind: "model_profile_activate" }>,
|
||||
operations: CoreOperations,
|
||||
): Promise<ModelWriteDocument> {
|
||||
if (
|
||||
!PROFILE_ID.test(profileId) ||
|
||||
patch === null ||
|
||||
typeof patch !== "object" ||
|
||||
Array.isArray(patch) ||
|
||||
"api_key" in patch
|
||||
) {
|
||||
return invalid("model_profile_edit_input_invalid");
|
||||
}
|
||||
// The shared builder owns patch validation (allow-list, expected_revision
|
||||
// CAS, field bounds, api_key/clear_api_key exclusivity).
|
||||
const result = await operations.post<unknown>(
|
||||
`/api/v1/model-profiles/${encodeURIComponent(plan.profile_id)}/activate`,
|
||||
{},
|
||||
);
|
||||
return modelProfileResult(plan.profile_id, result);
|
||||
}
|
||||
|
||||
// --- edit -----------------------------------------------------------------
|
||||
|
||||
export function planModelProfileEdit(input: {
|
||||
profileId: string;
|
||||
patch: unknown;
|
||||
}): ModelWriteDocument {
|
||||
// The shared builder owns patch validation (14-key allow-list,
|
||||
// expected_revision CAS, field bounds, api_key/clear_api_key exclusivity).
|
||||
// A rejected patch is `invalid`, never a plan the caller could confirm.
|
||||
let request: ReturnType<typeof buildModelProfileUpdateRequest>;
|
||||
try {
|
||||
request = buildModelProfileUpdateRequest(profileId, {
|
||||
...(patch as Record<string, unknown>),
|
||||
request = buildModelProfileUpdateRequest(input.profileId, input.patch);
|
||||
} catch (error) {
|
||||
if (error instanceof ModelProfileInputError) {
|
||||
return invalid("model_profile_edit_input_invalid");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const patch = request.body as Record<string, unknown>;
|
||||
if (!PROFILE_ID.test(input.profileId)) {
|
||||
return invalid("model_profile_edit_input_invalid");
|
||||
}
|
||||
return preview({
|
||||
schema_version: 1,
|
||||
kind: "model_profile_edit",
|
||||
profile_id: input.profileId,
|
||||
expected_revision: patch.expected_revision as number,
|
||||
// `api_key` is read from a controlled file at apply time, exactly like
|
||||
// `models configure`; the plan never carries the credential. The revision
|
||||
// is a top-level field, so it is stripped from the nested patch rather
|
||||
// than being duplicated in both places.
|
||||
patch: Object.fromEntries(
|
||||
Object.entries(patch).filter(
|
||||
([key]) => key !== "api_key" && key !== "expected_revision",
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeModelProfileEdit(
|
||||
plan: Extract<ModelWritePlan, { kind: "model_profile_edit" }>,
|
||||
operations: CorePatchOperations,
|
||||
apiKey: string | undefined,
|
||||
): Promise<ModelWriteDocument> {
|
||||
// Re-validate through the shared builder at apply time too, so a hand-edited
|
||||
// plan cannot smuggle an out-of-bounds patch past the write.
|
||||
let request: ReturnType<typeof buildModelProfileUpdateRequest>;
|
||||
try {
|
||||
request = buildModelProfileUpdateRequest(plan.profile_id, {
|
||||
...plan.patch,
|
||||
expected_revision: plan.expected_revision,
|
||||
...(apiKey === undefined ? {} : { api_key: apiKey }),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -114,10 +212,10 @@ export async function editModelProfile(
|
||||
throw error;
|
||||
}
|
||||
const result = await operations.patch<unknown>(request.path, request.body);
|
||||
return modelProfileResult(profileId, result);
|
||||
return modelProfileResult(plan.profile_id, result);
|
||||
}
|
||||
|
||||
export function modelProfileResult(
|
||||
function modelProfileResult(
|
||||
operationId: string,
|
||||
result:
|
||||
| Awaited<ReturnType<CoreOperations["post"]>>
|
||||
@@ -156,11 +254,69 @@ export function modelProfileResult(
|
||||
return committed(operationId, profile);
|
||||
}
|
||||
|
||||
export function validateModelWritePlan(value: unknown): ModelWritePlan | null {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.schema_version !== 1 || typeof candidate.kind !== "string") {
|
||||
return null;
|
||||
}
|
||||
const { digest, ...withoutDigest } = candidate;
|
||||
if (typeof digest !== "string" || digest !== digestOf(withoutDigest)) {
|
||||
return null;
|
||||
}
|
||||
switch (candidate.kind) {
|
||||
case "model_profile_activate":
|
||||
return typeof candidate.profile_id === "string" &&
|
||||
PROFILE_ID.test(candidate.profile_id) &&
|
||||
Object.keys(candidate).length === 3
|
||||
? (candidate as unknown as ModelWritePlan)
|
||||
: null;
|
||||
case "model_profile_edit": {
|
||||
if (
|
||||
typeof candidate.profile_id !== "string" ||
|
||||
!PROFILE_ID.test(candidate.profile_id) ||
|
||||
!Number.isSafeInteger(candidate.expected_revision)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// The shared builder is the single judge of a patch body; the plan must
|
||||
// not carry `api_key` (that is read from a controlled file at apply).
|
||||
let built: ReturnType<typeof buildModelProfileUpdateRequest>;
|
||||
try {
|
||||
built = buildModelProfileUpdateRequest(candidate.profile_id, {
|
||||
...(candidate.patch as Record<string, unknown>),
|
||||
expected_revision: candidate.expected_revision,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if ("api_key" in built.body) return null;
|
||||
return Object.keys(candidate).length === 6
|
||||
? (candidate as unknown as ModelWritePlan)
|
||||
: null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function modelWriteExitCode(document: ModelWriteDocument): number {
|
||||
return document.state === "committed" ? 0 : 1;
|
||||
return ["preview", "committed"].includes(document.state) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderModelWriteText(document: ModelWriteDocument): string {
|
||||
if (document.state === "preview") {
|
||||
const plan = document.plan;
|
||||
return [
|
||||
`预览:${plan.kind}`,
|
||||
`目标配置:${plan.profile_id}`,
|
||||
`确认摘要:${plan.digest}`,
|
||||
"未执行任何写操作。请把 --json 输出保存为 plan 文件,再用对应命令加 --confirm。",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
if (document.state === "committed") {
|
||||
return `已保存模型配置:${document.receipt.profile_id}\nrevision=${document.receipt.revision}\n`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { constants } from "node:fs";
|
||||
import { type FileHandle, open } from "node:fs/promises";
|
||||
import { readBoundedJson } from "./bounded-file.js";
|
||||
|
||||
export type PlanAttempt = {
|
||||
schema_version: 1;
|
||||
operation_id: string;
|
||||
plan_digest: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Durable client fence written before a mutating request. Once present, the
|
||||
* same plan may only query its operation receipt; it is never blindly posted
|
||||
* again after an unknown result.
|
||||
*/
|
||||
export async function claimPlanAttempt(
|
||||
planFile: string,
|
||||
attempt: PlanAttempt,
|
||||
): Promise<"first" | "prior" | "invalid"> {
|
||||
const journal = `${planFile}.one-memory-attempt`;
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await open(
|
||||
journal,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return "invalid";
|
||||
const current = await readBoundedJson(journal, 1024);
|
||||
return sameAttempt(current, attempt) ? "prior" : "invalid";
|
||||
}
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(attempt)}\n`, "utf8");
|
||||
await handle.sync();
|
||||
return "first";
|
||||
} catch {
|
||||
return "invalid";
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function sameAttempt(value: unknown, expected: PlanAttempt): boolean {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
(value as Record<string, unknown>).schema_version === 1 &&
|
||||
(value as Record<string, unknown>).operation_id === expected.operation_id &&
|
||||
(value as Record<string, unknown>).plan_digest === expected.plan_digest &&
|
||||
Object.keys(value).length === 3
|
||||
);
|
||||
}
|
||||
@@ -104,7 +104,7 @@ describe("package matrix harness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on every write command without a Runtime", {
|
||||
it("fails closed on every destructive UI-write command without a Runtime", {
|
||||
timeout: 120_000,
|
||||
}, async () => {
|
||||
const runtimeDist = await mkdtemp(
|
||||
@@ -140,7 +140,7 @@ describe("package matrix harness", () => {
|
||||
expect(document.state).toBe("verified");
|
||||
const results = document.commands.stopped_destructive.results;
|
||||
// Every destructive command fails closed while Runtime is stopped.
|
||||
expect(Object.keys(results)).toHaveLength(8);
|
||||
expect(Object.keys(results)).toHaveLength(7);
|
||||
for (const [name, result] of Object.entries(results)) {
|
||||
expect(result, `${name} did not fail closed`).toEqual({
|
||||
exit: 3,
|
||||
@@ -164,7 +164,7 @@ describe("package matrix harness", () => {
|
||||
const shim = [
|
||||
"#!/usr/bin/env node",
|
||||
"const a=process.argv.slice(2);",
|
||||
"if(['graph-width','models','memories','sources'].includes(a[0])){",
|
||||
"if(['calls','notes','graph','models','memories'].includes(a[0])){",
|
||||
' process.stdout.write(\'{"schema_version":1,"product":"memhub","state":"committed","operation_id":"x","receipt":{}}\\n\');',
|
||||
" process.exit(0);",
|
||||
"}",
|
||||
@@ -190,7 +190,7 @@ describe("package matrix harness", () => {
|
||||
expect(failure?.state).toBe("failed");
|
||||
expect(
|
||||
failure?.failures.filter((item) => item.includes("did not fail closed")),
|
||||
).toHaveLength(8);
|
||||
).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("can require an expected bundle SHA and reports bundle metadata", {
|
||||
@@ -291,7 +291,9 @@ describe("package matrix harness", () => {
|
||||
"sync",
|
||||
"memories",
|
||||
"profile",
|
||||
"graph-width",
|
||||
"calls",
|
||||
"notes",
|
||||
"graph",
|
||||
"models",
|
||||
"host",
|
||||
"doctor",
|
||||
@@ -309,12 +311,14 @@ describe("package matrix harness", () => {
|
||||
"uninstall",
|
||||
"delete-data",
|
||||
"recall",
|
||||
"setup",
|
||||
"scan",
|
||||
"progress",
|
||||
"jobs",
|
||||
"scan-roots",
|
||||
"path",
|
||||
"adapter",
|
||||
"write",
|
||||
"task",
|
||||
"serve",
|
||||
];
|
||||
const catalog = {
|
||||
|
||||
@@ -145,15 +145,13 @@ describe("memhub short-command contract", () => {
|
||||
[command.usage, command.alternative_usage ?? ""].join("\n"),
|
||||
).join("\n");
|
||||
for (const shortForm of [
|
||||
"memhub memories add",
|
||||
"memhub memories add-status",
|
||||
"memhub calls clear",
|
||||
"memhub notes add",
|
||||
"memhub notes process",
|
||||
"memhub models setup",
|
||||
"memhub models add",
|
||||
"memhub models test",
|
||||
"memhub models activate",
|
||||
"memhub models edit",
|
||||
"memhub graph-width",
|
||||
"memhub sources connect",
|
||||
"memhub graph width",
|
||||
"memhub write submit",
|
||||
"memhub write status",
|
||||
"memhub memories update",
|
||||
@@ -161,10 +159,6 @@ describe("memhub short-command contract", () => {
|
||||
]) {
|
||||
expect(flattened).toContain(shortForm);
|
||||
}
|
||||
// No "note" concept and no plan files on the CLI surface.
|
||||
for (const removed of ["memhub notes", "memhub calls", "--plan-file"]) {
|
||||
expect(flattened).not.toContain(removed);
|
||||
}
|
||||
expect(COMMANDS.some((command) => command.name === "permission")).toBe(
|
||||
false,
|
||||
);
|
||||
@@ -217,7 +211,7 @@ describe("memhub short-command contract", () => {
|
||||
// but an earlier catalog text still claimed they were "尚不可用". A user
|
||||
// reading `help path` would not try a command that works.
|
||||
it("does not call the wired path/adapter commands unavailable", () => {
|
||||
for (const name of ["scan-roots", "adapter"]) {
|
||||
for (const name of ["path", "adapter"]) {
|
||||
const command = COMMANDS.find((entry) => entry.name === name);
|
||||
expect(command).toBeDefined();
|
||||
const text = [
|
||||
@@ -283,7 +277,7 @@ describe("memhub short-command contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps destructive previews free of diagnostic side effects", {
|
||||
it("keeps plan-file apply previews free of diagnostic side effects", {
|
||||
timeout: 20_000,
|
||||
}, async () => {
|
||||
expect(
|
||||
@@ -297,10 +291,9 @@ describe("memhub short-command contract", () => {
|
||||
);
|
||||
try {
|
||||
for (const [index, argv] of [
|
||||
["memories", "delete", "m-1"],
|
||||
["models", "remove", "44444444-4444-4444-8444-444444444444"],
|
||||
["backup", "restore", "--source", path.join(root, "backup.sqlite3")],
|
||||
["graph-width", "--decision", "rebuild_with_new"],
|
||||
["sources", "import", "--plan-file", path.join(root, "plan.json")],
|
||||
["models", "configure", "--plan-file", path.join(root, "plan.json")],
|
||||
["memories", "apply", "--plan-file", path.join(root, "plan.json")],
|
||||
].entries()) {
|
||||
const dataDir = path.join(root, `data-${index}`);
|
||||
await import("node:fs/promises").then(({ mkdir }) =>
|
||||
@@ -312,8 +305,8 @@ describe("memhub short-command contract", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// A failure path is acceptable; the guard is that no preview adds
|
||||
// a diagnostic file.
|
||||
// The missing plan or stopped Runtime is expected; the guard is
|
||||
// that even the failure path did not add a diagnostic file.
|
||||
}
|
||||
expect(await readdir(dataDir)).toEqual([]);
|
||||
}
|
||||
|
||||
@@ -1,56 +1,122 @@
|
||||
// The rules behind the commands that attach to the Runtime directly.
|
||||
// A6: the rules behind the six product commands.
|
||||
//
|
||||
// The parsers are where the contract actually lives — "only irreversible
|
||||
// operations ask for --yes" and "unknown flags are rejected" are decisions,
|
||||
// not formatting — so they are tested directly rather than only through the
|
||||
// CLI.
|
||||
// The parsers are where the contract actually lives — "needs --yes to change
|
||||
// anything" and "no write without evidence" are decisions, not formatting — so
|
||||
// they are tested directly rather than only through the CLI.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseOpenAIMessages } from "./core-client.js";
|
||||
import {
|
||||
parseBackupCommand,
|
||||
parseScanCommand,
|
||||
parseScanRootsCommand,
|
||||
asPlanDocument,
|
||||
describe as describePlan,
|
||||
parseProductCommand,
|
||||
parseTaskCommand,
|
||||
parseWriteCommand,
|
||||
restoreConfirmationMatches,
|
||||
} from "./product-flows.js";
|
||||
|
||||
describe("scan-roots", () => {
|
||||
it("reads the root, source and recursive flag off add", () => {
|
||||
expect(
|
||||
parseScanRootsCommand([
|
||||
"add",
|
||||
"--root",
|
||||
"/tmp/x",
|
||||
"--source",
|
||||
"codex",
|
||||
"--no-recursive",
|
||||
]),
|
||||
).toEqual({
|
||||
kind: "add",
|
||||
root: "/tmp/x",
|
||||
source: "codex",
|
||||
recursive: false,
|
||||
});
|
||||
expect(parseScanRootsCommand(["add", "--root", "/tmp/x"])).toEqual({
|
||||
kind: "add",
|
||||
root: "/tmp/x",
|
||||
recursive: true,
|
||||
});
|
||||
describe("mutating commands are two-phase", () => {
|
||||
it("plans without --yes", () => {
|
||||
const parsed = parseProductCommand("scan", ["--root", "/tmp/a"]);
|
||||
expect(parsed?.confirmed).toBe(false);
|
||||
});
|
||||
|
||||
it("distinguishes remove from add and refuses add-only flags on remove", () => {
|
||||
expect(parseScanRootsCommand(["remove", "--root", "/tmp/x"])).toEqual({
|
||||
kind: "remove",
|
||||
root: "/tmp/x",
|
||||
});
|
||||
it("confirms with --yes", () => {
|
||||
const parsed = parseProductCommand("scan", ["--root", "/tmp/a", "--yes"]);
|
||||
expect(parsed?.confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it("says what it would do", () => {
|
||||
const parsed = parseProductCommand("scan", ["--root", "/tmp/a"]);
|
||||
// `?.plan!` would let a null parse pass silently; assert the parse first.
|
||||
if (parsed === null) {
|
||||
throw new Error("scan with a root must parse");
|
||||
}
|
||||
expect(describePlan(parsed.plan)).toContain("scan: /tmp/a");
|
||||
});
|
||||
|
||||
it("marks a plan as not applied", () => {
|
||||
// Assert the parse itself: `?.` let a null result satisfy this test for as
|
||||
// long as `backup create` was unparseable.
|
||||
const parsed = parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/b",
|
||||
]);
|
||||
if (parsed === null) {
|
||||
throw new Error("backup create must parse");
|
||||
}
|
||||
expect(parsed.plan.verb).toBe("create");
|
||||
expect(asPlanDocument(parsed.plan).applied).toBe(false);
|
||||
});
|
||||
|
||||
it("parses the verb for every command that takes one", () => {
|
||||
// `path add` / `path remove` / `backup create` were rejected outright
|
||||
// before the verb was skipped in parsing.
|
||||
expect(
|
||||
parseScanRootsCommand(["remove", "--root", "/tmp/x", "--no-recursive"]),
|
||||
parseProductCommand("path", ["add", "--root", "/tmp/x"])?.plan.action,
|
||||
).toBe("add scan root");
|
||||
expect(
|
||||
parseProductCommand("path", ["remove", "--root", "/tmp/x"])?.plan.action,
|
||||
).toBe("remove scan root");
|
||||
expect(
|
||||
parseProductCommand("backup", ["restore", "--source", "/tmp/b"])?.plan
|
||||
.action,
|
||||
).toBe("restore backup");
|
||||
});
|
||||
|
||||
it("defaults a scan to all registered roots", () => {
|
||||
const parsed = parseProductCommand("scan", []);
|
||||
expect(parsed?.plan.target).toBe("all registered roots");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown flags are rejected", () => {
|
||||
it("rejects a typo'd flag rather than ignoring it", () => {
|
||||
// A silently ignored --yes would turn a real apply into a plan, and the
|
||||
// user would believe the change had been made.
|
||||
expect(parseProductCommand("scan", ["--yesx"])).toBeNull();
|
||||
expect(parseProductCommand("scan", ["--root"])).toBeNull();
|
||||
expect(parseProductCommand("scan", ["--root", "--yes"])).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an unknown verb for backup", () => {
|
||||
expect(
|
||||
parseProductCommand("backup", ["make", "--target", "/tmp/b"]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// `path` and `scan` share one parser. Before this guard, an unknown verb was
|
||||
// silently treated as `path add` or a default `scan`, so `--yes` executed a
|
||||
// different operation than the one the caller named.
|
||||
it("rejects an unknown verb for path without turning it into add", () => {
|
||||
expect(
|
||||
parseScanRootsCommand([
|
||||
parseProductCommand("path", ["frobnicate", "--root", "/tmp/x", "--yes"]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an unknown verb for scan without turning it into all", () => {
|
||||
expect(
|
||||
parseProductCommand("scan", ["frobnicate", "--all", "--yes"]),
|
||||
).toBeNull();
|
||||
expect(parseProductCommand("scan", ["frobnicate", "--yes"])).toBeNull();
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -58,103 +124,137 @@ describe("scan-roots", () => {
|
||||
"codex",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is direct, but keeps accepting a legacy --yes", () => {
|
||||
expect(parseScanRootsCommand(["add", "--root", "/tmp/x", "--yes"])).toEqual(
|
||||
{ kind: "add", root: "/tmp/x", recursive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown verbs, typos and repeated flags", () => {
|
||||
expect(parseScanRootsCommand(["create", "--root", "/tmp/x"])).toBeNull();
|
||||
expect(parseScanRootsCommand(["add", "--rot", "/tmp/x"])).toBeNull();
|
||||
expect(parseScanRootsCommand(["add"])).toBeNull();
|
||||
expect(
|
||||
parseScanRootsCommand(["add", "--root", "/a", "--root", "/b"]),
|
||||
parseProductCommand("path", [
|
||||
"remove",
|
||||
"--root",
|
||||
"/tmp/x",
|
||||
"--no-recursive",
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseScanRootsCommand(["add", "--root", "/a", "--yes", "--yes"]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scan", () => {
|
||||
it("needs a source: there is no endpoint that scans a bare path", () => {
|
||||
expect(parseScanCommand(["--source", "codex"])).toEqual({
|
||||
kind: "source",
|
||||
sourceId: "codex",
|
||||
});
|
||||
expect(parseScanCommand([])).toBeNull();
|
||||
expect(parseScanCommand(["--root", "/tmp/a"])).toBeNull();
|
||||
});
|
||||
|
||||
it("no longer accepts --full, which was never sent to Core", () => {
|
||||
expect(parseScanCommand(["--source", "codex", "--full"])).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps --all as a hidden alias and a legacy --yes", () => {
|
||||
expect(parseScanCommand(["--all", "--yes"])).toEqual({ kind: "all" });
|
||||
expect(parseScanCommand(["--all", "--source", "codex"])).toBeNull();
|
||||
expect(parseScanCommand(["--source", "codex", "--yes"])).toEqual({
|
||||
kind: "source",
|
||||
sourceId: "codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a verb, a bad source id and repeated flags", () => {
|
||||
expect(parseScanCommand(["all"])).toBeNull();
|
||||
expect(parseScanCommand(["--source", "Bad Id"])).toBeNull();
|
||||
expect(
|
||||
parseScanCommand(["--source", "codex", "--source", "cursor"]),
|
||||
parseProductCommand("path", ["add", "--root", "/tmp/x", "--full"]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseScanCommand(["--source", "codex", "--json", "--json"]),
|
||||
parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/b",
|
||||
"--source",
|
||||
"/tmp/s",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("backup", () => {
|
||||
it("creates directly and requires a target", () => {
|
||||
expect(parseBackupCommand(["create", "--target", "/tmp/b"])).toEqual({
|
||||
verb: "create",
|
||||
target: "/tmp/b",
|
||||
});
|
||||
expect(parseBackupCommand(["create"])).toBeNull();
|
||||
expect(
|
||||
parseBackupCommand(["create", "--target", "/tmp/b", "--source", "/x"]),
|
||||
parseProductCommand("backup", [
|
||||
"restore",
|
||||
"--source",
|
||||
"/tmp/s",
|
||||
"--target",
|
||||
"/tmp/t",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("restores only with --yes and requires a source", () => {
|
||||
expect(parseBackupCommand(["restore", "--source", "/tmp/b"])).toEqual({
|
||||
verb: "restore",
|
||||
source: "/tmp/b",
|
||||
confirmed: false,
|
||||
});
|
||||
// A later value silently won before this guard. For `scan --source` that
|
||||
// means one extra shell/make variable can change which source is scanned
|
||||
// while the operator believes both were applied.
|
||||
it("rejects repeated value-bearing flags instead of letting the last one win", () => {
|
||||
expect(
|
||||
parseBackupCommand(["restore", "--source", "/tmp/b", "--yes"]),
|
||||
).toMatchObject({ confirmed: true });
|
||||
expect(parseBackupCommand(["restore", "--yes"])).toBeNull();
|
||||
parseProductCommand("scan", ["--source", "codex", "--source", "cursor"]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("path", [
|
||||
"add",
|
||||
"--root",
|
||||
"/tmp/a",
|
||||
"--root",
|
||||
"/tmp/b",
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/a",
|
||||
"--target",
|
||||
"/tmp/b",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts the legacy --confirm <path> and checks it lexically", () => {
|
||||
const parsed = parseBackupCommand([
|
||||
it("rejects repeated boolean flags instead of treating them as idempotent", () => {
|
||||
expect(parseProductCommand("scan", ["--all", "--all", "--yes"])).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("scan", ["--full", "--full", "--yes"]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("path", [
|
||||
"add",
|
||||
"--root",
|
||||
"/tmp/a",
|
||||
"--no-recursive",
|
||||
"--no-recursive",
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/a",
|
||||
"--yes",
|
||||
"--yes",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a repeated global --json spelling", () => {
|
||||
expect(
|
||||
parseProductCommand("scan", ["--root", "/tmp/a", "--json", "--json"]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/a",
|
||||
"--json",
|
||||
"--json",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("requires a source when restoring", () => {
|
||||
expect(
|
||||
parseProductCommand("backup", ["restore", "--target", "/tmp/b"]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parses the explicit restore confirmation path", () => {
|
||||
const parsed = parseProductCommand("backup", [
|
||||
"restore",
|
||||
"--source",
|
||||
"/tmp/b",
|
||||
"/tmp/backups/a.sqlite3",
|
||||
"--yes",
|
||||
"--confirm",
|
||||
"/tmp/b",
|
||||
"/tmp/backups/a.sqlite3",
|
||||
]);
|
||||
expect(parsed).toMatchObject({ confirmPath: "/tmp/b" });
|
||||
expect(restoreConfirmationMatches("/tmp/b", "/tmp/b")).toBe(true);
|
||||
expect(restoreConfirmationMatches("/tmp/b", undefined)).toBe(true);
|
||||
expect(restoreConfirmationMatches("/tmp/b", "/tmp/./b")).toBe(false);
|
||||
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",
|
||||
"/tmp/backups/../backups/a.sqlite3",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an unknown verb", () => {
|
||||
expect(parseBackupCommand(["delete", "--target", "/tmp/b"])).toBeNull();
|
||||
it("requires a target when creating a backup", () => {
|
||||
expect(parseProductCommand("backup", ["create"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,19 +305,11 @@ describe("write", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parses status under write, positional or --id", () => {
|
||||
it("parses status under write", () => {
|
||||
expect(parseWriteCommand(["status", "--id", "request-1"])).toEqual({
|
||||
kind: "status",
|
||||
id: "request-1",
|
||||
});
|
||||
expect(parseWriteCommand(["status", "request-1"])).toEqual({
|
||||
kind: "status",
|
||||
id: "request-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a repeated --session instead of letting the last one win", () => {
|
||||
expect(parseWriteCommand([...base, "--session", "s2"])).toBeNull();
|
||||
});
|
||||
|
||||
it("marks --live as priority live", () => {
|
||||
@@ -274,3 +366,53 @@ describe("task", () => {
|
||||
expect(parseTaskCommand(["--id", "req-1", "--json", "--json"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("path add and remove carry what the endpoint needs", () => {
|
||||
it("reads the source and recursive flag off the path add", () => {
|
||||
const parsed = parseProductCommand("path", [
|
||||
"add",
|
||||
"--root",
|
||||
"/tmp/scanme",
|
||||
"--source",
|
||||
"codex",
|
||||
"--no-recursive",
|
||||
]);
|
||||
// `?.plan!` let a null parse pass silently, so assert the parse itself.
|
||||
if (parsed === null) {
|
||||
throw new Error("path add must parse");
|
||||
}
|
||||
expect(parsed.plan.action).toBe("add scan root");
|
||||
expect(parsed.plan.target).toBe("/tmp/scanme");
|
||||
expect(parsed.plan.detail).toContain("source=codex");
|
||||
expect(parsed.plan.detail).toContain("recursive=false");
|
||||
});
|
||||
|
||||
it("distinguishes remove from add", () => {
|
||||
const parsed = parseProductCommand("path", [
|
||||
"remove",
|
||||
"--root",
|
||||
"/tmp/scanme",
|
||||
]);
|
||||
if (parsed === null) {
|
||||
throw new Error("path remove must parse");
|
||||
}
|
||||
// The action is the discriminator at apply time: add and remove share a
|
||||
// `--root`, so a mis-set action would register the directory it meant to delete.
|
||||
expect(parsed.plan.action).toBe("remove scan root");
|
||||
});
|
||||
|
||||
it("does not silently drop --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.
|
||||
|
||||
const without = parseProductCommand("path", ["add", "--root", "/tmp/a"]);
|
||||
expect(without?.confirmed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,163 +1,258 @@
|
||||
// Parsers for the commands that attach to the Runtime directly:
|
||||
// `scan-roots` (alias `path`), `scan`, `backup`, `write`.
|
||||
// A6: the product commands Runtime exposes on the CLI.
|
||||
//
|
||||
// Rules shared by all of them:
|
||||
// These are the six commands the objective names: path, adapter, scan, write,
|
||||
// task, backup. Two rules run through all of them:
|
||||
//
|
||||
// - **Only irreversible operations ask for `--yes`.** `backup restore`
|
||||
// overwrites the database, so without `--yes` it previews and changes
|
||||
// nothing. Registering a scan root, scanning a source, creating a backup and
|
||||
// submitting a write are direct. A legacy `--yes` on a direct command is
|
||||
// accepted and ignored so older scripts keep working.
|
||||
// - **Unknown or repeated flags are rejected**, never ignored: a dropped typo
|
||||
// would silently change what the command does.
|
||||
// - **Output is machine-readable.** `--json` emits one document on stdout.
|
||||
// - **Mutating commands are two-phase.** Without `--yes` they print a plan and
|
||||
// change nothing. This is the convention the existing install/service
|
||||
// commands already follow, and it is why `scan` and `backup` default to a
|
||||
// plan rather than quietly doing work.
|
||||
// - **Output is machine-readable.** `--json` emits one document on stdout so an
|
||||
// agent can consume it; the human form is text. Never both, so a caller that
|
||||
// parses stdout is not surprised by prose.
|
||||
//
|
||||
// The commands talk to the Runtime's HTTP API, not to the database and not to
|
||||
// the SDK: CLI and desktop are clients of the same Runtime.
|
||||
// HY. That is the A6 rule: CLI and Electron are clients of the same Runtime.
|
||||
|
||||
export type ScanRootsCommand =
|
||||
| {
|
||||
readonly kind: "add";
|
||||
readonly root: string;
|
||||
readonly source?: string;
|
||||
readonly recursive: boolean;
|
||||
}
|
||||
| { readonly kind: "remove"; readonly root: string };
|
||||
|
||||
export type ScanCommand =
|
||||
| { readonly kind: "source"; readonly sourceId: string }
|
||||
/** Hidden alias of `sources connect --all-detected --import`. */
|
||||
| { readonly kind: "all" };
|
||||
|
||||
export type BackupCommand =
|
||||
| { readonly verb: "create"; readonly target: string }
|
||||
| {
|
||||
readonly verb: "restore";
|
||||
readonly source: string;
|
||||
readonly confirmed: boolean;
|
||||
/** Legacy `--confirm <path>`: accepted, and must repeat `--source`. */
|
||||
readonly confirmPath?: string;
|
||||
};
|
||||
|
||||
interface ParsedFlags {
|
||||
readonly values: Map<string, string>;
|
||||
readonly switches: Set<string>;
|
||||
/** What a mutating command would do, before it is allowed to do it. */
|
||||
export interface Plan {
|
||||
readonly action: string;
|
||||
readonly target: string;
|
||||
readonly detail?: string;
|
||||
/** Which form of the command this plan is for, when one name has several.
|
||||
*
|
||||
* `backup` has `create` and `restore`, and applying the wrong one is not a
|
||||
* harmless mistake — so the verb travels with the plan the user confirmed.
|
||||
*/
|
||||
readonly verb?: string;
|
||||
/** The source the Runtime endpoint addresses, when the plan names one.
|
||||
*
|
||||
* Carried on the plan rather than re-parsed at apply time so the plan the
|
||||
* user confirmed and the request actually sent cannot disagree.
|
||||
*/
|
||||
readonly sourceId?: string;
|
||||
/** `scan --all`: every source Runtime actually found, not a named one. */
|
||||
readonly all?: boolean;
|
||||
}
|
||||
|
||||
/** Read `--flag value` pairs and bare switches; null on anything unknown,
|
||||
* repeated or missing its value. */
|
||||
function parseFlags(
|
||||
export function planAddRoot(
|
||||
root: string,
|
||||
source: string | undefined,
|
||||
recursive: boolean,
|
||||
): Plan {
|
||||
return {
|
||||
action: "add scan root",
|
||||
target: root,
|
||||
detail: `source=${source ?? "auto"} recursive=${recursive}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function planRemoveRoot(root: string): Plan {
|
||||
return { action: "remove scan root", target: root };
|
||||
}
|
||||
|
||||
export function planScan(root: string | undefined, full: boolean): Plan {
|
||||
return {
|
||||
action: "scan",
|
||||
target: root ?? "all registered roots",
|
||||
detail: `full=${full}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function planScanSource(sourceId: string, full: boolean): Plan {
|
||||
return {
|
||||
action: "scan",
|
||||
target: `source ${sourceId}`,
|
||||
detail: `full=${full}`,
|
||||
sourceId,
|
||||
};
|
||||
}
|
||||
|
||||
export function planScanAll(full: boolean): Plan {
|
||||
return {
|
||||
action: "scan",
|
||||
target: "all detected sources",
|
||||
detail: `full=${full}`,
|
||||
all: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function planBackup(target: string): Plan {
|
||||
return { action: "create backup", target, verb: "create" };
|
||||
}
|
||||
|
||||
export function planRestore(source: string): Plan {
|
||||
return { action: "restore backup", target: source, verb: "restore" };
|
||||
}
|
||||
|
||||
export interface ProductCommand {
|
||||
readonly plan: Plan;
|
||||
readonly confirmed: boolean;
|
||||
/** For destructive verbs with a named artifact, the exact value repeated by
|
||||
* the user. `backup restore` requires this to match `--source`. */
|
||||
readonly confirmPath?: string;
|
||||
}
|
||||
|
||||
/** Parse a mutating product command.
|
||||
*
|
||||
* Unknown flags are rejected rather than ignored: a typo'd `--yes` that is
|
||||
* silently dropped would turn a real apply into a plan, and the user would
|
||||
* believe the change had been made.
|
||||
*/
|
||||
export function parseProductCommand(
|
||||
name: string,
|
||||
args: readonly string[],
|
||||
valueFlags: readonly string[],
|
||||
switchFlags: readonly string[],
|
||||
): ParsedFlags | null {
|
||||
const values = new Map<string, string>();
|
||||
const switches = new Set<string>();
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
): ProductCommand | null {
|
||||
let root: string | undefined;
|
||||
let source: string | undefined;
|
||||
let target: string | undefined;
|
||||
let recursive = true;
|
||||
let full = false;
|
||||
let all = false;
|
||||
let confirmed = false;
|
||||
let confirmPath: string | undefined;
|
||||
const seen = new Set<string>();
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
const flags = new Set([
|
||||
"--root",
|
||||
"--source",
|
||||
"--target",
|
||||
"--confirm",
|
||||
"--no-recursive",
|
||||
"--full",
|
||||
"--all",
|
||||
"--yes",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
// The first token is the verb for commands that have one (`path add`,
|
||||
// `backup create`). It used to fall into the flag loop and be rejected as an
|
||||
// unknown flag, which made every one of these commands permanently
|
||||
// unparseable — `path add --root X` printed "参数无效" even though `help`
|
||||
// documented it. The tests missed this because they asserted
|
||||
// `parsed?.plan!` and null satisfies `?.` + `!`.
|
||||
const verb = args[0];
|
||||
const start = verb !== undefined && !verb.startsWith("--") ? 1 : 0;
|
||||
|
||||
for (let index = start; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (switchFlags.includes(arg)) {
|
||||
if (switches.has(arg)) return null;
|
||||
switches.add(arg);
|
||||
if (!flags.has(arg)) return null;
|
||||
// A repeated flag is a malformed argv, not an idempotent spelling. The
|
||||
// other command parsers already apply this rule to --json as well.
|
||||
if (seen.has(arg)) return null;
|
||||
seen.add(arg);
|
||||
if (arg === "--yes") {
|
||||
confirmed = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--confirm") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) return null;
|
||||
if (confirmPath !== undefined) return null;
|
||||
confirmPath = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--json") continue;
|
||||
if (arg === "--no-recursive") {
|
||||
recursive = false;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--full") {
|
||||
full = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--all") {
|
||||
all = true;
|
||||
continue;
|
||||
}
|
||||
if (!valueFlags.includes(arg) || values.has(arg)) return null;
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) return null;
|
||||
values.set(arg, value);
|
||||
if (seenValues.has(arg)) return null;
|
||||
seenValues.add(arg);
|
||||
index += 1;
|
||||
if (arg === "--root") root = value;
|
||||
else if (arg === "--source") source = value;
|
||||
else target = value;
|
||||
}
|
||||
return { values, switches };
|
||||
}
|
||||
|
||||
const SOURCE_ID = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
|
||||
/** `scan-roots add --root <path> [--source <id>] [--no-recursive]` and
|
||||
* `scan-roots remove --root <path>` (`list` is answered separately). */
|
||||
export function parseScanRootsCommand(
|
||||
args: readonly string[],
|
||||
): ScanRootsCommand | null {
|
||||
const verb = args[0];
|
||||
if (verb === "add") {
|
||||
const flags = parseFlags(
|
||||
args.slice(1),
|
||||
["--root", "--source"],
|
||||
["--no-recursive", "--yes", "--json"],
|
||||
);
|
||||
const root = flags?.values.get("--root");
|
||||
if (!flags || root === undefined) return null;
|
||||
const source = flags.values.get("--source");
|
||||
if (source !== undefined && !SOURCE_ID.test(source)) return null;
|
||||
if (name === "path") {
|
||||
if (confirmPath !== undefined) return null;
|
||||
if (target !== undefined) return null;
|
||||
if (root === undefined) return null;
|
||||
if (source === undefined && args.includes("--source")) return null;
|
||||
if (seen.has("--full") || seen.has("--all")) return null;
|
||||
if (args[0] !== "add" && args[0] !== "remove") return null;
|
||||
if (
|
||||
args[0] === "remove" &&
|
||||
(seen.has("--source") || seen.has("--no-recursive"))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "add",
|
||||
root,
|
||||
...(source === undefined ? {} : { source }),
|
||||
recursive: !flags.switches.has("--no-recursive"),
|
||||
plan:
|
||||
args[0] === "add"
|
||||
? planAddRoot(root, source, recursive)
|
||||
: planRemoveRoot(root),
|
||||
confirmed,
|
||||
};
|
||||
}
|
||||
if (verb === "remove") {
|
||||
const flags = parseFlags(args.slice(1), ["--root"], ["--yes", "--json"]);
|
||||
const root = flags?.values.get("--root");
|
||||
return flags && root !== undefined ? { kind: "remove", root } : null;
|
||||
if (name === "scan") {
|
||||
if (confirmPath !== undefined) return null;
|
||||
if (target !== undefined) return null;
|
||||
if (seen.has("--no-recursive")) return null;
|
||||
if (args[0] !== undefined && !args[0].startsWith("--")) return null;
|
||||
// `--all` means "every source Runtime actually found", so naming one too
|
||||
// would make the command mean two different things.
|
||||
if (all && source !== undefined) return null;
|
||||
if (all) return { plan: planScanAll(full), confirmed };
|
||||
// A source is the thing the Runtime scan endpoint addresses. A bare root is
|
||||
// still accepted for planning (it means "all registered roots") but cannot
|
||||
// be applied, because there is no endpoint that scans a raw path — the CLI
|
||||
// must not pretend it can.
|
||||
if (source === undefined) return { plan: planScan(root, full), confirmed };
|
||||
return { plan: planScanSource(source, full), confirmed };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `scan --source <id>`. A scan names a source: the Runtime has no endpoint
|
||||
* that scans a bare path, so a missing `--source` is a usage error rather
|
||||
* than a preview of something that can never run. */
|
||||
export function parseScanCommand(args: readonly string[]): ScanCommand | null {
|
||||
const flags = parseFlags(args, ["--source"], ["--all", "--yes", "--json"]);
|
||||
if (!flags) return null;
|
||||
const source = flags.values.get("--source");
|
||||
if (flags.switches.has("--all")) {
|
||||
return source === undefined ? { kind: "all" } : null;
|
||||
}
|
||||
return source !== undefined && SOURCE_ID.test(source)
|
||||
? { kind: "source", sourceId: source }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** `backup create --target <path>` and
|
||||
* `backup restore --source <path> [--yes]`. */
|
||||
export function parseBackupCommand(
|
||||
args: readonly string[],
|
||||
): BackupCommand | null {
|
||||
const verb = args[0];
|
||||
if (verb === "create") {
|
||||
const flags = parseFlags(args.slice(1), ["--target"], ["--yes", "--json"]);
|
||||
const target = flags?.values.get("--target");
|
||||
return flags && target !== undefined ? { verb: "create", target } : null;
|
||||
}
|
||||
if (verb === "restore") {
|
||||
const flags = parseFlags(
|
||||
args.slice(1),
|
||||
["--source", "--confirm"],
|
||||
["--yes", "--json"],
|
||||
);
|
||||
const source = flags?.values.get("--source");
|
||||
if (!flags || source === undefined) return null;
|
||||
const confirmPath = flags.values.get("--confirm");
|
||||
if (name === "backup") {
|
||||
const verb = args[0];
|
||||
if (verb !== "create" && verb !== "restore") return null;
|
||||
if (seen.has("--full") || seen.has("--all") || seen.has("--no-recursive")) {
|
||||
return null;
|
||||
}
|
||||
if (verb === "restore") {
|
||||
if (source === undefined) return null;
|
||||
if (target !== undefined) return null;
|
||||
return {
|
||||
plan: planRestore(source),
|
||||
confirmed,
|
||||
...(confirmPath === undefined ? {} : { confirmPath }),
|
||||
};
|
||||
}
|
||||
if (confirmPath !== undefined) return null;
|
||||
if (source !== undefined) return null;
|
||||
if (target === undefined) return null;
|
||||
return {
|
||||
verb: "restore",
|
||||
source,
|
||||
confirmed: flags.switches.has("--yes"),
|
||||
plan: planBackup(target),
|
||||
confirmed,
|
||||
...(confirmPath === undefined ? {} : { confirmPath }),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a legacy `--confirm <path>` names the same restore source.
|
||||
/** Whether `--confirm <path>` names the same restore source.
|
||||
*
|
||||
* The comparison is lexical on purpose: the CLI must not resolve symlinks or
|
||||
* touch the filesystem before Core has validated the managed source. A
|
||||
* different string is a different confirmation.
|
||||
* different string is a different confirmation, so a typo or a path escape
|
||||
* can never be applied as a side effect of `--yes`.
|
||||
*/
|
||||
export function restoreConfirmationMatches(
|
||||
source: string,
|
||||
confirmPath: string | undefined,
|
||||
): boolean {
|
||||
return confirmPath === undefined || confirmPath === source;
|
||||
return confirmPath === source;
|
||||
}
|
||||
|
||||
export interface WriteSubmitCommand {
|
||||
@@ -177,33 +272,41 @@ export interface WriteStatusCommand {
|
||||
|
||||
export type WriteCommand = WriteSubmitCommand | WriteStatusCommand;
|
||||
|
||||
/** Parse `write submit ...` and `write status <request-id>` (or `--id`). */
|
||||
/** Parse SDK-native `write submit|status`. */
|
||||
export function parseWriteCommand(
|
||||
args: readonly string[],
|
||||
): WriteCommand | null {
|
||||
if (args.filter((item) => item === "--json").length > 1) return null;
|
||||
const meaningful = args.filter((item) => item !== "--json");
|
||||
if (meaningful[0] === "status") {
|
||||
const rest = meaningful.slice(1);
|
||||
if (rest.length === 1 && !rest[0].startsWith("--")) {
|
||||
return { kind: "status", id: rest[0] };
|
||||
}
|
||||
const status = parseTaskCommand(rest);
|
||||
const status = parseTaskCommand(meaningful.slice(1));
|
||||
return status === null ? null : { kind: "status", id: status.id };
|
||||
}
|
||||
// `write --session ...` without `submit` stays accepted for older scripts.
|
||||
const input = meaningful[0] === "submit" ? meaningful.slice(1) : meaningful;
|
||||
const flags = parseFlags(
|
||||
input,
|
||||
["--session", "--source", "--messages-file", "--request-id", "--memory-at"],
|
||||
["--live"],
|
||||
);
|
||||
if (!flags) return null;
|
||||
const session = flags.values.get("--session");
|
||||
const source = flags.values.get("--source") ?? "memhub-cli";
|
||||
const messagesFile = flags.values.get("--messages-file");
|
||||
const requestId = flags.values.get("--request-id");
|
||||
const memoryAt = flags.values.get("--memory-at");
|
||||
let session: string | undefined;
|
||||
let source = "memhub-cli";
|
||||
let messagesFile: string | undefined;
|
||||
let requestId: string | undefined;
|
||||
let memoryAt: string | undefined;
|
||||
let priority: "live" | "normal" | undefined;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const arg = input[index];
|
||||
if (arg === "--live") {
|
||||
priority = "live";
|
||||
continue;
|
||||
}
|
||||
const value = input[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) return null;
|
||||
index += 1;
|
||||
if (arg === "--session") session = value;
|
||||
else if (arg === "--source") source = value;
|
||||
else if (arg === "--messages-file") messagesFile = value;
|
||||
else if (arg === "--request-id") requestId = value;
|
||||
else if (arg === "--memory-at") memoryAt = value;
|
||||
else return null;
|
||||
}
|
||||
|
||||
if (session === undefined || messagesFile === undefined || !source)
|
||||
return null;
|
||||
if (
|
||||
@@ -220,23 +323,39 @@ export function parseWriteCommand(
|
||||
...(requestId === undefined ? {} : { request_id: requestId }),
|
||||
...(memoryAt === undefined ? {} : { memory_at: memoryAt }),
|
||||
};
|
||||
return flags.switches.has("--live")
|
||||
? { ...command, priority: "live" }
|
||||
: command;
|
||||
return priority === undefined ? command : { ...command, priority };
|
||||
}
|
||||
|
||||
export interface TaskCommand {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
/** `--id <request-id>`: the legacy `task` spelling of `write status`. */
|
||||
export function parseTaskCommand(args: readonly string[]): TaskCommand | null {
|
||||
if (args.filter((item) => item === "--json").length > 1) return null;
|
||||
const flags = parseFlags(
|
||||
args.filter((item) => item !== "--json"),
|
||||
["--id"],
|
||||
[],
|
||||
);
|
||||
const id = flags?.values.get("--id");
|
||||
let id: string | undefined;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--json") continue;
|
||||
if (arg !== "--id") return null;
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) return null;
|
||||
index += 1;
|
||||
id = value;
|
||||
}
|
||||
return id === undefined ? null : { id };
|
||||
}
|
||||
|
||||
/** The human form. Kept separate from the JSON form on purpose. */
|
||||
export function describe(plan: Plan): string {
|
||||
const line = `${plan.action}: ${plan.target}`;
|
||||
return plan.detail ? `${line} (${plan.detail})` : line;
|
||||
}
|
||||
|
||||
/** The machine form: one document, nothing else on stdout. */
|
||||
export function asPlanDocument(plan: Plan): {
|
||||
schema_version: 1;
|
||||
plan: Plan;
|
||||
applied: boolean;
|
||||
} {
|
||||
return { schema_version: 1, plan, applied: false };
|
||||
}
|
||||
Loaded 100 of 630 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user