@ngockhoale/ukit 2.6.8 → 2.6.10
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/CHANGELOG.md +52 -0
- package/package.json +1 -1
- package/scripts/index/build-index.mjs +2 -1
- package/scripts/index/query-index.mjs +2 -0
- package/src/cli/commands/indexTools.js +5 -0
- package/src/cli/commands/install.js +2 -1
- package/src/cli/commands/metrics.js +127 -0
- package/src/cli/index.js +7 -0
- package/src/core/codeintel/analogy.js +197 -0
- package/src/core/codeintel/cochange.js +205 -0
- package/src/core/codeintel/compiler.js +82 -9
- package/src/core/codeintel/graph.js +291 -0
- package/src/core/codeintel/impact.js +23 -0
- package/src/core/codeintel/packet.js +1 -0
- package/src/core/codeintel/retriever.js +49 -3
- package/src/core/codeintel/summaries.js +194 -0
- package/src/core/codeintel/vectorProvider.js +213 -0
- package/src/core/runtimeConfig.js +50 -2
- package/src/diagnostics/failurePatterns.js +187 -0
- package/src/diagnostics/routeOutcomes.js +146 -0
- package/src/index/buildIndex.js +29 -0
- package/src/index/paths.js +2 -0
- package/templates/.claude/hooks/block-dangerous.sh +29 -5
- package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
- package/templates/.claude/hooks/protect-files.sh +26 -3
- package/templates/.claude/ukit/index/route-task.mjs +57 -6
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +9 -1
- package/templates/.claude/ukit/runtime/hook-input.sh +26 -3
- package/templates/ukit/storage/config.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,58 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.6.9 - 2026-09-19
|
|
6
|
+
|
|
7
|
+
Code Intelligence scale release — cycle C30 (CI-301..305, TASK-218..222).
|
|
8
|
+
|
|
9
|
+
- Vector lane: dep-free `vectorProvider.js` (FNV-1a feature-hashed embeddings,
|
|
10
|
+
L2-normalized, cosine-ranked; injectable loader seam for real embeddings,
|
|
11
|
+
degrade to hashed/null); wired as RRF lane w=0.6 between bm25 and semantic.
|
|
12
|
+
- Analogy lane: `analogy.js` fingerprints memory `procedure` records + index
|
|
13
|
+
symbols/files; `analogy` compile mode ranks by Jaccard, hits land in
|
|
14
|
+
packet.memory + L2 evidence.
|
|
15
|
+
- Co-change mining: `cochange.js` mines `git log --name-only` (spawnSync,
|
|
16
|
+
bounded, core.quotePath=false) into `cochange.json`; impact sets gain a
|
|
17
|
+
provenance-preserving co-change lane (kind:'cochange', confidence=count/10).
|
|
18
|
+
- L3 summaries: `summaries.js` extractive docblock/symbol summaries for
|
|
19
|
+
deep_flow+analogy packets; `packetToText` renders `summary:` lines.
|
|
20
|
+
- Graph store: `graph.js` `codegraph.json` JsonGraphStore (imports+calls+
|
|
21
|
+
cochange → nodes/edges, content-gated atomic write), contract-only
|
|
22
|
+
scip/pdg/lsp stubs via `createGraphProvider`.
|
|
23
|
+
- Derived artifacts wired into `ukit index build|refresh`, stale-refresh,
|
|
24
|
+
install, and index scripts via `buildDerivedIndexArtifacts` (config-gated,
|
|
25
|
+
never fatal, outside the core build's atomic-rename contract).
|
|
26
|
+
- Config: `codeIntel.{embedding,analogy,cochange,summaries,graph}` +
|
|
27
|
+
`retriever.weights.vector` validated in runtimeConfig with template parity;
|
|
28
|
+
`enabled:false` honored for analogy/graph lanes.
|
|
29
|
+
- Verification: full suite 2720/2720 green; unpacked ceiling 6.9MB.
|
|
30
|
+
|
|
31
|
+
## 2.6.8 - 2026-09-19
|
|
32
|
+
|
|
33
|
+
Code Intelligence Plane + memory v2 release — cycles C26–C29
|
|
34
|
+
(TASK-001..011, CI-201..206, DOC-201..206, DOC-301..305).
|
|
35
|
+
|
|
36
|
+
- Memory v2: 4-type record model (`project_rule|derived_fact|episode|procedure`),
|
|
37
|
+
atomic `records.json` store, backup-first idempotent migration from v1,
|
|
38
|
+
`ukit memory v2` ops; legacy `.ukit/storage/memory/` layout and all
|
|
39
|
+
`ukit memory *` subcommands stay compatible.
|
|
40
|
+
- Code Intelligence Plane: `compileContext` → Context Packet v1 with
|
|
41
|
+
peek→expand→read levels and token budgets; Task Router v1; freshness
|
|
42
|
+
identity (`HEAD + overlay + config`) with incremental dirty-gated reuse
|
|
43
|
+
and per-branch snapshots; hybrid retriever (exact + symbol + BM25 + RRF
|
|
44
|
+
rerank); `impact.js` multi-hop impact sets; post-edit `invalidation.js`
|
|
45
|
+
dirty-set and `diagnostics.js` feeding packet `next_actions`;
|
|
46
|
+
`TypeScriptSemanticProvider` (optional `typescript` import — no new deps,
|
|
47
|
+
degrade-safe).
|
|
48
|
+
- CLI: `ukit code peek|search|context|impact` (`--depth`, `--diagnostics`),
|
|
49
|
+
`ukit index status|doctor`, `ukit doctor --docs` (doc-contract checks).
|
|
50
|
+
- Docs governance: `docContracts.js` validator (links/stale-version/
|
|
51
|
+
render-vars/commands/budgets/registry/templates/dead-docs/archive-suggestions),
|
|
52
|
+
`context_layers` + `docs=` route segment, host capability matrix, BUG docs
|
|
53
|
+
consolidated into `docs/BUG_INDEX.md`, PROMPT_CACHING canonical/vendor split,
|
|
54
|
+
`templates/instructions/core.md` 210→157 lines.
|
|
55
|
+
- Benchmarks: `scripts/bench/runGold.mjs` scorecard + `--json` export.
|
|
56
|
+
|
|
5
57
|
## 2.6.7 - 2026-09-19
|
|
6
58
|
|
|
7
59
|
Canonical-instructions release — second roadmap slice (cycle C25,
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { buildCodeIndex } from '../../src/index/buildIndex.js';
|
|
4
|
+
import { buildCodeIndex, buildDerivedIndexArtifacts } from '../../src/index/buildIndex.js';
|
|
5
5
|
|
|
6
6
|
const args = process.argv.slice(2);
|
|
7
7
|
const rootArg = readFlagValue(args, '--root');
|
|
8
8
|
const rootDir = rootArg ? path.resolve(rootArg) : process.cwd();
|
|
9
9
|
|
|
10
10
|
const summary = await buildCodeIndex({ rootDir });
|
|
11
|
+
await buildDerivedIndexArtifacts(rootDir);
|
|
11
12
|
|
|
12
13
|
console.log('[index:build] completed');
|
|
13
14
|
console.log(`root: ${rootDir}`);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from '../../src/index/queryIndex.js';
|
|
7
7
|
import {
|
|
8
8
|
buildCodeIndex,
|
|
9
|
+
buildDerivedIndexArtifacts,
|
|
9
10
|
isIndexStale,
|
|
10
11
|
DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
11
12
|
} from '../../src/index/buildIndex.js';
|
|
@@ -28,6 +29,7 @@ if (!query) {
|
|
|
28
29
|
const stale = await isIndexStale({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS });
|
|
29
30
|
if (stale) {
|
|
30
31
|
await buildCodeIndex({ rootDir });
|
|
32
|
+
await buildDerivedIndexArtifacts(rootDir);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
const limit = Number.parseInt(flags.get('--limit') ?? '5', 10);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildCodeIndex,
|
|
3
|
+
buildDerivedIndexArtifacts,
|
|
3
4
|
isIndexStale,
|
|
4
5
|
DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
5
6
|
getIndexArtifactGeneratedAt,
|
|
@@ -42,6 +43,9 @@ export async function runIndexTools({ projectRoot, argv = [] }) {
|
|
|
42
43
|
|
|
43
44
|
if (subcommand === 'build' || subcommand === 'refresh') {
|
|
44
45
|
const summary = await buildCodeIndex({ rootDir: projectRoot });
|
|
46
|
+
// Derived artifacts (CI-303/CI-305): cochange.json + codegraph.json.
|
|
47
|
+
// Best-effort, config-gated, never fatal.
|
|
48
|
+
await buildDerivedIndexArtifacts(projectRoot);
|
|
45
49
|
const label = subcommand === 'build' ? 'build' : 'refresh';
|
|
46
50
|
console.log(`[UKit] Index ${label} completed.`);
|
|
47
51
|
console.log(`[UKit] files=${summary.fileCount}, symbols=${summary.symbolCount}, imports=${summary.importCount}`);
|
|
@@ -389,6 +393,7 @@ async function refreshIndexIfStale(rootDir) {
|
|
|
389
393
|
}
|
|
390
394
|
|
|
391
395
|
await buildCodeIndex({ rootDir });
|
|
396
|
+
await buildDerivedIndexArtifacts(rootDir);
|
|
392
397
|
|
|
393
398
|
if (lastRefreshMs !== null) {
|
|
394
399
|
console.log(`[UKit] stale index refreshed (last=${new Date(lastRefreshMs).toISOString()})`);
|
|
@@ -2,7 +2,7 @@ import { buildPathConfig } from '../../core/paths.js';
|
|
|
2
2
|
import { runInstallPipeline } from '../../core/runInstallPipeline.js';
|
|
3
3
|
import { formatRepairReport } from '../../core/repairBrokenHooks.js';
|
|
4
4
|
import { formatGatewayResilienceReport } from '../../core/gatewayResilienceEnv.js';
|
|
5
|
-
import { buildCodeIndex } from '../../index/buildIndex.js';
|
|
5
|
+
import { buildCodeIndex, buildDerivedIndexArtifacts } from '../../index/buildIndex.js';
|
|
6
6
|
import { installIndexRefreshHooks } from '../../index/gitHooks.js';
|
|
7
7
|
import fs from 'node:fs/promises';
|
|
8
8
|
import { pathExists, readJsonIfExists, removeFileOrLinkOnly } from '../../core/fileOps.js';
|
|
@@ -323,6 +323,7 @@ export async function runInstall({ packageRoot, projectRoot, packageVersion, arg
|
|
|
323
323
|
let indexSummary;
|
|
324
324
|
try {
|
|
325
325
|
indexSummary = await buildCodeIndex({ rootDir: projectRoot });
|
|
326
|
+
await buildDerivedIndexArtifacts(projectRoot);
|
|
326
327
|
} catch (error) {
|
|
327
328
|
console.log('[UKit] Install files synced, but source index build failed.');
|
|
328
329
|
console.log('[UKit] Fix the repo state, then rerun `ukit install` so the AI can index source code first.');
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// `ukit metrics` — read-only telemetry roll-up (SPEC §6, SI-102).
|
|
2
|
+
// Route outcomes + failure patterns (lazy, degrade to n/a) + v2 memory count.
|
|
3
|
+
// Never exits non-zero on missing telemetry; unknown flag → exit 1.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { collectRouteOutcomes } from '../../diagnostics/routeOutcomes.js';
|
|
8
|
+
import { buildRuntimePaths } from '../../core/runtimePaths.js';
|
|
9
|
+
|
|
10
|
+
const HELP_FLAGS = new Set(['--help', '-h', 'help']);
|
|
11
|
+
const KNOWN_FLAGS = new Set(['--json']);
|
|
12
|
+
|
|
13
|
+
const MODE_FIELDS = ['routes', 'joined', 'writeOk', 'writeFail', 'verifyAttempted', 'verifyOk', 'verifyFail', 'rescue', 'stalled'];
|
|
14
|
+
|
|
15
|
+
function printUsage() {
|
|
16
|
+
console.log('Usage: ukit metrics [--json]');
|
|
17
|
+
console.log('');
|
|
18
|
+
console.log('Print a read-only roll-up of UKit telemetry:');
|
|
19
|
+
console.log(' route-outcomes route-audit ↔ exec-ledger join stats per mode/taskType');
|
|
20
|
+
console.log(' failure-patterns mined failure signatures (n/a when unavailable)');
|
|
21
|
+
console.log(' memory v2 record count by status');
|
|
22
|
+
console.log('');
|
|
23
|
+
console.log('Options:');
|
|
24
|
+
console.log(' --json Print the full roll-up object as JSON');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function collectMemorySummary(projectRoot) {
|
|
28
|
+
try {
|
|
29
|
+
const { memoryV2RecordsPath } = buildRuntimePaths(projectRoot);
|
|
30
|
+
const raw = await fs.readFile(memoryV2RecordsPath, 'utf8');
|
|
31
|
+
const doc = JSON.parse(raw);
|
|
32
|
+
const records = Array.isArray(doc) ? doc : (Array.isArray(doc?.records) ? doc.records : []);
|
|
33
|
+
const byStatus = {};
|
|
34
|
+
for (const record of records) {
|
|
35
|
+
const status = record?.status ?? 'unknown';
|
|
36
|
+
byStatus[status] = (byStatus[status] ?? 0) + 1;
|
|
37
|
+
}
|
|
38
|
+
return { total: records.length, byStatus };
|
|
39
|
+
} catch {
|
|
40
|
+
return { total: 0, byStatus: {} };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function collectFailurePatterns(projectRoot) {
|
|
45
|
+
try {
|
|
46
|
+
const mod = await import('../../diagnostics/failurePatterns.js');
|
|
47
|
+
if (typeof mod?.mineFailurePatterns !== 'function') return null;
|
|
48
|
+
return await mod.mineFailurePatterns(projectRoot);
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printRouteOutcomes(routeOutcomes) {
|
|
55
|
+
console.log('route-outcomes');
|
|
56
|
+
console.log(` ledgers: ${routeOutcomes.ledgersScanned} audit rows: ${routeOutcomes.auditRowsScanned} joined: ${routeOutcomes.joined} coverage: ${(routeOutcomes.joinCoverage * 100).toFixed(1)}% unmatched audit: ${routeOutcomes.unmatchedAudit} unmatched ledger: ${routeOutcomes.unmatchedLedger}`);
|
|
57
|
+
const header = ['mode', ...MODE_FIELDS];
|
|
58
|
+
console.log(` ${header.join('\t')}`);
|
|
59
|
+
for (const [mode, bucket] of Object.entries(routeOutcomes.byMode)) {
|
|
60
|
+
console.log(` ${[mode, ...MODE_FIELDS.map((field) => bucket[field] ?? 0)].join('\t')}`);
|
|
61
|
+
}
|
|
62
|
+
if (Object.keys(routeOutcomes.byMode).length === 0) {
|
|
63
|
+
console.log(' (no route-audit data)');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function printFailurePatterns(failurePatterns) {
|
|
68
|
+
console.log('failure-patterns');
|
|
69
|
+
if (!failurePatterns) {
|
|
70
|
+
console.log(' n/a');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const patterns = Array.isArray(failurePatterns.patterns) ? failurePatterns.patterns : [];
|
|
74
|
+
if (patterns.length === 0) {
|
|
75
|
+
console.log(' (none)');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const pattern of patterns) {
|
|
79
|
+
console.log(` ${pattern.count}× ${pattern.signature} sessions=${pattern.sessions}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function printMemory(memory) {
|
|
84
|
+
const statusPairs = Object.entries(memory.byStatus).map(([status, count]) => `${status}=${count}`).join(' ');
|
|
85
|
+
console.log(`memory: v2 records=${memory.total}${statusPairs ? ` (${statusPairs})` : ''}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function runMetrics({ projectRoot, argv = [] }) {
|
|
89
|
+
if (argv.some((flag) => HELP_FLAGS.has(flag))) {
|
|
90
|
+
printUsage();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const unknown = argv.filter((flag) => !KNOWN_FLAGS.has(flag));
|
|
94
|
+
if (unknown.length > 0) {
|
|
95
|
+
console.error(`[UKit] Unknown metrics flag(s): ${unknown.join(', ')}`);
|
|
96
|
+
printUsage();
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const routeOutcomes = await collectRouteOutcomes(projectRoot).catch((error) => ({
|
|
102
|
+
ledgersScanned: 0,
|
|
103
|
+
auditRowsScanned: 0,
|
|
104
|
+
joined: 0,
|
|
105
|
+
unmatchedAudit: 0,
|
|
106
|
+
unmatchedLedger: 0,
|
|
107
|
+
byMode: {},
|
|
108
|
+
byTaskType: {},
|
|
109
|
+
joinCoverage: 0,
|
|
110
|
+
error: error?.message ?? String(error),
|
|
111
|
+
}));
|
|
112
|
+
const failurePatterns = await collectFailurePatterns(projectRoot);
|
|
113
|
+
const memory = await collectMemorySummary(projectRoot);
|
|
114
|
+
|
|
115
|
+
const rollup = { routeOutcomes, failurePatterns, memory };
|
|
116
|
+
|
|
117
|
+
if (argv.includes('--json')) {
|
|
118
|
+
console.log(JSON.stringify(rollup, null, 2));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
printRouteOutcomes(routeOutcomes);
|
|
123
|
+
console.log('');
|
|
124
|
+
printFailurePatterns(failurePatterns);
|
|
125
|
+
console.log('');
|
|
126
|
+
printMemory(memory);
|
|
127
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { runStatus } from './commands/status.js';
|
|
|
7
7
|
import { runMemory } from './commands/memory.js';
|
|
8
8
|
import { runUpdate } from './commands/update.js';
|
|
9
9
|
import { runCode } from './commands/code.js';
|
|
10
|
+
import { runMetrics } from './commands/metrics.js';
|
|
10
11
|
|
|
11
12
|
const GLOBAL_FLAGS = new Set(['--help', '-h', '--version', '-v']);
|
|
12
13
|
|
|
@@ -65,6 +66,11 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
|
|
|
65
66
|
return;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
if (command === 'metrics') {
|
|
70
|
+
await runMetrics({ projectRoot, argv: commandArgv });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
if (command === 'update') {
|
|
69
75
|
await runUpdate({ packageVersion, argv: commandArgv });
|
|
70
76
|
return;
|
|
@@ -98,6 +104,7 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
|
|
|
98
104
|
console.log(' code Context compiler surface (peek/search/context/impact)');
|
|
99
105
|
console.log(' status Show UKit runtime status');
|
|
100
106
|
console.log(' memory Inspect shared UKit memory');
|
|
107
|
+
console.log(' metrics Telemetry roll-up (route outcomes, failure patterns, memory)');
|
|
101
108
|
console.log(' update Upgrade the global UKit CLI to the latest version');
|
|
102
109
|
console.log(' version Show UKit version');
|
|
103
110
|
console.log('');
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { getArtifactPath, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION } from '../../index/paths.js';
|
|
5
|
+
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
6
|
+
|
|
7
|
+
// Analogy lane (SPEC §4, CI-302): ranks memory `procedure` records and
|
|
8
|
+
// index-bound file/symbol fingerprints against the query by Jaccard overlap.
|
|
9
|
+
// Read-only over both stores; every failure mode degrades into `omitted`
|
|
10
|
+
// reasons — never throws, never fabricates analogies.
|
|
11
|
+
//
|
|
12
|
+
// fingerprint = sorted-unique normalized features:
|
|
13
|
+
// lowercase [a-z0-9_$.-] tokens (length >= 3, minus stopwords) plus
|
|
14
|
+
// camelCase/punctuation sub-splits so `routeTaskFiles` shares features with
|
|
15
|
+
// "route task files" and `src/routing.js` shares `routing`.
|
|
16
|
+
|
|
17
|
+
const STOPWORDS = new Set([
|
|
18
|
+
'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
|
|
19
|
+
'this', 'that', 'it', 'as', 'by', 'be', 'use', 'using', 'implement', 'fix', 'task',
|
|
20
|
+
'cần', 'và', 'là', 'cho', 'một', 'những', 'dùng',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const MAX_MATCHED_FEATURES = 8;
|
|
24
|
+
const DEFAULT_LIMIT = 5;
|
|
25
|
+
|
|
26
|
+
function pushToken(set, raw) {
|
|
27
|
+
const token = String(raw ?? '').toLowerCase();
|
|
28
|
+
if (token.length >= 3 && !STOPWORDS.has(token)) set.add(token);
|
|
29
|
+
// Sub-split on punctuation + camelCase boundaries so compound tokens share
|
|
30
|
+
// features with their parts (e.g. `routeTaskFiles` → route/task/files).
|
|
31
|
+
const split = String(raw ?? '').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
32
|
+
for (const part of split.split(/[^A-Za-z0-9_$]+/)) {
|
|
33
|
+
const sub = part.toLowerCase();
|
|
34
|
+
if (sub.length >= 3 && !STOPWORDS.has(sub)) set.add(sub);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* fingerprintText(text) → string[] sorted-unique normalized features.
|
|
40
|
+
*/
|
|
41
|
+
export function fingerprintText(text) {
|
|
42
|
+
const features = new Set();
|
|
43
|
+
for (const token of String(text ?? '').split(/[^A-Za-z0-9_$.-]+/)) {
|
|
44
|
+
pushToken(features, token);
|
|
45
|
+
}
|
|
46
|
+
return [...features].sort();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* fingerprintRecord(record) → string[] | null.
|
|
51
|
+
* Only `procedure` records carry an analogy fingerprint (SPEC §4).
|
|
52
|
+
*/
|
|
53
|
+
export function fingerprintRecord(record) {
|
|
54
|
+
if (!record || typeof record !== 'object') return null;
|
|
55
|
+
if (record.type !== 'procedure') return null;
|
|
56
|
+
const parts = [record.text];
|
|
57
|
+
if (record.meta && typeof record.meta === 'object') {
|
|
58
|
+
parts.push(JSON.stringify(record.meta));
|
|
59
|
+
}
|
|
60
|
+
if (record.provenance) parts.push(record.provenance);
|
|
61
|
+
return fingerprintText(parts.filter(Boolean).join(' '));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* fingerprintFile({ filePath, symbols }) → string[] — path-shape tokens
|
|
66
|
+
* (incl. dir basenames) + symbol names.
|
|
67
|
+
*/
|
|
68
|
+
export function fingerprintFile({ filePath, symbols } = {}) {
|
|
69
|
+
const features = new Set(fingerprintText(filePath));
|
|
70
|
+
for (const sym of Array.isArray(symbols) ? symbols : []) {
|
|
71
|
+
for (const feature of fingerprintText(sym?.name)) features.add(feature);
|
|
72
|
+
}
|
|
73
|
+
return [...features].sort();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readArtifact(rootDir, name) {
|
|
77
|
+
try {
|
|
78
|
+
const raw = await fs.readFile(getArtifactPath(rootDir, name), 'utf8');
|
|
79
|
+
const parsed = JSON.parse(raw);
|
|
80
|
+
if (parsed?.schemaVersion !== undefined && parsed.schemaVersion !== INDEX_SCHEMA_VERSION) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return parsed;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Memory lane — lazy-imported inside try/catch so a missing/disabled memory
|
|
90
|
+
// v2 store degrades instead of throwing.
|
|
91
|
+
async function loadProcedureCandidates(projectRoot, config) {
|
|
92
|
+
if (config?.memoryV2?.enabled === false) {
|
|
93
|
+
return { records: null, why: 'memory-unavailable' };
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const loader = await import('../memory/storeV2Loader.js');
|
|
97
|
+
const records = await import('../memory/records.js');
|
|
98
|
+
if (typeof loader?.loadV2Records !== 'function' || typeof records?.isRecordUsable !== 'function') {
|
|
99
|
+
return { records: null, why: 'memory-unavailable' };
|
|
100
|
+
}
|
|
101
|
+
const loaded = await loader.loadV2Records(projectRoot, { type: 'procedure' });
|
|
102
|
+
if (!Array.isArray(loaded) || loaded.length === 0) {
|
|
103
|
+
return { records: [], why: 'memory-unavailable' };
|
|
104
|
+
}
|
|
105
|
+
return { records: loaded.filter((r) => records.isRecordUsable(r)), why: null };
|
|
106
|
+
} catch {
|
|
107
|
+
return { records: null, why: 'memory-unavailable' };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function jaccard(querySet, candidateFeatures) {
|
|
112
|
+
let shared = 0;
|
|
113
|
+
const matched = [];
|
|
114
|
+
for (const feature of candidateFeatures) {
|
|
115
|
+
if (querySet.has(feature)) {
|
|
116
|
+
shared += 1;
|
|
117
|
+
if (matched.length < MAX_MATCHED_FEATURES) matched.push(feature);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (shared === 0) return null;
|
|
121
|
+
const union = querySet.size + candidateFeatures.length - shared;
|
|
122
|
+
return { score: shared / union, matched };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* findAnalogies(projectRoot, query, { limit = 5 }) →
|
|
127
|
+
* { analogies: [{ kind:'procedure'|'code', ref, path?, score, matched:string[] }],
|
|
128
|
+
* omitted: [{what,why}] }
|
|
129
|
+
*
|
|
130
|
+
* Jaccard over fingerprint sets; `score>0` rows only. Ties → ref localeCompare.
|
|
131
|
+
*/
|
|
132
|
+
export async function findAnalogies(projectRoot, query, { limit = DEFAULT_LIMIT } = {}) {
|
|
133
|
+
const rootDir = path.resolve(projectRoot ?? process.cwd());
|
|
134
|
+
const effectiveLimit = Math.max(1, typeof limit === 'number' && Number.isFinite(limit) ? limit : DEFAULT_LIMIT);
|
|
135
|
+
const omitted = [];
|
|
136
|
+
const analogies = [];
|
|
137
|
+
|
|
138
|
+
const queryFeatures = new Set(fingerprintText(query));
|
|
139
|
+
if (queryFeatures.size === 0) {
|
|
140
|
+
omitted.push({ what: 'analogy-lane', why: 'empty-query-features' });
|
|
141
|
+
return { analogies, omitted };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let config = null;
|
|
145
|
+
try {
|
|
146
|
+
config = await loadRuntimeConfig(rootDir);
|
|
147
|
+
} catch {
|
|
148
|
+
config = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Lane 1 — memory `procedure` records.
|
|
152
|
+
const memory = await loadProcedureCandidates(rootDir, config);
|
|
153
|
+
if (memory.why) {
|
|
154
|
+
omitted.push({ what: 'analogy-lane', why: memory.why });
|
|
155
|
+
}
|
|
156
|
+
for (const record of memory.records ?? []) {
|
|
157
|
+
const features = fingerprintRecord(record);
|
|
158
|
+
if (!features || features.length === 0) continue;
|
|
159
|
+
const hit = jaccard(queryFeatures, features);
|
|
160
|
+
if (!hit) continue;
|
|
161
|
+
analogies.push({ kind: 'procedure', ref: record.id, ...hit });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Lane 2 — index files/symbols (schema-guarded artifact reads).
|
|
165
|
+
const [filesArtifact, symbolsArtifact] = await Promise.all([
|
|
166
|
+
readArtifact(rootDir, INDEX_ARTIFACTS.files),
|
|
167
|
+
readArtifact(rootDir, INDEX_ARTIFACTS.symbols),
|
|
168
|
+
]);
|
|
169
|
+
if (!filesArtifact && !symbolsArtifact) {
|
|
170
|
+
omitted.push({ what: 'analogy-lane', why: 'index-missing' });
|
|
171
|
+
}
|
|
172
|
+
const symbolsByFile = new Map();
|
|
173
|
+
for (const sym of symbolsArtifact?.items ?? []) {
|
|
174
|
+
if (!sym?.filePath) continue;
|
|
175
|
+
const list = symbolsByFile.get(sym.filePath) ?? [];
|
|
176
|
+
list.push(sym);
|
|
177
|
+
symbolsByFile.set(sym.filePath, list);
|
|
178
|
+
}
|
|
179
|
+
const codePaths = new Set();
|
|
180
|
+
for (const item of filesArtifact?.items ?? []) {
|
|
181
|
+
if (item?.filePath) codePaths.add(item.filePath);
|
|
182
|
+
}
|
|
183
|
+
for (const filePath of symbolsByFile.keys()) codePaths.add(filePath);
|
|
184
|
+
for (const filePath of codePaths) {
|
|
185
|
+
const features = fingerprintFile({ filePath, symbols: symbolsByFile.get(filePath) ?? [] });
|
|
186
|
+
if (features.length === 0) continue;
|
|
187
|
+
const hit = jaccard(queryFeatures, features);
|
|
188
|
+
if (!hit) continue;
|
|
189
|
+
analogies.push({ kind: 'code', ref: filePath, path: filePath, ...hit });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
analogies.sort((a, b) => b.score - a.score || a.ref.localeCompare(b.ref));
|
|
193
|
+
if (analogies.length === 0 && !memory.why && filesArtifact && symbolsArtifact) {
|
|
194
|
+
omitted.push({ what: 'analogy-lane', why: 'no-match' });
|
|
195
|
+
}
|
|
196
|
+
return { analogies: analogies.slice(0, effectiveLimit), omitted };
|
|
197
|
+
}
|