@yolk_vat-y/dsh-project-memory 0.5.6 → 0.5.7
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +2 -2
- package/src/auto-inject.js +346 -281
- package/src/chunker.js +14 -6
- package/src/commands/insight-actions.js +18 -14
- package/src/commands/invocation.js +36 -0
- package/src/commands/task-actions.js +24 -39
- package/src/commands/tasks.js +18 -13
- package/src/enhancer.js +11 -2
- package/src/index-pipeline.js +119 -0
- package/src/insight-store.js +57 -28
- package/src/lazy.js +15 -46
- package/src/link.js +10 -1
- package/src/parsers/pdfjs-parser.js +2 -4
- package/src/project-profile.js +12 -5
- package/src/readiness.js +27 -3
- package/src/recall.js +28 -8
- package/src/setup/taskbridge.js +11 -9
- package/src/store.js +56 -17
- package/src/symbols.js +58 -19
- package/src/tools/forget.js +2 -1
- package/src/tools/index-doc.js +4 -1
- package/src/tools/index-repo.js +33 -86
- package/src/tools/lesson-tools.js +2 -1
- package/src/tools/query-memory.js +17 -3
- package/src/tools/remember.js +3 -1
- package/src/tools/task-tools.js +4 -1
- package/src/tools/watch-repo.js +9 -5
- package/src/util/fs.js +14 -0
- package/src/util/session-cache.js +72 -0
- package/src/watch.js +39 -81
package/src/chunker.js
CHANGED
|
@@ -20,17 +20,25 @@ export function chunkText(text, chunkChars = 3000, maxChunks = 40) {
|
|
|
20
20
|
|
|
21
21
|
const chunks = []
|
|
22
22
|
for (const section of sections) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
const rawBlock = section.lines.join('\n')
|
|
24
|
+
if (!rawBlock.trim()) continue
|
|
25
|
+
// `.trim()` 会吃掉段首的空白与换行——把它们算进行号,否则整段的引用行号会偏低。
|
|
26
|
+
const lead = rawBlock.match(/^\s*/)[0]
|
|
27
|
+
let line = section.line + (lead.match(/\n/g) || []).length
|
|
28
|
+
let block = rawBlock.trim()
|
|
26
29
|
while (block.length > chunkChars) {
|
|
27
30
|
let splitAt = block.lastIndexOf('\n\n', chunkChars)
|
|
28
31
|
if (splitAt < chunkChars * 0.5) splitAt = block.lastIndexOf(' ', chunkChars)
|
|
29
32
|
if (splitAt < chunkChars * 0.5) splitAt = chunkChars
|
|
30
|
-
const
|
|
33
|
+
const rawPart = block.slice(0, splitAt)
|
|
34
|
+
const part = rawPart.trim()
|
|
31
35
|
if (part) chunks.push({ title: section.title, text: part, line })
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
// 行号按**原始**切片里的换行数推进,再加上被下一次 trim 吃掉的段首换行。
|
|
37
|
+
// 旧写法 `part.split('\n').length` 每次跨行切分都要多算一行,误差会累积。
|
|
38
|
+
const rest = block.slice(splitAt)
|
|
39
|
+
const nextLead = rest.match(/^\s*/)[0]
|
|
40
|
+
line += (rawPart.match(/\n/g) || []).length + (nextLead.match(/\n/g) || []).length
|
|
41
|
+
block = rest.trim()
|
|
34
42
|
if (chunks.length >= maxChunks) break
|
|
35
43
|
}
|
|
36
44
|
if (block) {
|
|
@@ -6,6 +6,7 @@ import { ProjectMemoryStore } from '../store.js'
|
|
|
6
6
|
import { memoryRootFor } from '../util/fs.js'
|
|
7
7
|
import { cfgInsight, GlobalStore, normalizeInsight, defaultGlobalFile, saveInsight, INSIGHT_KINDS } from '../insight-store.js'
|
|
8
8
|
import { projectRootFor } from '../setup/taskbridge.js'
|
|
9
|
+
import { fencedJson, invocationContext } from './invocation.js'
|
|
9
10
|
|
|
10
11
|
function insSummary(ins, extra = {}) {
|
|
11
12
|
const s = ins || {}
|
|
@@ -225,7 +226,9 @@ const KIND_CONTENT = {
|
|
|
225
226
|
procedure: ['steps', 'trigger'],
|
|
226
227
|
experience: ['problem', 'solution'],
|
|
227
228
|
}
|
|
228
|
-
|
|
229
|
+
// 切 kind 时要清掉的内容字段。**不含 trigger**:任何 kind 都可以带 authored trigger,
|
|
230
|
+
// 放进这里会在 lesson↔decision 之间切换时把自动注入能力一并删掉。
|
|
231
|
+
const ALL_CONTENT = ['body', 'pattern', 'fix', 'problem', 'solution', 'choice', 'reason', 'steps']
|
|
229
232
|
|
|
230
233
|
/** 编辑既有条目(title/kind/正文/trigger 等白名单字段)。任务级按 id 在任务池定位。 */
|
|
231
234
|
export function editMemoryItem({ store, gs, scope, id, fields }) {
|
|
@@ -244,10 +247,18 @@ export function editMemoryItem({ store, gs, scope, id, fields }) {
|
|
|
244
247
|
}
|
|
245
248
|
if (key === 'trigger') {
|
|
246
249
|
if (!v || typeof v !== 'object') continue
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
250
|
+
// 合并而非重建:只改传进来的成员,when/guard/prevents 等 authored 触发面必须原样保留,
|
|
251
|
+
// 否则一次 /insight edit 就会把自动注入的 trigger 降级成 legacy keywords。
|
|
252
|
+
const next = { ...(item.trigger || {}) }
|
|
253
|
+
if (Array.isArray(v.keywords)) next.keywords = v.keywords.map(String)
|
|
254
|
+
if (Array.isArray(v.scope)) next.scope = v.scope.map(String)
|
|
255
|
+
if (Array.isArray(v.actions)) next.actions = v.actions.map(String)
|
|
256
|
+
if (Array.isArray(v.paths)) next.paths = v.paths.map(String)
|
|
257
|
+
if (Array.isArray(v.symbols)) next.symbols = v.symbols.map(String)
|
|
258
|
+
if (v.when && typeof v.when === 'object') next.when = v.when
|
|
259
|
+
if (v.guard && typeof v.guard === 'object') next.guard = v.guard
|
|
260
|
+
if (typeof v.prevents === 'string') next.prevents = v.prevents
|
|
261
|
+
item.trigger = next
|
|
251
262
|
changed = true
|
|
252
263
|
continue
|
|
253
264
|
}
|
|
@@ -300,10 +311,6 @@ export function editMemoryItem({ store, gs, scope, id, fields }) {
|
|
|
300
311
|
return { ok: true, text: '已更新记忆条目' }
|
|
301
312
|
}
|
|
302
313
|
|
|
303
|
-
function jsonText(payload, note) {
|
|
304
|
-
return `${note}\n\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``
|
|
305
|
-
}
|
|
306
|
-
|
|
307
314
|
/** /insight <verb> <args…>:list [task|project|global] | confirm/promote/demote/archive/restore/delete <id> [taskId] */
|
|
308
315
|
export function insightCommandDefinition(config, ctx) {
|
|
309
316
|
return {
|
|
@@ -312,10 +319,7 @@ export function insightCommandDefinition(config, ctx) {
|
|
|
312
319
|
input: { hint: 'list [scope] | <动作> <scope> <id> [taskId]' },
|
|
313
320
|
handler: (invocation) => {
|
|
314
321
|
try {
|
|
315
|
-
const
|
|
316
|
-
const sid = agent?.id || agent?.session?.id
|
|
317
|
-
const session = sid && agent?.ctx ? agent.ctx.sessions?.get(sid) : (agent?.session || null)
|
|
318
|
-
const cwd = session?.header?.cwd || agent?.session?.header?.cwd
|
|
322
|
+
const { cwd } = invocationContext(invocation)
|
|
319
323
|
const lineRaw = (invocation?.rawInput || '').trim()
|
|
320
324
|
const raw = lineRaw.split(/\s+/).filter(Boolean)
|
|
321
325
|
const root = projectRootFor(cwd)
|
|
@@ -326,7 +330,7 @@ export function insightCommandDefinition(config, ctx) {
|
|
|
326
330
|
const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
|
|
327
331
|
const gs = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
|
|
328
332
|
const payload = listInsights(store, gs, scope)
|
|
329
|
-
return { kind: 'success', text:
|
|
333
|
+
return { kind: 'success', text: fencedJson(`记忆 ${scope}:${payload.count} 条`, JSON.stringify(payload)) }
|
|
330
334
|
}
|
|
331
335
|
if (verb === 'save') {
|
|
332
336
|
// /insight save <scope> <json>(新建/合并;project|global)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 命令处理器的公共接线:/tasks、/task、/insight 三个定义此前各自复制了
|
|
3
|
+
* 「解析 CommandInvocation → 会话与 cwd」和「拼 JSON 围栏载荷」两段代码。
|
|
4
|
+
* 集中在这里,语义只在一处维护。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 从命令调用里解出会话上下文。
|
|
9
|
+
*
|
|
10
|
+
* `sid` 优先取 `agent.id`;`session` 优先走 `sessions` 服务(拿到的是宿主实时会话,
|
|
11
|
+
* 带 append 等能力),服务缺失或不可用时退回 `agent.session`。
|
|
12
|
+
* @param {object} invocation - 宿主 CommandInvocation(handler 的入参)。
|
|
13
|
+
* @returns {{agent: object|undefined, sid: string|undefined, session: object|null, cwd: string|undefined}}
|
|
14
|
+
*/
|
|
15
|
+
export function invocationContext(invocation) {
|
|
16
|
+
const agent = invocation?.agent
|
|
17
|
+
const sid = agent?.id || agent?.session?.id
|
|
18
|
+
const fromService = sid && agent?.ctx ? agent.ctx.sessions?.get(sid) : null
|
|
19
|
+
// sessions 服务缺失**或查不到**时都退回 agent.session(文档承诺的降级路径)。
|
|
20
|
+
// 旧写法只在 agent.ctx 不存在时退回:服务在、但 get 返回 undefined 时 session 直接是 undefined,
|
|
21
|
+
// /task switch|todos|unbind 于是静默跳过 session.append(宿主 todo 同步失效)。
|
|
22
|
+
const session = fromService || agent?.session || null
|
|
23
|
+
const cwd = session?.header?.cwd || agent?.session?.header?.cwd
|
|
24
|
+
return { agent, sid, session, cwd }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 统一的命令输出载荷:人类可读摘要 + ```json 块。
|
|
29
|
+
* 客户端命令节点按尾部 JSON 解析并渲染面板,因此三个命令共用同一格式。
|
|
30
|
+
* @param {string} note - 人类可读摘要(可多行)。
|
|
31
|
+
* @param {string} payload - 已序列化的 JSON 载荷。
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function fencedJson(note, payload) {
|
|
35
|
+
return `${note}\n\n\`\`\`json\n${payload}\n\`\`\``
|
|
36
|
+
}
|
|
@@ -8,8 +8,7 @@ import { projectRootFor, adoptStepsToSession, shouldAdoptToHost } from '../setup
|
|
|
8
8
|
import { renderTaskSnapshot, buildTaskPayload } from './tasks.js'
|
|
9
9
|
import { fireReflect } from '../reflection-pipeline.js'
|
|
10
10
|
import { resolveRoute } from '../llm-route.js'
|
|
11
|
-
|
|
12
|
-
const VERBS = { switch: 'switch', archive: 'archive' }
|
|
11
|
+
import { fencedJson, invocationContext } from './invocation.js'
|
|
13
12
|
|
|
14
13
|
function describeTask(t) {
|
|
15
14
|
const done = (t.steps || []).filter((s) => s.status === 'completed').length
|
|
@@ -17,6 +16,23 @@ function describeTask(t) {
|
|
|
17
16
|
return `「${t.title}」(步骤 ${done}/${total})`
|
|
18
17
|
}
|
|
19
18
|
|
|
19
|
+
/**
|
|
20
|
+
* raw 去掉前 n 个空白分隔 token 后的**原始剩余文本**(保留内部空白与引号)。
|
|
21
|
+
* 旧写法 `raw.split(/\s+/).slice(n).join(' ')` 会把标题/JSON 里的连续空格压成一个,
|
|
22
|
+
* `/task rename <id> "a b"` 于是改名成 "a b"。
|
|
23
|
+
*/
|
|
24
|
+
function restAfter(raw, n) {
|
|
25
|
+
let i = 0
|
|
26
|
+
let seen = 0
|
|
27
|
+
while (i < raw.length && seen < n) {
|
|
28
|
+
while (i < raw.length && /\s/.test(raw[i])) i++
|
|
29
|
+
if (i >= raw.length) break
|
|
30
|
+
while (i < raw.length && !/\s/.test(raw[i])) i++
|
|
31
|
+
seen++
|
|
32
|
+
}
|
|
33
|
+
return raw.slice(i).replace(/^\s+/, '')
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
function withTaskSnapshot(config, cwd, sid, store, note) {
|
|
21
37
|
const tasks = store.getTasks()
|
|
22
38
|
const active = tasks.filter((t) => !t.archived)
|
|
@@ -24,7 +40,7 @@ function withTaskSnapshot(config, cwd, sid, store, note) {
|
|
|
24
40
|
const boundId = sid ? store.getBoundTaskId(sid) : null
|
|
25
41
|
const human = `项目 ${projectRootFor(cwd)}\n${note}\n任务: ${active.length} 套${archived ? `(归档 ${archived})` : ''}`
|
|
26
42
|
const payload = buildTaskPayload(active, boundId, archived)
|
|
27
|
-
return { kind: 'success', text:
|
|
43
|
+
return { kind: 'success', text: fencedJson(human, payload) }
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
export function taskCommandDefinition(config, ctx) {
|
|
@@ -34,10 +50,7 @@ export function taskCommandDefinition(config, ctx) {
|
|
|
34
50
|
input: { hint: 'switch|archive <任务id> | unbind' },
|
|
35
51
|
handler: (invocation) => {
|
|
36
52
|
try {
|
|
37
|
-
const
|
|
38
|
-
const sid = agent?.id || agent?.session?.id
|
|
39
|
-
const session = sid && agent?.ctx ? agent.ctx.sessions?.get(sid) : (agent?.session || null)
|
|
40
|
-
const cwd = session?.header?.cwd || agent?.session?.header?.cwd
|
|
53
|
+
const { session, sid, cwd } = invocationContext(invocation)
|
|
41
54
|
const raw = (invocation?.rawInput || '').trim()
|
|
42
55
|
const [verb, taskId, ...rest] = raw.split(/\s+/)
|
|
43
56
|
|
|
@@ -70,7 +83,7 @@ export function taskCommandDefinition(config, ctx) {
|
|
|
70
83
|
// /task rename <任务id> <json标题> —— 双击任务标题改名
|
|
71
84
|
if (verb === 'rename') {
|
|
72
85
|
if (!taskId) return { kind: 'error', text: '[task] rename 需要任务 id(由卡片双击调用)' }
|
|
73
|
-
const payload = raw
|
|
86
|
+
const payload = restAfter(raw, 2).trim()
|
|
74
87
|
let title = payload
|
|
75
88
|
try {
|
|
76
89
|
const parsed = JSON.parse(payload)
|
|
@@ -92,7 +105,7 @@ export function taskCommandDefinition(config, ctx) {
|
|
|
92
105
|
// /task todos <json> —— 卡片双击编辑步骤后,把整份新清单写回绑定任务并推宿主
|
|
93
106
|
// (与模型 todo_write 同一套数据,只是发起方是用户面板)
|
|
94
107
|
if (verb === 'todos') {
|
|
95
|
-
const rawJson = raw
|
|
108
|
+
const rawJson = restAfter(raw, 1).trim()
|
|
96
109
|
let todos
|
|
97
110
|
try {
|
|
98
111
|
todos = JSON.parse(rawJson)
|
|
@@ -126,7 +139,7 @@ export function taskCommandDefinition(config, ctx) {
|
|
|
126
139
|
return withTaskSnapshot(config, cwd, sid, store, note)
|
|
127
140
|
}
|
|
128
141
|
|
|
129
|
-
if (verb ===
|
|
142
|
+
if (verb === 'switch') {
|
|
130
143
|
const task = store.getTask(taskId)
|
|
131
144
|
if (!task) return { kind: 'error', text: `[task] 找不到任务: ${taskId}(/tasks 查看)` }
|
|
132
145
|
const prevBound = sid ? store.getBoundTaskId(sid) : null
|
|
@@ -149,7 +162,7 @@ export function taskCommandDefinition(config, ctx) {
|
|
|
149
162
|
return withTaskSnapshot(config, cwd, sid, store, note)
|
|
150
163
|
}
|
|
151
164
|
|
|
152
|
-
if (verb ===
|
|
165
|
+
if (verb === 'archive') {
|
|
153
166
|
const task = store.getTask(taskId)
|
|
154
167
|
if (!task) return { kind: 'error', text: `[task] 找不到任务: ${taskId}(/tasks 查看)` }
|
|
155
168
|
if (task.archived) return { kind: 'error', text: `[task] 已归档: ${task.title}` }
|
|
@@ -169,31 +182,3 @@ export function taskCommandDefinition(config, ctx) {
|
|
|
169
182
|
},
|
|
170
183
|
}
|
|
171
184
|
}
|
|
172
|
-
|
|
173
|
-
// 保留语义别名:/task-switch、/task-archive(仅当前端/旧入口用)
|
|
174
|
-
export function taskSwitchCommandDefinition(config) {
|
|
175
|
-
const base = taskCommandDefinition(config)
|
|
176
|
-
return {
|
|
177
|
-
...base,
|
|
178
|
-
name: 'task-switch',
|
|
179
|
-
handler: (invocation) => {
|
|
180
|
-
const id = (invocation?.rawInput || '').trim().split(/\s+/)[0]
|
|
181
|
-
return base.handler({ ...invocation, rawInput: id ? `switch ${id}` : '' })
|
|
182
|
-
},
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export function taskArchiveCommandDefinition(config) {
|
|
187
|
-
const base = taskCommandDefinition(config)
|
|
188
|
-
return {
|
|
189
|
-
...base,
|
|
190
|
-
name: 'task-archive',
|
|
191
|
-
handler: (invocation) => {
|
|
192
|
-
const id = (invocation?.rawInput || '').trim().split(/\s+/)[0]
|
|
193
|
-
return base.handler({ ...invocation, rawInput: id ? `archive ${id}` : '' })
|
|
194
|
-
},
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// renderTaskSnapshot 引用保留,供外部(测试/工具)使用
|
|
199
|
-
export { renderTaskSnapshot }
|
package/src/commands/tasks.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { memoryRootFor } from '../util/fs.js'
|
|
5
5
|
import { ProjectMemoryStore } from '../store.js'
|
|
6
6
|
import { projectRootFor } from '../setup/taskbridge.js'
|
|
7
|
+
import { fencedJson, invocationContext } from './invocation.js'
|
|
7
8
|
|
|
8
9
|
function timeAgo(iso) {
|
|
9
10
|
const diff = Date.now() - new Date(iso).getTime()
|
|
@@ -15,12 +16,21 @@ function timeAgo(iso) {
|
|
|
15
16
|
return `${Math.floor(h / 24)}天前`
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/** 步骤在历史数据里可能是字符串,也可能是 {content|text, status}:统一取文本与状态。 */
|
|
20
|
+
function stepContent(s) {
|
|
21
|
+
if (typeof s === 'string') return s
|
|
22
|
+
return s?.content ?? s?.text ?? ''
|
|
23
|
+
}
|
|
24
|
+
function stepStatus(s) {
|
|
25
|
+
return typeof s === 'string' ? 'pending' : (s?.status || 'pending')
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
export function buildTaskPayload(tasks, boundId, archived) {
|
|
19
29
|
return JSON.stringify({
|
|
20
30
|
tasks: tasks.map((t) => ({
|
|
21
31
|
id: t.id,
|
|
22
32
|
title: t.title,
|
|
23
|
-
steps: (t.steps || []).map((s) => ({ content: s
|
|
33
|
+
steps: (t.steps || []).map((s) => ({ content: stepContent(s), status: stepStatus(s) })),
|
|
24
34
|
files: (t.files || []).map((f) => ({ path: f, line: undefined })),
|
|
25
35
|
lastActiveAt: t.lastActiveAt,
|
|
26
36
|
updatedAt: t.updatedAt,
|
|
@@ -47,18 +57,16 @@ export function renderTaskSnapshot(config, cwd, sid) {
|
|
|
47
57
|
|
|
48
58
|
if (!active.length) {
|
|
49
59
|
const payload = buildTaskPayload([], boundId, archived)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
text: `项目 ${root}\n任务记录: 0 套${archived ? `(归档 ${archived})` : ''}。让模型开始干活并维护 todo 清单后会自动建档。\n\n\`\`\`json\n${payload}\n\`\`\``,
|
|
53
|
-
}
|
|
60
|
+
const note = `项目 ${root}\n任务记录: 0 套${archived ? `(归档 ${archived})` : ''}。让模型开始干活并维护 todo 清单后会自动建档。`
|
|
61
|
+
return { kind: 'success', text: fencedJson(note, payload) }
|
|
54
62
|
}
|
|
55
63
|
const lines = [`项目 ${root}`, `任务: ${active.length} 套${archived ? `(归档 ${archived})` : ''}`, '']
|
|
56
64
|
for (const t of active) {
|
|
57
|
-
const done = (t.steps || []).filter((s) => s
|
|
65
|
+
const done = (t.steps || []).filter((s) => stepStatus(s) === 'completed').length
|
|
58
66
|
const total = (t.steps || []).length
|
|
59
67
|
const progress = total ? `${done}/${total}` : '无步骤'
|
|
60
|
-
const inProgress = (t.steps || []).find((s) => s
|
|
61
|
-
const step = inProgress ? ` · 当前: ${inProgress
|
|
68
|
+
const inProgress = (t.steps || []).find((s) => stepStatus(s) === 'in_progress')
|
|
69
|
+
const step = inProgress ? ` · 当前: ${stepContent(inProgress)}` : ''
|
|
62
70
|
const marker = bound && bound.id === t.id ? '(本会话绑定)' : ''
|
|
63
71
|
const files = (t.files || []).slice(0, 8)
|
|
64
72
|
const fileLine = files.length ? `\n 文件: ${files.join(', ')}${t.files.length > 8 ? ' …' : ''}` : ''
|
|
@@ -67,7 +75,7 @@ export function renderTaskSnapshot(config, cwd, sid) {
|
|
|
67
75
|
lines.push('', '续接/改名/归档:直接告诉模型(list_tasks / select_task / archive_task)。')
|
|
68
76
|
const humanText = lines.join('\n')
|
|
69
77
|
const payload = buildTaskPayload(active, boundId, archived)
|
|
70
|
-
return { kind: 'success', text:
|
|
78
|
+
return { kind: 'success', text: fencedJson(humanText, payload) }
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
export function tasksCommandDefinition(config, ctx) {
|
|
@@ -76,10 +84,7 @@ export function tasksCommandDefinition(config, ctx) {
|
|
|
76
84
|
description: '查看本项目的任务清单(几套任务、进度、涉及文件、当前绑定)',
|
|
77
85
|
handler: (invocation) => {
|
|
78
86
|
try {
|
|
79
|
-
const
|
|
80
|
-
const sid = agent?.id || agent?.session?.id
|
|
81
|
-
const session = sid && agent?.ctx ? agent.ctx.sessions?.get(sid) : (agent?.session || null)
|
|
82
|
-
const cwd = session?.header?.cwd || agent?.session?.header?.cwd
|
|
87
|
+
const { cwd, sid } = invocationContext(invocation)
|
|
83
88
|
return renderTaskSnapshot(config, cwd, sid)
|
|
84
89
|
} catch (err) {
|
|
85
90
|
return { kind: 'error', text: `[tasks] ${err?.message || err}` }
|
package/src/enhancer.js
CHANGED
|
@@ -19,7 +19,14 @@ function acceptTsVersion(mod) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function initTypeScript(config) {
|
|
22
|
-
if (config?.enableTypeScript === false)
|
|
22
|
+
if (config?.enableTypeScript === false) {
|
|
23
|
+
// 显式关闭要把已解析的模块放掉:否则 enqueueEnhance 的 `if (!ts)` 仍会放行,
|
|
24
|
+
// 配置上关了、增强器照跑。
|
|
25
|
+
ts = null
|
|
26
|
+
tsPath = null
|
|
27
|
+
tsVersion = null
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
23
30
|
if (ts) return ts
|
|
24
31
|
|
|
25
32
|
// 1. 配置指定路径
|
|
@@ -265,7 +272,9 @@ export function deepParseWithTS(filePath, content) {
|
|
|
265
272
|
line: getLine(node)
|
|
266
273
|
})
|
|
267
274
|
} else if (ts.isTypeAliasDeclaration(node)) {
|
|
268
|
-
|
|
275
|
+
// checker.getTypeAtLocation(node.type) 对别名返回的是**别名自身**,于是
|
|
276
|
+
// `type X = { a: string }` 变成 `X= X -- f:1`。直接用类型节点的源码文本。
|
|
277
|
+
const typeStr = node.type ? node.type.getText().replace(/\s+/g, ' ').slice(0, 200) : 'any'
|
|
269
278
|
symbols.push({
|
|
270
279
|
name: node.name.getText(),
|
|
271
280
|
kind: 'type',
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// 索引一个文件的**唯一**实现。
|
|
2
|
+
//
|
|
3
|
+
// 这段逻辑以前在 index_repo(批量)、watch(轮询)、lazy(fs/observed 懒索引)里各抄一份,
|
|
4
|
+
// 三份随后各自打补丁、已经漂移:index_repo 多一道冗余的 dump 预检、体积超限时删旧记录,
|
|
5
|
+
// 而 watch/lazy 保留旧记录;terms 回填与单次读盘的写法也各不相同。
|
|
6
|
+
// 现在「这个文件该不该读、读出来该写什么条目」只在这里定义一次;
|
|
7
|
+
// 调用方只保留自己的编排(批量计数、watch 的 mtime 快照、lazy 的根解析与副作用)。
|
|
8
|
+
import { statSync } from 'node:fs'
|
|
9
|
+
import { isSupportedCode, isSupportedDoc, readFileForIndex } from './util/fs.js'
|
|
10
|
+
import { buildDocEntries } from './doc-pipeline.js'
|
|
11
|
+
import { docEntriesNeedBackfill } from './doc-index.js'
|
|
12
|
+
import { scanSymbols } from './symbols.js'
|
|
13
|
+
import { linkEntries } from './link.js'
|
|
14
|
+
|
|
15
|
+
/** 不索引的后缀。 */
|
|
16
|
+
export const UNSUPPORTED = 'unsupported'
|
|
17
|
+
/** 代码文件超过 maxFileSizeMb:读盘成本不抵符号表收益。 */
|
|
18
|
+
export const OVERSIZE = 'oversize'
|
|
19
|
+
/** 内容哈希未变,且无需回填词项。 */
|
|
20
|
+
export const UNCHANGED = 'unchanged'
|
|
21
|
+
/** 文档不可索引(dump / 抽取返回 null)→ 从 store 移除。 */
|
|
22
|
+
export const DROP = 'drop'
|
|
23
|
+
/** 有内容要写入 store。 */
|
|
24
|
+
export const WRITE = 'write'
|
|
25
|
+
|
|
26
|
+
/** 后缀 → 'code' | 'doc' | null。 */
|
|
27
|
+
export function fileKind(ext) {
|
|
28
|
+
if (isSupportedCode(ext)) return 'code'
|
|
29
|
+
if (isSupportedDoc(ext)) return 'doc'
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function exceedsSizeCap(kind, size, config) {
|
|
34
|
+
return kind === 'code' && !!config.maxFileSizeMb && size > config.maxFileSizeMb * 1024 * 1024
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 读取 + 判重 + 抽条目。抛出的异常(读盘失败、PDF 解析失败……)由调用方按各自策略处理:
|
|
39
|
+
* index_repo 收进 failures 汇总,watch 去重打印并可重试,lazy 记一行日志。
|
|
40
|
+
*
|
|
41
|
+
* @returns {{key: string, hash?: string, size?: number, type?: string, entries?: object[]}}
|
|
42
|
+
* key 为 UNSUPPORTED / OVERSIZE / UNCHANGED / DROP / WRITE 之一。
|
|
43
|
+
* @param {object} args
|
|
44
|
+
* @param {number} [args.size] 调用方已 statSync 时传入,避免重复 stat。
|
|
45
|
+
* @param {boolean} [args.force] index_repo 的 reindex:忽略哈希跳过。
|
|
46
|
+
*/
|
|
47
|
+
export async function planFileIndex({ rel, filePath, kind, config, record, existingEntries = [], size, force = false }) {
|
|
48
|
+
if (!kind) return { key: UNSUPPORTED }
|
|
49
|
+
const fileSize = typeof size === 'number' ? size : statSync(filePath).size
|
|
50
|
+
if (exceedsSizeCap(kind, fileSize, config)) return { key: OVERSIZE }
|
|
51
|
+
|
|
52
|
+
// 单次读盘:同一 buffer 供内容哈希与正文解码使用(未变更的文件不解码)。
|
|
53
|
+
const { hash, buffer } = readFileForIndex(filePath)
|
|
54
|
+
// 旧 store 的 doc 条目缺 terms → 即使哈希未变也重抽一次(一次性回填)。
|
|
55
|
+
const backfill = kind === 'doc' && docEntriesNeedBackfill(existingEntries)
|
|
56
|
+
if (!force && record && record.sha256 === hash && !backfill) return { key: UNCHANGED }
|
|
57
|
+
|
|
58
|
+
if (kind === 'code') {
|
|
59
|
+
return { key: WRITE, hash, size: fileSize, type: 'code', entries: scanSymbols(rel, filePath, buffer.toString('utf8')) }
|
|
60
|
+
}
|
|
61
|
+
// dump 判定在 buildDocEntries 内部(唯一真源):不可索引的文档返回 null。
|
|
62
|
+
const entries = await buildDocEntries(rel, filePath, {
|
|
63
|
+
chunkChars: config.chunkChars,
|
|
64
|
+
maxChunks: config.maxChunksPerFile,
|
|
65
|
+
maxFileSizeMb: config.maxFileSizeMb,
|
|
66
|
+
maxPdfPages: config.maxPdfPages,
|
|
67
|
+
})
|
|
68
|
+
return entries === null ? { key: DROP } : { key: WRITE, hash, size: fileSize, type: 'doc', entries }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* plan → 落盘用的更新记录。只接受 DROP / WRITE:UNCHANGED / OVERSIZE 由调用方自己跳过,
|
|
73
|
+
* 误传会直接抛错——静默写进一条 entries=undefined 的记录才是真正的坑。
|
|
74
|
+
* `expectedHash` 是 CAS 基准:扫描之后文件又被改动时,commit 会拒绝这次写入,
|
|
75
|
+
* 调用方据此回滚自己的快照(见 commitFileUpdates 的返回值)。
|
|
76
|
+
*/
|
|
77
|
+
export function toFileUpdate(rel, plan, record) {
|
|
78
|
+
if (plan.key === DROP) return { rel, drop: true }
|
|
79
|
+
if (plan.key !== WRITE) throw new Error(`toFileUpdate: unexpected plan key ${plan.key}`)
|
|
80
|
+
return {
|
|
81
|
+
rel,
|
|
82
|
+
expectedHash: record?.sha256 ?? null,
|
|
83
|
+
hash: plan.hash,
|
|
84
|
+
size: plan.size,
|
|
85
|
+
type: plan.type,
|
|
86
|
+
entries: plan.entries,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 一批更新一次性落盘:写入 / 移除 → 清掉本轮未见到的旧条目 → 重建链接。
|
|
92
|
+
* 单事务的好处是 store 只 save 一次,watch 每轮不会反复重写。
|
|
93
|
+
*
|
|
94
|
+
* @returns {{stale: string[], removed: number}} stale 是 CAS 失败(并发改动)的 rel,
|
|
95
|
+
* 调用方应让它们保持「未落快照」状态,下一轮重试。
|
|
96
|
+
*/
|
|
97
|
+
export function commitFileUpdates(store, { updates, unseen = null, link = true }) {
|
|
98
|
+
const stale = []
|
|
99
|
+
let removed = 0
|
|
100
|
+
store.commit((s) => {
|
|
101
|
+
for (const update of updates) {
|
|
102
|
+
if (update.drop) {
|
|
103
|
+
s.removeFile(update.rel)
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if (!s.applyFileUpdate(update.rel, update)) stale.push(update.rel)
|
|
107
|
+
}
|
|
108
|
+
if (unseen) {
|
|
109
|
+
for (const rel of Object.keys(s.files)) {
|
|
110
|
+
if (!unseen.has(rel)) {
|
|
111
|
+
s.removeFile(rel)
|
|
112
|
+
removed++
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (link) linkEntries(s)
|
|
117
|
+
})
|
|
118
|
+
return { stale, removed }
|
|
119
|
+
}
|
package/src/insight-store.js
CHANGED
|
@@ -10,6 +10,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } 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 { backfillDerivedTriggers } from './readiness.js'
|
|
13
14
|
|
|
14
15
|
export const INSIGHT_KINDS = ['lesson', 'decision', 'procedure', 'experience']
|
|
15
16
|
export const INSIGHT_SCOPES = ['task', 'project', 'global']
|
|
@@ -134,6 +135,23 @@ export function unionStrings(base = [], add = []) {
|
|
|
134
135
|
return [...out]
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
/**
|
|
139
|
+
* 跨 scope 移动(promote/demote)时保留"使用痕迹"。
|
|
140
|
+
*
|
|
141
|
+
* 提升/降级只改 scope,不该清零命中数、创建时间,也不该丢掉 `triggerDerived`
|
|
142
|
+
* (提示通道拿它当检索词)。这些字段不在 {@link normalizeInsight} 的白名单里,
|
|
143
|
+
* 必须显式带回——否则每次移动都会让条目"看起来从没被用过",global 层更是每次都丢派生词。
|
|
144
|
+
* @param {object} copy - 已归一化到新 scope 的副本
|
|
145
|
+
* @param {object} src - 原条目
|
|
146
|
+
*/
|
|
147
|
+
export function carryUsageFields(copy, src) {
|
|
148
|
+
copy.hitCount = num(src.hitCount, 0)
|
|
149
|
+
if (src.lastHitAt) copy.lastHitAt = src.lastHitAt
|
|
150
|
+
if (src.createdAt) copy.createdAt = src.createdAt
|
|
151
|
+
if (src.triggerDerived) copy.triggerDerived = src.triggerDerived
|
|
152
|
+
return copy
|
|
153
|
+
}
|
|
154
|
+
|
|
137
155
|
/** 把 base 合并进既有条目(content 加固、成员/文件/符号并集、置信取高、记命中)。 */
|
|
138
156
|
export function mergeInto(existing, base, cfg, nowIso) {
|
|
139
157
|
const now = nowIso || new Date().toISOString()
|
|
@@ -210,9 +228,12 @@ export function applyDecay(items, cfg, nowIso) {
|
|
|
210
228
|
const limit = Date.parse(now) - cfg.decayDays * DAY_MS
|
|
211
229
|
let archived = 0
|
|
212
230
|
for (const it of items) {
|
|
213
|
-
if (it.archived) continue
|
|
214
|
-
|
|
215
|
-
|
|
231
|
+
if (!it || it.archived) continue
|
|
232
|
+
// 用"最近活动"而不是 lastHitAt:lastHitAt 只由 merge/reinforce 写,而那两处同时把
|
|
233
|
+
// hitCount 加一。旧实现先 `hitCount > 0 → continue`,于是 lastHitAt 分支永远不可达,
|
|
234
|
+
// decayDays 实际是个死开关。activityOf 退到 updatedAt/createdAt,没命中过的条目也能衰减。
|
|
235
|
+
const last = activityOf(it)
|
|
236
|
+
if (last && last <= limit) {
|
|
216
237
|
it.archived = true
|
|
217
238
|
it.updatedAt = now
|
|
218
239
|
archived++
|
|
@@ -221,7 +242,11 @@ export function applyDecay(items, cfg, nowIso) {
|
|
|
221
242
|
return archived
|
|
222
243
|
}
|
|
223
244
|
|
|
224
|
-
/**
|
|
245
|
+
/**
|
|
246
|
+
* 超限按"最不活跃"物理删除,直到回落到上限。已有 archived 条目优先被删。
|
|
247
|
+
* 注意:没有归档可删时,本轮归档的条目会在同一次调用的后续循环里被删掉——
|
|
248
|
+
* `archived` 计数表示"先归档、随即被删",不是"留下了软删副本"。
|
|
249
|
+
*/
|
|
225
250
|
export function pruneItems(items, max, nowIso) {
|
|
226
251
|
const now = nowIso || new Date().toISOString()
|
|
227
252
|
let removed = 0
|
|
@@ -300,7 +325,11 @@ export class GlobalStore {
|
|
|
300
325
|
}
|
|
301
326
|
|
|
302
327
|
load() {
|
|
303
|
-
if (!this.doc)
|
|
328
|
+
if (!this.doc) {
|
|
329
|
+
this.doc = normalizeDoc(readJson(this.file, null))
|
|
330
|
+
// 与 project 级一致:global 条目也要补派生 trigger(提示通道用它提升召回;确定性、幂等)。
|
|
331
|
+
if (backfillDerivedTriggers(this.doc)) this.markDirty()
|
|
332
|
+
}
|
|
304
333
|
return this
|
|
305
334
|
}
|
|
306
335
|
|
|
@@ -475,16 +504,14 @@ export function promoteAllTasksToProject(store, globalStore, cfg, now) {
|
|
|
475
504
|
promoted++
|
|
476
505
|
continue
|
|
477
506
|
}
|
|
478
|
-
const moved =
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
movedFrom: { scope: 'task', id: ins.id, at: now },
|
|
487
|
-
}
|
|
507
|
+
const moved = carryUsageFields(
|
|
508
|
+
normalizeInsight(ins, { scope: 'project', source: ins.source, nowIso: now }),
|
|
509
|
+
ins,
|
|
510
|
+
)
|
|
511
|
+
moved.id = ins.id
|
|
512
|
+
moved.draft = false
|
|
513
|
+
moved.confidence = num(ins.confidence, cfg.promoteConfidence)
|
|
514
|
+
moved.movedFrom = { scope: 'task', id: ins.id, at: now }
|
|
488
515
|
pjItems.push(moved)
|
|
489
516
|
store.replaceInsightItems(pjItems)
|
|
490
517
|
removeFromTask(store, ins.id)
|
|
@@ -514,16 +541,14 @@ export function promoteProjectToGlobal(store, globalStore, cfg, now) {
|
|
|
514
541
|
if (glHit && glHit.score >= cfg.dedupOverlap) {
|
|
515
542
|
mergeInto(glHit.item, { sourceTaskIds: ins.sourceTaskIds, files: ins.files, symbols: ins.symbols, tags: ins.tags, confidence: ins.confidence }, cfg, now)
|
|
516
543
|
} else {
|
|
517
|
-
const movedIns =
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
movedFrom: { scope: 'project', id: ins.id, at: now },
|
|
526
|
-
}
|
|
544
|
+
const movedIns = carryUsageFields(
|
|
545
|
+
normalizeInsight(ins, { scope: 'global', source: ins.source, nowIso: now }),
|
|
546
|
+
ins,
|
|
547
|
+
)
|
|
548
|
+
movedIns.id = ins.id
|
|
549
|
+
movedIns.draft = false
|
|
550
|
+
movedIns.confidence = num(ins.confidence, cfg.promoteConfidence)
|
|
551
|
+
movedIns.movedFrom = { scope: 'project', id: ins.id, at: now }
|
|
527
552
|
glItems.push(movedIns)
|
|
528
553
|
}
|
|
529
554
|
items.splice(items.indexOf(ins), 1)
|
|
@@ -531,6 +556,8 @@ export function promoteProjectToGlobal(store, globalStore, cfg, now) {
|
|
|
531
556
|
}
|
|
532
557
|
if (moved) {
|
|
533
558
|
store.replaceInsightItems(items)
|
|
559
|
+
// 提升只增不减 global:不在这里收口,maxGlobalProcedures 就形同虚设。
|
|
560
|
+
pruneItems(globalStore.items(), cfg.maxGlobalProcedures, now)
|
|
534
561
|
globalStore.markDirty()
|
|
535
562
|
}
|
|
536
563
|
return moved
|
|
@@ -538,17 +565,19 @@ export function promoteProjectToGlobal(store, globalStore, cfg, now) {
|
|
|
538
565
|
|
|
539
566
|
// ---- 降级(反向,PR3 UI 使用;现在提供最小实现) ----
|
|
540
567
|
|
|
541
|
-
export function demoteToProject(store, globalStore, id, nowIso) {
|
|
568
|
+
export function demoteToProject(store, globalStore, id, nowIso, cfg = cfgInsight({})) {
|
|
542
569
|
if (!globalStore) return { ok: false, error: 'global 存储未初始化' }
|
|
543
570
|
const now = nowIso || new Date().toISOString()
|
|
544
571
|
const idx = globalStore.items().findIndex((i) => i.id === id)
|
|
545
572
|
if (idx === -1) return { ok: false, error: `global 无此条目: ${id}` }
|
|
546
573
|
const ins = globalStore.items()[idx]
|
|
547
574
|
const items = store.insightItems()
|
|
548
|
-
const copy = normalizeInsight(ins, { scope: 'project', nowIso: now })
|
|
575
|
+
const copy = carryUsageFields(normalizeInsight(ins, { scope: 'project', nowIso: now }), ins)
|
|
549
576
|
copy.id = `${ins.id}_d${Date.now().toString(36)}`
|
|
550
577
|
copy.movedFrom = { scope: 'global', id: ins.id, at: now }
|
|
551
578
|
items.push(copy)
|
|
579
|
+
// 降级只增不减 project:同样要收口 maxProject。
|
|
580
|
+
pruneItems(items, cfg.maxProject, now)
|
|
552
581
|
store.replaceInsightItems(items)
|
|
553
582
|
globalStore.items().splice(idx, 1)
|
|
554
583
|
globalStore.markDirty()
|
|
@@ -564,7 +593,7 @@ export function demoteToTask(store, taskId, id, nowIso) {
|
|
|
564
593
|
if (idx === -1) return { ok: false, error: `project 无此条目: ${id}` }
|
|
565
594
|
const ins = items[idx]
|
|
566
595
|
if (!Array.isArray(task.insights)) task.insights = []
|
|
567
|
-
const copy = normalizeInsight(ins, { scope: 'task', nowIso: now })
|
|
596
|
+
const copy = carryUsageFields(normalizeInsight(ins, { scope: 'task', nowIso: now }), ins)
|
|
568
597
|
copy.id = `${ins.id}_d${Date.now().toString(36)}`
|
|
569
598
|
copy.movedFrom = { scope: 'project', id: ins.id, at: now }
|
|
570
599
|
task.insights.push(copy)
|