dsh-session-title-summary 0.0.0-stage → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/panel-api.js ADDED
@@ -0,0 +1,40 @@
1
+ // Connection Fetch is shared by authenticated HTTP AND the Desktop carrier.
2
+ // A webServer-only route is invisible to Desktop's in-process API bridge.
3
+ export function registerPanelApi(ctx, status, start, configure, models) {
4
+ const json = (value, code = 200) => new Response(JSON.stringify(value), { status: code, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
5
+ const clean = (value) => typeof value === 'string' ? value.trim() : '';
6
+ const routes = [
7
+ { path: '/api/session-title-summary/status', methods: ['GET'], requestBody: 'buffered', fetch: async (request) => {
8
+ if (request.method !== 'GET') return json({ error: 'method-not-allowed' }, 405);
9
+ return json(status());
10
+ } },
11
+ { path: '/api/session-title-summary/backfill', methods: ['POST'], requestBody: 'buffered', fetch: async (request) => {
12
+ if (request.method !== 'POST') return json({ error: 'method-not-allowed' }, 405);
13
+ // Authentication and origin admission are owned by Connection, for both carriers.
14
+ const started = start('panel');
15
+ return json({ running: true, started }, 202);
16
+ } }
17
+ ];
18
+ if (typeof models === 'function') routes.push(
19
+ { path: '/api/session-title-summary/models', methods: ['GET'], requestBody: 'buffered', fetch: async (request) => {
20
+ if (request.method !== 'GET') return json({ error: 'method-not-allowed' }, 405);
21
+ try { return json(await models()); } catch (error) { return json({ error: 'catalog-unavailable', detail: String(error?.message ?? error) }, 503); }
22
+ } }
23
+ );
24
+ if (typeof configure === 'function') routes.push(
25
+ { path: '/api/session-title-summary/config', methods: ['POST'], requestBody: 'buffered', fetch: async (request) => {
26
+ if (request.method !== 'POST') return json({ error: 'method-not-allowed' }, 405);
27
+ let body;
28
+ try { body = await request.json(); } catch { return json({ error: 'invalid-json' }, 400); }
29
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) return json({ error: 'invalid-body' }, 400);
30
+ const provider = clean(body.provider);
31
+ const model = clean(body.model);
32
+ // The pair is set together or cleared together; a half pair is a client bug.
33
+ if ((provider === '') !== (model === '')) return json({ error: 'provider-and-model-together' }, 400);
34
+ if (provider.length > 128 || model.length > 256) return json({ error: 'value-too-long' }, 400);
35
+ const routeOverride = await configure(provider === '' ? undefined : { provider, model });
36
+ return json({ routeOverride });
37
+ } }
38
+ );
39
+ for (const route of routes) ctx.effect(() => ctx.connection.fetch.register(route), `session-title-summary: ${route.path}`);
40
+ }
package/pure.js ADDED
@@ -0,0 +1,255 @@
1
+ /** Pure title pipeline helpers with no Cordis imports, so they stay unit-testable. */
2
+
3
+ /** Byte length of one value once framed as JSON. */
4
+ export const jsonBytes = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
5
+
6
+ /** Cap one message's text so a single huge prompt cannot eat the whole budget. */
7
+ export function capText(text, capBytes) {
8
+ if (Buffer.byteLength(text, "utf8") <= capBytes) return text;
9
+ let out = text;
10
+ while (out.length > 0 && Buffer.byteLength(out + "…", "utf8") > capBytes) out = out.slice(0, Math.floor(out.length * 0.9));
11
+ return out + "…";
12
+ }
13
+
14
+ /**
15
+ * Select a bounded window (first `windowFirst` + last `windowLast` messages)
16
+ * whose JSON fits `budgetBytes`, always keeping message 0. The tail is
17
+ * preserved first (recent messages track the current topic), then the head is
18
+ * shrunk, then the tail; the middle is dropped (the omission count travels in
19
+ * the model input) so the input always fits the byte limit even on very long
20
+ * conversations. Each kept text is capped so one huge message cannot evict
21
+ * the rest.
22
+ * @param messages - full eligible human-message snapshot ({seq, text}).
23
+ * @returns selected messages plus how many were omitted.
24
+ */
25
+ export function selectWindow(messages, budgetBytes, windowFirst, windowLast) {
26
+ const perMessageCap = Math.max(512, Math.floor(budgetBytes / 4));
27
+ const capped = messages.map((message) => ({ ...message, text: capText(message.text, perMessageCap) }));
28
+ if (jsonBytes(capped) <= budgetBytes) return { selected: capped, omitted: 0 };
29
+ for (let tailCount = windowLast; tailCount >= 0; tailCount -= 1) {
30
+ for (let headCount = Math.max(1, windowFirst); headCount >= 1; headCount -= 1) {
31
+ const head = capped.slice(0, headCount);
32
+ const tailStart = Math.max(head.length, capped.length - tailCount);
33
+ const selected = [...head, ...capped.slice(tailStart)];
34
+ if (jsonBytes(selected) <= budgetBytes) return { selected, omitted: capped.length - selected.length };
35
+ }
36
+ }
37
+ return { selected: [capped[0]], omitted: capped.length - 1 };
38
+ }
39
+
40
+ //#region sensitive-content handling
41
+
42
+ /** Credential/token shapes that must never reach the model or a title. */
43
+ const CREDENTIAL_PATTERNS = [
44
+ /(?:api[_-]?key|token|secret|password|passwd|pwd|授权码|密钥|密码)\s*[:=:]\s*["']?[^\s"',。]{4,}/giu,
45
+ /\bsk-[A-Za-z0-9_-]{8,}/g,
46
+ /\bghp_[A-Za-z0-9]{16,}/g,
47
+ /\bgho_[A-Za-z0-9]{16,}/g,
48
+ /\bAKIA[0-9A-Z]{16}\b/g,
49
+ /\bBearer\s+[A-Za-z0-9._-]{8,}/gi,
50
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/g,
51
+ /\b[A-Fa-f0-9]{32,}\b/g,
52
+ /\b[A-Za-z0-9+/]{40,}={0,2}\b/g
53
+ ];
54
+
55
+ /** Filesystem-path shapes (rejected inside categories and topics). */
56
+ const PATH_PATTERN = /(?:^|[\s((【\[])(?:\/(?:Users|home|var|etc|opt|tmp|private|Applications|Library)\/\S+|~\/\S+|[A-Za-z]:\\{1,2}\S+)/u;
57
+
58
+ /** True when text carries a credential/token shape. */
59
+ export function containsCredential(text) {
60
+ return CREDENTIAL_PATTERNS.some((pattern) => {
61
+ pattern.lastIndex = 0;
62
+ return pattern.test(text);
63
+ });
64
+ }
65
+
66
+ /** True when text carries a filesystem path shape. */
67
+ export function containsPath(text) {
68
+ return PATH_PATTERN.test(text);
69
+ }
70
+
71
+ /** Replace credential/token shapes with a fixed marker. */
72
+ export function scrubCredentials(text) {
73
+ let out = text;
74
+ for (const pattern of CREDENTIAL_PATTERNS) out = out.replace(pattern, "[已脱敏]");
75
+ return out;
76
+ }
77
+
78
+ /** Elide long fenced code blocks and dedupe repeated message texts before sending. */
79
+ export function cleanForSend(messages) {
80
+ const seen = new Set();
81
+ const out = [];
82
+ for (const message of messages) {
83
+ const text = message.text.replace(/```[\s\S]{200,}?```/gu, "「代码省略」");
84
+ if (seen.has(text)) continue;
85
+ seen.add(text);
86
+ out.push({ ...message, text: scrubCredentials(text) });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ //#endregion
92
+
93
+ //#region title format: MMDD | 分类 | 主题
94
+
95
+ /** Format a Unix-ms timestamp as MMDD in the given IANA time zone. */
96
+ export function mmdd(createdAtMs, timeZone = "Asia/Shanghai") {
97
+ const fmt = new Intl.DateTimeFormat("en-US", { month: "2-digit", day: "2-digit", timeZone, timeZoneName: "short" });
98
+ const parts = fmt.formatToParts(new Date(createdAtMs));
99
+ const month = parts.find((p) => p.type === "month")?.value;
100
+ const day = parts.find((p) => p.type === "day")?.value;
101
+ return `${month}${day}`;
102
+ }
103
+
104
+ /** Shape of a parsed valid-format title. */
105
+ export const TITLE_SEPARATOR = " | ";
106
+
107
+ /** Compose the full three-part title. */
108
+ export function composeTitle(date, category, topic) {
109
+ return `${date}${TITLE_SEPARATOR}${category}${TITLE_SEPARATOR}${topic}`;
110
+ }
111
+
112
+ /** Compose the fallback two-part title. */
113
+ export function composeFallbackTitle(date, short) {
114
+ return `${date}${TITLE_SEPARATOR}${short}`;
115
+ }
116
+
117
+ /** Parse `MMDD | 分类 | 主题` or `MMDD | 简短标题`; null when not the format. */
118
+ export function parseTitleFormat(title) {
119
+ if (typeof title !== "string") return null;
120
+ const parts = title.split(TITLE_SEPARATOR.trim());
121
+ if (parts.length < 2) return null;
122
+ const date = parts[0].trim();
123
+ if (!/^(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])$/.test(date)) return null;
124
+ if (parts.length === 2) return { date, category: null, topic: parts[1].trim() };
125
+ if (parts.length === 3 && parts[1].trim().length > 0 && parts[2].trim().length > 0) {
126
+ return { date, category: parts[1].trim(), topic: parts[2].trim() };
127
+ }
128
+ return null;
129
+ }
130
+
131
+ //#endregion
132
+
133
+ //#region category mapping and validation
134
+
135
+ /** Options for category validation. */
136
+ const CATEGORY_CHARS = /^[\p{Script=Han}A-Za-z0-9·\-]{1,12}$/u;
137
+ const SENTENCE_PUNCTUATION = /[。!?!?,,、;;::…「」“”\s]{2,}|[。!?!?;;]/u;
138
+
139
+ /** Sentence-function wording that marks a phrase as a sentence, not a category word. */
140
+ const SENTENCE_WORDING = /(这是|那个|这些|帮我|请你|请我|需要|应该|进行|一个|可以|如何|怎么)/u;
141
+
142
+ /** Validate one category word against the structural rules. */
143
+ export function isValidCategory(category) {
144
+ if (typeof category !== "string") return false;
145
+ const value = category.trim();
146
+ if (value.length === 0 || value !== category) return false;
147
+ if (!CATEGORY_CHARS.test(value)) return false;
148
+ if (SENTENCE_PUNCTUATION.test(value)) return false;
149
+ if (SENTENCE_WORDING.test(value)) return false;
150
+ if (/^[\p{Script=Han}]+$/u.test(value) && value.length > 6) return false;
151
+ if (value.includes("|")) return false;
152
+ if (containsCredential(value) || containsPath(value)) return false;
153
+ return true;
154
+ }
155
+
156
+ /** Validate one topic phrase against the structural rules. */
157
+ export function isValidTopic(topic, maxChars = 24) {
158
+ if (typeof topic !== "string") return false;
159
+ const value = topic.trim();
160
+ if (value.length < 2 || value.length > maxChars) return false;
161
+ if (value.includes("|") || /[\r\n]/u.test(value)) return false;
162
+ if (containsCredential(value) || containsPath(value)) return false;
163
+ return true;
164
+ }
165
+
166
+ /**
167
+ * Map a raw model category onto the known vocabulary.
168
+ * Exact builtin match wins, then exact custom match, then the ordered synonym
169
+ * table (exact equality first, then substring containment), else the raw word
170
+ * is kept as a new custom category.
171
+ * @returns the final category plus whether a known entry matched.
172
+ */
173
+ export function mapCategory(raw, { builtin, custom, synonyms = [] }) {
174
+ const value = typeof raw === "string" ? raw.trim() : "";
175
+ if (builtin.includes(value)) return { category: value, matched: true };
176
+ if (custom.includes(value)) return { category: value, matched: true };
177
+ for (const [needle, canonical] of synonyms) {
178
+ if (value === needle) return { category: canonical, matched: true };
179
+ }
180
+ for (const [needle, canonical] of synonyms) {
181
+ if (needle.length > 0 && value.includes(needle)) return { category: canonical, matched: true };
182
+ }
183
+ return { category: value, matched: false };
184
+ }
185
+
186
+ /** Local fast-path keyword table entry match: keyword containment on a short first message. */
187
+ export function fastPathCategory(text, keywords) {
188
+ for (const [needle, category] of keywords) {
189
+ if (needle.length > 0 && text.toLowerCase().includes(needle.toLowerCase())) return category;
190
+ }
191
+ return null;
192
+ }
193
+
194
+ //#endregion
195
+
196
+ //#region model-output interpretation
197
+
198
+ /** Extract the first JSON object from model text (tolerating code fences and prose). */
199
+ export function parseModelJson(text) {
200
+ if (typeof text !== "string") return null;
201
+ const stripped = text.replace(/```(?:json)?/gi, "").trim();
202
+ const start = stripped.indexOf("{");
203
+ const end = stripped.lastIndexOf("}");
204
+ if (start < 0 || end <= start) return null;
205
+ try {
206
+ const value = JSON.parse(stripped.slice(start, end + 1));
207
+ return value !== null && typeof value === "object" ? value : null;
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ /** Asterisk emphasis wrapping a whole line, such as `**Title**`. */
214
+ const EMPHASIS_WRAPPER = /^(?<marker>\*{1,3})(?<inner>\S(?:.*\S)?)\k<marker>$/u;
215
+
216
+ /** First non-empty output line without whole-line asterisk emphasis. */
217
+ export function titleFromOutput(text) {
218
+ const line = text.split(/\r?\n/u).map((item) => item.trim()).find((item) => item.length > 0) ?? "";
219
+ const groups = EMPHASIS_WRAPPER.exec(line)?.groups;
220
+ const marker = groups?.["marker"];
221
+ const inner = groups?.["inner"];
222
+ return marker === undefined || inner === undefined || inner.includes(marker) ? line : inner;
223
+ }
224
+
225
+ /** Options for interpretModelResult. */
226
+ /**
227
+ * Interpret one parsed model result against the local gates.
228
+ * @returns {kind:'keep'} to keep the anchor, {kind:'full', category, topic} for
229
+ * a confident three-part result, or {kind:'fallback'} when any gate fails.
230
+ */
231
+ export function interpretModelResult(parsed, { minConfidence = 0.6, hasAnchor = false, topicMaxChars = 24 } = {}) {
232
+ if (parsed === null) return { kind: "fallback" };
233
+ if (hasAnchor && parsed.should_rename === false) return { kind: "keep" };
234
+ const confidence = typeof parsed.confidence === "number" ? parsed.confidence : 0;
235
+ if (confidence < minConfidence) return { kind: "fallback" };
236
+ if (!isValidCategory(parsed.category)) return { kind: "fallback" };
237
+ if (!isValidTopic(parsed.topic, topicMaxChars)) return { kind: "fallback" };
238
+ return { kind: "full", category: parsed.category.trim(), topic: parsed.topic.trim() };
239
+ }
240
+
241
+ /** Clean any free text into a short fallback title part. */
242
+ export function cleanShortTitle(text, maxChars = 24) {
243
+ if (typeof text !== "string") return "";
244
+ let value = scrubCredentials(text);
245
+ const parsed = parseTitleFormat(value);
246
+ if (parsed !== null) value = parsed.category !== null ? parsed.topic : parsed.topic;
247
+ value = titleFromOutput(value.split(/\r?\n/u)[0] ?? "");
248
+ value = value.replace(/[||]/gu, " ").replace(/\s+/gu, " ").trim();
249
+ value = value.replace(/^[#>\-*\s]+/u, "").trim();
250
+ value = value.replace(/[。!?!?,,、;;::\s]+$/u, "");
251
+ if (value.length > maxChars) value = value.slice(0, maxChars - 1) + "…";
252
+ return value;
253
+ }
254
+
255
+ //#endregion
package/store.js ADDED
@@ -0,0 +1,82 @@
1
+ // Dynamic category store: builtin + audited custom categories persisted as
2
+ // plain JSON (community-plugin convention, cf. ~/.dsh/dsh-ssh.json), with
3
+ // atomic writes and per-process memoization.
4
+ import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { homedir } from "node:os";
7
+
8
+ /** Default store location under the DSH home. */
9
+ export const defaultStorePath = () => join(homedir(), ".dsh", "session-title-summary", "categories.json");
10
+
11
+ /**
12
+ * One durable category ledger.
13
+ * Shape: { version: 1, custom: [{ name, source, first_seen_at, usage_count, normalized_from }] }.
14
+ */
15
+ export class CategoryStore {
16
+ constructor(filePath, seedCustom = []) {
17
+ this.filePath = filePath;
18
+ this.seedCustom = [...seedCustom];
19
+ this.data = this.load();
20
+ }
21
+
22
+ /** Read the file once; corrupt or missing files re-seed. */
23
+ load() {
24
+ try {
25
+ const raw = JSON.parse(readFileSync(this.filePath, "utf8"));
26
+ const custom = Array.isArray(raw.custom)
27
+ ? raw.custom.filter((e) => typeof e?.name === "string" && e.name.length > 0)
28
+ : [];
29
+ return { version: 1, custom };
30
+ } catch {
31
+ const custom = this.seedCustom.map((name) => ({
32
+ name,
33
+ source: "builtin-seed",
34
+ first_seen_at: new Date().toISOString(),
35
+ usage_count: 0,
36
+ normalized_from: []
37
+ }));
38
+ return { version: 1, custom };
39
+ }
40
+ }
41
+
42
+ /** Names of all known custom categories. */
43
+ list() {
44
+ return this.data.custom.map((entry) => entry.name);
45
+ }
46
+
47
+ /** Record one use: bump usage_count, merge normalized variants, first-seen stamp. */
48
+ recordUse(name, normalizedFrom = null) {
49
+ const clean = typeof name === "string" ? name.trim() : "";
50
+ if (clean.length === 0) return;
51
+ const now = new Date().toISOString();
52
+ const entry = this.data.custom.find((e) => e.name === clean);
53
+ if (entry === undefined) {
54
+ this.data.custom.push({
55
+ name: clean,
56
+ source: "model",
57
+ first_seen_at: now,
58
+ usage_count: 1,
59
+ normalized_from: normalizedFrom ? [normalizedFrom] : []
60
+ });
61
+ } else {
62
+ entry.usage_count = (Number.isInteger(entry.usage_count) ? entry.usage_count : 0) + 1;
63
+ if (normalizedFrom !== null && !entry.normalized_from.includes(normalizedFrom)) {
64
+ entry.normalized_from = [...entry.normalized_from, normalizedFrom];
65
+ }
66
+ }
67
+ this.save();
68
+ }
69
+
70
+ /** Atomic write: temp file in the same directory, then rename. */
71
+ save() {
72
+ mkdirSync(dirname(this.filePath), { recursive: true });
73
+ const temp = join(dirname(this.filePath), `.${Math.random().toString(36).slice(2)}.tmp`);
74
+ writeFileSync(temp, JSON.stringify(this.data, null, "\t") + "\n", "utf8");
75
+ renameSync(temp, this.filePath);
76
+ }
77
+
78
+ /** Whether the backing file exists yet (used by tests and diagnostics). */
79
+ exists() {
80
+ return existsSync(this.filePath);
81
+ }
82
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.