@yolk_vat-y/dsh-project-memory 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +43 -5
- package/README.zh-CN.md +44 -7
- package/client/client.js +1439 -464
- package/client/client.js.map +1 -1
- package/package.json +2 -2
- package/src/auto-inject.js +214 -0
- package/src/client/MemoryView.tsx +349 -0
- package/src/client/TaskCommandNode.tsx +41 -30
- package/src/client/TaskComponents.tsx +388 -0
- package/src/client/TaskPanel.module.css +225 -40
- package/src/client/TaskPanel.tsx +174 -391
- package/src/client/client.ts +4 -4
- package/src/client/locales.ts +74 -0
- package/src/client/task-data-store.ts +152 -0
- package/src/client/task-hooks.ts +169 -0
- package/src/client/task-ui-store.ts +118 -0
- package/src/commands/insight-actions.js +390 -0
- package/src/commands/task-actions.js +17 -7
- package/src/commands/tasks.js +6 -3
- package/src/global-seed.js +41 -0
- package/src/index.js +42 -7
- 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 +31 -2
- package/src/types.js +56 -0
- package/src/client/task-store.ts +0 -192
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
// v0.5 insight 存储与策略(服务端 .js,纯本地零依赖)
|
|
2
|
+
// - 单一 insight 实体,scope 分层:task(tasks.json 内 task.insights[]) / project(insights.json) / global(global.json)
|
|
3
|
+
// - 去重:双向 token overlap(src/similarity.js)≥ dedupOverlap 合并;reinforceBand~dedupOverlap 强化(不加内容)
|
|
4
|
+
// - 提升 = 字段变更(不跨文件复制):task 池内同一 insight 命中 ≥2 个任务且 confidence 达标 → project;
|
|
5
|
+
// project 条目 sourceTaskIds ≥ globalPromoteTasks → global
|
|
6
|
+
// - 归档 = 软删(archived:true,不进召回/注入);物理删除只发生在容量溢出
|
|
7
|
+
// - 写盘前密文过滤:命中密钥/token/私钥形态 → 拒绝写入
|
|
8
|
+
import { randomUUID } from 'node:crypto'
|
|
9
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
import { normalizedTokenOverlap, findBestOverlapMatch, insightMatchText } from './similarity.js'
|
|
13
|
+
|
|
14
|
+
export const INSIGHT_KINDS = ['lesson', 'decision', 'procedure', 'experience']
|
|
15
|
+
export const INSIGHT_SCOPES = ['task', 'project', 'global']
|
|
16
|
+
|
|
17
|
+
export const SECRET_PATTERNS = [
|
|
18
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/i,
|
|
19
|
+
/\bsk-[A-Za-z0-9]{20,}\b/,
|
|
20
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}\b/,
|
|
21
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
22
|
+
/\bAIza[0-9A-Za-z_-]{20,}\b/,
|
|
23
|
+
/\bxox[baprs]-[0-9A-Za-z-]{10,}\b/,
|
|
24
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/i,
|
|
25
|
+
/(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*['"][^'"]{8,}['"]/i,
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** 检测疑似密钥/token/私钥形态内容;命中返回第一个命中字段名,否则 null。 */
|
|
29
|
+
export function containsSecretFields(ins, fields = ['title', 'body', 'pattern', 'fix', 'problem', 'solution', 'choice', 'reason']) {
|
|
30
|
+
for (const f of fields) {
|
|
31
|
+
const v = ins && ins[f]
|
|
32
|
+
if (!v) continue
|
|
33
|
+
const text = Array.isArray(v) ? v.join('\n') : String(v)
|
|
34
|
+
for (const re of SECRET_PATTERNS) if (re.test(text)) return f
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function num(v, d) {
|
|
40
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : d
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 合并配置(config.insight 之上补默认)。 */
|
|
44
|
+
export function cfgInsight(config) {
|
|
45
|
+
const c = (config && config.insight) || {}
|
|
46
|
+
const cfg = {
|
|
47
|
+
dedupOverlap: num(c.dedupOverlap, 0.7),
|
|
48
|
+
reinforceBand: num(c.reinforceBand, 0.65),
|
|
49
|
+
maxProject: num(c.maxProject, 100),
|
|
50
|
+
maxGlobalProcedures: num(c.maxGlobalProcedures, 200),
|
|
51
|
+
promoteConfidence: num(c.promoteConfidence, 0.7),
|
|
52
|
+
globalPromoteTasks: num(c.globalPromoteTasks, 3),
|
|
53
|
+
decayDays: num(c.decayDays, 90),
|
|
54
|
+
globalFile: typeof c.globalFile === 'string' && c.globalFile ? c.globalFile : null,
|
|
55
|
+
}
|
|
56
|
+
if (cfg.reinforceBand > cfg.dedupOverlap) cfg.reinforceBand = cfg.dedupOverlap
|
|
57
|
+
return cfg
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function defaultGlobalFile() {
|
|
61
|
+
return path.join(os.homedir(), '.config', 'dsh-project-memory', 'global.json')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function emptyInsightsDoc() {
|
|
65
|
+
return { version: 1, migratedAt: null, items: [] }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function makeInsightId() {
|
|
69
|
+
return `ins_${randomUUID()}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const TEXT_FIELDS = ['title', 'body', 'pattern', 'fix', 'problem', 'solution', 'choice', 'reason']
|
|
73
|
+
const ARRAY_FIELDS = ['steps', 'files', 'symbols', 'tags', 'sourceTaskIds']
|
|
74
|
+
|
|
75
|
+
/** 由工具/测试传入的 raw 构造规范 insight(scope/source/timestamps 由调用方定)。 */
|
|
76
|
+
export function normalizeInsight(raw, extra = {}) {
|
|
77
|
+
const kind = INSIGHT_KINDS.includes(raw.kind) ? raw.kind : 'lesson'
|
|
78
|
+
const now = extra.nowIso || new Date().toISOString()
|
|
79
|
+
const ins = {
|
|
80
|
+
id: extra.id || makeInsightId(),
|
|
81
|
+
kind,
|
|
82
|
+
scope: extra.scope || 'project',
|
|
83
|
+
title: String(raw.title || raw.pattern || raw.problem || raw.topic || '(untitled)').slice(0, 200),
|
|
84
|
+
source: extra.source || raw.source || 'agent',
|
|
85
|
+
draft: raw.draft === true || extra.draft === true,
|
|
86
|
+
confidence: num(raw.confidence, num(extra.confidence, 0.8)),
|
|
87
|
+
hitCount: 0,
|
|
88
|
+
createdAt: extra.createdAt || now,
|
|
89
|
+
updatedAt: now,
|
|
90
|
+
}
|
|
91
|
+
if (extra.sourceTaskIds) ins.sourceTaskIds = [...extra.sourceTaskIds]
|
|
92
|
+
if (extra.movedFrom) ins.movedFrom = extra.movedFrom
|
|
93
|
+
for (const f of TEXT_FIELDS) {
|
|
94
|
+
if (raw[f] !== undefined && raw[f] !== null && raw[f] !== '') ins[f] = String(raw[f])
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(raw.steps) && raw.steps.length) ins.steps = raw.steps.map((s) => String(s))
|
|
97
|
+
for (const f of ARRAY_FIELDS) {
|
|
98
|
+
if (Array.isArray(raw[f]) && raw[f].length) ins[f] = [...new Set(raw[f].map((x) => String(x)))]
|
|
99
|
+
}
|
|
100
|
+
if (raw.trigger && typeof raw.trigger === 'object') {
|
|
101
|
+
const tr = { keywords: Array.isArray(raw.trigger.keywords) ? raw.trigger.keywords.map(String) : [] }
|
|
102
|
+
if (Array.isArray(raw.trigger.symbols)) tr.symbols = raw.trigger.symbols.map(String)
|
|
103
|
+
if (Array.isArray(raw.trigger.scope)) tr.scope = raw.trigger.scope.map(String)
|
|
104
|
+
if (tr.keywords.length) ins.trigger = tr
|
|
105
|
+
}
|
|
106
|
+
return ins
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function unionStrings(base = [], add = []) {
|
|
110
|
+
const out = new Set(base || [])
|
|
111
|
+
for (const x of add || []) if (x !== undefined && x !== null) out.add(String(x))
|
|
112
|
+
return [...out]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 把 base 合并进既有条目(content 加固、成员/文件/符号并集、置信取高、记命中)。 */
|
|
116
|
+
export function mergeInto(existing, base, cfg, nowIso) {
|
|
117
|
+
const now = nowIso || new Date().toISOString()
|
|
118
|
+
existing.sourceTaskIds = unionStrings(existing.sourceTaskIds, base.sourceTaskIds)
|
|
119
|
+
existing.files = unionStrings(existing.files, base.files)
|
|
120
|
+
existing.symbols = unionStrings(existing.symbols, base.symbols)
|
|
121
|
+
existing.tags = unionStrings(existing.tags, base.tags)
|
|
122
|
+
for (const [f, b] of [
|
|
123
|
+
['pattern', base.pattern],
|
|
124
|
+
['fix', base.fix],
|
|
125
|
+
['choice', base.choice],
|
|
126
|
+
['reason', base.reason],
|
|
127
|
+
['problem', base.problem],
|
|
128
|
+
['solution', base.solution],
|
|
129
|
+
['body', base.body],
|
|
130
|
+
]) {
|
|
131
|
+
if (b && !existing[f]) existing[f] = String(b)
|
|
132
|
+
}
|
|
133
|
+
if (!existing.steps && Array.isArray(base.steps) && base.steps.length) existing.steps = base.steps.map(String)
|
|
134
|
+
if (!existing.trigger && base.trigger) existing.trigger = base.trigger
|
|
135
|
+
existing.confidence = Math.max(num(existing.confidence, 0.5), num(base.confidence, 0.5))
|
|
136
|
+
existing.hitCount = (existing.hitCount || 0) + 1
|
|
137
|
+
existing.lastHitAt = now
|
|
138
|
+
existing.updatedAt = now
|
|
139
|
+
if (existing.archived) existing.archived = false // 重新被使用 → 复活
|
|
140
|
+
return existing
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function reinforceOnly(existing, base, nowIso) {
|
|
144
|
+
const now = nowIso || new Date().toISOString()
|
|
145
|
+
existing.sourceTaskIds = unionStrings(existing.sourceTaskIds, base.sourceTaskIds)
|
|
146
|
+
existing.hitCount = (existing.hitCount || 0) + 1
|
|
147
|
+
existing.lastHitAt = now
|
|
148
|
+
existing.updatedAt = now
|
|
149
|
+
if (existing.archived) existing.archived = false
|
|
150
|
+
return existing
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function allTaskInsights(store) {
|
|
154
|
+
const out = []
|
|
155
|
+
for (const t of store.getTasks() || []) for (const ins of t.insights || []) out.push(ins)
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function taskContaining(store, insightId) {
|
|
160
|
+
for (const t of store.getTasks() || []) {
|
|
161
|
+
if ((t.insights || []).some((i) => i.id === insightId)) return t
|
|
162
|
+
}
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function removeFromTask(store, insightId) {
|
|
167
|
+
const t = taskContaining(store, insightId)
|
|
168
|
+
if (!t) return
|
|
169
|
+
t.insights = (t.insights || []).filter((i) => i.id !== insightId)
|
|
170
|
+
store.markTasksDirty()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- 容量与衰减(归档代替硬删) ----
|
|
174
|
+
|
|
175
|
+
const DAY_MS = 86400000
|
|
176
|
+
|
|
177
|
+
function tsOf(ins, field) {
|
|
178
|
+
const v = ins[field]
|
|
179
|
+
return v ? Date.parse(v) || 0 : 0
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function activityOf(ins) {
|
|
183
|
+
return tsOf(ins, 'lastHitAt') || tsOf(ins, 'updatedAt') || tsOf(ins, 'createdAt') || 0
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function applyDecay(items, cfg, nowIso) {
|
|
187
|
+
const now = nowIso || new Date().toISOString()
|
|
188
|
+
const limit = Date.parse(now) - cfg.decayDays * DAY_MS
|
|
189
|
+
let archived = 0
|
|
190
|
+
for (const it of items) {
|
|
191
|
+
if (it.archived) continue
|
|
192
|
+
if ((it.hitCount || 0) > 0) continue
|
|
193
|
+
if (it.lastHitAt && Date.parse(it.lastHitAt) <= limit) {
|
|
194
|
+
it.archived = true
|
|
195
|
+
it.updatedAt = now
|
|
196
|
+
archived++
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return archived
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 超限先物理删归档里最不活跃的,再归档最不活跃的(下一轮被删),直到回落到上限。 */
|
|
203
|
+
export function pruneItems(items, max, nowIso) {
|
|
204
|
+
const now = nowIso || new Date().toISOString()
|
|
205
|
+
let removed = 0
|
|
206
|
+
let archivedNow = 0
|
|
207
|
+
while (items.length > max) {
|
|
208
|
+
const arch = items.filter((i) => i.archived).sort((a, b) => activityOf(a) - activityOf(b))
|
|
209
|
+
if (arch.length) {
|
|
210
|
+
const victim = arch[0]
|
|
211
|
+
const idx = items.indexOf(victim)
|
|
212
|
+
if (idx !== -1) items.splice(idx, 1)
|
|
213
|
+
removed++
|
|
214
|
+
} else {
|
|
215
|
+
const sorted = [...items].sort((a, b) => activityOf(a) - activityOf(b) || num(a.confidence, 0) - num(b.confidence, 0))
|
|
216
|
+
const victim = sorted[0]
|
|
217
|
+
if (!victim) break
|
|
218
|
+
victim.archived = true
|
|
219
|
+
victim.updatedAt = now
|
|
220
|
+
archivedNow++
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { removed, archived: archivedNow }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- Global 存储(~/.config/dsh-project-memory/global.json) ----
|
|
227
|
+
|
|
228
|
+
function readJson(file, fallback) {
|
|
229
|
+
let raw
|
|
230
|
+
try {
|
|
231
|
+
raw = readFileSync(file, 'utf8')
|
|
232
|
+
} catch {
|
|
233
|
+
return fallback
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
return JSON.parse(raw)
|
|
237
|
+
} catch {
|
|
238
|
+
const backup = `${file}.${Date.now()}.corrupt`
|
|
239
|
+
try {
|
|
240
|
+
renameSync(file, backup)
|
|
241
|
+
console.error(`[dsh-project-memory] corrupted ${path.basename(file)} moved to ${path.basename(backup)}; starting fresh`)
|
|
242
|
+
} catch {
|
|
243
|
+
// 保留现场,回退默认
|
|
244
|
+
}
|
|
245
|
+
return fallback
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function writeJsonAtomic(file, data) {
|
|
250
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
251
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
252
|
+
try {
|
|
253
|
+
writeFileSync(tmp, JSON.stringify(data))
|
|
254
|
+
renameSync(tmp, file)
|
|
255
|
+
} catch (err) {
|
|
256
|
+
try {
|
|
257
|
+
renameSync(tmp, file) // fsync 无关紧要:单文件覆盖 + 崩溃后 .tmp 清理即可
|
|
258
|
+
} catch {
|
|
259
|
+
/* ignore */
|
|
260
|
+
}
|
|
261
|
+
throw err
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function normalizeDoc(doc) {
|
|
266
|
+
if (doc && typeof doc === 'object' && Array.isArray(doc.items)) {
|
|
267
|
+
if (typeof doc.version !== 'number') doc.version = 1
|
|
268
|
+
if (!Array.isArray(doc.items)) doc.items = []
|
|
269
|
+
return doc
|
|
270
|
+
}
|
|
271
|
+
return emptyInsightsDoc()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export class GlobalStore {
|
|
275
|
+
constructor(file) {
|
|
276
|
+
this.file = file
|
|
277
|
+
this.doc = null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
load() {
|
|
281
|
+
if (!this.doc) this.doc = normalizeDoc(readJson(this.file, null))
|
|
282
|
+
return this
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
items() {
|
|
286
|
+
return this.doc.items
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
markDirty() {
|
|
290
|
+
this._dirty = true
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 变更提交:fn(this.doc) 后原子落盘。 */
|
|
294
|
+
commit(fn) {
|
|
295
|
+
const res = fn ? fn(this.doc, this) : undefined
|
|
296
|
+
if (this._dirty || res !== undefined) {
|
|
297
|
+
writeJsonAtomic(this.file, this.doc)
|
|
298
|
+
this._dirty = false
|
|
299
|
+
}
|
|
300
|
+
return res
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- 单条保存:scope 路由 ----
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 统一入口。scope: task 需要 taskId(store 内须存在该任务);project 走 store 的 insights 文档;
|
|
308
|
+
* global 走 globalStore。
|
|
309
|
+
* 返回 { ok, action: 'created'|'merged'|'reinforced'|'linked-project'|'linked-global'|'promoted'|…, id, hint }
|
|
310
|
+
*/
|
|
311
|
+
export function saveInsight({ store, globalStore, raw, scope, taskId, cfg, source = 'agent', nowIso }) {
|
|
312
|
+
const cfg2 = cfg || cfgInsight({})
|
|
313
|
+
const now = nowIso || new Date().toISOString()
|
|
314
|
+
const secretField = containsSecretFields(raw)
|
|
315
|
+
if (secretField) {
|
|
316
|
+
return { ok: false, error: `内容疑似包含密钥/token/私钥形态数据(字段: ${secretField}),已拒绝写入` }
|
|
317
|
+
}
|
|
318
|
+
const s = scope || 'project'
|
|
319
|
+
if (!INSIGHT_SCOPES.includes(s)) return { ok: false, error: `非法 scope: ${s}` }
|
|
320
|
+
if (s === 'global') {
|
|
321
|
+
if (!globalStore) return { ok: false, error: 'global 存储未初始化' }
|
|
322
|
+
const res = upsertIntoPool(globalStore.items(), raw, cfg2, now, { source, scope: 'global' })
|
|
323
|
+
if (res.action === 'created') pruneItems(globalStore.items(), cfg2.maxGlobalProcedures, now)
|
|
324
|
+
globalStore.markDirty()
|
|
325
|
+
return { ok: true, ...res, scope: 'global' }
|
|
326
|
+
}
|
|
327
|
+
if (s === 'task') {
|
|
328
|
+
if (!store || !taskId) return { ok: false, error: 'task 级写入需要 taskId' }
|
|
329
|
+
const task = store.getTask(taskId)
|
|
330
|
+
if (!task) return { ok: false, error: `任务不存在: ${taskId}` }
|
|
331
|
+
return saveTaskInsight(store, globalStore, task, raw, cfg2, now, source)
|
|
332
|
+
}
|
|
333
|
+
return saveProjectInsight(store, globalStore, raw, cfg2, now, source)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** 在 items 池内 upsert:≥ dedup 合并;≥ reinforceBand 强化;否则新建。raw 需已过密文检查。 */
|
|
337
|
+
function upsertIntoPool(items, raw, cfg, nowIso, extra = {}) {
|
|
338
|
+
const base = { ...raw, source: extra.source, sourceTaskIds: extra.sourceTaskIds || raw.sourceTaskIds }
|
|
339
|
+
const text = insightMatchText(base)
|
|
340
|
+
const hit = findBestOverlapMatch(text, items)
|
|
341
|
+
if (hit && hit.score >= cfg.dedupOverlap) {
|
|
342
|
+
mergeInto(hit.item, base, cfg, nowIso)
|
|
343
|
+
return { action: 'merged', id: hit.item.id }
|
|
344
|
+
}
|
|
345
|
+
if (hit && hit.score >= cfg.reinforceBand) {
|
|
346
|
+
reinforceOnly(hit.item, base, nowIso)
|
|
347
|
+
return { action: 'reinforced', id: hit.item.id }
|
|
348
|
+
}
|
|
349
|
+
const ins = normalizeInsight(base, { scope: extra.scope || 'project', source: extra.source, nowIso })
|
|
350
|
+
items.push(ins)
|
|
351
|
+
return { action: 'created', id: ins.id }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---- scope = project ----
|
|
355
|
+
|
|
356
|
+
function saveProjectInsight(store, globalStore, raw, cfg, now, source) {
|
|
357
|
+
const items = store.insightItems()
|
|
358
|
+
const res = upsertIntoPool(items, raw, cfg, now, { source, scope: 'project' })
|
|
359
|
+
store.replaceInsightItems(items) // 一律标脏(created/merged/reinforced 都改了数组)
|
|
360
|
+
applyDecay(items, cfg, now)
|
|
361
|
+
const pruned = pruneItems(items, cfg.maxProject, now)
|
|
362
|
+
const promoted = promoteProjectToGlobal(store, globalStore, cfg, now)
|
|
363
|
+
return { ok: true, ...res, scope: 'project', hint: buildHint(res, pruned, promoted) }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function buildHint(res, pruned, promoted) {
|
|
367
|
+
const bits = []
|
|
368
|
+
if (res.action === 'created') bits.push('已新建')
|
|
369
|
+
else if (res.action === 'merged') bits.push('已与既有条目合并')
|
|
370
|
+
else if (res.action === 'reinforced') bits.push('近重复,已强化命中')
|
|
371
|
+
if (pruned && pruned.removed) bits.push(`容量清理 ${pruned.removed} 条`)
|
|
372
|
+
if (promoted) bits.push(`自动提升 global ${promoted} 条`)
|
|
373
|
+
return bits.length ? bits.join(';') : ''
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---- scope = task ----
|
|
377
|
+
|
|
378
|
+
function saveTaskInsight(store, globalStore, task, raw, cfg, now, source) {
|
|
379
|
+
// 1) 已有 project 级知识 → 挂成员/命中,不再在 task 层重复存
|
|
380
|
+
const pjItems = store.insightItems()
|
|
381
|
+
const base = { ...raw, source, sourceTaskIds: [task.id] }
|
|
382
|
+
const text = insightMatchText(base)
|
|
383
|
+
const pjHit = findBestOverlapMatch(text, pjItems)
|
|
384
|
+
if (pjHit && pjHit.score >= cfg.dedupOverlap) {
|
|
385
|
+
mergeInto(pjHit.item, base, cfg, now)
|
|
386
|
+
store.replaceInsightItems(pjItems)
|
|
387
|
+
applyDecay(pjItems, cfg, now)
|
|
388
|
+
pruneItems(pjItems, cfg.maxProject, now)
|
|
389
|
+
const promoted = promoteProjectToGlobal(store, globalStore, cfg, now)
|
|
390
|
+
return { ok: true, action: 'linked-project', id: pjHit.item.id, scope: 'project', hint: `该知识已在 project 级,累计第 ${(pjHit.item.sourceTaskIds || []).length} 个任务命中${promoted ? ';已自动提升 global' : ''}` }
|
|
391
|
+
}
|
|
392
|
+
// 2) 已有 global 级知识 → 同样只挂命中
|
|
393
|
+
if (globalStore) {
|
|
394
|
+
const glHit = findBestOverlapMatch(text, globalStore.items())
|
|
395
|
+
if (glHit && glHit.score >= cfg.dedupOverlap) {
|
|
396
|
+
mergeInto(glHit.item, base, cfg, now)
|
|
397
|
+
globalStore.markDirty()
|
|
398
|
+
return { ok: true, action: 'linked-global', id: glHit.item.id, scope: 'global', hint: `该知识已在 global 级,累计第 ${(glHit.item.sourceTaskIds || []).length} 个任务命中` }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// 3) 跨任务池去重合并(同一坑只留一份,sourceTaskIds 累积)
|
|
402
|
+
const pool = allTaskInsights(store)
|
|
403
|
+
const hit = findBestOverlapMatch(text, pool)
|
|
404
|
+
if (hit && hit.score >= cfg.dedupOverlap) {
|
|
405
|
+
mergeInto(hit.item, base, cfg, now)
|
|
406
|
+
const holder = taskContaining(store, hit.item.id)
|
|
407
|
+
if (holder) {
|
|
408
|
+
store.markTasksDirty()
|
|
409
|
+
const holderId = holder.id
|
|
410
|
+
const promoted = promoteAllTasksToProject(store, globalStore, cfg, now)
|
|
411
|
+
const size = (hit.item.sourceTaskIds || []).length
|
|
412
|
+
return {
|
|
413
|
+
ok: true,
|
|
414
|
+
action: 'merged',
|
|
415
|
+
id: hit.item.id,
|
|
416
|
+
taskId: holderId,
|
|
417
|
+
hint: `跨任务去重:并入任务 ${holderId} 既有条目(现 ${size} 个任务命中)${promoted ? `;自动提升 project ${promoted} 条` : ''}`,
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// 4) 新建于当前任务
|
|
422
|
+
if (!Array.isArray(task.insights)) task.insights = []
|
|
423
|
+
const ins = normalizeInsight(raw, { scope: 'task', source, nowIso: now, sourceTaskIds: [task.id], confidence: raw.confidence })
|
|
424
|
+
task.insights.push(ins)
|
|
425
|
+
store.markTasksDirty()
|
|
426
|
+
return { ok: true, action: 'created', id: ins.id, taskId: task.id, hint: '已保存到任务级(草稿/私有)' }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---- 提升 = 字段变更 ----
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* task 池扫描:同一 insight 命中 ≥2 个任务 且 confidence ≥ promoteConfidence → 升 project。
|
|
433
|
+
* 先与 project 已有条目去重(重复则并成员并删 task 副本),否则移动。
|
|
434
|
+
* @returns 提升条数
|
|
435
|
+
*/
|
|
436
|
+
export function promoteAllTasksToProject(store, globalStore, cfg, now) {
|
|
437
|
+
const tasks = store.getTasks() || []
|
|
438
|
+
let promoted = 0
|
|
439
|
+
for (const task of tasks) {
|
|
440
|
+
const list = task.insights || []
|
|
441
|
+
for (const ins of [...list]) {
|
|
442
|
+
if (ins.scope && ins.scope !== 'task') continue
|
|
443
|
+
const members = new Set(ins.sourceTaskIds || [])
|
|
444
|
+
if (members.size < 2) continue
|
|
445
|
+
if (num(ins.confidence, 0) < cfg.promoteConfidence) continue
|
|
446
|
+
// 与 project 已有资产去重:重复 → 并成员,删 task 副本
|
|
447
|
+
const pjItems = store.insightItems()
|
|
448
|
+
const pjHit = findBestOverlapMatch(insightMatchText(ins), pjItems)
|
|
449
|
+
if (pjHit && pjHit.score >= cfg.dedupOverlap) {
|
|
450
|
+
mergeInto(pjHit.item, { sourceTaskIds: ins.sourceTaskIds, files: ins.files, symbols: ins.symbols, tags: ins.tags, confidence: ins.confidence }, cfg, now)
|
|
451
|
+
store.replaceInsightItems(pjItems)
|
|
452
|
+
removeFromTask(store, ins.id)
|
|
453
|
+
promoted++
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
const moved = {
|
|
457
|
+
...normalizeInsight(ins, { scope: 'project', source: ins.source, nowIso: now }),
|
|
458
|
+
id: ins.id,
|
|
459
|
+
draft: false,
|
|
460
|
+
confidence: num(ins.confidence, cfg.promoteConfidence),
|
|
461
|
+
hitCount: ins.hitCount || 0,
|
|
462
|
+
lastHitAt: ins.lastHitAt,
|
|
463
|
+
createdAt: ins.createdAt || now,
|
|
464
|
+
movedFrom: { scope: 'task', id: ins.id, at: now },
|
|
465
|
+
}
|
|
466
|
+
pjItems.push(moved)
|
|
467
|
+
store.replaceInsightItems(pjItems)
|
|
468
|
+
removeFromTask(store, ins.id)
|
|
469
|
+
promoted++
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (promoted) {
|
|
473
|
+
const items = store.insightItems()
|
|
474
|
+
applyDecay(items, cfg, now)
|
|
475
|
+
pruneItems(items, cfg.maxProject, now)
|
|
476
|
+
promoteProjectToGlobal(store, globalStore, cfg, now)
|
|
477
|
+
}
|
|
478
|
+
return promoted
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** project 扫描:sourceTaskIds ≥ globalPromoteTasks → 升 global。返回移动条数。 */
|
|
482
|
+
export function promoteProjectToGlobal(store, globalStore, cfg, now) {
|
|
483
|
+
if (!globalStore) return 0
|
|
484
|
+
const items = store.insightItems()
|
|
485
|
+
let moved = 0
|
|
486
|
+
for (const ins of [...items]) {
|
|
487
|
+
if (ins.scope && ins.scope !== 'project') continue
|
|
488
|
+
const members = new Set(ins.sourceTaskIds || [])
|
|
489
|
+
if (members.size < cfg.globalPromoteTasks) continue
|
|
490
|
+
const glItems = globalStore.items()
|
|
491
|
+
const glHit = findBestOverlapMatch(insightMatchText(ins), glItems)
|
|
492
|
+
if (glHit && glHit.score >= cfg.dedupOverlap) {
|
|
493
|
+
mergeInto(glHit.item, { sourceTaskIds: ins.sourceTaskIds, files: ins.files, symbols: ins.symbols, tags: ins.tags, confidence: ins.confidence }, cfg, now)
|
|
494
|
+
} else {
|
|
495
|
+
const movedIns = {
|
|
496
|
+
...normalizeInsight(ins, { scope: 'global', source: ins.source, nowIso: now }),
|
|
497
|
+
id: ins.id,
|
|
498
|
+
draft: false,
|
|
499
|
+
confidence: num(ins.confidence, cfg.promoteConfidence),
|
|
500
|
+
hitCount: ins.hitCount || 0,
|
|
501
|
+
lastHitAt: ins.lastHitAt,
|
|
502
|
+
createdAt: ins.createdAt || now,
|
|
503
|
+
movedFrom: { scope: 'project', id: ins.id, at: now },
|
|
504
|
+
}
|
|
505
|
+
glItems.push(movedIns)
|
|
506
|
+
}
|
|
507
|
+
items.splice(items.indexOf(ins), 1)
|
|
508
|
+
moved++
|
|
509
|
+
}
|
|
510
|
+
if (moved) {
|
|
511
|
+
store.replaceInsightItems(items)
|
|
512
|
+
globalStore.markDirty()
|
|
513
|
+
}
|
|
514
|
+
return moved
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ---- 降级(反向,PR3 UI 使用;现在提供最小实现) ----
|
|
518
|
+
|
|
519
|
+
export function demoteToProject(store, globalStore, id, nowIso) {
|
|
520
|
+
if (!globalStore) return { ok: false, error: 'global 存储未初始化' }
|
|
521
|
+
const now = nowIso || new Date().toISOString()
|
|
522
|
+
const idx = globalStore.items().findIndex((i) => i.id === id)
|
|
523
|
+
if (idx === -1) return { ok: false, error: `global 无此条目: ${id}` }
|
|
524
|
+
const ins = globalStore.items()[idx]
|
|
525
|
+
const items = store.insightItems()
|
|
526
|
+
const copy = normalizeInsight(ins, { scope: 'project', nowIso: now })
|
|
527
|
+
copy.id = `${ins.id}_d${Date.now().toString(36)}`
|
|
528
|
+
copy.movedFrom = { scope: 'global', id: ins.id, at: now }
|
|
529
|
+
items.push(copy)
|
|
530
|
+
store.replaceInsightItems(items)
|
|
531
|
+
globalStore.items().splice(idx, 1)
|
|
532
|
+
globalStore.markDirty()
|
|
533
|
+
return { ok: true, action: 'demoted', id: copy.id, hint: '已降级回 project' }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function demoteToTask(store, taskId, id, nowIso) {
|
|
537
|
+
const now = nowIso || new Date().toISOString()
|
|
538
|
+
const task = store.getTask(taskId)
|
|
539
|
+
if (!task) return { ok: false, error: `任务不存在: ${taskId}` }
|
|
540
|
+
const items = store.insightItems()
|
|
541
|
+
const idx = items.findIndex((i) => i.id === id)
|
|
542
|
+
if (idx === -1) return { ok: false, error: `project 无此条目: ${id}` }
|
|
543
|
+
const ins = items[idx]
|
|
544
|
+
if (!Array.isArray(task.insights)) task.insights = []
|
|
545
|
+
const copy = normalizeInsight(ins, { scope: 'task', nowIso: now })
|
|
546
|
+
copy.id = `${ins.id}_d${Date.now().toString(36)}`
|
|
547
|
+
copy.movedFrom = { scope: 'project', id: ins.id, at: now }
|
|
548
|
+
task.insights.push(copy)
|
|
549
|
+
items.splice(idx, 1)
|
|
550
|
+
store.replaceInsightItems(items)
|
|
551
|
+
store.markTasksDirty()
|
|
552
|
+
return { ok: true, action: 'demoted', id: copy.id, hint: '已降级到任务级' }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** 便捷:直接对归一化后文本算重叠(测试用)。 */
|
|
556
|
+
export function overlapOf(a, b) {
|
|
557
|
+
return normalizedTokenOverlap(String(a), String(b))
|
|
558
|
+
}
|
|
@@ -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
|
+
}
|