dsh-session-manager 0.4.10 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +218 -185
- package/README.md +158 -109
- package/README.zh.md +84 -29
- package/lib/annotation-store.js +159 -0
- package/lib/client.js +811 -54
- package/lib/clipboard-parser.js +208 -0
- package/lib/compat/dsh-adapter.js +17 -0
- package/lib/compat/zstd-frames.js +2 -2
- package/lib/index.js +1111 -1247
- package/lib/session-files.js +180 -0
- package/package.json +10 -3
|
@@ -0,0 +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 };
|
|
208
|
+
}
|
|
@@ -113,6 +113,23 @@ export function createDshAdapter(ctx) {
|
|
|
113
113
|
if (!persistence || typeof persistence.list !== "function") return [];
|
|
114
114
|
return normalizeSessionHeaders(await persistence.list());
|
|
115
115
|
},
|
|
116
|
+
/** Memory-only metadata for UI sorting. Never calls persistence.list/open. */
|
|
117
|
+
cachedSessionHeaders() {
|
|
118
|
+
const headers = new Map();
|
|
119
|
+
const add = value => {
|
|
120
|
+
const header = normalizeSessionHeader(value);
|
|
121
|
+
if (header) headers.set(header.id, header);
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
const cached = adapter.workspaceRegistry?.headers;
|
|
125
|
+
if (typeof cached?.values === "function") for (const header of cached.values()) add(header);
|
|
126
|
+
} catch { /* optional runtime surface; keep UI usable on older DSH */ }
|
|
127
|
+
try {
|
|
128
|
+
const live = adapter.sessions?.list?.();
|
|
129
|
+
if (Array.isArray(live)) for (const session of live) if (session?.header) add(session.header);
|
|
130
|
+
} catch { /* optional live metadata */ }
|
|
131
|
+
return [...headers.values()];
|
|
132
|
+
},
|
|
116
133
|
capabilities() {
|
|
117
134
|
const persistence = adapter.persistence;
|
|
118
135
|
const registry = adapter.workspaceRegistry;
|
|
@@ -72,8 +72,8 @@ export function scanZstdFrames(buffer, maxFrames = Number.POSITIVE_INFINITY) {
|
|
|
72
72
|
return { frames };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export async function decompressZstdFrame(frame) {
|
|
76
|
-
return zstdDecompressAsync(frame);
|
|
75
|
+
export async function decompressZstdFrame(frame, options) {
|
|
76
|
+
return zstdDecompressAsync(frame, options);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
export async function decompressAllZstdFrames(buffer) {
|