@yeaft/webchat-agent 1.0.587 → 1.0.588

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.
Binary file
@@ -17,6 +17,6 @@
17
17
  </head>
18
18
  <body>
19
19
  <div id="app"></div>
20
- <script type="module" src="app.bundle.js?v=8e6f87a6"></script>
20
+ <script type="module" src="app.bundle.js?v=0afa7538"></script>
21
21
  </body>
22
22
  </html>
@@ -166,8 +166,19 @@ try {
166
166
  $env:PATH = "$NodeDir;$Prefix;$LocalBin;$OriginalPath"
167
167
  & $Node $NpmCli --version *> $null
168
168
  if ($LASTEXITCODE -ne 0) { Stop-Install 'the selected Node.js does not provide a working npm' }
169
- & $Node $NpmCli --prefix $Prefix --global=false install $Package pm2 --registry=$Registry --no-audit --no-fund --loglevel=error
170
- if ($LASTEXITCODE -ne 0) { Stop-Install 'npm could not install the Yeaft Agent' }
169
+ # Default to CPU native dependencies without changing the caller or service environment.
170
+ $OriginalCuda = Get-Item -LiteralPath 'Env:ONNXRUNTIME_NODE_INSTALL_CUDA' -ErrorAction SilentlyContinue
171
+ try {
172
+ if (-not $env:ONNXRUNTIME_NODE_INSTALL_CUDA) { $env:ONNXRUNTIME_NODE_INSTALL_CUDA = 'skip' }
173
+ & $Node $NpmCli --prefix $Prefix --global=false install $Package pm2 --registry=$Registry --no-audit --no-fund --loglevel=error
174
+ if ($LASTEXITCODE -ne 0) { Stop-Install 'npm could not install the Yeaft Agent' }
175
+ } finally {
176
+ if ($null -ne $OriginalCuda) {
177
+ Set-Item -LiteralPath 'Env:ONNXRUNTIME_NODE_INSTALL_CUDA' -Value $OriginalCuda.Value
178
+ } else {
179
+ Remove-Item -LiteralPath 'Env:ONNXRUNTIME_NODE_INSTALL_CUDA' -ErrorAction SilentlyContinue
180
+ }
181
+ }
171
182
 
172
183
  $Cli = Join-Path $Prefix 'node_modules\@yeaft\webchat-agent\cli.js'
173
184
  $Pm2 = Join-Path $Prefix 'node_modules\pm2\bin\pm2'
@@ -124,7 +124,9 @@ fi
124
124
  YEAFT_INSTALL_SERVER="$SERVER" "$NODE" -e 'try {const u=new URL(process.env.YEAFT_INSTALL_SERVER);if(!["ws:","wss:"].includes(u.protocol)||!u.hostname||u.username||u.password)process.exit(1)}catch{process.exit(1)}' >/dev/null 2>&1 || fail 'invalid server URL'
125
125
  NODE_DIR=$(dirname "$NODE")
126
126
  printf 'Installing the Yeaft Agent in %s...\n' "$PREFIX"
127
- PATH="$NODE_DIR:$PREFIX/bin:$PATH" "$NPM" --prefix "$PREFIX" --global install "$PACKAGE" \
127
+ # Default to CPU native dependencies; scope the policy to npm, not the Agent service.
128
+ ONNXRUNTIME_NODE_INSTALL_CUDA="${ONNXRUNTIME_NODE_INSTALL_CUDA:-skip}" \
129
+ PATH="$NODE_DIR:$PREFIX/bin:$PATH" "$NPM" --prefix "$PREFIX" --global install "$PACKAGE" \
128
130
  --registry="$REGISTRY" --no-audit --no-fund --loglevel=error || fail 'npm could not install the Yeaft Agent'
129
131
  CLI_JS=$PREFIX/lib/node_modules/@yeaft/webchat-agent/cli.js
130
132
  [ -f "$CLI_JS" ] || fail 'the installed package did not provide yeaft-agent'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.587",
3
+ "version": "1.0.588",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -82,5 +82,8 @@
82
82
  "tweetnacl-util": "^0.15.1",
83
83
  "uuid": "^11.1.1",
84
84
  "ws": "^8.21.0"
85
+ },
86
+ "optionalDependencies": {
87
+ "@huggingface/transformers": "3.8.1"
85
88
  }
86
89
  }
@@ -3,7 +3,7 @@ import ctx from '../../context.js';
3
3
  import { sendToServer } from '../../connection/buffer.js';
4
4
 
5
5
  /** Lazy Person transport. Only deployment-local environment supplies database credentials.
6
- * MongoDB is never contacted at ordinary Agent startup, nor configured by browser messages.
6
+ * Storage is lazy and instance-local by default; browser messages cannot choose a backend.
7
7
  */
8
8
  export function createPersonBridge({
9
9
  context = ctx, send = sendToServer, env = process.env,
@@ -33,14 +33,6 @@ export function createPersonBridge({
33
33
  return true;
34
34
  }
35
35
  const uri = env.YEAFT_PERSON_MONGODB_URI;
36
- if (!uri) {
37
- if (response.op === 'status') {
38
- await send({ ...response, ok: true, data: { configured: false, reason: 'mongodb_not_configured' } });
39
- } else {
40
- await send({ ...response, ok: false, errorCode: 'not_configured', error: 'Configure YEAFT_PERSON_MONGODB_URI on the Agent to enable the digital person' });
41
- }
42
- return true;
43
- }
44
36
  const agentId = context.agentId || context.AGENT_ID;
45
37
  if (!agentId || !context.CONFIG?.yeaftDir) throw new Error('identity');
46
38
  // Server+Agent is a storage boundary; a re-registration must not reuse the old runtime.
@@ -54,6 +46,8 @@ export function createPersonBridge({
54
46
  if (!servicePromise) {
55
47
  servicePromise = Promise.resolve().then(() => createService({
56
48
  uri,
49
+ storage: env.YEAFT_PERSON_STORAGE,
50
+ embedding: { enabled: env.YEAFT_PERSON_EMBEDDING !== 'off', allowDownload: env.YEAFT_PERSON_EMBEDDING_DOWNLOAD !== '0' },
57
51
  dbName: env.YEAFT_PERSON_MONGODB_DB || 'yeaft_person',
58
52
  namespace,
59
53
  yeaftDir: context.CONFIG.yeaftDir,
@@ -74,10 +68,11 @@ export function createPersonBridge({
74
68
  NOT_OPEN: 'Open the digital person first',
75
69
  STALE: 'Digital person state changed; refresh and try again',
76
70
  IDEMPOTENCY_CONFLICT: 'This message identifier already belongs to a different request',
71
+ STORAGE_MISMATCH: 'The configured storage differs from the existing Person authority; do not switch without a verified migration',
77
72
  UNSUPPORTED: 'This capability is not available in this digital person version',
78
73
  };
79
74
  const known = Object.hasOwn(safeErrors, error?.code);
80
- // A lost Mongo commit acknowledgement or response-send failure is not a
75
+ // A lost database commit acknowledgement or response-send failure is not a
81
76
  // definitive rejection. Preserve the command ID so an explicit retry deduplicates.
82
77
  await send({ ...response, ok: false, errorCode: known ? error.code.toLowerCase() : 'outcome_unknown',
83
78
  error: known ? safeErrors[error.code] : 'Digital person outcome is unknown; refresh and check Agent database/model configuration' });
@@ -3,7 +3,7 @@ import { bytes, digest, fail, identifier, object, page, text } from './contracts
3
3
  // Versioned read-only methods, not the Agent's full registry. No shell/filesystem/VP side effects.
4
4
  const entries = [
5
5
  { id: 'Think', version: 1, domain: 'cognition', description: 'Intrinsic deliberate reflection, reorganization, association and reconsideration.', instructions: 'Revisit supplied experiences and current judgments; separate report from hypothesis, propose new concepts or associations, test a counterexample, and record the explicit finding with uncertainty. It is fine to rest. This is a method, not another model call or external action.', args: {} },
6
- { id: 'Recall', version: 1, domain: 'memory', description: 'Read a bounded page of your own MongoDB messages or concepts.', instructions: 'args: {kind:"messages"|"concepts",query?:string,cursor?:string|null,limit?:1..5}. Literal text search, not a semantic or exhaustive search. Preserve nextCursor to reach older/lower-ranked records.', args: { kind: 'messages|concepts', query: 'optional literal search text', cursor: 'optional opaque cursor', limit: '1..5' } },
6
+ { id: 'Recall', version: 1, domain: 'memory', description: 'Recall a bounded page of your own long-term messages or concepts.', instructions: 'args: {kind:"messages"|"concepts",query?:string,cursor?:string|null,limit?:1..5}. SQLite uses local hybrid keyword/semantic recall when available; inspect retrieval mode, coverage and degradation. Legacy storage may use literal matching. Results are not exhaustive or authorization. Preserve nextCursor for the same query; local ranking cursors expire after 5 minutes or restart.', args: { kind: 'messages|concepts', query: 'optional memory search text', cursor: 'optional opaque cursor', limit: '1..5' } },
7
7
  { id: 'Skill.reconsider', version: 1, domain: 'method', description: 'Read-only method for revising a previous judgment.', instructions: 'Identify one prior claim, list a concrete counterexample or missing evidence, and decide whether to retain, qualify or revise it. Mark what remains unresolved. Do not present the counterexample as an observed event without evidence.', args: {} },
8
8
  { id: 'Skill.associate', version: 1, domain: 'method', description: 'Read-only method for new conceptual associations.', instructions: 'Compare two recalled concepts or experiences. Propose a typed relation and a new question or hypothetical scenario. Co-occurrence is not causation; imagination is not an experience. Keep useful disagreement instead of forcing agreement.', args: {} },
9
9
  ];
@@ -16,7 +16,8 @@ export const CAPABILITY_MAP = Object.freeze({
16
16
  export const catalogRevision = digest(entries);
17
17
  export class PersonCapabilities {
18
18
  constructor(repository, ownerId) { this.repository = repository; this.ownerId = ownerId; this.loaded = new Set(['Think']); }
19
- async execute({ id, args }) {
19
+ async execute({ id, args }, { signal } = {}) {
20
+ signal?.throwIfAborted();
20
21
  if (id === 'catalog.search') {
21
22
  object(args, ['query', 'cursor', 'limit'], []);
22
23
  const query = text(args.query ?? '', 200, true).toLowerCase();
@@ -44,9 +45,10 @@ export class PersonCapabilities {
44
45
  text(args.query ?? '', 200, true);
45
46
  const limit = args.limit ?? 3;
46
47
  if (!Number.isInteger(limit) || limit < 1 || limit > 5) fail('INVALID_REQUEST');
47
- const cursor = args.kind === 'messages' ? page({ cursor: args.cursor, limit }).cursor : args.cursor;
48
- if (args.kind === 'concepts' && cursor != null) identifier(cursor);
49
- const result = await this.repository.recall(this.ownerId, { ...args, cursor, limit });
48
+ const localCursor = typeof args.cursor === 'string' && /^lm:[a-f0-9]{32}:\d{1,6}$/.test(args.cursor);
49
+ const cursor = args.kind === 'messages' && !localCursor ? page({ cursor: args.cursor, limit }).cursor : args.cursor;
50
+ if (cursor != null && (localCursor || args.kind === 'concepts')) identifier(cursor);
51
+ const result = await this.repository.recall(this.ownerId, { ...args, cursor, limit }, { signal });
50
52
  // Pages contain complete accepted records; context assembly may omit whole records later.
51
53
  if (bytes(result) > 60000) fail('CONTEXT_LIMIT');
52
54
  return { ...result, kind: args.kind, version: entry.version };
@@ -3,8 +3,9 @@ import { createHash } from 'node:crypto';
3
3
  // 只允许应用层显式产物;不要求或保存 provider 的隐藏推理。
4
4
  export const LIMITS = Object.freeze({ inputBytes: 8192, outputBytes: 65536, contextBytes: 65536, calls: 4, timeoutMs: 120000, leaseMs: 15000 });
5
5
  const ERRORS = {
6
- INVALID_REQUEST: 'Invalid digital person request.', NOT_CONFIGURED: 'MongoDB is not configured for digital person.',
7
- STORAGE_UNAVAILABLE: 'Digital person storage is unavailable; a transaction-capable MongoDB replica set is required.',
6
+ INVALID_REQUEST: 'Invalid digital person request.', NOT_CONFIGURED: 'Digital person storage configuration is missing.',
7
+ STORAGE_UNAVAILABLE: 'Digital person storage is unavailable. Check instance storage and database configuration.',
8
+ STORAGE_MISMATCH: 'Person storage is already bound to another backend; a verified migration is required.',
8
9
  NOT_OPEN: 'Open your digital person first.', BUSY: 'The digital person is already active.',
9
10
  IDEMPOTENCY_CONFLICT: 'This clientMessageId was already used for a different request.',
10
11
  STALE: 'The activity no longer owns the current person revision.', INVALID_PROPOSAL: 'The model returned an invalid cognitive proposal.',
@@ -0,0 +1,82 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { mkdir, open, rename, stat, unlink } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ async function isComplete(file, { size, sha256 }) {
7
+ try {
8
+ if ((await stat(file)).size !== size) return false;
9
+ const hash = createHash('sha256');
10
+ for await (const chunk of createReadStream(file)) hash.update(chunk);
11
+ return hash.digest('hex') === sha256;
12
+ } catch (error) {
13
+ if (error.code === 'ENOENT') return false;
14
+ throw error;
15
+ }
16
+ }
17
+
18
+ async function syncDirectory(dir) {
19
+ // Windows does not support opening directories for fsync. A lost directory
20
+ // entry after power failure is still a cache miss, never an accepted partial file.
21
+ if (process.platform === 'win32') return;
22
+ const handle = await open(dir, 'r');
23
+ try { await handle.sync(); } finally { await handle.close(); }
24
+ }
25
+
26
+ async function publish(file, url, expected) {
27
+ const dir = path.dirname(file);
28
+ await mkdir(dir, { recursive: true, mode: 0o700 });
29
+ const temporary = `${file}.${process.pid}.${randomUUID()}.partial`;
30
+ let handle;
31
+ try {
32
+ const response = await fetch(url);
33
+ if (response.status !== 200 || !response.body) throw new Error('model_download_failed');
34
+ handle = await open(temporary, 'wx', 0o600);
35
+ const hash = createHash('sha256');
36
+ let size = 0;
37
+ // Backpressure keeps memory bounded to stream chunks, including the ONNX file.
38
+ for await (const chunk of response.body) {
39
+ size += chunk.byteLength;
40
+ if (size > expected.size) throw new Error('model_integrity_failed');
41
+ hash.update(chunk);
42
+ await handle.writeFile(chunk);
43
+ }
44
+ if (size !== expected.size || hash.digest('hex') !== expected.sha256) throw new Error('model_integrity_failed');
45
+ await handle.sync();
46
+ await handle.close();
47
+ handle = null;
48
+ // Concurrent publishers have the same pinned digest. Never unlink the final
49
+ // path: an invalid reader or failed writer must not remove another one's repair.
50
+ await rename(temporary, file);
51
+ await syncDirectory(dir);
52
+ } finally {
53
+ await handle?.close();
54
+ await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Prepare an instance-owned, immutable model snapshot before Transformers sees it.
60
+ * `artifacts` is a trusted manifest of filename -> { size, sha256 }, pinned with
61
+ * `model` and its commit `revision`. The hashes are the completion markers: legacy
62
+ * complete files work offline, partial/corrupt files are misses, and SIGKILL leaves
63
+ * only ignored unique .partial files. No lock or mutable sidecar can become stale.
64
+ * Returns a local directory; inference must use it with remote access disabled.
65
+ */
66
+ export async function prepareModelCache({ yeaftDir, model, revision, artifacts, allowDownload = true }) {
67
+ if (!/^[\w.-]+\/[\w.-]+$/.test(model) || model.split('/').some(p => p === '.' || p === '..') || !/^[a-f0-9]{40}$/.test(revision)) {
68
+ throw new Error('invalid_model_identity');
69
+ }
70
+ const dir = path.resolve(yeaftDir, 'person', 'models', model, revision);
71
+ for (const [name, expected] of Object.entries(artifacts)) {
72
+ if (!name.split('/').every(p => /^[\w.-]+$/.test(p) && p !== '.' && p !== '..') ||
73
+ !Number.isSafeInteger(expected.size) || expected.size <= 0 || !/^[a-f0-9]{64}$/.test(expected.sha256)) {
74
+ throw new Error('invalid_model_artifact');
75
+ }
76
+ const file = path.join(dir, name);
77
+ if (await isComplete(file, expected)) continue;
78
+ if (!allowDownload) throw new Error('model_cache_incomplete');
79
+ await publish(file, `https://huggingface.co/${model}/resolve/${revision}/${name}`, expected);
80
+ }
81
+ return dir;
82
+ }
@@ -0,0 +1,54 @@
1
+ import { prepareModelCache } from './local-memory-cache.js';
2
+
3
+ export const LOCAL_MODEL = 'Xenova/multilingual-e5-small';
4
+ export const LOCAL_REVISION = '761b726dd34fb83930e26aab4e9ac3899aa1fa78';
5
+ // SHA-256 of the exact revision's files (LFS oid for tokenizer/ONNX). Update this
6
+ // manifest with LOCAL_REVISION, never from mutable download response metadata.
7
+ export const LOCAL_ARTIFACTS = Object.freeze({
8
+ 'config.json': Object.freeze({ size: 658, sha256: 'cb99455288675345e1a4f411438d5d0adbba5fbd3a67ea4fb03c015433b996c1' }),
9
+ 'tokenizer_config.json': Object.freeze({ size: 443, sha256: 'a1d6bc8734a6f635dc158508bef000f8e2e5a759c7d92f984b2c86e5ff53425b' }),
10
+ 'tokenizer.json': Object.freeze({ size: 17082730, sha256: '0b44a9d7b51c3c62626640cda0e2c2f70fdacdc25bbbd68038369d14ebdf4c39' }),
11
+ 'onnx/model_quantized.onnx': Object.freeze({ size: 118308185, sha256: 'f80102d3f2a1229f387d3c81909990d8945513e347b0eab049f7de3c6f98c193' }),
12
+ });
13
+ export const VECTOR_DIMENSION = 384;
14
+ export const CHUNK_SIZE = 320;
15
+ export const CHUNK_OVERLAP = 64;
16
+ // Version the complete space, not just the model name. A change invalidates derived chunks.
17
+ export const MODEL_FINGERPRINT = `${LOCAL_MODEL}@${LOCAL_REVISION}:q8:mean:l2:384:e5-prefix:codepoints-${CHUNK_SIZE}-${CHUNK_OVERLAP}:segmenter-v1`;
18
+
19
+ /** Worker-only lazy adapter. Only model artifacts use the network; text goes to local ONNX. */
20
+ export function createEmbedding({ yeaftDir, allowDownload = true, threads = 2 } = {}) {
21
+ let extractor;
22
+ async function load() {
23
+ if (extractor) return extractor;
24
+ const modelDir = await prepareModelCache({ yeaftDir, model: LOCAL_MODEL, revision: LOCAL_REVISION, artifacts: LOCAL_ARTIFACTS, allowDownload });
25
+ const { pipeline, env } = await import('@huggingface/transformers');
26
+ // Never let Transformers' non-atomic FileCache write model artifacts. The
27
+ // validated local snapshot is complete, including when restarting offline.
28
+ env.allowRemoteModels = false;
29
+ env.allowLocalModels = true;
30
+ env.localModelPath = '';
31
+ env.useBrowserCache = false;
32
+ env.useCustomCache = true;
33
+ env.customCache = { async match() {}, async put() { throw new Error('unexpected_model_cache_write'); } };
34
+ // 3.8.1 also uses this flag to pass the ONNX *path* to the native runtime.
35
+ // The custom cache above prevents its unsafe FileCache from being instantiated.
36
+ env.useFSCache = true;
37
+ extractor = await pipeline('feature-extraction', modelDir, {
38
+ revision: LOCAL_REVISION, dtype: 'q8', device: 'cpu',
39
+ local_files_only: true,
40
+ session_options: { intraOpNumThreads: Math.max(1, Math.min(4, threads)), interOpNumThreads: 1 },
41
+ });
42
+ return extractor;
43
+ }
44
+ return {
45
+ async embed(texts, type) {
46
+ const model = await load();
47
+ const output = await model(texts.map(text => `${type === 'query' ? 'query' : 'passage'}: ${text}`), {
48
+ pooling: 'mean', normalize: true, truncation: true, max_length: 512,
49
+ });
50
+ return output.tolist();
51
+ },
52
+ async close() { if (extractor) await extractor.dispose(); extractor = null; },
53
+ };
54
+ }
@@ -0,0 +1,40 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import { createEmbedding } from './local-memory-embedding.js';
3
+
4
+ // ONNX's native addon is not reliably reloadable in replacement Node threads.
5
+ // Keep it in this disposable process; SQLite/tokenization/ranking use a worker
6
+ // thread. The Agent supervises the whole process, including blocked native work.
7
+ let worker;
8
+ let embedder;
9
+ let closing = false;
10
+ process.on('message', message => {
11
+ if (message.init) {
12
+ if (worker) return;
13
+ const options = message.init;
14
+ worker = new Worker(new URL('./local-memory-worker.js', import.meta.url), { workerData: options });
15
+ worker.on('message', async response => {
16
+ if (response.embeddingRequest) {
17
+ const { id, texts, type } = response.embeddingRequest;
18
+ try {
19
+ embedder ??= createEmbedding({ ...options.embedding, yeaftDir: options.yeaftDir });
20
+ const value = await embedder.embed(texts, type);
21
+ worker.postMessage({ embeddingResult: { id, value } });
22
+ } catch {
23
+ worker.postMessage({ embeddingResult: { id, error: true } });
24
+ }
25
+ } else if (response.closed) {
26
+ closing = true;
27
+ try { await embedder?.close(); }
28
+ finally { process.disconnect(); }
29
+ } else {
30
+ process.send?.(response);
31
+ }
32
+ });
33
+ worker.on('error', () => process.exit(1));
34
+ worker.on('exit', code => { if (!closing || code) process.exit(1); });
35
+ return;
36
+ }
37
+ worker?.postMessage(message);
38
+ });
39
+ // Agent crash/IPC teardown must not leave the model process or its thread alive.
40
+ process.on('disconnect', () => process.exit(0));
@@ -0,0 +1,231 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { mkdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { parentPort, workerData } from 'node:worker_threads';
5
+ import { CHUNK_SIZE, CHUNK_OVERLAP, MODEL_FINGERPRINT, VECTOR_DIMENSION } from './local-memory-embedding.js';
6
+
7
+ const { yeaftDir, namespace, embedding = {}, embeddingModule, maxVectorChunks, maxEmbedChunksPerRecall, candidateLimit } = workerData;
8
+ const fingerprint = embeddingModule ? `${MODEL_FINGERPRINT}:injected:${embeddingModule}:${embedding.fingerprint ?? 'test'}` : MODEL_FINGERPRINT;
9
+ let db;
10
+ let embedder;
11
+ let embeddingId = 0;
12
+ const embeddingRequests = new Map();
13
+ function hostEmbedding(texts, type) {
14
+ return new Promise((resolve, reject) => {
15
+ const id = ++embeddingId;
16
+ embeddingRequests.set(id, { resolve, reject });
17
+ parentPort.postMessage({ embeddingRequest: { id, texts, type } });
18
+ });
19
+ }
20
+ const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
21
+
22
+ function tokens(text) {
23
+ return [...segmenter.segment(text.normalize('NFKC').toLowerCase())].filter(s => s.isWordLike).map(s => s.segment);
24
+ }
25
+ function chunks(text) {
26
+ const points = Array.from(text);
27
+ const result = [];
28
+ for (let start = 0; start < points.length; start += CHUNK_SIZE - CHUNK_OVERLAP) {
29
+ result.push(points.slice(start, start + CHUNK_SIZE).join(''));
30
+ if (start + CHUNK_SIZE >= points.length) break;
31
+ }
32
+ return result;
33
+ }
34
+ function transaction(fn) {
35
+ db.exec('BEGIN IMMEDIATE');
36
+ try { const result = fn(); db.exec('COMMIT'); return result; }
37
+ catch (error) { db.exec('ROLLBACK'); throw error; }
38
+ }
39
+ function open() {
40
+ if (db) return;
41
+ mkdirSync(path.join(yeaftDir, 'person'), { recursive: true, mode: 0o700 });
42
+ db = new DatabaseSync(path.join(yeaftDir, 'person', 'recall.db'));
43
+ try {
44
+ // Some supported early Node 22 builds omit FTS5. Keep authority usable and
45
+ // report an explicit literal-only fallback; never start a model on this path.
46
+ db.exec('CREATE VIRTUAL TABLE temp.person_fts_probe USING fts5(terms); DROP TABLE temp.person_fts_probe;');
47
+ } catch (error) {
48
+ db.close(); db = null;
49
+ if (/no such module: fts5/i.test(error.message)) error.code = 'FTS_UNAVAILABLE';
50
+ throw error;
51
+ }
52
+ db.exec(`PRAGMA busy_timeout=2000; PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;
53
+ CREATE TABLE IF NOT EXISTS spaces(namespace TEXT PRIMARY KEY, fingerprint TEXT NOT NULL);
54
+ CREATE TABLE IF NOT EXISTS checkpoints(namespace TEXT NOT NULL, owner TEXT NOT NULL, seq INTEGER NOT NULL, PRIMARY KEY(namespace,owner));
55
+ CREATE TABLE IF NOT EXISTS chunks(
56
+ rowid INTEGER PRIMARY KEY, namespace TEXT NOT NULL, owner TEXT NOT NULL, kind TEXT NOT NULL,
57
+ id TEXT NOT NULL, revision INTEGER NOT NULL, part INTEGER NOT NULL, seq INTEGER NOT NULL,
58
+ text TEXT NOT NULL, terms TEXT NOT NULL, vector BLOB,
59
+ UNIQUE(namespace,owner,kind,id,part));
60
+ CREATE INDEX IF NOT EXISTS recall_scope ON chunks(namespace,owner,kind,seq DESC);
61
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunk_fts USING fts5(terms, content='chunks', content_rowid='rowid', tokenize='unicode61');
62
+ CREATE TRIGGER IF NOT EXISTS chunks_insert AFTER INSERT ON chunks BEGIN
63
+ INSERT INTO chunk_fts(rowid,terms) VALUES (new.rowid,new.terms); END;
64
+ CREATE TRIGGER IF NOT EXISTS chunks_delete AFTER DELETE ON chunks BEGIN
65
+ INSERT INTO chunk_fts(chunk_fts,rowid,terms) VALUES ('delete',old.rowid,old.terms); END;`);
66
+ }
67
+ function ensureSpace() {
68
+ transaction(() => {
69
+ const previous = db.prepare('SELECT fingerprint FROM spaces WHERE namespace=?').get(namespace);
70
+ if (!previous || previous.fingerprint !== fingerprint) {
71
+ db.prepare('DELETE FROM chunks WHERE namespace=?').run(namespace);
72
+ db.prepare('DELETE FROM checkpoints WHERE namespace=?').run(namespace);
73
+ db.prepare('INSERT OR REPLACE INTO spaces VALUES (?,?)').run(namespace, fingerprint);
74
+ }
75
+ });
76
+ }
77
+ function checkpoint(owner) {
78
+ return db.prepare('SELECT seq FROM checkpoints WHERE namespace=? AND owner=?').get(namespace, owner)?.seq ?? 0;
79
+ }
80
+ function assertSpace() {
81
+ if (db.prepare('SELECT fingerprint FROM spaces WHERE namespace=?').get(namespace)?.fingerprint !== fingerprint) throw new Error('space_changed');
82
+ }
83
+ function apply(owner, items, lastSeq) {
84
+ return transaction(() => {
85
+ assertSpace();
86
+ const before = checkpoint(owner);
87
+ const remove = db.prepare('DELETE FROM chunks WHERE namespace=? AND owner=? AND kind=? AND id=?');
88
+ const insert = db.prepare('INSERT INTO chunks(namespace,owner,kind,id,revision,part,seq,text,terms) VALUES (?,?,?,?,?,?,?,?,?)');
89
+ for (const item of items) {
90
+ if (item.seq <= before) continue;
91
+ if (!['messages', 'concepts'].includes(item.kind) || !Number.isSafeInteger(item.seq) || !Number.isSafeInteger(item.revision) || typeof item.id !== 'string') throw new Error('invalid_journal');
92
+ remove.run(namespace, owner, item.kind, item.id);
93
+ if (item.record) {
94
+ const text = item.kind === 'messages' ? item.record.text : item.record.statement;
95
+ if (typeof text !== 'string') throw new Error('invalid_record');
96
+ for (const [part, chunk] of chunks(text).entries()) insert.run(namespace, owner, item.kind, item.id, item.revision, part, item.seq, chunk, tokens(chunk).join(' '));
97
+ }
98
+ }
99
+ if (!Number.isSafeInteger(lastSeq) || lastSeq < before || items.some(item => item.seq > lastSeq)) throw new Error('invalid_checkpoint');
100
+ db.prepare('INSERT INTO checkpoints VALUES (?,?,?) ON CONFLICT(namespace,owner) DO UPDATE SET seq=excluded.seq').run(namespace, owner, lastSeq);
101
+ return { checkpoint: lastSeq };
102
+ });
103
+ }
104
+ function vector(value) {
105
+ if ((!Array.isArray(value) && !ArrayBuffer.isView(value)) || value.length !== VECTOR_DIMENSION || !Array.from(value).every(Number.isFinite)) throw new Error('invalid_embedding');
106
+ const norm = Math.sqrt(Array.from(value).reduce((sum, n) => sum + n * n, 0));
107
+ if (!norm) throw new Error('invalid_embedding');
108
+ return Float32Array.from(value, n => n / norm);
109
+ }
110
+ function encode(v) {
111
+ const blob = Buffer.alloc(VECTOR_DIMENSION * 4);
112
+ v.forEach((n, i) => blob.writeFloatLE(n, i * 4));
113
+ return blob;
114
+ }
115
+ async function embed(texts, type) {
116
+ // Trusted deterministic fixtures run in this thread. Production inference runs
117
+ // in its disposable host process to avoid ONNX addon thread reload failures.
118
+ if (embeddingModule && !embedder) {
119
+ const module = await import(embeddingModule);
120
+ embedder = module.createEmbedding({ ...embedding, yeaftDir });
121
+ }
122
+ const results = embedder ? await embedder.embed(texts, type) : await hostEmbedding(texts, type);
123
+ if (!Array.isArray(results) || results.length !== texts.length) throw new Error('invalid_embedding');
124
+ return results.map(vector);
125
+ }
126
+ async function search(owner, kind, query) {
127
+ const reasons = [];
128
+ // Read space identity and vectors in one transaction, including when multiple
129
+ // local controllers use the same DB. Never accept vectors from an ABA space switch.
130
+ const { totalChunks, eligible } = transaction(() => {
131
+ assertSpace();
132
+ const totalChunks = db.prepare('SELECT count(*) AS n FROM chunks WHERE namespace=? AND owner=? AND kind=?').get(namespace, owner, kind).n;
133
+ // Exact cosine is deliberately bounded to the newest N chunks. FTS covers all indexed text.
134
+ const eligible = db.prepare('SELECT rowid,id,revision,seq,text,vector FROM chunks WHERE namespace=? AND owner=? AND kind=? ORDER BY seq DESC,id,part LIMIT ?').all(namespace, owner, kind, maxVectorChunks);
135
+ return { totalChunks, eligible };
136
+ });
137
+ let queryVector;
138
+ if (embedding.enabled !== false) {
139
+ try {
140
+ queryVector = (await embed([query], 'query'))[0];
141
+ const pending = eligible.filter(row => !row.vector).slice(0, maxEmbedChunksPerRecall);
142
+ for (let start = 0; start < pending.length; start += 8) {
143
+ const batch = pending.slice(start, start + 8);
144
+ const values = await embed(batch.map(row => row.text), 'passage');
145
+ transaction(() => {
146
+ assertSpace();
147
+ const update = db.prepare('UPDATE chunks SET vector=? WHERE rowid=? AND namespace=? AND owner=? AND kind=? AND id=? AND revision=? AND seq=?');
148
+ batch.forEach((row, i) => {
149
+ row.vector = encode(values[i]);
150
+ update.run(row.vector, row.rowid, namespace, owner, kind, row.id, row.revision, row.seq);
151
+ });
152
+ });
153
+ }
154
+ } catch {
155
+ queryVector = null;
156
+ reasons.push('embedding_unavailable');
157
+ // The controller replaces this process after delivering keyword results:
158
+ // ONNX itself can cache a rejected native-session initialization promise.
159
+ }
160
+ } else reasons.push('semantic_disabled');
161
+ assertSpace();
162
+ const terms = [...new Set(tokens(query))].slice(0, 32).map(term => `"${term.replaceAll('"', '""')}"`).join(' OR ');
163
+ // Group before limiting parents: many matching chunks must not crowd out a
164
+ // shorter record. MATERIALIZED keeps FTS5 bm25 in its supported row context.
165
+ const keyword = terms ? db.prepare(`WITH matches AS MATERIALIZED (
166
+ SELECT c.id,c.revision,bm25(chunk_fts) AS rank FROM chunk_fts JOIN chunks c ON c.rowid=chunk_fts.rowid
167
+ WHERE chunk_fts MATCH ? AND c.namespace=? AND c.owner=? AND c.kind=?)
168
+ SELECT id,revision,min(rank) AS rank FROM matches GROUP BY id,revision ORDER BY rank,id LIMIT ?`).all(terms, namespace, owner, kind, candidateLimit) : [];
169
+ let vectorScannedChunks = 0;
170
+ const semantic = [];
171
+ if (queryVector) for (const row of eligible) {
172
+ if (!row.vector || row.vector.length !== VECTOR_DIMENSION * 4) continue;
173
+ const bytes = Buffer.from(row.vector);
174
+ let cosine = 0;
175
+ for (let i = 0; i < VECTOR_DIMENSION; i++) cosine += queryVector[i] * bytes.readFloatLE(i * 4);
176
+ vectorScannedChunks++;
177
+ if (Number.isFinite(cosine)) semantic.push({ id: row.id, revision: row.revision, cosine });
178
+ }
179
+ semantic.sort((a, b) => b.cosine - a.cosine || a.id.localeCompare(b.id));
180
+ const ranks = new Map();
181
+ // Deduplicate each channel BEFORE RRF: a verbose record does not receive more votes.
182
+ for (const channel of [keyword, semantic]) {
183
+ const seen = new Set();
184
+ for (const ref of channel) {
185
+ if (seen.has(ref.id)) continue;
186
+ seen.add(ref.id);
187
+ const current = ranks.get(ref.id) ?? { id: ref.id, revision: ref.revision, score: 0 };
188
+ current.score += 1 / (60 + seen.size);
189
+ ranks.set(ref.id, current);
190
+ if (seen.size >= candidateLimit) break;
191
+ }
192
+ }
193
+ const refs = [...ranks.values()].sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, candidateLimit).map(({ id, revision }) => ({ id, revision }));
194
+ const semanticUsed = Boolean(queryVector && vectorScannedChunks);
195
+ if (queryVector && vectorScannedChunks < totalChunks) reasons.push('semantic_coverage_limited');
196
+ return { refs, resetWorker: reasons.includes('embedding_unavailable'), retrieval: {
197
+ mode: semanticUsed ? 'hybrid' : 'keyword', semantic: semanticUsed, degraded: reasons.some(r => r !== 'semantic_disabled'), reasons,
198
+ fingerprint, ranking: 'rrf-60', bounded: true, candidateLimit,
199
+ coverage: { totalChunks, vectorEligibleChunks: eligible.length, vectorScannedChunks, maxVectorChunks, maxEmbedChunksPerRecall },
200
+ } };
201
+ }
202
+ async function dispatch({ op, owner, ...args }) {
203
+ if (op === 'close') { await embedder?.close?.(); db?.close(); db = null; return null; }
204
+ if (typeof owner !== 'string' || !owner.length) throw new Error('invalid_owner');
205
+ open();
206
+ if (op === 'checkpoint') { ensureSpace(); return checkpoint(owner); }
207
+ assertSpace();
208
+ if (op === 'apply') return apply(owner, args.items, args.lastSeq);
209
+ if (op === 'search') return search(owner, args.kind, args.query);
210
+ throw new Error('invalid_operation');
211
+ }
212
+ // The controller issues one RPC at a time; serialize defensively inside the worker too.
213
+ let chain = Promise.resolve();
214
+ parentPort.on('message', message => {
215
+ // Results must bypass the serialized RPC chain, whose search is awaiting them.
216
+ if (message.embeddingResult) {
217
+ const { id, value, error } = message.embeddingResult;
218
+ const pending = embeddingRequests.get(id);
219
+ embeddingRequests.delete(id);
220
+ if (error) pending?.reject(new Error('embedding_unavailable'));
221
+ else pending?.resolve(value);
222
+ return;
223
+ }
224
+ chain = chain.then(async () => {
225
+ try {
226
+ const value = await dispatch(message);
227
+ if (message.op === 'close') { parentPort.postMessage({ closed: true }); return; }
228
+ parentPort.postMessage({ id: message.id, value });
229
+ } catch (error) { parentPort.postMessage({ id: message.id, error: { code: error.code || 'INDEX_FAILURE' } }); }
230
+ });
231
+ });