@aligndottech/cli 0.18.0 → 0.19.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 +10 -8
- package/dist/commands/check.d.ts.map +1 -1
- package/dist/commands/check.js +18 -1
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/setup.js +1 -1
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/why.d.ts.map +1 -1
- package/dist/commands/why.js +26 -8
- package/dist/commands/why.js.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/hook-payload.js +34 -4
- package/dist/lib/hook-payload.js.map +1 -1
- package/dist/lib/local-db.d.ts +54 -0
- package/dist/lib/local-db.d.ts.map +1 -1
- package/dist/lib/local-db.js +180 -6
- package/dist/lib/local-db.js.map +1 -1
- package/dist/lib/local-embeddings.d.ts.map +1 -1
- package/dist/lib/local-embeddings.js +13 -2
- package/dist/lib/local-embeddings.js.map +1 -1
- package/dist/lib/local-gateway-client.d.ts +36 -1
- package/dist/lib/local-gateway-client.d.ts.map +1 -1
- package/dist/lib/local-gateway-client.js +96 -8
- package/dist/lib/local-gateway-client.js.map +1 -1
- package/dist/lib/local-llm.d.ts +78 -15
- package/dist/lib/local-llm.d.ts.map +1 -1
- package/dist/lib/local-llm.js +327 -88
- package/dist/lib/local-llm.js.map +1 -1
- package/dist/lib/local-relationship-classifier.d.ts +22 -4
- package/dist/lib/local-relationship-classifier.d.ts.map +1 -1
- package/dist/lib/local-relationship-classifier.js +19 -9
- package/dist/lib/local-relationship-classifier.js.map +1 -1
- package/dist/lib/usage-telemetry.d.ts +36 -1
- package/dist/lib/usage-telemetry.d.ts.map +1 -1
- package/dist/lib/usage-telemetry.js +73 -3
- package/dist/lib/usage-telemetry.js.map +1 -1
- package/package.json +1 -1
package/dist/lib/local-db.d.ts
CHANGED
|
@@ -17,7 +17,51 @@ export interface DbStats {
|
|
|
17
17
|
decisions: number;
|
|
18
18
|
embeddings: number;
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Schema version this build expects. Bump when adding a step to `migrate`.
|
|
22
|
+
*
|
|
23
|
+
* Exported so a test can assert the migration WROTE the version it claims, without hardcoding
|
|
24
|
+
* the number - a test pinning a literal 1 had to be edited by this change rather than passing
|
|
25
|
+
* or failing on its own merits. A parity test also derives the highest `version <` branch in
|
|
26
|
+
* `migrate` from the source and compares it here, because forgetting the bump leaves the new
|
|
27
|
+
* branch running destructively on every open with nothing to stop it.
|
|
28
|
+
*/
|
|
29
|
+
export declare const SCHEMA_VERSION = 2;
|
|
30
|
+
/**
|
|
31
|
+
* A `source_url` is only an identity if it points at ONE thing. Some fetchers substitute a
|
|
32
|
+
* constant when the per-item link is missing, and connector-core ships one:
|
|
33
|
+
*
|
|
34
|
+
* dist/fetchers/teams.js:60 source_url: msg.webUrl ?? 'https://teams.microsoft.com'
|
|
35
|
+
*
|
|
36
|
+
* Treating that as an identity is destructive once the column is unique - every such message
|
|
37
|
+
* collapses onto one row. So a bare origin, an empty string and whitespace are normalised to
|
|
38
|
+
* null, which means "no identity" and never collides.
|
|
39
|
+
*
|
|
40
|
+
* Normalised away rather than stored beside a separate identity column: a URL that addresses a
|
|
41
|
+
* host and nothing on it is not a link to the decision, so keeping it would render a
|
|
42
|
+
* "source" link in `align search` that takes the reader to a homepage. One column, one meaning.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately narrow: a URL with any path is treated as identifying, because Confluence's
|
|
45
|
+
* `linkBase`
|
|
46
|
+
* fallback (`https://site.atlassian.net/wiki`) cannot be told apart from a genuinely short page
|
|
47
|
+
* URL, and a hardcoded list of known fallbacks would be wrong the day a fetcher invents one
|
|
48
|
+
* more. That case is caught by the other half of the key instead - uniqueness is on
|
|
49
|
+
* (source_url, title).
|
|
50
|
+
*/
|
|
51
|
+
export declare function identifyingSourceUrl(raw: string | null): string | null;
|
|
20
52
|
export declare function createLocalDb(dbPath: string): {
|
|
53
|
+
/**
|
|
54
|
+
* Insert, or refresh the decision that already carries this `source_url`, returning the id
|
|
55
|
+
* that now holds it.
|
|
56
|
+
*
|
|
57
|
+
* Refresh rather than ignore: a rewritten commit message or an edited Jira issue should be
|
|
58
|
+
* current in the graph. Returning the EXISTING id on a conflict is what keeps the caller's
|
|
59
|
+
* `setEmbedding` on the surviving row instead of orphaning a vector.
|
|
60
|
+
*
|
|
61
|
+
* A null `source_url` never conflicts - SQLite treats each NULL in a unique index as
|
|
62
|
+
* distinct - so `align capture` with no URL keeps inserting, by construction rather than by
|
|
63
|
+
* a special case here.
|
|
64
|
+
*/
|
|
21
65
|
insertDecision(row: {
|
|
22
66
|
title: string;
|
|
23
67
|
summary: string;
|
|
@@ -32,6 +76,16 @@ export declare function createLocalDb(dbPath: string): {
|
|
|
32
76
|
decisionId: string;
|
|
33
77
|
embedding: Float32Array;
|
|
34
78
|
}>;
|
|
79
|
+
/**
|
|
80
|
+
* `INSERT OR IGNORE` here was decorative: the id is a fresh UUID, so the only key that
|
|
81
|
+
* could conflict was guaranteed not to, and there was no unique index on the triple. With
|
|
82
|
+
* decisions now deduping, a re-import returns the SAME decision id and this added another
|
|
83
|
+
* identical edge every time - measured 1, 2, 3 over three imports, inflating the
|
|
84
|
+
* "similar decisions found" count `align local status` prints.
|
|
85
|
+
*
|
|
86
|
+
* The unique index (created in migrate) is what makes the OR IGNORE real. Confidence is
|
|
87
|
+
* refreshed rather than ignored so a better score replaces a worse one.
|
|
88
|
+
*/
|
|
35
89
|
insertLink(link: {
|
|
36
90
|
sourceId: string;
|
|
37
91
|
targetId: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-db.d.ts","sourceRoot":"","sources":["../../src/lib/local-db.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,OAAO;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;
|
|
1
|
+
{"version":3,"file":"local-db.d.ts","sourceRoot":"","sources":["../../src/lib/local-db.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,OAAO;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;AA2BD;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAetE;AA2HD,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM;IAaxC;;;;;;;;;;;OAWG;wBACiB;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM;qBAe1F,WAAW,EAAE;wBAMV,MAAM,GAAG,WAAW,GAAG,IAAI;6BAMtB,MAAM,aAAa,YAAY,GAAG,IAAI;6BAMtC,MAAM,GAAG,YAAY,GAAG,IAAI;wBAQjC,KAAK,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,YAAY,CAAA;KAAE,CAAC;IAU1E;;;;;;;;;OASG;qBACc;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;uBAQjF;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,EAAE;gBAQ7D,OAAO;eAQR,IAAI;aAIN,IAAI;EAIhB;AAED,MAAM,MAAM,OAAO,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC"}
|
package/dist/lib/local-db.js
CHANGED
|
@@ -26,8 +26,56 @@ CREATE TABLE IF NOT EXISTS decision_links (
|
|
|
26
26
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
27
27
|
);
|
|
28
28
|
`;
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Schema version this build expects. Bump when adding a step to `migrate`.
|
|
31
|
+
*
|
|
32
|
+
* Exported so a test can assert the migration WROTE the version it claims, without hardcoding
|
|
33
|
+
* the number - a test pinning a literal 1 had to be edited by this change rather than passing
|
|
34
|
+
* or failing on its own merits. A parity test also derives the highest `version <` branch in
|
|
35
|
+
* `migrate` from the source and compares it here, because forgetting the bump leaves the new
|
|
36
|
+
* branch running destructively on every open with nothing to stop it.
|
|
37
|
+
*/
|
|
38
|
+
export const SCHEMA_VERSION = 2;
|
|
39
|
+
/**
|
|
40
|
+
* A `source_url` is only an identity if it points at ONE thing. Some fetchers substitute a
|
|
41
|
+
* constant when the per-item link is missing, and connector-core ships one:
|
|
42
|
+
*
|
|
43
|
+
* dist/fetchers/teams.js:60 source_url: msg.webUrl ?? 'https://teams.microsoft.com'
|
|
44
|
+
*
|
|
45
|
+
* Treating that as an identity is destructive once the column is unique - every such message
|
|
46
|
+
* collapses onto one row. So a bare origin, an empty string and whitespace are normalised to
|
|
47
|
+
* null, which means "no identity" and never collides.
|
|
48
|
+
*
|
|
49
|
+
* Normalised away rather than stored beside a separate identity column: a URL that addresses a
|
|
50
|
+
* host and nothing on it is not a link to the decision, so keeping it would render a
|
|
51
|
+
* "source" link in `align search` that takes the reader to a homepage. One column, one meaning.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately narrow: a URL with any path is treated as identifying, because Confluence's
|
|
54
|
+
* `linkBase`
|
|
55
|
+
* fallback (`https://site.atlassian.net/wiki`) cannot be told apart from a genuinely short page
|
|
56
|
+
* URL, and a hardcoded list of known fallbacks would be wrong the day a fetcher invents one
|
|
57
|
+
* more. That case is caught by the other half of the key instead - uniqueness is on
|
|
58
|
+
* (source_url, title).
|
|
59
|
+
*/
|
|
60
|
+
export function identifyingSourceUrl(raw) {
|
|
61
|
+
if (raw === null)
|
|
62
|
+
return null;
|
|
63
|
+
const value = raw.trim();
|
|
64
|
+
if (!value)
|
|
65
|
+
return null;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = new URL(value);
|
|
68
|
+
// No path, no query, no fragment: this addresses a host, not a thing on it.
|
|
69
|
+
if ((parsed.pathname === '' || parsed.pathname === '/') && !parsed.search && !parsed.hash) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Not a parseable URL. `git://commit/<sha>` parses, but an opaque key may not, and an
|
|
75
|
+
// opaque key is still an identity - discarding it would be the destructive direction.
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
31
79
|
/**
|
|
32
80
|
* One-time data migrations, tracked in SQLite's built-in `user_version`.
|
|
33
81
|
*
|
|
@@ -36,6 +84,12 @@ const SCHEMA_VERSION = 1;
|
|
|
36
84
|
* one caller and it hardcoded that relation for any pair over 0.65 cosine. No classifier
|
|
37
85
|
* verdict was ever persisted, so there is no earned row to damage.
|
|
38
86
|
*
|
|
87
|
+
* 2. A decision's `source_url` identifies it, and nothing enforced that: `insertDecision` minted
|
|
88
|
+
* a fresh UUID per call, so re-importing the same commit added a second copy. The documented
|
|
89
|
+
* first run does exactly that - `setup --local` seeds from git, then its own outro tells you
|
|
90
|
+
* to run `align import git` - so a graph goes from 2 decisions to 4 by following the tips.
|
|
91
|
+
* Collapse the existing duplicates, then make them unrepresentable with a unique index.
|
|
92
|
+
*
|
|
39
93
|
* The version guard is load-bearing rather than tidiness. The same UPDATE run on every open
|
|
40
94
|
* is indistinguishable from this one today, and starts silently eating genuine conflicts the
|
|
41
95
|
* moment anything writes one.
|
|
@@ -45,6 +99,101 @@ function migrate(db) {
|
|
|
45
99
|
if (version < 1) {
|
|
46
100
|
db.exec(`UPDATE decision_links SET relation = 'relates' WHERE relation = 'conflicts_with'`);
|
|
47
101
|
}
|
|
102
|
+
if (version < 2) {
|
|
103
|
+
// IMMEDIATE, not the default DEFERRED: this transaction reads (the survivor scan) before it
|
|
104
|
+
// writes, and under WAL another writer arriving in between makes the first write fail with
|
|
105
|
+
// SQLITE_BUSY_SNAPSHOT, which busy_timeout does not retry. Every local command opens the DB
|
|
106
|
+
// and the advisory hook fires on every agent edit, so a concurrent open is the normal case.
|
|
107
|
+
db.exec('BEGIN IMMEDIATE');
|
|
108
|
+
try {
|
|
109
|
+
// The survivor is the row inserted FIRST. `rowid` rather than `id` breaks a created_at
|
|
110
|
+
// tie: created_at has one-second granularity so rows written in one import tie routinely,
|
|
111
|
+
// and ordering by a random UUID would keep an arbitrary one while this comment claimed to
|
|
112
|
+
// keep the long-standing id. rowid is insertion order (the table is not WITHOUT ROWID).
|
|
113
|
+
//
|
|
114
|
+
// Keyed on (source_url, title), not source_url alone. Some fetchers emit a CONSTANT
|
|
115
|
+
// source_url when the per-item link is missing - connector-core's Teams fallback is
|
|
116
|
+
// literally 'https://teams.microsoft.com' - and collapsing on the URL alone deleted every
|
|
117
|
+
// such message but one. Two rows sharing a URL AND a title are duplicates by any reading;
|
|
118
|
+
// two sharing only the URL are not.
|
|
119
|
+
db.exec(`
|
|
120
|
+
CREATE TEMP TABLE dedup_survivor AS
|
|
121
|
+
SELECT source_url, title, id FROM (
|
|
122
|
+
SELECT source_url, title, id,
|
|
123
|
+
ROW_NUMBER() OVER (PARTITION BY source_url, title ORDER BY created_at, rowid) AS rn
|
|
124
|
+
FROM decisions WHERE source_url IS NOT NULL
|
|
125
|
+
) WHERE rn = 1;
|
|
126
|
+
|
|
127
|
+
CREATE TEMP TABLE dedup_dropped AS
|
|
128
|
+
SELECT d.id AS id, s.id AS survivor_id
|
|
129
|
+
FROM decisions d JOIN dedup_survivor s
|
|
130
|
+
ON d.source_url = s.source_url AND d.title = s.title
|
|
131
|
+
WHERE d.id <> s.id;
|
|
132
|
+
`);
|
|
133
|
+
// Repoint edges rather than dropping them: an edge naming a duplicate is a real edge that
|
|
134
|
+
// happened to name the copy. Dropping it would lose a relationship the user earned.
|
|
135
|
+
db.exec(`
|
|
136
|
+
UPDATE decision_links SET source_id = (
|
|
137
|
+
SELECT survivor_id FROM dedup_dropped WHERE id = decision_links.source_id
|
|
138
|
+
) WHERE source_id IN (SELECT id FROM dedup_dropped);
|
|
139
|
+
UPDATE decision_links SET target_id = (
|
|
140
|
+
SELECT survivor_id FROM dedup_dropped WHERE id = decision_links.target_id
|
|
141
|
+
) WHERE target_id IN (SELECT id FROM dedup_dropped);
|
|
142
|
+
`);
|
|
143
|
+
// Both cleanups are scoped to the pairs repointing actually touched. An earlier version
|
|
144
|
+
// deduplicated decision_links table-wide, which deleted user-earned edges that had nothing
|
|
145
|
+
// to do with duplication - and kept the lowest UUID rather than the highest confidence.
|
|
146
|
+
db.exec(`
|
|
147
|
+
DELETE FROM decision_links
|
|
148
|
+
WHERE source_id = target_id
|
|
149
|
+
AND (source_id IN (SELECT survivor_id FROM dedup_dropped)
|
|
150
|
+
OR target_id IN (SELECT survivor_id FROM dedup_dropped));
|
|
151
|
+
`);
|
|
152
|
+
// Explicitly, not by CASCADE: SQLite leaves foreign_keys OFF unless asked, so the
|
|
153
|
+
// ON DELETE CASCADE in the schema does not fire and these would be orphaned.
|
|
154
|
+
db.exec(`DELETE FROM decision_embeddings WHERE decision_id IN (SELECT id FROM dedup_dropped)`);
|
|
155
|
+
db.exec(`DELETE FROM decisions WHERE id IN (SELECT id FROM dedup_dropped)`);
|
|
156
|
+
db.exec(`DROP TABLE dedup_dropped; DROP TABLE dedup_survivor;`);
|
|
157
|
+
// Created here rather than in SCHEMA on purpose: SCHEMA runs before this function, so on
|
|
158
|
+
// any already-duplicated graph the index would fail to build before the collapse could
|
|
159
|
+
// run. A fresh database passes through the same path with nothing to collapse.
|
|
160
|
+
//
|
|
161
|
+
// A distinct name from any previous attempt, because IF NOT EXISTS matches on the NAME
|
|
162
|
+
// only: an index of the same name with different columns would be silently kept, and
|
|
163
|
+
// every later insert would then fail with "ON CONFLICT clause does not match".
|
|
164
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS decisions_source_title_unique ON decisions(source_url, title)`);
|
|
165
|
+
// Same story for edges: insertLink's `OR IGNORE` was decorative without a unique index, so
|
|
166
|
+
// a re-import stacked an identical relates row every time. Collapse, then constrain.
|
|
167
|
+
//
|
|
168
|
+
// This one IS table-wide, unavoidably: the index cannot be created while any duplicate
|
|
169
|
+
// triple remains anywhere. Every row it removes duplicates another by definition - same
|
|
170
|
+
// source, same target, same relation. It keeps the HIGHEST confidence rather than the
|
|
171
|
+
// lowest uuid, so the survivor is the best score rather than an arbitrary one.
|
|
172
|
+
db.exec(`
|
|
173
|
+
DELETE FROM decision_links WHERE id NOT IN (
|
|
174
|
+
SELECT id FROM (
|
|
175
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
176
|
+
PARTITION BY source_id, target_id, relation ORDER BY confidence DESC, rowid
|
|
177
|
+
) AS rn FROM decision_links
|
|
178
|
+
) WHERE rn = 1
|
|
179
|
+
);
|
|
180
|
+
`);
|
|
181
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS decision_links_triple_unique ON decision_links(source_id, target_id, relation)`);
|
|
182
|
+
// Stamped INSIDE the transaction. Outside it, a process killed between COMMIT and the
|
|
183
|
+
// pragma replays the version-1 relabel on the next open, which turns an adjudicated
|
|
184
|
+
// conflicts_with edge into relates - the exact silent loss the docstring above warns of.
|
|
185
|
+
db.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
186
|
+
db.exec('COMMIT');
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
// Guarded: SQLITE_FULL, IOERR, BUSY, NOMEM and INTERRUPT auto-roll-back, and an
|
|
190
|
+
// unconditional ROLLBACK then throws "no transaction is active" and buries the real
|
|
191
|
+
// cause - so a user whose disk filled mid-migration would be told the wrong thing.
|
|
192
|
+
if (db.inTransaction)
|
|
193
|
+
db.exec('ROLLBACK');
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
48
197
|
if (version < SCHEMA_VERSION) {
|
|
49
198
|
db.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
50
199
|
}
|
|
@@ -61,10 +210,23 @@ export function createLocalDb(dbPath) {
|
|
|
61
210
|
db.exec(SCHEMA);
|
|
62
211
|
migrate(db);
|
|
63
212
|
return {
|
|
213
|
+
/**
|
|
214
|
+
* Insert, or refresh the decision that already carries this `source_url`, returning the id
|
|
215
|
+
* that now holds it.
|
|
216
|
+
*
|
|
217
|
+
* Refresh rather than ignore: a rewritten commit message or an edited Jira issue should be
|
|
218
|
+
* current in the graph. Returning the EXISTING id on a conflict is what keeps the caller's
|
|
219
|
+
* `setEmbedding` on the surviving row instead of orphaning a vector.
|
|
220
|
+
*
|
|
221
|
+
* A null `source_url` never conflicts - SQLite treats each NULL in a unique index as
|
|
222
|
+
* distinct - so `align capture` with no URL keeps inserting, by construction rather than by
|
|
223
|
+
* a special case here.
|
|
224
|
+
*/
|
|
64
225
|
insertDecision(row) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
226
|
+
const inserted = db.prepare(`INSERT INTO decisions (id, title, summary, source_url, platform) VALUES (?, ?, ?, ?, ?)
|
|
227
|
+
ON CONFLICT(source_url, title) DO UPDATE SET summary = excluded.summary, platform = excluded.platform
|
|
228
|
+
RETURNING id`).get(randomUUID(), row.title, row.summary, identifyingSourceUrl(row.sourceUrl), row.platform);
|
|
229
|
+
return inserted.id;
|
|
68
230
|
},
|
|
69
231
|
listDecisions() {
|
|
70
232
|
return db.prepare(`SELECT id, title, summary, source_url as sourceUrl, platform, created_at as createdAt FROM decisions ORDER BY created_at DESC`).all();
|
|
@@ -88,8 +250,20 @@ export function createLocalDb(dbPath) {
|
|
|
88
250
|
embedding: new Float32Array(r.embedding.buffer, r.embedding.byteOffset, r.embedding.byteLength / 4),
|
|
89
251
|
}));
|
|
90
252
|
},
|
|
253
|
+
/**
|
|
254
|
+
* `INSERT OR IGNORE` here was decorative: the id is a fresh UUID, so the only key that
|
|
255
|
+
* could conflict was guaranteed not to, and there was no unique index on the triple. With
|
|
256
|
+
* decisions now deduping, a re-import returns the SAME decision id and this added another
|
|
257
|
+
* identical edge every time - measured 1, 2, 3 over three imports, inflating the
|
|
258
|
+
* "similar decisions found" count `align local status` prints.
|
|
259
|
+
*
|
|
260
|
+
* The unique index (created in migrate) is what makes the OR IGNORE real. Confidence is
|
|
261
|
+
* refreshed rather than ignored so a better score replaces a worse one.
|
|
262
|
+
*/
|
|
91
263
|
insertLink(link) {
|
|
92
|
-
db.prepare(`INSERT
|
|
264
|
+
db.prepare(`INSERT INTO decision_links (id, source_id, target_id, relation, confidence) VALUES (?, ?, ?, ?, ?)
|
|
265
|
+
ON CONFLICT(source_id, target_id, relation)
|
|
266
|
+
DO UPDATE SET confidence = MAX(confidence, excluded.confidence)`).run(randomUUID(), link.sourceId, link.targetId, link.relation, link.confidence);
|
|
93
267
|
},
|
|
94
268
|
listLinks(filter) {
|
|
95
269
|
let sql = `SELECT id, source_id as sourceId, target_id as targetId, relation, confidence FROM decision_links WHERE 1=1`;
|
package/dist/lib/local-db.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-db.js","sourceRoot":"","sources":["../../src/lib/local-db.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AACtC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAwBpC,MAAM,MAAM,GAAG;;;;;;;;;;;;;;;;;;;;;;;CAuBd,CAAC;AAEF
|
|
1
|
+
{"version":3,"file":"local-db.js","sourceRoot":"","sources":["../../src/lib/local-db.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-embeddings.d.ts","sourceRoot":"","sources":["../../src/lib/local-embeddings.ts"],"names":[],"mappings":"AAGA,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,
|
|
1
|
+
{"version":3,"file":"local-embeddings.d.ts","sourceRoot":"","sources":["../../src/lib/local-embeddings.ts"],"names":[],"mappings":"AAGA,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CA6CtE;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,YAAY,EAAE,CAAC,EAAE,YAAY,GAAG,MAAM,CAmBzE"}
|
|
@@ -22,17 +22,20 @@ export async function getEmbedding(text) {
|
|
|
22
22
|
`(${err.message})`);
|
|
23
23
|
}
|
|
24
24
|
try {
|
|
25
|
-
// First call downloads
|
|
25
|
+
// First call downloads ~23MB from the Hugging Face Hub (huggingface.co), then caches.
|
|
26
26
|
// dtype 'q8' is load-bearing, not a perf tweak: @xenova/transformers@2 loaded
|
|
27
27
|
// quantized weights by default and v3+ default to fp32, which shifts pairwise
|
|
28
28
|
// cosine by up to 2.3e-02 against vectors already persisted in a user's local
|
|
29
29
|
// graph. Pinning q8 holds that to 6.6e-04. See local-embeddings-dtype.test.ts.
|
|
30
|
+
// The size follows from that pin: q8 fetches onnx/model_quantized.onnx (22.0MiB) plus
|
|
31
|
+
// the tokenizer, where fp32 would be 86.2MiB. This comment and the copy in setup.ts
|
|
32
|
+
// and README.md said "~90MB" for both, quoting the file the pin exists to avoid.
|
|
30
33
|
_pipe = (await mod.pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', {
|
|
31
34
|
dtype: 'q8',
|
|
32
35
|
}));
|
|
33
36
|
}
|
|
34
37
|
catch (err) {
|
|
35
|
-
throw new Error('Could not load the local embedding model (~
|
|
38
|
+
throw new Error('Could not load the local embedding model (~23MB, Xenova/all-MiniLM-L6-v2, from huggingface.co). ' +
|
|
36
39
|
'Check your internet connection or proxy and try again. ' +
|
|
37
40
|
`(${err.message})`);
|
|
38
41
|
}
|
|
@@ -41,6 +44,14 @@ export async function getEmbedding(text) {
|
|
|
41
44
|
return output[0].data;
|
|
42
45
|
}
|
|
43
46
|
export function cosineSimilarity(a, b) {
|
|
47
|
+
// Loudly, because the silent version is worse than a crash: this loop indexes `b[i]` over
|
|
48
|
+
// `a.length`, so a shorter `b` yields undefined -> NaN -> `NaN >= threshold` is false, and
|
|
49
|
+
// findSimilar drops the row as IRRELEVANT. A vector written by a different model would make
|
|
50
|
+
// decisions quietly unfindable with no error anywhere.
|
|
51
|
+
if (a.length !== b.length) {
|
|
52
|
+
throw new Error(`Embedding length mismatch: ${a.length} vs ${b.length}. The local graph holds a vector ` +
|
|
53
|
+
'from a different model - run `align local reset` and re-import to rebuild it.');
|
|
54
|
+
}
|
|
44
55
|
let dot = 0, normA = 0, normB = 0;
|
|
45
56
|
for (let i = 0; i < a.length; i++) {
|
|
46
57
|
dot += a[i] * b[i];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-embeddings.js","sourceRoot":"","sources":["../../src/lib/local-embeddings.ts"],"names":[],"mappings":"AACA,IAAI,KAAK,GAA6B,IAAI,CAAC;AAE3C,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAY;IAC7C,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,IAAI,GAAoG,CAAC;QACzG,IAAI,CAAC;YACH,+EAA+E;YAC/E,yEAAyE;YACzE,gFAAgF;YAChF,EAAE;YACF,0EAA0E;YAC1E,4EAA4E;YAC5E,2EAA2E;YAC3E,4EAA4E;YAC5E,yEAAyE;YACzE,uBAAuB;YACvB,MAAM,eAAe,GAAG,2BAA2B,CAAC;YACpD,GAAG,GAAG,CAAC,MAAM,MAAM,CAAC,eAAe,CAAC,CAA0B,CAAC;QACjE,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,IAAI,KAAK,CACb,uHAAuH;gBACvH,mHAAmH;gBACnH,IAAK,GAAa,CAAC,OAAO,GAAG,CAC9B,CAAC;QACJ,CAAC;QACD,IAAI,CAAC;YACH,
|
|
1
|
+
{"version":3,"file":"local-embeddings.js","sourceRoot":"","sources":["../../src/lib/local-embeddings.ts"],"names":[],"mappings":"AACA,IAAI,KAAK,GAA6B,IAAI,CAAC;AAE3C,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAY;IAC7C,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,IAAI,GAAoG,CAAC;QACzG,IAAI,CAAC;YACH,+EAA+E;YAC/E,yEAAyE;YACzE,gFAAgF;YAChF,EAAE;YACF,0EAA0E;YAC1E,4EAA4E;YAC5E,2EAA2E;YAC3E,4EAA4E;YAC5E,yEAAyE;YACzE,uBAAuB;YACvB,MAAM,eAAe,GAAG,2BAA2B,CAAC;YACpD,GAAG,GAAG,CAAC,MAAM,MAAM,CAAC,eAAe,CAAC,CAA0B,CAAC;QACjE,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,IAAI,KAAK,CACb,uHAAuH;gBACvH,mHAAmH;gBACnH,IAAK,GAAa,CAAC,OAAO,GAAG,CAC9B,CAAC;QACJ,CAAC;QACD,IAAI,CAAC;YACH,sFAAsF;YACtF,8EAA8E;YAC9E,8EAA8E;YAC9E,8EAA8E;YAC9E,+EAA+E;YAC/E,sFAAsF;YACtF,oFAAoF;YACpF,iFAAiF;YACjF,KAAK,GAAG,CAAC,MAAM,GAAG,CAAC,QAAQ,CAAC,oBAAoB,EAAE,yBAAyB,EAAE;gBAC3E,KAAK,EAAE,IAAI;aACZ,CAAC,CAAiC,CAAC;QACtC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,IAAI,KAAK,CACb,kGAAkG;gBAClG,yDAAyD;gBACzD,IAAK,GAAa,CAAC,OAAO,GAAG,CAC9B,CAAC;QACJ,CAAC;IACH,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,IAAI,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACvE,OAAO,MAAM,CAAC,CAAC,CAAE,CAAC,IAAI,CAAC;AACzB,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,CAAe,EAAE,CAAe;IAC/D,0FAA0F;IAC1F,2FAA2F;IAC3F,4FAA4F;IAC5F,uDAAuD;IACvD,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CACb,8BAA8B,CAAC,CAAC,MAAM,OAAO,CAAC,CAAC,MAAM,mCAAmC;YACxF,+EAA+E,CAChF,CAAC;IACJ,CAAC;IACD,IAAI,GAAG,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,CAAC;IAClC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAClC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAE,GAAG,CAAC,CAAC,CAAC,CAAE,CAAC;QACrB,KAAK,IAAI,CAAC,CAAC,CAAC,CAAE,GAAG,CAAC,CAAC,CAAC,CAAE,CAAC;QACvB,KAAK,IAAI,CAAC,CAAC,CAAC,CAAE,GAAG,CAAC,CAAC,CAAC,CAAE,CAAC;IACzB,CAAC;IACD,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAClD,OAAO,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,KAAK,CAAC;AACvC,CAAC"}
|
|
@@ -27,7 +27,39 @@ export declare const SIMILARITY_THRESHOLD = 0.65;
|
|
|
27
27
|
* than two wrong decisions, because the caller can act on it.
|
|
28
28
|
*/
|
|
29
29
|
export declare const SEARCH_THRESHOLD = 0.25;
|
|
30
|
+
/**
|
|
31
|
+
* Candidate floor for ADJUDICATION - the path that then pays an LLM per candidate.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately higher than the retrieval floor below, because this surface is expensive in
|
|
34
|
+
* three ways the hook is not: up to 5 sequential calls to the user's own provider, ~11s of
|
|
35
|
+
* latency, and an exit code. A keyless CI runner that gets `no-context` today exits 0; the
|
|
36
|
+
* moment retrieval finds anything it gets `unknown` and exits 2. And the classifier cannot
|
|
37
|
+
* reject a candidate - its vocabulary is ten positive relations with no "unrelated" - so a
|
|
38
|
+
* loose candidate that reaches it is reported rather than filtered.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately NOT recalibrated with the retrieval floor. Measured on the corpus, 0.30 would
|
|
41
|
+
* recover four more related pairs here too, and that trade is a separate decision with a
|
|
42
|
+
* separate blast radius (see relatedness-calibration.test.ts).
|
|
43
|
+
*/
|
|
30
44
|
export declare const RELATES_THRESHOLD = 0.45;
|
|
45
|
+
/**
|
|
46
|
+
* Candidate floor for RETRIEVAL ONLY (`depth: 'related'`, the agent editor hook).
|
|
47
|
+
*
|
|
48
|
+
* Measured, not chosen. Against fixtures/relatedness-corpus.json: 0.45 recovered 3 of 8 related
|
|
49
|
+
* pairs, 0.30 recovers 7 of 8, and neither admits a single unrelated pair. 0.25 scores the same
|
|
50
|
+
* 7 of 8, so the tie-break is margin over the worst false positive (0.2051): 0.30 clears it by
|
|
51
|
+
* 0.095 against 0.25's 0.045.
|
|
52
|
+
*
|
|
53
|
+
* 0.30 is also where this constant sat until commit 0cbef08 raised it to 0.45 inside a rename,
|
|
54
|
+
* unmentioned and unmeasured. So this is a revert with evidence attached rather than a new
|
|
55
|
+
* guess, and the evidence is a test that fails if the corpus stops supporting it.
|
|
56
|
+
*
|
|
57
|
+
* Safe to be looser here precisely because this path is cheap and honest: it returns above
|
|
58
|
+
* Stage 2, so it makes no provider call, and what the hook prints declines to assert anything
|
|
59
|
+
* ("related by content search and have NOT been adjudicated"). That is the same posture as
|
|
60
|
+
* `align search`, which has run at 0.25 all along - as has MCP `align_get_related_decisions`.
|
|
61
|
+
*/
|
|
62
|
+
export declare const RETRIEVAL_RELATES_THRESHOLD = 0.3;
|
|
31
63
|
export declare const DRIFT_THRESHOLD = 0.5;
|
|
32
64
|
export declare function createLocalGatewayClient(dbPath: string): {
|
|
33
65
|
/** Release the underlying SQLite handle (required on Windows before deleting the file). */
|
|
@@ -77,7 +109,10 @@ export declare function createLocalGatewayClient(dbPath: string): {
|
|
|
77
109
|
status: string;
|
|
78
110
|
}[]>;
|
|
79
111
|
searchDecisions(query: string, limit?: number): Promise<SearchResults>;
|
|
80
|
-
checkAlignment(diff: string, _context?: string
|
|
112
|
+
checkAlignment(diff: string, _context?: string, opts?: {
|
|
113
|
+
depth?: "related" | "full";
|
|
114
|
+
title?: string;
|
|
115
|
+
}): Promise<AlignmentResult>;
|
|
81
116
|
checkDrift(decisionId: string, content: string, _sourceType?: string): Promise<{
|
|
82
117
|
decisionId: string;
|
|
83
118
|
score: null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-gateway-client.d.ts","sourceRoot":"","sources":["../../src/lib/local-gateway-client.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"local-gateway-client.d.ts","sourceRoot":"","sources":["../../src/lib/local-gateway-client.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAE1E;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,OAAO,CAAC;AACzC;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,gBAAgB,OAAO,CAAC;AACrC;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAG/C,eAAO,MAAM,eAAe,MAAM,CAAC;AAEnC,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,MAAM;IA2DnD,2FAA2F;;;;;;;2BAS9D,MAAM;;;;;;;;uBAKV,KAAK,CAAC;QAAE,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;;;;;;;;;;;;;;;2BA6BhF;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE;;;;;;;;;2BAgBjB,MAAM,mBAAe,OAAO,CAAC,aAAa,CAAC;yBAiChE,MAAM,aACD,MAAM,SACX;QAAE,KAAK,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GACnD,OAAO,CAAC,eAAe,CAAC;2BAuJE,MAAM,WAAW,MAAM,gBAAgB,MAAM;;;;;;;;;;;0BAQ9C,MAAM;;;;;;;;EAmBrC;AAED,MAAM,MAAM,kBAAkB,GAAG,UAAU,CAAC,OAAO,wBAAwB,CAAC,CAAC"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createLocalDb } from './local-db.js';
|
|
2
2
|
import { cosineSimilarity, getEmbedding } from './local-embeddings.js';
|
|
3
3
|
import { classifyRelationship } from './local-relationship-classifier.js';
|
|
4
|
+
import { RECOMMENDED_OLLAMA_PULL } from './local-llm.js';
|
|
4
5
|
import { citationFor, repositoryOf } from './decision-links.js';
|
|
5
6
|
/**
|
|
6
7
|
* Cosine floor for linking two decisions as related on ingest.
|
|
@@ -30,9 +31,39 @@ export const SIMILARITY_THRESHOLD = 0.65;
|
|
|
30
31
|
* than two wrong decisions, because the caller can act on it.
|
|
31
32
|
*/
|
|
32
33
|
export const SEARCH_THRESHOLD = 0.25;
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Candidate floor for ADJUDICATION - the path that then pays an LLM per candidate.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately higher than the retrieval floor below, because this surface is expensive in
|
|
38
|
+
* three ways the hook is not: up to 5 sequential calls to the user's own provider, ~11s of
|
|
39
|
+
* latency, and an exit code. A keyless CI runner that gets `no-context` today exits 0; the
|
|
40
|
+
* moment retrieval finds anything it gets `unknown` and exits 2. And the classifier cannot
|
|
41
|
+
* reject a candidate - its vocabulary is ten positive relations with no "unrelated" - so a
|
|
42
|
+
* loose candidate that reaches it is reported rather than filtered.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately NOT recalibrated with the retrieval floor. Measured on the corpus, 0.30 would
|
|
45
|
+
* recover four more related pairs here too, and that trade is a separate decision with a
|
|
46
|
+
* separate blast radius (see relatedness-calibration.test.ts).
|
|
47
|
+
*/
|
|
35
48
|
export const RELATES_THRESHOLD = 0.45;
|
|
49
|
+
/**
|
|
50
|
+
* Candidate floor for RETRIEVAL ONLY (`depth: 'related'`, the agent editor hook).
|
|
51
|
+
*
|
|
52
|
+
* Measured, not chosen. Against fixtures/relatedness-corpus.json: 0.45 recovered 3 of 8 related
|
|
53
|
+
* pairs, 0.30 recovers 7 of 8, and neither admits a single unrelated pair. 0.25 scores the same
|
|
54
|
+
* 7 of 8, so the tie-break is margin over the worst false positive (0.2051): 0.30 clears it by
|
|
55
|
+
* 0.095 against 0.25's 0.045.
|
|
56
|
+
*
|
|
57
|
+
* 0.30 is also where this constant sat until commit 0cbef08 raised it to 0.45 inside a rename,
|
|
58
|
+
* unmentioned and unmeasured. So this is a revert with evidence attached rather than a new
|
|
59
|
+
* guess, and the evidence is a test that fails if the corpus stops supporting it.
|
|
60
|
+
*
|
|
61
|
+
* Safe to be looser here precisely because this path is cheap and honest: it returns above
|
|
62
|
+
* Stage 2, so it makes no provider call, and what the hook prints declines to assert anything
|
|
63
|
+
* ("related by content search and have NOT been adjudicated"). That is the same posture as
|
|
64
|
+
* `align search`, which has run at 0.25 all along - as has MCP `align_get_related_decisions`.
|
|
65
|
+
*/
|
|
66
|
+
export const RETRIEVAL_RELATES_THRESHOLD = 0.3;
|
|
36
67
|
// Below this similarity between a decision and new content, the content is
|
|
37
68
|
// considered to have drifted from the decision.
|
|
38
69
|
export const DRIFT_THRESHOLD = 0.5;
|
|
@@ -169,10 +200,16 @@ export function createLocalGatewayClient(dbPath) {
|
|
|
169
200
|
.filter((d) => d !== null);
|
|
170
201
|
return { results, count: results.length, strategy: 'semantic' };
|
|
171
202
|
},
|
|
172
|
-
async checkAlignment(diff, _context) {
|
|
203
|
+
async checkAlignment(diff, _context, opts = {}) {
|
|
173
204
|
// Stage 1: embeddings find candidate related decisions (free, local).
|
|
205
|
+
//
|
|
206
|
+
// The floor depends on what the caller will DO with the candidates. Retrieval-only stops
|
|
207
|
+
// above Stage 2, so a looser match costs one extra title in prose that asserts nothing;
|
|
208
|
+
// adjudication pays a provider call per candidate and can move an exit code, so it keeps
|
|
209
|
+
// the stricter bar. One constant could not serve both.
|
|
210
|
+
const threshold = opts.depth === 'related' ? RETRIEVAL_RELATES_THRESHOLD : RELATES_THRESHOLD;
|
|
174
211
|
const embedding = await getEmbedding(diff);
|
|
175
|
-
const similar = await findSimilar(embedding, 5,
|
|
212
|
+
const similar = await findSimilar(embedding, 5, threshold);
|
|
176
213
|
const candidates = similar
|
|
177
214
|
.map(s => {
|
|
178
215
|
const row = db.getDecisionById(s.decisionId);
|
|
@@ -182,12 +219,53 @@ export function createLocalGatewayClient(dbPath) {
|
|
|
182
219
|
if (!candidates.length) {
|
|
183
220
|
return { status: 'no-context', confidence: 0, relevant_decisions: [], conflicts: [], message: 'No related decisions found in your local graph.' };
|
|
184
221
|
}
|
|
222
|
+
// `depth:'related'` means retrieval only, and honouring it matters more here than in the
|
|
223
|
+
// cloud client. The editor hook asks for it to fit a <=10s budget (check.ts), but in
|
|
224
|
+
// local mode Stage 2 is also the only EGRESS in the pipeline: it posts the proposed
|
|
225
|
+
// content plus a stored decision to the user's own LLM provider, once per candidate, on
|
|
226
|
+
// every agent Write/Edit. This signature took two arguments, so the option was silently
|
|
227
|
+
// dropped and adjudication ran anyway - up to 5 provider calls per keystroke-level event,
|
|
228
|
+
// whose results the hook then abandoned at its 2.5s race.
|
|
229
|
+
//
|
|
230
|
+
// Matched as an allowlist-of-one rather than `!== 'full'`, which would be the safer
|
|
231
|
+
// polarity for an egress guard, because the two callers that MUST adjudicate
|
|
232
|
+
// (`align check` and `--ci`, check.ts) pass no depth at all. Inverting it would silence
|
|
233
|
+
// them. The cost of this direction: a future caller misspelling the value adjudicates,
|
|
234
|
+
// so keep `depth` typed as the union at every call site rather than widening it.
|
|
235
|
+
if (opts.depth === 'related') {
|
|
236
|
+
return {
|
|
237
|
+
status: 'retrieved',
|
|
238
|
+
confidence: Math.max(...candidates.map(c => c.score)),
|
|
239
|
+
relevant_decisions: candidates.map(c => ({
|
|
240
|
+
id: c.id,
|
|
241
|
+
title: c.title,
|
|
242
|
+
summary: c.summary,
|
|
243
|
+
similarity: c.score,
|
|
244
|
+
url: c.sourceUrl ?? undefined,
|
|
245
|
+
})),
|
|
246
|
+
conflicts: [],
|
|
247
|
+
message: `Found ${candidates.length} related decision(s) - retrieval only, not adjudicated.`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
185
250
|
// Stage 2: type each candidate against the proposed change (LLM, user's key,
|
|
186
251
|
// lazy - only the few candidates we surface here). Degrades to untyped.
|
|
187
|
-
|
|
252
|
+
// The caller's title when it gave one: `align check --title` exists because adjudicating
|
|
253
|
+
// on a bare diff means judging a file header and a few `+` lines. Accepting the option in
|
|
254
|
+
// the signature and then classifying against a placeholder is the dropped-`depth` defect
|
|
255
|
+
// one field over.
|
|
256
|
+
const subject = { title: opts.title ?? 'Proposed change', summary: diff.slice(0, 2000) };
|
|
188
257
|
const typed = [];
|
|
258
|
+
let chainStopped = false;
|
|
189
259
|
for (const c of candidates) {
|
|
190
|
-
|
|
260
|
+
// ALI-692: a recorded chain stop is a property of the PROVIDER, not of this
|
|
261
|
+
// candidate, so asking again per candidate repeats one doomed call N times -
|
|
262
|
+
// on a 429 that burns the retry budget while `--advisory` races its deadline.
|
|
263
|
+
// The remaining candidates still report, untyped, which is what `unknown` means.
|
|
264
|
+
const outcome = chainStopped
|
|
265
|
+
? { ok: false, reason: 'classifier_error' }
|
|
266
|
+
: await classifyRelationship(subject, { title: c.title, summary: c.summary });
|
|
267
|
+
if (!outcome.ok && outcome.failure?.kind === 'provider_stopped')
|
|
268
|
+
chainStopped = true;
|
|
191
269
|
const rel = outcome.ok ? outcome.relationship : null;
|
|
192
270
|
typed.push({
|
|
193
271
|
id: c.id,
|
|
@@ -198,6 +276,10 @@ export function createLocalGatewayClient(dbPath) {
|
|
|
198
276
|
confidence: rel?.confidence ?? c.score,
|
|
199
277
|
typed: rel !== null,
|
|
200
278
|
failureReason: outcome.ok ? undefined : outcome.reason,
|
|
279
|
+
// The diagnosis travels WITH the candidate it describes, so the hint below
|
|
280
|
+
// names the model that failed on this one rather than whatever a module
|
|
281
|
+
// getter happened to hold by the time the loop finished.
|
|
282
|
+
failure: outcome.ok ? undefined : outcome.failure,
|
|
201
283
|
reason: rel?.reason,
|
|
202
284
|
similarity: c.score,
|
|
203
285
|
});
|
|
@@ -231,11 +313,17 @@ export function createLocalGatewayClient(dbPath) {
|
|
|
231
313
|
if (unclassified) {
|
|
232
314
|
// ALI-420: an unvetted local model gets its own remedy. The no_llm_key hint below
|
|
233
315
|
// says "or run a local Ollama", which is nonsense to someone already running one.
|
|
316
|
+
// ALI-692: the third rung. A recorded chain stop names the model that failed,
|
|
317
|
+
// and this is the surface agents gate on - it used to fall through to an empty
|
|
318
|
+
// hint, discarding the diagnosis one frame above where it was recorded.
|
|
319
|
+
const failure = unclassified.failure;
|
|
234
320
|
const hint = unclassified.failureReason === 'unvetted_local_model'
|
|
235
|
-
?
|
|
321
|
+
? ` Ollama is running, but no recognised model is installed: \`ollama pull ${RECOMMENDED_OLLAMA_PULL}\`, or set ALIGN_OLLAMA_MODEL to name your own.`
|
|
236
322
|
: unclassified.failureReason === 'no_llm_key'
|
|
237
323
|
? ' Set ANTHROPIC_API_KEY or OPENAI_API_KEY (or run a local Ollama) so these can be classified.'
|
|
238
|
-
: ''
|
|
324
|
+
: failure?.kind === 'provider_stopped'
|
|
325
|
+
? ` ${failure.model} (${failure.provider}) returned an unusable response (${failure.detail}), and no weaker model was asked in its place.`
|
|
326
|
+
: '';
|
|
239
327
|
return {
|
|
240
328
|
status: 'unknown',
|
|
241
329
|
reason: unclassified.failureReason,
|