@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/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
- if (!section.lines.join('').trim()) continue
24
- let block = section.lines.join('\n').trim()
25
- let line = section.line
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 part = block.slice(0, splitAt).trim()
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
- line += part.split('\n').length
33
- block = block.slice(splitAt).trim()
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
- const ALL_CONTENT = ['body', 'pattern', 'fix', 'problem', 'solution', 'choice', 'reason', 'steps', 'trigger']
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
- item.trigger = {
248
- keywords: Array.isArray(v.keywords) ? v.keywords.map(String) : [],
249
- ...(Array.isArray(v.scope) ? { scope: v.scope.map(String) } : {}),
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 agent = invocation?.agent
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: jsonText(payload, `记忆 ${scope}:${payload.count} 条`) }
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: `${human}\n\n\`\`\`json\n${payload}\n\`\`\`` }
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 agent = invocation?.agent
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.split(/\s+/).slice(2).join(' ').trim()
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.split(/\s+/).slice(1).join(' ').trim()
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 === VERBS.switch) {
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 === VERBS.archive) {
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 }
@@ -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.content || s.text, status: s.status })),
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
- return {
51
- kind: 'success',
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.status === 'completed').length
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.status === 'in_progress')
61
- const step = inProgress ? ` · 当前: ${inProgress.content}` : ''
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: `${humanText}\n\n\`\`\`json\n${payload}\n\`\`\`` }
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 agent = invocation?.agent
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) return null
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
- const typeStr = node.type ? getTypeStr(checker.getTypeAtLocation(node.type)) : 'any'
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
+ }
@@ -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
- if ((it.hitCount || 0) > 0) continue
215
- if (it.lastHitAt && Date.parse(it.lastHitAt) <= limit) {
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) this.doc = normalizeDoc(readJson(this.file, null))
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
- ...normalizeInsight(ins, { scope: 'project', source: ins.source, nowIso: now }),
480
- id: ins.id,
481
- draft: false,
482
- confidence: num(ins.confidence, cfg.promoteConfidence),
483
- hitCount: ins.hitCount || 0,
484
- lastHitAt: ins.lastHitAt,
485
- createdAt: ins.createdAt || now,
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
- ...normalizeInsight(ins, { scope: 'global', source: ins.source, nowIso: now }),
519
- id: ins.id,
520
- draft: false,
521
- confidence: num(ins.confidence, cfg.promoteConfidence),
522
- hitCount: ins.hitCount || 0,
523
- lastHitAt: ins.lastHitAt,
524
- createdAt: ins.createdAt || now,
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)