@yolk_vat-y/dsh-project-memory 0.5.13 → 0.5.14
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 +126 -0
- package/README.md +5 -5
- package/README.zh-CN.md +5 -5
- package/client/client.js +120 -52
- package/client/client.js.map +1 -1
- package/package.json +8 -8
- package/src/audit.js +52 -3
- package/src/auto-inject.js +64 -16
- package/src/client/ShowTaskPanelNode.tsx +51 -0
- package/src/client/TaskPanel.tsx +8 -8
- package/src/client/client.ts +10 -31
- package/src/client/slash.ts +33 -17
- package/src/client/task-ui-store.ts +43 -0
- package/src/index.js +8 -3
- package/src/insight-store.js +2 -17
- package/src/readiness.js +26 -1
- package/src/store.js +8 -22
- package/src/tools/forget.js +1 -1
- package/src/tools/lesson-tools.js +1 -0
- package/src/tools/remember.js +5 -3
- package/src/tools/task-tools.js +8 -7
- package/src/tools/watch-repo.js +1 -1
- package/src/util/fs.js +33 -1
package/src/index.js
CHANGED
|
@@ -106,8 +106,11 @@ export const Config = Schema.object({
|
|
|
106
106
|
// Schema 里声明过:走 cordis.patch.yml 配它们会被宿主按「not a declared property」拒掉,
|
|
107
107
|
// 等于文档里的旋钮是假的。默认值以 cfgEngine 的兜底值为准(那里是权威,这里只负责暴露)。
|
|
108
108
|
gateCooldownSteps: Schema.number().default(2),
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
// 这两个是**保险丝**不是节流阀:节流由 gateCooldownSteps 与单轮 maxTokens 负责。
|
|
110
|
+
// 旧默认 12 / 4000 ≈ 11 条,实测真实日志里 11/81 个会话打满、打满后永久静默(1234 步失明);
|
|
111
|
+
// 实测最长会话用 35 条 / 8640 字符,故抬到 60 / 24000。显式配小值可复现旧行为。
|
|
112
|
+
maxItemsPerSession: Schema.number().default(60),
|
|
113
|
+
maxItemCharsPerSession: Schema.number().default(24000),
|
|
111
114
|
hintMinCoverage: Schema.number().default(0.45),
|
|
112
115
|
hintMinMatched: Schema.number().default(2),
|
|
113
116
|
hintMinSupport: Schema.number().default(0.15),
|
|
@@ -117,8 +120,10 @@ export const Config = Schema.object({
|
|
|
117
120
|
// 影子记录(admission-shadow.jsonl):**每步**一行,含全部候选的判据特征与场景。
|
|
118
121
|
// 主审计只在真的注入时写,静默步零痕迹 → 无法离线重放"换个阈值会怎样",也攒不出样本。
|
|
119
122
|
// 只写盘、不进 prompt、不花 token,所以默认开。
|
|
123
|
+
// 512KB 而不是 2MB:轮转只留 `.1` 一代,所以单项目日志的硬上限是
|
|
124
|
+
// `2 × shadowMaxBytes + 2 × auditMaxBytes`(原 4.6MB);512KB 下约 1.5MB。
|
|
120
125
|
shadowLog: Schema.boolean().default(true),
|
|
121
|
-
shadowMaxBytes: Schema.number().default(
|
|
126
|
+
shadowMaxBytes: Schema.number().default(524288),
|
|
122
127
|
}).default({}),
|
|
123
128
|
})
|
|
124
129
|
|
package/src/insight-store.js
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
// - 归档 = 软删(archived:true,不进召回/注入);物理删除只发生在容量溢出
|
|
7
7
|
// - 写盘前密文过滤:命中密钥/token/私钥形态 → 拒绝写入
|
|
8
8
|
import { randomUUID } from 'node:crypto'
|
|
9
|
-
import {
|
|
9
|
+
import { readFileSync, renameSync } from 'node:fs'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import os from 'node:os'
|
|
12
12
|
import { normalizedTokenOverlap, findBestOverlapMatch, insightMatchText } from './similarity.js'
|
|
13
|
+
import { writeJsonAtomic } from './util/fs.js'
|
|
13
14
|
import { backfillDerivedTriggers } from './readiness.js'
|
|
14
15
|
|
|
15
16
|
export const INSIGHT_KINDS = ['lesson', 'decision', 'procedure', 'experience']
|
|
@@ -332,22 +333,6 @@ function readJson(file, fallback) {
|
|
|
332
333
|
}
|
|
333
334
|
}
|
|
334
335
|
|
|
335
|
-
function writeJsonAtomic(file, data) {
|
|
336
|
-
mkdirSync(path.dirname(file), { recursive: true })
|
|
337
|
-
const tmp = `${file}.${process.pid}.tmp`
|
|
338
|
-
try {
|
|
339
|
-
writeFileSync(tmp, JSON.stringify(data))
|
|
340
|
-
renameSync(tmp, file)
|
|
341
|
-
} catch (err) {
|
|
342
|
-
try {
|
|
343
|
-
renameSync(tmp, file) // fsync 无关紧要:单文件覆盖 + 崩溃后 .tmp 清理即可
|
|
344
|
-
} catch {
|
|
345
|
-
/* ignore */
|
|
346
|
-
}
|
|
347
|
-
throw err
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
336
|
function normalizeDoc(doc) {
|
|
352
337
|
if (doc && typeof doc === 'object' && Array.isArray(doc.items)) {
|
|
353
338
|
if (typeof doc.version !== 'number') doc.version = 1
|
package/src/readiness.js
CHANGED
|
@@ -447,10 +447,35 @@ export function auditTriggers(items, opts = {}) {
|
|
|
447
447
|
* @returns {{coverage: number, matched: number, supported: number, terms: number}}
|
|
448
448
|
*/
|
|
449
449
|
export function idfCoverage(query, itemText, corpusTexts) {
|
|
450
|
+
return idfCoveragePrepared(query, itemText, corpusTermSets(corpusTexts))
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* 语料 → 词集合数组。**对一批候选循环评分时必须只建一次**。
|
|
455
|
+
*
|
|
456
|
+
* `idfCoverage(query, item, corpusTexts)` 每次都把整个语料重新 tokenize 一遍;调用方对 C 个
|
|
457
|
+
* 候选各调一次,就是 C×C 次 tokenize —— 实测真实 store(72 条)单步 147 ms,提出循环外
|
|
458
|
+
* 降到 1.3 ms(109×),且 coverage 结果逐字段一致。热路径上每个 pre-step 都要跑一次,所以
|
|
459
|
+
* 这个差别是"每条模型消息"级别的。
|
|
460
|
+
* @param {string[]} texts - 语料正文
|
|
461
|
+
* @returns {Set<string>[]} 与输入同序的词集合
|
|
462
|
+
*/
|
|
463
|
+
export function corpusTermSets(texts) {
|
|
464
|
+
return (texts || []).map((t) => new Set(tokenize(t)))
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 与 {@link idfCoverage} 同一套判据,但语料词集合由调用方预先建好(见 {@link corpusTermSets})。
|
|
469
|
+
* @param {string} query - 查询文本
|
|
470
|
+
* @param {string} itemText - 被测条目的评分文本
|
|
471
|
+
* @param {Set<string>[]} corpusSets - {@link corpusTermSets} 的产物
|
|
472
|
+
* @returns {{coverage: number, matched: number, supported: number, terms: number}}
|
|
473
|
+
*/
|
|
474
|
+
export function idfCoveragePrepared(query, itemText, corpusSets) {
|
|
450
475
|
const q = [...new Set(tokenize(query))]
|
|
451
476
|
if (!q.length) return { coverage: 0, matched: 0, supported: 0, terms: 0 }
|
|
452
477
|
const item = new Set(tokenize(itemText))
|
|
453
|
-
const corpus =
|
|
478
|
+
const corpus = corpusSets || []
|
|
454
479
|
const n = Math.max(corpus.length, 1)
|
|
455
480
|
let total = 0
|
|
456
481
|
let hit = 0
|
package/src/store.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'
|
|
|
2
2
|
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { rankExperience, tokenize, tokenizeRaw, extractCjkPhrases, makeSearchText } from './util/search.js'
|
|
5
|
+
import { writeJsonAtomic } from './util/fs.js'
|
|
5
6
|
import { backfillDerivedTriggers } from './readiness.js'
|
|
6
7
|
|
|
7
8
|
const FORMAT_FILE = 'format.json'
|
|
@@ -188,21 +189,6 @@ function loadJson(filePath, fallback) {
|
|
|
188
189
|
}
|
|
189
190
|
}
|
|
190
191
|
|
|
191
|
-
function writeJsonAtomic(filePath, data) {
|
|
192
|
-
const tmp = `${filePath}.${process.pid}.tmp`
|
|
193
|
-
try {
|
|
194
|
-
writeFileSync(tmp, JSON.stringify(data))
|
|
195
|
-
renameSync(tmp, filePath)
|
|
196
|
-
} catch (err) {
|
|
197
|
-
try {
|
|
198
|
-
unlinkSync(tmp)
|
|
199
|
-
} catch {
|
|
200
|
-
// tmp already gone (rename succeeded) or undeletable; nothing to do
|
|
201
|
-
}
|
|
202
|
-
throw err
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
192
|
function shardRelPath(dir, rel) {
|
|
207
193
|
return path.join(dir, SHARDS_DIR, createHash('sha256').update(rel).digest('hex') + '.json')
|
|
208
194
|
}
|
|
@@ -315,8 +301,8 @@ export class ProjectMemoryStore {
|
|
|
315
301
|
`[dsh-project-memory] migration dropped ${orphans.length} entry group(s) with no index record: ${orphans.slice(0, 3).join(', ')}${orphans.length > 3 ? ' …' : ''}`,
|
|
316
302
|
)
|
|
317
303
|
}
|
|
318
|
-
mkdirSync(path.join(this.dir, SHARDS_DIR), { recursive: true })
|
|
319
304
|
for (const rel of Object.keys(files)) {
|
|
305
|
+
// 父目录由 writeJsonAtomic 保证
|
|
320
306
|
writeJsonAtomic(shardRelPath(this.dir, rel), { relPath: rel, record: files[rel], entries: (entries[rel] || []).map(withoutPersistedDerived) })
|
|
321
307
|
}
|
|
322
308
|
writeJsonAtomic(formatPath, { version: 2, layout: 'sharded' })
|
|
@@ -524,7 +510,7 @@ export class ProjectMemoryStore {
|
|
|
524
510
|
}
|
|
525
511
|
for (const rel of this._dirtyShards) {
|
|
526
512
|
if (this.files[rel]) {
|
|
527
|
-
|
|
513
|
+
// 父目录由 writeJsonAtomic 保证
|
|
528
514
|
writeJsonAtomic(shardRelPath(this.dir, rel), { relPath: rel, record: this.files[rel], entries: (this.entries[rel] || []).map(withoutPersistedDerived) })
|
|
529
515
|
} else {
|
|
530
516
|
this._removedShards.add(rel)
|
|
@@ -580,11 +566,11 @@ export class ProjectMemoryStore {
|
|
|
580
566
|
if (N === 0) return {}
|
|
581
567
|
const df = {}
|
|
582
568
|
for (const entry of entries) {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
const terms = tokenizeRaw(
|
|
569
|
+
// 语料必须与**检索**同源:检索走 search.js 的 weightedFieldText = title×5 + keywords
|
|
570
|
+
// + summary + **terms** + sourcePath。此前这里手抄了一份少了 `terms`/`sourcePath` 的
|
|
571
|
+
// 副本,于是只出现在 `terms` 里的词 df=0 → 被当成极稀有词拿虚高 IDF(而 `terms` 正是
|
|
572
|
+
// 0.5.x 为修"只有前 300 字符可检索"加的主检索面)。直接调 makeSearchText,权重自动对齐。
|
|
573
|
+
const terms = tokenizeRaw(makeSearchText(entry))
|
|
588
574
|
const seen = new Set(terms)
|
|
589
575
|
for (const t of seen) {
|
|
590
576
|
df[t] = (df[t] || 0) + 1
|
package/src/tools/forget.js
CHANGED
|
@@ -11,7 +11,7 @@ export function forgetTool(config) {
|
|
|
11
11
|
id_or_query: {
|
|
12
12
|
type: 'string',
|
|
13
13
|
required: true,
|
|
14
|
-
description: 'Experience note id (from remember
|
|
14
|
+
description: 'Experience note id (from remember or query_memory output), or keywords to match.',
|
|
15
15
|
},
|
|
16
16
|
root: {
|
|
17
17
|
type: 'string',
|
|
@@ -16,6 +16,7 @@ export function lessonTool(config) {
|
|
|
16
16
|
name: 'save_lesson',
|
|
17
17
|
description:
|
|
18
18
|
'Save a lesson/decision/procedure/experience into the tiered memory (single insight entity). ' +
|
|
19
|
+
'Prefer this over remember for anything new: entries here carry scope, merge/reinforce, and auto-promote. ' +
|
|
19
20
|
'Call it when the task hit a real pitfall, got corrected, or made a deliberate choice worth remembering — not for routine work. ' +
|
|
20
21
|
'Similar entries auto-merge (bidirectional token overlap >= 0.7) or reinforce (0.65~0.7, accumulating task hits); ' +
|
|
21
22
|
'when the same insight is hit by 2+ tasks it auto-promotes task -> project, 3+ tasks project -> global. ' + kindDesc + ' ' + scopeDesc,
|
package/src/tools/remember.js
CHANGED
|
@@ -6,9 +6,11 @@ export function rememberTool(config) {
|
|
|
6
6
|
return defineTool({
|
|
7
7
|
name: 'remember',
|
|
8
8
|
description:
|
|
9
|
-
'Save an experience note (problem -> solution) into project memory, e.g. a bug you just fixed
|
|
10
|
-
'Retrieved later only when
|
|
11
|
-
'
|
|
9
|
+
'Save an experience note (problem -> solution) into project memory, e.g. a bug you just fixed. ' +
|
|
10
|
+
'Retrieved later only when query_memory matches the problem, never auto-injected. ' +
|
|
11
|
+
'A note for a similar problem is superseded instead of duplicated. ' +
|
|
12
|
+
'This writes the standalone experience file: for lessons / decisions / procedures prefer save_lesson ' +
|
|
13
|
+
'(the tiered insight store) — query_memory searches both.',
|
|
12
14
|
parameters: {
|
|
13
15
|
problem: {
|
|
14
16
|
type: 'string',
|
package/src/tools/task-tools.js
CHANGED
|
@@ -149,13 +149,14 @@ export function showTaskPanelTool(config) {
|
|
|
149
149
|
'Show the task panel in the UI. Call this when the user asks to see the task list or when you want to display the task panel to the user.',
|
|
150
150
|
parameters: {},
|
|
151
151
|
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
152
|
-
async execute(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
152
|
+
async execute() {
|
|
153
|
+
// 开面板的动作在**客户端**:这个工具名被 src/client/ShowTaskPanelNode.tsx 认领,
|
|
154
|
+
// 结果节点一渲染就由浏览器侧调 `taskUIStore.open()`。
|
|
155
|
+
//
|
|
156
|
+
// 宿主进程没有触碰面板状态的通道 —— 面板的 closed/minimized 是浏览器侧 UI store,
|
|
157
|
+
// 而 `ToolRunContext` 只有 `deferContext()` / `concludeTurn()` 两个自有成员
|
|
158
|
+
// (packages/core/tools/src/index.ts:418),**没有 `ctx`**。旧实现读 `exec.ctx` 再
|
|
159
|
+
// `emit('dsh:task-panel:show')`,于是每次都返回「无法获取上下文」,面板从未打开过。
|
|
159
160
|
return '已请求打开任务面板。'
|
|
160
161
|
},
|
|
161
162
|
})
|
package/src/tools/watch-repo.js
CHANGED
|
@@ -9,7 +9,7 @@ export function watchRepoTool(watchManager, config) {
|
|
|
9
9
|
description:
|
|
10
10
|
'Start silent auto-refresh for a project: the plugin polls the root (configurable interval), ' +
|
|
11
11
|
'detects changed/new docs and code files via mtime+content-hash, and re-indexes only those silently. ' +
|
|
12
|
-
'No
|
|
12
|
+
'No manual re-index needed after this. Call again with watch=false to stop.',
|
|
13
13
|
parameters: {
|
|
14
14
|
root: {
|
|
15
15
|
type: 'string',
|
package/src/util/fs.js
CHANGED
|
@@ -1,9 +1,41 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync,
|
|
3
|
+
unlinkSync, writeFileSync,
|
|
4
|
+
} from 'node:fs'
|
|
2
5
|
import { readFile } from 'node:fs/promises'
|
|
3
6
|
import { createHash } from 'node:crypto'
|
|
4
7
|
import os from 'node:os'
|
|
5
8
|
import path from 'node:path'
|
|
6
9
|
|
|
10
|
+
/**
|
|
11
|
+
* 原子写 JSON:先写同目录的 `<file>.<pid>.tmp`,再 rename 覆盖目标。
|
|
12
|
+
*
|
|
13
|
+
* 两点语义,别再各写一份副本:
|
|
14
|
+
* - 先建父目录(global insight 文件首次写入时父目录可能不存在)。
|
|
15
|
+
* - 失败时删掉自己的 `.tmp`,不重试 rename:同样的参数重试没有意义,而 `.tmp` 留下只有
|
|
16
|
+
* `cleanStaleTmp()` 会清,它扫不到 `~/.config/dsh-project-memory/`。
|
|
17
|
+
*
|
|
18
|
+
* 崩溃(kill -9 / 断电)走不到 catch,那种残留由 `cleanStaleTmp()` 兜底。
|
|
19
|
+
* @param {string} file - 目标文件绝对路径。
|
|
20
|
+
* @param {unknown} data - 要序列化的 JSON 值。
|
|
21
|
+
* @throws 序列化失败或写盘/rename 失败时原样抛出(调用方决定回退策略)。
|
|
22
|
+
*/
|
|
23
|
+
export function writeJsonAtomic(file, data) {
|
|
24
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
25
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(tmp, JSON.stringify(data))
|
|
28
|
+
renameSync(tmp, file)
|
|
29
|
+
} catch (err) {
|
|
30
|
+
try {
|
|
31
|
+
unlinkSync(tmp)
|
|
32
|
+
} catch {
|
|
33
|
+
// tmp 已不在(rename 其实成功了)或不可删;两种都无需处理
|
|
34
|
+
}
|
|
35
|
+
throw err
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
7
39
|
export function assertReadableFile(filePath, maxFileSizeMb) {
|
|
8
40
|
if (typeof filePath !== 'string' || !filePath) {
|
|
9
41
|
throw new Error('file_path must be a non-empty string')
|