@nomos-arc/arc 0.1.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/.claude/settings.local.json +10 -0
- package/.nomos-config.json +5 -0
- package/CLAUDE.md +108 -0
- package/LICENSE +190 -0
- package/README.md +569 -0
- package/dist/cli.js +21120 -0
- package/docs/auth/googel_plan.yaml +1093 -0
- package/docs/auth/google_task.md +235 -0
- package/docs/auth/hardened_blueprint.yaml +1658 -0
- package/docs/auth/red_team_report.yaml +336 -0
- package/docs/auth/session_state.yaml +162 -0
- package/docs/certificate/cer_enhance_plan.md +605 -0
- package/docs/certificate/certificate_report.md +338 -0
- package/docs/dev_overview.md +419 -0
- package/docs/feature_assessment.md +156 -0
- package/docs/how_it_works.md +78 -0
- package/docs/infrastructure/map.md +867 -0
- package/docs/init/master_plan.md +3581 -0
- package/docs/init/red_team_report.md +215 -0
- package/docs/init/report_phase_1a.md +304 -0
- package/docs/integrity-gate/enhance_drift.md +703 -0
- package/docs/integrity-gate/overview.md +108 -0
- package/docs/management/manger-task.md +99 -0
- package/docs/management/scafffold.md +76 -0
- package/docs/map/ATOMIC_BLUEPRINT.md +1349 -0
- package/docs/map/RED_TEAM_REPORT.md +159 -0
- package/docs/map/map_task.md +147 -0
- package/docs/map/semantic_graph_task.md +792 -0
- package/docs/map/semantic_master_plan.md +705 -0
- package/docs/phase7/TEAM_RED.md +249 -0
- package/docs/phase7/plan.md +1682 -0
- package/docs/phase7/task.md +275 -0
- package/docs/prompts/USAGE.md +312 -0
- package/docs/prompts/architect.md +165 -0
- package/docs/prompts/executer.md +190 -0
- package/docs/prompts/hardener.md +190 -0
- package/docs/prompts/red_team.md +146 -0
- package/docs/verification/goveranance-overview.md +396 -0
- package/docs/verification/governance-overview.md +245 -0
- package/docs/verification/verification-arc-ar.md +560 -0
- package/docs/verification/verification-architecture.md +560 -0
- package/docs/very_next.md +52 -0
- package/docs/whitepaper.md +89 -0
- package/overview.md +1469 -0
- package/package.json +63 -0
- package/src/adapters/__tests__/git.test.ts +296 -0
- package/src/adapters/__tests__/stdio.test.ts +70 -0
- package/src/adapters/git.ts +226 -0
- package/src/adapters/pty.ts +159 -0
- package/src/adapters/stdio.ts +113 -0
- package/src/cli.ts +83 -0
- package/src/commands/apply.ts +47 -0
- package/src/commands/auth.ts +301 -0
- package/src/commands/certificate.ts +89 -0
- package/src/commands/discard.ts +24 -0
- package/src/commands/drift.ts +116 -0
- package/src/commands/index.ts +78 -0
- package/src/commands/init.ts +121 -0
- package/src/commands/list.ts +75 -0
- package/src/commands/map.ts +55 -0
- package/src/commands/plan.ts +30 -0
- package/src/commands/review.ts +58 -0
- package/src/commands/run.ts +63 -0
- package/src/commands/search.ts +147 -0
- package/src/commands/show.ts +63 -0
- package/src/commands/status.ts +59 -0
- package/src/core/__tests__/budget.test.ts +213 -0
- package/src/core/__tests__/certificate.test.ts +385 -0
- package/src/core/__tests__/config.test.ts +191 -0
- package/src/core/__tests__/preflight.test.ts +24 -0
- package/src/core/__tests__/prompt.test.ts +358 -0
- package/src/core/__tests__/review.test.ts +161 -0
- package/src/core/__tests__/state.test.ts +362 -0
- package/src/core/auth/__tests__/manager.test.ts +166 -0
- package/src/core/auth/__tests__/server.test.ts +220 -0
- package/src/core/auth/gcp-projects.ts +160 -0
- package/src/core/auth/manager.ts +114 -0
- package/src/core/auth/server.ts +141 -0
- package/src/core/budget.ts +119 -0
- package/src/core/certificate.ts +502 -0
- package/src/core/config.ts +212 -0
- package/src/core/errors.ts +54 -0
- package/src/core/factory.ts +49 -0
- package/src/core/graph/__tests__/builder.test.ts +272 -0
- package/src/core/graph/__tests__/contract-writer.test.ts +175 -0
- package/src/core/graph/__tests__/enricher.test.ts +299 -0
- package/src/core/graph/__tests__/parser.test.ts +200 -0
- package/src/core/graph/__tests__/pipeline.test.ts +202 -0
- package/src/core/graph/__tests__/renderer.test.ts +128 -0
- package/src/core/graph/__tests__/resolver.test.ts +185 -0
- package/src/core/graph/__tests__/scanner.test.ts +231 -0
- package/src/core/graph/__tests__/show.test.ts +134 -0
- package/src/core/graph/builder.ts +303 -0
- package/src/core/graph/constraints.ts +94 -0
- package/src/core/graph/contract-writer.ts +93 -0
- package/src/core/graph/drift/__tests__/classifier.test.ts +215 -0
- package/src/core/graph/drift/__tests__/comparator.test.ts +335 -0
- package/src/core/graph/drift/__tests__/drift.test.ts +453 -0
- package/src/core/graph/drift/__tests__/reporter.test.ts +203 -0
- package/src/core/graph/drift/classifier.ts +165 -0
- package/src/core/graph/drift/comparator.ts +205 -0
- package/src/core/graph/drift/reporter.ts +77 -0
- package/src/core/graph/enricher.ts +251 -0
- package/src/core/graph/grammar-paths.ts +30 -0
- package/src/core/graph/html-template.ts +493 -0
- package/src/core/graph/map-schema.ts +137 -0
- package/src/core/graph/parser.ts +336 -0
- package/src/core/graph/pipeline.ts +209 -0
- package/src/core/graph/renderer.ts +92 -0
- package/src/core/graph/resolver.ts +195 -0
- package/src/core/graph/scanner.ts +145 -0
- package/src/core/logger.ts +46 -0
- package/src/core/orchestrator.ts +792 -0
- package/src/core/plan-file-manager.ts +66 -0
- package/src/core/preflight.ts +64 -0
- package/src/core/prompt.ts +173 -0
- package/src/core/review.ts +95 -0
- package/src/core/state.ts +294 -0
- package/src/core/worktree-coordinator.ts +77 -0
- package/src/search/__tests__/chunk-extractor.test.ts +339 -0
- package/src/search/__tests__/embedder-auth.test.ts +124 -0
- package/src/search/__tests__/embedder.test.ts +267 -0
- package/src/search/__tests__/graph-enricher.test.ts +178 -0
- package/src/search/__tests__/indexer.test.ts +518 -0
- package/src/search/__tests__/integration.test.ts +649 -0
- package/src/search/__tests__/query-engine.test.ts +334 -0
- package/src/search/__tests__/similarity.test.ts +78 -0
- package/src/search/__tests__/vector-store.test.ts +281 -0
- package/src/search/chunk-extractor.ts +167 -0
- package/src/search/embedder.ts +209 -0
- package/src/search/graph-enricher.ts +95 -0
- package/src/search/indexer.ts +483 -0
- package/src/search/lexical-searcher.ts +190 -0
- package/src/search/query-engine.ts +225 -0
- package/src/search/vector-store.ts +311 -0
- package/src/types/index.ts +572 -0
- package/src/utils/__tests__/ansi.test.ts +54 -0
- package/src/utils/__tests__/frontmatter.test.ts +79 -0
- package/src/utils/__tests__/sanitize.test.ts +229 -0
- package/src/utils/ansi.ts +19 -0
- package/src/utils/context.ts +44 -0
- package/src/utils/frontmatter.ts +27 -0
- package/src/utils/sanitize.ts +78 -0
- package/test/e2e/lifecycle.test.ts +330 -0
- package/test/fixtures/mock-planner-hang.ts +5 -0
- package/test/fixtures/mock-planner.ts +26 -0
- package/test/fixtures/mock-reviewer-bad.ts +8 -0
- package/test/fixtures/mock-reviewer-retry.ts +34 -0
- package/test/fixtures/mock-reviewer.ts +18 -0
- package/test/fixtures/sample-project/src/circular-a.ts +6 -0
- package/test/fixtures/sample-project/src/circular-b.ts +6 -0
- package/test/fixtures/sample-project/src/config.ts +15 -0
- package/test/fixtures/sample-project/src/main.ts +19 -0
- package/test/fixtures/sample-project/src/services/product-service.ts +20 -0
- package/test/fixtures/sample-project/src/services/user-service.ts +18 -0
- package/test/fixtures/sample-project/src/types.ts +14 -0
- package/test/fixtures/sample-project/src/utils/index.ts +14 -0
- package/test/fixtures/sample-project/src/utils/validate.ts +12 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import type { Logger } from 'winston';
|
|
3
|
+
import type { FileNode, ProjectMap, TextChunk } from '../types/index.js';
|
|
4
|
+
|
|
5
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const MIN_TEXT_LENGTH = 20;
|
|
8
|
+
|
|
9
|
+
// ─── ChunkExtractor ───────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extracts file-level and symbol-level TextChunks from a ProjectMap.
|
|
13
|
+
*
|
|
14
|
+
* Design notes:
|
|
15
|
+
* - Uses inline semantic data from ProjectMap.files[path].semantic — NO .semantic.md reads [TRAP-5].
|
|
16
|
+
* - Content hash computed from raw inputs, not composed text [S-6].
|
|
17
|
+
* - Chunks shorter than 20 chars are skipped (empty text guard).
|
|
18
|
+
*/
|
|
19
|
+
export class ChunkExtractor {
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly projectRoot: string,
|
|
22
|
+
private readonly logger: Logger,
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract file-level and symbol-level TextChunks from a ProjectMap.
|
|
27
|
+
* No filesystem reads are performed — all data comes from the in-memory map.
|
|
28
|
+
*/
|
|
29
|
+
extract(map: ProjectMap): TextChunk[] {
|
|
30
|
+
const chunks: TextChunk[] = [];
|
|
31
|
+
|
|
32
|
+
for (const [file_path, fileNode] of Object.entries(map.files)) {
|
|
33
|
+
const fileChunk = this.extractFileChunk(file_path, fileNode);
|
|
34
|
+
if (fileChunk !== null) {
|
|
35
|
+
chunks.push(fileChunk);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const symbolChunks = this.extractSymbolChunks(file_path, fileNode);
|
|
39
|
+
chunks.push(...symbolChunks);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return chunks;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Private ───────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
private extractFileChunk(file_path: string, fileNode: FileNode): TextChunk | null {
|
|
48
|
+
let text: string;
|
|
49
|
+
|
|
50
|
+
if (fileNode.semantic !== null) {
|
|
51
|
+
const exportedNames = fileNode.symbols
|
|
52
|
+
.filter(s => s.exported)
|
|
53
|
+
.map(s => s.name)
|
|
54
|
+
.join(', ');
|
|
55
|
+
|
|
56
|
+
text = [
|
|
57
|
+
`File: ${file_path}`,
|
|
58
|
+
`Purpose: ${fileNode.semantic.purpose}`,
|
|
59
|
+
`Overview: ${fileNode.semantic.overview}`,
|
|
60
|
+
`Key Logic: ${fileNode.semantic.key_logic.join('; ')}`,
|
|
61
|
+
`Usage Context: ${fileNode.semantic.usage_context.join('; ')}`,
|
|
62
|
+
`Exports: ${exportedNames}`,
|
|
63
|
+
`Dependencies: ${fileNode.dependencies.join(', ')}`,
|
|
64
|
+
].join('\n');
|
|
65
|
+
} else {
|
|
66
|
+
// Fallback: file path + symbol names + import sources [TRAP-5: no .semantic.md read]
|
|
67
|
+
this.logger.warn(
|
|
68
|
+
`[nomos:search:warn] No semantic data for ${file_path} — using fallback text`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const symbolNames = fileNode.symbols.map(s => s.name).join(', ');
|
|
72
|
+
const importSources = fileNode.imports.map(i => i.source).join(', ');
|
|
73
|
+
|
|
74
|
+
const lines = [
|
|
75
|
+
`File: ${file_path}`,
|
|
76
|
+
];
|
|
77
|
+
if (symbolNames) lines.push(`Symbols: ${symbolNames}`);
|
|
78
|
+
if (importSources) lines.push(`Imports: ${importSources}`);
|
|
79
|
+
|
|
80
|
+
text = lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (text.length < MIN_TEXT_LENGTH) return null;
|
|
84
|
+
|
|
85
|
+
const content_hash = this.computeFileHash(file_path, fileNode);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
id: file_path,
|
|
89
|
+
type: 'file',
|
|
90
|
+
file_path,
|
|
91
|
+
text,
|
|
92
|
+
symbol_name: null,
|
|
93
|
+
symbol_type: null,
|
|
94
|
+
line_start: null,
|
|
95
|
+
line_end: null,
|
|
96
|
+
parent_file_id: null,
|
|
97
|
+
content_hash,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private extractSymbolChunks(file_path: string, fileNode: FileNode): TextChunk[] {
|
|
102
|
+
const chunks: TextChunk[] = [];
|
|
103
|
+
const fileContentHash = this.computeFileHash(file_path, fileNode);
|
|
104
|
+
|
|
105
|
+
for (const symbol of fileNode.symbols) {
|
|
106
|
+
if (!symbol.exported && symbol.kind !== 'class' && symbol.kind !== 'function') {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const purposeText = fileNode.semantic?.purpose ?? file_path;
|
|
111
|
+
|
|
112
|
+
const text = [
|
|
113
|
+
`Symbol: ${symbol.name} (${symbol.kind})`,
|
|
114
|
+
`File: ${file_path}`,
|
|
115
|
+
`Signature: ${symbol.signature ?? 'N/A'}`,
|
|
116
|
+
`Lines: ${symbol.line}-${symbol.end_line ?? '?'}`,
|
|
117
|
+
`File Purpose: ${purposeText}`,
|
|
118
|
+
].join('\n');
|
|
119
|
+
|
|
120
|
+
if (text.length < MIN_TEXT_LENGTH) continue;
|
|
121
|
+
|
|
122
|
+
// Hash from raw symbol data + parent file content hash [S-6]
|
|
123
|
+
const symbolHashInput = JSON.stringify({
|
|
124
|
+
name: symbol.name,
|
|
125
|
+
kind: symbol.kind,
|
|
126
|
+
signature: symbol.signature,
|
|
127
|
+
line: symbol.line,
|
|
128
|
+
end_line: symbol.end_line,
|
|
129
|
+
parent_file_hash: fileContentHash,
|
|
130
|
+
});
|
|
131
|
+
const content_hash = crypto.createHash('sha256').update(symbolHashInput).digest('hex');
|
|
132
|
+
|
|
133
|
+
chunks.push({
|
|
134
|
+
id: `${file_path}::${symbol.name}`,
|
|
135
|
+
type: 'symbol',
|
|
136
|
+
file_path,
|
|
137
|
+
text,
|
|
138
|
+
symbol_name: symbol.name,
|
|
139
|
+
symbol_type: symbol.kind,
|
|
140
|
+
line_start: symbol.line,
|
|
141
|
+
line_end: symbol.end_line,
|
|
142
|
+
parent_file_id: file_path,
|
|
143
|
+
content_hash,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return chunks;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Compute content hash from raw inputs — NOT composed text.
|
|
152
|
+
* Decouples hash stability from text composition format changes [S-6].
|
|
153
|
+
*/
|
|
154
|
+
private computeFileHash(file_path: string, fileNode: FileNode): string {
|
|
155
|
+
const hashInput = JSON.stringify({
|
|
156
|
+
file_path,
|
|
157
|
+
semantic: fileNode.semantic, // null-safe
|
|
158
|
+
symbols: fileNode.symbols.map(s => ({
|
|
159
|
+
name: s.name,
|
|
160
|
+
kind: s.kind,
|
|
161
|
+
signature: s.signature,
|
|
162
|
+
})),
|
|
163
|
+
dependencies: fileNode.dependencies,
|
|
164
|
+
});
|
|
165
|
+
return crypto.createHash('sha256').update(hashInput).digest('hex');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
2
|
+
import type { RequestOptions } from '@google/generative-ai';
|
|
3
|
+
import type { Logger } from 'winston';
|
|
4
|
+
import { NomosError } from '../core/errors.js';
|
|
5
|
+
import type { NomosConfig } from '../types/index.js';
|
|
6
|
+
import { AuthManager } from '../core/auth/manager.js';
|
|
7
|
+
|
|
8
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function chunk<T>(arr: T[], size: number): T[][] {
|
|
11
|
+
const result: T[][] = [];
|
|
12
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
13
|
+
result.push(arr.slice(i, i + size));
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sleep(ms: number): Promise<void> {
|
|
19
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Embedder ─────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export class Embedder {
|
|
25
|
+
private readonly client: GoogleGenerativeAI;
|
|
26
|
+
private readonly requestOptions: RequestOptions | undefined;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly config: NomosConfig['search'],
|
|
30
|
+
private readonly logger: Logger,
|
|
31
|
+
apiKey?: string,
|
|
32
|
+
quotaProjectId?: string,
|
|
33
|
+
) {
|
|
34
|
+
const key = apiKey ?? process.env['GEMINI_API_KEY'];
|
|
35
|
+
if (!key) {
|
|
36
|
+
throw new NomosError(
|
|
37
|
+
'search_api_key_missing',
|
|
38
|
+
'No credentials found. Set GEMINI_API_KEY or run: arc auth login',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
this.client = new GoogleGenerativeAI(key);
|
|
42
|
+
if (quotaProjectId) {
|
|
43
|
+
this.requestOptions = {
|
|
44
|
+
customHeaders: { 'x-goog-user-project': quotaProjectId },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static async create(
|
|
50
|
+
config: NomosConfig['search'],
|
|
51
|
+
logger: Logger,
|
|
52
|
+
authManager?: AuthManager | null,
|
|
53
|
+
): Promise<Embedder> {
|
|
54
|
+
// Priority 1: GEMINI_API_KEY (no quota header — billing is tied to the API key's project)
|
|
55
|
+
const envKey = process.env['GEMINI_API_KEY'];
|
|
56
|
+
if (envKey) {
|
|
57
|
+
return new Embedder(config, logger, envKey);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Priority 2: OAuth credentials + quota project
|
|
61
|
+
if (authManager?.isLoggedIn()) {
|
|
62
|
+
const token = await authManager.getAccessToken();
|
|
63
|
+
const creds = authManager.loadCredentials();
|
|
64
|
+
return new Embedder(config, logger, token, creds?.quota_project_id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Priority 3: Neither — throw
|
|
68
|
+
throw new NomosError(
|
|
69
|
+
'search_api_key_missing',
|
|
70
|
+
'No credentials found. Set GEMINI_API_KEY or run: arc auth login',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Return the vector dimensions for the configured model. */
|
|
75
|
+
get dimensions(): number {
|
|
76
|
+
return this.config.embedding_dimensions;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Embed a single text string. Returns a Float32Array vector.
|
|
81
|
+
* Subject to request_timeout_ms [GAP-4].
|
|
82
|
+
*/
|
|
83
|
+
async embedOne(text: string): Promise<Float32Array> {
|
|
84
|
+
return this.withRetry(() =>
|
|
85
|
+
this.withTimeout(this.embedOneRaw(text), 'embedOne'),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Embed a batch of text strings. Returns Float32Array[] in the same order.
|
|
91
|
+
* Processes batches SEQUENTIALLY with rate-limit delay [TRAP-2].
|
|
92
|
+
* Each API call subject to request_timeout_ms [GAP-4].
|
|
93
|
+
* Logs rate-limit delays [S-8].
|
|
94
|
+
*/
|
|
95
|
+
async embedBatch(
|
|
96
|
+
texts: string[],
|
|
97
|
+
onBatchComplete?: (batchIndex: number, totalBatches: number) => void,
|
|
98
|
+
): Promise<Float32Array[]> {
|
|
99
|
+
const results: Float32Array[] = [];
|
|
100
|
+
const batches = chunk(texts, this.config.batch_size);
|
|
101
|
+
const delayMs = Math.ceil(60_000 / this.config.embedding_requests_per_minute);
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < batches.length; i++) {
|
|
104
|
+
if (i > 0) {
|
|
105
|
+
this.logger.warn(
|
|
106
|
+
`[nomos:search:warn] Rate limiting. Waiting ${delayMs}ms before batch ${i + 1}/${batches.length}...`,
|
|
107
|
+
);
|
|
108
|
+
await sleep(delayMs);
|
|
109
|
+
}
|
|
110
|
+
const batch = batches[i]!;
|
|
111
|
+
const vectors = await this.withRetry(() =>
|
|
112
|
+
this.withTimeout(
|
|
113
|
+
this.embedBatchRaw(batch),
|
|
114
|
+
`batch ${i + 1}/${batches.length}`,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
results.push(...vectors);
|
|
118
|
+
onBatchComplete?.(i, batches.length);
|
|
119
|
+
}
|
|
120
|
+
return results;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Private ────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
private async embedOneRaw(text: string): Promise<Float32Array> {
|
|
126
|
+
const model = this.client.getGenerativeModel({ model: this.config.embedding_model }, this.requestOptions);
|
|
127
|
+
const result = await model.embedContent({
|
|
128
|
+
content: { role: 'user', parts: [{ text }] },
|
|
129
|
+
// outputDimensionality is a supported API param not yet reflected in SDK types
|
|
130
|
+
...(this.config.embedding_dimensions && {
|
|
131
|
+
outputDimensionality: this.config.embedding_dimensions,
|
|
132
|
+
}),
|
|
133
|
+
} as Parameters<typeof model.embedContent>[0]);
|
|
134
|
+
return new Float32Array(result.embedding.values);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async embedBatchRaw(texts: string[]): Promise<Float32Array[]> {
|
|
138
|
+
const model = this.client.getGenerativeModel({ model: this.config.embedding_model }, this.requestOptions);
|
|
139
|
+
const requests = texts.map(text => ({
|
|
140
|
+
content: { role: 'user', parts: [{ text }] },
|
|
141
|
+
...(this.config.embedding_dimensions && {
|
|
142
|
+
outputDimensionality: this.config.embedding_dimensions,
|
|
143
|
+
}),
|
|
144
|
+
}));
|
|
145
|
+
// Cast to any: outputDimensionality not yet in SDK typings
|
|
146
|
+
const result = await model.batchEmbedContents({ requests } as Parameters<typeof model.batchEmbedContents>[0]);
|
|
147
|
+
return result.embeddings.map(e => new Float32Array(e.values));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
const timer = setTimeout(() => controller.abort(), this.config.request_timeout_ms);
|
|
153
|
+
|
|
154
|
+
const abortPromise = new Promise<never>((_, reject) => {
|
|
155
|
+
controller.signal.addEventListener('abort', () => {
|
|
156
|
+
reject(
|
|
157
|
+
new NomosError(
|
|
158
|
+
'search_embedding_failed',
|
|
159
|
+
`Embedding request timed out after ${this.config.request_timeout_ms}ms (${label})`,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
// Suppress unhandled-rejection warning when `promise` wins the race and
|
|
165
|
+
// the abort timer fires afterwards with no consumer for the rejection.
|
|
166
|
+
abortPromise.catch(() => {});
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
return await Promise.race([promise, abortPromise]);
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
176
|
+
const MAX_RETRIES = 3;
|
|
177
|
+
const BASE_DELAY_MS = 2_000;
|
|
178
|
+
|
|
179
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
180
|
+
try {
|
|
181
|
+
return await fn();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
// NomosErrors (timeout, missing key) are not retryable
|
|
184
|
+
if (err instanceof NomosError) throw err;
|
|
185
|
+
|
|
186
|
+
if (!this.isRetryable(err) || attempt === MAX_RETRIES) {
|
|
187
|
+
throw new NomosError(
|
|
188
|
+
'search_embedding_failed',
|
|
189
|
+
`Embedding failed after ${attempt + 1} attempt(s): ${(err as Error).message ?? String(err)}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const jitterMs = Math.random() * 1_000;
|
|
194
|
+
await sleep(BASE_DELAY_MS * Math.pow(2, attempt) + jitterMs);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Unreachable — TypeScript requires exhaustive return
|
|
199
|
+
throw new NomosError('search_embedding_failed', 'Unexpected exit from retry loop');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private isRetryable(err: unknown): boolean {
|
|
203
|
+
if (err instanceof Error) {
|
|
204
|
+
const msg = err.message;
|
|
205
|
+
return /429|rate.?limit|500|503|internal.?server/i.test(msg);
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import type { Logger } from 'winston';
|
|
3
|
+
import type { ProjectMap, SearchResult, ChunkType } from '../types/index.js';
|
|
4
|
+
|
|
5
|
+
// ─── RawSearchResult ──────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface RawSearchResult {
|
|
8
|
+
id: string;
|
|
9
|
+
type: ChunkType;
|
|
10
|
+
file_path: string;
|
|
11
|
+
symbol_name: string | null;
|
|
12
|
+
symbol_type: string | null;
|
|
13
|
+
line_start: number | null;
|
|
14
|
+
line_end: number | null;
|
|
15
|
+
purpose: string;
|
|
16
|
+
similarity_score: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ─── GraphEnricher ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export class GraphEnricher {
|
|
22
|
+
private projectMap: ProjectMap | null = null;
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly projectMapPath: string,
|
|
26
|
+
private readonly logger: Logger,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lazy-load and cache project_map.json [GAP-5].
|
|
31
|
+
* Parsed once per instance lifetime. Subsequent calls return cached data.
|
|
32
|
+
*/
|
|
33
|
+
private async loadMap(): Promise<ProjectMap> {
|
|
34
|
+
if (this.projectMap) return this.projectMap;
|
|
35
|
+
const raw = await fs.readFile(this.projectMapPath, 'utf-8');
|
|
36
|
+
this.projectMap = JSON.parse(raw) as ProjectMap;
|
|
37
|
+
return this.projectMap;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Enrich raw search results with dependency graph metadata.
|
|
42
|
+
* Stale results (file deleted since index) get is_stale = true [TRAP-4].
|
|
43
|
+
* Results sorted by similarity_score descending (preserves embedding rank).
|
|
44
|
+
*/
|
|
45
|
+
async enrich(results: RawSearchResult[]): Promise<SearchResult[]> {
|
|
46
|
+
const map = await this.loadMap();
|
|
47
|
+
const coreModuleSet = new Set(map.stats.core_modules);
|
|
48
|
+
|
|
49
|
+
const enriched: SearchResult[] = results.map((r) => {
|
|
50
|
+
const fileNode = map.files[r.file_path];
|
|
51
|
+
|
|
52
|
+
if (fileNode) {
|
|
53
|
+
return {
|
|
54
|
+
id: r.id,
|
|
55
|
+
type: r.type,
|
|
56
|
+
file_path: r.file_path,
|
|
57
|
+
symbol_name: r.symbol_name,
|
|
58
|
+
symbol_type: r.symbol_type,
|
|
59
|
+
line_start: r.line_start,
|
|
60
|
+
line_end: r.line_end,
|
|
61
|
+
purpose: r.purpose,
|
|
62
|
+
similarity_score: r.similarity_score,
|
|
63
|
+
graph_depth: fileNode.depth,
|
|
64
|
+
dependents_count: fileNode.dependents.length,
|
|
65
|
+
is_core_module: coreModuleSet.has(r.file_path),
|
|
66
|
+
is_stale: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// [TRAP-4] File not found in project map — stale result (deleted since last index).
|
|
71
|
+
// graph_depth = -1 is the internal sentinel. is_stale = true is the public signal.
|
|
72
|
+
this.logger.warn(
|
|
73
|
+
`[nomos:search:warn] Result for "${r.file_path}" not found in project map — marked as stale.`,
|
|
74
|
+
);
|
|
75
|
+
return {
|
|
76
|
+
id: r.id,
|
|
77
|
+
type: r.type,
|
|
78
|
+
file_path: r.file_path,
|
|
79
|
+
symbol_name: r.symbol_name,
|
|
80
|
+
symbol_type: r.symbol_type,
|
|
81
|
+
line_start: r.line_start,
|
|
82
|
+
line_end: r.line_end,
|
|
83
|
+
purpose: r.purpose,
|
|
84
|
+
similarity_score: r.similarity_score,
|
|
85
|
+
graph_depth: -1,
|
|
86
|
+
dependents_count: 0,
|
|
87
|
+
is_core_module: false,
|
|
88
|
+
is_stale: true,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Sort by similarity_score descending — preserve embedding rank
|
|
93
|
+
return enriched.sort((a, b) => b.similarity_score - a.similarity_score);
|
|
94
|
+
}
|
|
95
|
+
}
|