@assuredloop/cli 0.2.0 → 0.2.1
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/README.md +15 -15
- package/package.json +9 -1
- package/src/v4/level2.js +154 -0
- package/src/v4/search.js +9 -21
package/README.md
CHANGED
|
@@ -28,13 +28,13 @@ It needs Node 24 or newer and git 2.31 or newer. Install the npm package
|
|
|
28
28
|
|
|
29
29
|
npm install --global @assuredloop/cli
|
|
30
30
|
|
|
31
|
-
The recommended install adds the
|
|
32
|
-
|
|
31
|
+
The recommended install adds the embedding model's library beside it, for
|
|
32
|
+
search at level 2 (see "Search"). `al` names the library as an optional
|
|
33
|
+
dependency, so npm installs it only when you ask:
|
|
33
34
|
|
|
34
|
-
npm install --global @assuredloop/cli @
|
|
35
|
+
npm install --global @assuredloop/cli @huggingface/transformers@4.3.1
|
|
35
36
|
|
|
36
37
|
To pin a version, name it: `npm install --global @assuredloop/cli@<version>`.
|
|
37
|
-
Give both packages the same version.
|
|
38
38
|
|
|
39
39
|
The package carries the skill, at
|
|
40
40
|
`$(npm root -g)/@assuredloop/cli/skills/assuredloop/SKILL.md`. Add one line to
|
|
@@ -49,7 +49,7 @@ the skill at the pinned version into the project, for example as
|
|
|
49
49
|
`.claude/skills/assuredloop/SKILL.md`. Name that path in the AGENTS.md line,
|
|
50
50
|
and say in the commit which version it came from.
|
|
51
51
|
|
|
52
|
-
Search at level 2 needs the
|
|
52
|
+
Search at level 2 needs the library `@huggingface/transformers` 4.3.1 (see
|
|
53
53
|
"Search"). Install it in the project, or beside `al`.
|
|
54
54
|
|
|
55
55
|
## The model in one page
|
|
@@ -176,7 +176,7 @@ itself (2, then 1, then 0), and says which level answered and why.
|
|
|
176
176
|
|---|---|---|
|
|
177
177
|
| 0 | a scan of the export | nothing |
|
|
178
178
|
| 1 | a local full-text index (SQLite FTS5, BM25), with the ID in its own exact column | Node 24 or newer (`node:sqlite`) |
|
|
179
|
-
| 2 | level 1, plus a small local embedding model; word and vector results are fused | the
|
|
179
|
+
| 2 | level 1, plus a small local embedding model; word and vector results are fused | the library `@huggingface/transformers` 4.3.1, in the project or beside `al` |
|
|
180
180
|
|
|
181
181
|
- The default query is the current system: the spec text at the selected
|
|
182
182
|
commit. `--change <name>` adds that request's open change spec, its
|
|
@@ -199,7 +199,9 @@ itself (2, then 1, then 0), and says which level answered and why.
|
|
|
199
199
|
brings it up to date before it answers; `--rebuild` builds it again.
|
|
200
200
|
- Level 2 runs a small local model (bge-small-en-v1.5, pinned by its
|
|
201
201
|
revision). The model download is the only data that leaves the machine.
|
|
202
|
-
`al` itself makes no network call.
|
|
202
|
+
`al` itself makes no network call. Without the library, `al search`
|
|
203
|
+
answers at level 1, and a `Not known` line says how to add level 2. Only
|
|
204
|
+
`al search` loads the library.
|
|
203
205
|
|
|
204
206
|
## One central repo, and output repos
|
|
205
207
|
|
|
@@ -299,15 +301,13 @@ in this repository.
|
|
|
299
301
|
|
|
300
302
|
## Releasing
|
|
301
303
|
|
|
302
|
-
1. Bump `version` in `package.json` and
|
|
303
|
-
the same version in a PR, and merge it.
|
|
304
|
+
1. Bump `version` in `package.json` in a PR, and merge it.
|
|
304
305
|
2. Publish a GitHub Release tagged `v<version>` on that commit. Mark it a
|
|
305
306
|
pre-release to publish under npm's `next` tag.
|
|
306
307
|
|
|
307
308
|
`.github/workflows/publish.yml` does the rest: it runs the tests and
|
|
308
|
-
`al check --strict`, checks that the tag matches
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
for each package before its first publish.
|
|
309
|
+
`al check --strict`, checks that the tag matches `package.json`, and
|
|
310
|
+
publishes @assuredloop/cli to npm. It skips a version that is already on npm,
|
|
311
|
+
so a failed run can run again. It stores no npm token: npm trusts the
|
|
312
|
+
workflow by name, and adds a provenance statement saying which commit built
|
|
313
|
+
the package.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assuredloop/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "AssuredLoop keeps an AI focused on one change: the al command gathers and checks a change's requirement, spec, decisions, code and tests.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,5 +33,13 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"yaml": "2.9.1"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@huggingface/transformers": "4.3.1"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@huggingface/transformers": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
36
44
|
}
|
|
37
45
|
}
|
package/src/v4/level2.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Level 2 of `al search` (design.md 11): the embedding model and the vectors.
|
|
2
|
+
// `al search` gives it the rows to embed and a query, and it answers with the
|
|
3
|
+
// row keys ranked by similarity; search.js fuses that list with its full-text
|
|
4
|
+
// list.
|
|
5
|
+
//
|
|
6
|
+
// The vectors live beside al's index, in <dir>/vectors.sqlite. Only new or
|
|
7
|
+
// changed rows are embedded; a change of model embeds everything again.
|
|
8
|
+
// The model's library, @huggingface/transformers, is an optional peer
|
|
9
|
+
// dependency: it is found by normal Node resolution (the project first, then
|
|
10
|
+
// beside al) and imported only at the first embed, so no other command and no
|
|
11
|
+
// search without words loads it.
|
|
12
|
+
import { mkdirSync } from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const BATCH = 32;
|
|
18
|
+
|
|
19
|
+
// node:sqlite is imported here, not at load, so a Node without it still
|
|
20
|
+
// loads this file and falls back by search.js's own check.
|
|
21
|
+
async function open(dir) {
|
|
22
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
const db = new DatabaseSync(join(dir, 'vectors.sqlite'));
|
|
25
|
+
db.exec(`
|
|
26
|
+
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS vectors (key TEXT PRIMARY KEY, sha256 TEXT NOT NULL, vec BLOB NOT NULL);
|
|
28
|
+
`);
|
|
29
|
+
return db;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A vector as unit length, so a dot product is the cosine.
|
|
33
|
+
function unit(v) {
|
|
34
|
+
const a = Float32Array.from(v);
|
|
35
|
+
let n = 0;
|
|
36
|
+
for (const x of a) n += x * x;
|
|
37
|
+
n = Math.sqrt(n) || 1;
|
|
38
|
+
for (let i = 0; i < a.length; i++) a[i] /= n;
|
|
39
|
+
return a;
|
|
40
|
+
}
|
|
41
|
+
const blob = (a) => Buffer.from(a.buffer, a.byteOffset, a.byteLength);
|
|
42
|
+
const vector = (b) => new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
|
|
43
|
+
|
|
44
|
+
async function embedAll(embed, texts) {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (let i = 0; i < texts.length; i += BATCH) {
|
|
47
|
+
const part = texts.slice(i, i + BATCH);
|
|
48
|
+
const vs = await embed(part);
|
|
49
|
+
if (!Array.isArray(vs) || vs.length !== part.length) throw new Error(`the model gave ${vs?.length ?? 'no'} vectors for ${part.length} texts`);
|
|
50
|
+
out.push(...vs.map(unit));
|
|
51
|
+
}
|
|
52
|
+
if (out.some((v) => v.length !== out[0].length)) throw new Error('the model gave vectors of different lengths');
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A level-2 object: `model` names it in al's output; `embed(texts)` gives one
|
|
57
|
+
// vector per text. `queryPrefix` is put before a query only.
|
|
58
|
+
function createLevel2({ model, embed, queryPrefix = '' }) {
|
|
59
|
+
if (typeof model !== 'string' || !model) throw new Error('createLevel2 needs a model name');
|
|
60
|
+
if (typeof embed !== 'function') throw new Error('createLevel2 needs an embed function');
|
|
61
|
+
return {
|
|
62
|
+
model,
|
|
63
|
+
|
|
64
|
+
// Makes the stored vectors match `rows` ([{ key, sha256, text }]):
|
|
65
|
+
// embeds the new and changed rows and removes the others.
|
|
66
|
+
async refresh({ dir, rows }) {
|
|
67
|
+
const db = await open(dir);
|
|
68
|
+
try {
|
|
69
|
+
if (db.prepare("SELECT v FROM meta WHERE k = 'model'").get()?.v !== model) {
|
|
70
|
+
db.exec('DELETE FROM vectors');
|
|
71
|
+
db.prepare("INSERT OR REPLACE INTO meta (k, v) VALUES ('model', ?)").run(model);
|
|
72
|
+
}
|
|
73
|
+
const have = new Map(db.prepare('SELECT key, sha256 FROM vectors').all().map((x) => [x.key, x.sha256]));
|
|
74
|
+
const want = new Map(rows.map((r) => [r.key, r]));
|
|
75
|
+
const todo = rows.filter((r) => have.get(r.key) !== r.sha256);
|
|
76
|
+
const vs = await embedAll(embed, todo.map((r) => r.text));
|
|
77
|
+
db.exec('BEGIN');
|
|
78
|
+
const del = db.prepare('DELETE FROM vectors WHERE key = ?');
|
|
79
|
+
let removed = 0;
|
|
80
|
+
for (const k of have.keys()) if (!want.has(k)) { del.run(k); removed += 1; }
|
|
81
|
+
const put = db.prepare('INSERT OR REPLACE INTO vectors (key, sha256, vec) VALUES (?, ?, ?)');
|
|
82
|
+
todo.forEach((r, i) => put.run(r.key, r.sha256, blob(vs[i])));
|
|
83
|
+
db.exec('COMMIT');
|
|
84
|
+
return { embedded: todo.length, removed };
|
|
85
|
+
} finally {
|
|
86
|
+
db.close();
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// The `keys` ranked by similarity to `query`, best first, at most `k`.
|
|
91
|
+
// A plain scan of the stored vectors: no vector database is needed at
|
|
92
|
+
// this size (design.md 11).
|
|
93
|
+
async rank({ dir, query, keys, k }) {
|
|
94
|
+
const [q] = await embedAll(embed, [`${queryPrefix}${query}`]);
|
|
95
|
+
const db = await open(dir);
|
|
96
|
+
try {
|
|
97
|
+
const wanted = new Set(keys);
|
|
98
|
+
const scored = [];
|
|
99
|
+
for (const { key, vec } of db.prepare('SELECT key, vec FROM vectors').all()) {
|
|
100
|
+
if (!wanted.has(key)) continue;
|
|
101
|
+
const v = vector(vec);
|
|
102
|
+
if (v.length !== q.length) throw new Error('the stored vectors and the query differ in length');
|
|
103
|
+
let s = 0;
|
|
104
|
+
for (let i = 0; i < v.length; i++) s += v[i] * q[i];
|
|
105
|
+
scored.push([key, s]);
|
|
106
|
+
}
|
|
107
|
+
scored.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
108
|
+
return scored.slice(0, k).map(([key]) => key);
|
|
109
|
+
} finally {
|
|
110
|
+
db.close();
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const LIBRARY = '@huggingface/transformers';
|
|
117
|
+
const VERSION = '4.3.1';
|
|
118
|
+
export const INSTALL = `npm install --global @assuredloop/cli ${LIBRARY}@${VERSION}`;
|
|
119
|
+
|
|
120
|
+
// The library's path by normal Node resolution: from the project, then
|
|
121
|
+
// beside al. Null when neither has it.
|
|
122
|
+
export function findLibrary(top) {
|
|
123
|
+
for (const from of [join(top, 'package.json'), import.meta.url]) {
|
|
124
|
+
try { return createRequire(from).resolve(LIBRARY); } catch { /* not here */ }
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The real model: bge-small-en-v1.5 (MIT), the ONNX port, pinned by revision.
|
|
130
|
+
const MODEL = 'Xenova/bge-small-en-v1.5';
|
|
131
|
+
const REVISION = 'ea104dacec62c0de699686887e3f920caeb4f3e3';
|
|
132
|
+
|
|
133
|
+
// Level 2 with the library at `path`, which is imported at the first embed.
|
|
134
|
+
export function level2(path) {
|
|
135
|
+
let extractor = null;
|
|
136
|
+
async function embed(texts) {
|
|
137
|
+
if (!extractor) {
|
|
138
|
+
// The path is the library's require entry; its exports may come as a default.
|
|
139
|
+
const mod = await import(pathToFileURL(path).href);
|
|
140
|
+
const pipeline = mod.pipeline ?? mod.default?.pipeline;
|
|
141
|
+
if (typeof pipeline !== 'function') throw new Error(`${path} has no pipeline function`);
|
|
142
|
+
extractor = await pipeline('feature-extraction', MODEL, { revision: REVISION, dtype: 'q8' });
|
|
143
|
+
}
|
|
144
|
+
// bge takes the first token's vector (CLS pooling).
|
|
145
|
+
const out = await extractor(texts, { pooling: 'cls', normalize: true });
|
|
146
|
+
return out.tolist();
|
|
147
|
+
}
|
|
148
|
+
return createLevel2({
|
|
149
|
+
model: `${MODEL}@${REVISION.slice(0, 12)}`,
|
|
150
|
+
embed,
|
|
151
|
+
// bge's instruction for a short query that looks for passages.
|
|
152
|
+
queryPrefix: 'Represent this sentence for searching relevant passages: ',
|
|
153
|
+
});
|
|
154
|
+
}
|
package/src/v4/search.js
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
// al search (design.md 11): the current system, a change's context and the
|
|
2
2
|
// history, at the strongest level installed. Level 0 scans the export; level
|
|
3
3
|
// 1 is a SQLite FTS5 index of it (BM25) with the ID in its own exact column;
|
|
4
|
-
// level 2 fuses level 1 with the ranking of
|
|
5
|
-
//
|
|
6
|
-
// vectors: the package does, and returns a ranked list.
|
|
4
|
+
// level 2 fuses level 1 with the ranking of level2.js (reciprocal rank
|
|
5
|
+
// fusion), which needs the optional peer dependency @huggingface/transformers.
|
|
7
6
|
import { mkdirSync, rmSync } from 'node:fs';
|
|
8
|
-
import { createRequire } from 'node:module';
|
|
9
7
|
import { isAbsolute, join } from 'node:path';
|
|
10
|
-
import { pathToFileURL } from 'node:url';
|
|
11
8
|
import { parse } from 'yaml';
|
|
12
9
|
import { Fail, isName } from './base.js';
|
|
13
10
|
import { git } from './git.js';
|
|
@@ -39,22 +36,13 @@ async function sqlite() {
|
|
|
39
36
|
}
|
|
40
37
|
}
|
|
41
38
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
39
|
+
// Level 2 when its library is installed, or null with the reason. level2.js
|
|
40
|
+
// is loaded only here, and the library only at its first embed.
|
|
44
41
|
async function level2(top) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const mod = await import(pathToFileURL(path).href);
|
|
50
|
-
const l2 = mod.default;
|
|
51
|
-
if (!l2 || typeof l2.refresh !== 'function' || typeof l2.rank !== 'function') return { reason: `${path} is not a level 2 package (no refresh and rank)` };
|
|
52
|
-
return { l2 };
|
|
53
|
-
} catch (e) {
|
|
54
|
-
return { reason: `@assuredloop/search did not load: ${e.message.split('\n')[0]}` };
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return { reason: 'level 2 is not installed: no @assuredloop/search in the project or beside al' };
|
|
42
|
+
const { findLibrary, level2: create, LIBRARY, INSTALL } = await import('./level2.js');
|
|
43
|
+
const path = findLibrary(top);
|
|
44
|
+
if (!path) return { reason: `level 2 is not installed: no ${LIBRARY} in the project or beside al`, install: `level 2: ${LIBRARY} is not installed; to add level 2, run ${INSTALL}` };
|
|
45
|
+
return { l2: create(path) };
|
|
58
46
|
}
|
|
59
47
|
|
|
60
48
|
// The folder of the index: <git dir>/assuredloop, never in the working tree.
|
|
@@ -352,7 +340,7 @@ export async function search({ top, args, opts }) {
|
|
|
352
340
|
let lite = null;
|
|
353
341
|
if (level === 2) {
|
|
354
342
|
found2 = await level2(top);
|
|
355
|
-
if (!found2.l2) { falls.push(found2.reason); level = 1; }
|
|
343
|
+
if (!found2.l2) { falls.push(found2.reason); notKnown.push(found2.install); level = 1; }
|
|
356
344
|
}
|
|
357
345
|
if (level >= 1) {
|
|
358
346
|
lite = await sqlite();
|