dsh-notes-plugin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -0
- package/cordis.patch.yml +5 -0
- package/index.mjs +1149 -0
- package/lib/client.js +882 -0
- package/lib/styles.css +312 -0
- package/package.json +57 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
/* global harness */
|
|
2
|
+
// dsh-notes — host 端(ESM 静态包,发布版)
|
|
3
|
+
//
|
|
4
|
+
// 本文件是 bootstrap 开发版 host-impl.js 的**迁移**(不是重写):apply 体内的功能逻辑逐段保留
|
|
5
|
+
// (19 个 RPC + 3 个工具 + 约定注入 + 派发 + LLM 分类 + 缓存/归档/软删除 + 性能遥测)。
|
|
6
|
+
// 与开发版的三点结构性差异:
|
|
7
|
+
// 1. 形式:`return { inject, apply }`(被 new Function 执行)→ ESM `export name/inject/apply`
|
|
8
|
+
// (package.json 已声明 "type": "module"、"main": "./index.mjs")
|
|
9
|
+
// 2. 路径:PLUGIN_DIR/notes → ~/.dsh/notes(os.homedir()/.dsh/notes)。开发版目录仅保留两处用途:
|
|
10
|
+
// (a) 一次性数据迁移源;(b) styles.css / client-impl.js 等开发资产的回退读取路径。
|
|
11
|
+
// 3. RPC/工具注册:主通道仍是全局 Builtin `harness`(`harness.handle` / `harness.defineTool` /
|
|
12
|
+
// `harness.registerTool`,与 host-impl.js 的 `function handle(name,fn){ return harness.handle(...) }`
|
|
13
|
+
// 和 `harness.defineTool(def)` + `harness.registerTool(ctx, tool)` 姿势一致,原样保留)。
|
|
14
|
+
// 额外的兜底:若某部署没有 harness(例如真实 Cordis row 里没有沙箱注入的 Builtin),则同一批
|
|
15
|
+
// handler 退到 `ctx.webServer.register({kind:'exact', path:'/dsh-notes'})`、工具退到 `ctx.tools.register`
|
|
16
|
+
// (已发布范例 task-board-plugin/packages/dsh-agent-board/index.mjs 用的就是这条服务路径)。
|
|
17
|
+
// 两条通道互斥(工具不会重复注册);RPC 表始终维护,供 webServer 路由消费。
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import fsNode from 'node:fs'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
|
|
23
|
+
export const name = 'dsh-notes-plugin'
|
|
24
|
+
// 硬依赖:fs(笔记读写)+ sandboxPolicy(写策略)+ webServer(静态包 RPC 路由)+ tools(静态包工具注册)。
|
|
25
|
+
// 注意:harness 是动态插件的全局 Builtin,静态包里不存在(PACKAGING.md)——静态包必须 inject webServer/tools 走 ctx 服务通道。
|
|
26
|
+
export const inject = ['fs', 'sandboxPolicy', 'webServer', 'tools']
|
|
27
|
+
|
|
28
|
+
// ---- 路径锚点(模块级常量,import 时求值,无副作用)----
|
|
29
|
+
const PKG_DIR = path.dirname(fileURLToPath(import.meta.url)) // packages/dsh-notes
|
|
30
|
+
const NOTES_ROOT = path.join(os.homedir(), '.dsh', 'notes') // 发布版存储根
|
|
31
|
+
// 开发版目录:只用于 (a) 首次启动的一次性数据迁移 (b) 开发资产回退读取。发布环境不存在这些文件时静默跳过。
|
|
32
|
+
const LEGACY_PLUGIN_DIR = 'D:\\deepseek-work\\dsh-notes-plugin'
|
|
33
|
+
const LEGACY_NOTES_DIR = path.join(LEGACY_PLUGIN_DIR, 'notes')
|
|
34
|
+
// 样式/源码候选路径:包内 lib/styles.css 优先(P3 会把 styles.css 放那里),再包根,最后开发版回退
|
|
35
|
+
const CSS_CANDIDATES = [
|
|
36
|
+
path.join(PKG_DIR, 'lib', 'styles.css'),
|
|
37
|
+
path.join(PKG_DIR, 'styles.css'),
|
|
38
|
+
path.join(LEGACY_PLUGIN_DIR, 'styles.css'),
|
|
39
|
+
]
|
|
40
|
+
const RPC_PATH = '/dsh-notes'
|
|
41
|
+
|
|
42
|
+
// 零外部依赖:link: 安装的包从真实路径解析,裸 import '@deepseek-ai/dsh-tools' 会 ERR_MODULE_NOT_FOUND。
|
|
43
|
+
// defineTool 本体只是 校验+包装 出 {name, description, parameters, output, execute} 普通对象,
|
|
44
|
+
// 这里内联等价实现(与 task-board index.mjs 相同);parameters 已是完整 JSON Schema,原样透传。
|
|
45
|
+
function defineTool(options) {
|
|
46
|
+
var userExecute = options.execute
|
|
47
|
+
var userRender = options.output && options.output.render
|
|
48
|
+
return {
|
|
49
|
+
name: options.name,
|
|
50
|
+
description: options.description,
|
|
51
|
+
parameters: options.parameters,
|
|
52
|
+
output: {
|
|
53
|
+
schema: options.output.schema,
|
|
54
|
+
render: userRender ? function (args, value) { return userRender(args, value) } : undefined,
|
|
55
|
+
},
|
|
56
|
+
execute: function (args, exec) { return userExecute(args, exec) },
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function apply(ctx) {
|
|
61
|
+
const fs = ctx.fs
|
|
62
|
+
const sp = ctx.sandboxPolicy
|
|
63
|
+
const tools = ctx.tools
|
|
64
|
+
const webServer = ctx.webServer
|
|
65
|
+
const agents = ctx.get('agents')
|
|
66
|
+
const llm = ctx.get('llm')
|
|
67
|
+
const adm = ctx.get('agentDefaultModel')
|
|
68
|
+
const systemPrompt = ctx.get('systemPrompt')
|
|
69
|
+
const sessionPersistence = ctx.get('sessionPersistence')
|
|
70
|
+
const workspaceRegistry = ctx.get('workspaceRegistry')
|
|
71
|
+
const sessionTitle = ctx.get('sessionTitle')
|
|
72
|
+
const sessionQuery = ctx.get('sessionQuery')
|
|
73
|
+
const NOTES_DIR = NOTES_ROOT
|
|
74
|
+
const disposers = []
|
|
75
|
+
// 动态沙箱 Builtin:harness 是「dynamic Host half」的符号(cordis-host-runner 用 node:vm 注入),
|
|
76
|
+
// 静态包(真实 Cordis row)里通常不存在;存在时作为兼容通道使用(见 RPC 桥 / regTool 回退)。
|
|
77
|
+
const harnessRef = typeof harness !== 'undefined' ? harness : undefined
|
|
78
|
+
|
|
79
|
+
function genId() {
|
|
80
|
+
return 'n-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function basename(p) {
|
|
84
|
+
if (!p) return ''
|
|
85
|
+
const s = String(p).replace(/[\\/]+$/, '')
|
|
86
|
+
const parts = s.split(/[\\/]/)
|
|
87
|
+
return parts[parts.length - 1] || s
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function shortSid(sid) { return sid ? String(sid).replace(/^session-/, '').slice(0, 8) : '' }
|
|
91
|
+
|
|
92
|
+
// dispatches 派发历史:对象数组,front-matter 里以 JSON 字符串存储
|
|
93
|
+
function parseDispatches(s) {
|
|
94
|
+
if (!s) return []
|
|
95
|
+
try { const d = JSON.parse(s); return Array.isArray(d) ? d : [] } catch (e) { return [] }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function escYaml(s) {
|
|
99
|
+
s = String(s == null ? '' : s)
|
|
100
|
+
if (/[":#\[\]{}&,*?|<>=!%@\n]/.test(s)) {
|
|
101
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"'
|
|
102
|
+
}
|
|
103
|
+
return s
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildFM(m) {
|
|
107
|
+
return '---\n' +
|
|
108
|
+
'id: ' + escYaml(m.id) + '\n' +
|
|
109
|
+
'title: ' + escYaml(m.title) + '\n' +
|
|
110
|
+
'topic: ' + escYaml(m.topic || '') + '\n' +
|
|
111
|
+
'workspace: ' + escYaml(m.workspace || '') + '\n' +
|
|
112
|
+
'tags: ' + (m.tags || []).map(escYaml).join(', ') + '\n' +
|
|
113
|
+
'kind: ' + escYaml(m.kind || 'note') + '\n' +
|
|
114
|
+
'status: ' + escYaml(m.status || 'active') + '\n' +
|
|
115
|
+
'inject: ' + escYaml(m.inject ? 'true' : 'false') + '\n' +
|
|
116
|
+
'injectTo: ' + (m.injectTo || []).map(escYaml).join(', ') + '\n' +
|
|
117
|
+
'createdAt: ' + escYaml(m.createdAt) + '\n' +
|
|
118
|
+
'updatedAt: ' + escYaml(m.updatedAt) + '\n' +
|
|
119
|
+
'sessionId: ' + escYaml(m.sessionId) + '\n' +
|
|
120
|
+
'cwd: ' + escYaml(m.cwd) + '\n' +
|
|
121
|
+
'mergedFrom: ' + (m.mergedFrom || []).map(escYaml).join(', ') + '\n' +
|
|
122
|
+
'dispatches: ' + escYaml(JSON.stringify(m.dispatches || [])) + '\n' +
|
|
123
|
+
'archivedAt: ' + escYaml(m.archivedAt || '') + '\n' +
|
|
124
|
+
'deleted: ' + escYaml(m.deleted || 'false') + '\n' +
|
|
125
|
+
'---\n\n'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseFM(content) {
|
|
129
|
+
const meta = {}
|
|
130
|
+
let body = content
|
|
131
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
|
|
132
|
+
if (m) {
|
|
133
|
+
body = m[2] || ''
|
|
134
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
135
|
+
const idx = line.indexOf(':')
|
|
136
|
+
if (idx < 0) continue
|
|
137
|
+
const key = line.slice(0, idx).trim()
|
|
138
|
+
let val = line.slice(idx + 1).trim()
|
|
139
|
+
if (val.charAt(0) === '"' && val.charAt(val.length - 1) === '"') {
|
|
140
|
+
val = val.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\').replace(/\\n/g, '\n')
|
|
141
|
+
}
|
|
142
|
+
meta[key] = (key === 'tags' || key === 'mergedFrom' || key === 'injectTo')
|
|
143
|
+
? (val ? val.split(',').map(s => s.trim()).filter(Boolean) : [])
|
|
144
|
+
: val
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { meta, body }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sessCtx() {
|
|
151
|
+
const sc = { sessionId: '', cwd: '' }
|
|
152
|
+
try {
|
|
153
|
+
if (agents) {
|
|
154
|
+
const a = agents.currentInitiator()
|
|
155
|
+
if (a) {
|
|
156
|
+
sc.sessionId = a.sessionId || (a.session && a.session.id) || ''
|
|
157
|
+
sc.cwd = (a.session && a.session.header && a.session.header.cwd) || ''
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch (e) {}
|
|
161
|
+
return sc
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getPolicy() {
|
|
165
|
+
try {
|
|
166
|
+
if (sp && sp.resolve) {
|
|
167
|
+
return sp.resolve({ mode: 'danger-full-access' })
|
|
168
|
+
}
|
|
169
|
+
} catch (e) {}
|
|
170
|
+
return undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 三段式标题:工作区 · 会话 · 主题
|
|
174
|
+
function buildQuickTitle(sc, topic) {
|
|
175
|
+
const ws = basename(sc.cwd)
|
|
176
|
+
const sess = sc.sessionId ? sc.sessionId.replace(/^session-/, '').slice(0, 8) : ''
|
|
177
|
+
return [ws, sess, topic].filter(Boolean).join(' · ') || topic || '未分类'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function classifyTopic(text) {
|
|
181
|
+
if (!llm || !adm) return '未分类'
|
|
182
|
+
try {
|
|
183
|
+
const sel = adm.currentSelection()
|
|
184
|
+
if (!sel || !sel.provider || !sel.model) return '未分类'
|
|
185
|
+
const preset = ['需求', '设计', '开发', '调试', '运维', '调研', '其他']
|
|
186
|
+
const prompt = '你是一个笔记主题分类器。预设主题:' + preset.join('、') + '。请优先从预设主题中选择最匹配的一个;如果内容明显不属于任何预设主题,可输出一个新的简短主题(2-6个汉字)。只输出主题名本身,不要解释、标点或换行:\n\n' + text
|
|
187
|
+
let out = ''
|
|
188
|
+
for await (const chunk of llm.stream({
|
|
189
|
+
provider: sel.provider,
|
|
190
|
+
model: sel.model,
|
|
191
|
+
messages: [{
|
|
192
|
+
id: 'topic-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
193
|
+
role: 'user',
|
|
194
|
+
content: [{ type: 'text', text: prompt }],
|
|
195
|
+
source: { kind: 'user' }
|
|
196
|
+
}],
|
|
197
|
+
system: '你是一个笔记主题分类器,把用户内容归纳成极简主题。',
|
|
198
|
+
temperature: 0
|
|
199
|
+
})) {
|
|
200
|
+
if (chunk && chunk.type === 'text-delta') out += chunk.text
|
|
201
|
+
if (chunk && chunk.type === 'finish') break
|
|
202
|
+
}
|
|
203
|
+
const topic = out.trim().replace(/^["'「」『』]+|["'「」『』]+$/g, '')
|
|
204
|
+
return topic || '未分类'
|
|
205
|
+
} catch (e) {
|
|
206
|
+
console.error('notes: classifyTopic failed', e)
|
|
207
|
+
return '未分类'
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- 缓存层:解析结果按 id 常驻内存;本插件所有写入同步缓存,外部新增文件在 list 时懒加载 ----
|
|
212
|
+
const KINDS = ['note', 'decision', 'todo', 'link', 'quote']
|
|
213
|
+
const STATUSES = ['active', 'pinned', 'resolved', 'superseded']
|
|
214
|
+
const cache = new Map()
|
|
215
|
+
|
|
216
|
+
function noteFromParsed(id, p) {
|
|
217
|
+
const tags = p.meta.tags || []
|
|
218
|
+
// inject:显式 true/false 优先;旧数据(无 inject 字段)回退到 tags 含 convention(向后兼容)
|
|
219
|
+
const inject = p.meta.inject === 'true' ? true : (p.meta.inject === 'false' ? false : tags.indexOf('convention') >= 0)
|
|
220
|
+
// injectTo:数组(parseFM 已按 , 拆分);旧数据若是字符串也兜底成数组
|
|
221
|
+
let injectTo = []
|
|
222
|
+
if (Array.isArray(p.meta.injectTo)) injectTo = p.meta.injectTo
|
|
223
|
+
else if (p.meta.injectTo) injectTo = String(p.meta.injectTo).split(',').map(s => s.trim()).filter(Boolean)
|
|
224
|
+
return {
|
|
225
|
+
id: p.meta.id || id,
|
|
226
|
+
title: p.meta.title || 'Untitled',
|
|
227
|
+
topic: p.meta.topic || '未分类',
|
|
228
|
+
workspace: p.meta.workspace || '',
|
|
229
|
+
tags: tags,
|
|
230
|
+
kind: p.meta.kind || 'note',
|
|
231
|
+
status: p.meta.status || 'active',
|
|
232
|
+
inject: inject,
|
|
233
|
+
injectTo: injectTo,
|
|
234
|
+
createdAt: p.meta.createdAt || '',
|
|
235
|
+
updatedAt: p.meta.updatedAt || '',
|
|
236
|
+
sessionId: p.meta.sessionId || '',
|
|
237
|
+
cwd: p.meta.cwd || '',
|
|
238
|
+
mergedFrom: p.meta.mergedFrom || [],
|
|
239
|
+
dispatches: parseDispatches(p.meta.dispatches),
|
|
240
|
+
archivedAt: p.meta.archivedAt || '',
|
|
241
|
+
deleted: p.meta.deleted === 'true',
|
|
242
|
+
body: p.body || ''
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function noteFile(id) { return path.join(NOTES_ROOT, id + '.md') }
|
|
247
|
+
|
|
248
|
+
async function readNoteFile(id) {
|
|
249
|
+
perfStats.diskReads++
|
|
250
|
+
const ft = await fs.resolve(noteFile(id))
|
|
251
|
+
const c = await fs.readText(ft)
|
|
252
|
+
const note = noteFromParsed(id, parseFM(c))
|
|
253
|
+
cache.set(note.id, note)
|
|
254
|
+
return note
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function loadNote(id) {
|
|
258
|
+
const hit = cache.get(id)
|
|
259
|
+
if (hit) { perfStats.cacheReads++; return hit }
|
|
260
|
+
return readNoteFile(id)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function persistNote(n) {
|
|
264
|
+
perfStats.diskWrites++
|
|
265
|
+
const meta = {
|
|
266
|
+
id: n.id, title: n.title, topic: n.topic, workspace: n.workspace,
|
|
267
|
+
tags: n.tags || [], kind: n.kind || 'note', status: n.status || 'active',
|
|
268
|
+
inject: n.inject === true, injectTo: n.injectTo || [],
|
|
269
|
+
createdAt: n.createdAt, updatedAt: n.updatedAt,
|
|
270
|
+
sessionId: n.sessionId, cwd: n.cwd, mergedFrom: n.mergedFrom || [],
|
|
271
|
+
dispatches: n.dispatches || [],
|
|
272
|
+
archivedAt: n.archivedAt || '', deleted: n.deleted ? 'true' : 'false'
|
|
273
|
+
}
|
|
274
|
+
const content = buildFM(meta) + (n.body || '')
|
|
275
|
+
const ft = await fs.resolve(noteFile(n.id))
|
|
276
|
+
await fs.writeText(ft, content, undefined, undefined, getPolicy())
|
|
277
|
+
cache.set(n.id, Object.assign({}, n))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 列表/RPC 瘦身:不带 body,正文编辑走 notes-get 按需加载
|
|
281
|
+
function slim(n) {
|
|
282
|
+
return {
|
|
283
|
+
id: n.id, title: n.title, topic: n.topic, workspace: n.workspace,
|
|
284
|
+
tags: n.tags, kind: n.kind || 'note', status: n.status || 'active',
|
|
285
|
+
inject: n.inject === true, injectTo: n.injectTo || [],
|
|
286
|
+
createdAt: n.createdAt, updatedAt: n.updatedAt,
|
|
287
|
+
sessionId: n.sessionId, cwd: n.cwd, mergedFrom: n.mergedFrom,
|
|
288
|
+
dispatches: n.dispatches || [],
|
|
289
|
+
archivedAt: n.archivedAt, preview: String(n.body || '').slice(0, 200)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function _create(title, body, tags, topic, extra) {
|
|
294
|
+
const id = genId()
|
|
295
|
+
const now = new Date().toISOString()
|
|
296
|
+
const sc = sessCtx()
|
|
297
|
+
const ex = extra || {}
|
|
298
|
+
const note = {
|
|
299
|
+
id, title: title || 'Untitled', topic: topic || '未分类',
|
|
300
|
+
workspace: ex.workspace || basename(sc.cwd),
|
|
301
|
+
tags: tags || [],
|
|
302
|
+
kind: ex.kind || 'note',
|
|
303
|
+
status: ex.status || 'active',
|
|
304
|
+
inject: ex.inject === true,
|
|
305
|
+
injectTo: ex.injectTo || [],
|
|
306
|
+
createdAt: ex.createdAt || now, updatedAt: ex.updatedAt || now,
|
|
307
|
+
sessionId: ex.sessionId !== undefined ? ex.sessionId : sc.sessionId,
|
|
308
|
+
cwd: ex.cwd || sc.cwd,
|
|
309
|
+
mergedFrom: ex.mergedFrom || [],
|
|
310
|
+
dispatches: ex.dispatches || [],
|
|
311
|
+
archivedAt: ex.archivedAt || '',
|
|
312
|
+
deleted: false,
|
|
313
|
+
body: body || ''
|
|
314
|
+
}
|
|
315
|
+
await persistNote(note)
|
|
316
|
+
return { id, topic: note.topic, title: note.title, kind: note.kind, status: note.status }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function _list(tag, kind) {
|
|
320
|
+
try {
|
|
321
|
+
// 首次启动的一次性迁移(开发版 notes → ~/.dsh/notes)可能与首个 RPC 竞态,这里等一下
|
|
322
|
+
try { await migrationDone } catch (e) {}
|
|
323
|
+
const dirTarget = await fs.resolve(NOTES_DIR)
|
|
324
|
+
const info = await fs.stat(dirTarget)
|
|
325
|
+
if (!info) return []
|
|
326
|
+
const entries = await fs.listDir(dirTarget)
|
|
327
|
+
const notes = []
|
|
328
|
+
for (const entry of entries) {
|
|
329
|
+
if (!entry.name || !entry.name.endsWith('.md')) continue
|
|
330
|
+
const id = entry.name.replace(/\.md$/, '')
|
|
331
|
+
try {
|
|
332
|
+
const note = await loadNote(id)
|
|
333
|
+
if (note.deleted) continue
|
|
334
|
+
if (tag && (note.tags || []).indexOf(tag) < 0) continue
|
|
335
|
+
if (kind && note.kind !== kind) continue
|
|
336
|
+
notes.push(note)
|
|
337
|
+
} catch (e) { console.error('notes: read failed', entry.name, e) }
|
|
338
|
+
}
|
|
339
|
+
// 置顶(pinned)优先,其次按更新时间降序
|
|
340
|
+
notes.sort((a, b) => {
|
|
341
|
+
const pa = a.status === 'pinned' ? 1 : 0
|
|
342
|
+
const pb = b.status === 'pinned' ? 1 : 0
|
|
343
|
+
if (pa !== pb) return pb - pa
|
|
344
|
+
return (b.updatedAt || '').localeCompare(a.updatedAt || '')
|
|
345
|
+
})
|
|
346
|
+
return notes
|
|
347
|
+
} catch (e) {
|
|
348
|
+
console.error('notes: list error', e)
|
|
349
|
+
return []
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function _get(id) {
|
|
354
|
+
const note = await loadNote(id)
|
|
355
|
+
if (note.deleted) throw new Error('Note has been deleted')
|
|
356
|
+
return Object.assign({}, note)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function _update(id, title, body, tags, topic, kind, status, inject, injectTo) {
|
|
360
|
+
const note = Object.assign({}, await loadNote(id))
|
|
361
|
+
if (note.deleted) throw new Error('Note has been deleted')
|
|
362
|
+
if (title !== undefined) note.title = title
|
|
363
|
+
if (topic !== undefined) note.topic = topic
|
|
364
|
+
if (tags !== undefined) note.tags = tags
|
|
365
|
+
if (kind !== undefined) note.kind = kind
|
|
366
|
+
if (status !== undefined) note.status = status
|
|
367
|
+
if (inject !== undefined) note.inject = inject === true
|
|
368
|
+
if (injectTo !== undefined) note.injectTo = injectTo
|
|
369
|
+
if (body !== undefined) note.body = body
|
|
370
|
+
note.updatedAt = new Date().toISOString()
|
|
371
|
+
await persistNote(note)
|
|
372
|
+
return { id, kind: note.kind, status: note.status }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 快速记录队列:串行化避免读-改-写竞态导致内容丢失
|
|
376
|
+
// 合并策略:同 session 且上一条速记在 10 分钟内更新过才合并,否则新建(避免过度合并)
|
|
377
|
+
// 主题分类异步执行:先落盘返回,分类完成后回填主题与标题,不阻塞交互
|
|
378
|
+
const MERGE_WINDOW_MS = 10 * 60 * 1000
|
|
379
|
+
let quickChain = Promise.resolve()
|
|
380
|
+
function _quickCapture(text, sessionId, cwd, kind) {
|
|
381
|
+
const run = quickChain.then(() => _quickCaptureInner(text, sessionId, cwd, kind))
|
|
382
|
+
quickChain = run.then((r) => {
|
|
383
|
+
if (!r || !r.id) return
|
|
384
|
+
perfStats.classify++
|
|
385
|
+
const ct0 = Date.now()
|
|
386
|
+
return classifyTopic(text).then(async (topic) => {
|
|
387
|
+
perfStats.classifyMs += Date.now() - ct0
|
|
388
|
+
try {
|
|
389
|
+
const newTitle = r.merged ? undefined : buildQuickTitle({ sessionId: r.sid, cwd: r.cwd }, topic)
|
|
390
|
+
await _update(r.id, newTitle, undefined, undefined, topic)
|
|
391
|
+
} catch (e) {}
|
|
392
|
+
}).catch(() => {})
|
|
393
|
+
}, () => {})
|
|
394
|
+
return run
|
|
395
|
+
}
|
|
396
|
+
async function _quickCaptureInner(text, sessionId, cwd, kind) {
|
|
397
|
+
const now = new Date().toISOString()
|
|
398
|
+
const sc = sessCtx()
|
|
399
|
+
const sid = sessionId || sc.sessionId
|
|
400
|
+
const cw = cwd || sc.cwd
|
|
401
|
+
if (sid) {
|
|
402
|
+
const all = await _list()
|
|
403
|
+
const existing = all.find(n => (n.tags || []).indexOf('quick') >= 0 && n.sessionId === sid)
|
|
404
|
+
const fresh = existing && existing.updatedAt && (Date.now() - new Date(existing.updatedAt).getTime()) < MERGE_WINDOW_MS
|
|
405
|
+
if (existing && fresh) {
|
|
406
|
+
const stamp = '## ' + now.slice(0, 10) + ' ' + now.slice(11, 16) + '\n\n'
|
|
407
|
+
const newBody = String(existing.body || '').trim() + '\n\n' + stamp + text + '\n'
|
|
408
|
+
await _update(existing.id, undefined, newBody, undefined, undefined)
|
|
409
|
+
return { id: existing.id, topic: existing.topic, title: existing.title, kind: existing.kind, merged: true, sid: sid, cwd: cw }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const id = genId()
|
|
413
|
+
const title = buildQuickTitle({ sessionId: sid, cwd: cw }, '速记')
|
|
414
|
+
const note = {
|
|
415
|
+
id, title: title, topic: '分类中',
|
|
416
|
+
workspace: basename(cw),
|
|
417
|
+
tags: ['quick'],
|
|
418
|
+
kind: kind || 'note',
|
|
419
|
+
status: 'active',
|
|
420
|
+
createdAt: now, updatedAt: now,
|
|
421
|
+
sessionId: sid, cwd: cw,
|
|
422
|
+
mergedFrom: [], archivedAt: '',
|
|
423
|
+
deleted: false,
|
|
424
|
+
body: text + '\n'
|
|
425
|
+
}
|
|
426
|
+
await persistNote(note)
|
|
427
|
+
return { id, topic: '分类中', title: title, kind: note.kind, merged: false, sid: sid, cwd: cw }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// T3 指令式快速记录:从用户备注提取元数据(tags/titleHint/kind/inject)
|
|
431
|
+
// 复用 classifyTopic 的 llm.stream + adm.currentSelection 模式,temperature 0
|
|
432
|
+
// 解析容错:失败/不规范 → 返回 null(调用方按无备注处理,等价 notes-quick)
|
|
433
|
+
// 关键约束:绝不改写原文——LLM 只输出结构化 JSON,原文由调用方落盘
|
|
434
|
+
async function extractInstruction(text, note) {
|
|
435
|
+
if (!llm || !adm) return null
|
|
436
|
+
try {
|
|
437
|
+
const sel = adm.currentSelection()
|
|
438
|
+
if (!sel || !sel.provider || !sel.model) return null
|
|
439
|
+
const prompt = '给定选区原文和用户备注,从备注中提取笔记元数据。只输出严格 JSON,没提到的字段留空/默认,绝不改写原文。\n' +
|
|
440
|
+
'字段说明:\n' +
|
|
441
|
+
'- tags: 字符串数组,打标签(如备注"标记为重要 bug" → ["重要","bug"])\n' +
|
|
442
|
+
'- titleHint: 字符串,标题/主题引导(如"这是关于登录的" → "登录")\n' +
|
|
443
|
+
'- kind: 字符串,类型枚举 note/decision/todo/link/quote(如"这是待办" → todo)\n' +
|
|
444
|
+
'- inject: 布尔,是否设为约定(如"记住这个" → true)\n\n' +
|
|
445
|
+
'选区原文:\n' + text + '\n\n用户备注:\n' + note + '\n\n只输出 JSON:{"tags":[],"titleHint":"","kind":"note","inject":false}'
|
|
446
|
+
let out = ''
|
|
447
|
+
for await (const chunk of llm.stream({
|
|
448
|
+
provider: sel.provider,
|
|
449
|
+
model: sel.model,
|
|
450
|
+
messages: [{
|
|
451
|
+
id: 'instruct-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
452
|
+
role: 'user',
|
|
453
|
+
content: [{ type: 'text', text: prompt }],
|
|
454
|
+
source: { kind: 'user' }
|
|
455
|
+
}],
|
|
456
|
+
system: '你是一个笔记元数据提取器。从用户备注中提取结构化字段,输出严格 JSON,绝不改写原文。',
|
|
457
|
+
temperature: 0
|
|
458
|
+
})) {
|
|
459
|
+
if (chunk && chunk.type === 'text-delta') out += chunk.text
|
|
460
|
+
if (chunk && chunk.type === 'finish') break
|
|
461
|
+
}
|
|
462
|
+
// 容错解析:提取第一个 {...} 块;失败返回 null
|
|
463
|
+
const m = out.match(/\{[\s\S]*\}/)
|
|
464
|
+
if (!m) return null
|
|
465
|
+
const obj = JSON.parse(m[0])
|
|
466
|
+
const tags = Array.isArray(obj.tags) ? obj.tags.map(function (s) { return String(s).trim() }).filter(Boolean) : []
|
|
467
|
+
const titleHint = obj.titleHint ? String(obj.titleHint).trim() : ''
|
|
468
|
+
const kindRaw = String(obj.kind || '').trim().toLowerCase()
|
|
469
|
+
const kind = KINDS.indexOf(kindRaw) >= 0 ? kindRaw : 'note'
|
|
470
|
+
const inject = obj.inject === true
|
|
471
|
+
return { tags: tags, titleHint: titleHint, kind: kind, inject: inject }
|
|
472
|
+
} catch (e) {
|
|
473
|
+
console.error('notes: extractInstruction failed', e)
|
|
474
|
+
return null
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// T3 指令式快速记录:选区原文 + LLM 提取元数据 → 新建独立笔记
|
|
479
|
+
// 备注为空 / LLM 不可用 / 解析失败 → 等价 notes-quick(走合并逻辑,原文不变)
|
|
480
|
+
// 备注非空且 LLM 成功 → 新建独立笔记(不走合并窗口),body=选区原文(不变),应用提取的元数据
|
|
481
|
+
async function _quickInstruct(text, note, sessionId, cwd) {
|
|
482
|
+
const noteTrim = String(note || '').trim()
|
|
483
|
+
// 备注为空 → 走现有逻辑(合并窗口,行为不变)
|
|
484
|
+
if (!noteTrim) {
|
|
485
|
+
const r = await _quickCapture(text, sessionId, cwd, 'quote')
|
|
486
|
+
return { ok: true, id: r.id, applied: { tags: [], kind: r.kind || 'note', inject: false }, merged: r.merged }
|
|
487
|
+
}
|
|
488
|
+
// 备注非空 → LLM 提取元数据
|
|
489
|
+
const meta = await extractInstruction(text, noteTrim)
|
|
490
|
+
if (!meta) {
|
|
491
|
+
// LLM 不可用 / 解析失败 → 等价 notes-quick(合并逻辑,原文不变)
|
|
492
|
+
const r = await _quickCapture(text, sessionId, cwd, 'quote')
|
|
493
|
+
return { ok: true, id: r.id, applied: { tags: [], kind: r.kind || 'note', inject: false }, merged: r.merged, fallback: true }
|
|
494
|
+
}
|
|
495
|
+
// 新建独立笔记(不走合并窗口),body=选区原文(不变)
|
|
496
|
+
const now = new Date().toISOString()
|
|
497
|
+
const sc = sessCtx()
|
|
498
|
+
const sid = sessionId || sc.sessionId
|
|
499
|
+
const cw = cwd || sc.cwd
|
|
500
|
+
const extraTags = meta.tags.filter(function (t) { return t !== 'quick' })
|
|
501
|
+
const tags = ['quick'].concat(extraTags)
|
|
502
|
+
const topic = meta.titleHint || '速记'
|
|
503
|
+
const title = buildQuickTitle({ sessionId: sid, cwd: cw }, topic)
|
|
504
|
+
const id = genId()
|
|
505
|
+
const noteObj = {
|
|
506
|
+
id: id, title: title, topic: topic, workspace: basename(cw),
|
|
507
|
+
tags: tags, kind: meta.kind, status: 'active', inject: meta.inject, injectTo: [],
|
|
508
|
+
createdAt: now, updatedAt: now, sessionId: sid, cwd: cw,
|
|
509
|
+
mergedFrom: [], archivedAt: '', deleted: false,
|
|
510
|
+
body: text + '\n'
|
|
511
|
+
}
|
|
512
|
+
await persistNote(noteObj)
|
|
513
|
+
// 无 titleHint 时异步分类回填主题/标题(不阻塞交互,复用 classifyTopic 模式)
|
|
514
|
+
if (!meta.titleHint) {
|
|
515
|
+
classifyTopic(text).then(async function (topic2) {
|
|
516
|
+
try {
|
|
517
|
+
const newTitle = buildQuickTitle({ sessionId: sid, cwd: cw }, topic2)
|
|
518
|
+
await _update(id, newTitle, undefined, undefined, topic2)
|
|
519
|
+
} catch (e) {}
|
|
520
|
+
}).catch(function () {})
|
|
521
|
+
}
|
|
522
|
+
return { ok: true, id: id, applied: { tags: meta.tags, kind: meta.kind, inject: meta.inject, titleHint: meta.titleHint } }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function _delete(id) {
|
|
526
|
+
const note = Object.assign({}, await loadNote(id))
|
|
527
|
+
note.deleted = true
|
|
528
|
+
note.updatedAt = new Date().toISOString()
|
|
529
|
+
await persistNote(note)
|
|
530
|
+
return { id }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// 恢复软删除的笔记(撤销删除/撤销归档)
|
|
534
|
+
async function _restore(id) {
|
|
535
|
+
const note = Object.assign({}, await loadNote(id))
|
|
536
|
+
note.deleted = false
|
|
537
|
+
note.updatedAt = new Date().toISOString()
|
|
538
|
+
await persistNote(note)
|
|
539
|
+
return { id }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// 归档:快速记录按 session 合并;手动笔记按标签合并
|
|
543
|
+
async function _archive() {
|
|
544
|
+
const all = await _list()
|
|
545
|
+
const groups = new Map()
|
|
546
|
+
for (const n of all) {
|
|
547
|
+
const isQuick = (n.tags || []).indexOf('quick') >= 0
|
|
548
|
+
const key = isQuick
|
|
549
|
+
? 'quick:' + (n.sessionId || 'none')
|
|
550
|
+
: 'manual:' + (n.tags || []).slice().sort().join(',')
|
|
551
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
552
|
+
groups.get(key).push(n)
|
|
553
|
+
}
|
|
554
|
+
let merged = 0
|
|
555
|
+
const mergedIds = []
|
|
556
|
+
for (const members of groups.values()) {
|
|
557
|
+
if (members.length < 2) continue
|
|
558
|
+
members.sort((a, b) => (a.updatedAt || '').localeCompare(b.updatedAt || ''))
|
|
559
|
+
const now = new Date().toISOString()
|
|
560
|
+
const bodyParts = members.map(n => {
|
|
561
|
+
const d = (n.updatedAt || n.createdAt || '').slice(0, 10)
|
|
562
|
+
return '## ' + d + '\n\n' + String(n.body || '').trim() + '\n'
|
|
563
|
+
})
|
|
564
|
+
const body = bodyParts.join('\n')
|
|
565
|
+
const topic = members[members.length - 1].topic || members[0].topic || '未分类'
|
|
566
|
+
const last = members[members.length - 1]
|
|
567
|
+
const title = last.topic || members[0].title || '归档'
|
|
568
|
+
const r = await _create(title, body, members[0].tags || [], topic, {
|
|
569
|
+
workspace: last.workspace || '',
|
|
570
|
+
createdAt: members[0].createdAt || now,
|
|
571
|
+
updatedAt: now,
|
|
572
|
+
sessionId: last.sessionId || '',
|
|
573
|
+
cwd: last.cwd || '',
|
|
574
|
+
mergedFrom: members.map(n => n.id),
|
|
575
|
+
archivedAt: now
|
|
576
|
+
})
|
|
577
|
+
// 归档前先备份原笔记(.bak 后缀,_list 不会读到)
|
|
578
|
+
for (const n of members) {
|
|
579
|
+
try {
|
|
580
|
+
const src = await fs.resolve(noteFile(n.id))
|
|
581
|
+
const dst = await fs.resolve(noteFile(n.id) + '.bak')
|
|
582
|
+
const c = await fs.readText(src)
|
|
583
|
+
await fs.writeText(dst, c, undefined, undefined, getPolicy())
|
|
584
|
+
} catch (e) {}
|
|
585
|
+
}
|
|
586
|
+
for (const n of members) { await _delete(n.id) }
|
|
587
|
+
merged++
|
|
588
|
+
mergedIds.push(r.id)
|
|
589
|
+
}
|
|
590
|
+
return { merged, mergedIds }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// 派发目标会话列表(共享):**未归档的主会话**(可继续对话,符合 DSH 交互逻辑),不只是当前 live。
|
|
594
|
+
// sessionPersistence.list() 返回 SessionPersistenceSnapshot[]({header, revision, ...}),id/origin/cwd/createdAt 都在 header 里;兼容旧版直接返回 SessionHeader
|
|
595
|
+
function snapHeader(h) { return (h && h.header) ? h.header : h }
|
|
596
|
+
// 与左侧会话列表一致:sessionPersistence.list() 过滤子 agent + 已归档;名字 live 用 sessionTitle.get(最新 fold),非 live 用 persistence 最后 title。
|
|
597
|
+
async function _activeSessions() {
|
|
598
|
+
if (!workspaceRegistry || !workspaceRegistry.list) return []
|
|
599
|
+
// 已归档集合(workspaceRegistry.archivedSessionIds 是 registry 级归档集合)
|
|
600
|
+
const archivedSet = {}
|
|
601
|
+
try { const arch = workspaceRegistry.archivedSessionIds; if (Array.isArray(arch)) { for (const id of arch) archivedSet[id] = true } } catch (e) {}
|
|
602
|
+
// 数据源:各工作区的 sessionIds(= 左侧会话列表显示的有效会话;已关闭/废弃的不在任何工作区里,自然排除)
|
|
603
|
+
const entries = []
|
|
604
|
+
const seen = {}
|
|
605
|
+
const wl = workspaceRegistry.list() || []
|
|
606
|
+
for (const w of wl) {
|
|
607
|
+
const wsTitle = (w && w.title) || basename((w && w.path) || '')
|
|
608
|
+
let sids = []
|
|
609
|
+
try { sids = w.sessionIds || [] } catch (e) {}
|
|
610
|
+
for (const sid of (sids || [])) {
|
|
611
|
+
if (!sid || seen[sid]) continue
|
|
612
|
+
seen[sid] = true
|
|
613
|
+
if (archivedSet[sid]) continue // 已归档跳过
|
|
614
|
+
entries.push({ sid, wsTitle })
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (entries.length === 0) return []
|
|
618
|
+
// 批量读 title + header(origin/cwd/createdAt):sessionQuery.readTitleSnapshots(live/persisted 都行,取代已删除的 inspect)
|
|
619
|
+
const metaMap = {}
|
|
620
|
+
if (sessionQuery && sessionQuery.readTitleSnapshots) {
|
|
621
|
+
try {
|
|
622
|
+
const results = await sessionQuery.readTitleSnapshots(entries.map(e => e.sid))
|
|
623
|
+
for (const r of (results || [])) {
|
|
624
|
+
if (r && r.status === 'fulfilled' && r.value) {
|
|
625
|
+
const hd = r.value.session || {}
|
|
626
|
+
metaMap[r.sessionId] = { title: (r.value.title && r.value.title.title) || '', cwd: hd.cwd || '', origin: hd.origin || '', createdAt: hd.createdAt }
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
} catch (e) {}
|
|
630
|
+
}
|
|
631
|
+
const out = []
|
|
632
|
+
for (const e of entries) {
|
|
633
|
+
const meta = metaMap[e.sid] || {}
|
|
634
|
+
if (meta.origin === 'subagent') continue // 排除一次性子 agent
|
|
635
|
+
const liveAgent = agents && agents.get ? agents.get(e.sid) : undefined
|
|
636
|
+
const live = !!liveAgent
|
|
637
|
+
let title = meta.title || ''
|
|
638
|
+
// live 优先用 sessionTitle.get(最新 fold,含 fork 改名后的新名)
|
|
639
|
+
if (live && sessionTitle && sessionTitle.get && liveAgent.session) {
|
|
640
|
+
try { const snap = sessionTitle.get(liveAgent.session); if (snap && snap.title) title = snap.title } catch (e2) {}
|
|
641
|
+
}
|
|
642
|
+
// 无标题且非 live 的会话视为已关闭/废弃(从没生成标题,也不在运行),不在派发/注入列表显示
|
|
643
|
+
if (!title && !live) continue
|
|
644
|
+
out.push({ id: e.sid, short: shortSid(e.sid), name: title || (e.wsTitle + ' · ' + shortSid(e.sid)), cwd: meta.cwd || '', workspace: e.wsTitle, live: live, createdAt: meta.createdAt })
|
|
645
|
+
}
|
|
646
|
+
// 排序:live 在前,再按创建时间倒序
|
|
647
|
+
out.sort((a, b) => { if (a.live !== b.live) return a.live ? -1 : 1; return String(b.createdAt || '').localeCompare(String(a.createdAt || '')) })
|
|
648
|
+
return out
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// 任务派发(共享):主动注入上下文 + 触发对话——agent.send 一条消息到目标会话,
|
|
652
|
+
// source 标记为 { kind:'plugin', form:'recall' }(todo 作为"召回的上下文",区别于用户指令/系统提示拼接),
|
|
653
|
+
// wakeup=true 保证触发该会话 agent 去获取并处理这条上下文(可见反应,不污染系统提示)。
|
|
654
|
+
// opts: { sessionId, sessionName, workspace, mode('existing'|'new'), instruction }
|
|
655
|
+
async function _dispatch(id, opts) {
|
|
656
|
+
const o = opts || {}
|
|
657
|
+
const note = await _get(id)
|
|
658
|
+
if (note.deleted) return { error: '笔记已删除' }
|
|
659
|
+
if (!o.sessionId) return { error: '缺少目标会话' }
|
|
660
|
+
const target = agents && agents.get ? agents.get(o.sessionId) : undefined
|
|
661
|
+
if (!target || typeof target.send !== 'function') return { error: '目标会话当前未打开,无法触发工作。请先打开它,或改用「新建会话」。', needOpen: true }
|
|
662
|
+
const instruction = String(o.instruction || '').trim()
|
|
663
|
+
const text = '【笔记插件 · 派发的待办上下文】\n\n【待办】' + (note.title || 'Untitled') + '\n' + String(note.body || note.title || '').trim() + (instruction ? '\n\n【派发方补充的要求】\n' + instruction : '') + '\n\n—— 以上是笔记插件派发给你的待办上下文(recall)。请获取此上下文并开始处理。'
|
|
664
|
+
const msg = {
|
|
665
|
+
id: 'note-dispatch-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
666
|
+
role: 'user',
|
|
667
|
+
content: [{ type: 'text', text: text }],
|
|
668
|
+
// form:'recall':标记为"召回的上下文"而非用户指令;agent loop 照常处理(wakeup 触发),模型据 form 理解为参考资料
|
|
669
|
+
source: { kind: 'plugin', plugin: 'dsh-notes', form: 'recall' }
|
|
670
|
+
}
|
|
671
|
+
target.send(msg, 'next-turn', true)
|
|
672
|
+
// 派发历史:作为笔记属性记录(不改正文)
|
|
673
|
+
const rec = {
|
|
674
|
+
sessionId: o.sessionId,
|
|
675
|
+
sessionName: o.sessionName || shortSid(o.sessionId),
|
|
676
|
+
workspace: o.workspace || '',
|
|
677
|
+
mode: o.mode || 'existing',
|
|
678
|
+
instruction: instruction,
|
|
679
|
+
at: new Date().toISOString(),
|
|
680
|
+
done: false
|
|
681
|
+
}
|
|
682
|
+
note.dispatches = (note.dispatches || []).concat([rec])
|
|
683
|
+
note.updatedAt = new Date().toISOString()
|
|
684
|
+
await persistNote(note)
|
|
685
|
+
return { ok: true, id: note.id, sessionId: o.sessionId, sessionName: rec.sessionName, dispatch: rec }
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// 标记一条派发待办为完成(停止注入目标会话的系统提示)
|
|
689
|
+
async function _dispatchDone(id, dispatchIndex) {
|
|
690
|
+
const note = await _get(id)
|
|
691
|
+
if (note.deleted) return { error: '笔记已删除' }
|
|
692
|
+
const ds = note.dispatches || []
|
|
693
|
+
const i = typeof dispatchIndex === 'number' ? dispatchIndex : -1
|
|
694
|
+
if (i < 0 || i >= ds.length) return { error: '无效的派发记录索引' }
|
|
695
|
+
ds[i] = Object.assign({}, ds[i], { done: true, doneAt: new Date().toISOString() })
|
|
696
|
+
note.dispatches = ds
|
|
697
|
+
note.updatedAt = new Date().toISOString()
|
|
698
|
+
await persistNote(note)
|
|
699
|
+
return { ok: true, id: note.id }
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function _search(query, tag, topic, kind) {
|
|
703
|
+
const all = await _list()
|
|
704
|
+
const q = query ? String(query).toLowerCase() : ''
|
|
705
|
+
return all.filter(n => {
|
|
706
|
+
if (tag && (n.tags || []).indexOf(tag) < 0) return false
|
|
707
|
+
if (topic && n.topic !== topic) return false
|
|
708
|
+
if (kind && n.kind !== kind) return false
|
|
709
|
+
if (q) {
|
|
710
|
+
const hay = ((n.title || '') + ' ' + (n.body || '') + ' ' + (n.topic || '') + ' ' + (n.tags || []).join(' ')).toLowerCase()
|
|
711
|
+
if (hay.indexOf(q) < 0) return false
|
|
712
|
+
}
|
|
713
|
+
return true
|
|
714
|
+
})
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// T2.3 工作区约定:从常驻内存 cache 同步读取 convention 笔记,注入 agent 系统提示。
|
|
718
|
+
// text 是同步函数(systemPrompt 契约),故不能 await _list(),必须读 cache。
|
|
719
|
+
// 注入范围由约定笔记的 injectTo 字段决定(可选):
|
|
720
|
+
// 是否注入:inject 布尔字段(noteFromParsed 已对旧数据回退到 convention 标签)。
|
|
721
|
+
// 注入范围 injectTo 是多选数组:
|
|
722
|
+
// [](空) → 默认当前工作区(向后兼容旧数据 injectTo='')
|
|
723
|
+
// 含 'global' → 全局注入(不限工作区)
|
|
724
|
+
// 含 'workspace' → 当前工作区
|
|
725
|
+
// 含会话短 id → 注入该会话(可多选多个会话)
|
|
726
|
+
function conventionText() {
|
|
727
|
+
const shortSid = (x) => x ? String(x).replace(/^session-/, '').slice(0, 8) : ''
|
|
728
|
+
try {
|
|
729
|
+
let cwd = ''
|
|
730
|
+
let sid = ''
|
|
731
|
+
const a = agents && agents.currentInitiator ? agents.currentInitiator() : undefined
|
|
732
|
+
if (a) {
|
|
733
|
+
cwd = (a.session && a.session.header && a.session.header.cwd) || ''
|
|
734
|
+
sid = a.sessionId || (a.session && a.session.id) || ''
|
|
735
|
+
}
|
|
736
|
+
const ws = basename(cwd)
|
|
737
|
+
const curSid = shortSid(sid)
|
|
738
|
+
const matches = []
|
|
739
|
+
for (const n of cache.values()) {
|
|
740
|
+
if (n.deleted) continue
|
|
741
|
+
if (n.inject !== true) continue
|
|
742
|
+
const targets = n.injectTo || []
|
|
743
|
+
let hit = false
|
|
744
|
+
if (targets.length === 0) {
|
|
745
|
+
// 默认:当前工作区(workspace 为空视为全局约定)
|
|
746
|
+
hit = !n.workspace || !ws || n.workspace === ws
|
|
747
|
+
} else {
|
|
748
|
+
for (const t of targets) {
|
|
749
|
+
if (t === 'global') { hit = true; break }
|
|
750
|
+
if (t === 'workspace') { if (!n.workspace || !ws || n.workspace === ws) { hit = true; break } }
|
|
751
|
+
else if (t === curSid) { hit = true; break }
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (hit) matches.push(n)
|
|
755
|
+
}
|
|
756
|
+
if (matches.length === 0) return ''
|
|
757
|
+
matches.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''))
|
|
758
|
+
const blocks = matches.map(n => {
|
|
759
|
+
const src = shortSid(n.sessionId)
|
|
760
|
+
const srcLabel = src ? '(记录于会话 ' + src + ')' : '(来源会话未知)'
|
|
761
|
+
return '【' + (n.title || 'Untitled') + '】' + srcLabel + '\n' + String(n.body || '').trim()
|
|
762
|
+
}).join('\n\n')
|
|
763
|
+
const label = ws ? '工作区「' + ws + '」' : '全局'
|
|
764
|
+
const full = '以下是' + label + '已记录的约定(本地笔记,每条标注其记录会话;与当前任务无关时忽略):\n\n' + blocks
|
|
765
|
+
return full.length > 4000 ? full.slice(0, 4000) + '\n\n(内容过长已截断)' : full
|
|
766
|
+
} catch (e) { return '' }
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ---- 性能遥测:RPC 计数/耗时 + 缓存命中 + client 推送快照,节流写盘供诊断 ----
|
|
770
|
+
const perfStats = { started: new Date().toISOString(), rpc: {}, rpcMs: {}, classify: 0, classifyMs: 0, cacheReads: 0, diskReads: 0, diskWrites: 0, client: null }
|
|
771
|
+
const PERF_PATH = path.join(NOTES_ROOT, 'perf-report.json')
|
|
772
|
+
let lastPerfWrite = 0
|
|
773
|
+
function writePerfReport() {
|
|
774
|
+
const t = Date.now()
|
|
775
|
+
if (t - lastPerfWrite < 10000) return
|
|
776
|
+
lastPerfWrite = t
|
|
777
|
+
;(async () => {
|
|
778
|
+
try {
|
|
779
|
+
const ft = await fs.resolve(PERF_PATH)
|
|
780
|
+
await fs.writeText(ft, JSON.stringify({ writtenAt: new Date().toISOString(), host: perfStats }, null, 2), undefined, undefined, getPolicy())
|
|
781
|
+
} catch (e) {}
|
|
782
|
+
})()
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ---- client ↔ host RPC ----
|
|
786
|
+
// 主通道:全局 Builtin `harness.handle`(原 host-impl.js 的 helper 姿势原样保留,只是补了 handler 表)。
|
|
787
|
+
// 兜底通道:harness 缺失时同一批 handler 由 ctx.webServer.register 的 exact 路由承载。
|
|
788
|
+
const handlers = {}
|
|
789
|
+
function handle(name, fn) {
|
|
790
|
+
const wrapped = async (args) => {
|
|
791
|
+
const t0 = Date.now()
|
|
792
|
+
try { return await fn(args) }
|
|
793
|
+
finally { perfStats.rpc[name] = (perfStats.rpc[name] || 0) + 1; perfStats.rpcMs[name] = (perfStats.rpcMs[name] || 0) + (Date.now() - t0); writePerfReport() }
|
|
794
|
+
}
|
|
795
|
+
handlers[name] = wrapped // handler 表始终维护:webServer 兜底路由据此分发
|
|
796
|
+
if (harnessRef && typeof harnessRef.handle === 'function') return harnessRef.handle(name, wrapped)
|
|
797
|
+
return () => { delete handlers[name] }
|
|
798
|
+
}
|
|
799
|
+
disposers.push(handle('notes-perf', async (args) => { if (args && args.perf) perfStats.client = args.perf; return { ok: true } }))
|
|
800
|
+
disposers.push(handle('notes-list', async (args) => ({ notes: (await _list(args && args.tag, args && args.kind)).map(slim) })))
|
|
801
|
+
// 样式文件按需下发:避免 client 内嵌超长 CSS 字符串(包内 styles.css 优先,开发版目录回退)
|
|
802
|
+
disposers.push(handle('notes-css', async () => {
|
|
803
|
+
try {
|
|
804
|
+
for (const p of CSS_CANDIDATES) {
|
|
805
|
+
try { if (fsNode.existsSync(p)) return { css: fsNode.readFileSync(p, 'utf8') } } catch (e) {}
|
|
806
|
+
}
|
|
807
|
+
throw new Error('styles.css not found in: ' + CSS_CANDIDATES.join(' | '))
|
|
808
|
+
} catch (e) { return { error: String(e.message || e) } }
|
|
809
|
+
}))
|
|
810
|
+
// client 实现源码下发:开发版 bootstrap 壳通过它加载 client-impl.js(同理避免 define 传大字符串)。
|
|
811
|
+
// 发布版候选:开发版目录的 client-impl.js / host-impl.js,包内的 lib/client.js / index.mjs。
|
|
812
|
+
disposers.push(handle('notes-src', async (args) => {
|
|
813
|
+
try {
|
|
814
|
+
const which = args && args.which === 'client' ? 'client' : 'host'
|
|
815
|
+
const candidates = which === 'client'
|
|
816
|
+
? [path.join(LEGACY_PLUGIN_DIR, 'client-impl.js'), path.join(PKG_DIR, 'lib', 'client.js')]
|
|
817
|
+
: [path.join(LEGACY_PLUGIN_DIR, 'host-impl.js'), path.join(PKG_DIR, 'index.mjs')]
|
|
818
|
+
for (const p of candidates) {
|
|
819
|
+
try { if (fsNode.existsSync(p)) return { src: fsNode.readFileSync(p, 'utf8') } } catch (e) {}
|
|
820
|
+
}
|
|
821
|
+
throw new Error(which + ' source not found in: ' + candidates.join(' | '))
|
|
822
|
+
} catch (e) { return { error: String(e.message || e) } }
|
|
823
|
+
}))
|
|
824
|
+
disposers.push(handle('notes-get', async (args) => {
|
|
825
|
+
try { const n = await _get(args.id); const s = slim(n); s.body = n.body; return { note: s } } catch (e) { return { error: String(e.message || e) } }
|
|
826
|
+
}))
|
|
827
|
+
disposers.push(handle('notes-create', async (args) => {
|
|
828
|
+
try { return await _create(args.title, args.body, args.tags, args.topic, { kind: args.kind, status: args.status, inject: args.inject, injectTo: args.injectTo }) } catch (e) { return { error: String(e.message || e) } }
|
|
829
|
+
}))
|
|
830
|
+
disposers.push(handle('notes-update', async (args) => {
|
|
831
|
+
try { return await _update(args.id, args.title, args.body, args.tags, args.topic, args.kind, args.status, args.inject, args.injectTo) } catch (e) { return { error: String(e.message || e) } }
|
|
832
|
+
}))
|
|
833
|
+
disposers.push(handle('notes-quick', async (args) => {
|
|
834
|
+
try { return await _quickCapture(args.text, args.sessionId, args.cwd, args.kind) } catch (e) { return { error: String(e.message || e) } }
|
|
835
|
+
}))
|
|
836
|
+
// T3 指令式快速记录:备注非空时 LLM 提取 tags/titleHint/kind/inject,选区原文原样为 body,备注不进笔记
|
|
837
|
+
disposers.push(handle('notes-quick-instruct', async (args) => {
|
|
838
|
+
try { return await _quickInstruct(args.text, args.note, args.sessionId, args.cwd) } catch (e) { return { error: String(e.message || e) } }
|
|
839
|
+
}))
|
|
840
|
+
disposers.push(handle('notes-delete', async (args) => {
|
|
841
|
+
try { return await _delete(args.id) } catch (e) { return { error: String(e.message || e) } }
|
|
842
|
+
}))
|
|
843
|
+
disposers.push(handle('notes-restore', async (args) => {
|
|
844
|
+
try { return await _restore(args.id) } catch (e) { return { error: String(e.message || e) } }
|
|
845
|
+
}))
|
|
846
|
+
disposers.push(handle('notes-archive', async () => {
|
|
847
|
+
try { return await _archive() } catch (e) { return { error: String(e.message || e) } }
|
|
848
|
+
}))
|
|
849
|
+
disposers.push(handle('notes-search', async (args) => {
|
|
850
|
+
try { return { notes: (await _search(args && args.query, args && args.tag, args && args.topic, args && args.kind)).map(slim) } } catch (e) { return { error: String(e.message || e) } }
|
|
851
|
+
}))
|
|
852
|
+
// T2.3 工作区约定自动注入:注册动态 prompt context(order 130,位于 policy/delegation 之后)
|
|
853
|
+
if (systemPrompt && typeof systemPrompt.context === 'function') {
|
|
854
|
+
disposers.push(systemPrompt.context({ name: 'notes:workspace-conventions', order: 130, text: () => conventionText() }))
|
|
855
|
+
}
|
|
856
|
+
// 调试 RPC:预览当前会话将注入的约定文本(E2E 验证用)
|
|
857
|
+
disposers.push(handle('notes-conventions', async () => ({ text: conventionText() || '' })))
|
|
858
|
+
// 会话列表(注入范围多选用):与派发同源——工作区有效会话(排除已归档 + 子 agent),复用 _activeSessions
|
|
859
|
+
disposers.push(handle('notes-sessions', async () => {
|
|
860
|
+
try { return { sessions: await _activeSessions() } } catch (e) { return { sessions: [] } }
|
|
861
|
+
}))
|
|
862
|
+
// 活跃主会话列表(任务派发目标用):agents.roots() 返回顶层 live agents(天然排除子 agent)
|
|
863
|
+
disposers.push(handle('notes-active-sessions', async () => {
|
|
864
|
+
try { return { sessions: await _activeSessions() } } catch (e) { return { sessions: [] } }
|
|
865
|
+
}))
|
|
866
|
+
// 工作区列表(派发对话框的"新建会话"下拉用)
|
|
867
|
+
disposers.push(handle('notes-workspaces', async () => {
|
|
868
|
+
try {
|
|
869
|
+
if (!workspaceRegistry || !workspaceRegistry.list) return { workspaces: [] }
|
|
870
|
+
const list = workspaceRegistry.list() || []
|
|
871
|
+
return { workspaces: list.map(w => ({ id: w.id, title: w.title || basename(w.path || ''), cwd: w.path || '' })) }
|
|
872
|
+
} catch (e) { return { workspaces: [] } }
|
|
873
|
+
}))
|
|
874
|
+
// 任务派发(client 面板用):复用共享 _dispatch(系统提示注入形式,登记到 dispatches)
|
|
875
|
+
disposers.push(handle('notes-dispatch', async (args) => {
|
|
876
|
+
try { return await _dispatch(args.id, { sessionId: args.sessionId, sessionName: args.sessionName, workspace: args.workspace, mode: args.mode, instruction: args.instruction }) } catch (e) { return { error: String(e.message || e) } }
|
|
877
|
+
}))
|
|
878
|
+
// 标记派发待办完成(停止注入目标会话系统提示)
|
|
879
|
+
disposers.push(handle('notes-dispatch-done', async (args) => {
|
|
880
|
+
try { return await _dispatchDone(args.id, args.dispatchIndex) } catch (e) { return { error: String(e.message || e) } }
|
|
881
|
+
}))
|
|
882
|
+
// POC 存活探测(P1 骨架遗留,包内 lib/client.js 的「笔记POC」按钮消费;非 host-impl 的 19 个 RPC 之一)
|
|
883
|
+
disposers.push(handle('notes-ping', async (args) => ({ ok: true, pong: Date.now(), echo: (args && typeof args === 'object') ? args : null })))
|
|
884
|
+
|
|
885
|
+
// ---- RPC 兜底路由(harness 缺失时生效;harness 存在时它是无副作用的第二传送门)----
|
|
886
|
+
function readBody(req, limit) {
|
|
887
|
+
return new Promise(function (resolve, reject) {
|
|
888
|
+
var chunks = [], size = 0
|
|
889
|
+
req.on('data', function (c) { size += c.length; if (size > limit) { reject(new Error('payload too large')); try { req.destroy() } catch (_) {} return }; chunks.push(c) })
|
|
890
|
+
req.on('end', function () { resolve(Buffer.concat(chunks).toString('utf8')) })
|
|
891
|
+
req.on('error', reject)
|
|
892
|
+
})
|
|
893
|
+
}
|
|
894
|
+
if (webServer && typeof webServer.register === 'function') {
|
|
895
|
+
disposers.push(webServer.register({
|
|
896
|
+
kind: 'exact',
|
|
897
|
+
path: RPC_PATH,
|
|
898
|
+
handler: async function (req, res) {
|
|
899
|
+
res.setHeader('Content-Type', 'application/json')
|
|
900
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
901
|
+
if (req.method !== 'POST') { res.writeHead(405); res.end(JSON.stringify({ ok: false, message: 'method not allowed' })); return }
|
|
902
|
+
var payload = null
|
|
903
|
+
try { payload = JSON.parse(await readBody(req, 4 * 1024 * 1024)) } catch (e) { res.writeHead(400); res.end(JSON.stringify({ ok: false, message: 'bad request' })); return }
|
|
904
|
+
var fn = payload && handlers[payload.method]
|
|
905
|
+
if (!fn) { res.writeHead(404); res.end(JSON.stringify({ ok: false, message: 'unknown method: ' + payload.method })); return }
|
|
906
|
+
try { var out = await fn(payload.args); res.writeHead(200); res.end(JSON.stringify(out === undefined ? null : out)) } catch (e) { res.writeHead(500); res.end(JSON.stringify({ ok: false, message: String(e) })) }
|
|
907
|
+
},
|
|
908
|
+
}))
|
|
909
|
+
} else if (!(harnessRef && typeof harnessRef.handle === 'function')) {
|
|
910
|
+
console.error('notes: harness 与 ctx.webServer 均不可用,RPC 未注册')
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// 工具注册:主通道 = 全局 Builtin harness.defineTool + harness.registerTool(原 host-impl.js 姿势原样保留);
|
|
914
|
+
// 仅在 harness 缺失时回退到 ctx.tools.register(task-board 的服务路径)。两通道互斥,不会重复注册。
|
|
915
|
+
function regTool(def) {
|
|
916
|
+
if (harnessRef && typeof harnessRef.defineTool === 'function' && typeof harnessRef.registerTool === 'function') {
|
|
917
|
+
const tool = harnessRef.defineTool(def)
|
|
918
|
+
if (tool) { const d = harnessRef.registerTool(ctx, tool); if (typeof d === 'function') disposers.push(d) }
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
if (tools && typeof tools.register === 'function') {
|
|
922
|
+
const d = tools.register(defineTool(def))
|
|
923
|
+
if (typeof d === 'function') disposers.push(d)
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
console.error('notes: 无可用工具注册通道(harness / ctx.tools),工具未注册:' + (def && def.name))
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const outSchema = { type: 'object', additionalProperties: true }
|
|
930
|
+
function mkRender() {
|
|
931
|
+
return (args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ---- 工具层:合并 9 个细粒度工具为 3 个(note_search / note_get / note_manage)
|
|
935
|
+
// RPC 层保持 handler 不变(client panel 仍在用);工具只面向 Agent,瘦身 schema。
|
|
936
|
+
regTool({
|
|
937
|
+
name: 'note_search',
|
|
938
|
+
description: 'Search local notes by free-text query (matches title/body/topic/tags), with optional tag, topic, and kind filters. Returns slim notes (no body) for fast triage — call note_get for the full body of a specific id.',
|
|
939
|
+
parameters: {
|
|
940
|
+
type: 'object',
|
|
941
|
+
properties: {
|
|
942
|
+
query: { type: 'string', description: 'Free-text query against title, body, topic, and tags. Omit to list all (optionally filtered by tag/topic/kind).' },
|
|
943
|
+
tag: { type: 'string', description: 'Optional tag filter (exact match)' },
|
|
944
|
+
topic: { type: 'string', description: 'Optional topic filter (exact match)' },
|
|
945
|
+
kind: { type: 'string', enum: KINDS, description: 'Optional kind filter: note/decision/todo/link/quote' },
|
|
946
|
+
limit: { type: 'number', description: 'Optional max results (default 50)' }
|
|
947
|
+
}
|
|
948
|
+
},
|
|
949
|
+
output: { schema: outSchema, render: mkRender() },
|
|
950
|
+
async execute(args) {
|
|
951
|
+
const all = await _search(args && args.query, args && args.tag, args && args.topic, args && args.kind)
|
|
952
|
+
const limit = (args && args.limit) || 50
|
|
953
|
+
return { count: all.length, notes: all.slice(0, limit).map(slim) }
|
|
954
|
+
}
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
regTool({
|
|
958
|
+
name: 'note_get',
|
|
959
|
+
description: 'Read the full body and metadata of a local note by id. Use after note_search to retrieve the body of an interesting result.',
|
|
960
|
+
parameters: {
|
|
961
|
+
type: 'object',
|
|
962
|
+
properties: { id: { type: 'string', description: 'Note id' } },
|
|
963
|
+
required: ['id']
|
|
964
|
+
},
|
|
965
|
+
output: { schema: outSchema, render: mkRender() },
|
|
966
|
+
async execute(args) {
|
|
967
|
+
try { const n = await _get(args.id); return { note: n } }
|
|
968
|
+
catch (e) { return { error: String(e.message || e) } }
|
|
969
|
+
}
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
regTool({
|
|
973
|
+
name: 'note_manage',
|
|
974
|
+
description: 'Single tool for create/list/update/delete/restore/archive. Pick an action and supply its required fields. The Agent should prefer this for any non-search CRUD: one tool means one decision point and one schema to learn.\n\n' +
|
|
975
|
+
'Fields kind (what it is) and status (its lifecycle) are orthogonal: kind ∈ note/decision/todo/link/quote (default note); status ∈ active/pinned/resolved/superseded (default active).\n' +
|
|
976
|
+
'inject (boolean) controls whether the note is injected into the system prompt as a workspace convention — an explicit field, NOT a tag. injectTo (string[]) is the injection scope, a multi-select list: [] or ["workspace"]=current workspace (default), ["global"]=all sessions, or session short-ids like ["99f2b674","7f8b49e6"]=those sessions.\n\n' +
|
|
977
|
+
'Actions:\n' +
|
|
978
|
+
'- create: { title, body, topic?, tags?, kind?, status?, inject?, injectTo?, sessionId?, cwd?, workspace? }\n' +
|
|
979
|
+
'- list: { tag?, topic?, kind? } (no id/title/body needed)\n' +
|
|
980
|
+
'- update: { id, title?, body?, topic?, tags?, kind?, status?, inject?, injectTo? }\n' +
|
|
981
|
+
'- delete: { id } (soft delete; restorable via restore)\n' +
|
|
982
|
+
'- restore: { id } (undo delete/archive)\n' +
|
|
983
|
+
'- archive: {} (no fields; merges quick-captures by session and manual notes by tag)\n' +
|
|
984
|
+
'- dispatch: { id, targetSessionId?, targetSessionName?, instruction? } (assemble the todo context plus your instruction into one user message and send it to a live session as a real task; the handoff is recorded in the note\'s dispatches property. Omit targetSessionId to list live sessions.)',
|
|
985
|
+
parameters: {
|
|
986
|
+
type: 'object',
|
|
987
|
+
properties: {
|
|
988
|
+
action: { type: 'string', enum: ['create', 'list', 'update', 'delete', 'restore', 'archive', 'dispatch', 'debugws'], description: 'Action to perform' },
|
|
989
|
+
// create / update 字段
|
|
990
|
+
id: { type: 'string', description: 'Note id (required for update/delete/restore/dispatch)' },
|
|
991
|
+
title: { type: 'string', description: 'Title (create/update)' },
|
|
992
|
+
body: { type: 'string', description: 'Markdown body (create/update)' },
|
|
993
|
+
topic: { type: 'string', description: 'Topic (create/update; defaults to 未分类)' },
|
|
994
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Tags (create/update)' },
|
|
995
|
+
kind: { type: 'string', enum: KINDS, description: 'Kind (create/update): note/decision/todo/link/quote; default note' },
|
|
996
|
+
status: { type: 'string', enum: STATUSES, description: 'Status (create/update): active/pinned/resolved/superseded; default active' },
|
|
997
|
+
inject: { type: 'boolean', description: 'Inject as convention into system prompt (create/update); default false' },
|
|
998
|
+
injectTo: { type: 'array', items: { type: 'string' }, description: 'Injection scope multi-select (create/update): []/["workspace"]=current workspace (default), ["global"]=all, or session short-ids' },
|
|
999
|
+
// dispatch 字段
|
|
1000
|
+
targetSessionId: { type: 'string', description: 'Dispatch target: a live session id (dispatch). Omit to list live sessions.' },
|
|
1001
|
+
targetSessionName: { type: 'string', description: 'Dispatch target display name (dispatch, optional)' },
|
|
1002
|
+
instruction: { type: 'string', description: 'Dispatch: your concrete instruction appended to the todo context (dispatch, optional)' },
|
|
1003
|
+
// list 字段
|
|
1004
|
+
tag: { type: 'string', description: 'Tag filter (list only)' },
|
|
1005
|
+
// 高级(通常自动填充)
|
|
1006
|
+
sessionId: { type: 'string', description: 'Session id (advanced; usually auto-filled)' },
|
|
1007
|
+
cwd: { type: 'string', description: 'Working dir (advanced; usually auto-filled)' },
|
|
1008
|
+
workspace: { type: 'string', description: 'Workspace name (advanced; usually auto-filled)' }
|
|
1009
|
+
},
|
|
1010
|
+
required: ['action']
|
|
1011
|
+
},
|
|
1012
|
+
output: { schema: outSchema, render: mkRender() },
|
|
1013
|
+
async execute(args) {
|
|
1014
|
+
const action = args && args.action
|
|
1015
|
+
try {
|
|
1016
|
+
if (action === 'create') {
|
|
1017
|
+
if (!args.title || !args.body) return { error: 'note_manage.create 需要 title 和 body' }
|
|
1018
|
+
const r = await _create(args.title, args.body, args.tags, args.topic, {
|
|
1019
|
+
sessionId: args.sessionId, cwd: args.cwd, workspace: args.workspace,
|
|
1020
|
+
kind: args.kind, status: args.status, inject: args.inject, injectTo: args.injectTo
|
|
1021
|
+
})
|
|
1022
|
+
return { action: 'create', id: r.id, topic: r.topic, kind: r.kind, status: r.status, message: 'Note created' }
|
|
1023
|
+
}
|
|
1024
|
+
if (action === 'list') {
|
|
1025
|
+
const notes = await _list(args.tag, args.kind)
|
|
1026
|
+
return { action: 'list', count: notes.length, notes: notes.map(slim) }
|
|
1027
|
+
}
|
|
1028
|
+
if (action === 'update') {
|
|
1029
|
+
if (!args.id) return { error: 'note_manage.update 需要 id' }
|
|
1030
|
+
const r = await _update(args.id, args.title, args.body, args.tags, args.topic, args.kind, args.status, args.inject, args.injectTo)
|
|
1031
|
+
return { action: 'update', id: args.id, kind: r.kind, status: r.status, message: 'Note updated' }
|
|
1032
|
+
}
|
|
1033
|
+
if (action === 'delete') {
|
|
1034
|
+
if (!args.id) return { error: 'note_manage.delete 需要 id' }
|
|
1035
|
+
await _delete(args.id)
|
|
1036
|
+
return { action: 'delete', id: args.id, message: 'Note deleted (soft)' }
|
|
1037
|
+
}
|
|
1038
|
+
if (action === 'restore') {
|
|
1039
|
+
if (!args.id) return { error: 'note_manage.restore 需要 id' }
|
|
1040
|
+
await _restore(args.id)
|
|
1041
|
+
return { action: 'restore', id: args.id, message: 'Note restored' }
|
|
1042
|
+
}
|
|
1043
|
+
if (action === 'archive') {
|
|
1044
|
+
const r = await _archive()
|
|
1045
|
+
return { action: 'archive', merged: r.merged, mergedIds: r.mergedIds, message: 'Archived ' + r.merged + ' groups' }
|
|
1046
|
+
}
|
|
1047
|
+
if (action === 'debugws') {
|
|
1048
|
+
// dump 指定会话(args.sessionId=short id)的 title 事件历史 + fold 结果
|
|
1049
|
+
if (args.sessionId) {
|
|
1050
|
+
const hs = await sessionPersistence.list()
|
|
1051
|
+
const h = hs.map(snapHeader).find(x => x && shortSid(x.id) === args.sessionId)
|
|
1052
|
+
if (!h) return { error: '会话不存在: ' + args.sessionId }
|
|
1053
|
+
const insp = await sessionPersistence.inspect(h.id)
|
|
1054
|
+
const evs = (insp && insp.events) || []
|
|
1055
|
+
const titleEvs = []
|
|
1056
|
+
for (const ev of evs) { if (ev && ev.type === 'session/title') titleEvs.push({ seq: ev.seq, title: ev.data && ev.data.title, source: ev.data && ev.data.source && ev.data.source.kind }) }
|
|
1057
|
+
const live = agents && agents.get ? agents.get(h.id) : undefined
|
|
1058
|
+
let stTitle = '(not live)'
|
|
1059
|
+
if (live) { try { const s = sessionTitle.get(live.session); stTitle = s && s.title } catch (e) { stTitle = 'ERR' } }
|
|
1060
|
+
return { action: 'debugws', sessionShort: args.sessionId, live: !!live, parent: h.parentSession ? shortSid(h.parentSession) : '', sessionTitleGet: stTitle, titleEvents: titleEvs }
|
|
1061
|
+
}
|
|
1062
|
+
// 默认:对比 工作区 sessionIds(左侧列表数据源)vs sessionPersistence.list()(所有持久化),确认"已关闭"会话的差异
|
|
1063
|
+
const wl = (workspaceRegistry && workspaceRegistry.list) ? workspaceRegistry.list() : []
|
|
1064
|
+
const archivedSet = {}
|
|
1065
|
+
try { const arch = workspaceRegistry && workspaceRegistry.archivedSessionIds; if (Array.isArray(arch)) { for (const id of arch) archivedSet[id] = true } } catch (e) {}
|
|
1066
|
+
const wsInfo = []
|
|
1067
|
+
let totalInWs = 0, archivedInWs = 0
|
|
1068
|
+
for (const w of wl) {
|
|
1069
|
+
let sids = []
|
|
1070
|
+
try { sids = w.sessionIds || [] } catch (e) {}
|
|
1071
|
+
const cnt = Array.isArray(sids) ? sids.length : 0
|
|
1072
|
+
totalInWs += cnt
|
|
1073
|
+
const archCnt = Array.isArray(sids) ? sids.filter(s => archivedSet[s]).length : 0
|
|
1074
|
+
archivedInWs += archCnt
|
|
1075
|
+
wsInfo.push({ title: w.title, sessionCount: cnt, archivedInIt: archCnt })
|
|
1076
|
+
}
|
|
1077
|
+
let totalPersist = 0
|
|
1078
|
+
try { const hs = await sessionPersistence.list(); totalPersist = hs.length } catch (e) {}
|
|
1079
|
+
return {
|
|
1080
|
+
action: 'debugws',
|
|
1081
|
+
hasInspect: typeof sessionPersistence.inspect,
|
|
1082
|
+
hasStat: typeof sessionPersistence.stat,
|
|
1083
|
+
totalPersist: totalPersist,
|
|
1084
|
+
totalInWorkspaces: totalInWs,
|
|
1085
|
+
archivedInWorkspaces: archivedInWs,
|
|
1086
|
+
archivedSetSize: Object.keys(archivedSet).length,
|
|
1087
|
+
workspaces: wsInfo
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (action === 'dispatch') {
|
|
1091
|
+
if (!args.id) return { error: 'note_manage.dispatch 需要 id' }
|
|
1092
|
+
if (!args.targetSessionId) {
|
|
1093
|
+
// 未指定目标:返回当前活跃主会话列表(含名字)供 agent 选择
|
|
1094
|
+
const list = (await _activeSessions()).map(s => ({ sessionId: s.id, short: s.short, name: s.name, workspace: s.workspace }))
|
|
1095
|
+
return { action: 'dispatch', needTarget: true, activeSessions: list, message: '请用 targetSessionId 指定目标活跃会话' }
|
|
1096
|
+
}
|
|
1097
|
+
const r = await _dispatch(args.id, { sessionId: args.targetSessionId, sessionName: args.targetSessionName, instruction: args.instruction, mode: 'existing' })
|
|
1098
|
+
if (r.error) return { error: r.error }
|
|
1099
|
+
return { action: 'dispatch', id: args.id, sessionId: r.sessionId, message: '已派发到「' + r.sessionName + '」' + (args.instruction ? '(含具体要求)' : '') }
|
|
1100
|
+
}
|
|
1101
|
+
return { error: 'note_manage: 未知 action:' + String(action) + '(期望 create/list/update/delete/restore/archive/dispatch)' }
|
|
1102
|
+
} catch (e) { return { error: String(e.message || e) } }
|
|
1103
|
+
}
|
|
1104
|
+
})
|
|
1105
|
+
|
|
1106
|
+
// ---- 一次性数据迁移:开发版 D:\...\dsh-notes-plugin\notes → ~/.dsh/notes ----
|
|
1107
|
+
// 触发:目标目录缺失该 .md 时逐文件复制(幂等、不覆盖已存在的目标文件、不删除源目录)。
|
|
1108
|
+
// 走 ctx.fs 服务(而非 node:fs),保证写盘受 sandboxPolicy 管束,单测里也只落在内存 mock。
|
|
1109
|
+
async function listMd(dir) {
|
|
1110
|
+
try {
|
|
1111
|
+
const target = await fs.resolve(dir)
|
|
1112
|
+
const info = await fs.stat(target)
|
|
1113
|
+
if (!info) return []
|
|
1114
|
+
const entries = await fs.listDir(target)
|
|
1115
|
+
return (entries || []).map(e => e && e.name).filter(n => n && /\.md$/i.test(n))
|
|
1116
|
+
} catch (e) { return [] }
|
|
1117
|
+
}
|
|
1118
|
+
async function migrateLegacyNotes() {
|
|
1119
|
+
try {
|
|
1120
|
+
const legacyNames = await listMd(LEGACY_NOTES_DIR)
|
|
1121
|
+
if (legacyNames.length === 0) return { migrated: 0, skipped: 'no-legacy-notes' }
|
|
1122
|
+
const existing = {}
|
|
1123
|
+
for (const n of await listMd(NOTES_ROOT)) existing[n] = true
|
|
1124
|
+
let migrated = 0
|
|
1125
|
+
for (const name of legacyNames) {
|
|
1126
|
+
if (existing[name]) continue
|
|
1127
|
+
try {
|
|
1128
|
+
const src = await fs.resolve(path.join(LEGACY_NOTES_DIR, name))
|
|
1129
|
+
const dst = await fs.resolve(path.join(NOTES_ROOT, name))
|
|
1130
|
+
const content = await fs.readText(src)
|
|
1131
|
+
await fs.writeText(dst, content, undefined, undefined, getPolicy())
|
|
1132
|
+
migrated++
|
|
1133
|
+
} catch (e) { console.error('notes: migrate failed', name, e) }
|
|
1134
|
+
}
|
|
1135
|
+
if (migrated > 0) console.log('notes: migrated ' + migrated + ' legacy note(s) → ' + NOTES_ROOT)
|
|
1136
|
+
return { migrated: migrated, total: legacyNames.length }
|
|
1137
|
+
} catch (e) {
|
|
1138
|
+
console.error('notes: legacy migration error', e)
|
|
1139
|
+
return { migrated: 0, error: String(e && e.message || e) }
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
let migrationDone = migrateLegacyNotes()
|
|
1143
|
+
|
|
1144
|
+
ctx.effect(() => () => {
|
|
1145
|
+
for (const d of disposers) { try { d() } catch (e) {} }
|
|
1146
|
+
})
|
|
1147
|
+
// 发布版不再写 .last-host-load 开发心跳(静态包 import 即就绪,无需引导壳自检)
|
|
1148
|
+
console.log('notes plugin: host ready (static pkg), notes dir =', NOTES_ROOT, ', llm =', !!llm, ', adm =', !!adm, ', rpc =', RPC_PATH)
|
|
1149
|
+
}
|