dsh-memory-dba 0.0.0-stage → 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/src/stream.mjs ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Turn-to-stream writer.
3
+ *
4
+ * The stream is a timeline, not a transcript: one line per finished turn, no
5
+ * detail. Anything longer belongs in a workspace note that retrieval can reach.
6
+ * Writing is best-effort — a failed write must never fail the turn.
7
+ */
8
+
9
+ import { appendLayer, readLayer } from './store.mjs'
10
+
11
+ const SECRET_PATTERNS = [
12
+ /sk-[A-Za-z0-9_-]{16,}/g,
13
+ /\bBearer\s+[A-Za-z0-9._-]{16,}/gi,
14
+ /\b[A-Fa-f0-9]{32,}\b/g,
15
+ /\b\d{13,19}\b/g,
16
+ ]
17
+
18
+ /** Remove credential-looking runs before text reaches a committable file. */
19
+ export function sanitize(text) {
20
+ let out = String(text || '')
21
+ for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, '[redacted]')
22
+ return out
23
+ }
24
+
25
+ /** Collapse whitespace so one turn stays one line. */
26
+ export function oneLine(text, maxChars = 600) {
27
+ const flat = String(text || '').replace(/\s+/g, ' ').trim()
28
+ if (flat.length <= maxChars) return flat
29
+ let end = Math.max(0, maxChars - 1)
30
+ if (end > 0 && /[\uD800-\uDBFF]/.test(flat[end - 1]) && /[\uDC00-\uDFFF]/.test(flat[end])) end -= 1
31
+ return `${flat.slice(0, end)}…`
32
+ }
33
+
34
+ /** Local `YYYY-MM-DD HH:mm` timestamp. */
35
+ export function stamp(date = new Date()) {
36
+ const pad = n => String(n).padStart(2, '0')
37
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
38
+ }
39
+
40
+ /**
41
+ * Format one turn as a stream line.
42
+ *
43
+ * @param {{user?: string, assistant?: string, tag?: string, at?: Date}} turn
44
+ * @returns {string|null} Line, or null when the turn is not worth remembering.
45
+ */
46
+ export function formatTurn({ user, assistant, tag, at }) {
47
+ const u = oneLine(sanitize(user), 300)
48
+ const a = oneLine(sanitize(assistant), 600)
49
+ if (!u || !a) return null
50
+ const label = tag ? ` | tag=${oneLine(sanitize(tag), 100)}` : ''
51
+ return `- ${stamp(at)}${label} | 用户:${u} | 助手:${a}`
52
+ }
53
+
54
+ /**
55
+ * Append a turn unless it repeats the last line.
56
+ *
57
+ * @param {string} home Memory directory.
58
+ * @param {{user?: string, assistant?: string, tag?: string, at?: Date}} turn
59
+ * @returns {{written: boolean, reason?: string, line?: string}}
60
+ */
61
+ export function appendTurn(home, turn) {
62
+ const line = formatTurn(turn)
63
+ if (!line) return { written: false, reason: 'empty-turn' }
64
+ let last = ''
65
+ try {
66
+ const stream = readLayer(home, 'stream.md').trimEnd()
67
+ last = stream.slice(stream.lastIndexOf('\n') + 1)
68
+ } catch (error) {
69
+ if (error?.name !== 'MissingLayer') throw error
70
+ }
71
+ if (last === line) return { written: false, reason: 'duplicate' }
72
+ appendLayer(home, 'stream.md', line)
73
+ return { written: true, line }
74
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Tokenizer for mixed Chinese/English memory text.
3
+ *
4
+ * A trigram index cannot answer two-character Chinese words (a two-character
5
+ * query is shorter than one trigram), so CJK runs emit both unigrams and
6
+ * bigrams. Latin/digit runs stay whole words, which keeps identifiers such as
7
+ * `202301233055` searchable while ordinary words still match exactly.
8
+ */
9
+
10
+ const CJK = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u3040-\u30FF\uAC00-\uD7AF]/
11
+ const WORD = /[A-Za-z0-9_@.\-]+/g
12
+ const CJK_RUN = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u3040-\u30FF\uAC00-\uD7AF]+/g
13
+
14
+ /**
15
+ * Tokens for indexing and querying. Lowercases Latin text, keeps digits and
16
+ * CJK as-is, and deduplicates nothing (term frequency comes from repetition).
17
+ *
18
+ * @param {string} text Raw memory or query text.
19
+ * @returns {string[]} Tokens in source order.
20
+ */
21
+ export function tokenize(text) {
22
+ const tokens = []
23
+ if (!text) return tokens
24
+ const lower = String(text).toLowerCase()
25
+
26
+ for (const match of lower.matchAll(CJK_RUN)) {
27
+ const run = match[0]
28
+ for (let i = 0; i < run.length; i += 1) {
29
+ tokens.push(run[i])
30
+ if (i + 1 < run.length) tokens.push(run.slice(i, i + 2))
31
+ }
32
+ }
33
+
34
+ const withoutCjk = lower.replace(CJK_RUN, ' ')
35
+ for (const match of withoutCjk.matchAll(WORD)) {
36
+ const word = match[0].replace(/^[-.]+|[-.]+$/g, '')
37
+ if (word) tokens.push(word)
38
+ }
39
+ return tokens
40
+ }
41
+
42
+ /**
43
+ * Query terms with per-term weight. CJK bigrams carry the most signal, single
44
+ * characters the least; a bare digit string is exact-match material.
45
+ *
46
+ * @param {string} query User query text.
47
+ * @returns {Map<string, number>} term -> boost
48
+ */
49
+ export function queryTerms(query) {
50
+ const weights = new Map()
51
+ const bump = (term, weight) => {
52
+ if (!term) return
53
+ weights.set(term, Math.max(weights.get(term) || 0, weight))
54
+ }
55
+ const lower = String(query || '').toLowerCase()
56
+
57
+ for (const match of lower.matchAll(CJK_RUN)) {
58
+ const run = match[0]
59
+ if (run.length === 1) bump(run, 1)
60
+ for (let i = 0; i < run.length - 1; i += 1) bump(run.slice(i, i + 2), 1.6)
61
+ }
62
+ for (const match of lower.replace(CJK_RUN, ' ').matchAll(WORD)) {
63
+ const word = match[0].replace(/^[-.]+|[-.]+$/g, '')
64
+ if (!word) continue
65
+ bump(word, /\d{4,}/.test(word) ? 2 : 1.2)
66
+ }
67
+ return weights
68
+ }
69
+
70
+ /** True when the query contains no indexable term at all. */
71
+ export function isBlankQuery(query) {
72
+ return queryTerms(query).size === 0
73
+ }
@@ -0,0 +1,37 @@
1
+ import { parentPort } from 'node:worker_threads'
2
+ import { dirname } from 'node:path'
3
+ import { statSync } from 'node:fs'
4
+ import { embed } from './embed.mjs'
5
+ import { cosineInt8, encodeVector, loadVectorCache } from './vector.mjs'
6
+
7
+ /** The cache is re-parsed only when the file changes; each turn otherwise reuses it. */
8
+ let memo
9
+
10
+ function cacheFor(cachePath) {
11
+ const { mtimeMs } = statSync(cachePath)
12
+ if (!memo || memo.path !== cachePath || memo.mtimeMs !== mtimeMs) {
13
+ memo = { path: cachePath, mtimeMs, cache: loadVectorCache(dirname(dirname(cachePath))) }
14
+ }
15
+ return memo.cache
16
+ }
17
+
18
+ parentPort.on('message', async ({ query, cachePath, keys, topK, url, model, timeoutMs, keepAlive, sab }) => {
19
+ const header = new Int32Array(sab)
20
+ try {
21
+ const cache = cacheFor(cachePath)
22
+ if (cache.model !== model) throw new Error('model mismatch')
23
+ const vector = await embed(query, { url, model, timeoutMs, keepAlive })
24
+ if (vector.length !== cache.dim) throw new Error('dimension mismatch')
25
+ const encoded = encodeVector(vector)
26
+ const hits = keys.flatMap((key, index) => cache.entries[key] ? [{ index, score: cosineInt8(encoded, cache.entries[key]) }] : [])
27
+ .sort((left, right) => right.score - left.score || left.index - right.index).slice(0, Math.min(8, topK))
28
+ const floats = new Float32Array(sab)
29
+ for (let index = 0; index < hits.length; index += 1) {
30
+ header[2 + index * 2] = hits[index].index
31
+ floats[3 + index * 2] = hits[index].score
32
+ }
33
+ header[1] = hits.length
34
+ Atomics.store(header, 0, 1)
35
+ } catch { Atomics.store(header, 0, -1) }
36
+ Atomics.notify(header, 0)
37
+ })
package/src/vector.mjs ADDED
@@ -0,0 +1,121 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import { Worker } from 'node:worker_threads'
5
+
6
+ export class VectorCorrupt extends Error {
7
+ constructor() {
8
+ super('invalid vector cache')
9
+ this.name = 'VectorCorrupt'
10
+ }
11
+ }
12
+
13
+ export class MissingVectors extends Error {
14
+ constructor() {
15
+ super('vector cache missing')
16
+ this.name = 'MissingVectors'
17
+ }
18
+ }
19
+
20
+ export function docKey(text) {
21
+ return createHash('sha1').update(text, 'utf8').digest('hex')
22
+ }
23
+
24
+ export function encodeVector(vector) {
25
+ if (!(vector instanceof Float32Array) || !vector.length || !vector.every(Number.isFinite)) throw new VectorCorrupt()
26
+ let maximum = 0
27
+ for (const value of vector) maximum = Math.max(maximum, Math.abs(value))
28
+ const scale = maximum / 127
29
+ const quantized = Int8Array.from(vector, value => scale ? Math.round(value / scale) : 0)
30
+ return { s: scale, q: Buffer.from(quantized.buffer).toString('base64') }
31
+ }
32
+
33
+ function quantized(entry) {
34
+ if (!entry || typeof entry.s !== 'number' || !Number.isFinite(entry.s) || entry.s < 0 || typeof entry.q !== 'string' || !entry.q.length) throw new VectorCorrupt()
35
+ const buffer = Buffer.from(entry.q, 'base64')
36
+ if (buffer.toString('base64') !== entry.q) throw new VectorCorrupt()
37
+ const values = new Int8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
38
+ if (values.some(value => value === -128 || (entry.s === 0 && value !== 0))) throw new VectorCorrupt()
39
+ return values
40
+ }
41
+
42
+ export function decodeVector(entry) {
43
+ return Float32Array.from(quantized(entry), value => value * entry.s)
44
+ }
45
+
46
+ export function cosineInt8(left, right) {
47
+ const first = quantized(left)
48
+ const second = quantized(right)
49
+ if (first.length !== second.length) throw new VectorCorrupt()
50
+ let dot = 0
51
+ let firstNorm = 0
52
+ let secondNorm = 0
53
+ for (let index = 0; index < first.length; index += 1) {
54
+ dot += first[index] * second[index]
55
+ firstNorm += first[index] ** 2
56
+ secondNorm += second[index] ** 2
57
+ }
58
+ return firstNorm && secondNorm ? Math.max(-1, Math.min(1, dot / Math.sqrt(firstNorm * secondNorm))) : 0
59
+ }
60
+
61
+ function validateCache(cache) {
62
+ if (!cache || cache.version !== 1 || typeof cache.model !== 'string' || !cache.model.trim() || !Number.isInteger(cache.dim) || cache.dim < 1 || !cache.entries || typeof cache.entries !== 'object' || Array.isArray(cache.entries)) throw new VectorCorrupt()
63
+ for (const [key, entry] of Object.entries(cache.entries)) {
64
+ if (!/^[a-f0-9]{40}$/.test(key) || quantized(entry).length !== cache.dim) throw new VectorCorrupt()
65
+ }
66
+ return cache
67
+ }
68
+
69
+ export function loadVectorCache(home) {
70
+ let raw
71
+ try { raw = readFileSync(join(home, '.vec', 'embeddings.json'), 'utf8') } catch (error) {
72
+ if (error?.code === 'ENOENT') throw new MissingVectors()
73
+ throw error
74
+ }
75
+ try { return validateCache(JSON.parse(raw)) } catch { throw new VectorCorrupt() }
76
+ }
77
+
78
+ export function saveVectorCache(home, cache) {
79
+ validateCache(cache)
80
+ const file = join(home, '.vec', 'embeddings.json')
81
+ mkdirSync(dirname(file), { recursive: true })
82
+ const temporary = `${file}.tmp-${process.pid}`
83
+ const clean = { version: 1, model: cache.model, dim: cache.dim, entries: Object.fromEntries(Object.entries(cache.entries).map(([key, entry]) => [key, { s: entry.s, q: entry.q }])) }
84
+ writeFileSync(temporary, JSON.stringify(clean), 'utf8')
85
+ renameSync(temporary, file)
86
+ }
87
+
88
+ let worker
89
+ // After a failed lookup, skip semantic recall briefly: a down or cold embedder must
90
+ // not cost the synchronous budget on every single turn.
91
+ const coolDown = new Map()
92
+
93
+ export function semanticRecall(home, query, keys, { topK = 8, url = 'http://127.0.0.1:11434', model = 'bge-m3', timeoutMs = 1500, keepAlive = '30m' } = {}) {
94
+ const started = performance.now()
95
+ try {
96
+ const cachePath = join(home, '.vec', 'embeddings.json')
97
+ if (!keys.length || !existsSync(cachePath) || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return null
98
+ const coolKey = `${url}|${model}`
99
+ if ((coolDown.get(coolKey) || 0) > started) return null
100
+ if (!worker) {
101
+ const instance = new Worker(new URL('./vector-worker.mjs', import.meta.url))
102
+ instance.on('error', () => { if (worker === instance) worker = undefined })
103
+ instance.on('exit', () => { if (worker === instance) worker = undefined })
104
+ instance.unref()
105
+ worker = instance
106
+ }
107
+ const sab = new SharedArrayBuffer(8 + 8 * 8)
108
+ const header = new Int32Array(sab)
109
+ worker.postMessage({ query, cachePath, keys, topK: Math.min(8, Math.max(1, Math.floor(topK))), url, model, timeoutMs, keepAlive, sab })
110
+ const remaining = timeoutMs - (performance.now() - started)
111
+ if (remaining <= 0 || Atomics.wait(header, 0, 0, remaining) === 'timed-out' || Atomics.load(header, 0) !== 1) {
112
+ coolDown.set(coolKey, performance.now() + 60_000)
113
+ return null
114
+ }
115
+ const floats = new Float32Array(sab)
116
+ const result = new Map()
117
+ for (let index = 0; index < header[1]; index += 1) result.set(keys[header[2 + index * 2]], floats[3 + index * 2])
118
+ coolDown.delete(coolKey)
119
+ return result
120
+ } catch { return null }
121
+ }