solopup-memory 0.21.0-beta.2 → 0.22.0-beta.3
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 +37 -0
- package/COMPATIBILITY.md +8 -5
- package/DETECTION.md +139 -0
- package/DREAMING-DESIGN.md +167 -0
- package/README.en.md +35 -6
- package/README.md +44 -7
- package/RELEASE.md +36 -0
- package/SECURITY.md +11 -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 +117 -27
- package/managed-policy.mjs +3 -1
- package/package.json +91 -68
- package/privacy.mjs +25 -0
- 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/security-check.mjs +173 -0
- package/scripts/soak.mjs +219 -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} +10 -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/test-privacy.mjs +65 -0
- package/{test-project-stores.mjs → test/test-project-stores.mjs} +5 -5
- 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/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
|
+
}
|