@modusensus/dsh-mneme 0.12.0 → 0.13.0
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/README.md +48 -19
- package/docs/CONFIGURATION.md +43 -4
- package/docs/DIRECTORIES.md +114 -0
- package/docs/DISTILL.md +64 -0
- package/docs/DSH-GO.md +97 -0
- package/docs/ENTITIES.md +5 -3
- package/docs/FORGET.md +77 -0
- package/docs/KB.md +110 -0
- package/docs/SLEEP.md +28 -3
- package/docs/STORAGE.md +23 -0
- package/lib/api-standalone.js +4 -1
- package/lib/api.js +581 -29
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +3537 -1205
- package/lib/config.js +139 -1
- package/lib/continuity.js +7 -6
- package/lib/diary/auto.js +190 -0
- package/lib/diary/compose.js +440 -0
- package/lib/diary/dir.js +59 -0
- package/lib/diary/export.js +97 -0
- package/lib/diary/index.js +26 -0
- package/lib/diary/library.js +189 -0
- package/lib/diary/qq-inbox.js +142 -0
- package/lib/diary/rollover.js +61 -0
- package/lib/diary/session-provider.js +122 -0
- package/lib/dream/decisions.js +56 -5
- package/lib/dream/sleep.js +616 -15
- package/lib/dream.js +45 -13
- package/lib/dsh-go/host.js +162 -0
- package/lib/dsh-go/panel.js +2091 -0
- package/lib/dsh-go/text.js +342 -0
- package/lib/dsh-go/views/common.js +68 -0
- package/lib/dsh-go/views/entity.js +48 -0
- package/lib/dsh-go/views/memory.js +198 -0
- package/lib/dsh-go/views/root.js +30 -0
- package/lib/dsh-go/views/settings.js +398 -0
- package/lib/dsh-go/views/status.js +51 -0
- package/lib/embedding.js +42 -8
- package/lib/entities/extractor.js +36 -7
- package/lib/hot-memory.js +8 -9
- package/lib/index.js +92 -2
- package/lib/inject.js +189 -18
- package/lib/kb/index.js +309 -0
- package/lib/kb/routes.js +220 -0
- package/lib/kb/tools.js +148 -0
- package/lib/lang.js +87 -4
- package/lib/peak-hours.js +57 -2
- package/lib/semantic.js +347 -60
- package/lib/service.js +193 -66
- package/lib/settings.js +112 -14
- package/lib/store.js +347 -21
- package/lib/summarize.js +38 -11
- package/lib/tools.js +35 -4
- package/lib/user-settings.js +128 -32
- package/lib/vector-index.js +76 -6
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +116 -2
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +7 -1
- package/package.json +25 -1
- package/src/api-standalone.js +4 -1
- package/src/api.js +581 -29
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +139 -1
- package/src/continuity.js +7 -6
- package/src/diary/auto.js +190 -0
- package/src/diary/compose.js +440 -0
- package/src/diary/dir.js +59 -0
- package/src/diary/export.js +97 -0
- package/src/diary/index.js +26 -0
- package/src/diary/library.js +189 -0
- package/src/diary/qq-inbox.js +142 -0
- package/src/diary/rollover.js +61 -0
- package/src/diary/session-provider.js +122 -0
- package/src/dream/decisions.js +56 -5
- package/src/dream/sleep.js +616 -15
- package/src/dream.js +45 -13
- package/src/dsh-go/host.js +162 -0
- package/src/dsh-go/panel.js +2091 -0
- package/src/dsh-go/text.js +342 -0
- package/src/dsh-go/views/common.js +68 -0
- package/src/dsh-go/views/entity.js +48 -0
- package/src/dsh-go/views/memory.js +198 -0
- package/src/dsh-go/views/root.js +30 -0
- package/src/dsh-go/views/settings.js +398 -0
- package/src/dsh-go/views/status.js +51 -0
- package/src/embedding.js +42 -8
- package/src/entities/extractor.js +36 -7
- package/src/hot-memory.js +8 -9
- package/src/index.js +92 -2
- package/src/inject.js +189 -18
- package/src/kb/index.js +309 -0
- package/src/kb/routes.js +220 -0
- package/src/kb/tools.js +148 -0
- package/src/lang.js +87 -4
- package/src/peak-hours.js +57 -2
- package/src/semantic.js +347 -60
- package/src/service.js +193 -66
- package/src/settings.js +112 -14
- package/src/store.js +347 -21
- package/src/summarize.js +38 -11
- package/src/tools.js +35 -4
- package/src/user-settings.js +128 -32
- package/src/vector-index.js +76 -6
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +116 -2
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +7 -1
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +375 -4
- package/test/attachment-text.test.js +140 -0
- package/test/bm25-corpus-cache.test.js +145 -0
- package/test/capacity-warning.test.js +262 -0
- package/test/client.test.js +779 -154
- package/test/conflict-freeze.test.js +45 -1
- package/test/continuity.test.js +8 -1
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/dream-maxtokens-fallback.test.js +3 -3
- package/test/dream.test.js +62 -1
- package/test/dsh-go-host.test.js +134 -0
- package/test/dsh-go-panel-actions.test.js +854 -0
- package/test/dsh-go-panel.test.js +251 -0
- package/test/embedding-ready.test.js +33 -3
- package/test/entities.test.js +73 -0
- package/test/forget-corrections.test.js +70 -0
- package/test/image-memory.test.js +171 -0
- package/test/issue-617-step1-query-and-fusion.test.js +361 -0
- package/test/kb-api-wiring.test.js +128 -0
- package/test/kb-collections.test.js +272 -0
- package/test/kb-routes.test.js +379 -0
- package/test/mint-source.test.js +26 -0
- package/test/peak-hours-observability.test.js +70 -0
- package/test/persona-api.test.js +3 -3
- package/test/persona-by-session.test.js +568 -0
- package/test/persona-settings.test.js +7 -7
- package/test/reflection-update-gate.test.js +129 -0
- package/test/reindex-backfill.test.js +334 -0
- package/test/reindex-scheduler.test.js +369 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +598 -0
- package/test/standalone-api.test.js +24 -0
- package/test/store.test.js +20 -0
- package/test/summarize.test.js +26 -0
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +355 -0
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-queries.test.js +7 -0
- package/test/wiki-t1-jobs.test.js +125 -0
package/src/store.js
CHANGED
|
@@ -432,7 +432,10 @@ CREATE INDEX IF NOT EXISTS idx_distill_runs_created ON distill_runs(created_at);
|
|
|
432
432
|
-- · 本表 = 行级,「这条记忆出生自哪一轮」,覆盖没有窗口的铸造路径。
|
|
433
433
|
-- 只填 **create** 一种动作:merge/conflict/update/supersede 对既有行的改动,receipt_chain
|
|
434
434
|
-- 已经按 (run_id, record_id) 记过了——再造一份就是第二套账。create 是那边唯一的洞
|
|
435
|
-
-- (
|
|
435
|
+
-- (buildRecordReceipts 不给 create/create_merged 产行:新行没有「改动前后的账」可记,
|
|
436
|
+
-- 只有「出生自哪一轮」,那正是本表的事)。#619 起 buildOutcome 会给 create 派
|
|
437
|
+
-- disposition(created / created-refreshed),那是审计面读的结果,与 receipt_chain
|
|
438
|
+
-- 产不产行是两件事;created-refreshed 是原地刷新,没有出生,本表也不记。
|
|
436
439
|
-- run_type / trigger_source 就地冗余:run_id 在 dream_runs 与 distill_runs 两张表里
|
|
437
440
|
-- 各有自己的主键域,读侧不该为了显示一个类型去 union 两张审计表。
|
|
438
441
|
-- ON DELETE CASCADE:记忆行没了,归因没有指代对象;remove() 仍显式清(与
|
|
@@ -706,6 +709,59 @@ CREATE TABLE IF NOT EXISTS profile_fact_runs (
|
|
|
706
709
|
finished_at TEXT
|
|
707
710
|
);
|
|
708
711
|
CREATE INDEX IF NOT EXISTS idx_profile_fact_runs_card ON profile_fact_runs(agent_key, started_at);
|
|
712
|
+
|
|
713
|
+
-- ============================================================
|
|
714
|
+
-- knowledge base(知识库第二层,spec: docs/plans/2026-11-08-kb-qoder-parity.md §3.3)。
|
|
715
|
+
-- 与记忆行物理隔离的硬边界同 choro 那组表:kb 的行永不进 memories /
|
|
716
|
+
-- saveWithDedupe / dream / 质量过滤的任何池子。这里存的是「用户/agent 主动
|
|
717
|
+
-- 收进知识库的资料」——文件、网页、仓库地志页的**副本**与索引,天生属于外来
|
|
718
|
+
-- 原文,让巩固流程够得着它就会把用户刚存的手册自己归档掉。
|
|
719
|
+
-- 幂等键是 (collection_id, rel_key):同一来源重入库按 sha256 判 no-op,
|
|
720
|
+
-- 变了才替换 chunks(替换语义在 replaceKbDoc 里,事务包住删除与插入)。
|
|
721
|
+
-- rel_key 是来源的稳定相对键(本地文件=绝对路径、网页=URL、地志页=repo#slug),
|
|
722
|
+
-- 不是展示名——展示名在 kb_docs.title。
|
|
723
|
+
CREATE TABLE IF NOT EXISTS kb_collections (
|
|
724
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
725
|
+
key TEXT NOT NULL,
|
|
726
|
+
title TEXT NOT NULL,
|
|
727
|
+
description TEXT,
|
|
728
|
+
created_at TEXT NOT NULL,
|
|
729
|
+
updated_at TEXT NOT NULL
|
|
730
|
+
);
|
|
731
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_kb_collections_key ON kb_collections(key);
|
|
732
|
+
|
|
733
|
+
-- kind 限定三类(§3.3):file | url | choro_page。sha256 是**正文内容**的哈希,
|
|
734
|
+
-- 不是文件 stat 的指纹——文件 mtime 变了内容没变时重入库必须是 no-op,
|
|
735
|
+
-- 否则每次保存键盘敲一下都全量重切块。
|
|
736
|
+
CREATE TABLE IF NOT EXISTS kb_docs (
|
|
737
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
738
|
+
collection_id INTEGER NOT NULL,
|
|
739
|
+
rel_key TEXT NOT NULL,
|
|
740
|
+
kind TEXT NOT NULL,
|
|
741
|
+
source_path TEXT,
|
|
742
|
+
title TEXT NOT NULL,
|
|
743
|
+
bytes INTEGER NOT NULL DEFAULT 0,
|
|
744
|
+
sha256 TEXT,
|
|
745
|
+
indexed_at TEXT NOT NULL,
|
|
746
|
+
status TEXT NOT NULL DEFAULT 'ok',
|
|
747
|
+
error TEXT
|
|
748
|
+
);
|
|
749
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_kb_docs_collection_relkey ON kb_docs(collection_id, rel_key);
|
|
750
|
+
|
|
751
|
+
-- 分块正文。heading 是块所属的二级标题节(无节则空串),ordinal 是块在
|
|
752
|
+
-- 文档内的序号——检索命中要能回指「第几块、哪一节」,面板/工具靠它跳段落。
|
|
753
|
+
-- text 存原文切片(含分块时带的标题行?不含:标题在 heading 里,text 只放正文,
|
|
754
|
+
-- 免得同一条命中把标题算两遍)。
|
|
755
|
+
CREATE TABLE IF NOT EXISTS kb_chunks (
|
|
756
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
757
|
+
doc_id INTEGER NOT NULL,
|
|
758
|
+
ordinal INTEGER NOT NULL,
|
|
759
|
+
heading TEXT NOT NULL DEFAULT '',
|
|
760
|
+
text TEXT NOT NULL,
|
|
761
|
+
created_at TEXT NOT NULL
|
|
762
|
+
);
|
|
763
|
+
CREATE INDEX IF NOT EXISTS idx_kb_chunks_doc_ordinal ON kb_chunks(doc_id, ordinal);
|
|
764
|
+
|
|
709
765
|
`;
|
|
710
766
|
|
|
711
767
|
// Exported for API-layer type validation (standalone API POST /memories and
|
|
@@ -960,6 +1016,10 @@ function toConflictPending(row) {
|
|
|
960
1016
|
memory_a: row.memory_a,
|
|
961
1017
|
memory_b: row.memory_b,
|
|
962
1018
|
reason: row.reason ?? undefined,
|
|
1019
|
+
// Issue #603 第 2 层:入队时的相似度。存量行与调用方没给的行是 NULL,
|
|
1020
|
+
// 这里如实透出 undefined(条件展开:带 undefined 键会让 in-process schema
|
|
1021
|
+
// 校验判违规,同 toApiList 的处理)。
|
|
1022
|
+
...(typeof row.similarity === "number" ? { similarity: row.similarity } : {}),
|
|
963
1023
|
created_at: row.created_at,
|
|
964
1024
|
resolved_at: row.resolved_at ?? undefined,
|
|
965
1025
|
resolved_winner: row.resolved_winner ?? undefined
|
|
@@ -1493,6 +1553,18 @@ export function createStore(path) {
|
|
|
1493
1553
|
addColumn("memory_provenance", "distill_run_id", "ALTER TABLE memory_provenance ADD COLUMN distill_run_id TEXT");
|
|
1494
1554
|
db.exec("CREATE INDEX IF NOT EXISTS idx_memory_provenance_run ON memory_provenance(distill_run_id);");
|
|
1495
1555
|
|
|
1556
|
+
// Issue #603 第 2 层:冲突待审队列此前只能按 created_at 倒序排。一次 sleep 轮次
|
|
1557
|
+
// 里产出的对全部落在同一毫秒(实测 60 条挤在 60ms 内),排序键全同,队列在面板
|
|
1558
|
+
// 上看起来是随机的——而「哪一对最该先看」的唯一客观信号是相似度,此前只存在
|
|
1559
|
+
// reason 文本里(「相似度 0.87」),前端靠正则回收。落成列之后排序键是数据,
|
|
1560
|
+
// 不是解析结果。
|
|
1561
|
+
//
|
|
1562
|
+
// 可空且不带 DEFAULT:存量行没有这个信息,NULL = 「入队时没记」(比填 0 诚实——
|
|
1563
|
+
// 0 会被排到最后,等于替用户断言「这两条毫不相似」)。排序用 NULLS LAST。
|
|
1564
|
+
addColumn("conflict_pending", "similarity", "ALTER TABLE conflict_pending ADD COLUMN similarity REAL");
|
|
1565
|
+
// 索引建在加列之后(同 idx_memory_provenance_run 的理由):老库打开时列才刚加上。
|
|
1566
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_conflict_pending_similarity ON conflict_pending(similarity DESC, created_at DESC);");
|
|
1567
|
+
|
|
1496
1568
|
// Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
|
|
1497
1569
|
// (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
|
|
1498
1570
|
// enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
|
|
@@ -1714,13 +1786,12 @@ export function createStore(path) {
|
|
|
1714
1786
|
return db.prepare("DELETE FROM distill_parse_failures WHERE session_id = ?").run(sessionId).changes;
|
|
1715
1787
|
}
|
|
1716
1788
|
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
}
|
|
1789
|
+
// #597:count() 与 countByType() 共用的 WHERE 构造——两张 HTTP 面(宿主内 /list
|
|
1790
|
+
// 与 Bearer /memories)的 total 与 typeCounts 必须同口径,分布的合计才等于总数。
|
|
1791
|
+
// type 刻意不在这里:count() 按调用方带,countByType() 刻意不带(它要的就是
|
|
1792
|
+
// 同一份筛选下、**不带类型**的全库分布——面板分类栏被 type 筛过的页上数不出它)。
|
|
1793
|
+
function pushCountFilters(opts, clauses, params) {
|
|
1794
|
+
const { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = opts ?? {};
|
|
1724
1795
|
// Same filters as list() so a paged caller's total matches its rows.
|
|
1725
1796
|
if (minImportance != null) {
|
|
1726
1797
|
clauses.push("importance >= ?");
|
|
@@ -1786,10 +1857,32 @@ export function createStore(path) {
|
|
|
1786
1857
|
"(id IN (SELECT record_id FROM receipt_chain WHERE kind IN ('merge', 'update') AND verdict = 'live') OR source = 'dream')"
|
|
1787
1858
|
);
|
|
1788
1859
|
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
function count(type, opts = {}) {
|
|
1863
|
+
const clauses = [];
|
|
1864
|
+
const params = [];
|
|
1865
|
+
if (type !== undefined) {
|
|
1866
|
+
clauses.push("type = ?");
|
|
1867
|
+
params.push(type);
|
|
1868
|
+
}
|
|
1869
|
+
pushCountFilters(opts, clauses, params);
|
|
1789
1870
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1790
1871
|
return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
|
|
1791
1872
|
}
|
|
1792
1873
|
|
|
1874
|
+
// #597(/list 的 typeCounts):同一份筛选、不带 type,一次 GROUP BY 拿全库分布。
|
|
1875
|
+
// 面板分类栏的数字源:被 type 筛过又分过页的已加载行上数不出真分布。GROUP BY
|
|
1876
|
+
// 只回存在的类,缺类即 0;分布合计恒等于同筛选的 count(undefined)。
|
|
1877
|
+
function countByType(opts = {}) {
|
|
1878
|
+
const clauses = [];
|
|
1879
|
+
const params = [];
|
|
1880
|
+
pushCountFilters(opts, clauses, params);
|
|
1881
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1882
|
+
const rows = db.prepare(`SELECT type, count(*) AS c FROM memories ${where} GROUP BY type`).all(...params);
|
|
1883
|
+
return Object.fromEntries(rows.map((r) => [r.type, r.c]));
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1793
1886
|
function getById(id) {
|
|
1794
1887
|
const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
|
|
1795
1888
|
return toRow(row);
|
|
@@ -2575,6 +2668,30 @@ export function createStore(path) {
|
|
|
2575
2668
|
return needsEmbeddingFor(modelHash, 1).length > 0;
|
|
2576
2669
|
}
|
|
2577
2670
|
|
|
2671
|
+
/**
|
|
2672
|
+
* How many rows needsEmbeddingFor() would pick in total.
|
|
2673
|
+
*
|
|
2674
|
+
* A re-index run is bounded by a limit, so "indexed: 0" and "indexed: 3 of
|
|
2675
|
+
* 300" looked identical to the caller. Counting is cheap (no content is
|
|
2676
|
+
* materialised) and is what lets the caller say how much work is left.
|
|
2677
|
+
*/
|
|
2678
|
+
function countNeedsEmbeddingFor(modelHash) {
|
|
2679
|
+
if (!modelHash) {
|
|
2680
|
+
return db.prepare(
|
|
2681
|
+
`SELECT count(*) AS c FROM memories
|
|
2682
|
+
WHERE (embedding IS NULL OR embedding = '')
|
|
2683
|
+
AND forgotten = 0 AND archived = 0`
|
|
2684
|
+
).get().c;
|
|
2685
|
+
}
|
|
2686
|
+
return db.prepare(
|
|
2687
|
+
`SELECT count(*) AS c FROM memories
|
|
2688
|
+
WHERE ((embedding IS NULL OR embedding = '')
|
|
2689
|
+
OR embedding_model IS NULL
|
|
2690
|
+
OR embedding_model != ?)
|
|
2691
|
+
AND forgotten = 0 AND archived = 0`
|
|
2692
|
+
).get(String(modelHash)).c;
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2578
2695
|
/** Per-model provenance counts over active rows ('' = unknown origin). */
|
|
2579
2696
|
function embeddingModelBreakdown() {
|
|
2580
2697
|
return db.prepare(
|
|
@@ -3650,6 +3767,18 @@ export function createStore(path) {
|
|
|
3650
3767
|
return { path: typeof path === "string" ? path : null, pageSize, pageCount, freelistCount, fileBytes };
|
|
3651
3768
|
}
|
|
3652
3769
|
|
|
3770
|
+
/**
|
|
3771
|
+
* 容量预警的行数口径(#275 空间维度):活跃 / 归档各一格。体积与空闲页在
|
|
3772
|
+
* storageStats(),这里只补行数——两条 COUNT 的成本,挂在 /health 的按需读上,
|
|
3773
|
+
* 没有周期性消费者。归档增速(净增速率)是 recall-stats 的 archive 块(第五
|
|
3774
|
+
* 指标,按精确 archived_at 算),这里刻意不算速率:同一个数字长两处就会分叉。
|
|
3775
|
+
*/
|
|
3776
|
+
function capacityStats() {
|
|
3777
|
+
const active = db.prepare("SELECT COUNT(*) AS n FROM memories WHERE archived = 0").get().n;
|
|
3778
|
+
const archived = db.prepare("SELECT COUNT(*) AS n FROM memories WHERE archived = 1").get().n;
|
|
3779
|
+
return { active, archived };
|
|
3780
|
+
}
|
|
3781
|
+
|
|
3653
3782
|
/**
|
|
3654
3783
|
* 整库 VACUUM(#275 的手动步骤):代价 O(库大小) 且需要排他写锁,所以只在显式
|
|
3655
3784
|
* 入口里跑,绝不挂启动路径、也不开 auto_vacuum。
|
|
@@ -3712,7 +3841,10 @@ export function createStore(path) {
|
|
|
3712
3841
|
* pending once — a re-detection in a later dream run is a no-op, never a
|
|
3713
3842
|
* duplicate queue entry. Returns the pending row (freshly inserted, or the
|
|
3714
3843
|
* existing unresolved row when the pair is already pending), or undefined
|
|
3715
|
-
* when the re-detection is suppressed.
|
|
3844
|
+
* when the re-detection is suppressed. The returned row carries `fresh`:
|
|
3845
|
+
* true only when this call actually inserted a row, false when the pair was
|
|
3846
|
+
* already pending (issue #603 — callers used to read "non-null" as "parked",
|
|
3847
|
+
* so a re-detection re-reported the same pair as a new freeze every run).
|
|
3716
3848
|
*
|
|
3717
3849
|
* v0.8.1(issue #170 复核项 4):人工已裁决过的对(resolved_at 非空)不再
|
|
3718
3850
|
* 反复入队——除非任一侧在裁决之后又被改过(updated_at 晚于 resolved_at)。
|
|
@@ -3720,12 +3852,12 @@ export function createStore(path) {
|
|
|
3720
3852
|
* updated_at 是宽松代理(任何字段更新都会触发重新评审)——宁可多看一眼,
|
|
3721
3853
|
* 不静音真冲突。
|
|
3722
3854
|
*/
|
|
3723
|
-
function saveConflictPending({ run_id, memory_a, memory_b, reason }) {
|
|
3855
|
+
function saveConflictPending({ run_id, memory_a, memory_b, reason, similarity }) {
|
|
3724
3856
|
const [a, b] = [memory_a, memory_b].sort();
|
|
3725
3857
|
const existing = db.prepare(
|
|
3726
3858
|
"SELECT * FROM conflict_pending WHERE memory_a = ? AND memory_b = ? AND resolved_at IS NULL LIMIT 1"
|
|
3727
3859
|
).get(a, b);
|
|
3728
|
-
if (existing) return toConflictPending(existing);
|
|
3860
|
+
if (existing) return { ...toConflictPending(existing), fresh: false };
|
|
3729
3861
|
const reviewed = db.prepare(
|
|
3730
3862
|
"SELECT * FROM conflict_pending WHERE memory_a = ? AND memory_b = ? AND resolved_at IS NOT NULL ORDER BY resolved_at DESC, rowid DESC LIMIT 1"
|
|
3731
3863
|
).get(a, b);
|
|
@@ -3737,16 +3869,29 @@ export function createStore(path) {
|
|
|
3737
3869
|
}
|
|
3738
3870
|
const id = randomUUID();
|
|
3739
3871
|
const now = nowIso();
|
|
3872
|
+
// Issue #603 第 2 层:相似度落列。调用方拿不到(LLM 裁决路径只有理由文本)
|
|
3873
|
+
// 就写 NULL,不猜——面板与排序都按「未知」处理,而不是当成 0。
|
|
3874
|
+
const sim = typeof similarity === "number" && Number.isFinite(similarity)
|
|
3875
|
+
&& similarity >= 0 && similarity <= 1 ? similarity : null;
|
|
3740
3876
|
db.prepare(
|
|
3741
|
-
`INSERT INTO conflict_pending (id, run_id, memory_a, memory_b, reason, created_at)
|
|
3742
|
-
VALUES (?, ?, ?, ?, ?, ?)`
|
|
3743
|
-
).run(id, run_id ?? null, a, b, reason ?? null, now);
|
|
3744
|
-
return toConflictPending(db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id));
|
|
3877
|
+
`INSERT INTO conflict_pending (id, run_id, memory_a, memory_b, reason, similarity, created_at)
|
|
3878
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
3879
|
+
).run(id, run_id ?? null, a, b, reason ?? null, sim, now);
|
|
3880
|
+
return { ...toConflictPending(db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id)), fresh: true };
|
|
3745
3881
|
}
|
|
3746
3882
|
|
|
3747
3883
|
/**
|
|
3748
|
-
* List pending conflicts,
|
|
3749
|
-
* pass includeResolved to include resolved ones (audit view).
|
|
3884
|
+
* List pending conflicts, most-similar first. Unresolved rows only by
|
|
3885
|
+
* default; pass includeResolved to include resolved ones (audit view).
|
|
3886
|
+
*
|
|
3887
|
+
* Issue #603 第 2 层:排序键从「created_at DESC」改成「相似度 DESC → created_at
|
|
3888
|
+
* DESC」。原键在同一次 sleep 轮次里全同(实测 60 条落在 60ms 内),队列在面板
|
|
3889
|
+
* 上等于随机;相似度是「该先看哪对」的唯一客观信号,现在它是一列而不是 reason
|
|
3890
|
+
* 文本里的正则。
|
|
3891
|
+
*
|
|
3892
|
+
* NULL 排最后而不是当 0:存量行与 LLM 裁决路径没记相似度,把它们混进「低相似
|
|
3893
|
+
* 度」会把「未知」伪装成「已测量」。`(similarity IS NULL)` 前缀让这条不依赖
|
|
3894
|
+
* SQLite 的 NULL 排序约定,两个方向都稳。
|
|
3750
3895
|
*/
|
|
3751
3896
|
function listConflictPending({ limit = 50, offset = 0, includeResolved = false } = {}) {
|
|
3752
3897
|
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
@@ -3755,7 +3900,9 @@ export function createStore(path) {
|
|
|
3755
3900
|
if (!includeResolved) clauses.push("resolved_at IS NULL");
|
|
3756
3901
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
3757
3902
|
const rows = db.prepare(
|
|
3758
|
-
`SELECT * FROM conflict_pending ${where}
|
|
3903
|
+
`SELECT * FROM conflict_pending ${where}
|
|
3904
|
+
ORDER BY (similarity IS NULL), similarity DESC, created_at DESC, id
|
|
3905
|
+
LIMIT ? OFFSET ?`
|
|
3759
3906
|
).all(...params, lim, off);
|
|
3760
3907
|
return rows.map(toConflictPending);
|
|
3761
3908
|
}
|
|
@@ -4265,6 +4412,26 @@ export function createStore(path) {
|
|
|
4265
4412
|
return result.changes > 0;
|
|
4266
4413
|
}
|
|
4267
4414
|
|
|
4415
|
+
/**
|
|
4416
|
+
* 覆盖一条关系行的 metadata(#619)。关系本身仍按 saveRelation 追加,
|
|
4417
|
+
* 这条只给「同一条边被后续观察补充证据」用——情感相位判重命中时要把
|
|
4418
|
+
* evidence 并集与 sessionCount 写回既有边,否则边上的场合数会永远停在
|
|
4419
|
+
* 第一次写入那一刻。只改 metadata,不动 weight/source/created_at:这条边的
|
|
4420
|
+
* 来源与权重反映的是它第一次被建立时的事实。
|
|
4421
|
+
* 幂等(缺行 changes=0 → false)。
|
|
4422
|
+
*/
|
|
4423
|
+
function updateRelationMetadata(id, metadata) {
|
|
4424
|
+
const metaStr = metadata === undefined
|
|
4425
|
+
? null
|
|
4426
|
+
: typeof metadata === "string"
|
|
4427
|
+
? metadata
|
|
4428
|
+
: JSON.stringify(metadata);
|
|
4429
|
+
const result = db.prepare(
|
|
4430
|
+
"UPDATE entity_relations SET metadata = ? WHERE id = ?"
|
|
4431
|
+
).run(metaStr, id);
|
|
4432
|
+
return result.changes > 0;
|
|
4433
|
+
}
|
|
4434
|
+
|
|
4268
4435
|
/**
|
|
4269
4436
|
* Re-point every attr row whose memory_id is fromMemoryId to toMemoryId
|
|
4270
4437
|
* (autoDream merge migration, v0.3.0 Phase 4 / 4.3.2). When the keeper
|
|
@@ -4501,10 +4668,21 @@ export function createStore(path) {
|
|
|
4501
4668
|
/** All entities (optionally name-filtered, newest first). Used by sleep phase 4
|
|
4502
4669
|
* orphan detection: an entity with zero relations is a candidate for relation
|
|
4503
4670
|
* completion. */
|
|
4504
|
-
function listEntities({ limit = 1000 } = {}) {
|
|
4671
|
+
function listEntities({ limit = 1000, q } = {}) {
|
|
4672
|
+
const needle = typeof q === "string" ? q.trim() : "";
|
|
4673
|
+
if (needle === "") {
|
|
4674
|
+
const rows = db.prepare(
|
|
4675
|
+
"SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?"
|
|
4676
|
+
).all(limit);
|
|
4677
|
+
return rows.map(toEntity);
|
|
4678
|
+
}
|
|
4679
|
+
// 名字过滤放在 SQL 侧:调用方若在 LIMIT 之后过滤,排在 limit 之外的实体
|
|
4680
|
+
// 就永远搜不到(面板按 limit=200 取回再本地过滤,1.6 万实体时命中率约 1%)。
|
|
4681
|
+
// LIKE 的 % / _ 用 ESCAPE 转义,否则用户输入一个 "%" 就会把整库匹配出来。
|
|
4682
|
+
const escaped = needle.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
4505
4683
|
const rows = db.prepare(
|
|
4506
|
-
"SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?"
|
|
4507
|
-
).all(limit);
|
|
4684
|
+
"SELECT * FROM entities WHERE name LIKE ? ESCAPE '\\' ORDER BY last_seen DESC, name ASC LIMIT ?"
|
|
4685
|
+
).all(`%${escaped}%`, limit);
|
|
4508
4686
|
return rows.map(toEntity);
|
|
4509
4687
|
}
|
|
4510
4688
|
|
|
@@ -5128,9 +5306,143 @@ export function createStore(path) {
|
|
|
5128
5306
|
).all().map((r) => r.k);
|
|
5129
5307
|
}
|
|
5130
5308
|
|
|
5309
|
+
/* --------------------- knowledge base(知识库第二层,§3.3) --------------------- */
|
|
5310
|
+
// 与 choro 那组表同一套纪律:本域只读写 kb_* 三张表,绝不进 memories /
|
|
5311
|
+
// saveWithDedupe / dream / 质量过滤的任何池子。时间戳由调用方传(一次 ingest
|
|
5312
|
+
// 的所有行共用一个时刻),store 不自取 now()——两处时钟会把「同一次入库」拆成
|
|
5313
|
+
// 两个版本,sha256 幂等判据也会跟着抖。
|
|
5314
|
+
|
|
5315
|
+
function kbCollectionByKey(key) {
|
|
5316
|
+
return db.prepare("SELECT * FROM kb_collections WHERE key = ?").get(String(key)) ?? undefined;
|
|
5317
|
+
}
|
|
5318
|
+
|
|
5319
|
+
/** 集合列表带计数:面板左栏要的不是空壳 key 列表,docs/chunks 是它的主信息。 */
|
|
5320
|
+
function listKbCollections() {
|
|
5321
|
+
return db.prepare(
|
|
5322
|
+
`SELECT c.*,
|
|
5323
|
+
(SELECT COUNT(*) FROM kb_docs d WHERE d.collection_id = c.id) AS doc_count,
|
|
5324
|
+
(SELECT COUNT(*) FROM kb_chunks k JOIN kb_docs d2 ON k.doc_id = d2.id
|
|
5325
|
+
WHERE d2.collection_id = c.id) AS chunk_count
|
|
5326
|
+
FROM kb_collections c ORDER BY c.key`
|
|
5327
|
+
).all();
|
|
5328
|
+
}
|
|
5329
|
+
|
|
5330
|
+
// key 是外部键(面板/工具用它寻址),title 是展示名。ON CONFLICT 不动 created_at:
|
|
5331
|
+
// 「这个集合哪天建的」是事实,重命名不该改写它。
|
|
5332
|
+
function upsertKbCollection({ key, title, description = null, at }) {
|
|
5333
|
+
db.prepare(
|
|
5334
|
+
`INSERT INTO kb_collections (key, title, description, created_at, updated_at)
|
|
5335
|
+
VALUES (?, ?, ?, ?, ?)
|
|
5336
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
5337
|
+
title = excluded.title,
|
|
5338
|
+
description = excluded.description,
|
|
5339
|
+
updated_at = excluded.updated_at`
|
|
5340
|
+
).run(String(key), String(title), description ?? null, at, at);
|
|
5341
|
+
return kbCollectionByKey(key);
|
|
5342
|
+
}
|
|
5343
|
+
|
|
5344
|
+
/** 删集合连带删它的文档与分块(三张表是一个整体,留孤儿行等于悄悄占空间)。 */
|
|
5345
|
+
function deleteKbCollection(key) {
|
|
5346
|
+
const row = kbCollectionByKey(key);
|
|
5347
|
+
if (!row) return false;
|
|
5348
|
+
return runAtomically(() => {
|
|
5349
|
+
db.prepare(
|
|
5350
|
+
"DELETE FROM kb_chunks WHERE doc_id IN (SELECT id FROM kb_docs WHERE collection_id = ?)"
|
|
5351
|
+
).run(row.id);
|
|
5352
|
+
db.prepare("DELETE FROM kb_docs WHERE collection_id = ?").run(row.id);
|
|
5353
|
+
db.prepare("DELETE FROM kb_collections WHERE id = ?").run(row.id);
|
|
5354
|
+
return true;
|
|
5355
|
+
});
|
|
5356
|
+
}
|
|
5357
|
+
|
|
5358
|
+
function listKbDocs(collectionId) {
|
|
5359
|
+
return db.prepare(
|
|
5360
|
+
"SELECT * FROM kb_docs WHERE collection_id = ? ORDER BY rel_key"
|
|
5361
|
+
).all(collectionId);
|
|
5362
|
+
}
|
|
5363
|
+
|
|
5364
|
+
function kbDocByRelKey(collectionId, relKey) {
|
|
5365
|
+
return db.prepare(
|
|
5366
|
+
"SELECT * FROM kb_docs WHERE collection_id = ? AND rel_key = ?"
|
|
5367
|
+
).get(collectionId, String(relKey)) ?? undefined;
|
|
5368
|
+
}
|
|
5369
|
+
|
|
5370
|
+
function kbChunksByDoc(docId) {
|
|
5371
|
+
return db.prepare(
|
|
5372
|
+
"SELECT * FROM kb_chunks WHERE doc_id = ? ORDER BY ordinal"
|
|
5373
|
+
).all(docId);
|
|
5374
|
+
}
|
|
5375
|
+
|
|
5376
|
+
/**
|
|
5377
|
+
* 入库落库的唯一写口。幂等判据是 **正文 sha256**(不是文件 stat):同 rel_key +
|
|
5378
|
+
* 同 sha256 = 整件事 no-op,回执 skipped 且不动 chunks——否则编辑器保存一次
|
|
5379
|
+
* (mtime 变、内容没变)就要全量重切块,库和索引一起白忙。
|
|
5380
|
+
*
|
|
5381
|
+
* 变了才替换:删旧 chunks 再插新的,事务包住,半截失败整体回滚,库里保持上一版
|
|
5382
|
+
* 完整文档。上一版 status=error 的行不算命中(它没有可分块的正文,重来必须真跑)。
|
|
5383
|
+
*/
|
|
5384
|
+
function replaceKbDoc({ collectionId, relKey, kind, sourcePath = null, title, bytes = 0, sha256 = null, at, chunks = [] }) {
|
|
5385
|
+
return runAtomically(() => {
|
|
5386
|
+
const prev = kbDocByRelKey(collectionId, relKey);
|
|
5387
|
+
if (prev && prev.status === "ok" && sha256 && prev.sha256 === sha256) {
|
|
5388
|
+
return { docId: prev.id, chunks: kbChunksByDoc(prev.id).length, skipped: true };
|
|
5389
|
+
}
|
|
5390
|
+
db.prepare("DELETE FROM kb_chunks WHERE doc_id IN (SELECT id FROM kb_docs WHERE collection_id = ? AND rel_key = ?)")
|
|
5391
|
+
.run(collectionId, String(relKey));
|
|
5392
|
+
let docId;
|
|
5393
|
+
if (prev) {
|
|
5394
|
+
db.prepare(
|
|
5395
|
+
`UPDATE kb_docs SET kind = ?, source_path = ?, title = ?, bytes = ?, sha256 = ?,
|
|
5396
|
+
indexed_at = ?, status = 'ok', error = NULL WHERE id = ?`
|
|
5397
|
+
).run(kind, sourcePath, String(title), bytes, sha256, at, prev.id);
|
|
5398
|
+
docId = prev.id;
|
|
5399
|
+
} else {
|
|
5400
|
+
const info = db.prepare(
|
|
5401
|
+
`INSERT INTO kb_docs (collection_id, rel_key, kind, source_path, title, bytes, sha256, indexed_at, status, error)
|
|
5402
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'ok', NULL)`
|
|
5403
|
+
).run(collectionId, String(relKey), kind, sourcePath, String(title), bytes, sha256, at);
|
|
5404
|
+
docId = Number(info.lastInsertRowid);
|
|
5405
|
+
}
|
|
5406
|
+
const ins = db.prepare(
|
|
5407
|
+
"INSERT INTO kb_chunks (doc_id, ordinal, heading, text, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
5408
|
+
);
|
|
5409
|
+
for (const c of chunks) ins.run(docId, c.ordinal, c.heading ?? "", c.text ?? "", at);
|
|
5410
|
+
return { docId, chunks: chunks.length, skipped: false };
|
|
5411
|
+
});
|
|
5412
|
+
}
|
|
5413
|
+
|
|
5414
|
+
/** 入库失败也要留痕:面板要能显示「这份文档上次为什么没进来」,静默丢来源等于假成功。 */
|
|
5415
|
+
function markKbDocError({ collectionId, relKey, kind, sourcePath = null, title, error, at }) {
|
|
5416
|
+
const prev = kbDocByRelKey(collectionId, relKey);
|
|
5417
|
+
if (prev) {
|
|
5418
|
+
db.prepare(
|
|
5419
|
+
"UPDATE kb_docs SET status = 'error', error = ?, indexed_at = ?, source_path = ?, kind = ?, title = ? WHERE id = ?"
|
|
5420
|
+
).run(String(error ?? ""), at, sourcePath, kind, String(title ?? prev.title), prev.id);
|
|
5421
|
+
return prev.id;
|
|
5422
|
+
}
|
|
5423
|
+
const info = db.prepare(
|
|
5424
|
+
`INSERT INTO kb_docs (collection_id, rel_key, kind, source_path, title, bytes, sha256, indexed_at, status, error)
|
|
5425
|
+
VALUES (?, ?, ?, ?, ?, 0, NULL, ?, 'error', ?)`
|
|
5426
|
+
).run(collectionId, String(relKey), kind, sourcePath, String(title ?? relKey), at, String(error ?? ""));
|
|
5427
|
+
return Number(info.lastInsertRowid);
|
|
5428
|
+
}
|
|
5429
|
+
|
|
5430
|
+
/** 检索候选集:跨集合或单集合,一次取全(BM25 需要全语料算 df,不能分页取)。 */
|
|
5431
|
+
function kbChunksForSearch(collectionId = null) {
|
|
5432
|
+
return db.prepare(
|
|
5433
|
+
`SELECT k.id, k.doc_id, k.ordinal, k.heading, k.text,
|
|
5434
|
+
d.rel_key, d.title, d.kind, c.key AS collection_key
|
|
5435
|
+
FROM kb_chunks k
|
|
5436
|
+
JOIN kb_docs d ON k.doc_id = d.id
|
|
5437
|
+
JOIN kb_collections c ON d.collection_id = c.id
|
|
5438
|
+
WHERE d.status = 'ok' AND (? IS NULL OR d.collection_id = ?)`
|
|
5439
|
+
).all(collectionId, collectionId);
|
|
5440
|
+
}
|
|
5441
|
+
|
|
5131
5442
|
return {
|
|
5132
5443
|
db,
|
|
5133
5444
|
count,
|
|
5445
|
+
countByType,
|
|
5134
5446
|
getById,
|
|
5135
5447
|
// 车道 C:外来行身份的两个读口(幂等键 / 整包清点)。
|
|
5136
5448
|
getByShareExternalId,
|
|
@@ -5174,6 +5486,7 @@ export function createStore(path) {
|
|
|
5174
5486
|
embeddedCount,
|
|
5175
5487
|
needsEmbedding,
|
|
5176
5488
|
needsEmbeddingFor,
|
|
5489
|
+
countNeedsEmbeddingFor,
|
|
5177
5490
|
hasStaleEmbedding,
|
|
5178
5491
|
embeddingModelBreakdown,
|
|
5179
5492
|
embeddingDimensionBreakdown,
|
|
@@ -5232,6 +5545,7 @@ export function createStore(path) {
|
|
|
5232
5545
|
archivedEmbeddingStats,
|
|
5233
5546
|
clearArchivedEmbeddings,
|
|
5234
5547
|
storageStats,
|
|
5548
|
+
capacityStats,
|
|
5235
5549
|
vacuum,
|
|
5236
5550
|
saveFailure,
|
|
5237
5551
|
listFailures,
|
|
@@ -5284,6 +5598,7 @@ export function createStore(path) {
|
|
|
5284
5598
|
listBoostedRelations,
|
|
5285
5599
|
getLinkedMemoryIds,
|
|
5286
5600
|
saveRelation,
|
|
5601
|
+
updateRelationMetadata,
|
|
5287
5602
|
bumpRelationWeight,
|
|
5288
5603
|
migrateAttrsToMemory,
|
|
5289
5604
|
getRelations,
|
|
@@ -5303,6 +5618,17 @@ export function createStore(path) {
|
|
|
5303
5618
|
getContinuityProposal,
|
|
5304
5619
|
listContinuityProposals,
|
|
5305
5620
|
countContinuityProposals,
|
|
5621
|
+
// knowledge base(知识库第二层,§3.3):三张 kb_* 表,与记忆行物理隔离。
|
|
5622
|
+
kbCollectionByKey,
|
|
5623
|
+
listKbCollections,
|
|
5624
|
+
upsertKbCollection,
|
|
5625
|
+
deleteKbCollection,
|
|
5626
|
+
listKbDocs,
|
|
5627
|
+
kbDocByRelKey,
|
|
5628
|
+
kbChunksByDoc,
|
|
5629
|
+
replaceKbDoc,
|
|
5630
|
+
markKbDocError,
|
|
5631
|
+
kbChunksForSearch,
|
|
5306
5632
|
// chorography(仓库地志):M0 只写不读,消费面 M1 接(见上面注释块)。
|
|
5307
5633
|
choroRepoByRoot,
|
|
5308
5634
|
upsertChoroRepo,
|
package/src/summarize.js
CHANGED
|
@@ -9,6 +9,8 @@ import { createScopeResolver } from "./scope.js";
|
|
|
9
9
|
// #493 补充 §③:断链与巩固侧同一份实现(src/fence.js)。转录里出现闭合标记时
|
|
10
10
|
// 原先能提前闭合围栏——那是把用户消息升格成指令最短的一条路。
|
|
11
11
|
import { breakFenceMarkers } from "./fence.js";
|
|
12
|
+
// Issue #611:图片块的文本口径(占位,不是空串)。三个 textOf 副本共用这一份。
|
|
13
|
+
import { contentToText, countImages } from "./attachment-text.js";
|
|
12
14
|
// #512:每轮蒸馏一个 run id(归因主体)。store 侧也能铸造,但这里必须知道它——
|
|
13
15
|
// 写记忆时要把它一路带到 saveMemoryProvenance / 落 run 行,拿回来再写就是两次往返。
|
|
14
16
|
import { randomUUID } from "node:crypto";
|
|
@@ -292,13 +294,13 @@ const SUBAGENT_MESSAGE_KINDS = new Set(["agent-message", "subagent-settled"]);
|
|
|
292
294
|
|
|
293
295
|
// 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
|
|
294
296
|
// 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。
|
|
297
|
+
//
|
|
298
|
+
// Issue #611:图片块曾经在这里一律变空串,于是图-only 消息整条被丢、图+文消息只剩
|
|
299
|
+
// 文字——静默,没有任何计数。现在改由 attachment-text 统一产占位,
|
|
300
|
+
// `[图: 屏幕截图 … 1543x983]`。抽取口径只有一份,三个调用方(本文件 /
|
|
301
|
+
// continuity / hot-memory)共用,避免同一件事写成三份会漂移的实现。
|
|
295
302
|
function textOf(content) {
|
|
296
|
-
|
|
297
|
-
if (!Array.isArray(content)) return "";
|
|
298
|
-
return content
|
|
299
|
-
.map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : "")))
|
|
300
|
-
.filter((s) => s)
|
|
301
|
-
.join("\n");
|
|
303
|
+
return contentToText(content);
|
|
302
304
|
}
|
|
303
305
|
|
|
304
306
|
/**
|
|
@@ -333,6 +335,10 @@ function publicTranscriptParts(event) {
|
|
|
333
335
|
parts.push({
|
|
334
336
|
role: "agent",
|
|
335
337
|
text,
|
|
338
|
+
// Issue #611:把产文本的那份 content 一起带出去。判定与文本同源,计量才能
|
|
339
|
+
// 跟着同一份判定走(见 collectMessages 里的 imageCount)——另写一套「哪些块
|
|
340
|
+
// 算公开」给计量用,就是第二份实现,迟早与这份漂移。
|
|
341
|
+
blocks: Array.isArray(message?.content) ? message.content : [],
|
|
336
342
|
// 同一条子会话消息可能同时出现在 user/message 与 agent/inbox/spliced
|
|
337
343
|
// 里(投递路径重叠),按 id(无 id 时按内容)去重,否则转录会重复。
|
|
338
344
|
key: typeof message?.id === "string" && message.id
|
|
@@ -346,7 +352,7 @@ function publicTranscriptParts(event) {
|
|
|
346
352
|
const text = textOf(data?.content);
|
|
347
353
|
if (!text.trim()) break;
|
|
348
354
|
if (SUBAGENT_MESSAGE_KINDS.has(kind)) pushAgentMessage(data);
|
|
349
|
-
else parts.push({ role: "user", text });
|
|
355
|
+
else parts.push({ role: "user", text, blocks: Array.isArray(data?.content) ? data.content : [] });
|
|
350
356
|
break;
|
|
351
357
|
}
|
|
352
358
|
case "agent/inbox/spliced": {
|
|
@@ -358,7 +364,7 @@ function publicTranscriptParts(event) {
|
|
|
358
364
|
const blocks = Array.isArray(data?.message?.content) ? data.message.content : [];
|
|
359
365
|
const text = textOf(blocks);
|
|
360
366
|
// 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。
|
|
361
|
-
if (text.trim()) parts.push({ role: "assistant", text });
|
|
367
|
+
if (text.trim()) parts.push({ role: "assistant", text, blocks });
|
|
362
368
|
break;
|
|
363
369
|
}
|
|
364
370
|
default:
|
|
@@ -395,6 +401,10 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
|
|
|
395
401
|
const events = snapshotSessionEvents(session, afterSeq, throughSeq);
|
|
396
402
|
const lines = [];
|
|
397
403
|
const seenSubagentMessages = new Set();
|
|
404
|
+
// Issue #611:这一窗里被折成占位的图片张数。占位本身不丢信息,但「折了几张」
|
|
405
|
+
// 必须是个能读到的数——否则 off 档和旧版静默丢弃在观测上无法区分,下次再有人
|
|
406
|
+
// 问「图到底进来没有」还是得翻源码。计量只数窗口内事件,不受截断影响。
|
|
407
|
+
let imageCount = 0;
|
|
398
408
|
const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s);
|
|
399
409
|
const trimTranscript = (s, n) => {
|
|
400
410
|
if (typeof s !== "string" || s.length <= n) return s;
|
|
@@ -421,9 +431,20 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
|
|
|
421
431
|
if (part.role === "agent") {
|
|
422
432
|
if (part.key && seenSubagentMessages.has(part.key)) continue;
|
|
423
433
|
if (part.key) seenSubagentMessages.add(part.key);
|
|
424
|
-
|
|
425
|
-
|
|
434
|
+
}
|
|
435
|
+
// Issue #611 计量:数「真正进了转录的图块」,所以按 part 数、并且在去重之后
|
|
436
|
+
// ——同一条子会话消息会同时出现在 user/message 与 agent/inbox/spliced 里
|
|
437
|
+
// (投递路径重叠),按事件数会把它算两遍。
|
|
438
|
+
//
|
|
439
|
+
// 不按事件数还有个更要紧的理由:被 publicTranscriptParts 挡掉的注入消息
|
|
440
|
+
// (工具结果、插件注入、陌生 source.kind)里的图根本进不了转录,按事件累加
|
|
441
|
+
// 会报「这一窗有 2 张图」而转录里一张都没有。part 是渲染与落盘共用的同一份
|
|
442
|
+
// 判定,计量跟着它走,两处不会再各有一套口径。
|
|
443
|
+
imageCount += countImages(part.blocks);
|
|
444
|
+
if (part.role === "user") {
|
|
426
445
|
lines.push(STR.transcriptUser[language](part.text));
|
|
446
|
+
} else if (part.role === "agent") {
|
|
447
|
+
lines.push(STR.transcriptAgent[language](part.text));
|
|
427
448
|
} else {
|
|
428
449
|
lines.push(STR.transcriptAssistant[language](part.text));
|
|
429
450
|
}
|
|
@@ -485,6 +506,9 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
|
|
|
485
506
|
: [],
|
|
486
507
|
hasEvents: events.length > 0,
|
|
487
508
|
lastSeq,
|
|
509
|
+
// Issue #611:这一窗折成占位的图片张数。调用方把它写进审计 metadata,
|
|
510
|
+
// 让「图进来了几张」成为可对账的事实而不是推断。
|
|
511
|
+
imageCount,
|
|
488
512
|
// Issue #493:只数**转录正文**的字符数。minWindowChars 闸门与审计里的
|
|
489
513
|
// distillChars 量的都是「这一窗有多少可提炼内容」,不是「我们额外包了多少
|
|
490
514
|
// 提示词」——若把围栏与角色句算进去,一条「短」也会凭空越过任何阈值,
|
|
@@ -850,6 +874,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
|
|
|
850
874
|
0
|
|
851
875
|
);
|
|
852
876
|
const minWindowChars = config.summarizeMinWindowChars ?? 0;
|
|
877
|
+
const imageCount = collected.imageCount ?? 0;
|
|
853
878
|
if (minWindowChars > 0 && distillChars < minWindowChars) {
|
|
854
879
|
commitCursor(session.id, nextSeq);
|
|
855
880
|
writeAudit({
|
|
@@ -857,7 +882,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
|
|
|
857
882
|
model_id: route ? `${route.provider}:${route.model}` : "unknown",
|
|
858
883
|
status: "skipped",
|
|
859
884
|
error_message: "window-too-small",
|
|
860
|
-
metadata: { distillChars, minWindowChars }
|
|
885
|
+
metadata: { distillChars, minWindowChars, ...(imageCount > 0 ? { imageCount } : {}) }
|
|
861
886
|
});
|
|
862
887
|
return;
|
|
863
888
|
}
|
|
@@ -1064,6 +1089,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
|
|
|
1064
1089
|
audit.errorMessage = failureAudit.errorMessage;
|
|
1065
1090
|
audit.metadata = {
|
|
1066
1091
|
distillChars,
|
|
1092
|
+
...(imageCount > 0 ? { imageCount } : {}),
|
|
1067
1093
|
...failureAudit.metadata,
|
|
1068
1094
|
streak,
|
|
1069
1095
|
...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}),
|
|
@@ -1203,6 +1229,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
|
|
|
1203
1229
|
if (audit) {
|
|
1204
1230
|
audit.metadata = {
|
|
1205
1231
|
distillChars,
|
|
1232
|
+
...(imageCount > 0 ? { imageCount } : {}),
|
|
1206
1233
|
parsed: parsed.length,
|
|
1207
1234
|
...(parsedResult.salvaged ? { json_salvaged: true } : {}),
|
|
1208
1235
|
...(capped > 0 ? { capped } : {}),
|