@yolk_vat-y/dsh-project-memory 0.5.0 → 0.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolk_vat-y/dsh-project-memory",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Persistent project memory for dsh agents: index docs (PDF/Markdown/text) and code symbols into a searchable per-workspace store, recall them with cited sources, and keep experience entries (problems -> solutions) searchable on demand.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -17,7 +17,7 @@
17
17
  "url": "https://github.com/00080000/dsh-project-memory.git"
18
18
  },
19
19
  "scripts": {
20
- "test": "node test/run-test.mjs && node test/taskbridge.test.mjs && node test/insight-store.test.mjs && node test/reflection-pipeline.test.mjs && node test/auto-inject.test.mjs && node test/insight-actions.test.mjs",
20
+ "test": "node test/run-test.mjs && node test/taskbridge.test.mjs && node test/insight-store.test.mjs && node test/reflection-pipeline.test.mjs && node test/auto-inject.test.mjs && node test/insight-actions.test.mjs && node test/host-contract.test.mjs",
21
21
  "build:client": "tsdown"
22
22
  },
23
23
  "keywords": [
@@ -1,10 +1,10 @@
1
1
  // PR 2 静默注入引擎(entry 常驻块 + relevance 门控)
2
2
  // 安全约定:
3
3
  // - 纯函数 buildInjection 可单测;
4
- // - wrapLlmStream 仅在 resolver 给出项目 root 时才会真正动 messages,
5
- // 任何异常/禁用/无 root → 原样透传(零副作用),绝不让宿主请求受影响。
6
- // - “静默注入”文本带 [Memory Inject] 前缀,落在最后一条 user message 内容块里;
7
- // 指纹去重保证内容未变不重复追加(变更由宿主消息历史自然保留到压缩前)。
4
+ // - 唯一接线点是宿主 agent/pre-step 瀑布事件;任何异常/无会话 cwd → 交回合法决策,
5
+ // 绝不让宿主请求受影响(宿主直接读 decision.kind,返回 undefined 会崩掉整步)。
6
+ // - “静默注入”文本带 [Memory Inject] 前缀,作为一条 plugin source 的 user 消息追加;
7
+ // 按会话指纹去重,内容未变不重复追加。
8
8
  import { createHash } from 'node:crypto'
9
9
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
10
10
  import { ProjectMemoryStore } from './store.js'
@@ -25,6 +25,10 @@ export function cfgEngine(config) {
25
25
  maxTokens: typeof c.maxTokens === 'number' ? c.maxTokens : 400,
26
26
  entryMaxInsights: typeof c.entryMaxInsights === 'number' ? c.entryMaxInsights : 6,
27
27
  relevanceMin: typeof c.relevanceMin === 'number' ? c.relevanceMin : 0.25,
28
+ // resident 任务卡最多显示几个"编辑中"文件(纯写权重,最近写优先)
29
+ editedMax: typeof c.editedMax === 'number' ? c.editedMax : 3,
30
+ // 模型自己写/维护任务清单后、尚无新人类消息时,不把任务卡再回声给模型(省 token)
31
+ skipEchoSelfTodo: c.skipEchoSelfTodo !== false,
28
32
  }
29
33
  }
30
34
 
@@ -62,14 +66,40 @@ function linesOf(list) {
62
66
  return out
63
67
  }
64
68
 
65
- /** 任务卡 + 非草稿任务级 insights 摘要(常驻块主体)。 */
66
- export function buildEntryContent(task, cfg) {
69
+ /** 任务卡上"编辑中"文件(写过、最近写优先,最多 max 个)。 */
70
+ export function editedFiles(task, max) {
71
+ if (!task || !task.fileMeta) return []
72
+ const meta = task.fileMeta
73
+ // task.files 已由 taskbridge 保持热序(写过在前、按 lastWriteAt 倒序)
74
+ return (task.files || []).filter((f) => meta[f] && meta[f].lastWriteAt).slice(0, max)
75
+ }
76
+
77
+ /**
78
+ * 是否回声 resident 任务卡:最近一次推进是模型自己 todo 写步骤、且之后没有新人类消息时,
79
+ * 不回声(模型刚写的东西再喂回去 = 噪音/浪费 token)。保留相关 insights 注入不变。
80
+ */
81
+ export function shouldEchoTaskCard(task, cfg) {
82
+ if (!task) return false
83
+ const c = cfg || {}
84
+ if (c.skipEchoSelfTodo === false) return true
85
+ const lt = task.lastTodoAt
86
+ const lh = task.lastHumanAt
87
+ if (lt && lh) return !(lt > lh)
88
+ return true
89
+ }
90
+
91
+ /** 任务卡 + 非草稿任务级 insights 摘要(常驻块主体)。
92
+ * withEdited=false 用于去重指纹:排除随写文件高频变化的“编辑中”行。 */
93
+ export function buildEntryContent(task, cfg, { withEdited = true } = {}) {
67
94
  if (!task) return ''
95
+ const c = cfg || {}
68
96
  const steps = (task.steps || []).map((s) => (typeof s === 'string' ? s : s.content || s.text || '').slice(0, 80))
69
97
  const card = [`任务: ${task.title || '(untitled)'}`, `进度: ${steps.filter((s) => true).length ? `${steps.length} 步` : ''}`, ...steps.slice(0, 12).map((s, i) => ` ${i + 1}. ${s}`)]
70
98
  const insights = linesOf(task.insights)
99
+ const edited = editedFiles(task, c.editedMax || 3)
71
100
  const parts = [...card]
72
- if (insights.length) parts.push(`任务记忆:`, ...insights.slice(0, cfg.entryMaxInsights || 6))
101
+ if (withEdited && edited.length) parts.push(` 编辑中: ${edited.join(', ')}`)
102
+ if (insights.length) parts.push(`任务记忆:`, ...insights.slice(0, c.entryMaxInsights || 6))
73
103
  return parts.join('\n')
74
104
  }
75
105
 
@@ -122,89 +152,61 @@ export function buildInjection(opts) {
122
152
  labels.push(label)
123
153
  if (parts.join('\n').length > budgetChars) break
124
154
  }
125
- // 常驻块文本(内容哈希由 wrapper 判“是否已注入过”)
126
- const entry = task ? buildEntryContent(task, cfg) : ''
155
+ // 常驻块文本。去重指纹只看稳定内容(任务标题/步骤/insights + 相关 insights):
156
+ // “编辑中”随每次写文件变化,若参与指纹会导致每写一个文件就重发整块(噪音 + token 浪费)。
157
+ const echo = shouldEchoTaskCard(task, cfg)
158
+ const entry = echo ? buildEntryContent(task, cfg) : ''
159
+ const entryStable = echo ? buildEntryContent(task, cfg, { withEdited: false }) : ''
127
160
  const total = [entry, ...parts].filter(Boolean)
128
161
  if (!total.length) return { text: '', labels: [] }
129
- let text = total.join('\n')
130
- if (text.length > budgetChars) text = text.slice(0, budgetChars) + '\n…(截断)'
131
- return { text, labels }
162
+ const clamp = (t) => (t.length > budgetChars ? t.slice(0, budgetChars) + '\n…(截断)' : t)
163
+ const dedupeText = clamp([entryStable, ...parts].filter(Boolean).join('\n'))
164
+ return { text: clamp(total.join('\n')), labels, dedupeText }
132
165
  }
133
166
 
134
- /**
135
- * 包装 llm.stream。resolver(ctx, config) 返回项目 root;返回 null → 完全透传。
136
- * opts.state 持有“当前注入指纹”(跨请求去重)。
167
+ /** 注册 agent/pre-step 监听,向每步请求的 enter 决策追加记忆消息(默认开)。
168
+ * 宿主瀑布事件签名为 (payload, next):payload.agent.session 提供会话(header.cwd=项目根)。
169
+ * 任何异常/无会话 cwd → 原样返回默认决策,零副作用,绝不让宿主请求受影响。
137
170
  */
138
- export function wrapLlmStream(streamFn, config, { resolveRoot, state, llmName = 'llm' } = {}) {
139
- const auto = (config && config.autoContext) || {}
140
- if (auto.enabled === false) return streamFn
141
- const orig = typeof streamFn === 'function' ? streamFn : () => {}
142
- const cfg = cfgEngine(config)
143
- const mem = state || {}
144
- return async function* wrapped(params) {
145
- try {
146
- const resolved = resolveRoot ? resolveRoot(config) : null
147
- const root = typeof resolved === 'string' ? resolved : (resolved && resolved.root) || null
148
- const sessionId = resolved && typeof resolved === 'object' ? resolved.sessionId || null : null
149
- if (root) {
150
- const query = lastUserText(params && params.messages)
151
- const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
152
- const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
153
- const sid = sessionId || null
154
- const boundTaskId = sid && store.getBoundTaskId(sid) ? store.getBoundTaskId(sid) : null
155
- const task = boundTaskId ? store.getTask(boundTaskId) : null
156
- const built = buildInjection({ query, task, store, globalStore, projectTagsList: projectTags(root), cfg })
157
- const fp = fingerprint(built.text)
158
- if (built.text && fp !== mem.lastFp) {
159
- const last = params.messages && params.messages[params.messages.length - 1]
160
- if (last && last.role === 'user') {
161
- const content = Array.isArray(last.content) ? last.content : []
162
- content.push({ type: 'text', text: `\n\n${INJECT_MARK} ${llmName}\n${built.text}` })
163
- last.content = content
164
- mem.lastFp = fp
165
- if (!mem.loggedFire) {
166
- mem.loggedFire = true
167
- console.log(`[dsh-project-memory] auto-context: FIRST injection fired (${built.text.length} chars, root=${root}${sessionId ? `, session=${sessionId}` : ''})`)
168
- }
169
- }
170
- }
171
- }
172
- } catch (err) {
173
- console.error(`[dsh-project-memory] auto-inject skipped: ${err?.message || err}`)
174
- }
175
- // 无论注入与否都原样走宿主 stream(异常同样原样传播)
176
- yield* orig(params)
177
- }
178
- }
179
-
180
- /** 注册 agent/pre-step 监听,向每步请求的 enter 决策追加记忆消息(默认开)。 */
181
171
  export function installAutoInject(ctx, config) {
182
172
  const auto = (config && config.autoContext) || {}
183
173
  if (auto.enabled === false) return
184
174
  const cfg = cfgEngine(config)
185
- const mem = {}
186
- ctx.on('agent/pre-step', async (next, { agent, session }) => {
187
- const decision = await next()
188
- if (!decision || decision.kind !== 'enter') return decision
189
- const root = session?.header?.cwd
190
- const sessionId = session?.id
191
- if (!root) return decision
175
+ // 每个会话一份“上次注入指纹”。用单个变量会让并发会话互相抑制注入。
176
+ const lastFpBySession = new Map()
177
+ const LAST_FP_MAX = 200
178
+ ctx.on('agent/pre-step', async (payload, next) => {
179
+ // 宿主契约是 waterfall(payload, next),next 一定存在;但一旦宿主版本漂移、或事件被当
180
+ // 普通事件调用,next 缺失会让本监听器 reject(历史事故正是 `next is not a function`)。
181
+ // 更致命的是返回 undefined:宿主 agent.ts 直接读 `decision.kind`,会崩掉整步 → 无法回复。
182
+ // 因此两种情况都退化为一个合法的 enter 决策,绝不让宿主请求受影响。
183
+ const fallback = () => ({ kind: 'enter', messages: (payload && payload.messages) || [] })
184
+ const decision = typeof next === 'function' ? await next() : fallback()
185
+ if (!decision) return fallback()
186
+ if (decision.kind !== 'enter') return decision
192
187
  try {
188
+ const agent = payload && payload.agent
189
+ const session = agent && agent.session
190
+ const root = session && session.header && session.header.cwd
191
+ const sessionId = session && session.id
192
+ if (!root || !sessionId) return decision
193
193
  const query = lastUserText(decision.messages)
194
194
  const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
195
195
  const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
196
- const boundTaskId = sessionId && store.getBoundTaskId(sessionId) ? store.getBoundTaskId(sessionId) : null
196
+ const boundTaskId = store.getBoundTaskId(sessionId) ? store.getBoundTaskId(sessionId) : null
197
197
  const task = boundTaskId ? store.getTask(boundTaskId) : null
198
198
  const built = buildInjection({ query: query || '', task, store, globalStore, projectTagsList: projectTags(root), cfg })
199
- const fp = fingerprint(built.text)
200
- if (built.text && fp !== mem.lastFp) {
201
- const last = decision.messages && decision.messages[decision.messages.length - 1]
202
- if (last && last.role === 'user') {
203
- const content = Array.isArray(last.content) ? last.content : []
204
- content.push({ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${built.text}` })
205
- last.content = content
206
- mem.lastFp = fp
207
- }
199
+ const fp = fingerprint(built.dedupeText ?? built.text)
200
+ if (built.text && fp !== lastFpBySession.get(sessionId)) {
201
+ // 以宿主 createUserMessage 构造的完整 user 消息追加(带 id/source,plan-mode narration 同款)。
202
+ // 裸 {role,content} 消息缺 source 会让宿主逐条读 message.source.kind 时崩溃。
203
+ lastFpBySession.set(sessionId, fp)
204
+ if (lastFpBySession.size > LAST_FP_MAX) lastFpBySession.delete(lastFpBySession.keys().next().value)
205
+ const injectMessage = createUserMessage({
206
+ content: [{ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${built.text}` }],
207
+ source: { kind: 'plugin', plugin: 'dsh-project-memory', form: 'notice', summary: `记忆注入 ${built.text.length} 字符` },
208
+ })
209
+ return { ...decision, messages: [...decision.messages, injectMessage] }
208
210
  }
209
211
  } catch (err) {
210
212
  console.error(`[dsh-project-memory] auto-inject skipped: ${err?.message || err}`)
@@ -2,7 +2,7 @@
2
2
  * Task Panel Presentational 组件 — MiniBar(折叠迷你条)与 TaskCard(任务卡)。
3
3
  * 纯渲染 + 卡片局部行内编辑/拖拽状态,动作经 props 回调交给容器(TaskPanel.tsx)。
4
4
  */
5
- import { useRef, useState } from 'react'
5
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
6
6
  import {
7
7
  Button,
8
8
  IconChevronDownOutline14,
@@ -45,6 +45,75 @@ function StepIcon({ status }: { status: TaskStep['status'] }) {
45
45
  return <span className={css.stepPending} />
46
46
  }
47
47
 
48
+ /**
49
+ * 双击判定窗口(ms)。
50
+ * 单击(如卡片展开/收起)会延迟该时长执行:若期间收到第二次点击则按“双击”处理并取消单击,
51
+ * 从而避免“第一下先展开/收起、布局位移后第二下点不到目标”的问题。
52
+ * 数值是可感知与可靠性的折中:太小慢速双击会失效,太大单击会显“肉”。
53
+ */
54
+ const CLICK_SINGLE_DELAY_MS = 250
55
+
56
+ /**
57
+ * 行内自适应高度文本编辑器(textarea)。
58
+ * 编辑体验与单行输入一致(Enter 提交 / Shift+Enter 换行 / Esc 取消 / 失焦提交),
59
+ * 但高度随内容自动增长(按实际渲染行高测量),最多 maxRows 行后内部滚动,
60
+ * 解决长步骤/长任务名只能挤在一行里横向滚动编辑的问题。
61
+ */
62
+ interface InlineEditorProps {
63
+ value: string
64
+ className?: string
65
+ maxRows?: number
66
+ onValueChange: (value: string) => void
67
+ onCommit: () => void
68
+ onCancel: () => void
69
+ }
70
+
71
+ function InlineEditor({ value, className, maxRows = 8, onValueChange, onCommit, onCancel }: InlineEditorProps) {
72
+ const ref = useRef<HTMLTextAreaElement>(null)
73
+
74
+ // 在绘制前按真实内容测量并写死高度:内容 <= maxRows 行时刚好包住,超出后内部滚动。
75
+ useLayoutEffect(() => {
76
+ const el = ref.current
77
+ if (!el) return
78
+ el.style.height = 'auto'
79
+ const cs = getComputedStyle(el)
80
+ const lineHeight = parseFloat(cs.lineHeight) || 20
81
+ const padV = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0)
82
+ const borderV = (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.borderBottomWidth) || 0)
83
+ const cap = maxRows * lineHeight + padV + borderV
84
+ const height = Math.min(el.scrollHeight, cap)
85
+ el.style.height = `${height}px`
86
+ el.style.overflowY = el.scrollHeight > cap ? 'auto' : 'hidden'
87
+ }, [value, maxRows])
88
+
89
+ return (
90
+ <textarea
91
+ ref={ref}
92
+ className={className}
93
+ rows={1}
94
+ value={value}
95
+ autoFocus
96
+ spellCheck={false}
97
+ onChange={(e) => onValueChange(e.target.value)}
98
+ onFocus={(e) => {
99
+ // 光标放到末尾,便于直接在原文后继续输入
100
+ const len = e.target.value.length
101
+ e.target.setSelectionRange(len, len)
102
+ }}
103
+ onBlur={onCommit}
104
+ onKeyDown={(e) => {
105
+ if (e.key === 'Enter' && !e.shiftKey) {
106
+ e.preventDefault()
107
+ onCommit()
108
+ } else if (e.key === 'Escape') {
109
+ e.preventDefault()
110
+ onCancel()
111
+ }
112
+ }}
113
+ />
114
+ )
115
+ }
116
+
48
117
  interface MiniBarProps {
49
118
  label: string
50
119
  hint: string
@@ -163,29 +232,66 @@ export function TaskCard({
163
232
  onRename(value)
164
233
  }
165
234
 
235
+ // —— 卡片头单击(展开/收起)与双击(编辑任务名)的时序判定 ——
236
+ // 单击动作延迟 CLICK_SINGLE_DELAY_MS 执行;若在窗口内收到第二次点击,取消单击、
237
+ // 命中任务名则直接进入编辑,避免“第一下已展开/收起、布局位移、第二下点不到任务名”。
238
+ const headClickTimer = useRef<number | null>(null)
239
+ // 编辑状态下,点卡片头任意处会先触发失焦提交;该次点击不应再当作“切换展开”的单击。
240
+ const suppressHeadClick = useRef(false)
241
+ useEffect(() => () => {
242
+ if (headClickTimer.current !== null) window.clearTimeout(headClickTimer.current)
243
+ }, [])
244
+
245
+ const handleHeadClick = (e: React.MouseEvent<HTMLButtonElement>) => {
246
+ if (suppressHeadClick.current) {
247
+ // 上一次 mousedown 落在标题编辑框内(失焦提交产生),吞掉这次点击,不切换展开
248
+ suppressHeadClick.current = false
249
+ return
250
+ }
251
+ if (titleEdit !== null) return
252
+ const target = e.target as HTMLElement
253
+ const onTitle = isBound && target.closest('[data-card-title]') !== null
254
+ if (headClickTimer.current !== null) {
255
+ // 第二次点击 → 双击:取消待执行的单击动作
256
+ window.clearTimeout(headClickTimer.current)
257
+ headClickTimer.current = null
258
+ if (onTitle) setTitleEdit({ value: String(task.title ?? '') })
259
+ else onToggleExpand()
260
+ return
261
+ }
262
+ headClickTimer.current = window.setTimeout(() => {
263
+ headClickTimer.current = null
264
+ onToggleExpand()
265
+ }, CLICK_SINGLE_DELAY_MS)
266
+ }
267
+
166
268
  return (
167
269
  <article data-task-id={task.id} className={`${css.card}${isBound ? ` ${css.cardBound}` : ''}`}>
168
270
  <button
169
271
  className={css.cardHead}
170
- onClick={() => { if (titleEdit !== null) return; onToggleExpand() }}
272
+ onClick={handleHeadClick}
273
+ onMouseDownCapture={() => {
274
+ // 标题编辑中,点击卡片头任意处都会先失焦提交;标记该次点击以在 onClick 中吞掉,
275
+ // 避免“改名提交一下、卡片又收起/展开一下”的误切换。
276
+ if (titleEdit !== null) suppressHeadClick.current = true
277
+ }}
278
+ onDoubleClick={(e) => e.preventDefault()}
171
279
  aria-expanded={expanded}
172
280
  >
173
281
  <div className={css.cardTitleRow}>
174
282
  {isBound && <span className={css.boundBadge}>{t('task.current')}</span>}
175
283
  {isBound && titleEdit !== null ? (
176
- <input
177
- className={css.stepInput}
284
+ <InlineEditor
285
+ className={css.editorArea}
178
286
  value={titleEdit.value}
179
- autoFocus
180
- onChange={(e) => setTitleEdit({ value: e.target.value })}
181
- onBlur={commitTitleEdit}
182
- onKeyDown={(e) => {
183
- if (e.key === 'Enter') commitTitleEdit()
184
- else if (e.key === 'Escape') setTitleEdit(null)
185
- }}
287
+ maxRows={4}
288
+ onValueChange={(value) => setTitleEdit({ value })}
289
+ onCommit={commitTitleEdit}
290
+ onCancel={() => setTitleEdit(null)}
186
291
  />
187
292
  ) : (
188
293
  <span
294
+ data-card-title
189
295
  className={`${css.cardTitle}${isBound ? ` ${css.cardTitleEditable}` : ''}`}
190
296
  title={isBound ? t('task.title-edit') : undefined}
191
297
  onDoubleClick={(e) => {
@@ -252,16 +358,13 @@ export function TaskCard({
252
358
  <StepIcon status={step.status} />
253
359
  )}
254
360
  {stepIsEditing ? (
255
- <input
256
- className={css.stepInput}
361
+ <InlineEditor
362
+ className={css.editorArea}
257
363
  value={stepEdit!.value}
258
- autoFocus
259
- onChange={(e) => setStepEdit({ index: i, value: e.target.value })}
260
- onBlur={() => commitStepEdit(i)}
261
- onKeyDown={(e) => {
262
- if (e.key === 'Enter') commitStepEdit(i)
263
- else if (e.key === 'Escape') setStepEdit(null)
264
- }}
364
+ maxRows={10}
365
+ onValueChange={(value) => setStepEdit({ index: i, value })}
366
+ onCommit={() => commitStepEdit(i)}
367
+ onCancel={() => setStepEdit(null)}
265
368
  />
266
369
  ) : (
267
370
  <span
@@ -280,6 +280,7 @@
280
280
  line-height: 20px;
281
281
  color: var(--dsw-alias-label-secondary);
282
282
  word-break: break-word;
283
+ white-space: pre-wrap;
283
284
  }
284
285
  .stepContentDone {
285
286
  color: var(--dsw-alias-label-dimmed);
@@ -430,7 +431,7 @@
430
431
  outline: 1px dashed var(--dsw-alias-border-l3, rgba(127,127,127,.4));
431
432
  outline-offset: 1px;
432
433
  }
433
- /* 行内编辑框(步骤/标题共用,单行 input) */
434
+ /* 行内编辑框(步骤/标题共用,单行 input,仍被 MemoryView 表单复用) */
434
435
  .stepInput {
435
436
  flex: 1 1 auto;
436
437
  min-width: 0;
@@ -446,6 +447,33 @@
446
447
  padding: 0 5px;
447
448
  }
448
449
 
450
+ /* 任务卡行内编辑器(textarea,按内容自适应增高;高度由 JS 测量写死,
451
+ 内容超过 maxRows 行后内部滚动,避免长步骤/长任务名单行横向拖拽编辑) */
452
+ .editorArea {
453
+ flex: 1 1 0%;
454
+ min-width: 0;
455
+ box-sizing: border-box;
456
+ font-family: inherit;
457
+ font-size: 12px;
458
+ line-height: 20px;
459
+ color: var(--dsw-alias-label-primary);
460
+ background: var(--dsw-alias-bg-base, transparent);
461
+ border: 1px solid var(--dsw-alias-border-l3, rgba(127,127,127,.45));
462
+ border-radius: 5px;
463
+ padding: 2px 5px;
464
+ margin: 0;
465
+ resize: none;
466
+ overflow: hidden;
467
+ vertical-align: middle;
468
+ cursor: text;
469
+ white-space: pre-wrap;
470
+ word-break: break-word;
471
+ }
472
+ .editorArea:focus {
473
+ outline: none;
474
+ border-color: var(--dsw-alias-border-l3, rgba(127,127,127,.6));
475
+ }
476
+
449
477
  /* —— 步骤拖拽重排:源行半透明 —— */
450
478
  .stepDragging {
451
479
  opacity: 0.3;
package/src/index.js CHANGED
@@ -118,14 +118,22 @@ export function apply(ctx, config) {
118
118
  installAutoInject(ctx, config)
119
119
 
120
120
  if (config.autoIndexOnFirstUse) {
121
- ctx.effect(async () => {
122
- const root = process.cwd()
123
- try {
124
- watchManager.addRoot(root)
125
- const report = await indexRepository(ctx, config, root)
126
- console.log(`[dsh-project-memory] ${report}`)
127
- } catch (err) {
128
- console.error(`[dsh-project-memory] auto-index failed for ${root}: ${err.message}`)
121
+ // effect 回调必须同步并返回 disposer;异步体独立执行,用 cancelled 标志避免卸载后再写日志
122
+ ctx.effect(() => {
123
+ let cancelled = false
124
+ const run = async () => {
125
+ const root = process.cwd()
126
+ try {
127
+ watchManager.addRoot(root)
128
+ const report = await indexRepository(ctx, config, root)
129
+ if (!cancelled) console.log(`[dsh-project-memory] ${report}`)
130
+ } catch (err) {
131
+ if (!cancelled) console.error(`[dsh-project-memory] auto-index failed for ${root}: ${err.message}`)
132
+ }
133
+ }
134
+ run()
135
+ return () => {
136
+ cancelled = true
129
137
  }
130
138
  })
131
139
  }
package/src/lazy.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path'
2
2
  import { tmpdir } from 'node:os'
3
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
4
- import { isSupportedCode, isSupportedDoc, memoryRootFor, relativePath, sha256OfFile, storeKey } from './util/fs.js'
3
+ import { existsSync, readdirSync, statSync } from 'node:fs'
4
+ import { isSupportedCode, isSupportedDoc, memoryRootFor, readFileForIndex, relativePath, storeKey } from './util/fs.js'
5
5
  import { buildDocEntries } from './doc-pipeline.js'
6
6
  import { scanSymbols } from './symbols.js'
7
7
  import { linkEntries } from './link.js'
@@ -88,12 +88,13 @@ export async function indexFile(ctx, config, filePath, watchManager = null) {
88
88
  const existing = store.fileRecord(rel)
89
89
  let hash
90
90
  let size
91
+ let buffer
91
92
  let entries
92
93
  try {
93
94
  if (isSupportedCode(ext) && config.maxFileSizeMb && statSync(filePath).size > config.maxFileSizeMb * 1024 * 1024) {
94
95
  return false
95
96
  }
96
- ;({ hash, size } = await sha256OfFile(filePath))
97
+ ;({ hash, size, buffer } = readFileForIndex(filePath))
97
98
  } catch {
98
99
  return false
99
100
  }
@@ -105,7 +106,7 @@ export async function indexFile(ctx, config, filePath, watchManager = null) {
105
106
  }
106
107
 
107
108
  if (isSupportedCode(ext)) {
108
- entries = scanSymbols(rel, filePath, readFileSync(filePath, 'utf8'))
109
+ entries = scanSymbols(rel, filePath, buffer.toString('utf8'))
109
110
  onFileObserved(store, rel, filePath, config, root)
110
111
  return store.commit((s) => {
111
112
  s.markFile(rel, { sha256: hash, size, type: 'code', indexedAt: new Date().toISOString() })
@@ -8,6 +8,8 @@ import { findProjectRoot } from '../lazy.js'
8
8
  import { ProjectMemoryStore } from '../store.js'
9
9
 
10
10
  const FS_FILE_TOOLS = new Set(['read', 'write', 'edit', 'read_image'])
11
+ const WRITE_TOOLS = new Set(['write', 'edit']) // 编辑/写入 = 高热点
12
+ const READ_TOOLS = new Set(['read', 'read_image'])
11
13
  const MAX_FILES_PER_TASK = 100
12
14
  const TITLE_MAX = 24
13
15
 
@@ -100,7 +102,42 @@ function pickTitle(meta, todos) {
100
102
  }
101
103
 
102
104
  /**
103
- * 单条会话事件处理(导出便于测试):user/message 记首条真人文本;
105
+ * 热点排序:写过(编辑)的文件永远排前;同是写过按 lastWriteAt 倒序;
106
+ * 纯读文件按 lastReadAt 倒序排在写区之后(任何读取都不会越过写过文件)。
107
+ */
108
+ export function hotSortFiles(files, meta) {
109
+ const m = meta || {}
110
+ return (files || []).slice().sort((a, b) => {
111
+ const A = m[a]
112
+ const B = m[b]
113
+ const aw = A && A.lastWriteAt ? 1 : 0
114
+ const bw = B && B.lastWriteAt ? 1 : 0
115
+ if (aw !== bw) return bw - aw
116
+ if (aw) return String(B.lastWriteAt).localeCompare(String(A.lastWriteAt))
117
+ return String(B.lastReadAt || '').localeCompare(String(A.lastReadAt || ''))
118
+ })
119
+ }
120
+
121
+ /** 触碰文件后更新元数据并重排 task.files(写 vs 读分别记时间/次数)。 */
122
+ export function touchTaskFile(task, rel, kind, now) {
123
+ task.fileMeta = task.fileMeta || {}
124
+ if (!task.files.includes(rel)) {
125
+ task.files.push(rel)
126
+ if (task.files.length > MAX_FILES_PER_TASK) {
127
+ const dropped = task.files.shift()
128
+ delete task.fileMeta[dropped]
129
+ }
130
+ }
131
+ const m = task.fileMeta[rel] || {}
132
+ m.n = (m.n || 0) + 1
133
+ if (kind === 'write') m.lastWriteAt = now
134
+ m.lastReadAt = now
135
+ task.fileMeta[rel] = m
136
+ task.files = hotSortFiles(task.files, task.fileMeta)
137
+ }
138
+
139
+ /**
140
+ * 单条会话事件处理(导出便于测试):user/message 记首条真人文本并刷新 lastHumanAt;
104
141
  * todo/write → 已绑定则覆盖 steps,未绑定则自动新建任务并绑定;
105
142
  * tool/call(fs 工具)→ 绑定任务 files 并集。
106
143
  */
@@ -108,17 +145,35 @@ export function onSessionEvent(config, session, event, meta) {
108
145
  const sessionId = session?.id
109
146
  const type = event?.type
110
147
  if (!sessionId || !type) return
148
+ const now = new Date().toISOString()
111
149
 
150
+ // 人类消息:记首条真人文本 + 若已绑定任务则刷新 lastHumanAt(供“模型自维护不回声任务卡”判断)
112
151
  if (type === 'user/message') {
113
- if (!meta.has(sessionId) && event.data?.source?.kind === 'user') {
152
+ if (event.data?.source?.kind === 'user') {
114
153
  const text = firstTextOf(event.data?.content)
115
- if (text) meta.set(sessionId, { firstHuman: text })
154
+ const m = meta.get(sessionId) || {}
155
+ if (!m.firstHuman && text) m.firstHuman = text
156
+ m.lastHumanAt = now
157
+ meta.set(sessionId, m)
158
+ try {
159
+ const { store } = taskStoreFor(session?.header?.cwd, config)
160
+ store.commit((s) => {
161
+ const tid = s.getBoundTaskId(sessionId)
162
+ const task = tid ? s.getTask(tid) : null
163
+ if (task && !task.archived) {
164
+ task.lastHumanAt = now
165
+ task.updatedAt = now
166
+ s._dirtyTasks = true
167
+ }
168
+ })
169
+ } catch {
170
+ /* 人类消息戳记失败不影响主流程 */
171
+ }
116
172
  }
117
173
  return
118
174
  }
119
175
 
120
176
  const { root, store } = taskStoreFor(session?.header?.cwd, config)
121
- const now = new Date().toISOString()
122
177
 
123
178
  if (type === 'todo/write') {
124
179
  const todos = event.data?.todos
@@ -136,6 +191,7 @@ export function onSessionEvent(config, session, event, meta) {
136
191
  title,
137
192
  steps: null,
138
193
  files: [],
194
+ fileMeta: {},
139
195
  archived: false,
140
196
  lastSessionId: sessionId,
141
197
  createdAt: now,
@@ -146,6 +202,7 @@ export function onSessionEvent(config, session, event, meta) {
146
202
  s.setBinding(sessionId, task.id)
147
203
  }
148
204
  task.steps = todos
205
+ task.lastTodoAt = now // 模型/todo 最近一次自维护时刻
149
206
  task.updatedAt = now
150
207
  task.lastActiveAt = now
151
208
  task.lastSessionId = sessionId
@@ -169,13 +226,12 @@ export function onSessionEvent(config, session, event, meta) {
169
226
  if (!raw) return
170
227
  const rel = normalizeRelFile(root, raw)
171
228
  if (!rel) return
229
+ const kind = WRITE_TOOLS.has(data.name) ? 'write' : READ_TOOLS.has(data.name) ? 'read' : null
230
+ if (!kind) return
172
231
  store.commit((s) => {
173
232
  const task = s.getTask(taskId)
174
233
  if (!task || task.archived) return
175
- if (!task.files.includes(rel)) {
176
- task.files.push(rel)
177
- if (task.files.length > MAX_FILES_PER_TASK) task.files.shift()
178
- }
234
+ touchTaskFile(task, rel, kind, now)
179
235
  task.lastActiveAt = now
180
236
  s._dirtyTasks = true
181
237
  })
package/src/store.js CHANGED
@@ -14,6 +14,7 @@ const INSIGHTS_FILE = 'insights.json'
14
14
  const SHARDS_DIR = 'shards'
15
15
 
16
16
  const storeCache = new Map()
17
+ const STORE_CACHE_MAX = 32
17
18
 
18
19
  function loadJson(filePath, fallback) {
19
20
  let raw
@@ -85,6 +86,12 @@ export class ProjectMemoryStore {
85
86
  this._loadSharded()
86
87
  this._loadInsights()
87
88
  storeCache.set(key, this)
89
+ // 只保留最近打开的项目:长期跨多项目运行时不至于无限增长(被逐出只是下次重新读盘)
90
+ while (storeCache.size > STORE_CACHE_MAX) {
91
+ const oldest = storeCache.keys().next().value
92
+ if (oldest === key) break
93
+ storeCache.delete(oldest)
94
+ }
88
95
  return this
89
96
  }
90
97