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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.1",
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",
@@ -177,34 +177,40 @@ export function wrapLlmStream(streamFn, config, { resolveRoot, state, llmName =
177
177
  }
178
178
  }
179
179
 
180
- /** 注册 agent/pre-step 监听,向每步请求的 enter 决策追加记忆消息(默认开)。 */
180
+ /** 注册 agent/pre-step 监听,向每步请求的 enter 决策追加记忆消息(默认开)。
181
+ * 宿主瀑布事件签名为 (payload, next):payload.agent.session 提供会话(header.cwd=项目根)。
182
+ * 任何异常/无会话 cwd → 原样返回默认决策,零副作用,绝不让宿主请求受影响。
183
+ */
181
184
  export function installAutoInject(ctx, config) {
182
185
  const auto = (config && config.autoContext) || {}
183
186
  if (auto.enabled === false) return
184
187
  const cfg = cfgEngine(config)
185
188
  const mem = {}
186
- ctx.on('agent/pre-step', async (next, { agent, session }) => {
189
+ ctx.on('agent/pre-step', async (payload, next) => {
187
190
  const decision = await next()
188
191
  if (!decision || decision.kind !== 'enter') return decision
189
- const root = session?.header?.cwd
190
- const sessionId = session?.id
191
- if (!root) return decision
192
192
  try {
193
+ const agent = payload && payload.agent
194
+ const session = agent && agent.session
195
+ const root = session && session.header && session.header.cwd
196
+ const sessionId = session && session.id
197
+ if (!root || !sessionId) return decision
193
198
  const query = lastUserText(decision.messages)
194
199
  const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
195
200
  const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
196
- const boundTaskId = sessionId && store.getBoundTaskId(sessionId) ? store.getBoundTaskId(sessionId) : null
201
+ const boundTaskId = store.getBoundTaskId(sessionId) ? store.getBoundTaskId(sessionId) : null
197
202
  const task = boundTaskId ? store.getTask(boundTaskId) : null
198
203
  const built = buildInjection({ query: query || '', task, store, globalStore, projectTagsList: projectTags(root), cfg })
199
204
  const fp = fingerprint(built.text)
200
205
  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
- }
206
+ // 以宿主 createUserMessage 构造的完整 user 消息追加(带 id/source,plan-mode narration 同款)。
207
+ // 裸 {role,content} 消息缺 source 会让宿主逐条读 message.source.kind 时崩溃。
208
+ mem.lastFp = fp
209
+ const injectMessage = createUserMessage({
210
+ content: [{ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${built.text}` }],
211
+ source: { kind: 'plugin', plugin: 'dsh-project-memory', form: 'notice', summary: `记忆注入 ${built.text.length} 字符` },
212
+ })
213
+ return { ...decision, messages: [...decision.messages, injectMessage] }
208
214
  }
209
215
  } catch (err) {
210
216
  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;