rcs-context 0.0.0-stage → 0.1.39

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,241 @@
1
+ // Copyright (c) 2026 ScreamingMaggot. This source code is licensed under the MIT License.
2
+ // memory-vec.mjs — Phase 3 semantic arm (pure math + local embedder + RRF).
3
+ //
4
+ // The vector layer is a ranking signal, never a fact source: vectors are derived
5
+ // from records, stored in a plain table, and any failure returns the lexical
6
+ // result unchanged. Default is off; callers must supply an embedding or enable
7
+ // DSH_MEMORY_VEC explicitly.
8
+
9
+ import { createHash } from 'node:crypto'
10
+
11
+ export const VEC_DEFAULTS = Object.freeze({
12
+ k: 60,
13
+ wLex: 1.0,
14
+ wVec: 0.8,
15
+ insightBoost: 1.15,
16
+ cosFloor: 0.35,
17
+ noAnswerCos: 0.45,
18
+ maxK: 64,
19
+ embedTimeoutMs: 300,
20
+ batch: 16,
21
+ })
22
+
23
+ export const VEC_KINDS = new Set(['insight', 'file_state', 'command', 'search', 'error', 'user_answer'])
24
+
25
+ const KIND_RANK = {
26
+ insight: 0, file_state: 1, command: 2, search: 3, user_answer: 4, error: 5,
27
+ job: 6, task: 6, user_turn: 8, tool_unknown: 9,
28
+ }
29
+ const kindRank = (k) => KIND_RANK[k] ?? 7
30
+
31
+ export function vecNormalize(input) {
32
+ if (!input || typeof input.length !== 'number' || !input.length) return null
33
+ const out = new Float32Array(input.length)
34
+ let sum = 0
35
+ for (let i = 0; i < input.length; i++) {
36
+ const x = Number(input[i])
37
+ if (!Number.isFinite(x)) return null
38
+ out[i] = x
39
+ sum += x * x
40
+ }
41
+ if (!(sum > 0)) return null
42
+ const inv = 1 / Math.sqrt(sum)
43
+ for (let i = 0; i < out.length; i++) out[i] *= inv
44
+ return out
45
+ }
46
+
47
+ export function vecToBlob(v) {
48
+ if (!v || !v.length) return null
49
+ const buf = Buffer.allocUnsafe(v.length * 4)
50
+ for (let i = 0; i < v.length; i++) buf.writeFloatLE(Number(v[i]), i * 4)
51
+ return buf
52
+ }
53
+
54
+ export function vecFromBlob(blob, dim = 0) {
55
+ if (!blob) return null
56
+ const bytes = blob instanceof Uint8Array ? blob : (ArrayBuffer.isView(blob) ? new Uint8Array(blob.buffer, blob.byteOffset, blob.byteLength) : null)
57
+ if (!bytes) return null
58
+ const n = dim > 0 ? Number(dim) : Math.floor(bytes.byteLength / 4)
59
+ if (!Number.isFinite(n) || n <= 0 || bytes.byteLength < n * 4) return null
60
+ const ab = new ArrayBuffer(n * 4)
61
+ new Uint8Array(ab).set(bytes.subarray(0, n * 4))
62
+ return new Float32Array(ab)
63
+ }
64
+
65
+ export function vecDot(a, b) {
66
+ if (!a || !b || a.length !== b.length) return null
67
+ let s = 0
68
+ for (let i = 0; i < a.length; i++) s += a[i] * b[i]
69
+ return s
70
+ }
71
+
72
+ export function vecCosine(a, b) {
73
+ const dot = vecDot(a, b)
74
+ if (dot === null) return null
75
+ let na = 0
76
+ let nb = 0
77
+ for (let i = 0; i < a.length; i++) { na += a[i] * a[i]; nb += b[i] * b[i] }
78
+ if (!(na > 0) || !(nb > 0)) return null
79
+ return dot / Math.sqrt(na * nb)
80
+ }
81
+
82
+ // rows: [{ record_id, vec: Uint8Array|Float32Array, ... }]
83
+ export function vecTopK(rows, query, { k = 20, floor = VEC_DEFAULTS.cosFloor, dim = 0 } = {}) {
84
+ const q = query instanceof Float32Array ? query : vecNormalize(query)
85
+ if (!q || !Array.isArray(rows)) return []
86
+ const out = []
87
+ for (const row of rows) {
88
+ const v = row.vec instanceof Float32Array ? row.vec : vecFromBlob(row.vec, dim || q.length)
89
+ if (!v || v.length !== q.length) continue
90
+ const cos = vecCosine(q, v)
91
+ if (cos === null || !Number.isFinite(cos) || cos < floor) continue
92
+ out.push({ ...row, cos })
93
+ }
94
+ out.sort((a, b) => b.cos - a.cos || Number(a.record_id) - Number(b.record_id))
95
+ return out.slice(0, Math.max(1, Number(k) || 20))
96
+ }
97
+
98
+ // Weighted reciprocal-rank fusion. Inputs are already ordered best-first;
99
+ // `lex` items are the lexical arm, `vec` items carry `cos` and full records.
100
+ export function fuseRRF(lex = [], vec = [], opts = {}) {
101
+ const K = Number(opts.k ?? VEC_DEFAULTS.k) || VEC_DEFAULTS.k
102
+ const wLex = Number.isFinite(Number(opts.wLex)) ? Number(opts.wLex) : VEC_DEFAULTS.wLex
103
+ const wVec = Number.isFinite(Number(opts.wVec)) ? Number(opts.wVec) : VEC_DEFAULTS.wVec
104
+ const boost = Number.isFinite(Number(opts.insightBoost)) ? Number(opts.insightBoost) : VEC_DEFAULTS.insightBoost
105
+ const limit = Math.max(1, Number(opts.limit) || 10)
106
+ const rankLex = new Map()
107
+ const rankVec = new Map()
108
+ const rows = new Map()
109
+ lex.forEach((r, i) => {
110
+ if (!r || r.rowid === undefined) return
111
+ const id = Number(r.rowid)
112
+ if (!Number.isFinite(id)) return
113
+ rankLex.set(id, i + 1)
114
+ rows.set(id, { rowid: id, rec: r, cos: null })
115
+ })
116
+ vec.forEach((r, i) => {
117
+ if (!r || r.record_id === undefined) return
118
+ const id = Number(r.record_id)
119
+ if (!Number.isFinite(id)) return
120
+ rankVec.set(id, i + 1)
121
+ const prev = rows.get(id)
122
+ if (prev) prev.cos = r.cos
123
+ else rows.set(id, { rowid: id, rec: r.rec ?? r, cos: r.cos })
124
+ })
125
+ const scored = []
126
+ for (const item of rows.values()) {
127
+ const rl = rankLex.get(item.rowid)
128
+ const rv = rankVec.get(item.rowid)
129
+ let s = 0
130
+ if (rl) s += wLex / (K + rl)
131
+ if (rv) s += wVec / (K + rv)
132
+ if (item.rec?.kind === 'insight') s *= boost
133
+ scored.push({ ...item, score: s, lexRank: rl ?? null, vecRank: rv ?? null })
134
+ }
135
+ scored.sort((a, b) => b.score - a.score
136
+ || kindRank(a.rec?.kind) - kindRank(b.rec?.kind)
137
+ || Number(b.rec?.updated_ts ?? 0) - Number(a.rec?.updated_ts ?? 0)
138
+ || a.rowid - b.rowid)
139
+ const maxCos = vec.reduce((m, r) => (Number.isFinite(r?.cos) ? Math.max(m, r.cos) : m), 0)
140
+ return { items: scored.slice(0, limit), total: scored.length, maxCos }
141
+ }
142
+
143
+ export function vecQueryEligible(query) {
144
+ const s = String(query ?? '').trim()
145
+ if (!s) return false
146
+ if (/[\u3400-\u9fff]/.test(s)) return s.replace(/\s+/g, '').length >= 2
147
+ return /[A-Za-z][A-Za-z0-9_./#-]{2,}/.test(s)
148
+ }
149
+
150
+ export function vecSrcHash(model, key, text) {
151
+ return createHash('sha1').update(String(model ?? '') + '\n' + String(key ?? '') + '\n' + String(text ?? '')).digest('hex')
152
+ }
153
+
154
+ export function vecLocalUrl(raw) {
155
+ try {
156
+ const u = new URL(String(raw || ''))
157
+ const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '')
158
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1'
159
+ } catch {
160
+ return false
161
+ }
162
+ }
163
+
164
+ function parseVectors(data) {
165
+ const list = Array.isArray(data?.embeddings) ? data.embeddings
166
+ : (Array.isArray(data?.data) ? data.data.map((d) => d?.embedding) : null)
167
+ if (!Array.isArray(list)) return null
168
+ const out = []
169
+ for (const item of list) {
170
+ const v = vecNormalize(item)
171
+ if (!v) return null
172
+ out.push(v)
173
+ }
174
+ return out
175
+ }
176
+
177
+ // One batch call. The default budget is 300 ms and only loopback endpoints are
178
+ // allowed (the text never leaves the machine unless explicitly overridden).
179
+ export async function embedBatch({
180
+ url = 'http://127.0.0.1:11434', model = '', texts = [], timeoutMs = VEC_DEFAULTS.embedTimeoutMs,
181
+ allowRemote = false, fetchImpl = globalThis.fetch,
182
+ } = {}) {
183
+ const list = Array.isArray(texts) ? texts.map((t) => String(t ?? '')).filter(Boolean) : []
184
+ if (!list.length) return { ok: true, vectors: [], dim: 0, ms: 0 }
185
+ if (!model) return { ok: false, error: 'model-missing' }
186
+ if (!allowRemote && !vecLocalUrl(url)) return { ok: false, error: 'remote-not-allowed' }
187
+ if (typeof fetchImpl !== 'function') return { ok: false, error: 'fetch-unavailable' }
188
+ const base = String(url).replace(/\/+$/, '')
189
+ const ctrl = new AbortController()
190
+ const timer = setTimeout(() => { try { ctrl.abort() } catch { /* ignore */ } }, Math.max(10, Number(timeoutMs) || VEC_DEFAULTS.embedTimeoutMs))
191
+ timer.unref?.()
192
+ const t0 = performance.now()
193
+ try {
194
+ const res = await fetchImpl(`${base}/api/embed`, {
195
+ method: 'POST',
196
+ headers: { 'content-type': 'application/json' },
197
+ body: JSON.stringify({ model, input: list }),
198
+ signal: ctrl.signal,
199
+ })
200
+ if (!res || !res.ok) return { ok: false, error: 'http-' + (res?.status ?? 'error'), ms: Number((performance.now() - t0).toFixed(3)) }
201
+ const data = await res.json()
202
+ const vectors = parseVectors(data)
203
+ if (!vectors || vectors.length !== list.length) return { ok: false, error: 'bad-response', ms: Number((performance.now() - t0).toFixed(3)) }
204
+ const dim = vectors[0]?.length ?? 0
205
+ if (!dim || vectors.some((v) => v.length !== dim)) return { ok: false, error: 'dim-mismatch', ms: Number((performance.now() - t0).toFixed(3)) }
206
+ return { ok: true, vectors, dim, model, ms: Number((performance.now() - t0).toFixed(3)) }
207
+ } catch (err) {
208
+ const aborted = err?.name === 'AbortError' || /abort/i.test(String(err?.message ?? ''))
209
+ return { ok: false, error: aborted ? 'timeout' : 'request-failed', ms: Number((performance.now() - t0).toFixed(3)) }
210
+ } finally {
211
+ clearTimeout(timer)
212
+ }
213
+ }
214
+
215
+ // Probe /api/tags for the first embedding-capable model (Ollama reports
216
+ // capabilities; do not trust error text such as 501 during startup).
217
+ export async function probeEmbedder({
218
+ url = 'http://127.0.0.1:11434', timeoutMs = 500, allowRemote = false, fetchImpl = globalThis.fetch,
219
+ } = {}) {
220
+ if (!allowRemote && !vecLocalUrl(url)) return { ok: false, error: 'remote-not-allowed' }
221
+ if (typeof fetchImpl !== 'function') return { ok: false, error: 'fetch-unavailable' }
222
+ const base = String(url).replace(/\/+$/, '')
223
+ const ctrl = new AbortController()
224
+ const timer = setTimeout(() => { try { ctrl.abort() } catch { /* ignore */ } }, Math.max(10, Number(timeoutMs) || 500))
225
+ timer.unref?.()
226
+ try {
227
+ const res = await fetchImpl(`${base}/api/tags`, { signal: ctrl.signal })
228
+ if (!res || !res.ok) return { ok: false, error: 'http-' + (res?.status ?? 'error') }
229
+ const data = await res.json()
230
+ const models = Array.isArray(data?.models) ? data.models : []
231
+ const hit = models.find((m) => Array.isArray(m?.capabilities) && m.capabilities.includes('embedding'))
232
+ ?? models.find((m) => /embed/i.test(String(m?.name ?? '')))
233
+ if (!hit) return { ok: false, error: 'model-missing', models: models.length }
234
+ return { ok: true, model: String(hit.name ?? hit.model ?? ''), models: models.length }
235
+ } catch (err) {
236
+ const aborted = err?.name === 'AbortError' || /abort/i.test(String(err?.message ?? ''))
237
+ return { ok: false, error: aborted ? 'timeout' : 'request-failed' }
238
+ } finally {
239
+ clearTimeout(timer)
240
+ }
241
+ }