simt-dev 0.5.0 → 0.6.2
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 +146 -0
- package/README.md +78 -59
- package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +13 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +1 -1
- package/docs/decisions/ADR-097-the-channels-publish-by-hand-and-the-version-rides-in-as-an-input.md +71 -0
- package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +149 -0
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +79 -0
- package/package.json +4 -4
- package/src/agenthooks/render.mjs +87 -21
- package/src/argv.mjs +13 -0
- package/src/bench/ablate.mjs +1 -3
- package/src/bench/grade.mjs +1 -1
- package/src/bench/mine.mjs +3 -3
- package/src/bench/probes.mjs +1 -3
- package/src/bench/report.mjs +1 -3
- package/src/bench/run.mjs +1 -3
- package/src/bench/tasks.mjs +1 -3
- package/src/bench/variants.mjs +1 -3
- package/src/brief/assemble.mjs +2 -4
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +47 -17
- package/src/commands/agenthook.mjs +6 -2
- package/src/commands/bench.mjs +4 -4
- package/src/commands/ci.mjs +1 -1
- package/src/commands/compare.mjs +1 -1
- package/src/commands/distill.mjs +1 -1
- package/src/commands/gate.mjs +2 -2
- package/src/commands/graph.mjs +2 -2
- package/src/commands/harness.mjs +87 -14
- package/src/commands/help.mjs +29 -7
- package/src/commands/hooks.mjs +1 -1
- package/src/commands/index.mjs +17 -3
- package/src/commands/init.mjs +2 -2
- package/src/commands/local.mjs +11 -0
- package/src/commands/mcp.mjs +1 -1
- package/src/commands/pack.mjs +246 -0
- package/src/commands/plugin.mjs +61 -0
- package/src/commands/rules.mjs +1 -1
- package/src/commands/skills.mjs +1 -1
- package/src/commands/skillscompile.mjs +1 -3
- package/src/commands/skillsinstall.mjs +1 -1
- package/src/commands/sync.mjs +8 -8
- package/src/commands/tokens.mjs +1 -1
- package/src/commands/upgrade.mjs +1 -1
- package/src/commands/why.mjs +1 -1
- package/src/compare/discover.mjs +1 -1
- package/src/compare/hooksstate.mjs +19 -7
- package/src/compare/learn.mjs +2 -2
- package/src/compare/row.mjs +3 -3
- package/src/decisions/index.mjs +1 -1
- package/src/distill/distill.mjs +2 -2
- package/src/distribute/release.mjs +142 -0
- package/src/graph/load.mjs +9 -8
- package/src/hooks/dispatcher.mjs +3 -2
- package/src/hooks/shim.mjs +11 -6
- package/src/index/govern.mjs +1 -1
- package/src/index/map.mjs +1 -1
- package/src/index/persist.mjs +2 -2
- package/src/index/store.mjs +50 -20
- package/src/ledger/index.mjs +2 -2
- package/src/local/client.mjs +22 -8
- package/src/local/config.mjs +17 -0
- package/src/locate.mjs +22 -1
- package/src/mcp/prompts.mjs +118 -0
- package/src/mcp/resources.mjs +96 -22
- package/src/mcp/server.mjs +33 -6
- package/src/mcp/tools.mjs +12 -12
- package/src/measure/session.mjs +1 -1
- package/src/memory/decide.mjs +1 -1
- package/src/memory/lessons.mjs +14 -14
- package/src/name.mjs +11 -1
- package/src/package-version.mjs +12 -10
- package/src/packs/manage.mjs +563 -0
- package/src/packs.mjs +15 -5
- package/src/plugins.mjs +164 -0
- package/src/projection/loaders.mjs +84 -10
- package/src/projection/manifest.mjs +4 -4
- package/src/projection/project.mjs +3 -3
- package/src/projection/rules.mjs +24 -4
- package/src/projection/surfaces.mjs +20 -2
- package/src/review/critic.mjs +4 -4
- package/src/toolwords.mjs +8 -5
- package/src/tsconfig.mjs +3 -3
- package/src/upgrade/plan.mjs +1 -1
- /package/bin/{simt-dev.mjs → simt.mjs} +0 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The binary channel: what a release publishes, where a repository keeps its copy,
|
|
2
|
+
// and the one command that gets it there.
|
|
3
|
+
//
|
|
4
|
+
// This module is the single place that knows those facts, because three generated
|
|
5
|
+
// surfaces must agree on them and they are rendered from different call sites: the
|
|
6
|
+
// locator block (src/locate.mjs) checks a path, the hook shim's remedy prints a
|
|
7
|
+
// download command, and the CI workflow renders the same download as a step. A
|
|
8
|
+
// second copy of the asset name or the URL is a copy that drifts, and a drifted
|
|
9
|
+
// remedy tells a reader to fetch bytes that do not exist.
|
|
10
|
+
//
|
|
11
|
+
// The channel is a fourth beside npm, PyPI and Homebrew (ADR-058), and unlike the
|
|
12
|
+
// other three it is the one that needs no toolchain at all: the npm channel needs
|
|
13
|
+
// npm, the wheel needs a Python, and the formula - until this release - needed
|
|
14
|
+
// node. The binary is a file. That is the whole design (ADR-098), and it is why
|
|
15
|
+
// the locator checks a path in the repository before it checks PATH: the bytes a
|
|
16
|
+
// repository pinned are the bytes its gate runs, on a machine with nothing else.
|
|
17
|
+
|
|
18
|
+
import { CLI, PACKAGE_NAME } from '../name.mjs';
|
|
19
|
+
|
|
20
|
+
// The GitHub repository the release is published from. A fact about where the
|
|
21
|
+
// bytes come from, like the npm registry host: a reader outside this estate
|
|
22
|
+
// reads it as a URL, not as an organisation. Not exported: the only surface a
|
|
23
|
+
// caller needs is the URL, and a second copy of the slug is a copy that drifts.
|
|
24
|
+
const REPO_SLUG = 'QuazarMedia/simt-dev';
|
|
25
|
+
|
|
26
|
+
/** The platforms the release builds. The set is small on purpose: it is the set
|
|
27
|
+
* the locator's remedy and the CI step can name, and a platform in this list that
|
|
28
|
+
* the release does not build is a remedy that downloads a 404. */
|
|
29
|
+
export const TARGETS = ['darwin-arm64', 'darwin-x64', 'linux-x64'];
|
|
30
|
+
|
|
31
|
+
// Where a repository keeps the binary it pinned, relative to its top level.
|
|
32
|
+
// Beneath `.simt`, like every other per-repo state this tool writes, so a reader
|
|
33
|
+
// who has seen one knows where to look for all of them. The name is the command,
|
|
34
|
+
// not the package: the reader types one program and this is it, the same way
|
|
35
|
+
// `bin/simt.mjs` is it on npm.
|
|
36
|
+
const LOCAL_BIN_NAME = CLI;
|
|
37
|
+
|
|
38
|
+
/** The directory a repository keeps its pinned binary in, relative to its top
|
|
39
|
+
* level. Exported because a caller that installs the binary must create it and
|
|
40
|
+
* has no other source for the fact. */
|
|
41
|
+
export const LOCAL_BIN_DIR = '.simt/bin';
|
|
42
|
+
|
|
43
|
+
/** The path the locator checks, relative to the repository's top level. */
|
|
44
|
+
export const LOCAL_BIN = `${LOCAL_BIN_DIR}/${LOCAL_BIN_NAME}`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The release asset name for a version and a target.
|
|
48
|
+
*
|
|
49
|
+
* The package name, not the command: it sits beside its siblings in the release
|
|
50
|
+
* and next to the npm tarball `simt-dev-0.5.0.tgz`, where one name for the project
|
|
51
|
+
* is what a reader matching names expects.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} version x.y.z
|
|
54
|
+
* @param {string} osArch one of TARGETS
|
|
55
|
+
* @returns {string} e.g. `simt-dev-0.5.0-darwin-arm64`
|
|
56
|
+
*/
|
|
57
|
+
export function assetName(version, osArch) {
|
|
58
|
+
if (!TARGETS.includes(osArch)) throw new Error(`unknown target ${osArch}; known: ${TARGETS.join(', ')}`);
|
|
59
|
+
return `${PACKAGE_NAME}-${version}-${osArch}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The asset name with the platform left for the shell to fill in at runtime.
|
|
64
|
+
*
|
|
65
|
+
* The CI step and the remedy both run on a machine whose platform is not known
|
|
66
|
+
* where the text was written, so the name is completed from `uname` or the
|
|
67
|
+
* runner there. One template shared with {@link assetName} keeps the two from
|
|
68
|
+
* drifting: a name rendered here that the release does not publish is a 404.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} version x.y.z
|
|
71
|
+
* @returns {string} e.g. `simt-dev-0.5.0-$os-$arch`, for a shell with $os and $arch set
|
|
72
|
+
*/
|
|
73
|
+
export function assetNameExpr(version) {
|
|
74
|
+
return `${PACKAGE_NAME}-${version}-$os-$arch`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The URL the release serves a version's assets from.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} version x.y.z
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function releaseBase(version) {
|
|
84
|
+
return `https://github.com/${REPO_SLUG}/releases/download/v${version}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The URL the release serves one asset from.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} version x.y.z
|
|
91
|
+
* @param {string} osArch one of TARGETS
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export function releaseUrl(version, osArch) {
|
|
95
|
+
return `${releaseBase(version)}/${assetName(version, osArch)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The download a reader without npm runs from their repository's top level: fetch
|
|
100
|
+
* the asset this machine needs and put it where the locator looks.
|
|
101
|
+
*
|
|
102
|
+
* One line on purpose - it is rendered into a remedy that a reader copies from an
|
|
103
|
+
* error message, and a multi-line script in an error message is not copied. The
|
|
104
|
+
* platform is derived rather than baked in, because the remedy lives in a file
|
|
105
|
+
* that is committed and shared: a URL rendered for the machine that wrote the
|
|
106
|
+
* shim would be a 404 for the colleague who clones it.
|
|
107
|
+
*
|
|
108
|
+
* The transport is TLS from the release, and the version the bytes report is
|
|
109
|
+
* checked against the repository's pin by the dispatcher itself (ADR-098), so
|
|
110
|
+
* the remedy does not also carry a checksum: it is the shortest command that
|
|
111
|
+
* reaches a working state, and the gate re-verifies what it runs.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} version x.y.z
|
|
114
|
+
* @returns {string} POSIX sh, run from the repository's top level
|
|
115
|
+
*/
|
|
116
|
+
export function localInstall(version) {
|
|
117
|
+
return [
|
|
118
|
+
`mkdir -p ${LOCAL_BIN_DIR}`,
|
|
119
|
+
...platformLines(),
|
|
120
|
+
`curl -fsSL "${releaseBase(version)}/${assetNameExpr(version)}" -o ${LOCAL_BIN}`,
|
|
121
|
+
`chmod +x ${LOCAL_BIN}`,
|
|
122
|
+
].join(' && ');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The two shell lines that derive this machine's release target at run time.
|
|
127
|
+
*
|
|
128
|
+
* `uname -m` answers `x86_64` on both platforms' Intel builds and `arm64` on
|
|
129
|
+
* Apple Silicon, so one substitution covers the naming gap. Both the remedy and
|
|
130
|
+
* the generated CI step name their asset from these lines: the text is committed
|
|
131
|
+
* and shared, so a URL baked for the machine that wrote it is a 404 for the
|
|
132
|
+
* colleague who clones it, and a workflow expression interpolated into a command
|
|
133
|
+
* substitution is worse - the CI step once rendered `os=$('${{ runner.os }}' …)`,
|
|
134
|
+
* which the runner substitutes to `os=$('Linux' …)` and `set -eu` refuses, so the
|
|
135
|
+
* install failed on every run in the one repository kind this channel exists for.
|
|
136
|
+
* The step is executed, not pattern-matched, in test/binary.test.mjs.
|
|
137
|
+
*
|
|
138
|
+
* @returns {string[]} POSIX sh lines assigning `$os` and `$arch`
|
|
139
|
+
*/
|
|
140
|
+
export function platformLines() {
|
|
141
|
+
return [`os=$(uname -s | tr A-Z a-z)`, `arch=$(uname -m | sed "s/x86_64/x64/")`];
|
|
142
|
+
}
|
package/src/graph/load.mjs
CHANGED
|
@@ -35,14 +35,15 @@ function defaultRegistryPath() {
|
|
|
35
35
|
/**
|
|
36
36
|
* The registry to read when the reader names none, in order (ADR-061 ruling 3):
|
|
37
37
|
*
|
|
38
|
-
* 1. The package root, when it holds one. In
|
|
39
|
-
* own registry
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
38
|
+
* 1. The package root, when it holds one. In a source checkout that is how the tool
|
|
39
|
+
* finds its own registry, sitting next to the code that reads it. An installed
|
|
40
|
+
* package holds no registry (ADR-061 ruling 2): the catalogue is workspace data,
|
|
41
|
+
* and an install serving another estate's skills to an adopting repository would
|
|
42
|
+
* be a confident wrong answer. This tier is a source-checkout convenience; when it
|
|
43
|
+
* is empty, resolution falls through to the workspace member or an honest absence.
|
|
44
|
+
* 2. A member of the resolved workspace, when exactly one holds one: reachable in a checkout
|
|
45
|
+
* whose package root holds no registry. More than one holding it is ambiguity and the
|
|
46
|
+
* reader must pick; none is an honest absence, reported with the
|
|
46
47
|
* workspace that was looked in.
|
|
47
48
|
*
|
|
48
49
|
* Every step is file existence plus the configured workspace, so the answer can be
|
package/src/hooks/dispatcher.mjs
CHANGED
|
@@ -68,8 +68,9 @@ export async function runGates(event, { root, manifest }) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
// The gates below run from the binary that resolved this shim. For a repository
|
|
71
|
-
// without npm that is
|
|
72
|
-
// the one this repo adopted, the gates that run here
|
|
71
|
+
// without npm that is the release binary in `.simt/bin` (ADR-098), whose version
|
|
72
|
+
// nothing else pins: if it is not the one this repo adopted, the gates that run here
|
|
73
|
+
// are not the ones CI runs.
|
|
73
74
|
// Placed after the empty-list check on purpose - with no gates there is nothing the
|
|
74
75
|
// version could get wrong, and failing that case would block commits in repos that
|
|
75
76
|
// deliberately declare none.
|
package/src/hooks/shim.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { renderLocator } from '../locate.mjs';
|
|
20
20
|
import { BIN_ENV, CLI, MANIFEST_FILE, PACKAGE_NAME } from '../name.mjs';
|
|
21
|
+
import { localInstall } from '../distribute/release.mjs';
|
|
21
22
|
import { packageVersion } from '../package-version.mjs';
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -49,8 +50,8 @@ export function fileFor(event) {
|
|
|
49
50
|
* @param {string} event one of EVENTS
|
|
50
51
|
* @param {{npm?: boolean, version?: string}} [opts]
|
|
51
52
|
* `npm` is whether the repository has a package.json: it decides the remedy's install
|
|
52
|
-
* (a devDependency where one exists,
|
|
53
|
-
* node_modules to pin in). `version` defaults to the running package's own version,
|
|
53
|
+
* (a devDependency where one exists, the release binary into the repository where
|
|
54
|
+
* there is no node_modules to pin in). `version` defaults to the running package's own version,
|
|
54
55
|
* so a shim generated by 0.2.0 names 0.2.0 in its remedy and CI install.
|
|
55
56
|
* @returns {string} POSIX sh source, LF-terminated, executable
|
|
56
57
|
*/
|
|
@@ -62,8 +63,9 @@ export function renderShim(event, { npm = true, version } = {}) {
|
|
|
62
63
|
// The install that finds the binary when node_modules and PATH both come up empty.
|
|
63
64
|
// A repository with a package.json pins simt-dev as a devDependency, so the remedy
|
|
64
65
|
// installs it there; one without (Gradle, Connect IQ) has no node_modules to pin in,
|
|
65
|
-
// so the remedy
|
|
66
|
-
|
|
66
|
+
// so the remedy downloads the release binary into the repository - the same pinned
|
|
67
|
+
// bytes CI gets, and one that needs no npm and no node on the machine (ADR-098).
|
|
68
|
+
const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v);
|
|
67
69
|
const D = '$';
|
|
68
70
|
return `#!/bin/sh
|
|
69
71
|
${SHIM_MARKER}
|
|
@@ -75,7 +77,8 @@ ${SHIM_MARKER}
|
|
|
75
77
|
# This file only locates the dispatcher and hands over. It resolves in order:
|
|
76
78
|
# 1. ${D}SIMT_DEV_BIN explicit override, for CI images and odd checkouts
|
|
77
79
|
# 2. node_modules/ the pinned project dependency
|
|
78
|
-
# 3.
|
|
80
|
+
# 3. .simt/bin/ the release binary this repository downloaded
|
|
81
|
+
# 4. ${CLI} on PATH a global install
|
|
79
82
|
# If none resolves it REFUSES rather than exiting 0. A hook that cannot verify
|
|
80
83
|
# anything must never report success - that is what teaches authors to reach for
|
|
81
84
|
# --no-verify.
|
|
@@ -102,7 +105,9 @@ ${renderLocator(`hooks run ${event} --root "${D}ROOT"`, {
|
|
|
102
105
|
*/
|
|
103
106
|
export function renderEvidenceShim({ npm = true, version } = {}) {
|
|
104
107
|
const v = version ?? packageVersion();
|
|
105
|
-
|
|
108
|
+
// The same split as renderShim: a devDependency where one exists, the release
|
|
109
|
+
// binary into the repository where there is no node_modules to pin in.
|
|
110
|
+
const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v);
|
|
106
111
|
const D = '$';
|
|
107
112
|
return `#!/bin/sh
|
|
108
113
|
${SHIM_MARKER}
|
package/src/index/govern.mjs
CHANGED
|
@@ -56,7 +56,7 @@ export function sourceRoots(files) {
|
|
|
56
56
|
/**
|
|
57
57
|
* One Affects token.
|
|
58
58
|
*
|
|
59
|
-
* A prefixed command (`simt
|
|
59
|
+
* A prefixed command (`simt init`, `simt hooks run`) is a command only when
|
|
60
60
|
* that command exists. The tool name on its own is not a command and not a path.
|
|
61
61
|
* Anything else is a path or a glob, at the repo root and then under each source
|
|
62
62
|
* root. A token that is none of those is unresolved.
|
package/src/index/map.mjs
CHANGED
|
@@ -285,7 +285,7 @@ export async function embedTaskQuery(root, task, { embed = false, allowRemote =
|
|
|
285
285
|
* }} [opts]
|
|
286
286
|
*/
|
|
287
287
|
export async function measureRecall(root, opts = {}) {
|
|
288
|
-
const tasksDir = join(root, '.simt
|
|
288
|
+
const tasksDir = join(root, '.simt', 'bench', 'tasks');
|
|
289
289
|
let names;
|
|
290
290
|
try {
|
|
291
291
|
names = (await readdir(tasksDir)).filter((name) => name.endsWith('.json')).sort();
|
package/src/index/persist.mjs
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
9
|
|
|
10
|
-
export const SQLITE_FILE = '.simt
|
|
11
|
-
export const JSONL_FILE = '.simt
|
|
10
|
+
export const SQLITE_FILE = '.simt/index.sqlite';
|
|
11
|
+
export const JSONL_FILE = '.simt/index.jsonl';
|
|
12
12
|
export const SCHEMA_V = '3';
|
|
13
13
|
|
|
14
14
|
const SCHEMA = `
|
package/src/index/store.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// otherwise. Either one is a cache: `git ls-files -s` can rebuild it, so a missing
|
|
5
5
|
// file is not a lost database. A blob that has not changed is not read again.
|
|
6
6
|
|
|
7
|
-
import { execFile } from 'node:child_process';
|
|
7
|
+
import { execFile, spawn } from 'node:child_process';
|
|
8
8
|
import { stat } from 'node:fs/promises';
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
10
|
|
|
@@ -387,9 +387,9 @@ export async function packageOf(root, filePath, cache) {
|
|
|
387
387
|
}
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
-
function git(root, args, input
|
|
390
|
+
function git(root, args, input) {
|
|
391
391
|
return new Promise((resolve, reject) => {
|
|
392
|
-
const child = execFile('git', args, { cwd: root, encoding:
|
|
392
|
+
const child = execFile('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
393
393
|
if (error) reject(Object.assign(error, { stdout, stderr }));
|
|
394
394
|
else resolve(stdout);
|
|
395
395
|
});
|
|
@@ -477,28 +477,58 @@ function normalise(filePath, got) {
|
|
|
477
477
|
return { symbols, imports: Array.isArray(got.imports) ? got.imports : [], edges };
|
|
478
478
|
}
|
|
479
479
|
|
|
480
|
+
/**
|
|
481
|
+
* The body of each listed blob, read as one `cat-file --batch` stream.
|
|
482
|
+
*
|
|
483
|
+
* Streamed and parsed incrementally rather than buffered: the combined output of a
|
|
484
|
+
* repository whose tracked content exceeds execFile's maxBuffer (64 MiB) used to
|
|
485
|
+
* reject the whole index with "stdout maxBuffer length exceeded" — an ~82 MiB
|
|
486
|
+
* Android checkout died on first run for exactly that reason.
|
|
487
|
+
*/
|
|
480
488
|
export async function blobTexts(root, blobs) {
|
|
481
489
|
const unique = [...new Set(blobs)];
|
|
482
490
|
const texts = new Map();
|
|
483
491
|
if (unique.length === 0) return texts;
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
492
|
+
const child = spawn('git', ['cat-file', '--batch'], { cwd: root });
|
|
493
|
+
child.stdin.end(`${unique.join('\n')}\n`);
|
|
494
|
+
let pending = null;
|
|
495
|
+
const onChunk = (chunk) => {
|
|
496
|
+
pending = pending === null ? chunk : Buffer.concat([pending, chunk]);
|
|
497
|
+
let consumed = 0;
|
|
498
|
+
for (;;) {
|
|
499
|
+
const nl = pending.indexOf(0x0a, consumed);
|
|
500
|
+
if (nl < 0) break;
|
|
501
|
+
const parts = pending.toString('utf8', consumed, nl).split(' ');
|
|
502
|
+
const size = Number(parts[2]);
|
|
503
|
+
if (parts[1] !== 'blob' || !Number.isFinite(size)) {
|
|
504
|
+
consumed = nl + 1;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const end = nl + 1 + size + 1;
|
|
508
|
+
if (pending.length < end) break; // the body is not fully arrived yet
|
|
509
|
+
const start = nl + 1;
|
|
510
|
+
const body = pending.subarray(start, start + size);
|
|
511
|
+
consumed = end;
|
|
512
|
+
texts.set(parts[0], body.includes(0) ? null : body.toString('utf8'));
|
|
495
513
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
514
|
+
// Drop the consumed prefix. Copy the tail once it has grown past a MiB, so the
|
|
515
|
+
// released bytes are actually freed instead of staying alive behind a view into
|
|
516
|
+
// the buffer they came from.
|
|
517
|
+
if (consumed > 0) {
|
|
518
|
+
const tail = pending.subarray(consumed);
|
|
519
|
+
pending = tail.length === 0 ? null : consumed >= 1 << 20 ? Buffer.from(tail) : tail;
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
return new Promise((resolve, reject) => {
|
|
523
|
+
let stderr = '';
|
|
524
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
525
|
+
child.stdout.on('data', onChunk);
|
|
526
|
+
child.on('error', reject);
|
|
527
|
+
child.on('close', (code) => {
|
|
528
|
+
if (code !== 0) reject(new Error(`git cat-file --batch exited ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
|
|
529
|
+
else resolve(texts);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
502
532
|
}
|
|
503
533
|
|
|
504
534
|
async function blobSizes(root, blobs) {
|
package/src/ledger/index.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
|
|
23
23
|
const exec = promisify(execFile);
|
|
24
24
|
|
|
25
|
-
const LEDGER_FILE = '.simt
|
|
25
|
+
const LEDGER_FILE = '.simt/ledger.jsonl';
|
|
26
26
|
|
|
27
27
|
/** Phrases that claim a run happened. `unverified` and `not verified` are removed
|
|
28
28
|
* first, because both contain the word and neither is a claim. */
|
|
@@ -76,7 +76,7 @@ export async function recordRun(root, { cmd, exit, ms, output }) {
|
|
|
76
76
|
}
|
|
77
77
|
: {}),
|
|
78
78
|
};
|
|
79
|
-
await mkdir(join(root, '.simt
|
|
79
|
+
await mkdir(join(root, '.simt'), { recursive: true });
|
|
80
80
|
await appendFile(join(root, LEDGER_FILE), `${JSON.stringify(line)}\n`);
|
|
81
81
|
|
|
82
82
|
if (exit === 0 && typeof cmd === 'string') {
|
package/src/local/client.mjs
CHANGED
|
@@ -26,6 +26,7 @@ function guardEndpoint(endpoint, { allowRemote = false } = {}) {
|
|
|
26
26
|
if (!model) {
|
|
27
27
|
return { ok: false, error: 'local model endpoint is missing `model`', refusedRemote: false };
|
|
28
28
|
}
|
|
29
|
+
const apiKey = typeof endpoint.apiKey === 'string' && endpoint.apiKey.length > 0 ? endpoint.apiKey : null;
|
|
29
30
|
const loopback = isLoopbackUrl(rawUrl);
|
|
30
31
|
if (!loopback && !allowRemote) {
|
|
31
32
|
return {
|
|
@@ -39,6 +40,7 @@ function guardEndpoint(endpoint, { allowRemote = false } = {}) {
|
|
|
39
40
|
url: rawUrl,
|
|
40
41
|
baseUrl: resolveEndpointUrl(rawUrl, ''),
|
|
41
42
|
model,
|
|
43
|
+
apiKey,
|
|
42
44
|
timeoutMs: Number.isInteger(endpoint.timeoutMs) && endpoint.timeoutMs > 0
|
|
43
45
|
? endpoint.timeoutMs
|
|
44
46
|
: DEFAULT_LOCAL_TIMEOUT_MS,
|
|
@@ -46,13 +48,20 @@ function guardEndpoint(endpoint, { allowRemote = false } = {}) {
|
|
|
46
48
|
};
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
async function requestJson(url, { method = 'GET', body = undefined, timeoutMs = DEFAULT_LOCAL_TIMEOUT_MS, fetchImpl = globalThis.fetch } = {}) {
|
|
51
|
+
async function requestJson(url, { method = 'GET', body = undefined, timeoutMs = DEFAULT_LOCAL_TIMEOUT_MS, apiKey = null, fetchImpl = globalThis.fetch } = {}) {
|
|
50
52
|
const controller = new AbortController();
|
|
51
53
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
52
54
|
try {
|
|
55
|
+
const headers = {
|
|
56
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
57
|
+
accept: 'application/json',
|
|
58
|
+
};
|
|
59
|
+
if (typeof apiKey === 'string' && apiKey.length > 0) {
|
|
60
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
61
|
+
}
|
|
53
62
|
const response = await fetchImpl(url, {
|
|
54
63
|
method,
|
|
55
|
-
headers
|
|
64
|
+
headers,
|
|
56
65
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
57
66
|
signal: controller.signal,
|
|
58
67
|
});
|
|
@@ -121,6 +130,7 @@ export async function localModels(endpoint, { allowRemote = false, fetchImpl = g
|
|
|
121
130
|
const res = await requestJson(targetUrl, {
|
|
122
131
|
method: 'GET',
|
|
123
132
|
timeoutMs: guard.timeoutMs,
|
|
133
|
+
apiKey: guard.apiKey,
|
|
124
134
|
fetchImpl,
|
|
125
135
|
});
|
|
126
136
|
if (!res.ok) {
|
|
@@ -200,6 +210,7 @@ export async function localChat(
|
|
|
200
210
|
stream: false,
|
|
201
211
|
},
|
|
202
212
|
timeoutMs: guard.timeoutMs,
|
|
213
|
+
apiKey: guard.apiKey,
|
|
203
214
|
fetchImpl,
|
|
204
215
|
});
|
|
205
216
|
|
|
@@ -216,12 +227,14 @@ export async function localChat(
|
|
|
216
227
|
}
|
|
217
228
|
|
|
218
229
|
const choice = Array.isArray(res.data.choices) ? res.data.choices[0] : null;
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
230
|
+
const message = choice?.message ?? {};
|
|
231
|
+
// Prefer the final answer. A reasoning model (omlx, Qwen3-thinking) emits its
|
|
232
|
+
// output in `reasoning_content` and leaves `content` empty or absent when the
|
|
233
|
+
// budget is tiny (e.g. doctor's 1-token ping), so fall back to it, then the
|
|
234
|
+
// legacy `text` field. Whitespace-only output counts as absent.
|
|
235
|
+
const content = [message.content, message.reasoning_content, choice?.text]
|
|
236
|
+
.map((value) => (typeof value === 'string' && value.trim().length > 0 ? value : null))
|
|
237
|
+
.find((value) => value !== null) ?? null;
|
|
225
238
|
|
|
226
239
|
if (content === null) {
|
|
227
240
|
return {
|
|
@@ -275,6 +288,7 @@ export async function localEmbed(
|
|
|
275
288
|
input: Array.isArray(input) ? input.map(String) : String(input ?? ''),
|
|
276
289
|
},
|
|
277
290
|
timeoutMs: guard.timeoutMs,
|
|
291
|
+
apiKey: guard.apiKey,
|
|
278
292
|
fetchImpl,
|
|
279
293
|
});
|
|
280
294
|
|
package/src/local/config.mjs
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
// "embed": { "url": "http://127.0.0.1:11434/v1", "model": "nomic-embed-code" }
|
|
9
9
|
// }
|
|
10
10
|
//
|
|
11
|
+
// Each role also accepts an optional `apiKey` (a non-empty string). When present it is
|
|
12
|
+
// sent as an `Authorization: Bearer <key>` header, for servers that require a token
|
|
13
|
+
// (omlx, llama.cpp `--api-key`, OpenAI-compatible gateways). Omitted by default.
|
|
14
|
+
//
|
|
11
15
|
// Non-loopback URLs are refused unless `--allow-remote` is explicitly passed (ADR-075).
|
|
12
16
|
|
|
13
17
|
import { EXIT, fail } from '../output/index.mjs';
|
|
@@ -114,6 +118,17 @@ function parseEndpoint(role, entry, { allowRemote = false } = {}) {
|
|
|
114
118
|
};
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
// Optional bearer credential for servers that require authentication (omlx,
|
|
122
|
+
// llama.cpp `--api-key`, OpenAI-compatible gateways). Absent by default: most
|
|
123
|
+
// local servers (Ollama, LM Studio) accept unauthenticated loopback requests.
|
|
124
|
+
let apiKey = '';
|
|
125
|
+
if (entry.apiKey !== undefined) {
|
|
126
|
+
if (typeof entry.apiKey !== 'string' || entry.apiKey.trim().length === 0) {
|
|
127
|
+
return { endpoint: null, finding: `\`local.${role}.apiKey\` must be a non-empty string when set` };
|
|
128
|
+
}
|
|
129
|
+
apiKey = entry.apiKey;
|
|
130
|
+
}
|
|
131
|
+
|
|
117
132
|
const loopback = isLoopbackHost(parsedUrl.hostname);
|
|
118
133
|
const baseUrl = resolveEndpointUrl(rawUrl, '');
|
|
119
134
|
const blocked = !loopback && !allowRemote;
|
|
@@ -126,6 +141,7 @@ function parseEndpoint(role, entry, { allowRemote = false } = {}) {
|
|
|
126
141
|
baseUrl,
|
|
127
142
|
model,
|
|
128
143
|
timeoutMs,
|
|
144
|
+
apiKey,
|
|
129
145
|
loopback: false,
|
|
130
146
|
blocked: true,
|
|
131
147
|
},
|
|
@@ -141,6 +157,7 @@ function parseEndpoint(role, entry, { allowRemote = false } = {}) {
|
|
|
141
157
|
baseUrl,
|
|
142
158
|
model,
|
|
143
159
|
timeoutMs,
|
|
160
|
+
apiKey,
|
|
144
161
|
loopback,
|
|
145
162
|
blocked: false,
|
|
146
163
|
},
|
package/src/locate.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// this is a block and not a script.
|
|
13
13
|
|
|
14
14
|
import { BIN_ENV, BIN_REL, CLI, PACKAGE_NAME } from './name.mjs';
|
|
15
|
+
import { LOCAL_BIN } from './distribute/release.mjs';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* @param {string} command arguments to hand the tool, with `$ROOT` spelled as a shell
|
|
@@ -24,6 +25,13 @@ import { BIN_ENV, BIN_REL, CLI, PACKAGE_NAME } from './name.mjs';
|
|
|
24
25
|
* @returns {string} POSIX sh source
|
|
25
26
|
*/
|
|
26
27
|
export function renderLocator(command, { subject = CLI, remedy = `REMEDY run: npm i -D ${PACKAGE_NAME}` } = {}) {
|
|
28
|
+
// The remedy is emitted single-quoted (see the assignment at the bottom of the
|
|
29
|
+
// block), so a quote in it would end that string and re-open the shell to
|
|
30
|
+
// whatever follows. Refuse at render time: the failure is a template bug, and
|
|
31
|
+
// it should be found where the string is written, not in a hook on a machine.
|
|
32
|
+
if (remedy.includes("'")) {
|
|
33
|
+
throw new Error(`the remedy is embedded single-quoted and cannot contain a quote: ${remedy}`);
|
|
34
|
+
}
|
|
27
35
|
const D = '$';
|
|
28
36
|
return `# ${D}${BIN_ENV} must name ONE program: an executable on PATH, or a .mjs entry
|
|
29
37
|
# point to hand to node. It cannot be a command line, because there is no safe way
|
|
@@ -59,6 +67,15 @@ if [ -f "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ]; then
|
|
|
59
67
|
exec node "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ${command}
|
|
60
68
|
fi
|
|
61
69
|
|
|
70
|
+
# The binary this repository downloaded for itself: the pinned bytes, no node.
|
|
71
|
+
# It is checked before PATH so a repository's own copy wins over whatever a
|
|
72
|
+
# machine happens to have installed, the same way node_modules wins - and the
|
|
73
|
+
# dispatcher still compares the version it runs against the pin, so a stale
|
|
74
|
+
# copy is refused rather than trusted.
|
|
75
|
+
if [ -x "${D}ROOT/${LOCAL_BIN}" ]; then
|
|
76
|
+
exec "${D}ROOT/${LOCAL_BIN}" ${command}
|
|
77
|
+
fi
|
|
78
|
+
|
|
62
79
|
if command -v ${CLI} >/dev/null 2>&1; then
|
|
63
80
|
exec ${CLI} ${command}
|
|
64
81
|
fi
|
|
@@ -68,7 +85,11 @@ echo "${CLI}: cannot locate ${subject}." >&2
|
|
|
68
85
|
# unbound variable aborts the shell, so an unguarded ${D}${BIN_ENV} here would
|
|
69
86
|
# kill the message that exists to explain the failure - and this block only runs
|
|
70
87
|
# when nothing resolved, which is the one moment the reader needs it.
|
|
71
|
-
echo " looked for: ${D}{${BIN_ENV}:-<unset>}, ${D}ROOT/node_modules/${PACKAGE_NAME}, ${CLI} on PATH" >&2
|
|
88
|
+
echo " looked for: ${D}{${BIN_ENV}:-<unset>}, ${D}ROOT/node_modules/${PACKAGE_NAME}, ${D}ROOT/${LOCAL_BIN}, ${CLI} on PATH" >&2
|
|
89
|
+
# The remedy is assigned single-quoted and echoed, so a command in it - the binary
|
|
90
|
+
# download derives its platform with $(uname) - stays text here and runs only when
|
|
91
|
+
# the reader runs it. A remedy in double quotes would be half-executed by this echo.
|
|
92
|
+
remedy='${remedy}'
|
|
72
93
|
echo "${remedy}" >&2
|
|
73
94
|
exit 1
|
|
74
95
|
`;
|