@jacebenson/jsn 3.2.0 → 3.3.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/bin/jsn.js CHANGED
@@ -1,5 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { cli } from '../src/cli.js';
3
+ // Suppress the node:sqlite experimental warning on Node 22.x.
4
+ // SQLite is stable in Node 24+. The patch must be applied before any
5
+ // module imports node:sqlite, so we use a dynamic import after patching.
6
+ const originalEmitWarning = process.emitWarning;
7
+ process.emitWarning = function emitWarning(warning, ...args) {
8
+ const message = typeof warning === 'string'
9
+ ? warning
10
+ : (warning?.message || '');
11
+ if (message.includes('SQLite')) {
12
+ return;
13
+ }
14
+ return originalEmitWarning.apply(this, [warning, ...args]);
15
+ };
4
16
 
5
- cli.parse();
17
+ import('../src/cli.js').then((m) => m.cli.parse());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,10 +8,10 @@
8
8
  },
9
9
  "main": "src/cli.js",
10
10
  "scripts": {
11
- "test": "node --test $(find test -name '*.test.js')",
11
+ "test": "NODE_OPTIONS=--require=./suppress-warnings.cjs node --test $(find test -name '*.test.js')",
12
12
  "lint": "npx eslint src/ bin/ test/",
13
13
  "postinstall": "node scripts/postinstall.mjs",
14
- "start": "node bin/jsn.js",
14
+ "start": "NODE_OPTIONS=--require=./suppress-warnings.cjs node bin/jsn.js",
15
15
  "tag-reminder": "node scripts/tag-reminder.js",
16
16
  "release": "node scripts/release.js"
17
17
  },
@@ -24,13 +24,14 @@
24
24
  "author": "Jace Benson",
25
25
  "license": "MIT",
26
26
  "engines": {
27
- "node": ">=18.0.0"
27
+ "node": ">=22.5.0"
28
28
  },
29
29
  "dependencies": {
30
30
  "@inquirer/core": "^10.1.0",
31
31
  "@inquirer/prompts": "^7.5.1",
32
32
  "chalk": "^5.4.1",
33
33
  "cli-table3": "^0.6.5",
34
+ "gray-matter": "^4.0.3",
34
35
  "yargs": "^17.7.2"
35
36
  },
36
37
  "devDependencies": {
@@ -40,6 +41,7 @@
40
41
  },
41
42
  "files": [
42
43
  "bin/jsn.js",
44
+ "suppress-warnings.cjs",
43
45
  "src/",
44
46
  "skills/",
45
47
  "scripts/",
@@ -54,9 +54,10 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
54
54
 
55
55
  1. **Specific commands** — `jsn flows`, `jsn rules`, `jsn catalogitems`, etc.
56
56
  2. **`jsn records --table <name>`** — generic CRUD on any table
57
- 3. **`jsn rest`** — raw Table API escape hatch
58
- 4. **`jsn eval`** — ⚠️ **LAST RESORT ONLY**
59
- 5. **Ask the human** — if none of the above work
57
+ 3. **`jsn docs search <query>`** — offline ServiceNow documentation search (no instance needed)
58
+ 4. **`jsn rest`** — raw Table API escape hatch
59
+ 5. **`jsn eval`** — ⚠️ **LAST RESORT ONLY**
60
+ 6. **Ask the human** — if none of the above work
60
61
 
61
62
  ## Key Commands
62
63
 
@@ -69,6 +70,7 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
69
70
  | **UX** | `forms` (section/element layout), `lists`, `clientscripts`, `uipolicies` |
70
71
  | **Data** | `tables`, `columns`, `includes`, `logs`, `properties`, `records` |
71
72
  | **Config** | `setup`, `auth`, `profiles`, `updatesets`, `scopes` |
73
+ | **Docs** | `docs sync`, `docs status`, `docs search`, `docs serve` |
72
74
  | **Developer** | `eval`, `rest`, `skill`, `version` |
73
75
 
74
76
  ### `jsn rest` examples
@@ -84,6 +86,32 @@ jsn rest --table incident --sys-id abc123... --json
84
86
  jsn rest "/api/now/table/incident?sysparm_limit=3" --json
85
87
  ```
86
88
 
89
+ ### `jsn docs` — offline documentation search
90
+
91
+ No ServiceNow instance required. Searches the full ServiceNow docs locally with FTS5 + semantic hybrid search.
92
+
93
+ ```bash
94
+ # First run: download docs and build the index (~45k files, ~3-5 min)
95
+ jsn docs sync
96
+
97
+ # Check state
98
+ jsn docs status
99
+
100
+ # Search (keyword + semantic hybrid)
101
+ jsn docs search "GlideRecord query" --limit 5 --json
102
+
103
+ # Filter by bundle
104
+ jsn docs search "REST API" --bundle it-service-management --json
105
+
106
+ # Start the web UI
107
+ jsn docs serve
108
+
109
+ # Share on your network
110
+ jsn docs serve --expose
111
+ ```
112
+
113
+ **Re-running `jsn docs sync`** does a smart incremental refresh — pulls latest docs and only rebuilds changed files (seconds, not minutes).
114
+
87
115
  ## Troubleshooting Field Behavior
88
116
 
89
117
  Check order (priority):
package/src/cli.js CHANGED
@@ -27,8 +27,9 @@ import { groupRolesCmd } from './commands/grouproles.js';
27
27
  import { ticketsCmd } from './commands/tickets.js';
28
28
  import { versionCmd } from './commands/version.js';
29
29
  import { devCmd } from './commands/dev.js';
30
- import { skillCmd } from './commands/skill.js';
30
+ import { skillCmd, checkSkill } from './commands/skill.js';
31
31
  import { getVersion, checkLatest } from './commands/version.js';
32
+ import { docsCmd } from './commands/docs/docs.js';
32
33
 
33
34
  // Dev subcommands promoted to root level for progressive disclosure
34
35
  import {
@@ -178,7 +179,7 @@ export function buildCLI() {
178
179
  // Guard mutation commands when no instance configured
179
180
  (argv) => {
180
181
  const cmd = (argv._[0] || '').toString();
181
- if (!cmd || ['help', 'version', 'completion', 'setup', 'auth', 'profiles', 'skill'].includes(cmd)) {
182
+ if (!cmd || ['help', 'version', 'completion', 'setup', 'auth', 'profiles', 'skill', 'docs'].includes(cmd)) {
182
183
  return;
183
184
  }
184
185
  // Check for --instance override
@@ -204,10 +205,15 @@ export function buildCLI() {
204
205
 
205
206
  // Daily npm version check (fire-and-forget, non-blocking)
206
207
  // Checks npm for newer jsn releases, at most once per 24 hours.
207
- const skipNpmCheck = ['help', 'version', 'completion', 'skill'].includes(cmd)
208
+ const skipNpmCheck = ['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)
208
209
  || process.env.JSN_NO_VERSION_CHECK === '1'
209
210
  || argv.json
210
211
  || argv.quiet;
212
+
213
+ // Auto-check skill on every command (fire-and-forget, non-blocking)
214
+ const skipSkillCheck = ['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)
215
+ || process.env.JSN_NO_SKILL_CHECK === '1'
216
+ || argv['no-skill-check']
211
217
  if (!skipNpmCheck) {
212
218
  const cacheFile = path.join(globalConfigDir(), '.last-npm-check');
213
219
  let shouldCheck = true;
@@ -239,7 +245,7 @@ export function buildCLI() {
239
245
  }
240
246
 
241
247
  // Print context header for interactive terminals
242
- if (!['help', 'version', 'completion', 'skill'].includes(cmd)) {
248
+ if (!['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)) {
243
249
  await argv.app.printContextHeader(argv);
244
250
  }
245
251
  })
@@ -322,6 +328,7 @@ export function buildCLI() {
322
328
  .command(evalCmd(wrap))
323
329
  .command(restCmd(wrap))
324
330
  .command(skillCmd(wrap))
331
+ .command(docsCmd(wrap))
325
332
  .command(versionCmd(wrap))
326
333
  // Legacy
327
334
  .command(devCmd(wrap))
@@ -0,0 +1,140 @@
1
+ // Docs DB path resolution and schema helpers.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { DatabaseSync } from 'node:sqlite';
7
+
8
+ const DOCS_DIR_NAME = 'servicenow-cli/docs';
9
+
10
+ function xdgCacheHome() {
11
+ if (process.env.XDG_CACHE_HOME) return process.env.XDG_CACHE_HOME;
12
+ return path.join(os.homedir(), '.cache');
13
+ }
14
+
15
+ export function getDocsCacheDir() {
16
+ return path.join(xdgCacheHome(), DOCS_DIR_NAME);
17
+ }
18
+
19
+ export function getDocsDbPath() {
20
+ return path.join(getDocsCacheDir(), 'docs.db');
21
+ }
22
+
23
+ export function getDocsSourceDir() {
24
+ return path.join(getDocsCacheDir(), 'source');
25
+ }
26
+
27
+ export function getDocsSourceMarkdownDir() {
28
+ return path.join(getDocsSourceDir(), 'markdown');
29
+ }
30
+
31
+ export function ensureDocsCacheDir() {
32
+ const dir = getDocsCacheDir();
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ return dir;
35
+ }
36
+
37
+ export function openDocsDb(opts = {}) {
38
+ const dbPath = opts.dbPath || getDocsDbPath();
39
+ ensureDocsCacheDir();
40
+ const db = new DatabaseSync(dbPath);
41
+ db.exec('PRAGMA journal_mode = WAL');
42
+ return db;
43
+ }
44
+
45
+ export function closeDocsDb(db) {
46
+ try {
47
+ db.close();
48
+ } catch {
49
+ // ignore
50
+ }
51
+ }
52
+
53
+ export function docsDbExists() {
54
+ return fs.existsSync(getDocsDbPath());
55
+ }
56
+
57
+ export function getSchemaVersion(db) {
58
+ try {
59
+ const row = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
60
+ return row ? parseInt(row.value, 10) : 0;
61
+ } catch {
62
+ return 0;
63
+ }
64
+ }
65
+
66
+ export function setSchemaVersion(db, version) {
67
+ db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)");
68
+ db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES('schema_version', ?)").run(String(version));
69
+ }
70
+
71
+ export function initDocsSchema(db) {
72
+ db.exec(`
73
+ CREATE TABLE IF NOT EXISTS docs (
74
+ id INTEGER PRIMARY KEY,
75
+ path TEXT NOT NULL UNIQUE,
76
+ title TEXT,
77
+ release TEXT,
78
+ bundle TEXT,
79
+ doc_type TEXT,
80
+ locale TEXT,
81
+ frontmatter TEXT,
82
+ body TEXT,
83
+ size INTEGER,
84
+ mtime INTEGER,
85
+ hash TEXT,
86
+ hrr_vector BLOB
87
+ );
88
+
89
+ CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
90
+ title, body,
91
+ content='docs',
92
+ content_rowid='id',
93
+ tokenize='porter unicode61'
94
+ );
95
+
96
+ CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs BEGIN
97
+ INSERT INTO docs_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
98
+ END;
99
+ CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs BEGIN
100
+ INSERT INTO docs_fts(docs_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
101
+ END;
102
+ CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs BEGIN
103
+ INSERT INTO docs_fts(docs_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
104
+ INSERT INTO docs_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
105
+ END;
106
+
107
+ CREATE INDEX IF NOT EXISTS idx_docs_bundle ON docs(bundle);
108
+ CREATE INDEX IF NOT EXISTS idx_docs_doc_type ON docs(doc_type);
109
+ CREATE INDEX IF NOT EXISTS idx_docs_release ON docs(release);
110
+
111
+ CREATE TABLE IF NOT EXISTS meta (
112
+ key TEXT PRIMARY KEY,
113
+ value TEXT
114
+ );
115
+ `);
116
+ setSchemaVersion(db, 1);
117
+ }
118
+
119
+ export function hasEmbeddings(db) {
120
+ try {
121
+ const cols = db.prepare('PRAGMA table_info(docs)').all();
122
+ if (!cols.some((c) => c.name === 'hrr_vector')) return false;
123
+ return db.prepare('SELECT COUNT(*) AS n FROM docs WHERE hrr_vector IS NOT NULL').get().n > 0;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ export function getMeta(db, key, defaultValue = null) {
130
+ try {
131
+ const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
132
+ return row ? row.value : defaultValue;
133
+ } catch {
134
+ return defaultValue;
135
+ }
136
+ }
137
+
138
+ export function setMeta(db, key, value) {
139
+ db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES(?, ?)').run(key, value);
140
+ }
@@ -0,0 +1,166 @@
1
+ // jsn docs — ServiceNow documentation search.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import net from 'node:net';
6
+ import process from 'node:process';
7
+ import {
8
+ getDocsDbPath, getDocsSourceDir, getDocsSourceMarkdownDir, docsDbExists,
9
+ openDocsDb, closeDocsDb, getMeta,
10
+ } from './db.js';
11
+ import { syncDocs } from './sync.js';
12
+ import { refreshDocs } from './refresh.js';
13
+ import { embedDocs } from './embed.js';
14
+ import { searchDocs } from './search.js';
15
+ import { serveDocs } from './serve.js';
16
+
17
+ function formatBytes(n) {
18
+ if (n == null) return '-';
19
+ if (n < 1024) return `${n} B`;
20
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
21
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
22
+ }
23
+
24
+ export function docsCmd(wrap) {
25
+ return {
26
+ command: 'docs [subcommand]',
27
+ describe: 'Search ServiceNow documentation locally (sync, status, search, serve)',
28
+ builder: (yargs) => {
29
+ return yargs
30
+ .command({
31
+ command: 'sync',
32
+ describe: 'Clone/update ServiceNowDocs and build the docs.db index',
33
+ builder: (y) => y
34
+ .option('no-embed', { type: 'boolean', describe: 'Skip HRR semantic embeddings' })
35
+ .option('no-ingest', { type: 'boolean', describe: 'Clone/pull only; do not rebuild index' }),
36
+ handler: wrap(async (argv, app) => {
37
+ const result = syncDocs({
38
+ markdownDir: getDocsSourceMarkdownDir(),
39
+ dbPath: getDocsDbPath(),
40
+ embed: argv['no-embed'] !== true,
41
+ noIngest: argv['no-ingest'] === true,
42
+ });
43
+ app.ok(result, { summary: 'Docs sync complete' });
44
+ }),
45
+ })
46
+ .command({
47
+ command: 'status',
48
+ describe: 'Show docs database status',
49
+ handler: wrap(async (argv, app) => {
50
+ const dbPath = getDocsDbPath();
51
+ const repoDir = getDocsSourceDir();
52
+ const markdownDir = getDocsSourceMarkdownDir();
53
+ const downloaded = fs.existsSync(path.join(repoDir, '.git')) && fs.existsSync(markdownDir);
54
+ const dbExists = docsDbExists();
55
+
56
+ let state, embedded, documents, bundles, docTypes, syncedAt, dbSize;
57
+ if (dbExists) {
58
+ const db = openDocsDb();
59
+ documents = db.prepare('SELECT COUNT(*) AS n FROM docs').get().n;
60
+ bundles = db.prepare('SELECT COUNT(DISTINCT bundle) AS n FROM docs').get().n;
61
+ docTypes = db.prepare('SELECT COUNT(DISTINCT doc_type) AS n FROM docs').get().n;
62
+ const embCount = db.prepare('SELECT COUNT(*) AS n FROM docs WHERE hrr_vector IS NOT NULL').get().n;
63
+ syncedAt = getMeta(db, 'synced_at');
64
+ const stat = fs.statSync(dbPath);
65
+ dbSize = stat.size;
66
+ closeDocsDb(db);
67
+
68
+ embedded = embCount >= documents && documents > 0;
69
+ if (embedded) state = 'ready';
70
+ else if (documents > 0) state = 'indexed (not embedded)';
71
+ else state = 'empty';
72
+ } else {
73
+ state = downloaded ? 'not indexed' : 'not downloaded';
74
+ embedded = false;
75
+ }
76
+
77
+ // Quick probe to see if the server is running on the default port.
78
+ const served = await new Promise((resolve) => {
79
+ const sock = new net.Socket();
80
+ sock.setTimeout(500);
81
+ sock.on('connect', () => { sock.destroy(); resolve(true); });
82
+ sock.on('error', () => resolve(false));
83
+ sock.on('timeout', () => { sock.destroy(); resolve(false); });
84
+ sock.connect(3000, '127.0.0.1');
85
+ });
86
+
87
+ app.ok({
88
+ state,
89
+ downloaded: { ok: downloaded, path: repoDir },
90
+ db: { ok: dbExists, path: dbPath },
91
+ embedded: { ok: embedded },
92
+ ...(documents != null && { documents, bundles, docTypes, syncedAt: syncedAt ? new Date(parseInt(syncedAt, 10)).toISOString() : null }),
93
+ ...(dbSize != null && { dbSizeHuman: formatBytes(dbSize) }),
94
+ ...(served && { served: 'http://127.0.0.1:3000' }),
95
+ }, { summary: `Docs database: ${state}` });
96
+ }),
97
+ })
98
+ .command({
99
+ command: 'search <query>',
100
+ describe: 'Search docs (FTS5 + optional HRR hybrid rerank)',
101
+ builder: (y) => y
102
+ .positional('query', { type: 'string', describe: 'Search query' })
103
+ .option('limit', { type: 'number', default: 20, describe: 'Max results' })
104
+ .option('offset', { type: 'number', default: 0, describe: 'Result offset' })
105
+ .option('bundle', { type: 'string', describe: 'Filter by bundle' })
106
+ .option('doc-type', { type: 'string', describe: 'Filter by doc type' })
107
+ .option('mode', { type: 'string', choices: ['keyword', 'hybrid'], default: 'hybrid', describe: 'Search mode' }),
108
+ handler: wrap(async (argv, app) => {
109
+ const result = searchDocs({
110
+ query: argv.query,
111
+ limit: argv.limit,
112
+ offset: argv.offset,
113
+ bundle: argv.bundle,
114
+ docType: argv['doc-type'],
115
+ mode: argv.mode,
116
+ });
117
+ app.ok(result, { summary: `${result.count} result(s) for "${result.query}" (${result.mode})` });
118
+ }),
119
+ })
120
+ .command({
121
+ command: 'serve',
122
+ describe: 'Start the docs search web UI',
123
+ builder: (y) => y
124
+ .option('port', { type: 'number', default: 3000, describe: 'HTTP port' })
125
+ .option('host', { type: 'string', default: '127.0.0.1', describe: 'Bind address' })
126
+ .option('expose', { type: 'boolean', default: false, describe: 'Bind to 0.0.0.0 to expose on the network' }),
127
+ handler: wrap(async (argv, _app) => {
128
+ try {
129
+ const host = argv.expose ? '0.0.0.0' : argv.host;
130
+ await serveDocs({ port: argv.port, host });
131
+ } catch (err) {
132
+ process.stderr.write(`Error: ${err.message}\n`);
133
+ process.exit(1);
134
+ }
135
+ }),
136
+ })
137
+ .command({
138
+ command: 'embed',
139
+ describe: 'Backfill or refresh HRR semantic embeddings',
140
+ handler: wrap(async (argv, app) => {
141
+ const result = embedDocs();
142
+ app.ok(result, { summary: `Embedded ${result.embedded} of ${result.total} docs` });
143
+ }),
144
+ })
145
+ .command({
146
+ command: 'refresh',
147
+ describe: 'Incrementally refresh docs.db from existing source clone',
148
+ handler: wrap(async (argv, app) => {
149
+ const result = refreshDocs({ docsDir: getDocsSourceMarkdownDir() });
150
+ app.ok(result, { summary: `Refresh complete: +${result.added} ~${result.updated} -${result.removed} =${result.unchanged}` });
151
+ }),
152
+ });
153
+ },
154
+ handler: () => {
155
+ process.stdout.write('jsn docs — ServiceNow documentation search\n\n');
156
+ process.stdout.write('Available subcommands:\n');
157
+ process.stdout.write(' sync Download docs and build a searchable index (handles updates too)\n');
158
+ process.stdout.write(' status Show whether docs are downloaded, indexed, embedded, or served\n');
159
+ process.stdout.write(' search Full-text + semantic search across the local docs index\n');
160
+ process.stdout.write(' serve Start a local web UI for browsing and searching docs\n');
161
+ process.stdout.write('\n');
162
+ process.stdout.write('Tip: use "jsn docs serve --expose" to bind to 0.0.0.0 and share on your network.\n');
163
+ process.stdout.write('\nRun "jsn docs <command> --help" for details.\n');
164
+ },
165
+ };
166
+ }
@@ -0,0 +1,46 @@
1
+ // Populate HRR phase-vector embeddings for semantic reranking.
2
+
3
+ import fs from 'node:fs';
4
+ import { openDocsDb, closeDocsDb, getDocsDbPath } from './db.js';
5
+ import { encodeText, phasesToBytes, docSurface, DEFAULT_DIM } from './hrr.js';
6
+
7
+ export function embedDocs(opts = {}) {
8
+ const dbPath = opts.dbPath || getDocsDbPath();
9
+
10
+ if (!fs.existsSync(dbPath)) {
11
+ throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
12
+ }
13
+
14
+ const db = openDocsDb({ dbPath });
15
+
16
+ const cols = db.prepare('PRAGMA table_info(docs)').all();
17
+ if (!cols.some((c) => c.name === 'hrr_vector')) {
18
+ db.exec('ALTER TABLE docs ADD COLUMN hrr_vector BLOB');
19
+ }
20
+
21
+ db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)");
22
+ db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES('hrr_dim', ?)").run(String(DEFAULT_DIM));
23
+
24
+ const rows = db.prepare('SELECT id, title, body FROM docs WHERE hrr_vector IS NULL').all();
25
+ const total = db.prepare('SELECT COUNT(*) AS n FROM docs').get().n;
26
+
27
+ const update = db.prepare('UPDATE docs SET hrr_vector = ? WHERE id = ?');
28
+ const started = Date.now();
29
+ let n = 0;
30
+
31
+ db.exec('BEGIN');
32
+ for (const row of rows) {
33
+ const surface = docSurface(row.title, row.body);
34
+ const vec = encodeText(surface, DEFAULT_DIM);
35
+ update.run(phasesToBytes(vec), row.id);
36
+ n++;
37
+ if (n % 5000 === 0) process.stderr.write(` ...${n}\n`);
38
+ }
39
+ db.exec('COMMIT');
40
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
41
+ db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES('embeddings', ?)").run('true');
42
+ closeDocsDb(db);
43
+
44
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
45
+ return { embedded: n, total, secs, dbPath };
46
+ }
@@ -0,0 +1,95 @@
1
+ // Holographic Reduced Representations (HRR) — phase-vector encoding.
2
+ //
3
+ // Pure-JS port of Hermes' plugins/memory/holographic/holographic.py.
4
+ // A concept is a fixed-width vector of phases in [0, 2π). Tokens become
5
+ // deterministic phase vectors (SHA-256 -> uint16 -> angle); a text is the
6
+ // "bundle" (circular mean) of its token vectors. Similarity is mean(cos(a-b)).
7
+
8
+ import { createHash } from 'node:crypto';
9
+
10
+ const TWO_PI = 2 * Math.PI;
11
+ export const DEFAULT_DIM = 512;
12
+
13
+ export function encodeAtom(word, dim = DEFAULT_DIM) {
14
+ const valuesPerBlock = 16;
15
+ const blocks = Math.ceil(dim / valuesPerBlock);
16
+ const phases = new Float64Array(dim);
17
+ let idx = 0;
18
+ for (let i = 0; i < blocks && idx < dim; i++) {
19
+ const digest = createHash('sha256').update(`${word}:${i}`).digest();
20
+ for (let j = 0; j < 32 && idx < dim; j += 2) {
21
+ const v = digest.readUInt16LE(j);
22
+ phases[idx++] = v * (TWO_PI / 65536);
23
+ }
24
+ }
25
+ return phases;
26
+ }
27
+
28
+ export function tokenize(text) {
29
+ return (text || '')
30
+ .toLowerCase()
31
+ .split(/\s+/)
32
+ .map((t) => t.replace(/^[.,!?;:"'()[\]{}]+|[.,!?;:"'()[\]{}]+$/g, ''))
33
+ .filter(Boolean);
34
+ }
35
+
36
+ export function bundle(vectors, dim = DEFAULT_DIM) {
37
+ const re = new Float64Array(dim);
38
+ const im = new Float64Array(dim);
39
+ for (const v of vectors) {
40
+ for (let k = 0; k < dim; k++) {
41
+ re[k] += Math.cos(v[k]);
42
+ im[k] += Math.sin(v[k]);
43
+ }
44
+ }
45
+ const out = new Float64Array(dim);
46
+ for (let k = 0; k < dim; k++) {
47
+ let a = Math.atan2(im[k], re[k]);
48
+ if (a < 0) a += TWO_PI;
49
+ out[k] = a;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ export function encodeText(text, dim = DEFAULT_DIM) {
55
+ const tokens = tokenize(text);
56
+ if (tokens.length === 0) return encodeAtom('__hrr_empty__', dim);
57
+ return bundle(tokens.map((t) => encodeAtom(t, dim)), dim);
58
+ }
59
+
60
+ export function similarity(a, b) {
61
+ const dim = a.length;
62
+ let sum = 0;
63
+ for (let k = 0; k < dim; k++) sum += Math.cos(a[k] - b[k]);
64
+ return sum / dim;
65
+ }
66
+
67
+ export function phasesToBytes(phases) {
68
+ const buf = Buffer.allocUnsafe(phases.length * 2);
69
+ for (let k = 0; k < phases.length; k++) {
70
+ let q = Math.round((phases[k] / TWO_PI) * 65536) % 65536;
71
+ if (q < 0) q += 65536;
72
+ buf.writeUInt16LE(q, k * 2);
73
+ }
74
+ return buf;
75
+ }
76
+
77
+ export function bytesToPhases(buf) {
78
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
79
+ const dim = buf.byteLength / 2;
80
+ const phases = new Float64Array(dim);
81
+ for (let k = 0; k < dim; k++) {
82
+ phases[k] = view.getUint16(k * 2, true) * (TWO_PI / 65536);
83
+ }
84
+ return phases;
85
+ }
86
+
87
+ export function docSurface(title, body, maxBodyTokens = 40) {
88
+ const headings = (body || '')
89
+ .split('\n')
90
+ .filter((l) => /^#{1,6}\s/.test(l))
91
+ .map((l) => l.replace(/^#{1,6}\s+/, ''))
92
+ .join(' ');
93
+ const bodyStart = tokenize(body).slice(0, maxBodyTokens).join(' ');
94
+ return [title || '', headings, bodyStart].filter(Boolean).join(' ');
95
+ }