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.
- package/LICENSE.txt +21 -0
- package/README.md +194 -2
- package/condense/chunk.mjs +258 -0
- package/condense/ivr.mjs +104 -0
- package/contextinjector-webui/README.md +43 -0
- package/contextinjector-webui/lib/client.js +3034 -0
- package/contextinjector-webui/lib/index.mjs +1565 -0
- package/contextinjector-webui/lib/trace-read.mjs +180 -0
- package/contextinjector-webui/package.json +21 -0
- package/cordis.patch.yml +25 -0
- package/package.json +66 -4
- package/toolfold/toolfold.mjs +513 -0
- package/web/contextinjector.mjs +3139 -0
- package/web/extreason.mjs +1107 -0
- package/web/ledger-online.mjs +229 -0
- package/web/logcompiler.mjs +369 -0
- package/web/memory-durability.mjs +431 -0
- package/web/memory-janitor.mjs +551 -0
- package/web/memory-ledger-core.mjs +1331 -0
- package/web/memory-ledger.mjs +836 -0
- package/web/memory-observations.mjs +430 -0
- package/web/memory-online-bridge.mjs +688 -0
- package/web/memory-proposal-store.mjs +1054 -0
- package/web/memory-proposals.mjs +2306 -0
- package/web/memory-trace.mjs +533 -0
- package/web/memory-vec.mjs +241 -0
- package/web/memory.mjs +5801 -0
|
@@ -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
|
+
}
|