solopup-memory 0.21.0-beta.3 → 0.23.0-beta.1
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.md +38 -0
- package/COMPATIBILITY.md +8 -5
- package/DETECTION.md +139 -0
- package/DREAMING-DESIGN.md +167 -0
- package/README.en.md +64 -6
- package/README.md +67 -5
- package/RELEASE.md +36 -0
- package/SECURITY.md +7 -0
- package/context-history.mjs +20 -3
- package/detect.mjs +441 -0
- package/eval/bench-detect.mjs +107 -0
- package/eval/semantic/cases.json +387 -0
- package/eval/semantic-suite.mjs +156 -0
- package/index.mjs +85 -11
- package/managed-policy.mjs +3 -1
- package/package.json +90 -69
- package/project-stores.mjs +83 -4
- package/scripts/demo-source-recall.mjs +123 -0
- package/scripts/detect.mjs +69 -0
- package/scripts/dream.mjs +205 -0
- package/scripts/probe-history.mjs +92 -0
- package/scripts/probe-load.mjs +1 -1
- package/scripts/probe-package.mjs +6 -2
- package/scripts/projects.mjs +5 -4
- package/scripts/security-check.mjs +177 -0
- package/scripts/soak.mjs +223 -0
- package/scripts/superagent-worktree.mjs +20 -0
- package/scripts/telemetry.mjs +187 -0
- package/scripts/verify.mjs +3 -1
- package/{test-adversarial.mjs → test/test-adversarial.mjs} +5 -5
- package/{test-belief-revision.mjs → test/test-belief-revision.mjs} +7 -7
- package/{test-candidates.mjs → test/test-candidates.mjs} +4 -4
- package/{test-capture-diagnostics.mjs → test/test-capture-diagnostics.mjs} +6 -6
- package/{test-capture-field-lineage.mjs → test/test-capture-field-lineage.mjs} +4 -4
- package/{test-capture-inputs.mjs → test/test-capture-inputs.mjs} +8 -8
- package/{test-capture-jobs.mjs → test/test-capture-jobs.mjs} +5 -5
- package/{test-capture-projections.mjs → test/test-capture-projections.mjs} +12 -12
- package/{test-capture-revision.mjs → test/test-capture-revision.mjs} +6 -6
- package/{test-capture-submission.mjs → test/test-capture-submission.mjs} +3 -3
- package/{test-capture-validation-flow.mjs → test/test-capture-validation-flow.mjs} +5 -5
- package/{test-capture-validation-job.mjs → test/test-capture-validation-job.mjs} +5 -5
- package/{test-capture-validation-lineage.mjs → test/test-capture-validation-lineage.mjs} +2 -2
- package/{test-capture-validation.mjs → test/test-capture-validation.mjs} +2 -2
- package/{test-capture.mjs → test/test-capture.mjs} +20 -20
- package/{test-compaction-compat.mjs → test/test-compaction-compat.mjs} +5 -5
- package/{test-compat.mjs → test/test-compat.mjs} +3 -3
- package/{test-concurrency.mjs → test/test-concurrency.mjs} +3 -3
- package/{test-conflict.mjs → test/test-conflict.mjs} +2 -2
- package/{test-context-history.mjs → test/test-context-history.mjs} +1 -1
- package/{test-control-schema.mjs → test/test-control-schema.mjs} +1 -1
- package/test/test-detect.mjs +125 -0
- package/test/test-dream-plan.mjs +72 -0
- package/test/test-dsh-registration.mjs +111 -0
- package/{test-episodes.mjs → test/test-episodes.mjs} +5 -5
- package/{test-explicit-memory.mjs → test/test-explicit-memory.mjs} +4 -4
- package/{test-field-lineage.mjs → test/test-field-lineage.mjs} +4 -4
- package/{test-history.mjs → test/test-history.mjs} +4 -4
- package/{test-inline-summary.mjs → test/test-inline-summary.mjs} +5 -5
- package/{test-legacy-source-identity.mjs → test/test-legacy-source-identity.mjs} +2 -2
- package/{test-links.mjs → test/test-links.mjs} +4 -4
- package/{test-managed-access.mjs → test/test-managed-access.mjs} +29 -5
- package/{test-managed-activation.mjs → test/test-managed-activation.mjs} +5 -5
- package/{test-meaning.mjs → test/test-meaning.mjs} +6 -6
- package/{test-native-service-lifecycle.mjs → test/test-native-service-lifecycle.mjs} +1 -1
- package/{test-native-write-validation.mjs → test/test-native-write-validation.mjs} +4 -4
- package/{test-packaging.mjs → test/test-packaging.mjs} +32 -5
- package/{test-paired-analysis.mjs → test/test-paired-analysis.mjs} +1 -1
- package/{test-patch.mjs → test/test-patch.mjs} +11 -11
- package/{test-plugin-contract.mjs → test/test-plugin-contract.mjs} +4 -4
- package/{test-privacy.mjs → test/test-privacy.mjs} +3 -3
- package/{test-project-stores.mjs → test/test-project-stores.mjs} +125 -7
- package/{test-publication.mjs → test/test-publication.mjs} +4 -4
- package/{test-restore.mjs → test/test-restore.mjs} +11 -11
- package/{test-retrieval.mjs → test/test-retrieval.mjs} +2 -2
- package/{test-revision-admission.mjs → test/test-revision-admission.mjs} +3 -3
- package/{test-rule-admission.mjs → test/test-rule-admission.mjs} +8 -8
- package/{test-runtime-startup.mjs → test/test-runtime-startup.mjs} +2 -2
- package/{test-schema.mjs → test/test-schema.mjs} +2 -2
- package/{test-security.mjs → test/test-security.mjs} +6 -6
- package/test/test-semantic-suite.mjs +20 -0
- package/{test-service-readiness.mjs → test/test-service-readiness.mjs} +1 -1
- package/{test-smell.mjs → test/test-smell.mjs} +4 -4
- package/{test-snapshots.mjs → test/test-snapshots.mjs} +3 -3
- package/{test-source-completeness.mjs → test/test-source-completeness.mjs} +6 -6
- package/{test-source-key.mjs → test/test-source-key.mjs} +2 -2
- package/{test-source-retirement.mjs → test/test-source-retirement.mjs} +4 -4
- package/{test-status-both.mjs → test/test-status-both.mjs} +12 -4
- package/{test-supersede.mjs → test/test-supersede.mjs} +3 -3
- package/test/test-telemetry.mjs +60 -0
- package/{test-text-admission.mjs → test/test-text-admission.mjs} +6 -6
- package/{test-tool-inputs.mjs → test/test-tool-inputs.mjs} +1 -1
- package/{test-tool-observations.mjs → test/test-tool-observations.mjs} +6 -6
- package/{test-tool-write-guard.mjs → test/test-tool-write-guard.mjs} +3 -3
- package/{test-tool-write-lifecycle.mjs → test/test-tool-write-lifecycle.mjs} +3 -3
- package/{test-usability.mjs → test/test-usability.mjs} +7 -7
- package/{test-validation-source-refs.mjs → test/test-validation-source-refs.mjs} +2 -2
- package/{test-validation-transition-context.mjs → test/test-validation-transition-context.mjs} +1 -1
- package/{test-withdrawal-archives.mjs → test/test-withdrawal-archives.mjs} +6 -6
- package/{test-withdrawal.mjs → test/test-withdrawal.mjs} +2 -2
- package/{test.mjs → test/test.mjs} +4 -4
- package/version.mjs +1 -1
package/RELEASE.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
+
# 0.23.0-beta.1 — project memory across worktrees
|
|
2
|
+
|
|
3
|
+
This version adds opt-in, reviewed linking between worktrees of the same local Git repository. If a Super Agent prepare command fails after creating the directory, its prototype can skip preparation on retry and start the worker without the intended link; inspect the leftover directory and do not retry until preparation will run again. It does not automatically merge pre-existing project stores or link clones. Review the worktree identity, performance and recovery limits before use. G1 remains unpassed; real-model semantics and cross-platform operation have not been validated. Existing conversation or provider copies are not erased.
|
|
4
|
+
|
|
5
|
+
Unlink changes future directory resolution and is not an immediate revocation barrier for already resolved paths or in-flight capture. Stop affected Workers and capture before unlinking and review retained copies.
|
|
6
|
+
|
|
7
|
+
The tested Super Agent prototype does not expose `memory_search` or `memory_write` to Workers by default. Linking enables project context injection; it does not establish direct Worker memory tool access.
|
|
8
|
+
|
|
9
|
+
A local candidate was installed and loaded in one daily DSH profile; its health check detected pre-existing pending withdrawal cleanup. Real-model semantic acceptance remains outstanding. The npm beta tag and GitHub Releases identify versions that have actually been published.
|
|
10
|
+
|
|
11
|
+
# 0.22.0-beta.3 — DSH 0.2.2 compatibility and first-run integration
|
|
12
|
+
|
|
13
|
+
**Published public beta.** Published to npm as `0.22.0-beta.3` (dist-tag `beta`) and to GitHub Releases on 2026-11-09; the npm tarball sha256 `621bf9c0d21e39d769377a421e80330402c7d9d5840745209b8500a11c9f2fe2` matches the release `SHA256SUMS`. `memory_status` should report `0.22.0-beta.3` after a clean restart. This version integrates the onboarding/test-layout work with current main and waits for DSH asynchronous Session Projection registration before exposing dependent features. New hosts use retained projection handles; older hosts retain their registry-reader path. Existing beta tags and the published registry version are not rewritten.
|
|
14
|
+
|
|
15
|
+
The isolated Linux host checks use the official npm CLI distributions for DSH 0.2.1-rc.5 and 0.2.2-rc.1, synthetic Sessions, no user profile or credentials, and no model calls. They cover six-tool activation, live projection state, exact removal of obsolete memory sections while preserving other content, cold replay, rejected registrations and owner disposal. This is not real-model semantic acceptance or daily desktop verification. See [COMPATIBILITY.md](COMPATIBILITY.md) and the integration receipt for exact results and remaining gates.
|
|
16
|
+
|
|
17
|
+
# 0.22.0-beta.2 — detection layer, semantic pipeline gate, dream P0/P1
|
|
18
|
+
|
|
19
|
+
**Superseded by 0.22.0-beta.3; never published.** (v0.22.0-beta.1 tag failed before submission because npm 11 parsed the relative tarball path as a GitHub shorthand; no npm version was published. This beta.2 commit fixes the path and passes the same gates, but publication completed only with beta.3.) This commit adds engineering infrastructure and read-only/planning features; stable semantic acceptance is still not claimed.
|
|
20
|
+
|
|
21
|
+
- L0 detection: `detect.mjs` + `scripts/detect.mjs` read-only invariant checks (index format, projection consistency, anchors, withdrawal residue, duplicates/conflicts, budgets, scope, version drift, topic structure, duplicate stores). `memory_status` exposes a bounded `lint` summary.
|
|
22
|
+
- L2 pipeline-fidelity suite: 10 frozen cases (~190ms) checking that a correct or deliberately wrong candidate keeps its conditions/meaning/expiry/scope through write → projection → retrieval. `semanticTruthEvaluated` stays false (no real extraction model run).
|
|
23
|
+
- L3 telemetry: capture success rate, zero-output days, stale pending candidates, short-session skips.
|
|
24
|
+
- Dream P0 shadow plan: read-only op list from L0+L3 with evidence, zero model calls. Dream P1 applies only `compact-index` (reuses deterministic `pruneStore`) behind `--reviewed <planHash>`, after a file backup, with post-apply L0 verification; every other op remains human-only.
|
|
25
|
+
- Short sessions below the auto-capture turn threshold now emit `capture/skipped/short-session-below-threshold` instead of silently losing context.
|
|
26
|
+
- Add a mechanical security self-check (`scripts/security-check.mjs`, wired into `verify`): zero runtime dependencies and install hooks; no runtime network calls or listening sockets; the single subprocess is `restore.mjs` invoking fixed-argument `tar` without a shell; no secrets or machine absolute paths in shipped files; workflow actions pinned to commit SHAs with `contents: read`, no `secrets.*` and no `pull_request_target`; package contents exclude research/logs/tarballs; MIT license, engines and `.gitignore` present.
|
|
27
|
+
- Add a reliability test (`scripts/soak.mjs`) on the official offline install path: repeated real DSH host start/stop, SIGKILL recovery with no leftover `.write.lock`/`.publication.json`, two concurrent hosts on one store followed by a clean reload, unchanged memory content files across cycles, and store permissions 0700 (files 0600).
|
|
28
|
+
|
|
29
|
+
Known gaps: no real-model semantic acceptance; no per-memory usage counters; dream P1 does not apply merges, conflict arbitration or transcript-based corrections.
|
|
30
|
+
|
|
31
|
+
# 0.21.0-beta.4 — automated beta release infrastructure
|
|
32
|
+
|
|
33
|
+
This maintenance version adds the repository release workflow, OIDC publishing support, immutable artifact checks and post-publish GitHub/market updates. Memory behavior and data formats are unchanged. Hosted workflow success and trusted-publisher validation must be checked in the release receipts; a workflow file alone does not establish them. Stable semantic acceptance and historical erasure remain outside this release.
|
|
34
|
+
|
|
35
|
+
## Historical beta.3 release evidence
|
|
36
|
+
|
|
1
37
|
# 0.21.0-beta.3 - private-memory exposure reduction
|
|
2
38
|
|
|
3
39
|
Public beta. Conservative known-category filtering, not full privacy isolation or stable semantic acceptance.
|
package/SECURITY.md
CHANGED
|
@@ -75,3 +75,10 @@
|
|
|
75
75
|
## beta.3 privacy boundary
|
|
76
76
|
|
|
77
77
|
Recognized private records, including their quoted evidence, are omitted from automatic projection and unrelated saved-memory contexts. The classifier is heuristic and incomplete; explicit [private] or [private:category] text is conservatively treated as on-demand. Searches default to excluding recognized private content; includePrivate=true is a disclosure control, not a user authentication boundary. Other tools can read the raw files. Private facts explicitly present in the current conversation or prior ordinary messages remain in that conversation. Existing append-only archives, caches, exports, backups and provider-side copies are not purged. Never claim that hiding an index or memory_forget erases those copies. Physical raw-index migration and reviewed local erasure are separate work.
|
|
78
|
+
|
|
79
|
+
## 发版前机械自查(0.22.0-beta.1 起)
|
|
80
|
+
|
|
81
|
+
`node scripts/security-check.mjs` 只读检查发行面的机械项:零运行时依赖与安装钩子、运行时无网络/监听面、唯一子进程是 `restore.mjs` 以固定参数调用 `tar`(无 shell)、发行文件无密钥与机器绝对路径、工作流 action 按 commit pin 且权限为 `contents: read`、无 `secrets.*`/`pull_request_target`、发行包不含研究目录与日志、MIT 许可与引擎声明。
|
|
82
|
+
|
|
83
|
+
这不是独立安全审计,也不覆盖:依赖供应链以外的攻击、宿主自身的模型/网络行为、物理访问、已知的隐私分类器不完整、以及“记忆被用户自己手工编辑”的情形。可靠性验证见 `node scripts/soak.mjs`(官方离线安装 + 真实宿主多轮启停 + SIGKILL 恢复 + 并发宿主 + 数据指纹不变)。
|
|
84
|
+
|
package/context-history.mjs
CHANGED
|
@@ -90,8 +90,25 @@ export function assertCurrentMemoryRequest(messages,currentText) {
|
|
|
90
90
|
|
|
91
91
|
export function setupMemoryHistory(ctx,render) {
|
|
92
92
|
const projections = optionalService(ctx, 'sessionProjections')
|
|
93
|
-
if(typeof projections?.register!=='function'
|
|
94
|
-
projections.register(memoryContextProjection())
|
|
93
|
+
if(typeof projections?.register!=='function')return{available:false,observe:()=>{}}
|
|
94
|
+
const registration=projections.register(memoryContextProjection())
|
|
95
|
+
// DSH 0.2.2 returns a callable thenable. Await it before publishing hooks,
|
|
96
|
+
// while retaining synchronous support for the older registry API.
|
|
97
|
+
const ready=handle=>{
|
|
98
|
+
// Disposal can win after the host resolves registration but before this
|
|
99
|
+
// continuation runs. The owning fiber already releases its registration.
|
|
100
|
+
if(ctx.fiber?.uid===null)return{available:false,observe:()=>{}}
|
|
101
|
+
return installMemoryHistory(ctx,render,typeof handle?.stateOf==='function'
|
|
102
|
+
? session=>handle.stateOf(session)
|
|
103
|
+
: typeof projections.stateOf==='function'
|
|
104
|
+
? session=>projections.stateOf(session,HISTORY_PROJECTION_KEY)
|
|
105
|
+
: undefined)
|
|
106
|
+
}
|
|
107
|
+
return typeof registration?.then==='function'?Promise.resolve(registration).then(ready):ready(registration)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function installMemoryHistory(ctx,render,stateOf) {
|
|
111
|
+
if(!stateOf)fail('memory-context-reader-unavailable')
|
|
95
112
|
const sessions=new Map()
|
|
96
113
|
const cwdFor=(session,fallback)=>typeof ctx.workingDirectory?.get==='function'?ctx.workingDirectory.get(session):fallback
|
|
97
114
|
const refresh=(input,next)=>{
|
|
@@ -99,7 +116,7 @@ export function setupMemoryHistory(ctx,render) {
|
|
|
99
116
|
if(!session)fail('memory-context-session-unavailable')
|
|
100
117
|
const cwd=cwdFor(session)
|
|
101
118
|
sessions.set(session.id,{ref:new WeakRef(session),cwd})
|
|
102
|
-
reconcileMemoryContext(session,
|
|
119
|
+
reconcileMemoryContext(session,stateOf(session),render(cwd))
|
|
103
120
|
return next()
|
|
104
121
|
}
|
|
105
122
|
// Run before pre-step consumers (including summarizers), then recheck at
|
package/detect.mjs
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 记忆层检测框架 · L0 不变量层。
|
|
3
|
+
*
|
|
4
|
+
* 只读:不改任何记忆数据,不调用模型。输出结构化 findings,供 CLI、测试与人工复核共用。
|
|
5
|
+
* 设计原则:每一条“我们踩过的坑”都必须有一个可复现的检查项;查不出来就明说“未知”,
|
|
6
|
+
* 不把“没发现”写成“没问题”。
|
|
7
|
+
*
|
|
8
|
+
* 分层约定(完整框架见 DETECTION.md):
|
|
9
|
+
* L0 不变量(本文件,确定性、毫秒级)
|
|
10
|
+
* L1 管道行为(脚本模型,确定性)
|
|
11
|
+
* L2 真实语义(真模型,冻结留出集,按需)
|
|
12
|
+
* L3 长期观测(遥测:捕获成功率、零产出天数、待审年龄)
|
|
13
|
+
* L4 安全与隐私(生成式对抗集 + 隐私覆盖)
|
|
14
|
+
* L5 性能与容量
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
18
|
+
import { createHash } from 'node:crypto'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
import { candidateSnapshot, withdrawalPolicy } from './candidates.mjs'
|
|
22
|
+
import { filterWithdrawn } from './withdrawal.mjs'
|
|
23
|
+
import { privateMemory } from './privacy.mjs'
|
|
24
|
+
import { VERSION } from './version.mjs'
|
|
25
|
+
import { SIMILARITY, TYPES, conflictSignature, isExpired, similarity, summaryOf } from './index.mjs'
|
|
26
|
+
|
|
27
|
+
const INDEX_LINE = /^- \[(user|feedback|project|reference)\] /
|
|
28
|
+
const TOPIC_REF = /\s—\s([A-Za-z0-9._-]+\.md)(?:#(.+))?$/
|
|
29
|
+
const SUMMARY_MAX = 200
|
|
30
|
+
const LIMITS = { indexMaxLines: 300, indexMaxBytes: 25 * 1024, storeMaxBytes: 2 * 1024 * 1024 }
|
|
31
|
+
const VERSION_TOKEN = /\bv\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?\b/g
|
|
32
|
+
|
|
33
|
+
/** 索引里的锚点是人写的,标题是带标点的;比较前统一折叠空白与标点。 */
|
|
34
|
+
function foldAnchor(text) {
|
|
35
|
+
return String(text ?? '')
|
|
36
|
+
.normalize('NFC')
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[\s\-_·、,。;:!?()()【】\[\]《》〈〉「」『』"'“”‘’/\\|]+/g, '')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readText(file) {
|
|
42
|
+
try {
|
|
43
|
+
return readFileSync(file, 'utf8')
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function active(records) {
|
|
50
|
+
return records.filter((record) => ['recorded', 'adopted'].includes(record.status))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function storeBytes(dir) {
|
|
54
|
+
let total = 0
|
|
55
|
+
const walk = (current, depth) => {
|
|
56
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
57
|
+
if (entry.name.startsWith('.')) continue
|
|
58
|
+
if (depth === 0 && entry.name === 'projects') continue
|
|
59
|
+
const full = join(current, entry.name)
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
walk(full, depth + 1)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (entry.isFile()) {
|
|
65
|
+
try {
|
|
66
|
+
total += statSync(full).size
|
|
67
|
+
} catch {
|
|
68
|
+
/* 读不到就不计 */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
walk(dir, 0)
|
|
75
|
+
} catch {
|
|
76
|
+
/* 目录不存在按 0 算 */
|
|
77
|
+
}
|
|
78
|
+
return total
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function bySeverity(findings, severity) {
|
|
82
|
+
return findings.filter((item) => item.severity === severity).length
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 检测单个库(全局库或项目库)。
|
|
87
|
+
* @param {string} dir - 库目录。
|
|
88
|
+
* @param {{scope?: 'global'|'project', now?: number, root?: string}} options
|
|
89
|
+
*/
|
|
90
|
+
export function detectStore(dir, options = {}) {
|
|
91
|
+
const scope = options.scope ?? 'global'
|
|
92
|
+
const now = options.now ?? Date.now()
|
|
93
|
+
const findings = []
|
|
94
|
+
const ref = (file, line) => (line === undefined ? { file } : { file, line })
|
|
95
|
+
const add = (id, severity, message, refs = [], fixHint) =>
|
|
96
|
+
findings.push({ id, severity, message, ...(refs.length ? { refs } : {}), ...(fixHint ? { fixHint } : {}) })
|
|
97
|
+
|
|
98
|
+
const indexText = readText(join(dir, 'MEMORY.md'))
|
|
99
|
+
const hasIndex = indexText !== undefined
|
|
100
|
+
const text = indexText ?? ''
|
|
101
|
+
const rawLines = text.replace(/\n$/, '').split(/\r?\n/)
|
|
102
|
+
const entries = []
|
|
103
|
+
rawLines.forEach((line, index) => {
|
|
104
|
+
if (line.trim().startsWith('- [')) entries.push({ line: line.trim(), lineNo: index + 1, raw: line })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// I1 索引行格式:类型、摘要长度、期限语法
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (!INDEX_LINE.test(entry.line)) {
|
|
110
|
+
add('I1-invalid-index-line', 'error', '索引行类型不合法,宿主与检索都不会把它当条目', [ref('MEMORY.md', entry.lineNo)], '改成 - [user|feedback|project|reference] 摘要 — 文件')
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (summaryOf(entry.line).length > SUMMARY_MAX) {
|
|
114
|
+
add('I1-summary-too-long', 'warning', `摘要超过 ${SUMMARY_MAX} 字(${summaryOf(entry.line).length})`, [ref('MEMORY.md', entry.lineNo)])
|
|
115
|
+
}
|
|
116
|
+
if (entry.line.includes('(exp:') && !/\(exp:\d{4}-\d{2}-\d{2}\)/.test(entry.line)) {
|
|
117
|
+
add('I1-invalid-expiry', 'error', '期限标记语法错误,过期判断会静默失效', [ref('MEMORY.md', entry.lineNo)])
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// I2 索引指向的正文与锚点必须存在
|
|
122
|
+
const topicCache = new Map()
|
|
123
|
+
const topicText = (name) => {
|
|
124
|
+
if (!topicCache.has(name)) topicCache.set(name, readText(join(dir, name)))
|
|
125
|
+
return topicCache.get(name)
|
|
126
|
+
}
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (/—\s*索引摘要(?: \(exp:\d{4}-\d{2}-\d{2}\))?$/.test(entry.line)) continue
|
|
129
|
+
const match = TOPIC_REF.exec(entry.line)
|
|
130
|
+
if (match === null) {
|
|
131
|
+
if (!argLooksLegacy(entry.line)) {
|
|
132
|
+
add('I2-unresolvable-topic-ref', 'warning', '索引行既没有正文引用,也没有“索引摘要”标记', [ref('MEMORY.md', entry.lineNo)])
|
|
133
|
+
}
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
const file = match[1]
|
|
137
|
+
const topic = topicText(file)
|
|
138
|
+
if (topic === undefined) {
|
|
139
|
+
add('I2-missing-topic-file', 'error', `索引指向的 ${file} 不存在`, [ref('MEMORY.md', entry.lineNo)], '补回主题文件或改成“— 索引摘要”')
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if (match[2] !== undefined) {
|
|
143
|
+
const wanted = foldAnchor(match[2])
|
|
144
|
+
const headings = [...topic.matchAll(/^#{2,6}\s*(.+)$/gm)].map((heading) => foldAnchor(heading[1]))
|
|
145
|
+
if (!headings.some((heading) => heading === wanted || heading.includes(wanted) || wanted.includes(heading))) {
|
|
146
|
+
add('I2-missing-anchor', 'warning', `锚点在 ${file} 中找不到:${match[2]}`, [ref('MEMORY.md', entry.lineNo)], '改锚点或补标题')
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 账本
|
|
152
|
+
let snapshot = null
|
|
153
|
+
try {
|
|
154
|
+
snapshot = candidateSnapshot(dir, scope)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// 项目库可能以另一作用域登记;只报告,不猜。
|
|
157
|
+
add('I0-snapshot-error', 'error', `候选账本不可读:${error.message}`)
|
|
158
|
+
}
|
|
159
|
+
const records = snapshot?.records ?? []
|
|
160
|
+
const current = active(records)
|
|
161
|
+
const pending = records.filter((record) => record.status === 'pending')
|
|
162
|
+
|
|
163
|
+
// I3 有效记录必须出现在索引里(投影不丢条目)
|
|
164
|
+
for (const record of current) {
|
|
165
|
+
const line = (record.indexLine ?? `- [${record.type}] ${record.summary}`).trim()
|
|
166
|
+
if (!entries.some((entry) => entry.line === line)) {
|
|
167
|
+
add('I3-record-missing-from-index', 'error', `有效记录未投影到索引:${record.id}`, [], '从账本重建索引,或确认该记录已过期')
|
|
168
|
+
}
|
|
169
|
+
if (record.detail !== undefined) {
|
|
170
|
+
const topic = topicText(`${record.type}.md`)
|
|
171
|
+
if (topic === undefined) {
|
|
172
|
+
add('I3-detail-topic-missing', 'error', `记录 ${record.id} 的正文文件 ${record.type}.md 不存在`)
|
|
173
|
+
} else if (!topic.includes(`[candidate:${record.id}]\n${record.detail}`)) {
|
|
174
|
+
add('I3-detail-mismatch', 'error', `记录 ${record.id} 的正文与账本不一致(索引会指向错误内容)`, [ref(`${record.type}.md`)], '以账本为准重建正文块')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// I4 索引行是否有账本依据(手工条目允许,但必须可见)
|
|
180
|
+
const knownLines = new Set(current.map((record) => (record.indexLine ?? `- [${record.type}] ${record.summary}`).trim()))
|
|
181
|
+
let unclassified = 0
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
if (!knownLines.has(entry.line)) unclassified += 1
|
|
184
|
+
}
|
|
185
|
+
if (unclassified > 0) {
|
|
186
|
+
add('I4-index-without-ledger', 'info', `${unclassified} 条索引没有对应账本记录(手工或旧格式,不能当已审查证据)`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// I5 撤回必须传播到索引
|
|
190
|
+
try {
|
|
191
|
+
const policy = withdrawalPolicy(dir)
|
|
192
|
+
if (filterWithdrawn(text, policy, true) !== text) {
|
|
193
|
+
add('I5-withdrawal-residue', 'error', '索引里仍有已撤回内容', [], '重新发布撤回或修复投影')
|
|
194
|
+
}
|
|
195
|
+
} catch (error) {
|
|
196
|
+
add('I5-withdrawal-unreadable', 'warning', `撤回策略不可读:${error.message}`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// I6 重复与近重复
|
|
200
|
+
const seen = new Map()
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
const key = entry.line
|
|
203
|
+
seen.set(key, (seen.get(key) ?? 0) + 1)
|
|
204
|
+
}
|
|
205
|
+
for (const [line, count] of seen) if (count > 1) add('I6-duplicate-line', 'warning', `完全重复的索引行 ×${count}:${line.slice(0, 60)}`)
|
|
206
|
+
// 相似度对是 O(n²):索引上限 300 行时约 4.5 万次比较,可接受;
|
|
207
|
+
// 超过 400 行只比前 400 条,并显式说明,避免把检测器变成慢查询。
|
|
208
|
+
const PAIR_LIMIT = 400
|
|
209
|
+
if (entries.length > PAIR_LIMIT) {
|
|
210
|
+
add('I6-similarity-truncated', 'info', `索引 ${entries.length} 行,相似度比对只覆盖前 ${PAIR_LIMIT} 条(全量重复检查仍覆盖全部)`)
|
|
211
|
+
}
|
|
212
|
+
const pairEntries = entries.slice(0, PAIR_LIMIT)
|
|
213
|
+
for (let i = 0; i < pairEntries.length; i += 1) {
|
|
214
|
+
for (let j = i + 1; j < pairEntries.length; j += 1) {
|
|
215
|
+
const score = similarity(summaryOf(pairEntries[i].line), summaryOf(pairEntries[j].line))
|
|
216
|
+
if (score >= SIMILARITY.reject) {
|
|
217
|
+
add('I6-near-duplicate', 'warning', `疑似重复(相似度 ${score.toFixed(2)}):MEMORY.md:${pairEntries[i].lineNo} 与 :${pairEntries[j].lineNo}`, [], '用 supersedes 合并,不要并排堆两条')
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// I7 冲突签名:同一属性的不同取值同时有效
|
|
223
|
+
const signatures = new Map()
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
const sig = conflictSignature(summaryOf(entry.line))
|
|
226
|
+
if (sig.values.length === 0) continue
|
|
227
|
+
if (!signatures.has(sig.signature)) signatures.set(sig.signature, [])
|
|
228
|
+
signatures.get(sig.signature).push(entry)
|
|
229
|
+
}
|
|
230
|
+
for (const [signature, group] of signatures) {
|
|
231
|
+
if (group.length > 1) {
|
|
232
|
+
add('I7-conflict', 'warning', `同一属性有 ${group.length} 个并存取值(${signature.slice(0, 40)}):` + group.map((entry) => `MEMORY.md:${entry.lineNo}`).join('、'), [], '确认哪个现行,用 supersedes 收敛')
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// I8 预算
|
|
237
|
+
const indexBytes = Buffer.byteLength(text, 'utf8')
|
|
238
|
+
if (entries.length > LIMITS.indexMaxLines) add('I8-budget-lines', 'error', `索引 ${entries.length} 行,超过 ${LIMITS.indexMaxLines} 行上限`, [], '运行 memory_prune 或把细节移到主题文件')
|
|
239
|
+
if (indexBytes > LIMITS.indexMaxBytes) add('I8-budget-bytes', 'error', `索引 ${indexBytes} 字节,超过 ${LIMITS.indexMaxBytes} 上限`)
|
|
240
|
+
const bytes = storeBytes(dir)
|
|
241
|
+
if (bytes > LIMITS.storeMaxBytes) add('I8-budget-store', 'error', `库占用 ${bytes} 字节,超过 ${LIMITS.storeMaxBytes} 上限`)
|
|
242
|
+
|
|
243
|
+
// I9 作用域
|
|
244
|
+
for (const record of current) {
|
|
245
|
+
if (record.scope !== scope) add('I9-scope-mismatch', 'error', `记录 ${record.id} 的作用域是 ${record.scope},不应出现在 ${scope} 库`)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// I10 版本/规则漂移:只能提示,不能自动判定哪边是真相
|
|
249
|
+
const versions = [...new Set((text.match(VERSION_TOKEN) ?? []).map((token) => token.replace(/^v/, '')))]
|
|
250
|
+
const stale = versions.filter((token) => token !== VERSION)
|
|
251
|
+
if (stale.length > 0) add('I10-version-mention', 'info', `索引提到与当前版本(${VERSION})不同的版本号:${stale.join('、')}`, [], '确认是否已过时,过时就 supersedes')
|
|
252
|
+
|
|
253
|
+
// I11 隐私:只报告暴露面,不做“已保护”的假承诺
|
|
254
|
+
const privateRecords = current.filter((record) => privateMemory(record))
|
|
255
|
+
if (privateRecords.length > 0) add('I11-private-records', 'info', `${privateRecords.length} 条记录按隐私规则只在按需检索时返回;主题文件与账本仍是明文`)
|
|
256
|
+
|
|
257
|
+
// I12 历史账本
|
|
258
|
+
if (entries.length > 0 && !existsSync(join(dir, '.episodes.json'))) {
|
|
259
|
+
add('I12-no-history-ledger', 'info', '有索引但没有历史账本,无法回答“当时怎么记的”')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 主题文件反向检查:正文块必须有索引行
|
|
263
|
+
const anchorsByFile = new Map()
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
const match = TOPIC_REF.exec(entry.line)
|
|
266
|
+
if (match === null) continue
|
|
267
|
+
if (!anchorsByFile.has(match[1])) anchorsByFile.set(match[1], new Set())
|
|
268
|
+
if (match[2] !== undefined) anchorsByFile.get(match[1]).add(foldAnchor(match[2]))
|
|
269
|
+
}
|
|
270
|
+
for (const type of TYPES) {
|
|
271
|
+
const file = `${type}.md`
|
|
272
|
+
const topic = topicText(file)
|
|
273
|
+
if (topic === undefined) continue
|
|
274
|
+
for (const match of topic.matchAll(/\[candidate:([0-9a-f]{8,64})\]/g)) {
|
|
275
|
+
const id = match[1]
|
|
276
|
+
const record = records.find((item) => item.id === id)
|
|
277
|
+
if (record === undefined) {
|
|
278
|
+
add('I13-detail-without-record', 'warning', `正文块 ${id} 在账本里不存在(孤立正文)`, [ref(file)])
|
|
279
|
+
} else if (!['recorded', 'adopted'].includes(record.status)) {
|
|
280
|
+
add('I13-detail-by-inactive-record', 'warning', `正文块 ${id} 对应的记录状态是 ${record.status}`, [ref(file)])
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// I14 结构:正文块必须在某个 ## 标题之下;文件末行不能是半句。
|
|
284
|
+
const lines = topic.split(/\r?\n/)
|
|
285
|
+
let sawHeading = false
|
|
286
|
+
lines.forEach((line, index) => {
|
|
287
|
+
if (/^#{2,6}\s+/.test(line)) sawHeading = true
|
|
288
|
+
else if (line.includes('[candidate:') && !sawHeading) {
|
|
289
|
+
add('I14-candidate-without-heading', 'warning', '正文块出现在任何 ## 标题之前,按标题读取时会被漏掉', [ref(file, index + 1)])
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
lines.forEach((line, index) => {
|
|
293
|
+
const trimmed = line.trim()
|
|
294
|
+
if (trimmed.length === 0 || trimmed.startsWith('#')) return
|
|
295
|
+
const next = lines[index + 1]
|
|
296
|
+
const isParagraphEnd = next === undefined || next.trim().length === 0
|
|
297
|
+
if (!isParagraphEnd) return
|
|
298
|
+
if (!/[,、:;,;]$/.test(trimmed)) return
|
|
299
|
+
const isBullet = /^([-*]|\d+\.)\s/.test(trimmed)
|
|
300
|
+
// 非列表行以冒号结尾、下一段是列表:这是引导句,不是截断。
|
|
301
|
+
const nextNonEmpty = lines.slice(index + 1).find((item) => item.trim().length > 0)
|
|
302
|
+
const leadIn = !isBullet && /[::]$/.test(trimmed) && (nextNonEmpty?.trim().startsWith('- ') ?? false)
|
|
303
|
+
if (leadIn) return
|
|
304
|
+
add('I14-truncated-paragraph', 'warning', `段落以未完成标点结尾,像被截断:${trimmed.slice(-40)}`, [ref(file, index + 1)])
|
|
305
|
+
})
|
|
306
|
+
// I15 标题与索引锚点的对应:只有该文件已经使用锚点引用时才检查,避免把旧格式误报。
|
|
307
|
+
const used = anchorsByFile.get(file)
|
|
308
|
+
if (used !== undefined && used.size > 0) {
|
|
309
|
+
for (const match of topic.matchAll(/^#{2,6}\s*(.+)$/gm)) {
|
|
310
|
+
const heading = foldAnchor(match[1])
|
|
311
|
+
if (heading.length === 0) continue
|
|
312
|
+
if (![...used].some((anchor) => anchor === heading || heading.includes(anchor) || anchor.includes(heading))) {
|
|
313
|
+
add('I15-heading-without-index', 'info', `标题没有索引指向:${match[1].trim().slice(0, 40)}`, [ref(file)])
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
kind: 'store',
|
|
321
|
+
version: VERSION,
|
|
322
|
+
dir,
|
|
323
|
+
scope,
|
|
324
|
+
generatedAt: new Date().toISOString(),
|
|
325
|
+
summary: { errors: bySeverity(findings, 'error'), warnings: bySeverity(findings, 'warning'), infos: bySeverity(findings, 'info') },
|
|
326
|
+
metrics: {
|
|
327
|
+
indexExists: hasIndex,
|
|
328
|
+
indexLines: entries.length,
|
|
329
|
+
indexBytes,
|
|
330
|
+
limitLines: LIMITS.indexMaxLines,
|
|
331
|
+
limitBytes: LIMITS.indexMaxBytes,
|
|
332
|
+
storeBytes: bytes,
|
|
333
|
+
storeLimitBytes: LIMITS.storeMaxBytes,
|
|
334
|
+
activeRecords: current.length,
|
|
335
|
+
pendingRecords: pending.length,
|
|
336
|
+
privateRecords: privateRecords.length,
|
|
337
|
+
unclassifiedIndexLines: unclassified,
|
|
338
|
+
expiredIndexLines: entries.filter((entry) => isExpired(entry.line, now)).length,
|
|
339
|
+
},
|
|
340
|
+
findings,
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** 旧格式索引行常见形态:以“— 文件名”之外的破折号结尾,例如手工补的说明。 */
|
|
345
|
+
function argLooksLegacy(line) {
|
|
346
|
+
return /—\s*[^\s].*[,。;,;]$/.test(line) || !line.includes('—')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function hashPolicy(dir) {
|
|
350
|
+
const parts = []
|
|
351
|
+
for (const name of ['MEMORY.md', ...TYPES.map((type) => `${type}.md`)]) {
|
|
352
|
+
const text = readText(join(dir, name))
|
|
353
|
+
if (text !== undefined) parts.push(`${name}\n${text}`)
|
|
354
|
+
}
|
|
355
|
+
return createHash('sha256').update(parts.join('\n')).digest('hex')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* 检测整棵记忆根目录:全局库 + 各项目库,并做跨库检查。
|
|
360
|
+
* @param {string} root - 记忆根(通常是 ~/.dsh/memory)。
|
|
361
|
+
*/
|
|
362
|
+
export function detectRoot(root, options = {}) {
|
|
363
|
+
const stores = []
|
|
364
|
+
const globalReport = detectStore(root, { ...options, scope: 'global' })
|
|
365
|
+
stores.push(globalReport)
|
|
366
|
+
|
|
367
|
+
const projectsDir = join(root, 'projects')
|
|
368
|
+
let names = []
|
|
369
|
+
try {
|
|
370
|
+
names = readdirSync(projectsDir, { withFileTypes: true })
|
|
371
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
372
|
+
.map((entry) => entry.name)
|
|
373
|
+
.sort()
|
|
374
|
+
} catch {
|
|
375
|
+
names = []
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const crossFindings = []
|
|
379
|
+
const hashes = new Map()
|
|
380
|
+
const hashStore = (dir, name) => {
|
|
381
|
+
const digest = hashPolicy(dir)
|
|
382
|
+
if (!hashes.has(digest)) hashes.set(digest, [])
|
|
383
|
+
hashes.get(digest).push(name)
|
|
384
|
+
}
|
|
385
|
+
if (globalReport.metrics.indexLines > 0) hashStore(root, 'global')
|
|
386
|
+
const emptyStores = []
|
|
387
|
+
const legacyNamed = []
|
|
388
|
+
for (const name of names) {
|
|
389
|
+
const dir = join(projectsDir, name)
|
|
390
|
+
let scope = 'project'
|
|
391
|
+
let report
|
|
392
|
+
try {
|
|
393
|
+
report = detectStore(dir, { ...options, scope })
|
|
394
|
+
} catch (error) {
|
|
395
|
+
report = {
|
|
396
|
+
kind: 'store', version: VERSION, dir, scope, generatedAt: new Date().toISOString(),
|
|
397
|
+
summary: { errors: 1, warnings: 0, infos: 0 }, metrics: null,
|
|
398
|
+
findings: [{ id: 'I0-detect-error', severity: 'error', message: error.message }],
|
|
399
|
+
}
|
|
400
|
+
report.findings[0].refs = []
|
|
401
|
+
}
|
|
402
|
+
stores.push(report)
|
|
403
|
+
if (report.metrics && report.metrics.indexLines === 0 && report.metrics.activeRecords === 0) emptyStores.push(name)
|
|
404
|
+
if (report.metrics && report.metrics.indexLines === 0 && report.metrics.activeRecords === 0) legacyNamed.push(name)
|
|
405
|
+
if (report.metrics.indexLines > 0) hashStore(dir, name)
|
|
406
|
+
}
|
|
407
|
+
for (const name of emptyStores) {
|
|
408
|
+
crossFindings.push({ id: 'X1-empty-project-store', severity: 'info', message: `空项目库:${name}`, refs: [] })
|
|
409
|
+
}
|
|
410
|
+
for (const [digest, namesWithSame] of hashes) {
|
|
411
|
+
if (namesWithSame.length > 1) {
|
|
412
|
+
crossFindings.push({ id: 'X2-duplicate-store', severity: 'warning', message: `内容完全相同的库:${namesWithSame.join('、')}(sha256 ${digest.slice(0, 12)}…)`, refs: [] })
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (names.length > 0) {
|
|
416
|
+
// 项目库数量与真实工作区数量不必相等,但空库/测试夹具必须显式可见。
|
|
417
|
+
crossFindings.push({ id: 'X3-project-store-count', severity: 'info', message: `项目库 ${names.length} 个,其中空库 ${emptyStores.length} 个`, refs: [] })
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const totals = stores.reduce(
|
|
421
|
+
(acc, store) => ({
|
|
422
|
+
errors: acc.errors + store.summary.errors,
|
|
423
|
+
warnings: acc.warnings + store.summary.warnings,
|
|
424
|
+
infos: acc.infos + store.summary.infos,
|
|
425
|
+
}),
|
|
426
|
+
{ errors: 0, warnings: 0, infos: 0 },
|
|
427
|
+
)
|
|
428
|
+
for (const finding of crossFindings) totals[finding.severity === 'error' ? 'errors' : finding.severity === 'warning' ? 'warnings' : 'infos'] += 1
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
kind: 'root',
|
|
432
|
+
version: VERSION,
|
|
433
|
+
root,
|
|
434
|
+
generatedAt: new Date().toISOString(),
|
|
435
|
+
summary: totals,
|
|
436
|
+
stores,
|
|
437
|
+
crossFindings,
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export { LIMITS as DETECT_LIMITS }
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 对比实验:L0 检测器 vs 人工审计基线 + 各门禁环节的耗时。
|
|
4
|
+
*
|
|
5
|
+
* 基线来自 2026-11-08 的人工只读审计(data-audit 报告)。每一条都标了归属层:
|
|
6
|
+
* detect = 应由 L0 检测器抓到;blind-spot = 已知 L0 抓不到(语义/跨措辞);
|
|
7
|
+
* telemetry = 属于 L3;doc = 文档/引用一致性;accepted = 设计上允许。
|
|
8
|
+
*
|
|
9
|
+
* node eval/bench-detect.mjs # 人类可读
|
|
10
|
+
* node eval/bench-detect.mjs --json # 机器可读
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
|
|
17
|
+
import { collect as collectTelemetry } from '../scripts/telemetry.mjs'
|
|
18
|
+
import { detectRoot, detectStore } from '../detect.mjs'
|
|
19
|
+
|
|
20
|
+
const ROOT = process.env.SOLOPUP_MEMORY_ROOT ?? process.env.DSH_HOME ?? `${process.env.HOME}/.dsh`
|
|
21
|
+
const MEMORY_ROOT = ROOT.endsWith('memory') ? ROOT : `${ROOT}/memory`
|
|
22
|
+
|
|
23
|
+
export const BASELINE = [
|
|
24
|
+
{ id: 'duplicate-popscriptH-store', layer: 'detect', find: (report) => report.crossFindings.some((row) => row.id === 'X2-duplicate-store') },
|
|
25
|
+
{ id: 'fixture-stores-duplicated', layer: 'accepted', find: () => false },
|
|
26
|
+
{ id: 'empty-project-stores', layer: 'detect', find: (report) => report.crossFindings.some((row) => row.id === 'X1-empty-project-store') },
|
|
27
|
+
{ id: 'stale-version-v0.2.0', layer: 'detect', find: (report) => report.stores.some((store) => store.findings.some((row) => row.id === 'I10-version-mention')) },
|
|
28
|
+
{ id: 'truncated-paragraph-user-md', layer: 'detect', find: (report) => report.stores.some((store) => store.findings.some((row) => row.id === 'I14-truncated-paragraph' && row.refs?.[0]?.file === 'user.md')) },
|
|
29
|
+
{ id: 'orphan-heading-without-index', layer: 'detect', find: (report) => report.stores.some((store) => store.findings.some((row) => row.id === 'I15-heading-without-index')) },
|
|
30
|
+
{ id: 'private-plaintext-exposure', layer: 'detect', find: (report) => report.stores.some((store) => store.findings.some((row) => row.id === 'I11-private-records')) },
|
|
31
|
+
{ id: 'numeric-contradiction-5/5-vs-6/6', layer: 'blind-spot', find: () => false },
|
|
32
|
+
{ id: 'rule-conflict-100-vs-200-lines', layer: 'blind-spot', find: () => false },
|
|
33
|
+
{ id: 'pending-candidate-8-days', layer: 'telemetry', find: () => false },
|
|
34
|
+
{ id: 'state-field-lastCaptureAt-missing', layer: 'doc', find: () => false },
|
|
35
|
+
{ id: 'index-summary-without-detail', layer: 'accepted', find: () => false },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
function bench(fn, rounds = 20) {
|
|
39
|
+
const times = []
|
|
40
|
+
for (let index = 0; index < rounds; index += 1) {
|
|
41
|
+
const started = performance.now()
|
|
42
|
+
fn()
|
|
43
|
+
times.push(performance.now() - started)
|
|
44
|
+
}
|
|
45
|
+
times.sort((left, right) => left - right)
|
|
46
|
+
return { medianMs: Number(times[Math.floor(times.length / 2)].toFixed(2)), minMs: Number(times[0].toFixed(2)), maxMs: Number(times.at(-1).toFixed(2)) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function syntheticStore(entries) {
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-scale-'))
|
|
51
|
+
const lines = ['# 索引', '', '## project', '']
|
|
52
|
+
for (let index = 0; index < entries; index += 1) lines.push(`- [project] 规模测试条目 ${index} 编号 ${index} — project.md`)
|
|
53
|
+
writeFileSync(join(dir, 'MEMORY.md'), lines.join('\n') + '\n')
|
|
54
|
+
writeFileSync(join(dir, 'project.md'), '# project\n')
|
|
55
|
+
return dir
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function run() {
|
|
59
|
+
const report = detectRoot(MEMORY_ROOT)
|
|
60
|
+
const findings = report.stores.flatMap((store) => store.findings.map((row) => ({ store: store.scope, ...row })))
|
|
61
|
+
const rows = BASELINE.map((item) => ({ id: item.id, layer: item.layer, detected: item.find(report) }))
|
|
62
|
+
const detectable = rows.filter((row) => row.layer === 'detect')
|
|
63
|
+
const detected = detectable.filter((row) => row.detected)
|
|
64
|
+
return {
|
|
65
|
+
root: MEMORY_ROOT,
|
|
66
|
+
generatedAt: new Date().toISOString(),
|
|
67
|
+
recall: {
|
|
68
|
+
detectable: detectable.length,
|
|
69
|
+
detected: detected.length,
|
|
70
|
+
rate: detected.length / detectable.length,
|
|
71
|
+
missed: detectable.filter((row) => !row.detected).map((row) => row.id),
|
|
72
|
+
},
|
|
73
|
+
blindSpots: rows.filter((row) => row.layer === 'blind-spot').map((row) => row.id),
|
|
74
|
+
otherLayers: rows.filter((row) => ['telemetry', 'doc', 'accepted'].includes(row.layer)).map((row) => ({ id: row.id, layer: row.layer })),
|
|
75
|
+
detector: { findings: findings.length, errors: report.summary.errors, warnings: report.summary.warnings, infos: report.summary.infos, ...bench(() => detectRoot(MEMORY_ROOT)) },
|
|
76
|
+
telemetry: { ...bench(() => collectTelemetry(MEMORY_ROOT)) },
|
|
77
|
+
scale: (() => {
|
|
78
|
+
const rooms = { 300: syntheticStore(300), 1000: syntheticStore(1000) }
|
|
79
|
+
try {
|
|
80
|
+
return {
|
|
81
|
+
300: { ...bench(() => detectStore(rooms[300], { scope: 'project' }), 10) },
|
|
82
|
+
1000: { ...bench(() => detectStore(rooms[1000], { scope: 'project' }), 5) },
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
for (const dir of Object.values(rooms)) rmSync(dir, { recursive: true, force: true })
|
|
86
|
+
}
|
|
87
|
+
})(),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (process.argv[1] !== undefined && import.meta.url === `file://${process.argv[1]}`) {
|
|
92
|
+
const result = run()
|
|
93
|
+
if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result, null, 2) + '\n')
|
|
94
|
+
else {
|
|
95
|
+
const lines = [
|
|
96
|
+
`对比实验 · ${result.generatedAt}`,
|
|
97
|
+
`根目录:${result.root}`,
|
|
98
|
+
`检测器召回(只算“应由 L0 抓”的项):${result.recall.detected}/${result.recall.detectable} = ${(result.recall.rate * 100).toFixed(0)}%${result.recall.missed.length ? ';漏:' + result.recall.missed.join('、') : ''}`,
|
|
99
|
+
`已知盲区(不冒充已覆盖):${result.blindSpots.join('、') || '无'}`,
|
|
100
|
+
`其他层:${result.otherLayers.map((row) => `${row.id}[${row.layer}]`).join('、') || '无'}`,
|
|
101
|
+
`L0 检测耗时(20 次):中位 ${result.detector.medianMs}ms,范围 ${result.detector.minMs}–${result.detector.maxMs}ms;存储级 findings ${result.detector.findings} 条(error ${result.detector.errors} / warning ${result.detector.warnings} / info ${result.detector.infos})`,
|
|
102
|
+
`L3 遥测耗时(20 次):中位 ${result.telemetry.medianMs}ms,范围 ${result.telemetry.minMs}–${result.telemetry.maxMs}ms`,
|
|
103
|
+
`规模对照:300 行索引中位 ${result.scale[300].medianMs}ms;1000 行中位 ${result.scale[1000].medianMs}ms(相似度对上限 400)`,
|
|
104
|
+
]
|
|
105
|
+
process.stdout.write(lines.join('\n') + '\n')
|
|
106
|
+
}
|
|
107
|
+
}
|