@yolk_vat-y/dsh-project-memory 0.4.4 → 0.5.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 +46 -0
- package/README.md +11 -2
- package/README.zh-CN.md +11 -2
- package/client/client.js +892 -107
- package/client/client.js.map +1 -1
- package/package.json +2 -2
- package/src/auto-inject.js +220 -0
- package/src/client/MemoryView.tsx +349 -0
- package/src/client/TaskCommandNode.tsx +30 -11
- package/src/client/TaskComponents.tsx +171 -21
- package/src/client/TaskPanel.module.css +151 -1
- package/src/client/TaskPanel.tsx +74 -14
- package/src/client/client.ts +4 -4
- package/src/client/locales.ts +68 -0
- package/src/client/task-data-store.ts +21 -5
- package/src/commands/insight-actions.js +390 -0
- package/src/commands/task-actions.js +8 -0
- package/src/commands/tasks.js +1 -0
- package/src/global-seed.js +41 -0
- package/src/index.js +38 -4
- package/src/insight-store.js +558 -0
- package/src/project-profile.js +78 -0
- package/src/reflection-pipeline.js +146 -0
- package/src/similarity.js +52 -0
- package/src/store.js +73 -0
- package/src/tools/lesson-tools.js +101 -0
- package/src/tools/task-tools.js +12 -2
- package/src/types.js +56 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// PR 2 项目技术栈画像(tags):解析 package.json / go.mod / Cargo.toml → 内存缓存
|
|
2
|
+
// 用途:global procedure 的 trigger.scope 过滤(与画像 tags 取交集为空则跳过)
|
|
3
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
const cache = new Map() // root -> { tags: string[], mtimeMs: number }
|
|
7
|
+
|
|
8
|
+
function tryRead(file) {
|
|
9
|
+
try {
|
|
10
|
+
return readFileSync(file, 'utf8')
|
|
11
|
+
} catch {
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function depsOf(manifest, fields) {
|
|
17
|
+
const tags = new Set()
|
|
18
|
+
for (const f of fields) {
|
|
19
|
+
const deps = manifest && manifest[f]
|
|
20
|
+
if (deps && typeof deps === 'object') {
|
|
21
|
+
for (const name of Object.keys(deps)) {
|
|
22
|
+
tags.add(name.toLowerCase())
|
|
23
|
+
if (name.startsWith('@')) tags.add(name.split('/')[1].toLowerCase())
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return tags
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 解析项目技术栈 tags。带 mtime 缓存;任何解析失败返回空集(不抛)。
|
|
32
|
+
* @param {string} root
|
|
33
|
+
* @returns {string[]}
|
|
34
|
+
*/
|
|
35
|
+
export function projectTags(root) {
|
|
36
|
+
if (!root) return []
|
|
37
|
+
try {
|
|
38
|
+
const cached = cache.get(root)
|
|
39
|
+
const pkgPath = path.join(root, 'package.json')
|
|
40
|
+
const marker = pkgPath
|
|
41
|
+
const mtime = statSync(marker).mtimeMs
|
|
42
|
+
if (cached && cached.mtimeMs === mtime) return cached.tags
|
|
43
|
+
const tags = collectTags(root)
|
|
44
|
+
cache.set(root, { tags, mtimeMs: mtime })
|
|
45
|
+
return tags
|
|
46
|
+
} catch {
|
|
47
|
+
const tags = collectTags(root)
|
|
48
|
+
cache.set(root, { tags, mtimeMs: 0 })
|
|
49
|
+
return tags
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function collectTags(root) {
|
|
54
|
+
const tags = new Set()
|
|
55
|
+
try {
|
|
56
|
+
const pkg = JSON.parse(tryRead(path.join(root, 'package.json')) || '{}')
|
|
57
|
+
for (const t of depsOf(pkg, ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'])) tags.add(t)
|
|
58
|
+
for (const k of Array.isArray(pkg.keywords) ? pkg.keywords : []) if (typeof k === 'string') tags.add(k.toLowerCase())
|
|
59
|
+
if (pkg.name) tags.add(String(pkg.name).toLowerCase())
|
|
60
|
+
} catch {
|
|
61
|
+
// package.json 缺失或损坏 → 继续尝试其它清单
|
|
62
|
+
}
|
|
63
|
+
const go = tryRead(path.join(root, 'go.mod'))
|
|
64
|
+
if (go) {
|
|
65
|
+
const m = go.match(/^module\s+(\S+)/m)
|
|
66
|
+
if (m) tags.add(m[1].toLowerCase())
|
|
67
|
+
}
|
|
68
|
+
const cargo = tryRead(path.join(root, 'Cargo.toml'))
|
|
69
|
+
if (cargo) {
|
|
70
|
+
const m = cargo.match(/^\s*name\s*=\s*"([^"]+)"/m)
|
|
71
|
+
if (m) tags.add(m[1].toLowerCase())
|
|
72
|
+
}
|
|
73
|
+
return [...tags]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function clearProjectTagsCache() {
|
|
77
|
+
cache.clear()
|
|
78
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// PR 1b 反思管线(v0.5,默认关)
|
|
2
|
+
// - 触发:select_task 切走旧任务 / archive_task 与 /task archive /(预留空闲与 compaction 前)
|
|
3
|
+
// - 输入不依赖对话原文:task 快照(title + steps + files)相对上次反思的摘要变更(digest 门控)
|
|
4
|
+
// - 冷却:同任务 cooldownMs 内、内容摘要未变 → 跳过(LLM 零调用)
|
|
5
|
+
// - 产出只写 task 级草稿(source: reflect, draft: true),不直接写 project/global;
|
|
6
|
+
// 由 insight-store 的跨任务去重/自动提升语义处理(2 个独立任务命中同一坑 → 升 project)
|
|
7
|
+
// - 任何失败只记日志,绝不抛出、不阻塞调用方
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
import { ProjectMemoryStore } from './store.js'
|
|
10
|
+
import { memoryRootFor } from './util/fs.js'
|
|
11
|
+
import { cfgInsight, saveInsight, GlobalStore, defaultGlobalFile } from './insight-store.js'
|
|
12
|
+
import { chatText, parseStructuredJson } from './llm.js'
|
|
13
|
+
|
|
14
|
+
export const REFLECT_SYSTEM =
|
|
15
|
+
'You distill task work into durable lessons and decisions for project memory. ' +
|
|
16
|
+
'Only record things worth remembering: a real pitfall hit and fixed, a correction received, or a deliberate tradeoff decision. ' +
|
|
17
|
+
'Normal development does not produce entries. ' +
|
|
18
|
+
'Return a STRICT JSON object: {"lessons":[{"pattern":"具体错误模式","fix":"修正做法","files":["rel/path.ts"],"symbols":["fnName"],"confidence":0.8}], ' +
|
|
19
|
+
'"decisions":[{"topic":"主题","choice":"选了什么","reason":"为什么","confidence":0.9}]}. ' +
|
|
20
|
+
'No markdown fences, no commentary, only the JSON object.'
|
|
21
|
+
|
|
22
|
+
export function taskReflectionSnapshot(task) {
|
|
23
|
+
return {
|
|
24
|
+
title: task ? task.title : '',
|
|
25
|
+
steps: ((task && task.steps) || []).map((s) => (typeof s === 'string' ? s : s.content || s.text || '')).filter(Boolean).join('\n'),
|
|
26
|
+
files: ((task && task.files) || []).join('\n'),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function taskDigest(task) {
|
|
31
|
+
const snap = taskReflectionSnapshot(task)
|
|
32
|
+
return createHash('sha256')
|
|
33
|
+
.update(JSON.stringify({ steps: snap.steps, files: snap.files }))
|
|
34
|
+
.digest('hex')
|
|
35
|
+
.slice(0, 16)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 反思是否到期(enabled / 未反思过 / 摘要已变 / 冷却已过)。 */
|
|
39
|
+
export function isReflectDue(config, task, nowMs = Date.now()) {
|
|
40
|
+
const rc = (config && config.reflection) || {}
|
|
41
|
+
if (rc.enabled === false || rc.enabled == null) return { due: false, reason: 'disabled' }
|
|
42
|
+
if (!task) return { due: false, reason: 'no-task' }
|
|
43
|
+
const last = task.reflection || {}
|
|
44
|
+
if (!last.lastAt) return { due: true, reason: 'never' }
|
|
45
|
+
if (last.digest === taskDigest(task)) return { due: false, reason: 'unchanged' }
|
|
46
|
+
const cooldown = typeof rc.cooldownMs === 'number' ? rc.cooldownMs : 1800000
|
|
47
|
+
if (nowMs - Date.parse(last.lastAt) < cooldown) return { due: false, reason: 'cooldown' }
|
|
48
|
+
return { due: true, reason: 'changed' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeList(arr, max) {
|
|
52
|
+
if (!Array.isArray(arr)) return []
|
|
53
|
+
return arr.filter((x) => x && typeof x === 'object').slice(0, max)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 对某任务做一次反思(可归档任务照做——归档正是收割时机)。
|
|
58
|
+
* 全程 try/catch:调用方 fire-and-forget 即可。
|
|
59
|
+
*/
|
|
60
|
+
export async function reflectTaskAfter({ config, llm, root, taskId, reason = 'transition' }) {
|
|
61
|
+
const rc = (config && config.reflection) || {}
|
|
62
|
+
const fallback = { ok: false, skipped: 'disabled' }
|
|
63
|
+
if (!rc.enabled) return fallback
|
|
64
|
+
if (!llm) return { ok: false, skipped: 'no-llm' }
|
|
65
|
+
try {
|
|
66
|
+
const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
|
|
67
|
+
const task = store.getTask(taskId)
|
|
68
|
+
const gate = isReflectDue(config, task)
|
|
69
|
+
if (!gate.due) return { ok: false, skipped: gate.reason }
|
|
70
|
+
|
|
71
|
+
const snap = taskReflectionSnapshot(task)
|
|
72
|
+
const user =
|
|
73
|
+
`Task: ${snap.title || '(untitled)'}\n` +
|
|
74
|
+
(snap.steps ? `Steps:\n${snap.steps.slice(0, 2000)}\n` : '') +
|
|
75
|
+
(snap.files ? `Files touched:\n${snap.files.slice(0, 1000)}\n` : '') +
|
|
76
|
+
`Trigger: ${reason}\n\nReturn the JSON object.`
|
|
77
|
+
|
|
78
|
+
const raw = await chatText(llm, REFLECT_SYSTEM, user, { timeoutMs: 90000 })
|
|
79
|
+
const parsed = parseStructuredJson(raw)
|
|
80
|
+
if (!parsed) return { ok: false, skipped: 'unparsable' }
|
|
81
|
+
|
|
82
|
+
const cfg = cfgInsight(config)
|
|
83
|
+
const globalStore = new GlobalStore(cfg.globalFile || defaultGlobalFile()).load()
|
|
84
|
+
const maxLessons = typeof rc.maxLessonsPerReflect === 'number' ? rc.maxLessonsPerReflect : 3
|
|
85
|
+
const maxDecisions = typeof rc.maxDecisionsPerReflect === 'number' ? rc.maxDecisionsPerReflect : 2
|
|
86
|
+
const written = []
|
|
87
|
+
|
|
88
|
+
for (const l of normalizeList(parsed.lessons, maxLessons)) {
|
|
89
|
+
if (!(l.pattern || l.title)) continue
|
|
90
|
+
const res = store.commit((s) =>
|
|
91
|
+
saveInsight({
|
|
92
|
+
store: s,
|
|
93
|
+
globalStore,
|
|
94
|
+
raw: {
|
|
95
|
+
title: l.title || l.pattern,
|
|
96
|
+
kind: 'lesson',
|
|
97
|
+
pattern: l.pattern,
|
|
98
|
+
fix: l.fix,
|
|
99
|
+
files: l.files,
|
|
100
|
+
symbols: l.symbols,
|
|
101
|
+
confidence: typeof l.confidence === 'number' ? l.confidence : 0.7,
|
|
102
|
+
draft: true,
|
|
103
|
+
},
|
|
104
|
+
scope: 'task',
|
|
105
|
+
taskId,
|
|
106
|
+
cfg,
|
|
107
|
+
source: 'reflect',
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
if (res && res.ok) written.push({ kind: 'lesson', id: res.id, action: res.action })
|
|
111
|
+
}
|
|
112
|
+
for (const d of normalizeList(parsed.decisions, maxDecisions)) {
|
|
113
|
+
if (!(d.topic || d.choice)) continue
|
|
114
|
+
const res = store.commit((s) =>
|
|
115
|
+
saveInsight({
|
|
116
|
+
store: s,
|
|
117
|
+
globalStore,
|
|
118
|
+
raw: { title: d.topic, kind: 'decision', choice: d.choice, reason: d.reason, confidence: typeof d.confidence === 'number' ? d.confidence : 0.7, draft: true },
|
|
119
|
+
scope: 'task',
|
|
120
|
+
taskId,
|
|
121
|
+
cfg,
|
|
122
|
+
source: 'reflect',
|
|
123
|
+
}),
|
|
124
|
+
)
|
|
125
|
+
if (res && res.ok) written.push({ kind: 'decision', id: res.id, action: res.action })
|
|
126
|
+
}
|
|
127
|
+
// 标记反思进度(冷却/摘要以磁盘为准)
|
|
128
|
+
const now = new Date().toISOString()
|
|
129
|
+
store.commit((s) => s.updateTask(taskId, { reflection: { lastAt: now, digest: taskDigest(task) } }))
|
|
130
|
+
globalStore.commit() // task 路径可能链到 global(命中既有知识)
|
|
131
|
+
return { ok: true, reason, written }
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error(`[dsh-project-memory] reflection failed for ${taskId}: ${err?.message || err}`)
|
|
134
|
+
return { ok: false, skipped: 'error', error: String(err?.message || err) }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 工具/命令层 fire-and-forget:默认关时立刻廉价返回,绝不让错误上抛。 */
|
|
139
|
+
export function fireReflect(config, host, root, taskId, reason) {
|
|
140
|
+
const rc = (config && config.reflection) || {}
|
|
141
|
+
if (!rc.enabled) return Promise.resolve({ ok: false, skipped: 'disabled' })
|
|
142
|
+
return reflectTaskAfter({ config, llm: host?.llm, root, taskId, reason }).catch((err) => {
|
|
143
|
+
console.error(`[dsh-project-memory] fireReflect error: ${err?.message || err}`)
|
|
144
|
+
return { ok: false, skipped: 'error' }
|
|
145
|
+
})
|
|
146
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// 通用文本相似度:双向 token overlap(归一化 0..1)
|
|
2
|
+
// v0.5 去重 / 合并 / 强化共用的**唯一**相似度指标;BM25 只用于排序,禁止当阈值。
|
|
3
|
+
//
|
|
4
|
+
// 归一化定义:normalized = 共同 token 数 / 两文本中较长者的 token 数(= min(ratioA, ratioB))。
|
|
5
|
+
// 它与 v0.4 experience 层 "双向 ratio ≥ t" 判定等价:
|
|
6
|
+
// ratioA ≥ t 且 ratioB ≥ t ⟺ overlap / max(lenA, lenB) ≥ t
|
|
7
|
+
// 因此 "≥ 0.7 合并 / 0.65~0.7 强化" 只需在此单一尺度上比较,跨库/跨长度可比。
|
|
8
|
+
import { tokenize } from './util/search.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 归一化双向 token overlap。
|
|
12
|
+
* @param {string} a
|
|
13
|
+
* @param {string} b
|
|
14
|
+
* @returns {number} 0..1
|
|
15
|
+
*/
|
|
16
|
+
export function normalizedTokenOverlap(a, b) {
|
|
17
|
+
if (!a || !b) return 0
|
|
18
|
+
const ta = tokenize(String(a))
|
|
19
|
+
const tb = tokenize(String(b))
|
|
20
|
+
if (!ta.length || !tb.length) return 0
|
|
21
|
+
const sa = new Set(ta)
|
|
22
|
+
const sb = new Set(tb)
|
|
23
|
+
let overlap = 0
|
|
24
|
+
for (const t of sa) if (sb.has(t)) overlap++
|
|
25
|
+
return overlap / Math.max(sa.size, sb.size)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 一条 insight 参与相似度判定的文本(标题 + kind 专属正文)。 */
|
|
29
|
+
export function insightMatchText(ins) {
|
|
30
|
+
return `${ins.title || ''} ${ins.problem || ''} ${ins.pattern || ''} ${ins.topic || ''} ${ins.choice || ''} ${ins.body || ''}`.trim()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 在候选列表里找与 text 重叠分最高(且未归档)的一条。
|
|
35
|
+
* @param {string} text
|
|
36
|
+
* @param {Array<object>} items
|
|
37
|
+
* @param {(item: object) => string} [getText]
|
|
38
|
+
* @returns {{ item: object, score: number } | null}
|
|
39
|
+
*/
|
|
40
|
+
export function findBestOverlapMatch(text, items, getText = (it) => insightMatchText(it)) {
|
|
41
|
+
let best = null
|
|
42
|
+
let bestScore = 0
|
|
43
|
+
for (const item of items) {
|
|
44
|
+
if (item && item.archived) continue
|
|
45
|
+
const score = normalizedTokenOverlap(text, getText(item))
|
|
46
|
+
if (score > bestScore) {
|
|
47
|
+
bestScore = score
|
|
48
|
+
best = item
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return best ? { item: best, score: bestScore } : null
|
|
52
|
+
}
|
package/src/store.js
CHANGED
|
@@ -10,6 +10,7 @@ const EXPERIENCE_FILE = 'experience.json'
|
|
|
10
10
|
const TASKS_FILE = 'tasks.json'
|
|
11
11
|
const BINDING_FILE = 'binding.json'
|
|
12
12
|
const WATCH_FILE = 'watch.json'
|
|
13
|
+
const INSIGHTS_FILE = 'insights.json'
|
|
13
14
|
const SHARDS_DIR = 'shards'
|
|
14
15
|
|
|
15
16
|
const storeCache = new Map()
|
|
@@ -60,12 +61,14 @@ export class ProjectMemoryStore {
|
|
|
60
61
|
this.files = {}
|
|
61
62
|
this.entries = {}
|
|
62
63
|
this.experience = []
|
|
64
|
+
this.insights = { version: 1, migratedAt: null, items: [] }
|
|
63
65
|
this.tasks = []
|
|
64
66
|
this.binding = {}
|
|
65
67
|
this.watchlist = []
|
|
66
68
|
this._dirtyShards = new Set()
|
|
67
69
|
this._removedShards = new Set()
|
|
68
70
|
this._dirtyExperience = false
|
|
71
|
+
this._dirtyInsights = false
|
|
69
72
|
this._dirtyTasks = false
|
|
70
73
|
this._dirtyBinding = false
|
|
71
74
|
this._dirtyWatch = false
|
|
@@ -80,6 +83,7 @@ export class ProjectMemoryStore {
|
|
|
80
83
|
if (hot && hot !== this) return hot
|
|
81
84
|
this._migrateLegacyIfNeeded()
|
|
82
85
|
this._loadSharded()
|
|
86
|
+
this._loadInsights()
|
|
83
87
|
storeCache.set(key, this)
|
|
84
88
|
return this
|
|
85
89
|
}
|
|
@@ -146,6 +150,71 @@ export class ProjectMemoryStore {
|
|
|
146
150
|
this._formatWritten = existsSafe(path.join(this.dir, FORMAT_FILE))
|
|
147
151
|
}
|
|
148
152
|
|
|
153
|
+
// ---- v0.5 insights:project 级 insight 文档(任务级在 task.insights[]) ----
|
|
154
|
+
// 迁移语义(与初版方案的差异,见 PLAN §10 风险 / 收尾说明):
|
|
155
|
+
// v0.4 experience.json 仍由 remember/forget/query_memory 服务,不删除;
|
|
156
|
+
// 首次加载把旧笔记**复制导入** insights.json(kind: experience, source: migrate),
|
|
157
|
+
// migratedAt 落盘保证跨进程/崩溃幂等。销毁式收敛放到 recall 统一 PR。
|
|
158
|
+
_loadInsights() {
|
|
159
|
+
const doc = loadJson(path.join(this.dir, INSIGHTS_FILE), null)
|
|
160
|
+
this.insights = doc && typeof doc === 'object' && Array.isArray(doc.items) ? doc : { version: 1, migratedAt: null, items: [] }
|
|
161
|
+
this._migrateExperienceToInsights()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_migrateExperienceToInsights() {
|
|
165
|
+
if (this.insights.migratedAt) return
|
|
166
|
+
const legacy = this.experience
|
|
167
|
+
if (!Array.isArray(legacy) || legacy.length === 0) return
|
|
168
|
+
const now = new Date().toISOString()
|
|
169
|
+
const known = new Set(this.insights.items.map((it) => it.id))
|
|
170
|
+
let imported = 0
|
|
171
|
+
for (const item of legacy) {
|
|
172
|
+
if (!item || !item.problem || known.has(item.id)) continue
|
|
173
|
+
known.add(item.id)
|
|
174
|
+
this.insights.items.push({
|
|
175
|
+
id: item.id,
|
|
176
|
+
kind: 'experience',
|
|
177
|
+
scope: 'project',
|
|
178
|
+
title: String(item.problem).slice(0, 120),
|
|
179
|
+
problem: item.problem,
|
|
180
|
+
solution: item.solution,
|
|
181
|
+
files: item.sourceFile ? [item.sourceFile] : [],
|
|
182
|
+
symbols: [],
|
|
183
|
+
sourceTaskIds: [],
|
|
184
|
+
source: 'migrate',
|
|
185
|
+
confidence: 1,
|
|
186
|
+
hitCount: 0,
|
|
187
|
+
createdAt: item.createdAt || now,
|
|
188
|
+
updatedAt: item.updatedAt || now,
|
|
189
|
+
})
|
|
190
|
+
imported++
|
|
191
|
+
}
|
|
192
|
+
this.insights.migratedAt = now
|
|
193
|
+
try {
|
|
194
|
+
mkdirSync(this.dir, { recursive: true })
|
|
195
|
+
writeJsonAtomic(path.join(this.dir, INSIGHTS_FILE), this.insights)
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error(`[dsh-project-memory] insights migration write failed: ${err.message}`)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
insightsDoc() {
|
|
202
|
+
return this.insights
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
insightItems() {
|
|
206
|
+
return this.insights.items
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
replaceInsightItems(items) {
|
|
210
|
+
this.insights.items = items
|
|
211
|
+
this._dirtyInsights = true
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
markTasksDirty() {
|
|
215
|
+
this._dirtyTasks = true
|
|
216
|
+
}
|
|
217
|
+
|
|
149
218
|
cleanStaleTmp() {
|
|
150
219
|
const scanDirs = [this.dir, path.join(this.dir, SHARDS_DIR)]
|
|
151
220
|
const now = Date.now()
|
|
@@ -195,6 +264,10 @@ export class ProjectMemoryStore {
|
|
|
195
264
|
writeJsonAtomic(path.join(this.dir, EXPERIENCE_FILE), this.experience)
|
|
196
265
|
this._dirtyExperience = false
|
|
197
266
|
}
|
|
267
|
+
if (this._dirtyInsights) {
|
|
268
|
+
writeJsonAtomic(path.join(this.dir, INSIGHTS_FILE), this.insights)
|
|
269
|
+
this._dirtyInsights = false
|
|
270
|
+
}
|
|
198
271
|
if (this._dirtyTasks) {
|
|
199
272
|
writeJsonAtomic(path.join(this.dir, TASKS_FILE), this.tasks)
|
|
200
273
|
this._dirtyTasks = false
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
3
|
+
import { ProjectMemoryStore } from '../store.js'
|
|
4
|
+
import { truncate } from '../util/text.js'
|
|
5
|
+
import { cfgInsight, saveInsight, defaultGlobalFile, GlobalStore } from '../insight-store.js'
|
|
6
|
+
import { ensureGlobalInit } from '../global-seed.js'
|
|
7
|
+
import { INSIGHT_KINDS, INSIGHT_SCOPES } from '../insight-store.js'
|
|
8
|
+
|
|
9
|
+
function sessionIdOf(exec) {
|
|
10
|
+
return exec?.agent?.session?.id || exec?.ctx?.session?.id
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function lessonTool(config) {
|
|
14
|
+
const kindDesc = `insight 语义:lesson(曾踩坑/纠偏, pattern→fix) | decision(权衡选型, choice→reason) | procedure(多步指南, steps+可选 trigger) | experience(problem→solution,v0.4 兼容)。默认 lesson。`
|
|
15
|
+
const scopeDesc =
|
|
16
|
+
'作用域:task(任务私有,随任务归档,不进共享注入) | project(项目资产) | global(个人能力库,跨项目,建议配 trigger 关键词以便将来技能命中)。' +
|
|
17
|
+
'解析顺序:显式 scope > task_id > 当前绑定任务 > project。'
|
|
18
|
+
return defineTool({
|
|
19
|
+
name: 'save_lesson',
|
|
20
|
+
description:
|
|
21
|
+
'Save a lesson/decision/procedure/experience into the tiered memory (single insight entity). ' +
|
|
22
|
+
'Call it when the task hit a real pitfall, got corrected, or made a deliberate choice worth remembering — not for routine work. ' +
|
|
23
|
+
'Similar entries auto-merge (bidirectional token overlap >= 0.7) or reinforce (0.65~0.7, accumulating task hits); ' +
|
|
24
|
+
'when the same insight is hit by 2+ tasks it auto-promotes task -> project, 3+ tasks project -> global. ' + kindDesc + ' ' + scopeDesc,
|
|
25
|
+
parameters: {
|
|
26
|
+
title: { type: 'string', description: 'One-line title (lesson: the failure pattern; decision: the topic; procedure: the name).' },
|
|
27
|
+
kind: { type: 'string', enum: INSIGHT_KINDS, description: kindDesc },
|
|
28
|
+
scope: { type: 'string', enum: INSIGHT_SCOPES, description: scopeDesc },
|
|
29
|
+
pattern: { type: 'string', description: 'kind=lesson: 具体错误模式(同 title 时二选一)' },
|
|
30
|
+
fix: { type: 'string', description: 'kind=lesson: 修正做法' },
|
|
31
|
+
choice: { type: 'string', description: 'kind=decision: 选了什么' },
|
|
32
|
+
reason: { type: 'string', description: 'kind=decision: 为什么(权衡点)' },
|
|
33
|
+
steps: { type: 'array', items: { type: 'string' }, description: 'kind=procedure: 有序步骤(未来静默注入的正文)' },
|
|
34
|
+
trigger: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
properties: {
|
|
38
|
+
keywords: { type: 'array', items: { type: 'string' } },
|
|
39
|
+
symbols: { type: 'array', items: { type: 'string' } },
|
|
40
|
+
scope: { type: 'array', items: { type: 'string' } },
|
|
41
|
+
},
|
|
42
|
+
description: 'kind=procedure: "作为 Skill" 的触发关键词(keywords/symbols + scope tags)',
|
|
43
|
+
},
|
|
44
|
+
task_id: { type: 'string', description: '目标任务 id(scope 缺省时优先于绑定任务)' },
|
|
45
|
+
files: { type: 'array', items: { type: 'string' }, description: '关联文件(项目相对路径)' },
|
|
46
|
+
symbols: { type: 'array', items: { type: 'string' }, description: '关联符号' },
|
|
47
|
+
confidence: { type: 'number', description: '置信 0..1(默认 0.8)' },
|
|
48
|
+
root: { type: 'string', description: 'Project root. Defaults to current working directory.' },
|
|
49
|
+
},
|
|
50
|
+
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
51
|
+
async execute(args, exec) {
|
|
52
|
+
const root = resolveIndexRoot(exec, args.root)
|
|
53
|
+
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
54
|
+
const store = new ProjectMemoryStore(memoryDir).load()
|
|
55
|
+
const cfg = cfgInsight(config)
|
|
56
|
+
const sid = sessionIdOf(exec)
|
|
57
|
+
const boundTaskId = sid ? store.getBoundTaskId(sid) : undefined
|
|
58
|
+
const taskId = args.task_id || boundTaskId
|
|
59
|
+
|
|
60
|
+
const raw = {
|
|
61
|
+
title: args.title,
|
|
62
|
+
kind: args.kind,
|
|
63
|
+
pattern: args.pattern,
|
|
64
|
+
fix: args.fix,
|
|
65
|
+
choice: args.choice,
|
|
66
|
+
reason: args.reason,
|
|
67
|
+
steps: args.steps,
|
|
68
|
+
trigger: args.trigger,
|
|
69
|
+
files: args.files,
|
|
70
|
+
symbols: args.symbols,
|
|
71
|
+
confidence: args.confidence,
|
|
72
|
+
}
|
|
73
|
+
if (!raw.title && !raw.pattern && !raw.choice) {
|
|
74
|
+
return truncate(JSON.stringify({ success: false, error: '需要 title / pattern / choice 之一' }), config.maxOutputChars || 8000)
|
|
75
|
+
}
|
|
76
|
+
if (!INSIGHT_KINDS.includes(raw.kind)) raw.kind = 'lesson'
|
|
77
|
+
|
|
78
|
+
let scope = args.scope
|
|
79
|
+
if (!scope) scope = taskId ? 'task' : 'project'
|
|
80
|
+
if (scope === 'task' && !taskId) {
|
|
81
|
+
return truncate(JSON.stringify({ success: false, error: 'task 级写入需要 task_id(或先 select_task 绑定会话)' }), config.maxOutputChars || 8000)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
ensureGlobalInit(config) // 幂等确保 global 文件结构就绪
|
|
85
|
+
const globalStore = new GlobalStore(cfg.globalFile || defaultGlobalFile()).load()
|
|
86
|
+
|
|
87
|
+
let result
|
|
88
|
+
store.commit((s) => {
|
|
89
|
+
result = saveInsight({ store: s, globalStore, raw, scope, taskId, cfg, source: 'agent' })
|
|
90
|
+
return result
|
|
91
|
+
})
|
|
92
|
+
// global 变更(global scope 直写或 project 自动提升)单独落盘
|
|
93
|
+
globalStore.commit()
|
|
94
|
+
if (!result) result = { ok: false, error: '写入未完成' }
|
|
95
|
+
const body = result.ok
|
|
96
|
+
? `Saved insight ${result.id} [scope=${result.scope || scope}] action=${result.action}${result.hint ? ` — ${result.hint}` : ''}`
|
|
97
|
+
: `Failed: ${result.error}`
|
|
98
|
+
return truncate(body, config.maxOutputChars || 8000)
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
}
|
package/src/tools/task-tools.js
CHANGED
|
@@ -3,6 +3,7 @@ import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
|
3
3
|
import { ProjectMemoryStore } from '../store.js'
|
|
4
4
|
import { truncate } from '../util/text.js'
|
|
5
5
|
import { genTaskId, hash8, adoptStepsToSession, shouldAdoptToHost } from '../setup/taskbridge.js'
|
|
6
|
+
import { fireReflect } from '../reflection-pipeline.js'
|
|
6
7
|
import { createHash } from 'node:crypto'
|
|
7
8
|
|
|
8
9
|
function sessionIdOf(exec) {
|
|
@@ -51,7 +52,7 @@ export function listTasksTool(config) {
|
|
|
51
52
|
})
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
export function selectTaskTool(config) {
|
|
55
|
+
export function selectTaskTool(config, host) {
|
|
55
56
|
return defineTool({
|
|
56
57
|
name: 'select_task',
|
|
57
58
|
description:
|
|
@@ -68,6 +69,7 @@ export function selectTaskTool(config) {
|
|
|
68
69
|
const sid = sessionIdOf(exec)
|
|
69
70
|
const now = new Date().toISOString()
|
|
70
71
|
const error = (msg) => truncate(JSON.stringify({ success: false, error: msg }), config.maxOutputChars)
|
|
72
|
+
const prevBound = sid ? store.getBoundTaskId(sid) : undefined
|
|
71
73
|
|
|
72
74
|
if (!args.taskId && !args.title) {
|
|
73
75
|
return error('需要 taskId 或 title(先 list_tasks;未绑定会话出现 todo 时会自动新建任务)')
|
|
@@ -133,11 +135,17 @@ export function selectTaskTool(config) {
|
|
|
133
135
|
adoptStepsToSession(exec?.agent?.session || exec?.ctx?.session, task)
|
|
134
136
|
}
|
|
135
137
|
|
|
138
|
+
// 反思钩子:切走旧任务时异步收割(默认关,零成本;失败只记日志)
|
|
139
|
+
if (prevBound && prevBound !== task.id) {
|
|
140
|
+
fireReflect(config, host, root, prevBound, 'switch-away')
|
|
141
|
+
}
|
|
142
|
+
|
|
136
143
|
const card = {
|
|
137
144
|
taskId: task.id,
|
|
138
145
|
title: task.title,
|
|
139
146
|
steps: task.steps,
|
|
140
147
|
files: task.files,
|
|
148
|
+
insights: task.insights || [], // v0.5:任务级 lessons/decisions/procedures
|
|
141
149
|
hint: hint + ';请据此用 todo_write 重建本会话清单',
|
|
142
150
|
}
|
|
143
151
|
return truncate(JSON.stringify(card), config.maxOutputChars)
|
|
@@ -164,7 +172,7 @@ export function showTaskPanelTool(config) {
|
|
|
164
172
|
})
|
|
165
173
|
}
|
|
166
174
|
|
|
167
|
-
export function archiveTaskTool(config) {
|
|
175
|
+
export function archiveTaskTool(config, host) {
|
|
168
176
|
return defineTool({
|
|
169
177
|
name: 'archive_task',
|
|
170
178
|
description: 'Archive a task: hide from default views, exclude from capacity, stop syncing. Restore by selecting it again.',
|
|
@@ -182,6 +190,8 @@ export function archiveTaskTool(config) {
|
|
|
182
190
|
task.archived = true
|
|
183
191
|
task.updatedAt = new Date().toISOString()
|
|
184
192
|
store.save()
|
|
193
|
+
// 反思钩子:归档即收割最终教训(默认关)
|
|
194
|
+
fireReflect(config, host, root, args.taskId, 'archive')
|
|
185
195
|
return truncate(JSON.stringify({ success: true, archived: true, hint: '已归档,select_task 可恢复' }), config.maxOutputChars)
|
|
186
196
|
},
|
|
187
197
|
})
|
package/src/types.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// JSDoc 类型定义(服务端一律 .js;本文件仅供编辑器/阅读共享心智,无运行时导出)。
|
|
2
|
+
// v0.5 单一 insight 实体:kind 区分语义,scope 分层,提升 = scope 字段变更。
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {'lesson' | 'decision' | 'procedure' | 'experience'} InsightKind
|
|
6
|
+
* lesson: 曾踩坑/纠偏(pattern→fix);decision: 曾权衡的选型(topic→choice+reason);
|
|
7
|
+
* procedure: 多步操作指南(steps);experience: v0.4 旧笔记迁移而来(problem→solution)。
|
|
8
|
+
*
|
|
9
|
+
* @typedef {'task' | 'project' | 'global'} InsightScope
|
|
10
|
+
* task: tasks.json 内嵌于任务实体 task.insights[](私有草稿);
|
|
11
|
+
* project: .dsh-project-memory/insights.json(团队/项目资产);
|
|
12
|
+
* global: ~/.config/dsh-project-memory/global.json(个人能力库,跨项目)。
|
|
13
|
+
*
|
|
14
|
+
* @typedef {object} Insight
|
|
15
|
+
* @property {string} id 全局唯一(新条目 "ins_…";迁移条目沿用旧 uuid)
|
|
16
|
+
* @property {InsightKind} kind
|
|
17
|
+
* @property {InsightScope} scope
|
|
18
|
+
* @property {boolean} [draft] true = 反思自动产出,未审核、不参与注入
|
|
19
|
+
* @property {string} title 一句话标题
|
|
20
|
+
* @property {string} [body] 正文
|
|
21
|
+
* @property {string[]} [steps] procedure 有序步骤
|
|
22
|
+
* @property {string} [choice] decision:选项
|
|
23
|
+
* @property {string} [reason] decision:理由
|
|
24
|
+
* @property {string} [pattern] lesson:错误模式
|
|
25
|
+
* @property {string} [fix] lesson:修正做法
|
|
26
|
+
* @property {string} [problem] experience:问题(旧字段保留)
|
|
27
|
+
* @property {string} [solution] experience:解法(旧字段保留)
|
|
28
|
+
* @property {string[]} [files] 关联代码位置
|
|
29
|
+
* @property {string[]} [symbols] 关联符号
|
|
30
|
+
* @property {string[]} [sourceTaskIds] 曾在哪些任务中出现/被命中(提升判定唯一依据)
|
|
31
|
+
* @property {'reflect' | 'agent' | 'user' | 'migrate'} [source] 来源
|
|
32
|
+
* @property {{ scope: string, id: string, at: string }} [movedFrom] 提升/降级审计
|
|
33
|
+
* @property {{ keywords: string[], symbols?: string[], scope?: string[] }} [trigger]
|
|
34
|
+
* procedure 的 "生成 Skill" 触发规则(无独立 skill 实体)
|
|
35
|
+
* @property {number} [confidence] 0..1
|
|
36
|
+
* @property {number} [hitCount] 被注入/命中的次数(强化信号,无 LLM)
|
|
37
|
+
* @property {string} [lastHitAt] 最近一次命中时间 ISO(衰减/归档依据)
|
|
38
|
+
* @property {boolean} [archived] 归档 = 软删:不进召回/注入,可恢复
|
|
39
|
+
* @property {string[]} [tags]
|
|
40
|
+
* @property {string} createdAt
|
|
41
|
+
* @property {string} updatedAt
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {object} InsightConfig
|
|
46
|
+
* @property {number} [dedupOverlap] 双向 token overlap 阈值(≥ 合并/取代)
|
|
47
|
+
* @property {number} [reinforceBand] 近重复强化带下界(0.65~dedupOverlap → 强化)
|
|
48
|
+
* @property {number} [maxProject] project insights 容量(归档区另计)
|
|
49
|
+
* @property {number} [maxGlobalProcedures] global insights 容量
|
|
50
|
+
* @property {number} [promoteConfidence] 提升 project 所需最低 confidence
|
|
51
|
+
* @property {number} [globalPromoteTasks] sourceTaskIds ≥ N → 提升 global
|
|
52
|
+
* @property {number} [decayDays] 久置归档启发:hitCount==0 超该天数 → 归档(软删)
|
|
53
|
+
* @property {string} [globalFile] global.json 绝对路径(默认 ~/.config/dsh-project-memory/global.json)
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
export {}
|