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/LICENSE +21 -0
- package/README.md +147 -3
- package/bin/cli.mjs +199 -0
- package/package.json +34 -4
- package/src/assemble.mjs +80 -0
- package/src/embed.mjs +35 -0
- package/src/index.mjs +240 -0
- package/src/search.mjs +258 -0
- package/src/store.mjs +171 -0
- package/src/stream.mjs +74 -0
- package/src/tokenize.mjs +73 -0
- package/src/vector-worker.mjs +37 -0
- package/src/vector.mjs +121 -0
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
|
+
}
|
package/src/tokenize.mjs
ADDED
|
@@ -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
|
+
}
|