simt-dev 0.3.1 → 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 (52) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +14 -4
  3. package/package.json +2 -2
  4. package/rules/core.md +1 -1
  5. package/src/agenthooks/policy.mjs +10 -2
  6. package/src/agenthooks/prompt.mjs +81 -0
  7. package/src/agenthooks/render.mjs +138 -17
  8. package/src/argv.mjs +16 -1
  9. package/src/bench/grade.mjs +3 -0
  10. package/src/bench/run.mjs +85 -39
  11. package/src/bench/variants.mjs +456 -0
  12. package/src/brief/assemble.mjs +346 -0
  13. package/src/brief/kind.mjs +134 -0
  14. package/src/brief/packet.mjs +197 -0
  15. package/src/commands/agenthook.mjs +84 -5
  16. package/src/commands/bench.mjs +40 -15
  17. package/src/commands/brief.mjs +78 -0
  18. package/src/commands/compare.mjs +10 -2
  19. package/src/commands/decide.mjs +116 -0
  20. package/src/commands/help.mjs +34 -3
  21. package/src/commands/index.mjs +17 -1
  22. package/src/commands/lessons.mjs +127 -0
  23. package/src/commands/local.mjs +97 -0
  24. package/src/commands/map.mjs +42 -7
  25. package/src/commands/repoindex.mjs +4 -1
  26. package/src/commands/rules.mjs +17 -2
  27. package/src/commands/skills.mjs +3 -17
  28. package/src/commands/skillscompile.mjs +255 -0
  29. package/src/commands/sync.mjs +15 -0
  30. package/src/commands/tests.mjs +2 -49
  31. package/src/commands/why.mjs +18 -23
  32. package/src/compare/learn.mjs +680 -0
  33. package/src/decisions/index.mjs +116 -65
  34. package/src/federation/client.mjs +188 -0
  35. package/src/federation/config.mjs +134 -0
  36. package/src/federation/query.mjs +48 -0
  37. package/src/graph/compile.mjs +475 -0
  38. package/src/index/gate.mjs +1 -13
  39. package/src/index/govern.mjs +23 -11
  40. package/src/index/map.mjs +251 -17
  41. package/src/index/persist.mjs +74 -7
  42. package/src/index/store.mjs +222 -8
  43. package/src/kernel/variants.mjs +353 -0
  44. package/src/ledger/index.mjs +36 -1
  45. package/src/local/client.mjs +482 -0
  46. package/src/local/config.mjs +234 -0
  47. package/src/mcp/tools.mjs +138 -1
  48. package/src/memory/decide.mjs +462 -0
  49. package/src/memory/lessons.mjs +858 -0
  50. package/src/projection/lock.mjs +39 -7
  51. package/src/projection/rules.mjs +32 -8
  52. package/src/toolwords.mjs +6 -0
@@ -12,6 +12,14 @@ import { tmpdir } from 'node:os';
12
12
  import { join } from 'node:path';
13
13
  import { promisify } from 'node:util';
14
14
 
15
+ import {
16
+ LESSONS_FILE,
17
+ errorSignature,
18
+ firstErrorLine,
19
+ normaliseErrorLine,
20
+ recordFailThenFix,
21
+ } from '../memory/lessons.mjs';
22
+
15
23
  const exec = promisify(execFile);
16
24
 
17
25
  const LEDGER_FILE = '.simt-dev/ledger.jsonl';
@@ -34,7 +42,11 @@ export async function treeId(root) {
34
42
  // The ledger is a record of the tree, so it cannot be part of the tree it names.
35
43
  // `git add -A` would take it once the file exists, and the line would prove a
36
44
  // tree that stopped existing when the line was written.
37
- await exec('git', ['add', '-A', '--', '.', ':(exclude).simt-dev/ledger.jsonl'], { cwd: root, env });
45
+ await exec(
46
+ 'git',
47
+ ['add', '-A', '--', '.', `:(exclude)${LEDGER_FILE}`, `:(exclude)${LESSONS_FILE}`],
48
+ { cwd: root, env },
49
+ );
38
50
  const { stdout } = await exec('git', ['write-tree'], { cwd: root, env });
39
51
  return stdout.trim();
40
52
  } finally {
@@ -47,6 +59,7 @@ export async function treeId(root) {
47
59
  * change, and a ledger that kept logs would keep whatever the command printed.
48
60
  */
49
61
  export async function recordRun(root, { cmd, exit, ms, output }) {
62
+ const { entries: prior } = await readLedger(root).catch(() => ({ entries: [] }));
50
63
  const line = {
51
64
  v: 1,
52
65
  ts: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
@@ -56,9 +69,31 @@ export async function recordRun(root, { cmd, exit, ms, output }) {
56
69
  ms,
57
70
  outputDigest: `sha256-${createHash('sha256').update(output).digest('hex')}`,
58
71
  summary: summarise(output, exit),
72
+ ...(exit !== 0
73
+ ? {
74
+ errorLine: normaliseErrorLine(firstErrorLine(output)),
75
+ signature: errorSignature(output),
76
+ }
77
+ : {}),
59
78
  };
60
79
  await mkdir(join(root, '.simt-dev'), { recursive: true });
61
80
  await appendFile(join(root, LEDGER_FILE), `${JSON.stringify(line)}\n`);
81
+
82
+ if (exit === 0 && typeof cmd === 'string') {
83
+ let pendingFail = null;
84
+ for (const entry of prior) {
85
+ if (entry.cmd !== cmd) continue;
86
+ if (entry.exit !== 0) {
87
+ if (!pendingFail) pendingFail = entry;
88
+ } else {
89
+ pendingFail = null;
90
+ }
91
+ }
92
+ if (pendingFail) {
93
+ await recordFailThenFix(root, { failEntry: pendingFail, passEntry: line }).catch(() => {});
94
+ }
95
+ }
96
+
62
97
  return line;
63
98
  }
64
99
 
@@ -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
+ }