simt-dev 0.3.0 → 0.4.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +138 -74
  3. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  4. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
  5. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  6. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  7. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  8. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  9. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  10. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  11. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  12. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  13. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  14. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  15. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  16. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  17. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  18. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  19. package/package.json +2 -2
  20. package/rules/core.md +2 -2
  21. package/src/agenthooks/policy.mjs +10 -2
  22. package/src/agenthooks/prompt.mjs +81 -0
  23. package/src/agenthooks/render.mjs +138 -17
  24. package/src/argv.mjs +34 -1
  25. package/src/bench/grade.mjs +3 -0
  26. package/src/bench/run.mjs +85 -39
  27. package/src/bench/variants.mjs +456 -0
  28. package/src/brief/assemble.mjs +346 -0
  29. package/src/brief/kind.mjs +134 -0
  30. package/src/brief/packet.mjs +197 -0
  31. package/src/ci/workflow.mjs +11 -1
  32. package/src/commands/agenthook.mjs +84 -5
  33. package/src/commands/bench.mjs +53 -16
  34. package/src/commands/brief.mjs +78 -0
  35. package/src/commands/compare.mjs +10 -2
  36. package/src/commands/decide.mjs +116 -0
  37. package/src/commands/evidence.mjs +96 -0
  38. package/src/commands/gate.mjs +165 -0
  39. package/src/commands/help.mjs +65 -3
  40. package/src/commands/hooks.mjs +45 -5
  41. package/src/commands/impact.mjs +205 -0
  42. package/src/commands/index.mjs +52 -1
  43. package/src/commands/lessons.mjs +127 -0
  44. package/src/commands/local.mjs +97 -0
  45. package/src/commands/map.mjs +126 -0
  46. package/src/commands/repoindex.mjs +55 -0
  47. package/src/commands/rules.mjs +17 -2
  48. package/src/commands/shared.mjs +398 -0
  49. package/src/commands/skills.mjs +3 -17
  50. package/src/commands/skillscompile.mjs +255 -0
  51. package/src/commands/sync.mjs +87 -3
  52. package/src/commands/tests.mjs +145 -0
  53. package/src/commands/tokens.mjs +30 -1
  54. package/src/commands/why.mjs +172 -0
  55. package/src/compare/learn.mjs +680 -0
  56. package/src/decisions/index.mjs +116 -65
  57. package/src/federation/client.mjs +188 -0
  58. package/src/federation/config.mjs +134 -0
  59. package/src/federation/query.mjs +48 -0
  60. package/src/graph/compile.mjs +475 -0
  61. package/src/graph/spec.mjs +1 -1
  62. package/src/hooks/dispatcher.mjs +15 -0
  63. package/src/hooks/shim.mjs +31 -0
  64. package/src/index/compact.mjs +228 -0
  65. package/src/index/extract.mjs +331 -0
  66. package/src/index/gate.mjs +89 -0
  67. package/src/index/govern.mjs +234 -0
  68. package/src/index/impact.mjs +186 -0
  69. package/src/index/map.mjs +627 -0
  70. package/src/index/persist.mjs +239 -0
  71. package/src/index/store.mjs +515 -0
  72. package/src/index/tests.mjs +294 -0
  73. package/src/kernel/budget.mjs +100 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/variants.mjs +353 -0
  76. package/src/ledger/index.mjs +147 -0
  77. package/src/local/client.mjs +482 -0
  78. package/src/local/config.mjs +234 -0
  79. package/src/mcp/tools.mjs +138 -1
  80. package/src/measure/context.mjs +7 -5
  81. package/src/memory/decide.mjs +462 -0
  82. package/src/memory/lessons.mjs +858 -0
  83. package/src/projection/disclosure.mjs +92 -0
  84. package/src/projection/lock.mjs +125 -0
  85. package/src/projection/manifest.mjs +52 -0
  86. package/src/projection/markers.mjs +56 -17
  87. package/src/projection/project.mjs +24 -17
  88. package/src/projection/rules.mjs +407 -49
  89. package/src/projection/shared.mjs +428 -0
  90. package/src/projection/targets.mjs +2 -1
  91. package/src/toolwords.mjs +8 -0
@@ -0,0 +1,482 @@
1
+ // Zero-dependency OpenAI-compatible client for local model servers (plan 0003 R37):
2
+ // Ollama, llama.cpp `llama-server`, LM Studio and MLX servers over `http://127.0.0.1:<port>/v1/...`.
3
+ //
4
+ // Two hard invariants (ADR-075, ADR-090):
5
+ // 1. When `local` is unset in the manifest, no socket is ever opened.
6
+ // 2. Non-loopback URLs are refused before any socket is opened unless `allowRemote`
7
+ // (`--allow-remote`) is explicitly set.
8
+
9
+ import {
10
+ DEFAULT_LOCAL_TIMEOUT_MS,
11
+ isLoopbackUrl,
12
+ parseLocalConfig,
13
+ readLocalConfig,
14
+ resolveEndpointUrl,
15
+ } from './config.mjs';
16
+
17
+ function guardEndpoint(endpoint, { allowRemote = false } = {}) {
18
+ if (!endpoint || typeof endpoint !== 'object') {
19
+ return { ok: false, error: 'local model endpoint is not configured', refusedRemote: false };
20
+ }
21
+ const rawUrl = typeof endpoint.url === 'string' ? endpoint.url.trim() : '';
22
+ if (!rawUrl) {
23
+ return { ok: false, error: 'local model endpoint is missing `url`', refusedRemote: false };
24
+ }
25
+ const model = typeof endpoint.model === 'string' ? endpoint.model.trim() : '';
26
+ if (!model) {
27
+ return { ok: false, error: 'local model endpoint is missing `model`', refusedRemote: false };
28
+ }
29
+ const loopback = isLoopbackUrl(rawUrl);
30
+ if (!loopback && !allowRemote) {
31
+ return {
32
+ ok: false,
33
+ refusedRemote: true,
34
+ error: `refused non-loopback URL ${rawUrl} without --allow-remote (ADR-075)`,
35
+ };
36
+ }
37
+ return {
38
+ ok: true,
39
+ url: rawUrl,
40
+ baseUrl: resolveEndpointUrl(rawUrl, ''),
41
+ model,
42
+ timeoutMs: Number.isInteger(endpoint.timeoutMs) && endpoint.timeoutMs > 0
43
+ ? endpoint.timeoutMs
44
+ : DEFAULT_LOCAL_TIMEOUT_MS,
45
+ loopback,
46
+ };
47
+ }
48
+
49
+ async function requestJson(url, { method = 'GET', body = undefined, timeoutMs = DEFAULT_LOCAL_TIMEOUT_MS, fetchImpl = globalThis.fetch } = {}) {
50
+ const controller = new AbortController();
51
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
52
+ try {
53
+ const response = await fetchImpl(url, {
54
+ method,
55
+ headers: body !== undefined ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
56
+ body: body !== undefined ? JSON.stringify(body) : undefined,
57
+ signal: controller.signal,
58
+ });
59
+ const text = await response.text();
60
+ let parsed = null;
61
+ if (text.trim().length > 0) {
62
+ try {
63
+ parsed = JSON.parse(text);
64
+ } catch {
65
+ parsed = null;
66
+ }
67
+ }
68
+ if (!response.ok) {
69
+ const serverMsg =
70
+ parsed?.error?.message ??
71
+ (typeof parsed?.error === 'string' ? parsed.error : null) ??
72
+ (text.trim().slice(0, 160) || `HTTP ${response.status}`);
73
+ return {
74
+ ok: false,
75
+ status: response.status,
76
+ error: `${method} ${url} failed (${response.status}): ${serverMsg}`,
77
+ socketOpened: true,
78
+ };
79
+ }
80
+ if (parsed === null || typeof parsed !== 'object') {
81
+ return {
82
+ ok: false,
83
+ status: response.status,
84
+ error: `${method} ${url} returned non-JSON response`,
85
+ socketOpened: true,
86
+ };
87
+ }
88
+ return { ok: true, status: response.status, data: parsed, socketOpened: true };
89
+ } catch (error) {
90
+ const reason =
91
+ error?.name === 'AbortError'
92
+ ? `timed out after ${timeoutMs} ms`
93
+ : error?.cause?.message ?? error?.message ?? String(error);
94
+ return {
95
+ ok: false,
96
+ status: null,
97
+ error: `${method} ${url} failed: ${reason}`,
98
+ socketOpened: true,
99
+ };
100
+ } finally {
101
+ clearTimeout(timer);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Query `GET <baseUrl>/v1/models` on an OpenAI-compatible local server.
107
+ */
108
+ export async function localModels(endpoint, { allowRemote = false, fetchImpl = globalThis.fetch } = {}) {
109
+ const guard = guardEndpoint(endpoint, { allowRemote });
110
+ if (!guard.ok) {
111
+ return {
112
+ ok: false,
113
+ models: [],
114
+ error: guard.error,
115
+ refusedRemote: guard.refusedRemote,
116
+ socketOpened: false,
117
+ };
118
+ }
119
+
120
+ const targetUrl = resolveEndpointUrl(guard.baseUrl, '/models');
121
+ const res = await requestJson(targetUrl, {
122
+ method: 'GET',
123
+ timeoutMs: guard.timeoutMs,
124
+ fetchImpl,
125
+ });
126
+ if (!res.ok) {
127
+ return {
128
+ ok: false,
129
+ models: [],
130
+ status: res.status,
131
+ error: res.error,
132
+ refusedRemote: false,
133
+ socketOpened: res.socketOpened,
134
+ };
135
+ }
136
+
137
+ const rawList = Array.isArray(res.data.data)
138
+ ? res.data.data
139
+ : Array.isArray(res.data.models)
140
+ ? res.data.models
141
+ : [];
142
+ const models = rawList
143
+ .map((item) => (typeof item === 'string' ? item : item?.id ?? item?.name ?? ''))
144
+ .map((id) => String(id).trim())
145
+ .filter(Boolean);
146
+
147
+ return {
148
+ ok: true,
149
+ models,
150
+ status: res.status,
151
+ error: null,
152
+ refusedRemote: false,
153
+ socketOpened: true,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Send a `POST <baseUrl>/v1/chat/completions` request to an OpenAI-compatible local server.
159
+ */
160
+ export async function localChat(
161
+ endpoint,
162
+ {
163
+ messages = undefined,
164
+ prompt = '',
165
+ system = undefined,
166
+ temperature = 0,
167
+ maxTokens = 256,
168
+ allowRemote = false,
169
+ fetchImpl = globalThis.fetch,
170
+ } = {},
171
+ ) {
172
+ const guard = guardEndpoint(endpoint, { allowRemote });
173
+ if (!guard.ok) {
174
+ return {
175
+ ok: false,
176
+ content: null,
177
+ model: endpoint?.model ?? null,
178
+ usage: null,
179
+ error: guard.error,
180
+ refusedRemote: guard.refusedRemote,
181
+ socketOpened: false,
182
+ };
183
+ }
184
+
185
+ const chatMessages = Array.isArray(messages) && messages.length > 0
186
+ ? messages
187
+ : [
188
+ ...(system ? [{ role: 'system', content: String(system) }] : []),
189
+ { role: 'user', content: String(prompt) },
190
+ ];
191
+
192
+ const targetUrl = resolveEndpointUrl(guard.baseUrl, '/chat/completions');
193
+ const res = await requestJson(targetUrl, {
194
+ method: 'POST',
195
+ body: {
196
+ model: guard.model,
197
+ messages: chatMessages,
198
+ temperature,
199
+ ...(Number.isInteger(maxTokens) && maxTokens > 0 ? { max_tokens: maxTokens } : {}),
200
+ stream: false,
201
+ },
202
+ timeoutMs: guard.timeoutMs,
203
+ fetchImpl,
204
+ });
205
+
206
+ if (!res.ok) {
207
+ return {
208
+ ok: false,
209
+ content: null,
210
+ model: guard.model,
211
+ usage: null,
212
+ error: res.error,
213
+ refusedRemote: false,
214
+ socketOpened: res.socketOpened,
215
+ };
216
+ }
217
+
218
+ const choice = Array.isArray(res.data.choices) ? res.data.choices[0] : null;
219
+ const content =
220
+ typeof choice?.message?.content === 'string'
221
+ ? choice.message.content
222
+ : typeof choice?.text === 'string'
223
+ ? choice.text
224
+ : null;
225
+
226
+ if (content === null) {
227
+ return {
228
+ ok: false,
229
+ content: null,
230
+ model: guard.model,
231
+ usage: null,
232
+ error: `POST ${targetUrl} returned no choice content`,
233
+ refusedRemote: false,
234
+ socketOpened: true,
235
+ };
236
+ }
237
+
238
+ return {
239
+ ok: true,
240
+ content,
241
+ model: typeof res.data.model === 'string' ? res.data.model : guard.model,
242
+ usage: res.data.usage ?? null,
243
+ error: null,
244
+ refusedRemote: false,
245
+ socketOpened: true,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Send a `POST <baseUrl>/v1/embeddings` request to an OpenAI-compatible local server.
251
+ */
252
+ export async function localEmbed(
253
+ endpoint,
254
+ { input, allowRemote = false, fetchImpl = globalThis.fetch } = {},
255
+ ) {
256
+ const guard = guardEndpoint(endpoint, { allowRemote });
257
+ if (!guard.ok) {
258
+ return {
259
+ ok: false,
260
+ embeddings: [],
261
+ dimensions: 0,
262
+ model: endpoint?.model ?? null,
263
+ usage: null,
264
+ error: guard.error,
265
+ refusedRemote: guard.refusedRemote,
266
+ socketOpened: false,
267
+ };
268
+ }
269
+
270
+ const targetUrl = resolveEndpointUrl(guard.baseUrl, '/embeddings');
271
+ const res = await requestJson(targetUrl, {
272
+ method: 'POST',
273
+ body: {
274
+ model: guard.model,
275
+ input: Array.isArray(input) ? input.map(String) : String(input ?? ''),
276
+ },
277
+ timeoutMs: guard.timeoutMs,
278
+ fetchImpl,
279
+ });
280
+
281
+ if (!res.ok) {
282
+ return {
283
+ ok: false,
284
+ embeddings: [],
285
+ dimensions: 0,
286
+ model: guard.model,
287
+ usage: null,
288
+ error: res.error,
289
+ refusedRemote: false,
290
+ socketOpened: res.socketOpened,
291
+ };
292
+ }
293
+
294
+ const rawVectors = Array.isArray(res.data.data)
295
+ ? res.data.data.map((entry) => entry?.embedding)
296
+ : Array.isArray(res.data.embeddings)
297
+ ? res.data.embeddings
298
+ : Array.isArray(res.data.embedding)
299
+ ? [res.data.embedding]
300
+ : [];
301
+
302
+ const validVectors = rawVectors.filter(
303
+ (vec) => Array.isArray(vec) && vec.length > 0 && vec.every((n) => typeof n === 'number' && Number.isFinite(n)),
304
+ );
305
+
306
+ if (validVectors.length === 0) {
307
+ return {
308
+ ok: false,
309
+ embeddings: [],
310
+ dimensions: 0,
311
+ model: guard.model,
312
+ usage: null,
313
+ error: `POST ${targetUrl} returned no valid embedding vector`,
314
+ refusedRemote: false,
315
+ socketOpened: true,
316
+ };
317
+ }
318
+
319
+ return {
320
+ ok: true,
321
+ embeddings: validVectors,
322
+ dimensions: validVectors[0].length,
323
+ model: typeof res.data.model === 'string' ? res.data.model : guard.model,
324
+ usage: res.data.usage ?? null,
325
+ error: null,
326
+ refusedRemote: false,
327
+ socketOpened: true,
328
+ };
329
+ }
330
+
331
+ function summarizeBlockedEndpoint(endpoint) {
332
+ if (!endpoint) return null;
333
+ return {
334
+ role: endpoint.role,
335
+ configured: true,
336
+ ok: false,
337
+ status: endpoint.blocked ? 'refused-remote' : 'skipped',
338
+ url: endpoint.baseUrl,
339
+ model: endpoint.model,
340
+ loopback: endpoint.loopback,
341
+ error: endpoint.blocked
342
+ ? `refused non-loopback URL ${endpoint.url} without --allow-remote (ADR-075)`
343
+ : 'skipped due to configuration errors',
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Probe the capabilities of the repository's configured local model endpoints (`simt local doctor`).
349
+ *
350
+ * When `local` is unset in the manifest, returns `status: 'unconfigured'` with
351
+ * `socketsOpened: 0` without calling `fetchImpl`. When any endpoint is non-loopback
352
+ * and `allowRemote` is false, refuses with `status: 'refused-remote'` and `socketsOpened: 0`.
353
+ */
354
+ export async function probeLocal(
355
+ root,
356
+ { manifest = undefined, allowRemote = false, fetchImpl = globalThis.fetch } = {},
357
+ ) {
358
+ const config =
359
+ manifest !== undefined
360
+ ? { ...parseLocalConfig(manifest, { allowRemote }), manifestExists: true, manifestError: null }
361
+ : await readLocalConfig(root, { allowRemote });
362
+
363
+ if (config.manifestError !== null) {
364
+ return {
365
+ ok: false,
366
+ configured: false,
367
+ status: 'invalid-manifest',
368
+ allowRemote,
369
+ remoteBlocked: false,
370
+ socketsOpened: 0,
371
+ chat: null,
372
+ embed: null,
373
+ findings: config.findings,
374
+ };
375
+ }
376
+
377
+ if (!config.configured) {
378
+ return {
379
+ ok: true,
380
+ configured: false,
381
+ status: 'unconfigured',
382
+ allowRemote,
383
+ remoteBlocked: false,
384
+ socketsOpened: 0,
385
+ chat: null,
386
+ embed: null,
387
+ findings: [],
388
+ };
389
+ }
390
+
391
+ if (config.findings.length > 0) {
392
+ return {
393
+ ok: false,
394
+ configured: true,
395
+ status: config.remoteBlocked ? 'refused-remote' : 'invalid-config',
396
+ allowRemote,
397
+ remoteBlocked: config.remoteBlocked,
398
+ socketsOpened: 0,
399
+ chat: summarizeBlockedEndpoint(config.chat),
400
+ embed: summarizeBlockedEndpoint(config.embed),
401
+ findings: config.findings,
402
+ };
403
+ }
404
+
405
+ let socketsOpened = 0;
406
+ const findings = [];
407
+ let chatReport = null;
408
+ let embedReport = null;
409
+
410
+ if (config.chat) {
411
+ const modelsRes = await localModels(config.chat, { allowRemote, fetchImpl });
412
+ if (modelsRes.socketOpened) socketsOpened += 1;
413
+ const chatRes = await localChat(config.chat, {
414
+ messages: [{ role: 'user', content: 'ping' }],
415
+ maxTokens: 1,
416
+ temperature: 0,
417
+ allowRemote,
418
+ fetchImpl,
419
+ });
420
+ if (chatRes.socketOpened) socketsOpened += 1;
421
+
422
+ if (!chatRes.ok) findings.push(`chat probe failed: ${chatRes.error}`);
423
+
424
+ chatReport = {
425
+ role: 'chat',
426
+ configured: true,
427
+ ok: chatRes.ok,
428
+ status: chatRes.ok ? 'ok' : 'unreachable',
429
+ url: config.chat.baseUrl,
430
+ model: config.chat.model,
431
+ responseModel: chatRes.ok ? chatRes.model : null,
432
+ loopback: config.chat.loopback,
433
+ modelListed: modelsRes.ok ? modelsRes.models.includes(config.chat.model) : null,
434
+ models: modelsRes.ok ? modelsRes.models : [],
435
+ error: chatRes.ok ? null : chatRes.error,
436
+ };
437
+ }
438
+
439
+ if (config.embed) {
440
+ const modelsRes = await localModels(config.embed, { allowRemote, fetchImpl });
441
+ if (modelsRes.socketOpened) socketsOpened += 1;
442
+ const embedRes = await localEmbed(config.embed, {
443
+ input: 'ping',
444
+ allowRemote,
445
+ fetchImpl,
446
+ });
447
+ if (embedRes.socketOpened) socketsOpened += 1;
448
+
449
+ if (!embedRes.ok) findings.push(`embed probe failed: ${embedRes.error}`);
450
+
451
+ embedReport = {
452
+ role: 'embed',
453
+ configured: true,
454
+ ok: embedRes.ok,
455
+ status: embedRes.ok ? 'ok' : 'unreachable',
456
+ url: config.embed.baseUrl,
457
+ model: config.embed.model,
458
+ responseModel: embedRes.ok ? embedRes.model : null,
459
+ loopback: config.embed.loopback,
460
+ modelListed: modelsRes.ok ? modelsRes.models.includes(config.embed.model) : null,
461
+ models: modelsRes.ok ? modelsRes.models : [],
462
+ dimensions: embedRes.ok ? embedRes.dimensions : null,
463
+ error: embedRes.ok ? null : embedRes.error,
464
+ };
465
+ }
466
+
467
+ const ok =
468
+ (chatReport === null || chatReport.ok) &&
469
+ (embedReport === null || embedReport.ok);
470
+
471
+ return {
472
+ ok,
473
+ configured: true,
474
+ status: ok ? 'ok' : 'unreachable',
475
+ allowRemote,
476
+ remoteBlocked: false,
477
+ socketsOpened,
478
+ chat: chatReport,
479
+ embed: embedReport,
480
+ findings,
481
+ };
482
+ }
@@ -0,0 +1,234 @@
1
+ // Local model configuration in `simt.json` / `simt-dev.json` (plan 0003 R37, ADR-075, ADR-090).
2
+ //
3
+ // Local models are optional and off by default. When `local` is absent from the manifest,
4
+ // no socket is ever opened. When configured:
5
+ //
6
+ // "local": {
7
+ // "chat": { "url": "http://127.0.0.1:11434/v1", "model": "qwen2.5-coder:7b" },
8
+ // "embed": { "url": "http://127.0.0.1:11434/v1", "model": "nomic-embed-code" }
9
+ // }
10
+ //
11
+ // Non-loopback URLs are refused unless `--allow-remote` is explicitly passed (ADR-075).
12
+
13
+ import { MANIFEST_FILE } from '../name.mjs';
14
+ import { readManifest } from '../projection/manifest.mjs';
15
+
16
+ /** Default per-request timeout for local model probes and requests, in milliseconds. */
17
+ export const DEFAULT_LOCAL_TIMEOUT_MS = 5000;
18
+
19
+ const ROLES = ['chat', 'embed'];
20
+
21
+ const IPV4_LOOPBACK = /^127(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
22
+
23
+ /**
24
+ * Whether a parsed URL hostname resolves strictly to the local loopback interface.
25
+ *
26
+ * Accepts `localhost` (and `*.localhost`), IPv4 `127.0.0.0/8`, and IPv6 `::1`.
27
+ * Wildcard bind addresses (`0.0.0.0`, `::`) and LAN/remote hosts return false.
28
+ */
29
+ export function isLoopbackHost(hostname) {
30
+ if (typeof hostname !== 'string') return false;
31
+ const clean = hostname.trim().toLowerCase().replace(/^\[|\]$/g, '');
32
+ if (!clean) return false;
33
+ if (clean === 'localhost' || clean.endsWith('.localhost')) return true;
34
+ if (IPV4_LOOPBACK.test(clean)) return true;
35
+ if (clean === '::1' || clean === '0:0:0:0:0:0:0:1') return true;
36
+ return false;
37
+ }
38
+
39
+ /**
40
+ * Whether a URL string is a valid HTTP(S) loopback URL with no embedded credentials.
41
+ */
42
+ export function isLoopbackUrl(urlString) {
43
+ if (typeof urlString !== 'string' || urlString.trim().length === 0) return false;
44
+ let parsed;
45
+ try {
46
+ parsed = new URL(urlString.trim());
47
+ } catch {
48
+ return false;
49
+ }
50
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
51
+ if (parsed.username !== '' || parsed.password !== '') return false;
52
+ return isLoopbackHost(parsed.hostname);
53
+ }
54
+
55
+ /**
56
+ * Normalise an OpenAI-compatible base URL and append an endpoint path under `/v1`.
57
+ *
58
+ * Accepts base URLs written either with or without a trailing `/v1` (for example
59
+ * `http://127.0.0.1:11434` or `http://127.0.0.1:11434/v1`) so Ollama, llama.cpp
60
+ * `llama-server`, LM Studio and MLX configs work identically.
61
+ */
62
+ export function resolveEndpointUrl(baseUrl, endpointPath = '') {
63
+ const trimmedBase = String(baseUrl ?? '').trim().replace(/\/+$/, '');
64
+ const baseWithV1 = trimmedBase.endsWith('/v1') ? trimmedBase : `${trimmedBase}/v1`;
65
+ const cleanPath = String(endpointPath ?? '')
66
+ .trim()
67
+ .replace(/^\/+/, '')
68
+ .replace(/^v1(?:\/+|$)/, '');
69
+ return cleanPath ? `${baseWithV1}/${cleanPath}` : baseWithV1;
70
+ }
71
+
72
+ function parseEndpoint(role, entry, { allowRemote = false } = {}) {
73
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
74
+ return { endpoint: null, finding: `\`local.${role}\` must be an object with \`url\` and \`model\`` };
75
+ }
76
+
77
+ const rawUrl = typeof entry.url === 'string' ? entry.url.trim() : '';
78
+ if (!rawUrl) {
79
+ return { endpoint: null, finding: `\`local.${role}.url\` is required` };
80
+ }
81
+
82
+ let parsedUrl;
83
+ try {
84
+ parsedUrl = new URL(rawUrl);
85
+ } catch {
86
+ return { endpoint: null, finding: `\`local.${role}.url\` (${rawUrl}) is not a valid URL` };
87
+ }
88
+
89
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
90
+ return {
91
+ endpoint: null,
92
+ finding: `\`local.${role}.url\` (${rawUrl}) must use http:// or https://`,
93
+ };
94
+ }
95
+
96
+ if (parsedUrl.username !== '' || parsedUrl.password !== '') {
97
+ return {
98
+ endpoint: null,
99
+ finding: `\`local.${role}.url\` must not embed credentials in the URL`,
100
+ };
101
+ }
102
+
103
+ const model = typeof entry.model === 'string' ? entry.model.trim() : '';
104
+ if (!model) {
105
+ return { endpoint: null, finding: `\`local.${role}.model\` is required` };
106
+ }
107
+
108
+ const timeoutMs = entry.timeoutMs === undefined ? DEFAULT_LOCAL_TIMEOUT_MS : entry.timeoutMs;
109
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
110
+ return {
111
+ endpoint: null,
112
+ finding: `\`local.${role}.timeoutMs\` must be a positive whole number`,
113
+ };
114
+ }
115
+
116
+ const loopback = isLoopbackHost(parsedUrl.hostname);
117
+ const baseUrl = resolveEndpointUrl(rawUrl, '');
118
+ const blocked = !loopback && !allowRemote;
119
+
120
+ if (blocked) {
121
+ return {
122
+ endpoint: {
123
+ role,
124
+ url: rawUrl,
125
+ baseUrl,
126
+ model,
127
+ timeoutMs,
128
+ loopback: false,
129
+ blocked: true,
130
+ },
131
+ remoteBlocked: true,
132
+ finding: `\`local.${role}.url\` (${rawUrl}) is not a loopback address; non-loopback URLs are refused without --allow-remote (ADR-075)`,
133
+ };
134
+ }
135
+
136
+ return {
137
+ endpoint: {
138
+ role,
139
+ url: rawUrl,
140
+ baseUrl,
141
+ model,
142
+ timeoutMs,
143
+ loopback,
144
+ blocked: false,
145
+ },
146
+ remoteBlocked: false,
147
+ finding: null,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Parse and validate the `local` block of a manifest. Pure: never opens a file or socket.
153
+ */
154
+ export function parseLocalConfig(manifest, { allowRemote = false } = {}) {
155
+ const block = manifest?.local;
156
+ if (block === undefined || block === null) {
157
+ return { configured: false, chat: null, embed: null, findings: [], remoteBlocked: false };
158
+ }
159
+ if (typeof block !== 'object' || Array.isArray(block)) {
160
+ return {
161
+ configured: true,
162
+ chat: null,
163
+ embed: null,
164
+ findings: ['`local` must be an object'],
165
+ remoteBlocked: false,
166
+ };
167
+ }
168
+
169
+ const findings = [];
170
+ let remoteBlocked = false;
171
+
172
+ for (const key of Object.keys(block)) {
173
+ if (!ROLES.includes(key)) {
174
+ findings.push(`\`local.${key}\` is not a recognised local model role (expected \`chat\` or \`embed\`)`);
175
+ }
176
+ }
177
+
178
+ const parsedRoles = { chat: null, embed: null };
179
+ for (const role of ROLES) {
180
+ const raw = block[role];
181
+ if (raw === undefined || raw === null) continue;
182
+ const parsed = parseEndpoint(role, raw, { allowRemote });
183
+ parsedRoles[role] = parsed.endpoint;
184
+ if (parsed.remoteBlocked) remoteBlocked = true;
185
+ if (parsed.finding) findings.push(parsed.finding);
186
+ }
187
+
188
+ const configured =
189
+ parsedRoles.chat !== null ||
190
+ parsedRoles.embed !== null ||
191
+ findings.length > 0;
192
+
193
+ return {
194
+ configured,
195
+ chat: parsedRoles.chat,
196
+ embed: parsedRoles.embed,
197
+ findings,
198
+ remoteBlocked,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Read and validate the `local` block from a repository's manifest on disk.
204
+ */
205
+ export async function readLocalConfig(root, { allowRemote = false } = {}) {
206
+ const { manifest, exists, error } = await readManifest(root);
207
+ if (!exists) {
208
+ return {
209
+ configured: false,
210
+ chat: null,
211
+ embed: null,
212
+ findings: [],
213
+ remoteBlocked: false,
214
+ manifestExists: false,
215
+ manifestError: null,
216
+ };
217
+ }
218
+ if (error !== null || manifest === null) {
219
+ return {
220
+ configured: false,
221
+ chat: null,
222
+ embed: null,
223
+ findings: [`${MANIFEST_FILE} is ${error ?? 'unreadable'}`],
224
+ remoteBlocked: false,
225
+ manifestExists: true,
226
+ manifestError: error ?? 'unreadable',
227
+ };
228
+ }
229
+ return {
230
+ ...parseLocalConfig(manifest, { allowRemote }),
231
+ manifestExists: true,
232
+ manifestError: null,
233
+ };
234
+ }