dsh-session-manager 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -1
- package/README.md +54 -72
- package/README.zh.md +50 -78
- package/lib/annotation-store.js +198 -158
- package/lib/client.js +939 -98
- package/lib/clipboard-parser.js +207 -207
- package/lib/index.js +147 -4
- package/lib/session-files.js +211 -179
- package/package.json +2 -2
package/lib/clipboard-parser.js
CHANGED
|
@@ -1,208 +1,208 @@
|
|
|
1
|
-
// Clipboard parser for AI-generated annotation payloads. Accepts a raw
|
|
2
|
-
// string from navigator.clipboard.readText() and extracts a structured
|
|
3
|
-
// {tags, note, priority?} payload. Tolerates fenced code blocks and
|
|
4
|
-
// conversational wrappers, then enforces the same normalization rules
|
|
5
|
-
// as the annotation store so the import button can call
|
|
6
|
-
// annotationStore.save without re-validating.
|
|
7
|
-
|
|
8
|
-
import { ANNOTATION_LIMITS, normalizeAnnotationPatch } from "./annotation-store.js";
|
|
9
|
-
|
|
10
|
-
const NUL = String.fromCharCode(0);
|
|
11
|
-
const BACKSLASH = String.fromCharCode(92);
|
|
12
|
-
const QUOTE = String.fromCharCode(34);
|
|
13
|
-
const NEWLINE = String.fromCharCode(10);
|
|
14
|
-
const TAG_REJECT = /[,,\x00-\x1f]/;
|
|
15
|
-
|
|
16
|
-
const tagRejectReason = (key, reason) => ({ field: "tags", kind: "rejected", key, reason });
|
|
17
|
-
|
|
18
|
-
const trimLength = (value, max) => value.length > max ? value.slice(0, max) : value;
|
|
19
|
-
|
|
20
|
-
const rejectNonPositive = value => value !== null && (!Number.isInteger(value) || value < 1 || value > 5);
|
|
21
|
-
|
|
22
|
-
const normalizeStringArray = (value, warnings) => {
|
|
23
|
-
if (!Array.isArray(value)) throw new Error("tags must be an array of strings");
|
|
24
|
-
const seen = new Set();
|
|
25
|
-
const tags = [];
|
|
26
|
-
let dropped = 0;
|
|
27
|
-
for (const item of value) {
|
|
28
|
-
if (typeof item !== "string") { dropped++; continue; }
|
|
29
|
-
const trimmed = item.trim();
|
|
30
|
-
if (!trimmed) { dropped++; continue; }
|
|
31
|
-
if (trimmed.length > ANNOTATION_LIMITS.tagLength || TAG_REJECT.test(trimmed)) {
|
|
32
|
-
warnings.push(tagRejectReason(trimmed, "每个标签最多 32 字符,不能包含逗号或控制字符"));
|
|
33
|
-
dropped++;
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
const key = trimmed.toLowerCase();
|
|
37
|
-
if (seen.has(key)) continue;
|
|
38
|
-
seen.add(key);
|
|
39
|
-
tags.push(trimmed);
|
|
40
|
-
}
|
|
41
|
-
return { tags, dropped };
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const normalizeNote = (value, warnings) => {
|
|
45
|
-
if (typeof value !== "string") throw new Error("note must be a string");
|
|
46
|
-
if (value.includes(NUL)) throw new Error("note must not contain NUL characters");
|
|
47
|
-
if (value.length > ANNOTATION_LIMITS.noteLength) {
|
|
48
|
-
warnings.push({ field: "note", kind: "truncated", from: value.length, to: ANNOTATION_LIMITS.noteLength });
|
|
49
|
-
return trimLength(value, ANNOTATION_LIMITS.noteLength);
|
|
50
|
-
}
|
|
51
|
-
return value;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const normalizePriority = (value, warnings) => {
|
|
55
|
-
if (value === undefined) return undefined;
|
|
56
|
-
// Treat AI-returned null as the default priority (3 = Normal) so the UI
|
|
57
|
-
// never has to special-case unset entries.
|
|
58
|
-
if (value === null) return 3;
|
|
59
|
-
if (rejectNonPositive(value)) {
|
|
60
|
-
warnings.push({ field: "priority", kind: "rejected", value, reason: "优先级必须为 1–5 的整数;省略或 null 会按 3(普通)保存" });
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
return value;
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
// Find the first balanced top-level JSON object in the text. Handles
|
|
67
|
-
// strings, escapes and nested objects without relying on regular
|
|
68
|
-
// expressions.
|
|
69
|
-
const scanForJsonObject = text => {
|
|
70
|
-
const len = text.length;
|
|
71
|
-
for (let i = 0; i < len; i++) {
|
|
72
|
-
const ch = text[i];
|
|
73
|
-
if (ch !== "{") continue;
|
|
74
|
-
let depth = 0;
|
|
75
|
-
let inString = false;
|
|
76
|
-
let escape = false;
|
|
77
|
-
for (let j = i; j < len; j++) {
|
|
78
|
-
const c = text[j];
|
|
79
|
-
if (inString) {
|
|
80
|
-
if (escape) escape = false;
|
|
81
|
-
else if (c === BACKSLASH) escape = true;
|
|
82
|
-
else if (c === QUOTE) inString = false;
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
if (c === QUOTE) inString = true;
|
|
86
|
-
else if (c === "{") depth++;
|
|
87
|
-
else if (c === "}") {
|
|
88
|
-
depth--;
|
|
89
|
-
if (depth === 0) return text.slice(i, j + 1);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return null;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
// Strip common conversational wrappers (Markdown fences, leading prose)
|
|
97
|
-
// before scanning for JSON. The fence language tag is honored when
|
|
98
|
-
// present so we still find JSON inside triple-backtick fences.
|
|
99
|
-
const stripWrappers = text => {
|
|
100
|
-
// Strip UTF-8 BOM (U+FEFF) if present so JSON.parse doesn't reject it,
|
|
101
|
-
// then trim leading and trailing whitespace (the prior /s+/g was a literal
|
|
102
|
-
// "s+" regex and stripped only literal "s" characters, not whitespace).
|
|
103
|
-
let trimmed = text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
|
|
104
|
-
trimmed = trimmed.replace(/^\s+|\s+$/g, "");
|
|
105
|
-
const fence = trimmed.match(/^```(?:json|JSON)?\s*([\s\S]*?)\s*```$/);
|
|
106
|
-
if (fence) return fence[1];
|
|
107
|
-
const braceStart = trimmed.indexOf("{");
|
|
108
|
-
const braceEnd = trimmed.lastIndexOf("}");
|
|
109
|
-
if (braceStart >= 0 && braceEnd > braceStart) return trimmed.slice(braceStart, braceEnd + 1);
|
|
110
|
-
return trimmed;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const ALLOWED_KEYS = new Set(["tags", "note", "priority"]);
|
|
114
|
-
|
|
115
|
-
// Parse a clipboard string into an annotation patch. The returned patch
|
|
116
|
-
// is compatible with annotationStore.save: tags/note/priority are
|
|
117
|
-
// normalized the same way as the server-side validation. Surfaces a
|
|
118
|
-
// list of warnings for truncated notes and dropped tags instead of
|
|
119
|
-
// failing the import.
|
|
120
|
-
export function parseClipboardAnnotation(raw) {
|
|
121
|
-
if (typeof raw !== "string" || raw.trim() === "") {
|
|
122
|
-
return { ok: false, error: "剪贴板为空" };
|
|
123
|
-
}
|
|
124
|
-
const stripped = stripWrappers(raw);
|
|
125
|
-
let parsed;
|
|
126
|
-
try { parsed = JSON.parse(stripped); }
|
|
127
|
-
catch {
|
|
128
|
-
const fallback = scanForJsonObject(stripped);
|
|
129
|
-
if (fallback === null) return { ok: false, error: "无法在剪贴板内容中找到合法的 JSON 对象" };
|
|
130
|
-
try { parsed = JSON.parse(fallback); }
|
|
131
|
-
catch { return { ok: false, error: "剪贴板中的 JSON 无法解析" }; }
|
|
132
|
-
}
|
|
133
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
134
|
-
return { ok: false, error: "剪贴板内容不是 JSON 对象" };
|
|
135
|
-
}
|
|
136
|
-
const warnings = [];
|
|
137
|
-
const unknown = Object.keys(parsed).filter(key => !ALLOWED_KEYS.has(key));
|
|
138
|
-
for (const key of unknown) warnings.push({ field: key, kind: "ignored", reason: "未知的标记字段,已忽略" });
|
|
139
|
-
|
|
140
|
-
let tags = [];
|
|
141
|
-
let note = "";
|
|
142
|
-
let priority;
|
|
143
|
-
if ("tags" in parsed) {
|
|
144
|
-
try { const result = normalizeStringArray(parsed.tags, warnings); tags = result.tags; }
|
|
145
|
-
catch (error) { return { ok: false, error: error.message }; }
|
|
146
|
-
}
|
|
147
|
-
if ("note" in parsed) {
|
|
148
|
-
try { note = normalizeNote(parsed.note, warnings); }
|
|
149
|
-
catch (error) { return { ok: false, error: error.message }; }
|
|
150
|
-
}
|
|
151
|
-
if ("priority" in parsed) {
|
|
152
|
-
const normalized = normalizePriority(parsed.priority, warnings);
|
|
153
|
-
if (normalized !== undefined) priority = normalized;
|
|
154
|
-
}
|
|
155
|
-
const patch = { tags, note, ...(priority !== undefined ? { priority } : {}) };
|
|
156
|
-
try { normalizeAnnotationPatch(patch); }
|
|
157
|
-
catch (error) { return { ok: false, error: error.message }; }
|
|
158
|
-
return { ok: true, annotation: patch, warnings };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Static prompt templates copied to the clipboard. The Chinese version
|
|
162
|
-
// is the default; English is exposed for users who prefer it. Both ask
|
|
163
|
-
// the model to return strict JSON so the importer can validate it.
|
|
164
|
-
export const CLIPBOARD_PROMPTS = Object.freeze({
|
|
165
|
-
zh: [
|
|
166
|
-
"请根据当前对话内容,为这个会话生成整理标记。",
|
|
167
|
-
"",
|
|
168
|
-
"要求:",
|
|
169
|
-
"1. 生成最多 20 个标签,每个标签最多 32 个字符。",
|
|
170
|
-
"2. 标签应简洁、具体,便于之后搜索。",
|
|
171
|
-
"3. 标签不要重复,不要生成无意义的标签。",
|
|
172
|
-
"4. 生成一段不超过 2000 个字符的备注,总结本次对话的主题、结论、未完成事项或下一步行动。",
|
|
173
|
-
"5. 不要编造对话中没有出现的信息。",
|
|
174
|
-
"6. 只返回合法 JSON,不要使用 Markdown 代码块,不要附加解释。",
|
|
175
|
-
"",
|
|
176
|
-
"返回格式:",
|
|
177
|
-
"{",
|
|
178
|
-
" ", "tags", ": [", "标签1", ", ", "标签2", "],",
|
|
179
|
-
" ", "note", ": ", "会话备注", ",",
|
|
180
|
-
"}"
|
|
181
|
-
].join(NEWLINE),
|
|
182
|
-
en: [
|
|
183
|
-
"Summarize this conversation into session annotations.",
|
|
184
|
-
"",
|
|
185
|
-
"Requirements:",
|
|
186
|
-
"1. Up to 20 tags, at most 32 characters each.",
|
|
187
|
-
"2. Tags should be concise and help future searches.",
|
|
188
|
-
"3. Do not repeat tags or invent meaningless ones.",
|
|
189
|
-
"4. Write a note under 2000 characters summarizing the topic, conclusions, outstanding items and next steps.",
|
|
190
|
-
"5. Do not fabricate details that are not present in the conversation.",
|
|
191
|
-
"6. Return only valid JSON, no Markdown fences, no extra commentary.",
|
|
192
|
-
"",
|
|
193
|
-
"Return shape:",
|
|
194
|
-
"{",
|
|
195
|
-
" ", "tags", ": [", "tag1", ", ", "tag2", "],",
|
|
196
|
-
" ", "note", ": ", "session note", ",",
|
|
197
|
-
"}"
|
|
198
|
-
].join(NEWLINE)
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
// Resolve the locale key from the active dictionary. Falls back to zh.
|
|
202
|
-
export function clipboardPromptFor(dictionaries, language) {
|
|
203
|
-
const available = dictionaries ? Object.keys(dictionaries) : [];
|
|
204
|
-
const key = available.includes(language) ? language
|
|
205
|
-
: available.includes("zh") ? "zh"
|
|
206
|
-
: available[0] || "zh";
|
|
207
|
-
return { key, text: CLIPBOARD_PROMPTS[key] ?? CLIPBOARD_PROMPTS.zh };
|
|
1
|
+
// Clipboard parser for AI-generated annotation payloads. Accepts a raw
|
|
2
|
+
// string from navigator.clipboard.readText() and extracts a structured
|
|
3
|
+
// {tags, note, priority?} payload. Tolerates fenced code blocks and
|
|
4
|
+
// conversational wrappers, then enforces the same normalization rules
|
|
5
|
+
// as the annotation store so the import button can call
|
|
6
|
+
// annotationStore.save without re-validating.
|
|
7
|
+
|
|
8
|
+
import { ANNOTATION_LIMITS, normalizeAnnotationPatch } from "./annotation-store.js";
|
|
9
|
+
|
|
10
|
+
const NUL = String.fromCharCode(0);
|
|
11
|
+
const BACKSLASH = String.fromCharCode(92);
|
|
12
|
+
const QUOTE = String.fromCharCode(34);
|
|
13
|
+
const NEWLINE = String.fromCharCode(10);
|
|
14
|
+
const TAG_REJECT = /[,,\x00-\x1f]/;
|
|
15
|
+
|
|
16
|
+
const tagRejectReason = (key, reason) => ({ field: "tags", kind: "rejected", key, reason });
|
|
17
|
+
|
|
18
|
+
const trimLength = (value, max) => value.length > max ? value.slice(0, max) : value;
|
|
19
|
+
|
|
20
|
+
const rejectNonPositive = value => value !== null && (!Number.isInteger(value) || value < 1 || value > 5);
|
|
21
|
+
|
|
22
|
+
const normalizeStringArray = (value, warnings) => {
|
|
23
|
+
if (!Array.isArray(value)) throw new Error("tags must be an array of strings");
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const tags = [];
|
|
26
|
+
let dropped = 0;
|
|
27
|
+
for (const item of value) {
|
|
28
|
+
if (typeof item !== "string") { dropped++; continue; }
|
|
29
|
+
const trimmed = item.trim();
|
|
30
|
+
if (!trimmed) { dropped++; continue; }
|
|
31
|
+
if (trimmed.length > ANNOTATION_LIMITS.tagLength || TAG_REJECT.test(trimmed)) {
|
|
32
|
+
warnings.push(tagRejectReason(trimmed, "每个标签最多 32 字符,不能包含逗号或控制字符"));
|
|
33
|
+
dropped++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const key = trimmed.toLowerCase();
|
|
37
|
+
if (seen.has(key)) continue;
|
|
38
|
+
seen.add(key);
|
|
39
|
+
tags.push(trimmed);
|
|
40
|
+
}
|
|
41
|
+
return { tags, dropped };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const normalizeNote = (value, warnings) => {
|
|
45
|
+
if (typeof value !== "string") throw new Error("note must be a string");
|
|
46
|
+
if (value.includes(NUL)) throw new Error("note must not contain NUL characters");
|
|
47
|
+
if (value.length > ANNOTATION_LIMITS.noteLength) {
|
|
48
|
+
warnings.push({ field: "note", kind: "truncated", from: value.length, to: ANNOTATION_LIMITS.noteLength });
|
|
49
|
+
return trimLength(value, ANNOTATION_LIMITS.noteLength);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const normalizePriority = (value, warnings) => {
|
|
55
|
+
if (value === undefined) return undefined;
|
|
56
|
+
// Treat AI-returned null as the default priority (3 = Normal) so the UI
|
|
57
|
+
// never has to special-case unset entries.
|
|
58
|
+
if (value === null) return 3;
|
|
59
|
+
if (rejectNonPositive(value)) {
|
|
60
|
+
warnings.push({ field: "priority", kind: "rejected", value, reason: "优先级必须为 1–5 的整数;省略或 null 会按 3(普通)保存" });
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Find the first balanced top-level JSON object in the text. Handles
|
|
67
|
+
// strings, escapes and nested objects without relying on regular
|
|
68
|
+
// expressions.
|
|
69
|
+
const scanForJsonObject = text => {
|
|
70
|
+
const len = text.length;
|
|
71
|
+
for (let i = 0; i < len; i++) {
|
|
72
|
+
const ch = text[i];
|
|
73
|
+
if (ch !== "{") continue;
|
|
74
|
+
let depth = 0;
|
|
75
|
+
let inString = false;
|
|
76
|
+
let escape = false;
|
|
77
|
+
for (let j = i; j < len; j++) {
|
|
78
|
+
const c = text[j];
|
|
79
|
+
if (inString) {
|
|
80
|
+
if (escape) escape = false;
|
|
81
|
+
else if (c === BACKSLASH) escape = true;
|
|
82
|
+
else if (c === QUOTE) inString = false;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (c === QUOTE) inString = true;
|
|
86
|
+
else if (c === "{") depth++;
|
|
87
|
+
else if (c === "}") {
|
|
88
|
+
depth--;
|
|
89
|
+
if (depth === 0) return text.slice(i, j + 1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Strip common conversational wrappers (Markdown fences, leading prose)
|
|
97
|
+
// before scanning for JSON. The fence language tag is honored when
|
|
98
|
+
// present so we still find JSON inside triple-backtick fences.
|
|
99
|
+
const stripWrappers = text => {
|
|
100
|
+
// Strip UTF-8 BOM (U+FEFF) if present so JSON.parse doesn't reject it,
|
|
101
|
+
// then trim leading and trailing whitespace (the prior /s+/g was a literal
|
|
102
|
+
// "s+" regex and stripped only literal "s" characters, not whitespace).
|
|
103
|
+
let trimmed = text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
|
|
104
|
+
trimmed = trimmed.replace(/^\s+|\s+$/g, "");
|
|
105
|
+
const fence = trimmed.match(/^```(?:json|JSON)?\s*([\s\S]*?)\s*```$/);
|
|
106
|
+
if (fence) return fence[1];
|
|
107
|
+
const braceStart = trimmed.indexOf("{");
|
|
108
|
+
const braceEnd = trimmed.lastIndexOf("}");
|
|
109
|
+
if (braceStart >= 0 && braceEnd > braceStart) return trimmed.slice(braceStart, braceEnd + 1);
|
|
110
|
+
return trimmed;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const ALLOWED_KEYS = new Set(["tags", "note", "priority"]);
|
|
114
|
+
|
|
115
|
+
// Parse a clipboard string into an annotation patch. The returned patch
|
|
116
|
+
// is compatible with annotationStore.save: tags/note/priority are
|
|
117
|
+
// normalized the same way as the server-side validation. Surfaces a
|
|
118
|
+
// list of warnings for truncated notes and dropped tags instead of
|
|
119
|
+
// failing the import.
|
|
120
|
+
export function parseClipboardAnnotation(raw) {
|
|
121
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
122
|
+
return { ok: false, error: "剪贴板为空" };
|
|
123
|
+
}
|
|
124
|
+
const stripped = stripWrappers(raw);
|
|
125
|
+
let parsed;
|
|
126
|
+
try { parsed = JSON.parse(stripped); }
|
|
127
|
+
catch {
|
|
128
|
+
const fallback = scanForJsonObject(stripped);
|
|
129
|
+
if (fallback === null) return { ok: false, error: "无法在剪贴板内容中找到合法的 JSON 对象" };
|
|
130
|
+
try { parsed = JSON.parse(fallback); }
|
|
131
|
+
catch { return { ok: false, error: "剪贴板中的 JSON 无法解析" }; }
|
|
132
|
+
}
|
|
133
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
134
|
+
return { ok: false, error: "剪贴板内容不是 JSON 对象" };
|
|
135
|
+
}
|
|
136
|
+
const warnings = [];
|
|
137
|
+
const unknown = Object.keys(parsed).filter(key => !ALLOWED_KEYS.has(key));
|
|
138
|
+
for (const key of unknown) warnings.push({ field: key, kind: "ignored", reason: "未知的标记字段,已忽略" });
|
|
139
|
+
|
|
140
|
+
let tags = [];
|
|
141
|
+
let note = "";
|
|
142
|
+
let priority;
|
|
143
|
+
if ("tags" in parsed) {
|
|
144
|
+
try { const result = normalizeStringArray(parsed.tags, warnings); tags = result.tags; }
|
|
145
|
+
catch (error) { return { ok: false, error: error.message }; }
|
|
146
|
+
}
|
|
147
|
+
if ("note" in parsed) {
|
|
148
|
+
try { note = normalizeNote(parsed.note, warnings); }
|
|
149
|
+
catch (error) { return { ok: false, error: error.message }; }
|
|
150
|
+
}
|
|
151
|
+
if ("priority" in parsed) {
|
|
152
|
+
const normalized = normalizePriority(parsed.priority, warnings);
|
|
153
|
+
if (normalized !== undefined) priority = normalized;
|
|
154
|
+
}
|
|
155
|
+
const patch = { tags, note, ...(priority !== undefined ? { priority } : {}) };
|
|
156
|
+
try { normalizeAnnotationPatch(patch); }
|
|
157
|
+
catch (error) { return { ok: false, error: error.message }; }
|
|
158
|
+
return { ok: true, annotation: patch, warnings };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Static prompt templates copied to the clipboard. The Chinese version
|
|
162
|
+
// is the default; English is exposed for users who prefer it. Both ask
|
|
163
|
+
// the model to return strict JSON so the importer can validate it.
|
|
164
|
+
export const CLIPBOARD_PROMPTS = Object.freeze({
|
|
165
|
+
zh: [
|
|
166
|
+
"请根据当前对话内容,为这个会话生成整理标记。",
|
|
167
|
+
"",
|
|
168
|
+
"要求:",
|
|
169
|
+
"1. 生成最多 20 个标签,每个标签最多 32 个字符。",
|
|
170
|
+
"2. 标签应简洁、具体,便于之后搜索。",
|
|
171
|
+
"3. 标签不要重复,不要生成无意义的标签。",
|
|
172
|
+
"4. 生成一段不超过 2000 个字符的备注,总结本次对话的主题、结论、未完成事项或下一步行动。",
|
|
173
|
+
"5. 不要编造对话中没有出现的信息。",
|
|
174
|
+
"6. 只返回合法 JSON,不要使用 Markdown 代码块,不要附加解释。",
|
|
175
|
+
"",
|
|
176
|
+
"返回格式:",
|
|
177
|
+
"{",
|
|
178
|
+
" ", "tags", ": [", "标签1", ", ", "标签2", "],",
|
|
179
|
+
" ", "note", ": ", "会话备注", ",",
|
|
180
|
+
"}"
|
|
181
|
+
].join(NEWLINE),
|
|
182
|
+
en: [
|
|
183
|
+
"Summarize this conversation into session annotations.",
|
|
184
|
+
"",
|
|
185
|
+
"Requirements:",
|
|
186
|
+
"1. Up to 20 tags, at most 32 characters each.",
|
|
187
|
+
"2. Tags should be concise and help future searches.",
|
|
188
|
+
"3. Do not repeat tags or invent meaningless ones.",
|
|
189
|
+
"4. Write a note under 2000 characters summarizing the topic, conclusions, outstanding items and next steps.",
|
|
190
|
+
"5. Do not fabricate details that are not present in the conversation.",
|
|
191
|
+
"6. Return only valid JSON, no Markdown fences, no extra commentary.",
|
|
192
|
+
"",
|
|
193
|
+
"Return shape:",
|
|
194
|
+
"{",
|
|
195
|
+
" ", "tags", ": [", "tag1", ", ", "tag2", "],",
|
|
196
|
+
" ", "note", ": ", "session note", ",",
|
|
197
|
+
"}"
|
|
198
|
+
].join(NEWLINE)
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Resolve the locale key from the active dictionary. Falls back to zh.
|
|
202
|
+
export function clipboardPromptFor(dictionaries, language) {
|
|
203
|
+
const available = dictionaries ? Object.keys(dictionaries) : [];
|
|
204
|
+
const key = available.includes(language) ? language
|
|
205
|
+
: available.includes("zh") ? "zh"
|
|
206
|
+
: available[0] || "zh";
|
|
207
|
+
return { key, text: CLIPBOARD_PROMPTS[key] ?? CLIPBOARD_PROMPTS.zh };
|
|
208
208
|
}
|
package/lib/index.js
CHANGED
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
* Optional live-writer/coordinator internals are guarded before mutation.
|
|
24
24
|
*/
|
|
25
25
|
import { lstat, mkdir, readdir, realpath, rename, rm } from "node:fs/promises";
|
|
26
|
-
import { dirname, join } from "node:path";
|
|
26
|
+
import { basename, dirname, join } from "node:path";
|
|
27
27
|
import { randomBytes } from "node:crypto";
|
|
28
28
|
import { createAnnotationStore } from "./annotation-store.js";
|
|
29
29
|
import { createDshAdapter, normalizeSessionHeader } from "./compat/dsh-adapter.js";
|
|
30
|
-
import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, encodeSessionSegment, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
|
|
30
|
+
import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
|
|
31
31
|
|
|
32
32
|
export const name = "dsh-session-manager";
|
|
33
33
|
|
|
@@ -167,6 +167,11 @@ const scanSessionHeaders = async (persistence, dshHome) => {
|
|
|
167
167
|
const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHeaders(persistence, dshHome)).headers;
|
|
168
168
|
|
|
169
169
|
export function apply(ctx) {
|
|
170
|
+
try {
|
|
171
|
+
const buildId = "dsh-session-manager v0.5.2+release-17 (clearSelection-keeps-bulk-mode-on)";
|
|
172
|
+
if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
|
|
173
|
+
else if (typeof console !== "undefined" && console?.info) console.info(buildId);
|
|
174
|
+
} catch (_) { /* ignore logging failures */ }
|
|
170
175
|
const adapter = createDshAdapter(ctx);
|
|
171
176
|
let annotationStore;
|
|
172
177
|
const annotations = () => {
|
|
@@ -222,6 +227,23 @@ export function apply(ctx) {
|
|
|
222
227
|
});
|
|
223
228
|
};
|
|
224
229
|
|
|
230
|
+
/** Add one id to the registry-global archive set (durable, serialized).
|
|
231
|
+
* Mirror of unarchiveSession; both touch workspaceRegistry state directly
|
|
232
|
+
* because the host-side ctx.workspaces service is not available inside
|
|
233
|
+
* the per-request dispatch (Cordis throws "cannot get property
|
|
234
|
+
* 'workspaces' without inject" otherwise). */
|
|
235
|
+
const archiveSession = async (sessionId) => {
|
|
236
|
+
const registry = ctx.workspaceRegistry;
|
|
237
|
+
await registry.enqueueOperation(async () => {
|
|
238
|
+
const state = registry.requireState();
|
|
239
|
+
if (state.archivedSessionIds.includes(sessionId)) return;
|
|
240
|
+
await registry.setState({
|
|
241
|
+
...state,
|
|
242
|
+
archivedSessionIds: [...state.archivedSessionIds, sessionId]
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
|
|
225
247
|
/** Detach one session from every workspace's ordered accounting. */
|
|
226
248
|
const detachFromWorkspaces = async (sessionId) => {
|
|
227
249
|
for (const entity of ctx.workspaceRegistry.list()) {
|
|
@@ -389,6 +411,17 @@ ${rest}`, "utf8");
|
|
|
389
411
|
throw new Error("持久化后端无法定位会话工件路径");
|
|
390
412
|
}
|
|
391
413
|
const newPath = newLocation.path;
|
|
414
|
+
// Keep the stored generation in sync with the filename selected by
|
|
415
|
+
// persistence.locate(). persistence.locate() always returns the
|
|
416
|
+
// SESSION_FORMAT_VERSION path (DSH 0.1.7+ ships v4 by default),
|
|
417
|
+
// regardless of oldHeader.version. Without the stamp below, the
|
|
418
|
+
// rewritten file is `session.v4.jsonl.zstd` carrying a v0/v3 header,
|
|
419
|
+
// which JsonlSessionPersistence.listArtifacts rejects on the next
|
|
420
|
+
// startup (`session generation filename identifies v4, but its
|
|
421
|
+
// header identifies v0`), breaking persistence.list() and crashing
|
|
422
|
+
// the workspace registry while it boots. (issue #18)
|
|
423
|
+
const expectedVersion = parseSessionGenerationFromFilename(basename(newPath));
|
|
424
|
+
if (typeof expectedVersion === "number") newHeader.version = expectedVersion;
|
|
392
425
|
const root = typeof dshHomePath === "function" ? dshHomePath("sessions") : undefined;
|
|
393
426
|
await assertSessionDirectory(root, dirname(oldPath), sessionId);
|
|
394
427
|
await assertSessionDirectory(root, dirname(newPath), sessionId, { allowMissing: true });
|
|
@@ -1036,8 +1069,71 @@ ${rest}`, "utf8");
|
|
|
1036
1069
|
}
|
|
1037
1070
|
}, "session-manager: post-boot re-fold stale projection cache rows");
|
|
1038
1071
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1072
|
+
/** Build a normalized annotation patch from one bulk action + payload.
|
|
1073
|
+
* Returns null when the payload is missing or malformed so the caller can
|
|
1074
|
+
* surface a 400 instead of letting the store throw on a bad value.
|
|
1075
|
+
*/
|
|
1076
|
+
const annotationPatchFromBatchAction = (action, payload) => {
|
|
1077
|
+
if (!payload || typeof payload !== "object") return null;
|
|
1078
|
+
if (action === "favorite" || action === "unfavorite") return { favorite: action === "favorite" };
|
|
1079
|
+
if (action === "review" || action === "unreview") return { reviewLater: action === "review" };
|
|
1080
|
+
if (action === "set-priority") {
|
|
1081
|
+
const p = Number(payload.priority);
|
|
1082
|
+
if (!Number.isInteger(p) || p < 1 || p > 5) return null;
|
|
1083
|
+
return { priority: p };
|
|
1084
|
+
}
|
|
1085
|
+
if (action === "add-tags" || action === "remove-tags") {
|
|
1086
|
+
if (!Array.isArray(payload.tags)) return null;
|
|
1087
|
+
return action === "add-tags" ? { tags: payload.tags } : { tags: [] };
|
|
1088
|
+
}
|
|
1089
|
+
return null;
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
/** Run one batch action against a single session id. Annotation-affecting
|
|
1093
|
+
* actions resolve the annotation accessor from the surrounding closure
|
|
1094
|
+
* so it can never be silently undefined. Session-management actions reuse
|
|
1095
|
+
* the existing helpers verbatim so move/preset-migrate validation and
|
|
1096
|
+
* live-session handling stay identical to the per-row buttons.
|
|
1097
|
+
*/
|
|
1098
|
+
const runBatchAction = async ({ action, sessionId, payload, mutate, deleteSession, unarchiveSession, moveSession, migratePreset, adapter }) => {
|
|
1099
|
+
const annotationsAccessor = annotations;
|
|
1100
|
+
if (action === "favorite" || action === "unfavorite" || action === "review" || action === "unreview" || action === "set-priority" || action === "add-tags" || action === "remove-tags") {
|
|
1101
|
+
const patch = annotationPatchFromBatchAction(action, payload);
|
|
1102
|
+
if (patch === null) {
|
|
1103
|
+
const e = new Error("invalid batch payload for " + action); e.code = "bad-request"; throw e;
|
|
1104
|
+
}
|
|
1105
|
+
try {
|
|
1106
|
+
await annotationsAccessor().batchUpdate([sessionId], patch);
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
throw error;
|
|
1109
|
+
}
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
if (action === "archive") { await mutate(sessionId, () => archiveSession(sessionId)); return; }
|
|
1113
|
+
if (action === "unarchive") { await mutate(sessionId, () => unarchiveSession(sessionId)); return; }
|
|
1114
|
+
if (action === "delete") { await mutate(sessionId, () => deleteSession(sessionId)); return; }
|
|
1115
|
+
if (action === "move") {
|
|
1116
|
+
const targetWorkspaceId = typeof payload?.targetWorkspaceId === "string" ? payload.targetWorkspaceId.trim() : "";
|
|
1117
|
+
if (!targetWorkspaceId) { const e = new Error("targetWorkspaceId required"); e.code = "bad-request"; throw e; }
|
|
1118
|
+
await mutate(sessionId, () => moveSession(sessionId, targetWorkspaceId));
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (action === "preset-migrate") {
|
|
1122
|
+
const toPreset = typeof payload?.toPreset === "string" ? payload.toPreset.trim() : "";
|
|
1123
|
+
if (!toPreset) { const e = new Error("toPreset required"); e.code = "bad-request"; throw e; }
|
|
1124
|
+
await mutate(sessionId, () => migratePreset({ sessionId, toPreset }));
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
const snapshot = adapter.workspaceRegistry?.headers?.get?.(sessionId)
|
|
1128
|
+
?? adapter.sessions?.list?.()?.find(s => s?.id === sessionId)?.header
|
|
1129
|
+
?? null;
|
|
1130
|
+
const isSubagent = snapshot?.origin === "subagent";
|
|
1131
|
+
const isBlank = snapshot?.blank === true;
|
|
1132
|
+
if (isSubagent) { const e = new Error("subagent"); e.code = "subagent-skip"; throw e; }
|
|
1133
|
+
if (isBlank) { const e = new Error("blank"); e.code = "blank-skip"; throw e; }
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
ctx.effect(() => ctx.webServer.register({ kind: "prefix",
|
|
1041
1137
|
path: API_PREFIX,
|
|
1042
1138
|
handler: async (req, res) => {
|
|
1043
1139
|
try {
|
|
@@ -1084,6 +1180,53 @@ ${rest}`, "utf8");
|
|
|
1084
1180
|
}
|
|
1085
1181
|
return send(res, 200, { ok: true, result });
|
|
1086
1182
|
}
|
|
1183
|
+
if (req.method === "POST" && path === "/batch") {
|
|
1184
|
+
// Per-action body shape (each action validates its own payload):
|
|
1185
|
+
// archive : no payload
|
|
1186
|
+
// unarchive : no payload
|
|
1187
|
+
// delete : no payload
|
|
1188
|
+
// move : { targetWorkspaceId }
|
|
1189
|
+
// preset-migrate: { toPreset }
|
|
1190
|
+
// favorite : no payload (defaults to true)
|
|
1191
|
+
// unfavorite : no payload
|
|
1192
|
+
// review : no payload
|
|
1193
|
+
// unreview : no payload
|
|
1194
|
+
// set-priority : { priority: 1..5 }
|
|
1195
|
+
// add-tags : { tags: string[] }
|
|
1196
|
+
// remove-tags : { tags: string[] }
|
|
1197
|
+
const rawAction = typeof body.action === "string" ? body.action : "";
|
|
1198
|
+
const rawIds = Array.isArray(body.sessionIds) ? body.sessionIds : [];
|
|
1199
|
+
const ids = Array.from(new Set(rawIds.filter(id => typeof id === "string" && id.trim() !== "").map(id => id.trim())));
|
|
1200
|
+
for (const id of ids) assertSessionId(id);
|
|
1201
|
+
if (ids.length === 0) return send(res, 400, { ok: false, error: "sessionIds 必须是非空数组", code: "bad-request" });
|
|
1202
|
+
if (ids.length > 200) return send(res, 400, { ok: false, error: "单次批量最多 200 条,请缩小筛选范围后重试", code: "batch-too-large" });
|
|
1203
|
+
const payload = body.payload && typeof body.payload === "object" ? body.payload : {};
|
|
1204
|
+
const BATCH_ACTIONS = new Set(["archive", "unarchive", "delete", "move", "preset-migrate", "favorite", "unfavorite", "review", "unreview", "set-priority", "add-tags", "remove-tags"]);
|
|
1205
|
+
if (!BATCH_ACTIONS.has(rawAction)) return send(res, 400, { ok: false, error: "action 不支持: " + rawAction, code: "bad-request" });
|
|
1206
|
+
const items = [];
|
|
1207
|
+
let success = 0, failed = 0, skipped = 0;
|
|
1208
|
+
for (const sessionId of ids) {
|
|
1209
|
+
try {
|
|
1210
|
+
await runBatchAction({ action: rawAction, sessionId, payload, mutate, deleteSession, unarchiveSession, moveSession, migratePreset, adapter });
|
|
1211
|
+
items.push({ sessionId, status: "success" });
|
|
1212
|
+
success++;
|
|
1213
|
+
} catch (error) {
|
|
1214
|
+
const code = error && typeof error.code === "string" ? error.code : undefined;
|
|
1215
|
+
if (code === "subagent-skip" || code === "blank-skip") {
|
|
1216
|
+
items.push({ sessionId, status: "skipped", reason: code });
|
|
1217
|
+
skipped++;
|
|
1218
|
+
} else {
|
|
1219
|
+
items.push({ sessionId, status: "failed", code, error: safeErrorMessage(error) });
|
|
1220
|
+
failed++;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return send(res, 200, { ok: true, result: {
|
|
1225
|
+
summary: { total: ids.length, success, failed, skipped },
|
|
1226
|
+
items,
|
|
1227
|
+
aborted: false,
|
|
1228
|
+
} });
|
|
1229
|
+
}
|
|
1087
1230
|
if (req.method === "GET" && path === "/workspaces") {
|
|
1088
1231
|
const sessionCreatedAt = Object.fromEntries(adapter.cachedSessionHeaders()
|
|
1089
1232
|
.filter(header => typeof header.createdAt === "number" || typeof header.createdAt === "string")
|