dsh-prime-memory 0.14.0-beta.6 → 0.14.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.md +11 -0
- package/CHANGELOG.ja.md +11 -0
- package/CHANGELOG.ko.md +11 -0
- package/CHANGELOG.md +11 -0
- package/README.en.md +25 -0
- package/README.ja.md +25 -0
- package/README.ko.md +25 -0
- package/README.md +25 -0
- package/dist/contract.d.ts +85 -0
- package/dist/stats.js +101 -1
- package/dist/store/l1-snapshot.d.ts +106 -5
- package/dist/store/l1-snapshot.js +118 -10
- package/dist/store/l1.d.ts +59 -2
- package/dist/store/l1.js +108 -2
- package/package.json +1 -1
package/CHANGELOG.en.md
CHANGED
|
@@ -18,8 +18,19 @@ This file covers the **0.12.0** release notes and the current **unreleased** cha
|
|
|
18
18
|
- New host API `MemoryDb.l0ByAnchor(sessionId, turn, step?)` fetches L0 messages **by coordinate** instead of by recency; this is the single read entry point for the upcoming evidence reader.
|
|
19
19
|
- **The records panel now shows the source anchor** where it previously always showed a dash.
|
|
20
20
|
|
|
21
|
+
- **Memory retirement (soft delete) and a real cleanup round-trip: deleting no longer means losing data.** "Delete" used to be a **physical** delete — a mistake could only be undone by hand from the `records/*.jsonl` fact source. Deletion now has two tiers, and **the reversible one is the default**.
|
|
22
|
+
- **Retirement (soft delete)**: the main-table row is kept, `valid_to` is closed, and a supersede marker is written (reserved `metadata` key `dsh_superseded`, carrying time / reason / verdict / conflict-pair id). Only the `l1_fts` and `l1_vec` rows are dropped. **The retrieval SQL is untouched** — zero query drift. All three retirement paths (conflict verdict / dedup supersede via `update`·`merge` / manual delete) **share one primitive**, so "delete" means the same thing everywhere.
|
|
23
|
+
- **Endpoints 33 → 38**: `records-retired` / `records-restore` / `cleanup-retired` / `snapshots-list` / `snapshot-restore`. Three lists (the `contract.ts` map, the `MEMORY_ENDPOINTS` allow-list in `stats.ts`, and the dispatcher `case` table) plus the endpoint-count assertion were updated together — missing any one makes an endpoint permanently 404 while the client `rpc` catch swallows the error silently and the whole panel block disappears.
|
|
24
|
+
- **`memory_delete` now retires 1 record by default** (was 3) and gained an exact `ids` path that skips semantic matching. The old semantic top-N batch delete **actually removed two unrelated real memories** during testing: precision has to come from exact ids, not from similarity.
|
|
25
|
+
- **Physical cleanup defaults to dry-run**: omitting `dryRun` means `true`. Even when explicitly executed it first writes a **full-library snapshot**, verifies it **by content hash**, and aborts with zero deletions on any mismatch. To make that impossible to bypass, `deleteL1Batch` was reduced to a **single caller** (`exportThenPurge`), pinned by a source-level guard test — "there is no physical-delete path that skips the export" is now a structural fact rather than a convention.
|
|
26
|
+
- **The return ticket was missing.** `restoreL1Snapshot` had **no production caller at all**, so "export before cleanup" was only half true: the export existed, the way back did not. `snapshots-list` / `snapshot-restore` now wire it up — snapshot **directory names only** (paths and `..` are rejected), dry-run by default, and `stillRetired` reported honestly. That field is not optional: cleanup only ever removes **already-retired** records and the snapshot is taken **before** deletion, so every record it can bring back carries a retirement marker. **Back in the main table ≠ back in recall**, and staying silent about it makes people believe recovery finished. Pass `unretire: true` for a genuine one-call rollback (it reuses the existing `restore`, no new write path).
|
|
27
|
+
- **Panel**: the records tab gained a collapsed "retired (recoverable)" section that only fetches when expanded, and delete confirmations now state that the action is reversible. **Physical cleanup deliberately has no panel entry** — irreversible actions stay on the RPC/model surface.
|
|
28
|
+
- **§C conflict freeze: same-batch contradictions can now be frozen (fixing "the only thing the model ever says is exactly what gets rejected").** Investigation showed that `validateConflictPair`'s third condition required the other side to be a known record from the candidate pool — and **ids of memories created in the same batch are not in it** (they were just generated and are not stored yet). So the most typical "the machine cannot decide" case, two fresh contradictory memories in one batch, was **necessarily rejected and fell back to `store`** even when the model correctly emitted `conflict`. Evidence: the model emits it 7/7 at the model level, yet that hop never reached the database (`conflict_pending` has had 0 rows since the DB was created, and `conflict` receipts are 0 across `store 381 / merge 204 / update 172 / skip 7`). Fix: `validateConflictPair` takes an optional `batchIds` (omitted = previous behaviour) while still requiring exactly one side to be the current record, which keeps pairing unique; plus a queue-full guard so that **a loser belonging to the current batch is never auto-resolved** (otherwise freshly extracted output would be retired immediately with nobody informed) — it is simply not parked and stored as usual.
|
|
29
|
+
|
|
21
30
|
### Fixed
|
|
22
31
|
|
|
32
|
+
- **The panel reported "conflict freeze is off" while the switch was clearly on.** The `conflicts` / `conflict-resolve` endpoints read the **static deployment config** `cfg.conflictFreeze.enabled`, whereas the panel writes the **live runtime setting**. The deployment default is always `false`, so with the switch on and `true` persisted to `settings.yaml` the page still said it was off. It now resolves through `effectiveCfg(cfg, live)`, the same resolution the dedup pipeline uses: there is exactly **one** source of truth, and the read and write sides must see the same state — otherwise you get "the list says it is on while the verdict says it is off".
|
|
33
|
+
- **`dsh-memory/embedding-reindex` was declared but permanently 404.** The endpoint existed in the contract yet was missing from both the `MEMORY_ENDPOINTS` allow-list and the dispatcher `case` table, and `startReindex()` was **dead code** — which is why the settings "vector index" block only ever offered Cancel and never Start. Allow-list, `case`, and tests added.
|
|
23
34
|
- **`UiRecord.sourceMessageIds` was a dead field.** It read a column `l1_records` never had, so it always resolved to `[]` and the panel's source row **never rendered**. Replaced by `sourceAnchors`, which reads real data.
|
|
24
35
|
|
|
25
36
|
## [0.12.0] — 2026-09-17
|
package/CHANGELOG.ja.md
CHANGED
|
@@ -20,8 +20,19 @@
|
|
|
20
20
|
- ホスト側の新 API `MemoryDb.l0ByAnchor(sessionId, turn, step?)`:**座標で** L0 メッセージを取得します(時間順ではない)。今後の「証拠リーダー」の唯一の入口です。
|
|
21
21
|
- **記録パネルがソースアンカーを表示**(従来は常に「-」でした)。
|
|
22
22
|
|
|
23
|
+
- **記憶の退場(ソフト削除)とクリーンアップの往復:削除はデータ喪失ではなくなりました。** 従来の「削除」は**物理削除**で、誤削除は `records/*.jsonl` の事実源から手作業で拾い直すしかありませんでした。現在は二段階で、**可逆な側が既定**です。
|
|
24
|
+
- **退場(ソフト削除)**:主テーブルの行は残し、`valid_to` を閉じ、取代マーカー(`metadata` の予約キー `dsh_superseded`:時刻 / 理由 / 裁定結果 / 衝突ペア id)を書き込み、`l1_fts` と `l1_vec` の行だけを落とします。**検索側 SQL は一字も変えていません**(クエリのドリフトゼロ)。三つの退場経路(裁定敗北 / 重複排除による取代 `update`·`merge` / 手動削除)は**同一プリミティブを共有**します。
|
|
25
|
+
- **エンドポイント 33 → 38**:`records-retired` / `records-restore` / `cleanup-retired` / `snapshots-list` / `snapshot-restore`。三つの一覧(`contract.ts` のマップ / `stats.ts` の `MEMORY_ENDPOINTS` 許可リスト / ディスパッチャの `case`)と端点総数のアサーションを同時に更新——どれか一つでも漏れると端点は恒久的に 404 になり、クライアント `rpc` の catch が黙って握り潰しパネルが丸ごと消えます。
|
|
26
|
+
- **`memory_delete` の既定は 1 件のみ退場**(従来 3 件)。意味的マッチを行わない `ids` **厳密経路**を追加。旧実装の意味的 top-N 一括削除は、実測で**無関係な実記憶を 2 件誤削除**しました——「正確に消す」は類似度ではなく厳密 ID で担保すべきものです。
|
|
27
|
+
- **物理クリーンアップは既定でドライラン**(`dryRun` 省略 = `true`)。明示実行でも**全庫スナップショット**を先に書き、**内容ハッシュで検証**し、不一致なら中止して 1 件も削除しません。これを回避不能にするため `deleteL1Batch` を**単一の呼び出し元**(`exportThenPurge`)に収束させ、ソースレベルのガードテストで固定しました。
|
|
28
|
+
- **復路が欠けていました。** `restoreL1Snapshot` は**プロダクションからの呼び出しが皆無**で、「クリーンアップ前にエクスポート」は半分しか成立していませんでした(エクスポートは在るが戻り道が無い)。`snapshots-list` / `snapshot-restore` で接続:受け付けるのはスナップショットの**ディレクトリ名のみ**(パスと `..` は拒否)、既定はドライラン、`stillRetired` を正直に報告します。この項目は必須です——クリーンアップは**既に退場した**記録しか消さず、スナップショットは削除**前**に撮るため、戻せる記録はすべて退場マーカー付きです。**主テーブルに戻った ≠ 検索に戻った**。黙っていると「復元完了」と誤解されます。完全な一発ロールバックは `unretire: true`(既存の `restore` を再利用、新しい書き込み経路は作りません)。
|
|
29
|
+
- **パネル**:記録タブに「退場済み(復元可能)」区画を追加(既定は折り畳み、展開時のみ取得)。削除確認の文言は「復元可能」に変更。**物理クリーンアップは意図的にパネル入口を作りません**——不可逆操作は RPC / モデル側の出口だけに残します。
|
|
30
|
+
- **§C 矛盾凍結:同一バッチ内の矛盾も凍結できるようになりました(「モデルが唯一言うことだけが必ず拒否される」の修正)。** 調査の結果、`validateConflictPair` の第③条件は「相手が候補プール内の既知レコードであること」を要求していましたが、**同一バッチで生成された新記憶の id はそこに含まれません**(まだ保存されていないため)。つまり最も典型的な「機械には判定できない」ケース(同一バッチの矛盾した二つの新記憶)は、モデルが正しく `conflict` を出しても**必ず不成対と判定され `store` にフォールバック**していました。証拠:モデル層では 7/7 で出力されるのに、その一歩は一度も DB に到達していません(`conflict_pending` は DB 作成以来 0 行、`l1_receipts` の `conflict` は `store 381 / merge 204 / update 172 / skip 7` の中で 0 件)。修正:`validateConflictPair` に任意の `batchIds` を追加(省略 = 従来動作)。ただし「必ず一方が本条の記録」は維持し、ペアが一意になるようにします。さらにキュー満杯ガードを追加し、**敗方が当バッチの新記憶である場合は自動了結しない**(抽出直後の産出が誰にも知られず即退場するのを避ける)ようにし、単に停放置せず通常どおり格納します。
|
|
31
|
+
|
|
23
32
|
### 修正
|
|
24
33
|
|
|
34
|
+
- **スイッチは明らかに ON なのに、パネルは「矛盾凍結は未開啟」と表示していました。** `conflicts` / `conflict-resolve` 端点は**デプロイ時の静的設定** `cfg.conflictFreeze.enabled` を読んでいたのに対し、パネルは**実行時設定**(live)に書き込んでいました。デプロイ既定値は常に `false` のため、スイッチが ON で `settings.yaml` に `true` が保存されていても未開啟と表示されます。`effectiveCfg(cfg, live)` 経由に変更し、重複排除パイプラインと同じ解決に統一しました——真実の源は**一つ**であり、読み手と書き手が同じ状態を見る必要があります。
|
|
35
|
+
- **`dsh-memory/embedding-reindex` は宣言されているのに恒久的に 404。** 契約には存在するものの `MEMORY_ENDPOINTS` 許可リストにもディスパッチャの `case` にも無く、`startReindex()` は**死んだコード**でした(設定の「ベクトル索引」ブロックに「開始」が無く「キャンセル」だけだった理由)。許可リスト + `case` + テストを追加。
|
|
25
36
|
- **`UiRecord.sourceMessageIds` は死んだフィールドでした。** `l1_records` に**存在しない列**を読んでいたため常に `[]` になり、パネルの来源行は**一度も描画されていません**でした。実データを読む `sourceAnchors` に置き換えました。
|
|
26
37
|
|
|
27
38
|
## [0.12.0] — 2026-09-17
|
package/CHANGELOG.ko.md
CHANGED
|
@@ -20,8 +20,19 @@
|
|
|
20
20
|
- 호스트 신규 API `MemoryDb.l0ByAnchor(sessionId, turn, step?)`: **좌표로**(시간순이 아니라) L0 메시지를 가져옵니다. 향후 "증거 리더"의 유일한 진입점입니다.
|
|
21
21
|
- **기록 패널이 출처 앵커를 표시**합니다(이전에는 항상 "-"였습니다).
|
|
22
22
|
|
|
23
|
+
- **기억 퇴장(소프트 삭제)과 정리 왕복: 삭제가 더 이상 데이터 손실이 아닙니다.** 기존의 "삭제"는 **물리 삭제**였고, 실수는 `records/*.jsonl` 사실 원본에서 손으로 건져내는 수밖에 없었습니다. 이제 두 단계이며 **되돌릴 수 있는 쪽이 기본**입니다.
|
|
24
|
+
- **퇴장(소프트 삭제)**: 메인 테이블 행은 유지하고 `valid_to`를 닫은 뒤 대체 마커(`metadata` 예약 키 `dsh_superseded`: 시각 / 사유 / 판정 결과 / 충돌 페어 id)를 기록하고, `l1_fts`와 `l1_vec` 행만 제거합니다. **검색 측 SQL은 한 글자도 바꾸지 않았습니다**(쿼리 드리프트 0). 세 가지 퇴장 경로(판정 패배 / 중복 제거 대체 `update`·`merge` / 수동 삭제)는 **동일한 프리미티브를 공유**합니다.
|
|
25
|
+
- **엔드포인트 33 → 38**: `records-retired` / `records-restore` / `cleanup-retired` / `snapshots-list` / `snapshot-restore`. 세 목록(`contract.ts` 맵 / `stats.ts`의 `MEMORY_ENDPOINTS` 허용 목록 / 디스패처 `case`)과 엔드포인트 총수 단언을 함께 갱신했습니다 — 하나라도 빠지면 해당 엔드포인트는 영구히 404가 되고, 클라이언트 `rpc`의 catch가 조용히 삼켜 패널 블록 전체가 사라집니다.
|
|
26
|
+
- **`memory_delete`의 기본은 1건만 퇴장**(기존 3건)이며, 의미 매칭을 건너뛰는 `ids` **정확 경로**를 추가했습니다. 기존 의미 기반 top-N 일괄 삭제는 실측에서 **무관한 실제 기억 2건을 잘못 삭제**했습니다 — "정확히 지우기"는 유사도가 아니라 정확한 ID로 보장해야 합니다.
|
|
27
|
+
- **물리 정리는 기본이 드라이런**(`dryRun` 생략 = `true`). 명시적으로 실행해도 먼저 **전체 라이브러리 스냅샷**을 쓰고 **내용 해시로 검증**하며, 불일치면 중단하고 한 건도 삭제하지 않습니다. 이를 우회 불가능하게 만들기 위해 `deleteL1Batch`를 **단일 호출자**(`exportThenPurge`)로 수렴시키고 소스 수준 가드 테스트로 고정했습니다.
|
|
28
|
+
- **복귀 경로가 없었습니다.** `restoreL1Snapshot`은 **프로덕션 호출자가 전혀 없어서** "정리 전에 내보내기"가 절반만 성립했습니다(내보내기는 있는데 돌아오는 길이 없음). `snapshots-list` / `snapshot-restore`로 연결했습니다: 스냅샷 **디렉터리 이름만** 받고(경로와 `..`는 거부), 기본은 드라이런이며 `stillRetired`를 정직하게 보고합니다. 이 필드는 필수입니다 — 정리는 **이미 퇴장한** 기록만 지우고 스냅샷은 삭제 **전**에 찍히므로, 되찾을 수 있는 모든 기록에 퇴장 마커가 붙어 있습니다. **메인 테이블로 돌아온 것 ≠ 검색으로 돌아온 것**이며, 이를 말하지 않으면 "복구 완료"로 오해합니다. 완전한 한 번의 롤백은 `unretire: true`(기존 `restore`를 재사용, 새 쓰기 경로 없음)입니다.
|
|
29
|
+
- **패널**: 기록 탭에 "퇴장됨(복구 가능)" 구획을 추가했습니다(기본 접힘, 펼칠 때만 조회). 삭제 확인 문구는 "복구 가능"으로 변경했습니다. **물리 정리는 의도적으로 패널 진입점을 만들지 않습니다** — 되돌릴 수 없는 동작은 RPC / 모델 표면에만 남깁니다.
|
|
30
|
+
- **§C 모순 동결: 동일 배치 내 모순도 이제 동결할 수 있습니다("모델이 유일하게 하는 말이 정확히 거부되는" 문제 수정).** 조사 결과 `validateConflictPair`의 세 번째 조건은 "상대방이 후보 풀의 기존 기록일 것"을 요구했지만, **동일 배치에서 생성된 새 기억의 id는 거기에 없습니다**(아직 저장되지 않았기 때문). 즉 가장 전형적인 "기계가 판단할 수 없는" 상황(한 배치의 모순된 두 새 기억)은 모델이 `conflict`를 올바르게 내도 **반드시 페어가 아니라고 판정되어 `store`로 폴백**했습니다. 증거: 모델 층에서는 7/7로 출력되지만 그 단계는 DB에 한 번도 도달하지 않았습니다(`conflict_pending`은 DB 생성 이래 0행, `l1_receipts`의 `conflict`는 `store 381 / merge 204 / update 172 / skip 7` 중 0건). 수정: `validateConflictPair`에 선택적 `batchIds` 추가(생략 = 기존 동작). 단 "정확히 한쪽이 본 기록"은 유지해 페어가 유일하도록 합니다. 또한 큐 만원 가드를 추가해 **패배자가 이번 배치의 새 기억이면 자동 종결하지 않습니다**(방금 추출된 산출물이 아무에게도 알려지지 않고 즉시 퇴장하는 것을 방지) — 단순히 대기시키지 않고 정상 저장합니다.
|
|
31
|
+
|
|
23
32
|
### 수정
|
|
24
33
|
|
|
34
|
+
- **스위치는 분명히 켜져 있는데 패널은 "모순 동결 미개방"이라고 표시했습니다.** `conflicts` / `conflict-resolve` 엔드포인트는 **배포 시점의 정적 설정** `cfg.conflictFreeze.enabled`를 읽었지만, 패널은 **런타임 설정**(live)에 기록했습니다. 배포 기본값은 항상 `false`이므로, 스위치가 켜지고 `settings.yaml`에 `true`가 저장되어도 미개방으로 표시됩니다. `effectiveCfg(cfg, live)` 경로로 변경해 중복 제거 파이프라인과 동일한 해석으로 통일했습니다 — 진실의 원천은 **하나**이며, 읽는 쪽과 쓰는 쪽이 같은 상태를 봐야 합니다.
|
|
35
|
+
- **`dsh-memory/embedding-reindex`는 선언되었지만 영구히 404였습니다.** 계약에는 있으나 `MEMORY_ENDPOINTS` 허용 목록과 디스패처 `case` 양쪽에 없었고, `startReindex()`는 **죽은 코드**였습니다(설정의 "벡터 인덱스" 블록에 "시작"이 없고 "취소"만 있던 이유). 허용 목록 + `case` + 테스트를 추가했습니다.
|
|
25
36
|
- **`UiRecord.sourceMessageIds`는 죽은 필드였습니다.** `l1_records`에 **존재하지 않는 컬럼**을 읽어 항상 `[]`가 되었고, 패널의 출처 행은 **한 번도 렌더링되지 않았습니다**. 실제 데이터를 읽는 `sourceAnchors`로 교체했습니다.
|
|
26
37
|
|
|
27
38
|
## [0.12.0] — 2026-09-17
|
package/CHANGELOG.md
CHANGED
|
@@ -22,8 +22,19 @@
|
|
|
22
22
|
- **忠实投影**:不 `stripCodeBlocks`、不按长度截断、不做「值不值得记」筛选——**捕获可以为省 token 丢东西,取证不行**。唯一保留的过滤是「插件注入的上下文不算用户发言」。
|
|
23
23
|
- **失败可分类**(这是本节的重点):`no-service` / `no-anchor` / `session-unreadable` / `anchor-not-found` / `timeout` / `error`。前四类的区分是必需的——把「读不到」当成「没谈过」会让已归档会话的记忆被系统性误判。
|
|
24
24
|
|
|
25
|
+
- **记忆退场(软删)与清理闭环:删除不再等于丢数据。** 此前"删除"是**物理删除**——一次误删只能去 `records/*.jsonl` 事实源里手工捞。现在删除分两档,**可逆的那档是默认**,不可逆的那档需要显式要求且自带导出物。
|
|
26
|
+
- **退场(软删)**:保留主表行 + 闭合 `valid_to` + 写取代标记(`metadata` 保留键 `dsh_superseded`,含时刻 / 原因 / 裁决结论 / 冲突对 id),只撤掉 `l1_fts` 与 `l1_vec` 行。**检索侧 SQL 一字未改**——不引入任何查询漂移。三条退场路径(裁决判负 / 去重取代 `update`·`merge` / 人工删除)**共用同一个原语**,否则迟早出现"某条路径还在硬删"的不一致语义,而那种不一致只在误删发生时才暴露。
|
|
27
|
+
- **端点 33 → 38**:`records-retired`(已退场列表)/ `records-restore`(找回)/ `cleanup-retired`(物理清理)/ `snapshots-list`(快照清单)/ `snapshot-restore`(从快照回灌)。三处清单(`contract.ts` 映射表 / `stats.ts` 的 `MEMORY_ENDPOINTS` 白名单 / 分发 `case`)与端点总数断言同步更新——少改任一处都会让端点恒返 404,而客户端 `rpc` 的 catch 会静默吞掉异常、面板整块消失。
|
|
28
|
+
- **`memory_delete` 默认只退场 1 条**(原为 3),并新增 `ids` **精确路径**(跳过语义匹配)。原实现用语义 top-N 批量删,实测**误删过两条无关的真实记忆**——"删得准"必须由精确 ID 保证,而不是靠相似度。
|
|
29
|
+
- **物理清理默认干跑**:省略 `dryRun` 即视为 `true`。即便显式执行,也先落**全库快照**并**按内容哈希**校验,不一致即中止且一条不删。为此把 `deleteL1Batch` 收敛到**唯一调用方**(`exportThenPurge`),并由源码守卫测试钉住——"没有绕过导出的物理删除路径"由此成为结构事实,而不是一句约定。
|
|
30
|
+
- **补上回程票**:`restoreL1Snapshot` 此前**只有测试在调用**,于是"先有导出物再清理"只成立了一半——导出物在、回灌出口不在,真出事只能人工解析 `l1-records.json`。现接上 `snapshots-list` / `snapshot-restore`:只收快照**目录名**(拒绝路径与 `..`)、默认干跑、并如实报出 `stillRetired`。这一条是必需的:清理只清理**已退场**记录,而快照拍在删除**之前**,所以找回的每一条都带退场标记——**写回主表 ≠ 回到召回**,不说明就会让人以为"恢复完了"。要一步完成真正的回滚用 `unretire: true`(复用既有 `restore`,不新开写路径)。
|
|
31
|
+
- **面板**:记录页新增「已退场(可恢复)」区(默认折叠、展开时才拉取,不让它拖慢正常浏览);删除确认文案改为明确"可恢复"。**物理清理刻意不做面板入口**——不可逆动作只留 RPC / 模型出口。
|
|
32
|
+
- **§C 矛盾冻结:同批次矛盾现在也能冻结(修掉"模型唯一会说的话恰好被拒收")。** 取证发现 `validateConflictPair` 的第③条硬性要求"另一方必须是候选池里的已知记录",而**同批次新记忆的 id 不在其中**(它们是本轮刚生成的、尚未入库)。于是"本轮两条新记忆互相矛盾"这种最典型的"机器判不了"情形,模型即便正确 emit 了 `conflict`,也**必然被判不成对而回落 `store`**。证据:模型层 7/7 会 emit,但那一跳从未落库(`conflict_pending` 建库以来 0 行、`l1_receipts` 里 `conflict` 凭证 0 条,而 `store 381 / merge 204 / update 172 / skip 7`)。修法:`validateConflictPair` 新增可选 `batchIds`(缺省 = 旧行为),仍要求"恰有一方是本条记忆"以保证配对唯一;并补队列满护栏——**败方属本轮新记忆时不做自动了结**,否则刚抽取的产出会立刻退场且没有任何人被告知,改为不停放、照常入库。
|
|
33
|
+
|
|
25
34
|
### 修复
|
|
26
35
|
|
|
36
|
+
- **面板说"矛盾冻结未开启",而开关明明是开的。** `conflicts` / `conflict-resolve` 端点读的是**部署静态配置** `cfg.conflictFreeze.enabled`,而面板写入的是**运行时设置**(live)。部署默认恒 `false`,于是开关已开、`settings.yaml` 已落 `true`,本页仍报未开启。改走 `effectiveCfg(cfg, live)`,与去重管线同一套解析——开关只有**一个**事实源,读端与写端必须看同一份状态,否则会出现"列表说开着、裁决说没开"的自相矛盾。
|
|
37
|
+
- **`dsh-memory/embedding-reindex` 声明了却恒返 404。** 端点写在契约里,但既缺席 `MEMORY_ENDPOINTS` 白名单、也没有分发 `case`;同时 `startReindex()` 是**死代码**,设置页「向量索引」区块因此只有"取消"没有"开始"。补白名单 + `case` + 用例。
|
|
27
38
|
- **`UiRecord.sourceMessageIds` 是死字段。** 它读的是 `l1_records` **从不存在的列**,永远回退 `[]`,于是记录面板的来源行**从未渲染过**。已替换为读真实数据的 `sourceAnchors`。
|
|
28
39
|
|
|
29
40
|
## [0.12.0] — 2026-09-17
|
package/README.en.md
CHANGED
|
@@ -473,6 +473,31 @@ dual-write storage architecture) are modeled after **MemoryCore** from
|
|
|
473
473
|
[TencentCloud/TencentDB-Agent-Memory](https://github.com/TencentCloud/TencentDB-Agent-Memory).
|
|
474
474
|
Thanks to the original project for open-sourcing its design and implementation.
|
|
475
475
|
|
|
476
|
+
## Memory Retirement, Recovery, and Cleanup
|
|
477
|
+
|
|
478
|
+
Deletion has **two tiers**, and the tier is chosen by cost: **the reversible one is the default**, while the irreversible one must be requested explicitly and always carries an export.
|
|
479
|
+
|
|
480
|
+
| Action | Endpoint / tool | Reversible | Notes |
|
|
481
|
+
| --- | --- | --- | --- |
|
|
482
|
+
| Retire (soft delete) | `memory_delete` · `dsh-memory/records-delete` | ✅ | Keeps the main-table row, closes `valid_to`, writes a supersede marker, and drops only the FTS/vector rows. **Retires 1 record by default**; for batches pass exact `ids` rather than relying on semantic matching |
|
|
483
|
+
| Recover | `dsh-memory/records-restore` | — | Clears the retirement marker and rebuilds indexes, returning the record to recall |
|
|
484
|
+
| Physical cleanup | `dsh-memory/cleanup-retired` | ❌ | The plugin's **only irreversible** action. **Dry-run by default** (omitting `dryRun` deletes nothing); even when executed explicitly it first writes a full-library snapshot and verifies it **by content hash**, aborting with zero deletions on any mismatch |
|
|
485
|
+
| List snapshots | `dsh-memory/snapshots-list` | — | Lists the snapshots under `snapshots/` that have a valid manifest, with reason and record counts |
|
|
486
|
+
| Restore from snapshot | `dsh-memory/snapshot-restore` | — | Writes cleaned-up records back. **Dry-run by default**; accepts snapshot directory names only, never paths |
|
|
487
|
+
|
|
488
|
+
All three retirement paths — conflict verdict, dedup supersede (`update`/`merge`), manual delete — **share one primitive**, so "delete" means the same thing in all of them: recoverable.
|
|
489
|
+
|
|
490
|
+
### How the cleanup safety net is built
|
|
491
|
+
|
|
492
|
+
Physical deletion must be preceded by a snapshot that passes verification (see the table above). The way back is:
|
|
493
|
+
|
|
494
|
+
1. `dsh-memory/snapshots-list` — get the snapshot directory name (shaped like `l1-<timestamp>-<reason>`);
|
|
495
|
+
2. `dsh-memory/snapshot-restore` — dry-run first to read `missing` (how many records would genuinely come back, not the snapshot's total), then write with an explicit `dryRun:false`.
|
|
496
|
+
|
|
497
|
+
The restore entry point accepts **directory names only, never paths**: otherwise this RPC would incidentally gain the ability to read any directory and write its contents into the retrieval database. Restore itself is an idempotent upsert and can be re-run safely.
|
|
498
|
+
|
|
499
|
+
> **One semantic you need to know**: `cleanup-retired` only removes **already-retired** records, and the snapshot is taken **before** deletion — so every record a cleanup snapshot can bring back carries a retirement marker. By default `snapshot-restore` only writes the row back to the main table (and reports it honestly in `stillRetired`); **"back in the main table" ≠ "back in recall"**. For a genuine one-call rollback add `unretire: true`, or call `records-restore` on that batch of ids afterwards.
|
|
500
|
+
|
|
476
501
|
## Roadmap
|
|
477
502
|
|
|
478
503
|
Features under planning — feedback and priorities welcome in the
|
package/README.ja.md
CHANGED
|
@@ -294,6 +294,31 @@ dsh ホストはプラグインのログをコンソールへ出力します。
|
|
|
294
294
|
- 想起注入点は `agent/pre-step`(メッセージ側合成メッセージ)+ エージェント作用域 `systemPrompt.context`(ペルソナ/ナビ安定域);
|
|
295
295
|
- ストレージ/検索は公式 sqlite バックエンドの単機削ぎ版(マルチテナント分離列・TCVDB クラウドバックエンド・監査表を削除;トークン化は公式同様 jieba を使用)。
|
|
296
296
|
|
|
297
|
+
## 記憶の退場・復元・クリーンアップ
|
|
298
|
+
|
|
299
|
+
削除は**二段階**で、段階はコストで決まります:**可逆な側が既定**、不可逆な側は明示的に要求する必要があり、常にエクスポートを伴います。
|
|
300
|
+
|
|
301
|
+
| 操作 | 端点 / ツール | 可逆 | 説明 |
|
|
302
|
+
| --- | --- | --- | --- |
|
|
303
|
+
| 退場(ソフト削除) | `memory_delete` · `dsh-memory/records-delete` | ✅ | 主テーブルの行を残し `valid_to` を閉じ、取代マーカーを書き、FTS/ベクトル行だけを落とします。**既定は 1 件のみ退場**。一括は意味的マッチではなく厳密な `ids` を渡してください |
|
|
304
|
+
| 復元 | `dsh-memory/records-restore` | — | 退場マーカーを消しインデックスを再構築して、想起対象に戻します |
|
|
305
|
+
| 物理クリーンアップ | `dsh-memory/cleanup-retired` | ❌ | 本プラグインで**唯一不可逆**な操作。**既定はドライラン**(`dryRun` 省略なら何も削除しません)。明示実行でも先に全庫スナップショットを書き、**内容ハッシュで検証**し、不一致なら中止して 1 件も削除しません |
|
|
306
|
+
| スナップショット一覧 | `dsh-memory/snapshots-list` | — | `snapshots/` 下で有効なマニフェストを持つスナップショットを、理由と件数つきで列挙 |
|
|
307
|
+
| スナップショットから復元 | `dsh-memory/snapshot-restore` | — | クリーンアップ済みの記録を書き戻します。**既定はドライラン**。受け付けるのはディレクトリ名のみでパスは不可 |
|
|
308
|
+
|
|
309
|
+
三つの退場経路——裁定敗北、重複排除による取代(`update`/`merge`)、手動削除——は**同一プリミティブを共有**するため、「削除」はどの経路でも同じ意味になります:復元可能です。
|
|
310
|
+
|
|
311
|
+
### クリーンアップの「後悔薬」の構成
|
|
312
|
+
|
|
313
|
+
物理削除の前には必ず検証を通過したスナップショットが必要です(上の表を参照)。戻る道は次のとおりです:
|
|
314
|
+
|
|
315
|
+
1. `dsh-memory/snapshots-list` —— スナップショットのディレクトリ名(`l1-<タイムスタンプ>-<理由>` 形式)を取得;
|
|
316
|
+
2. `dsh-memory/snapshot-restore` —— まずドライランで `missing`(スナップショット総数ではなく、実際に戻る件数)を確認し、明示的に `dryRun:false` で書き戻します。
|
|
317
|
+
|
|
318
|
+
復元の入口は**ディレクトリ名のみを受け付け、パスは受け付けません**。そうでなければこの RPC が「任意のディレクトリを読んで内容を検索 DB に書き込む」能力を副次的に得てしまいます。復元自体は冪等な upsert で、安全に再実行できます。
|
|
319
|
+
|
|
320
|
+
> **知っておくべき一つの意味論**:`cleanup-retired` は**既に退場した**記録しか消さず、スナップショットは削除**前**に撮られるため、クリーンアップのスナップショットで戻せる記録はすべて退場マーカー付きです。既定では `snapshot-restore` は行を主テーブルに書き戻すだけ(`stillRetired` で正直に報告)で、**「主テーブルに戻った」≠「想起に戻った」**。真の一発ロールバックには `unretire: true` を付けるか、後でその id 群に対して `records-restore` を呼んでください。
|
|
321
|
+
|
|
297
322
|
## ロードマップ
|
|
298
323
|
|
|
299
324
|
[Issues](https://github.com/drscrewdriver/dsh-prime-memory/issues) で要望と優先度を募集中:
|
package/README.ko.md
CHANGED
|
@@ -294,6 +294,31 @@ dsh 호스트는 플러그인 로그를 콘솔로 출력합니다. 플러그인
|
|
|
294
294
|
- 회상 주입점은 `agent/pre-step`(메시지 측 합성 메시지)+ 에이전트 범위 `systemPrompt.context`(페르소나/내비 안정 영역);
|
|
295
295
|
- 저장/검색은 공식 sqlite 백엔드의 단기 슬림판(멀티테넌트 분리열·TCVDB 클라우드 백엔드·감사표 삭제;토큰화는 공식과 동일 jieba 사용).
|
|
296
296
|
|
|
297
|
+
## 기억 퇴장·복구·정리
|
|
298
|
+
|
|
299
|
+
삭제는 **두 단계**이며 단계는 비용으로 정해집니다: **되돌릴 수 있는 쪽이 기본**이고, 되돌릴 수 없는 쪽은 명시적으로 요청해야 하며 항상 내보내기를 동반합니다.
|
|
300
|
+
|
|
301
|
+
| 동작 | 엔드포인트 / 도구 | 되돌리기 | 설명 |
|
|
302
|
+
| --- | --- | --- | --- |
|
|
303
|
+
| 퇴장(소프트 삭제) | `memory_delete` · `dsh-memory/records-delete` | ✅ | 메인 테이블 행을 유지하고 `valid_to`를 닫으며 대체 마커를 쓰고 FTS/벡터 행만 제거합니다. **기본은 1건만 퇴장**. 일괄은 의미 매칭이 아니라 정확한 `ids`를 전달하세요 |
|
|
304
|
+
| 복구 | `dsh-memory/records-restore` | — | 퇴장 마커를 지우고 인덱스를 재구축해 회상 대상으로 되돌립니다 |
|
|
305
|
+
| 물리 정리 | `dsh-memory/cleanup-retired` | ❌ | 이 플러그인의 **유일한 비가역** 동작. **기본은 드라이런**(`dryRun` 생략 시 아무것도 삭제하지 않음). 명시 실행이라도 먼저 전체 라이브러리 스냅샷을 쓰고 **내용 해시로 검증**하며, 불일치면 중단하고 한 건도 삭제하지 않습니다 |
|
|
306
|
+
| 스냅샷 목록 | `dsh-memory/snapshots-list` | — | `snapshots/` 아래에서 유효한 매니페스트를 가진 스냅샷을 사유와 건수와 함께 나열 |
|
|
307
|
+
| 스냅샷에서 복원 | `dsh-memory/snapshot-restore` | — | 정리된 기록을 다시 씁니다. **기본은 드라이런**. 디렉터리 이름만 받고 경로는 받지 않습니다 |
|
|
308
|
+
|
|
309
|
+
세 가지 퇴장 경로 — 판정 패배, 중복 제거 대체(`update`/`merge`), 수동 삭제 — 는 **동일한 프리미티브를 공유**하므로 "삭제"는 어느 경로에서나 같은 의미입니다: 복구 가능합니다.
|
|
310
|
+
|
|
311
|
+
### 정리의 "되돌리기 수단" 구성
|
|
312
|
+
|
|
313
|
+
물리 삭제 전에는 반드시 검증을 통과한 스냅샷이 있어야 합니다(위 표 참조). 돌아가는 길은 다음과 같습니다:
|
|
314
|
+
|
|
315
|
+
1. `dsh-memory/snapshots-list` —— 스냅샷 디렉터리 이름(`l1-<타임스탬프>-<사유>` 형식)을 얻습니다;
|
|
316
|
+
2. `dsh-memory/snapshot-restore` —— 먼저 드라이런으로 `missing`(스냅샷 총수가 아니라 실제로 되돌아오는 건수)을 확인하고, 명시적으로 `dryRun:false`로 기록합니다.
|
|
317
|
+
|
|
318
|
+
복원 진입점은 **디렉터리 이름만 받고 경로는 받지 않습니다**. 그렇지 않으면 이 RPC가 "임의 디렉터리를 읽어 내용을 검색 DB에 쓰는" 능력을 부수적으로 얻게 됩니다. 복원 자체는 멱등 upsert이므로 안전하게 재실행할 수 있습니다.
|
|
319
|
+
|
|
320
|
+
> **반드시 알아야 할 의미론 하나**: `cleanup-retired`는 **이미 퇴장한** 기록만 지우고 스냅샷은 삭제 **전**에 찍히므로, 정리 스냅샷으로 되찾을 수 있는 모든 기록에는 퇴장 마커가 붙어 있습니다. 기본적으로 `snapshot-restore`는 행을 메인 테이블로 되돌릴 뿐이며(`stillRetired`로 정직하게 보고), **"메인 테이블로 돌아옴" ≠ "회상으로 돌아옴"** 입니다. 진정한 한 번의 롤백에는 `unretire: true`를 붙이거나, 이후 해당 id들에 `records-restore`를 호출하세요.
|
|
321
|
+
|
|
297
322
|
## 로드맵
|
|
298
323
|
|
|
299
324
|
[Issues](https://github.com/drscrewdriver/dsh-prime-memory/issues)에서 요구와 우선순위 환영:
|
package/README.md
CHANGED
|
@@ -410,6 +410,31 @@ fsync),断电等极端崩溃最多丢最后一小段尾部,检索库可用
|
|
|
410
410
|
词元供 BM25 精确整词命中、二元组保子词召回;加载失败自动回退纯二元组,
|
|
411
411
|
FTS 索引按分词器版本戳自动重建)。
|
|
412
412
|
|
|
413
|
+
## 记忆退场、恢复与清理
|
|
414
|
+
|
|
415
|
+
删除分**两档**,档位由代价决定:**可逆的那档是默认**,不可逆的那档需要显式要求,并且自带导出物。
|
|
416
|
+
|
|
417
|
+
| 动作 | 端点 / 工具 | 可逆 | 说明 |
|
|
418
|
+
| --- | --- | --- | --- |
|
|
419
|
+
| 退场(软删) | `memory_delete` · `dsh-memory/records-delete` | ✅ | 保留主表行 + 闭合 `valid_to` + 写取代标记,只撤掉 FTS/向量行。**默认只退场 1 条**;批量请传精确 `ids`,不要依赖语义匹配 |
|
|
420
|
+
| 找回 | `dsh-memory/records-restore` | — | 清退场标记 + 重建索引,记录重新进入召回 |
|
|
421
|
+
| 物理清理 | `dsh-memory/cleanup-retired` | ❌ | 本插件**唯一不可逆**的动作。**默认干跑**(省略 `dryRun` 即不删);即便显式执行,也先落全库快照并**按内容哈希**校验,不一致即中止且一条不删 |
|
|
422
|
+
| 快照清单 | `dsh-memory/snapshots-list` | — | 列出 `snapshots/` 下带合法清单的快照(含原因与条数) |
|
|
423
|
+
| 从快照回灌 | `dsh-memory/snapshot-restore` | — | 把已清理的记录写回。**默认干跑**;只收快照目录名,不接受路径 |
|
|
424
|
+
|
|
425
|
+
三条退场路径——裁决判负、去重取代(`update`/`merge`)、人工删除——**共用同一个原语**,所以"删除"在三处语义一致:都可恢复。
|
|
426
|
+
|
|
427
|
+
### 清理的"后悔药"是怎么构成的
|
|
428
|
+
|
|
429
|
+
物理删除前必须先落快照并校验通过(见上表)。走回去的那条路是:
|
|
430
|
+
|
|
431
|
+
1. `dsh-memory/snapshots-list` —— 拿到快照目录名(形如 `l1-<时间戳>-<原因>`);
|
|
432
|
+
2. `dsh-memory/snapshot-restore` —— 先干跑看 `missing`(真正被找回的条数,而不是快照总条数),再显式 `dryRun:false` 写回。
|
|
433
|
+
|
|
434
|
+
恢复入口**只收目录名、不收路径**:否则这条 RPC 就等于顺带获得了"读任意目录并把内容写进检索库"的能力。恢复本身是幂等的 upsert,可安全重跑。
|
|
435
|
+
|
|
436
|
+
> **一处必须知道的语义**:`cleanup-retired` 只清理**已退场**记录,而快照拍在删除**之前**——所以清理快照能找回的每一条都带着退场标记。默认情况下 `snapshot-restore` 只把行写回主表(并在 `stillRetired` 里如实报出),**"回到主表" ≠ "回到召回"**;要一步完成真正的回滚,加 `unretire: true`,或随后对该批 id 调 `records-restore`。
|
|
437
|
+
|
|
413
438
|
## 路线图
|
|
414
439
|
|
|
415
440
|
以下为规划中的功能,欢迎在 [Issues](https://github.com/drscrewdriver/dsh-prime-memory/issues) 反馈需求与优先级:
|
package/dist/contract.d.ts
CHANGED
|
@@ -677,9 +677,90 @@ export interface CleanupRetiredResponse {
|
|
|
677
677
|
aborted: boolean;
|
|
678
678
|
/** 快照目录(真跑时非空)。 */
|
|
679
679
|
dir: string;
|
|
680
|
+
/**
|
|
681
|
+
* 快照**目录名**(真跑时非空)。
|
|
682
|
+
*
|
|
683
|
+
* `dir` 是给人看的绝对路径;`name` 是给 `snapshot-restore` 用的**唯一寻址口径**
|
|
684
|
+
* ——恢复入口只收名字、不收路径(见 `isSnapshotName`)。没有它,调用方就得自己
|
|
685
|
+
* 从 `dir` 里切最后一段,还要同时兼容 `/` 与 `\` 两种分隔符。
|
|
686
|
+
*/
|
|
687
|
+
name: string;
|
|
680
688
|
/** 中止原因(仅 aborted 时非空)。 */
|
|
681
689
|
diffs: string[];
|
|
682
690
|
}
|
|
691
|
+
/** dsh-memory/snapshots-list(可用快照清单;恢复前先看有哪些)。 */
|
|
692
|
+
export interface SnapshotsListRequest {
|
|
693
|
+
/** 最多返回几份(1~200,默认 50)。 */
|
|
694
|
+
limit?: number;
|
|
695
|
+
}
|
|
696
|
+
/** 一份快照的摘要(与 `l1-snapshot.SnapshotSummary` 同形)。 */
|
|
697
|
+
export interface SnapshotSummaryView {
|
|
698
|
+
/** 目录名——恢复时传它。 */
|
|
699
|
+
name: string;
|
|
700
|
+
/** 绝对路径(给人看/给运维定位)。 */
|
|
701
|
+
dir: string;
|
|
702
|
+
createdAt: string;
|
|
703
|
+
/** 建它的原因(如 `cleanup-retired` / `pre-rebuild`)。 */
|
|
704
|
+
reason: string;
|
|
705
|
+
/** `l1_records` 条数。 */
|
|
706
|
+
records: number;
|
|
707
|
+
receipts: number;
|
|
708
|
+
conflicts: number;
|
|
709
|
+
/** `l1_vec` 行数(派生投影,不落快照,只记数)。 */
|
|
710
|
+
vecCount: number;
|
|
711
|
+
}
|
|
712
|
+
export interface SnapshotsListResponse {
|
|
713
|
+
/** 按时间**倒序**(最新的在前)。 */
|
|
714
|
+
items: SnapshotSummaryView[];
|
|
715
|
+
total: number;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* dsh-memory/snapshot-restore(从快照把已物理删除的记录灌回检索库)。
|
|
719
|
+
*
|
|
720
|
+
* **默认干跑**:`dryRun` 省略即视为 `true`。恢复本身是幂等的 upsert(不删任何东西),
|
|
721
|
+
* 但它是"把历史状态写回当前库",仍须调用方显式要求才做——与 `cleanup-retired` 同一条纪律。
|
|
722
|
+
*/
|
|
723
|
+
export interface SnapshotRestoreRequest {
|
|
724
|
+
/** 快照**目录名**(见 snapshots-list)。**不接受路径**。 */
|
|
725
|
+
name: string;
|
|
726
|
+
/** 只恢复这些 id(≤2000);省略 = 快照内全部。 */
|
|
727
|
+
ids?: string[];
|
|
728
|
+
/** 默认 true(干跑)。显式 false 才真正写库。 */
|
|
729
|
+
dryRun?: boolean;
|
|
730
|
+
/**
|
|
731
|
+
* 顺手把带回的记录**放回检索面**(清退场标记 + 重建索引)。默认 `false`。
|
|
732
|
+
*
|
|
733
|
+
* 为什么默认关:`cleanup-retired` 只清理**已退场**记录,快照又拍在删除**之前**
|
|
734
|
+
* ——所以清理快照找回的每一条都带退场标记,只回主表、不回召回。默认关 = 恢复
|
|
735
|
+
* 的是"当时的状态";要一步到位(真正的回滚)再显式打开。两种情形都会在
|
|
736
|
+
* `stillRetired` / `unretired` 里如实报出,不存在"悄悄复活"。
|
|
737
|
+
*/
|
|
738
|
+
unretire?: boolean;
|
|
739
|
+
}
|
|
740
|
+
export interface SnapshotRestoreResponse {
|
|
741
|
+
name: string;
|
|
742
|
+
dir: string;
|
|
743
|
+
dryRun: boolean;
|
|
744
|
+
/** 快照里的记录总数(过滤前)。 */
|
|
745
|
+
inSnapshot: number;
|
|
746
|
+
/** 本次涉及(过滤后)的条数。 */
|
|
747
|
+
targets: number;
|
|
748
|
+
/** 其中当前**不在库**的条数——真正被找回的条数。 */
|
|
749
|
+
missing: number;
|
|
750
|
+
/** 实际写回条数(干跑恒为 0)。 */
|
|
751
|
+
restored: number;
|
|
752
|
+
failed: number;
|
|
753
|
+
/** 成功补回向量的条数(干跑恒为 0;嵌入不可用时可能为 0)。 */
|
|
754
|
+
vectorsWritten: number;
|
|
755
|
+
/** 本次顺手放回检索面的条数(仅 `unretire:true` 时可能非零)。 */
|
|
756
|
+
unretired: number;
|
|
757
|
+
/** 回到主表但**仍不在检索面**的 id(再调 `records-restore` 可放回)。 */
|
|
758
|
+
stillRetired: string[];
|
|
759
|
+
/** 请求了但该快照里没有的 id。 */
|
|
760
|
+
notFound: string[];
|
|
761
|
+
/** 提示(如名字非法/快照不存在,或"还有记录没回到检索面")。 */
|
|
762
|
+
notice?: string;
|
|
763
|
+
}
|
|
683
764
|
/** dsh-memory/graph-search(图谱节点检索;紧凑节点卡)。 */
|
|
684
765
|
export interface GraphSearchRequest {
|
|
685
766
|
/** 自然语言查询(≤4096 字符;空查询返回空)。 */
|
|
@@ -968,6 +1049,8 @@ export interface DshMemoryRequestMap {
|
|
|
968
1049
|
'dsh-memory/records-retired': RecordsRetiredRequest;
|
|
969
1050
|
'dsh-memory/records-restore': RecordsRestoreRequest;
|
|
970
1051
|
'dsh-memory/cleanup-retired': CleanupRetiredRequest;
|
|
1052
|
+
'dsh-memory/snapshots-list': SnapshotsListRequest;
|
|
1053
|
+
'dsh-memory/snapshot-restore': SnapshotRestoreRequest;
|
|
971
1054
|
}
|
|
972
1055
|
export interface DshMemoryResponseMap {
|
|
973
1056
|
'dsh-memory/stats': StatsResponse;
|
|
@@ -1006,6 +1089,8 @@ export interface DshMemoryResponseMap {
|
|
|
1006
1089
|
'dsh-memory/records-retired': RecordsRetiredResponse;
|
|
1007
1090
|
'dsh-memory/records-restore': RecordsRestoreResponse;
|
|
1008
1091
|
'dsh-memory/cleanup-retired': CleanupRetiredResponse;
|
|
1092
|
+
'dsh-memory/snapshots-list': SnapshotsListResponse;
|
|
1093
|
+
'dsh-memory/snapshot-restore': SnapshotRestoreResponse;
|
|
1009
1094
|
}
|
|
1010
1095
|
/** 全部端点名(client 调用与 host case 表的共用字面量来源)。 */
|
|
1011
1096
|
export type DshMemoryEndpoint = keyof DshMemoryResponseMap;
|
package/dist/stats.js
CHANGED
|
@@ -23,6 +23,7 @@ import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView } from './store/re
|
|
|
23
23
|
import { resolveConflictPair, listConflictPairs } from './conflict-service.js';
|
|
24
24
|
import { sourceAnchorLabels } from './pipeline/anchors.js';
|
|
25
25
|
import { readSupersedeMarker } from './store/supersede.js';
|
|
26
|
+
import { isSnapshotName } from './store/l1-snapshot.js';
|
|
26
27
|
import { errDetail } from './util/filelog.js';
|
|
27
28
|
import { snapshotTokenCost } from './token-cost.js';
|
|
28
29
|
const require = createRequire(import.meta.url);
|
|
@@ -75,6 +76,8 @@ export const MEMORY_ENDPOINTS = [
|
|
|
75
76
|
'dsh-memory/records-retired',
|
|
76
77
|
'dsh-memory/records-restore',
|
|
77
78
|
'dsh-memory/cleanup-retired',
|
|
79
|
+
'dsh-memory/snapshots-list',
|
|
80
|
+
'dsh-memory/snapshot-restore',
|
|
78
81
|
];
|
|
79
82
|
/** HTTP 路由前缀(客户端 fetch `/dsh-memory/rpc/<短方法名>`)。 */
|
|
80
83
|
const RPC_ROUTE_PREFIX = '/dsh-memory/rpc';
|
|
@@ -845,12 +848,13 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
845
848
|
purged: 0,
|
|
846
849
|
aborted: false,
|
|
847
850
|
dir: '',
|
|
851
|
+
name: '',
|
|
848
852
|
diffs: [],
|
|
849
853
|
};
|
|
850
854
|
return resp;
|
|
851
855
|
}
|
|
852
856
|
if (targets.length === 0) {
|
|
853
|
-
const resp = { dryRun: false, targets: 0, purged: 0, aborted: false, dir: '', diffs: [] };
|
|
857
|
+
const resp = { dryRun: false, targets: 0, purged: 0, aborted: false, dir: '', name: '', diffs: [] };
|
|
854
858
|
return resp;
|
|
855
859
|
}
|
|
856
860
|
const r = await stores.l1.purgeRetired(targets, 'cleanup-retired');
|
|
@@ -860,10 +864,106 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
860
864
|
purged: r.purged,
|
|
861
865
|
aborted: r.aborted,
|
|
862
866
|
dir: r.dir,
|
|
867
|
+
name: r.name,
|
|
863
868
|
diffs: r.diffs,
|
|
864
869
|
};
|
|
865
870
|
return resp;
|
|
866
871
|
}
|
|
872
|
+
// ── 快照(清单 / 回灌):`cleanup-retired` 与「重建」的**回程票** ──
|
|
873
|
+
// 在此之前 `restoreL1Snapshot` 只有测试调用:导出物会落盘,却没有任何出口能
|
|
874
|
+
// 装回去 —— "清理是本插件唯一不可逆的动作"这句话因此只成立了一半。
|
|
875
|
+
// 读方向(列表)不开权限门(看得见才知道要不要恢复);回灌由 memoryMutate 门控。
|
|
876
|
+
case 'dsh-memory/snapshots-list': {
|
|
877
|
+
const p = (payload ?? {});
|
|
878
|
+
const raw = Math.floor(Number(p.limit));
|
|
879
|
+
const limit = Number.isFinite(raw) && raw > 0 ? Math.min(raw, 200) : 50;
|
|
880
|
+
const { items, total } = await stores.l1.listSnapshots({ limit });
|
|
881
|
+
const resp = { items, total };
|
|
882
|
+
return resp;
|
|
883
|
+
}
|
|
884
|
+
case 'dsh-memory/snapshot-restore': {
|
|
885
|
+
if (!live?.get().memoryMutate) {
|
|
886
|
+
throw new Error('记忆写删未开放:请在记忆库面板开启高权限模式');
|
|
887
|
+
}
|
|
888
|
+
const p = (payload ?? {});
|
|
889
|
+
const name = typeof p.name === 'string' ? p.name.trim() : '';
|
|
890
|
+
if (!name)
|
|
891
|
+
throw new Error('需要 name(快照目录名,见 dsh-memory/snapshots-list)');
|
|
892
|
+
// 只收名字、不收路径:恢复入口若接受任意路径,就等于顺带给这条 RPC 开放了
|
|
893
|
+
// "读任意目录并把内容写进检索库"的能力。非法名一律拒绝而不是静默返零。
|
|
894
|
+
if (!isSnapshotName(name)) {
|
|
895
|
+
throw new Error('name 非法:只接受快照目录名(形如 l1-<时间戳>-<原因>),不接受路径');
|
|
896
|
+
}
|
|
897
|
+
// **默认干跑**:省略 `dryRun` 即视为 true(与 cleanup-retired 同一条纪律)。
|
|
898
|
+
const dryRun = p.dryRun !== false;
|
|
899
|
+
const unretire = p.unretire === true;
|
|
900
|
+
const ids = (Array.isArray(p.ids) ? p.ids : [])
|
|
901
|
+
.filter((x) => typeof x === 'string' && x !== '')
|
|
902
|
+
.slice(0, 2000);
|
|
903
|
+
if (dryRun) {
|
|
904
|
+
const plan = await stores.l1.planSnapshotRestore(name, ids);
|
|
905
|
+
const resp = {
|
|
906
|
+
name,
|
|
907
|
+
dir: plan.dir,
|
|
908
|
+
dryRun: true,
|
|
909
|
+
inSnapshot: plan.inSnapshot,
|
|
910
|
+
targets: plan.targets,
|
|
911
|
+
missing: plan.missing,
|
|
912
|
+
restored: 0,
|
|
913
|
+
failed: 0,
|
|
914
|
+
vectorsWritten: 0,
|
|
915
|
+
unretired: 0,
|
|
916
|
+
stillRetired: unretire ? [] : plan.stillRetired,
|
|
917
|
+
notFound: plan.notFound,
|
|
918
|
+
...(plan.found ? {} : { notice: `找不到快照 ${name}:snapshots/ 下没有同名目录,或它的 manifest.json 缺失/版本不符` }),
|
|
919
|
+
};
|
|
920
|
+
return resp;
|
|
921
|
+
}
|
|
922
|
+
const r = await stores.l1.restoreFromSnapshot(name, { ids, unretire });
|
|
923
|
+
if (!r.found) {
|
|
924
|
+
const resp = {
|
|
925
|
+
name,
|
|
926
|
+
dir: '',
|
|
927
|
+
dryRun: false,
|
|
928
|
+
inSnapshot: 0,
|
|
929
|
+
targets: 0,
|
|
930
|
+
missing: 0,
|
|
931
|
+
restored: 0,
|
|
932
|
+
failed: 0,
|
|
933
|
+
vectorsWritten: 0,
|
|
934
|
+
unretired: 0,
|
|
935
|
+
stillRetired: [],
|
|
936
|
+
notFound: [],
|
|
937
|
+
notice: `找不到快照 ${name}:snapshots/ 下没有同名目录,或它的 manifest.json 缺失/版本不符`,
|
|
938
|
+
};
|
|
939
|
+
return resp;
|
|
940
|
+
}
|
|
941
|
+
deps.logger.info(`[memory] 快照恢复 ${name}:写回 ${r.restored} 条(补向量 ${r.vectorsWritten} 条,失败 ${r.failed} 条,放回检索面 ${r.unretired} 条)`);
|
|
942
|
+
const resp = {
|
|
943
|
+
name,
|
|
944
|
+
dir: r.dir,
|
|
945
|
+
dryRun: false,
|
|
946
|
+
inSnapshot: r.inSnapshot,
|
|
947
|
+
targets: r.targets,
|
|
948
|
+
missing: r.missing,
|
|
949
|
+
restored: r.restored,
|
|
950
|
+
failed: r.failed,
|
|
951
|
+
vectorsWritten: r.vectorsWritten,
|
|
952
|
+
unretired: r.unretired,
|
|
953
|
+
stillRetired: r.stillRetired,
|
|
954
|
+
notFound: r.notFound,
|
|
955
|
+
// 写回主表 ≠ 回到检索面:清理快照里的记录都带退场标记,不明说会让人以为
|
|
956
|
+
// "恢复完了"而记忆其实仍不可见。这是本次接线最容易漏掉的一跳。
|
|
957
|
+
...(r.stillRetired.length > 0
|
|
958
|
+
? {
|
|
959
|
+
notice: `已写回主表,但其中 ${r.stillRetired.length} 条仍处于退场态、不会出现在召回里` +
|
|
960
|
+
`(清理只清理已退场记录,快照拍在删除之前)。要放回检索面:` +
|
|
961
|
+
`再调 dsh-memory/records-restore(或本次改用 unretire:true)。`,
|
|
962
|
+
}
|
|
963
|
+
: {}),
|
|
964
|
+
};
|
|
965
|
+
return resp;
|
|
966
|
+
}
|
|
867
967
|
// ── 知识图谱(面板图谱视图;graph 未装配时返空不报错) ──
|
|
868
968
|
case 'dsh-memory/graph-search': {
|
|
869
969
|
const p = (payload ?? {});
|
|
@@ -23,6 +23,49 @@ export interface L1SnapshotManifest {
|
|
|
23
23
|
export declare function snapshotDirName(createdAt: Date, reason: string): string;
|
|
24
24
|
/** 快照目录的绝对路径(与既有 `pendingPathFor` / `reconcileStatePathFor` 同风格)。 */
|
|
25
25
|
export declare function snapshotPathFor(dataDir: string, createdAt: Date, reason: string): string;
|
|
26
|
+
/** 快照的**存放根**(所有 `snapshotPathFor` 产物都在它下面)。 */
|
|
27
|
+
export declare function snapshotsRootFor(dataDir: string): string;
|
|
28
|
+
/** 目录路径 → 目录名(与 `snapshotDirFor` 互为逆运算;兼容 `/` 与 `\`)。 */
|
|
29
|
+
export declare function snapshotNameOf(dir: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* 目录名是否是可寻址的快照名。
|
|
32
|
+
*
|
|
33
|
+
* **只接受"名字",不接受路径**——这是恢复入口的第一道门。若允许调用方传路径,
|
|
34
|
+
* 恢复就变成了"把任意目录里的 JSON 灌进记忆库",而这条 RPC 的信任级别只到
|
|
35
|
+
* "本机同用户",不该顺带获得读任意目录并把内容写进检索库的能力。
|
|
36
|
+
* 故:长度受限、必须带 `l1-` 前缀(与 `snapshotDirName` 的产物一致)、
|
|
37
|
+
* 且不含路径分隔符与 `..`。
|
|
38
|
+
*/
|
|
39
|
+
export declare function isSnapshotName(name: string): boolean;
|
|
40
|
+
/** 名字 → 绝对路径(仅当名字合法;否则返回 `undefined`,由调用方拒绝)。 */
|
|
41
|
+
export declare function snapshotDirFor(dataDir: string, name: string): string | undefined;
|
|
42
|
+
/** 一份可用快照的摘要(面板 / 工具选哪份来恢复)。 */
|
|
43
|
+
export interface SnapshotSummary {
|
|
44
|
+
/** 目录名(恢复时传它,不传路径)。 */
|
|
45
|
+
name: string;
|
|
46
|
+
dir: string;
|
|
47
|
+
createdAt: string;
|
|
48
|
+
/** 建这份快照的原因(如 `cleanup-retired` / `pre-rebuild`)。 */
|
|
49
|
+
reason: string;
|
|
50
|
+
records: number;
|
|
51
|
+
receipts: number;
|
|
52
|
+
conflicts: number;
|
|
53
|
+
/** `l1_vec` 行数(派生投影,只记数)。 */
|
|
54
|
+
vecCount: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 列出可用快照(按时间**倒序**:最新的在前)。
|
|
58
|
+
*
|
|
59
|
+
* 只认**带合法清单**的目录:清单缺失或版本不符的目录不算快照(半截写入的产物
|
|
60
|
+
* 不能出现在"选一份来恢复"的列表里,否则人会选中一份根本恢复不了的东西)。
|
|
61
|
+
* 目录不存在**不抛**,返回空列表——"还没建过快照"是部署状态,不是调用错误。
|
|
62
|
+
*/
|
|
63
|
+
export declare function listSnapshots(dataDir: string, opts?: {
|
|
64
|
+
limit?: number;
|
|
65
|
+
}): Promise<{
|
|
66
|
+
items: SnapshotSummary[];
|
|
67
|
+
total: number;
|
|
68
|
+
}>;
|
|
26
69
|
/** 稳定内容哈希(与 `canonicalRecords` 配套:同内容恒同哈希)。 */
|
|
27
70
|
export declare function hashRecords(records: readonly MemoryRecord[]): string;
|
|
28
71
|
/** 任意对象的内容哈希(用于 receipts / conflicts 这类外来形状)。 */
|
|
@@ -45,12 +88,14 @@ export interface SnapshotDbLike {
|
|
|
45
88
|
limit?: number;
|
|
46
89
|
}) => readonly unknown[];
|
|
47
90
|
countL1Vec: () => number;
|
|
48
|
-
upsertL1: (record: MemoryRecord) => boolean;
|
|
91
|
+
upsertL1: (record: MemoryRecord, embedding?: Float32Array) => boolean;
|
|
49
92
|
}
|
|
50
93
|
/** 分页取全量 L1(一次 500,避免大库一次性拉爆内存)。 */
|
|
51
94
|
export declare function listAllL1(db: SnapshotDbLike, hardLimit?: number): MemoryRecord[];
|
|
52
95
|
export interface CreateSnapshotResult {
|
|
53
96
|
dir: string;
|
|
97
|
+
/** 目录名——`snapshot-restore` 的寻址口径(调用方不必自己切路径)。 */
|
|
98
|
+
name: string;
|
|
54
99
|
manifest: L1SnapshotManifest;
|
|
55
100
|
records: readonly MemoryRecord[];
|
|
56
101
|
}
|
|
@@ -75,20 +120,74 @@ export interface SnapshotVerification {
|
|
|
75
120
|
/** 比对快照与当前库(**按内容哈希**,不是按行数)。 */
|
|
76
121
|
export declare function verifySnapshot(db: SnapshotDbLike, dir: string): Promise<SnapshotVerification>;
|
|
77
122
|
export interface RestoreResult {
|
|
123
|
+
/** 快照里的记录总数(按 `ids` 过滤**之前**)。 */
|
|
124
|
+
inSnapshot: number;
|
|
125
|
+
/** 本次实际要恢复的条数(过滤**之后**)。 */
|
|
126
|
+
targets: number;
|
|
78
127
|
restored: number;
|
|
79
128
|
failed: number;
|
|
129
|
+
/** 成功补上向量的条数(未提供 `vectorize` 时恒为 0)。 */
|
|
130
|
+
vectorsWritten: number;
|
|
131
|
+
/** 请求了但快照里没有的 id(仅传 `ids` 时可能非空)。 */
|
|
132
|
+
notFound: string[];
|
|
80
133
|
}
|
|
134
|
+
export interface RestoreOptions {
|
|
135
|
+
/** 日志出口(缺省静默)。 */
|
|
136
|
+
logger?: {
|
|
137
|
+
info: (m: string) => void;
|
|
138
|
+
warn: (m: string) => void;
|
|
139
|
+
};
|
|
140
|
+
/** 只恢复这些 id;省略 = 快照内全部。 */
|
|
141
|
+
ids?: readonly string[];
|
|
142
|
+
/**
|
|
143
|
+
* 可选向量补算钩子。
|
|
144
|
+
*
|
|
145
|
+
* 为何是**注入的函数**而不是直接 import 嵌入模块:快照模块刻意不依赖嵌入栈
|
|
146
|
+
* (见模块头——`l1_vec` 是派生投影,不落快照)。把向量能力做成参数,单向依赖
|
|
147
|
+
* 就保住了:快照模块提供"从 JSON 回到检索库"的事实,调用方提供"怎么算向量"。
|
|
148
|
+
* 实现方在嵌入不可用时应返回 `undefined` 而**不是抛**——见 `restore` 的同款理由。
|
|
149
|
+
*/
|
|
150
|
+
vectorize?: (records: readonly MemoryRecord[]) => Promise<readonly (Float32Array | undefined)[]>;
|
|
151
|
+
}
|
|
152
|
+
/** 干跑结论:这份快照恢复下去**会发生什么**(不写库)。 */
|
|
153
|
+
export interface SnapshotRestorePlan {
|
|
154
|
+
name: string;
|
|
155
|
+
/** 解析出的绝对路径;名字非法或清单缺失时为空串。 */
|
|
156
|
+
dir: string;
|
|
157
|
+
/** 这个名字是否指向一份**真实存在且清单合法**的快照。 */
|
|
158
|
+
found: boolean;
|
|
159
|
+
inSnapshot: number;
|
|
160
|
+
/** 本次实际要恢复的条数(按 `ids` 过滤后)。 */
|
|
161
|
+
targets: number;
|
|
162
|
+
/** 其中当前**不在库**的条数——这才是真正被找回的条数。 */
|
|
163
|
+
missing: number;
|
|
164
|
+
/**
|
|
165
|
+
* 目标里**仍处于退场态**的 id。
|
|
166
|
+
*
|
|
167
|
+
* 为什么这个字段是必需的:`cleanup-retired` 只清理**已退场**记录,而快照拍在删除
|
|
168
|
+
* **之前** ——所以清理快照能找回的每一条都带着退场标记。于是"回到主表"≠"回到
|
|
169
|
+
* 检索面":记录行在,但仍不出现在召回里,还要 `records-restore` 才放得回去。
|
|
170
|
+
* 不报这个,调用方会以为恢复完了、而记忆其实还是不可见的。
|
|
171
|
+
*/
|
|
172
|
+
stillRetired: string[];
|
|
173
|
+
notFound: string[];
|
|
174
|
+
}
|
|
175
|
+
/** 按 id 过滤快照记录,并报出请求了却没找到的 id(人工恢复要能看到"没找到哪条")。 */
|
|
176
|
+
export declare function selectSnapshotTargets(records: readonly MemoryRecord[], ids?: readonly string[]): {
|
|
177
|
+
targets: MemoryRecord[];
|
|
178
|
+
notFound: string[];
|
|
179
|
+
};
|
|
81
180
|
/**
|
|
82
181
|
* 从快照恢复 L1。
|
|
83
182
|
*
|
|
84
183
|
* **幂等**:走 `upsertL1`(按 id upsert),恢复两遍与一遍等价,中断后重跑安全。
|
|
85
184
|
* 只恢复 `l1_records`——receipts / conflicts 今天不被 `clearL1()` 销毁(见模块头),
|
|
86
185
|
* 且它们的写入口不归本模块所有(单一所有者)。
|
|
186
|
+
*
|
|
187
|
+
* **向量一次算完再逐条写**:`vectorize` 收的是整批记录,而不是每条回调一次——
|
|
188
|
+
* 否则恢复 787 条就是 787 次嵌入往返。
|
|
87
189
|
*/
|
|
88
|
-
export declare function restoreL1Snapshot(db: SnapshotDbLike, dir: string,
|
|
89
|
-
info: (m: string) => void;
|
|
90
|
-
warn: (m: string) => void;
|
|
91
|
-
}): Promise<RestoreResult>;
|
|
190
|
+
export declare function restoreL1Snapshot(db: SnapshotDbLike, dir: string, opts?: RestoreOptions): Promise<RestoreResult>;
|
|
92
191
|
/**
|
|
93
192
|
* 清空前必须调用的守门函数:先建快照,再允许清空。
|
|
94
193
|
*
|
|
@@ -108,6 +207,8 @@ export interface ExportThenPurgeResult {
|
|
|
108
207
|
aborted: boolean;
|
|
109
208
|
/** 快照目录(中止时也给,便于人工查看失败现场)。 */
|
|
110
209
|
dir: string;
|
|
210
|
+
/** 快照**目录名**(供 `snapshot-restore` 直接寻址;中止且未建快照时为空串)。 */
|
|
211
|
+
name: string;
|
|
111
212
|
purged: number;
|
|
112
213
|
/** 校验差异(仅 aborted 时非空)。 */
|
|
113
214
|
diffs: string[];
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
* 等于把证据链悄悄拆了。这条有专门用例。
|
|
34
34
|
*/
|
|
35
35
|
import { createHash } from 'node:crypto';
|
|
36
|
+
import { readdir } from 'node:fs/promises';
|
|
36
37
|
import { atomicWriteJson, readJsonIfExists } from '../util/io.js';
|
|
37
38
|
export const SNAPSHOT_VERSION = 1;
|
|
38
39
|
/** 快照目录名:`l1-<时间戳>-<原因>`。**时间戳在前**,目录自然按时间排序。 */
|
|
@@ -45,6 +46,80 @@ export function snapshotDirName(createdAt, reason) {
|
|
|
45
46
|
export function snapshotPathFor(dataDir, createdAt, reason) {
|
|
46
47
|
return `${dataDir.replace(/[\\/]+$/, '')}/snapshots/${snapshotDirName(createdAt, reason)}`;
|
|
47
48
|
}
|
|
49
|
+
/** 快照的**存放根**(所有 `snapshotPathFor` 产物都在它下面)。 */
|
|
50
|
+
export function snapshotsRootFor(dataDir) {
|
|
51
|
+
return `${dataDir.replace(/[\\/]+$/, '')}/snapshots`;
|
|
52
|
+
}
|
|
53
|
+
/** 目录路径 → 目录名(与 `snapshotDirFor` 互为逆运算;兼容 `/` 与 `\`)。 */
|
|
54
|
+
export function snapshotNameOf(dir) {
|
|
55
|
+
return dir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? '';
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 目录名是否是可寻址的快照名。
|
|
59
|
+
*
|
|
60
|
+
* **只接受"名字",不接受路径**——这是恢复入口的第一道门。若允许调用方传路径,
|
|
61
|
+
* 恢复就变成了"把任意目录里的 JSON 灌进记忆库",而这条 RPC 的信任级别只到
|
|
62
|
+
* "本机同用户",不该顺带获得读任意目录并把内容写进检索库的能力。
|
|
63
|
+
* 故:长度受限、必须带 `l1-` 前缀(与 `snapshotDirName` 的产物一致)、
|
|
64
|
+
* 且不含路径分隔符与 `..`。
|
|
65
|
+
*/
|
|
66
|
+
export function isSnapshotName(name) {
|
|
67
|
+
if (typeof name !== 'string')
|
|
68
|
+
return false;
|
|
69
|
+
const n = name.trim();
|
|
70
|
+
if (n.length === 0 || n.length > 200)
|
|
71
|
+
return false;
|
|
72
|
+
if (!n.startsWith('l1-'))
|
|
73
|
+
return false;
|
|
74
|
+
if (n.includes('/') || n.includes('\\') || n.includes('..'))
|
|
75
|
+
return false;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
/** 名字 → 绝对路径(仅当名字合法;否则返回 `undefined`,由调用方拒绝)。 */
|
|
79
|
+
export function snapshotDirFor(dataDir, name) {
|
|
80
|
+
return isSnapshotName(name) ? `${snapshotsRootFor(dataDir)}/${name.trim()}` : undefined;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 列出可用快照(按时间**倒序**:最新的在前)。
|
|
84
|
+
*
|
|
85
|
+
* 只认**带合法清单**的目录:清单缺失或版本不符的目录不算快照(半截写入的产物
|
|
86
|
+
* 不能出现在"选一份来恢复"的列表里,否则人会选中一份根本恢复不了的东西)。
|
|
87
|
+
* 目录不存在**不抛**,返回空列表——"还没建过快照"是部署状态,不是调用错误。
|
|
88
|
+
*/
|
|
89
|
+
export async function listSnapshots(dataDir, opts = {}) {
|
|
90
|
+
const root = snapshotsRootFor(dataDir);
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
names = await readdir(root);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { items: [], total: 0 };
|
|
97
|
+
}
|
|
98
|
+
// 目录名里时间戳在前(`l1-<时间戳>-<原因>`),故字典序倒序即时间倒序。
|
|
99
|
+
names.sort((a, b) => b.localeCompare(a));
|
|
100
|
+
const items = [];
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
if (!isSnapshotName(name))
|
|
103
|
+
continue;
|
|
104
|
+
const dir = `${root}/${name}`;
|
|
105
|
+
const manifest = await readSnapshotManifest(dir);
|
|
106
|
+
if (manifest === undefined)
|
|
107
|
+
continue;
|
|
108
|
+
items.push({
|
|
109
|
+
name,
|
|
110
|
+
dir,
|
|
111
|
+
createdAt: manifest.createdAt,
|
|
112
|
+
reason: manifest.reason,
|
|
113
|
+
records: manifest.sections.records.count,
|
|
114
|
+
receipts: manifest.sections.receipts.count,
|
|
115
|
+
conflicts: manifest.sections.conflicts.count,
|
|
116
|
+
vecCount: manifest.vecCount,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const raw = Math.floor(Number(opts.limit));
|
|
120
|
+
const limit = Number.isFinite(raw) && raw > 0 ? raw : items.length;
|
|
121
|
+
return { items: items.slice(0, limit), total: items.length };
|
|
122
|
+
}
|
|
48
123
|
/** 记录数组的规范序列化:键序固定 + 按 id 排序,保证**同一内容恒得同一哈希**。 */
|
|
49
124
|
function canonicalRecords(records) {
|
|
50
125
|
return JSON.stringify(records
|
|
@@ -116,7 +191,7 @@ export async function createL1Snapshot(db, dir, reason, now = new Date()) {
|
|
|
116
191
|
await atomicWriteJson(`${dir}/l1-receipts.json`, receipts);
|
|
117
192
|
await atomicWriteJson(`${dir}/l1-conflicts.json`, conflicts);
|
|
118
193
|
await atomicWriteJson(`${dir}/manifest.json`, manifest);
|
|
119
|
-
return { dir, manifest, records };
|
|
194
|
+
return { dir, name: snapshotNameOf(dir), manifest, records };
|
|
120
195
|
}
|
|
121
196
|
/** 读快照清单;不存在或版本不符返回 undefined。 */
|
|
122
197
|
export async function readSnapshotManifest(dir) {
|
|
@@ -149,23 +224,54 @@ export async function verifySnapshot(db, dir) {
|
|
|
149
224
|
diffs.push('conflict_pending 内容与快照不一致');
|
|
150
225
|
return { ok: diffs.length === 0, diffs };
|
|
151
226
|
}
|
|
227
|
+
/** 按 id 过滤快照记录,并报出请求了却没找到的 id(人工恢复要能看到"没找到哪条")。 */
|
|
228
|
+
export function selectSnapshotTargets(records, ids) {
|
|
229
|
+
if (!ids || ids.length === 0)
|
|
230
|
+
return { targets: [...records], notFound: [] };
|
|
231
|
+
const wanted = new Set(ids);
|
|
232
|
+
const targets = records.filter((r) => typeof r?.id === 'string' && wanted.has(r.id));
|
|
233
|
+
const found = new Set(targets.map((r) => r.id));
|
|
234
|
+
return { targets, notFound: [...wanted].filter((id) => !found.has(id)) };
|
|
235
|
+
}
|
|
152
236
|
/**
|
|
153
237
|
* 从快照恢复 L1。
|
|
154
238
|
*
|
|
155
239
|
* **幂等**:走 `upsertL1`(按 id upsert),恢复两遍与一遍等价,中断后重跑安全。
|
|
156
240
|
* 只恢复 `l1_records`——receipts / conflicts 今天不被 `clearL1()` 销毁(见模块头),
|
|
157
241
|
* 且它们的写入口不归本模块所有(单一所有者)。
|
|
242
|
+
*
|
|
243
|
+
* **向量一次算完再逐条写**:`vectorize` 收的是整批记录,而不是每条回调一次——
|
|
244
|
+
* 否则恢复 787 条就是 787 次嵌入往返。
|
|
158
245
|
*/
|
|
159
|
-
export async function restoreL1Snapshot(db, dir,
|
|
160
|
-
const
|
|
246
|
+
export async function restoreL1Snapshot(db, dir, opts = {}) {
|
|
247
|
+
const { logger, ids, vectorize } = opts;
|
|
248
|
+
const all = await readSnapshotRecords(dir);
|
|
249
|
+
const { targets, notFound } = selectSnapshotTargets(all, ids);
|
|
250
|
+
let vectors = [];
|
|
251
|
+
if (vectorize && targets.length > 0) {
|
|
252
|
+
try {
|
|
253
|
+
vectors = await vectorize(targets);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
// 向量补算失败**不中止恢复**:记录先回到检索面(关键词仍可召回)比"一条都没恢复"
|
|
257
|
+
// 严重程度低得多。缺失的向量留给后续 `embedding-reindex`。
|
|
258
|
+
logger?.warn(`[memory] 快照恢复:向量补算失败,先回关键词检索面(日后重建可补齐): ${err instanceof Error ? err.message : String(err)}`);
|
|
259
|
+
vectors = [];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
161
262
|
let restored = 0;
|
|
162
263
|
let failed = 0;
|
|
163
|
-
|
|
264
|
+
let vectorsWritten = 0;
|
|
265
|
+
for (let i = 0; i < targets.length; i++) {
|
|
266
|
+
const record = targets[i];
|
|
164
267
|
if (typeof record?.id !== 'string' || typeof record.content !== 'string') {
|
|
165
268
|
failed += 1;
|
|
166
269
|
continue;
|
|
167
270
|
}
|
|
168
|
-
|
|
271
|
+
const vec = vectors[i];
|
|
272
|
+
if (vec !== undefined)
|
|
273
|
+
vectorsWritten += 1;
|
|
274
|
+
if (db.upsertL1(record, vec))
|
|
169
275
|
restored += 1;
|
|
170
276
|
else
|
|
171
277
|
failed += 1;
|
|
@@ -174,7 +280,7 @@ export async function restoreL1Snapshot(db, dir, logger) {
|
|
|
174
280
|
logger?.warn(`[memory] 快照恢复:${restored} 条成功,${failed} 条失败`);
|
|
175
281
|
else
|
|
176
282
|
logger?.info(`[memory] 快照恢复:${restored} 条`);
|
|
177
|
-
return { restored, failed };
|
|
283
|
+
return { inSnapshot: all.length, targets: targets.length, restored, failed, vectorsWritten, notFound };
|
|
178
284
|
}
|
|
179
285
|
/**
|
|
180
286
|
* 清空前必须调用的守门函数:先建快照,再允许清空。
|
|
@@ -202,23 +308,25 @@ export async function snapshotBeforeClear(db, dataDir, reason, now = new Date())
|
|
|
202
308
|
*/
|
|
203
309
|
export async function exportThenPurge(db, dataDir, ids, reason, logger, now = new Date()) {
|
|
204
310
|
if (ids.length === 0)
|
|
205
|
-
return { ok: true, aborted: false, dir: '', purged: 0, diffs: [] };
|
|
311
|
+
return { ok: true, aborted: false, dir: '', name: '', purged: 0, diffs: [] };
|
|
206
312
|
let dir = '';
|
|
313
|
+
let name = '';
|
|
207
314
|
try {
|
|
208
315
|
const snap = await snapshotBeforeClear(db, dataDir, reason, now);
|
|
209
316
|
dir = snap.dir;
|
|
317
|
+
name = snap.name;
|
|
210
318
|
}
|
|
211
319
|
catch (err) {
|
|
212
320
|
const msg = err instanceof Error ? err.message : String(err);
|
|
213
321
|
logger?.warn(`[memory] 清理中止:快照写入失败(${msg})——未删除任何记录`);
|
|
214
|
-
return { ok: false, aborted: true, dir, purged: 0, diffs: [`快照写入失败:${msg}`] };
|
|
322
|
+
return { ok: false, aborted: true, dir, name, purged: 0, diffs: [`快照写入失败:${msg}`] };
|
|
215
323
|
}
|
|
216
324
|
const verdict = await verifySnapshot(db, dir);
|
|
217
325
|
if (!verdict.ok) {
|
|
218
326
|
logger?.warn(`[memory] 清理中止:快照校验未通过(${verdict.diffs.join(';')})——未删除任何记录`);
|
|
219
|
-
return { ok: false, aborted: true, dir, purged: 0, diffs: verdict.diffs };
|
|
327
|
+
return { ok: false, aborted: true, dir, name, purged: 0, diffs: verdict.diffs };
|
|
220
328
|
}
|
|
221
329
|
const purged = db.deleteL1Batch([...ids]);
|
|
222
330
|
logger?.info(`[memory] 已物理清理 ${purged} 条已退场记录(快照:${dir})`);
|
|
223
|
-
return { ok: true, aborted: false, dir, purged, diffs: [] };
|
|
331
|
+
return { ok: true, aborted: false, dir, name, purged, diffs: [] };
|
|
224
332
|
}
|
package/dist/store/l1.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import type { L1Hit, MemoryFamily, MemoryLogger, MemoryRecord } from '../types.j
|
|
|
2
2
|
import type { GraphNodeSearchResult } from '../graph/types.js';
|
|
3
3
|
import type { L1Receipt, ReceiptQuery } from './receipts.js';
|
|
4
4
|
import type { ConflictPair, ConflictResolution } from './conflicts.js';
|
|
5
|
-
import type
|
|
6
|
-
import { type ExportThenPurgeResult } from './l1-snapshot.js';
|
|
5
|
+
import { type SupersedeInfo } from './supersede.js';
|
|
6
|
+
import { type ExportThenPurgeResult, type RestoreResult, type SnapshotRestorePlan, type SnapshotSummary } from './l1-snapshot.js';
|
|
7
7
|
import { type EmbeddingService } from './embedding.js';
|
|
8
8
|
import { type MemoryDb } from './sqlite.js';
|
|
9
9
|
export type RecallStrategy = 'keyword' | 'embedding' | 'hybrid';
|
|
@@ -30,9 +30,28 @@ export interface L1SearchOptions {
|
|
|
30
30
|
/** 嵌入查询内层钳制(ms,只缩短不放大;召回路径传入给 FTS 降级留时间)。 */
|
|
31
31
|
embeddingTimeoutMs?: number;
|
|
32
32
|
}
|
|
33
|
+
/** 快照回灌的结果(在 `RestoreResult` 之上补"从哪来"与"找回了多少")。 */
|
|
34
|
+
export interface SnapshotRestoreOutcome extends RestoreResult {
|
|
35
|
+
/** 解析出的快照目录;名字非法或快照不存在时为空串。 */
|
|
36
|
+
dir: string;
|
|
37
|
+
/** 这个名字是否指向一份真实存在且清单合法的快照。 */
|
|
38
|
+
found: boolean;
|
|
39
|
+
/** 其中当前**不在库**、本次被找回的条数(写库前算出)。 */
|
|
40
|
+
missing: number;
|
|
41
|
+
/** 本次顺手放回检索面的条数(仅 `unretire: true` 时可能非零)。 */
|
|
42
|
+
unretired: number;
|
|
43
|
+
notFound: string[];
|
|
44
|
+
/**
|
|
45
|
+
* 回到主表但**仍未回到检索面**的 id(见 `SnapshotRestorePlan.stillRetired`)。
|
|
46
|
+
* `unretire: true` 且放回成功时为空数组。
|
|
47
|
+
*/
|
|
48
|
+
stillRetired: string[];
|
|
49
|
+
}
|
|
33
50
|
export declare class L1Store {
|
|
34
51
|
private readonly db;
|
|
35
52
|
private readonly strategy;
|
|
53
|
+
/** 记忆库根目录(`records/` 与 `snapshots/` 都在它下面)。 */
|
|
54
|
+
private readonly dataDir;
|
|
36
55
|
private readonly recordsDir;
|
|
37
56
|
private readonly legacyFile;
|
|
38
57
|
private readonly helper;
|
|
@@ -137,6 +156,44 @@ export declare class L1Store {
|
|
|
137
156
|
* 这里只把 L1Store 已知的 dataDir 与 logger 接上去,避免端点层自己去推路径。
|
|
138
157
|
*/
|
|
139
158
|
purgeRetired(ids: string[], reason: string): Promise<ExportThenPurgeResult>;
|
|
159
|
+
/** 可用快照列表(按时间倒序;面板/工具据此选一份来恢复)。 */
|
|
160
|
+
listSnapshots(opts?: {
|
|
161
|
+
limit?: number;
|
|
162
|
+
}): Promise<{
|
|
163
|
+
items: SnapshotSummary[];
|
|
164
|
+
total: number;
|
|
165
|
+
}>;
|
|
166
|
+
/**
|
|
167
|
+
* 名字 → 真实快照。
|
|
168
|
+
*
|
|
169
|
+
* 两道判定合一:名字合法(`snapshotDirFor`)且**清单存在且版本相符**
|
|
170
|
+
* (`readSnapshotManifest`)。只有前者会被"目录里有个同名空目录"骗过——
|
|
171
|
+
* 而那正是半截写入的产物,选中它恢复会得到 0 条却报成功。
|
|
172
|
+
*/
|
|
173
|
+
private resolveSnapshot;
|
|
174
|
+
/**
|
|
175
|
+
* 干跑:算出"这份快照恢复下去会发生什么",**不写库**。
|
|
176
|
+
*
|
|
177
|
+
* `missing` 才是真正被找回的条数——快照里绝大多数记录今天仍在库里(快照是
|
|
178
|
+
* **全库**拷贝,而被清掉的只是其中几条)。只报 `targets` 会让人以为"要恢复 787 条",
|
|
179
|
+
* 从而不敢按下去。
|
|
180
|
+
*/
|
|
181
|
+
planSnapshotRestore(name: string, ids?: readonly string[]): Promise<SnapshotRestorePlan>;
|
|
182
|
+
/**
|
|
183
|
+
* 从快照恢复(不可逆动作的**回程票**;本身幂等,可安全重跑)。
|
|
184
|
+
*
|
|
185
|
+
* 向量按整批补算(`helper.batch`),失败即降级成"暂时只走关键词召回"而不中止——
|
|
186
|
+
* 与 `restore` 同一条纪律:补不上向量是小事,让人以为记录丢了是大事。
|
|
187
|
+
*
|
|
188
|
+
* @param opts.unretire - 顺手把带退场标记的记录放回检索面(走既有 `restore`,
|
|
189
|
+
* 不新开写路径)。默认 `false`:只回主表,与"恢复的是当时的状态"一致。
|
|
190
|
+
*/
|
|
191
|
+
restoreFromSnapshot(name: string, opts?: {
|
|
192
|
+
ids?: readonly string[];
|
|
193
|
+
unretire?: boolean;
|
|
194
|
+
}): Promise<SnapshotRestoreOutcome>;
|
|
195
|
+
/** 这批 id 里当前**在库**的集合(分块查,避免一次 IN 太多参数)。 */
|
|
196
|
+
private existingIds;
|
|
140
197
|
/**
|
|
141
198
|
* 三策略检索(自动召回与 memory_search 工具共用接缝)。
|
|
142
199
|
* embedding 不可用时自动降级 keyword;type 后置过滤;
|
package/dist/store/l1.js
CHANGED
|
@@ -10,7 +10,8 @@ import { existsSync, promises as fs } from 'node:fs';
|
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import { familyForType, isScopeVisible } from '../types.js';
|
|
12
12
|
import { graphHitRecordIds } from '../graph/search.js';
|
|
13
|
-
import {
|
|
13
|
+
import { isRetired } from './supersede.js';
|
|
14
|
+
import { exportThenPurge, readSnapshotManifest, readSnapshotRecords, restoreL1Snapshot, selectSnapshotTargets, snapshotDirFor, listSnapshots as listSnapshotsIn } from './l1-snapshot.js';
|
|
14
15
|
import { EmbedHelper, NoopEmbeddingService } from './embedding.js';
|
|
15
16
|
import { appendJsonl, dayKey, ensureDir, readJsonl } from '../util/io.js';
|
|
16
17
|
import { applyDecayWeight, normalizeRrf, rrfMerge } from './search-utils.js';
|
|
@@ -20,6 +21,8 @@ const CANDIDATE_MULTIPLIER = 3;
|
|
|
20
21
|
export class L1Store {
|
|
21
22
|
db;
|
|
22
23
|
strategy;
|
|
24
|
+
/** 记忆库根目录(`records/` 与 `snapshots/` 都在它下面)。 */
|
|
25
|
+
dataDir;
|
|
23
26
|
recordsDir;
|
|
24
27
|
legacyFile;
|
|
25
28
|
helper;
|
|
@@ -36,6 +39,7 @@ export class L1Store {
|
|
|
36
39
|
graphLane) {
|
|
37
40
|
this.db = db;
|
|
38
41
|
this.strategy = strategy;
|
|
42
|
+
this.dataDir = dataDir;
|
|
39
43
|
this.recordsDir = path.join(dataDir, 'records');
|
|
40
44
|
this.legacyFile = path.join(dataDir, 'l1', 'records.jsonl');
|
|
41
45
|
this.embedSvc = embed;
|
|
@@ -242,7 +246,109 @@ export class L1Store {
|
|
|
242
246
|
// 那等于给"先导出后清理"留了一条硬删后门。
|
|
243
247
|
const known = new Map(this.db.getL1ByIds(ids).map((r) => [r.id, r]));
|
|
244
248
|
const retiredIds = ids.filter((id) => known.get(id)?.validTo !== undefined);
|
|
245
|
-
return exportThenPurge(this.db,
|
|
249
|
+
return exportThenPurge(this.db, this.dataDir, retiredIds, reason, this.logger);
|
|
250
|
+
}
|
|
251
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
252
|
+
// 快照的**读与回灌**(task_27):`purgeRetired` 会先落快照,但只落不接等于
|
|
253
|
+
// 后悔药只做了一半——"清理不可逆"这句话必须配一条能走回去的路,否则
|
|
254
|
+
// `exportThenPurge` 的导出物就只是给人手工解析的 JSON。
|
|
255
|
+
//
|
|
256
|
+
// 恢复走 `restoreL1Snapshot`(本文件的 `restore` 管的是**软删**退场,
|
|
257
|
+
// 两者不是一件事:软删的行一直在主表里,快照恢复要管的是**已被物理删除**的行)。
|
|
258
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
259
|
+
/** 可用快照列表(按时间倒序;面板/工具据此选一份来恢复)。 */
|
|
260
|
+
listSnapshots(opts = {}) {
|
|
261
|
+
return listSnapshotsIn(this.dataDir, opts);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* 名字 → 真实快照。
|
|
265
|
+
*
|
|
266
|
+
* 两道判定合一:名字合法(`snapshotDirFor`)且**清单存在且版本相符**
|
|
267
|
+
* (`readSnapshotManifest`)。只有前者会被"目录里有个同名空目录"骗过——
|
|
268
|
+
* 而那正是半截写入的产物,选中它恢复会得到 0 条却报成功。
|
|
269
|
+
*/
|
|
270
|
+
async resolveSnapshot(name) {
|
|
271
|
+
const dir = snapshotDirFor(this.dataDir, name);
|
|
272
|
+
if (dir === undefined)
|
|
273
|
+
return undefined;
|
|
274
|
+
if ((await readSnapshotManifest(dir)) === undefined)
|
|
275
|
+
return undefined;
|
|
276
|
+
return { dir, records: await readSnapshotRecords(dir) };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* 干跑:算出"这份快照恢复下去会发生什么",**不写库**。
|
|
280
|
+
*
|
|
281
|
+
* `missing` 才是真正被找回的条数——快照里绝大多数记录今天仍在库里(快照是
|
|
282
|
+
* **全库**拷贝,而被清掉的只是其中几条)。只报 `targets` 会让人以为"要恢复 787 条",
|
|
283
|
+
* 从而不敢按下去。
|
|
284
|
+
*/
|
|
285
|
+
async planSnapshotRestore(name, ids) {
|
|
286
|
+
const resolved = await this.resolveSnapshot(name);
|
|
287
|
+
if (resolved === undefined) {
|
|
288
|
+
return { name, dir: '', found: false, inSnapshot: 0, targets: 0, missing: 0, stillRetired: [], notFound: [] };
|
|
289
|
+
}
|
|
290
|
+
const { targets, notFound } = selectSnapshotTargets(resolved.records, ids);
|
|
291
|
+
const targetIds = targets.filter((r) => typeof r?.id === 'string').map((r) => r.id);
|
|
292
|
+
const current = this.existingIds(targetIds);
|
|
293
|
+
return {
|
|
294
|
+
name,
|
|
295
|
+
dir: resolved.dir,
|
|
296
|
+
found: true,
|
|
297
|
+
inSnapshot: resolved.records.length,
|
|
298
|
+
targets: targets.length,
|
|
299
|
+
missing: targetIds.filter((id) => !current.has(id)).length,
|
|
300
|
+
// 目标里带退场标记的那些:它们即便回到主表也仍不在检索面(见字段说明)。
|
|
301
|
+
stillRetired: targets.filter((r) => isRetired(r)).map((r) => r.id),
|
|
302
|
+
notFound,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* 从快照恢复(不可逆动作的**回程票**;本身幂等,可安全重跑)。
|
|
307
|
+
*
|
|
308
|
+
* 向量按整批补算(`helper.batch`),失败即降级成"暂时只走关键词召回"而不中止——
|
|
309
|
+
* 与 `restore` 同一条纪律:补不上向量是小事,让人以为记录丢了是大事。
|
|
310
|
+
*
|
|
311
|
+
* @param opts.unretire - 顺手把带退场标记的记录放回检索面(走既有 `restore`,
|
|
312
|
+
* 不新开写路径)。默认 `false`:只回主表,与"恢复的是当时的状态"一致。
|
|
313
|
+
*/
|
|
314
|
+
async restoreFromSnapshot(name, opts = {}) {
|
|
315
|
+
const resolved = await this.resolveSnapshot(name);
|
|
316
|
+
if (resolved === undefined) {
|
|
317
|
+
return { dir: '', found: false, inSnapshot: 0, targets: 0, missing: 0, unretired: 0, stillRetired: [], restored: 0, failed: 0, vectorsWritten: 0, notFound: [] };
|
|
318
|
+
}
|
|
319
|
+
// `missing` 必须在写库**之前**算:写完再算恒为 0,那这个字段就废了。
|
|
320
|
+
const { targets } = selectSnapshotTargets(resolved.records, opts.ids);
|
|
321
|
+
const targetIds = targets.filter((r) => typeof r?.id === 'string').map((r) => r.id);
|
|
322
|
+
const current = this.existingIds(targetIds);
|
|
323
|
+
const missing = targetIds.filter((id) => !current.has(id)).length;
|
|
324
|
+
const retired = targets.filter((r) => isRetired(r)).map((r) => r.id);
|
|
325
|
+
const r = await restoreL1Snapshot(this.db, resolved.dir, {
|
|
326
|
+
logger: this.logger,
|
|
327
|
+
ids: opts.ids,
|
|
328
|
+
vectorize: async (records) => {
|
|
329
|
+
const vecs = await this.helper.batch(records.map((x) => x.content));
|
|
330
|
+
// 零向量 = "嵌入其实没算出来",按未补上计(与 `restore` 的判据一致)。
|
|
331
|
+
return vecs.map((v) => (v && !isZeroVector(v) ? v : undefined));
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
let unretired = 0;
|
|
335
|
+
let stillRetired = retired;
|
|
336
|
+
if (opts.unretire && retired.length > 0) {
|
|
337
|
+
// 复用已验收的 `restore`(清标记 + 重算向量 + 重建 FTS),不另开一条写路径。
|
|
338
|
+
const back = await this.restore(retired);
|
|
339
|
+
unretired = back.restored;
|
|
340
|
+
stillRetired = [];
|
|
341
|
+
}
|
|
342
|
+
return { ...r, dir: resolved.dir, found: true, missing, unretired, stillRetired };
|
|
343
|
+
}
|
|
344
|
+
/** 这批 id 里当前**在库**的集合(分块查,避免一次 IN 太多参数)。 */
|
|
345
|
+
existingIds(ids) {
|
|
346
|
+
const out = new Set();
|
|
347
|
+
for (let i = 0; i < ids.length; i += 400) {
|
|
348
|
+
for (const r of this.db.getL1ByIds(ids.slice(i, i + 400)))
|
|
349
|
+
out.add(r.id);
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
246
352
|
}
|
|
247
353
|
/**
|
|
248
354
|
* 三策略检索(自动召回与 memory_search 工具共用接缝)。
|