@heixui/dsh-session-suite 0.0.0-stage → 0.3.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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Fact keys and supersession.
3
+ *
4
+ * Lexical similarity cannot see a value change: "拼团还没开" and "拼团开了" are
5
+ * two states of one fact that share almost no vocabulary, while "身高一米七"
6
+ * and "长胖到 55 公斤" share a lot but are two different facts. Keying each
7
+ * memory with `对象.方面` (for example `她.身高`) gives the engine something
8
+ * beyond the wording: entries that share a key describe the same slot, so the
9
+ * newest one wins and the older ones are marked superseded — kept in storage so
10
+ * the decision can be undone, but no longer recalled.
11
+ *
12
+ * This module is pure: no IO, no clock, no imports. The caller supplies the
13
+ * timestamps, and every function leaves its input untouched.
14
+ */
15
+
16
+ /**
17
+ * Segments generic enough that a key built from them says nothing. A memory
18
+ * keyed `信息.内容` would collide with every other vague note, which is worse
19
+ * than no key at all, because the collision silently buries memories.
20
+ */
21
+ export const CATCH_ALL_SEGMENTS = Object.freeze([
22
+ '信息',
23
+ '内容',
24
+ '事情',
25
+ '事项',
26
+ '记忆',
27
+ '其他',
28
+ '杂项',
29
+ 'note',
30
+ 'info',
31
+ 'other',
32
+ 'misc',
33
+ 'general',
34
+ ])
35
+
36
+ /** A catch-all key groups everything, so a key may have at most this many segments. */
37
+ const MAX_SEGMENTS = 3
38
+
39
+ /**
40
+ * Separator characters that all mean "segment boundary". Half-width `.` is the
41
+ * canonical form; the rest are the ones a model actually emits when asked for a
42
+ * dotted key in Chinese — the ideographic full stop and the middle dots.
43
+ */
44
+ const SEPARATORS = /[.。·・//.﹒]/g
45
+
46
+ /** Every kind of whitespace, including the ideographic space. */
47
+ const WHITESPACE = /\s+/g
48
+
49
+ /** Only these shapes are parsed as a timestamp; anything else is "unknown". */
50
+ const ISO_LIKE = /^\d{4}-\d{2}-\d{2}/
51
+
52
+ /**
53
+ * Normalise a raw key to canonical form.
54
+ *
55
+ * Whitespace is dropped, separators are unified, empty segments collapse, ASCII
56
+ * is lowercased, and the result is trimmed of leading/trailing dots.
57
+ *
58
+ * @param {unknown} raw candidate key
59
+ * @returns {string | null} canonical key, or null when nothing usable remains
60
+ */
61
+ export function normalizeFactKey(raw) {
62
+ if (typeof raw !== 'string') return null
63
+ // NFKC folds full-width ASCII (`ABC`, `1`, `.`) onto its half-width form,
64
+ // which is what makes `她.身高` and `她.身高` the same key.
65
+ const folded = raw.normalize('NFKC')
66
+ const segments = folded
67
+ .replace(SEPARATORS, '.')
68
+ .replace(WHITESPACE, '')
69
+ .split('.')
70
+ .map(segment => segment.replace(/[A-Z]/g, ch => ch.toLowerCase()))
71
+ .filter(segment => segment.length > 0)
72
+ if (segments.length === 0) return null
73
+ return segments.join('.')
74
+ }
75
+
76
+ /**
77
+ * Is this key too broad to group memories safely?
78
+ *
79
+ * A key is catch-all when it has no usable segment (null, empty, punctuation
80
+ * only), a single segment, more than three segments, or any segment drawn from
81
+ * {@link CATCH_ALL_SEGMENTS}. The raw value is accepted as well as a canonical
82
+ * one, so callers cannot forget to normalise first.
83
+ *
84
+ * @param {unknown} key key to judge
85
+ * @returns {boolean} true when the key must not be used for supersession
86
+ */
87
+ export function isCatchAllKey(key) {
88
+ const canonical = normalizeFactKey(key)
89
+ if (!canonical) return true
90
+ const segments = canonical.split('.')
91
+ if (segments.length < 2 || segments.length > MAX_SEGMENTS) return true
92
+ return segments.some(segment => CATCH_ALL_SEGMENTS.includes(segment))
93
+ }
94
+
95
+ /**
96
+ * Read an entry's timestamp as a comparable number.
97
+ *
98
+ * @param {unknown} value `createdAt` in any accepted shape
99
+ * @returns {number | null} epoch milliseconds, or null when it cannot be judged
100
+ */
101
+ function comparableTime(value) {
102
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
103
+ if (value instanceof Date) {
104
+ const time = value.getTime()
105
+ return Number.isFinite(time) ? time : null
106
+ }
107
+ if (typeof value === 'string' && ISO_LIKE.test(value)) {
108
+ const time = Date.parse(value)
109
+ return Number.isFinite(time) ? time : null
110
+ }
111
+ return null
112
+ }
113
+
114
+ /**
115
+ * Work out which entries a fact key retires.
116
+ *
117
+ * Rules:
118
+ * - Entries are grouped by canonical key. Entries without a key, with an
119
+ * unparsable key, or with a catch-all key never participate.
120
+ * - Within a group the greatest `createdAt` survives; every entry strictly
121
+ * older than it is superseded.
122
+ * - **Exact timestamp ties supersede nothing.** When two entries claim the same
123
+ * slot at the same instant there is no basis for ordering them, and guessing
124
+ * would permanently bury a real memory, so both stay live.
125
+ * - Entries whose timestamp cannot be read are likewise kept.
126
+ *
127
+ * Grouping by canonical key also means `她.身高` and `她。身高` compete, and the
128
+ * result does not depend on input order (only `byId` uses the first tied winner
129
+ * when several entries share the greatest timestamp).
130
+ *
131
+ * @param {Array<{id?: string, factKey?: unknown, createdAt?: unknown}>} entries memories
132
+ * @returns {{superseded: Array<{id: unknown, byId: unknown, factKey: string}>, kept: unknown[]}}
133
+ */
134
+ export function converge(entries) {
135
+ const list = Array.isArray(entries) ? entries : []
136
+ /** @type {Map<string, number[]>} */
137
+ const groups = new Map()
138
+
139
+ for (let index = 0; index < list.length; index += 1) {
140
+ const entry = list[index]
141
+ if (!entry || typeof entry !== 'object') continue
142
+ const key = normalizeFactKey(entry.factKey)
143
+ if (!key || isCatchAllKey(key)) continue
144
+ const bucket = groups.get(key)
145
+ if (bucket) bucket.push(index)
146
+ else groups.set(key, [index])
147
+ }
148
+
149
+ const superseded = []
150
+ const retired = new Set()
151
+ const times = list.map(entry => (entry && typeof entry === 'object' ? comparableTime(entry.createdAt) : null))
152
+
153
+ for (const [key, indexes] of groups) {
154
+ if (indexes.length < 2) continue
155
+ const timed = indexes.filter(index => times[index] !== null)
156
+ if (timed.length === 0) continue
157
+ let newest = timed[0]
158
+ for (const index of timed) {
159
+ // Strict `>` keeps the first entry of an exact tie as `byId`; the tie
160
+ // itself supersedes nothing, which is enforced by the strict `<` below.
161
+ if (times[index] > times[newest]) newest = index
162
+ }
163
+ for (const index of timed) {
164
+ if (times[index] < times[newest]) {
165
+ retired.add(index)
166
+ superseded.push({ id: list[index].id, byId: list[newest].id, factKey: key })
167
+ }
168
+ }
169
+ }
170
+
171
+ const kept = []
172
+ for (let index = 0; index < list.length; index += 1) {
173
+ if (!retired.has(index)) kept.push(list[index]?.id)
174
+ }
175
+ return { superseded, kept }
176
+ }
177
+
178
+ /**
179
+ * Annotate a copy of the entry list with the supersession verdict.
180
+ *
181
+ * Superseded entries gain `supersededBy` (the surviving id) and `supersededAt`
182
+ * (the surviving entry's `createdAt`, so the stamp is deterministic); every
183
+ * other field and every other entry is copied through unchanged. The input
184
+ * objects are never written to — callers can undo a supersession by dropping
185
+ * those two fields again.
186
+ *
187
+ * @param {Array<object>} entries memories
188
+ * @param {{now?: number}} [options] override the recorded `supersededAt`
189
+ * @returns {Array<object>} new array, same length and order as the input
190
+ */
191
+ export function applySupersession(entries, options = {}) {
192
+ const list = Array.isArray(entries) ? entries : []
193
+ const { superseded } = converge(list)
194
+ const override = options && typeof options === 'object' ? comparableTime(options.now) : null
195
+
196
+ const byId = new Map()
197
+ const entryById = new Map()
198
+ for (const entry of list) {
199
+ if (entry && typeof entry === 'object') entryById.set(entry.id, entry)
200
+ }
201
+ for (const item of superseded) {
202
+ if (!byId.has(item.id)) byId.set(item.id, item.byId)
203
+ }
204
+
205
+ return list.map(entry => {
206
+ if (!entry || typeof entry !== 'object') return entry
207
+ if (!byId.has(entry.id)) return { ...entry }
208
+ const survivor = entryById.get(byId.get(entry.id))
209
+ const at = override ?? comparableTime(survivor?.createdAt)
210
+ return { ...entry, supersededBy: byId.get(entry.id), supersededAt: at }
211
+ })
212
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * A small Markdown subset parser shared by the HTML and image exports.
3
+ *
4
+ * The export pipeline must not depend on a markup library (a Host plugin cannot
5
+ * import extra packages), and both renderers need the *same* structure: headings,
6
+ * paragraphs, lists, quotes, fenced code, and inline emphasis. So the Host parses
7
+ * once into blocks with inline runs, the HTML renderer emits markup, and the
8
+ * browser draws the very same runs on a canvas.
9
+ *
10
+ * Supported: `#`–`######`, paragraphs, `-`/`*`/`+` and `1.` lists (indent = nesting),
11
+ * `>` quotes, ``` fenced code, `---` rules, `**bold**`, `*italic*`, `` `code` ``,
12
+ * `[text](url)`. Anything else stays literal text, which is the right failure mode
13
+ * for a transcript.
14
+ */
15
+
16
+ /**
17
+ * Split one line of inline Markdown into styled runs.
18
+ *
19
+ * @param {string} text inline source
20
+ * @returns {{ text: string, bold?: true, italic?: true, code?: true, href?: string }[]} runs
21
+ */
22
+ export function inlineRuns(text) {
23
+ const source = String(text ?? '')
24
+ const pattern = /(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)\s]+\))/g
25
+ const runs = []
26
+ let last = 0
27
+ for (const match of source.matchAll(pattern)) {
28
+ if (match.index > last) runs.push({ text: source.slice(last, match.index) })
29
+ const token = match[0]
30
+ if (token.startsWith('**') || token.startsWith('__')) runs.push({ text: token.slice(2, -2), bold: true })
31
+ else if (token.startsWith('`')) runs.push({ text: token.slice(1, -1), code: true })
32
+ else if (token.startsWith('[')) {
33
+ const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(token)
34
+ if (link) runs.push({ text: link[1], href: link[2] })
35
+ else runs.push({ text: token })
36
+ } else runs.push({ text: token.slice(1, -1), italic: true })
37
+ last = match.index + token.length
38
+ }
39
+ if (last < source.length) runs.push({ text: source.slice(last) })
40
+ return runs.length > 0 ? runs : [{ text: '' }]
41
+ }
42
+
43
+ /**
44
+ * Parse a Markdown document into blocks.
45
+ *
46
+ * @param {string} source markdown
47
+ * @returns {any[]} blocks
48
+ */
49
+ export function parseMarkdown(source) {
50
+ const lines = String(source ?? '').replace(/\r\n?/g, '\n').split('\n')
51
+ const blocks = []
52
+ let paragraph = []
53
+ let index = 0
54
+
55
+ const flush = () => {
56
+ if (paragraph.length === 0) return
57
+ blocks.push({ type: 'paragraph', runs: inlineRuns(paragraph.join('\n').trim()) })
58
+ paragraph = []
59
+ }
60
+
61
+ while (index < lines.length) {
62
+ const line = lines[index]
63
+
64
+ if (/^\s*```/.test(line)) {
65
+ flush()
66
+ const lang = line.trim().slice(3).trim()
67
+ const body = []
68
+ index += 1
69
+ while (index < lines.length && !/^\s*```/.test(lines[index])) {
70
+ body.push(lines[index])
71
+ index += 1
72
+ }
73
+ index += 1
74
+ blocks.push({ type: 'code', lang, text: body.join('\n') })
75
+ continue
76
+ }
77
+
78
+ if (/^\s*$/.test(line)) {
79
+ flush()
80
+ index += 1
81
+ continue
82
+ }
83
+
84
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line)
85
+ if (heading) {
86
+ flush()
87
+ blocks.push({ type: 'heading', level: heading[1].length, runs: inlineRuns(heading[2].trim()) })
88
+ index += 1
89
+ continue
90
+ }
91
+
92
+ if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
93
+ flush()
94
+ blocks.push({ type: 'hr' })
95
+ index += 1
96
+ continue
97
+ }
98
+
99
+ // Pipe table: a header row immediately followed by a separator row.
100
+ const separator = /^\s*\|?[\s:|-]*-[\s:|-]*\|[\s:|-]*$/
101
+ if (/^\s*\|.*\|\s*$/.test(line) && index + 1 < lines.length && separator.test(lines[index + 1]) && lines[index + 1].includes('-')) {
102
+ flush()
103
+ const cells = row => row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim())
104
+ const headers = cells(line).map(cell => inlineRuns(cell))
105
+ index += 2
106
+ const rows = []
107
+ while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index])) {
108
+ rows.push(cells(lines[index]).map(cell => inlineRuns(cell)))
109
+ index += 1
110
+ }
111
+ blocks.push({ type: 'table', headers, rows })
112
+ continue
113
+ }
114
+
115
+ if (/^\s*>/.test(line)) {
116
+ flush()
117
+ const quote = []
118
+ while (index < lines.length && /^\s*>/.test(lines[index])) {
119
+ quote.push(lines[index].replace(/^\s*>\s?/, ''))
120
+ index += 1
121
+ }
122
+ blocks.push({ type: 'quote', lines: quote.map(entry => inlineRuns(entry.trim())).filter(runs => runs.some(run => run.text.trim())) })
123
+ continue
124
+ }
125
+
126
+ const bullet = /^(\s*)([-*+])\s+(.*)$/.exec(line)
127
+ const ordered = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(line)
128
+ if (bullet || ordered) {
129
+ flush()
130
+ const isOrdered = Boolean(ordered)
131
+ const items = []
132
+ while (index < lines.length) {
133
+ const nextBullet = /^(\s*)([-*+])\s+(.*)$/.exec(lines[index])
134
+ const nextOrdered = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(lines[index])
135
+ const match = isOrdered ? nextOrdered : nextBullet
136
+ if (match) {
137
+ items.push({ indent: Math.floor(match[1].replace(/\t/g, ' ').length / 2), runs: inlineRuns(match[3].trim()) })
138
+ index += 1
139
+ continue
140
+ }
141
+ if (items.length > 0 && /^\s{2,}\S/.test(lines[index])) {
142
+ const continuation = lines[index].trim()
143
+ const lastItem = items[items.length - 1]
144
+ lastItem.runs = inlineRuns(`${runsToText(lastItem.runs)} ${continuation}`)
145
+ index += 1
146
+ continue
147
+ }
148
+ break
149
+ }
150
+ blocks.push({ type: 'list', ordered: isOrdered, start: isOrdered ? Number(ordered[2]) : 1, items })
151
+ continue
152
+ }
153
+
154
+ paragraph.push(line)
155
+ index += 1
156
+ }
157
+
158
+ flush()
159
+ return blocks
160
+ }
161
+
162
+ /** @param {readonly any[]} runs runs @returns {string} plain text */
163
+ export function runsToText(runs) {
164
+ return (runs ?? []).map(run => run.text ?? '').join('')
165
+ }
166
+
167
+ /** @param {string} value raw @returns {string} HTML-safe */
168
+ export function escapeHtml(value) {
169
+ return String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char])
170
+ }
171
+
172
+ /**
173
+ * @param {readonly any[]} runs runs
174
+ * @returns {string} inline HTML
175
+ */
176
+ export function runsToHtml(runs) {
177
+ return (runs ?? [])
178
+ .map(run => {
179
+ let html = escapeHtml(run.text ?? '')
180
+ if (run.code) html = `<code>${html}</code>`
181
+ if (run.bold) html = `<strong>${html}</strong>`
182
+ if (run.italic) html = `<em>${html}</em>`
183
+ if (run.href) html = `<a href="${escapeHtml(run.href)}" rel="noreferrer noopener" target="_blank">${html}</a>`
184
+ return html
185
+ })
186
+ .join('')
187
+ }
188
+
189
+ /**
190
+ * @param {readonly any[]} blocks parsed blocks
191
+ * @returns {string} block HTML
192
+ */
193
+ export function blocksToHtml(blocks) {
194
+ const out = []
195
+ for (const block of blocks ?? []) {
196
+ if (block.type === 'heading') {
197
+ const level = Math.min(6, Math.max(1, block.level ?? 2) + 1)
198
+ out.push(`<h${level}>${runsToHtml(block.runs)}</h${level}>`)
199
+ } else if (block.type === 'paragraph') {
200
+ out.push(`<p>${runsToHtml(block.runs).replace(/\n/g, '<br>')}</p>`)
201
+ } else if (block.type === 'list') {
202
+ const tag = block.ordered ? 'ol' : 'ul'
203
+ const startAttr = block.ordered && block.start !== 1 ? ` start="${block.start}"` : ''
204
+ const items = block.items
205
+ .map(item => {
206
+ const depth = Math.min(3, item.indent ?? 0)
207
+ return `<li class="depth-${depth}">${runsToHtml(item.runs)}</li>`
208
+ })
209
+ .join('')
210
+ out.push(`<${tag}${startAttr}>${items}</${tag}>`)
211
+ } else if (block.type === 'quote') {
212
+ out.push(`<blockquote>${(block.lines ?? []).map(runs => `<p>${runsToHtml(runs)}</p>`).join('')}</blockquote>`)
213
+ } else if (block.type === 'table') {
214
+ const head = (block.headers ?? []).map(runs => `<th>${runsToHtml(runs)}</th>`).join('')
215
+ const body = (block.rows ?? [])
216
+ .map(row => `<tr>${row.map(runs => `<td>${runsToHtml(runs)}</td>`).join('')}</tr>`)
217
+ .join('')
218
+ out.push(`<div class="table-wrap"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`)
219
+ } else if (block.type === 'code') {
220
+ out.push(`<pre><code>${escapeHtml(block.text)}</code></pre>`)
221
+ } else if (block.type === 'hr') {
222
+ out.push('<hr>')
223
+ }
224
+ }
225
+ return out.join('\n')
226
+ }
227
+
228
+ /**
229
+ * Plain one-line preview of a Markdown string, for menu and picker rows.
230
+ *
231
+ * @param {string} text markdown
232
+ * @param {number} [limit] character budget
233
+ * @returns {string} preview
234
+ */
235
+ export function previewText(text, limit = 120) {
236
+ const plain = String(text ?? '')
237
+ .replace(/```[\s\S]*?```/g, ' ')
238
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
239
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
240
+ // attachment placeholders are honest in an export but useless in a menu row.
241
+ // The counted form ("[3 张图片]") must go first: the generic rule below
242
+ // would otherwise eat only the trailing "图片]" and leave "[3 张".
243
+ .replace(/[\s*\d+\s*张(?:图片|文件)\s*]/g, ' ')
244
+ .replace(/[文件:[^]]*]/g, ' ')
245
+ .replace(/\[image: [^\]]*\]/g, ' ')
246
+ .replace(/[?(?:图片|image|file)[^]\]]*]?/g, ' ')
247
+ .replace(/^\s{0,3}#{1,6}\s+/gm, '')
248
+ .replace(/[*_`>]/g, '')
249
+ .replace(/\s+/g, ' ')
250
+ .trim()
251
+ return plain.length > limit ? `${plain.slice(0, limit)}…` : plain
252
+ }