@sigrun/sigrun 0.0.0-stage → 0.1.1
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 +214 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +89 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core-owned tool surface (plan Task 1): pi's coding tools plus the
|
|
3
|
+
* hand-written search tools, installed by `openHarness` for every
|
|
4
|
+
* conversation — never through the extension loader, never quarantined.
|
|
5
|
+
*
|
|
6
|
+
* TRUST POSTURE (spec): these tools are NOT filesystem-confined.
|
|
7
|
+
* `isConfinedPath` (server/content-parts.ts) is an HTTP-submission-layer
|
|
8
|
+
* check only; the model-driven tools may touch any path the conversation's
|
|
9
|
+
* `ExecutionEnv` can reach. Sigrun's security model is policy-gated: an
|
|
10
|
+
* installed gating extension gates write/exec-class calls; with none
|
|
11
|
+
* installed, tool calls proceed ungated (fail open — core ships no gate of
|
|
12
|
+
* its own). No filesystem confinement is claimed here.
|
|
13
|
+
*
|
|
14
|
+
* Two extensions install here:
|
|
15
|
+
*
|
|
16
|
+
* - `sigrun.coding-tools`: pi-durable's `write`, `edit`, `bash` factories
|
|
17
|
+
* plus a `read` wrapper that returns image files as image content
|
|
18
|
+
* (upstream's read hard-errors on images; sigrun's model surface accepts
|
|
19
|
+
* them — the same inline `ImageContent` the §5 submission path produces).
|
|
20
|
+
* - `sigrun.search-tools`: `find`, `grep`, `ls` — hand-written in TypeScript
|
|
21
|
+
* against `ExecutionEnv` primitives (no shell-out to fd/rg), so remote
|
|
22
|
+
* workspaces stay correct regardless of the resolver (the env seam is the
|
|
23
|
+
* only filesystem access path; see workspace.ts).
|
|
24
|
+
*
|
|
25
|
+
* Everything resolves through `ToolExecutionApi.env` — the per-call env the
|
|
26
|
+
* harness builds — so paths are conversation-relative exactly like the
|
|
27
|
+
* upstream coding tools.
|
|
28
|
+
*/
|
|
29
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
30
|
+
import { createBashTool, createEditTool, createReadTool, createWriteTool, } from "@earendil-works/pi-durable/tools";
|
|
31
|
+
import { defineExtension, defineTool } from "@earendil-works/pi-durable";
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Coding tools (read/write/edit/bash)
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
/**
|
|
36
|
+
* The upstream text read this module wraps (see {@link imageReadTool}).
|
|
37
|
+
* Built once; the wrapper delegates every execution to it.
|
|
38
|
+
*/
|
|
39
|
+
const textReadTool = createReadTool();
|
|
40
|
+
/** Extract the mime type from upstream's `unsupported_image` diagnostic. */
|
|
41
|
+
const IMAGE_MIME_IN_MESSAGE = /is an image \(([^)]+)\)/;
|
|
42
|
+
/**
|
|
43
|
+
* The `read` tool with image support: delegates to pi-durable's read and
|
|
44
|
+
* rescues exactly its `unsupported_image` error — the error carries the
|
|
45
|
+
* byte-sniffed mime type, which the tools module does not export a detector
|
|
46
|
+
* for — re-reading the bytes and returning them as base64 `ImageContent`
|
|
47
|
+
* (the upload path's inline shape; pi-ai tool results admit image content).
|
|
48
|
+
* Every other result — text content, truncation diagnostics, other errors —
|
|
49
|
+
* passes through unchanged, and non-image binary behavior is upstream's.
|
|
50
|
+
*/
|
|
51
|
+
export const imageReadTool = defineTool({
|
|
52
|
+
name: "read",
|
|
53
|
+
description: `${textReadTool.description} Image files (PNG, JPEG, GIF, WebP, BMP) are returned as image content.`,
|
|
54
|
+
parameters: textReadTool.parameters,
|
|
55
|
+
async execute(args, api, context) {
|
|
56
|
+
const result = await textReadTool.execute(args, api, context);
|
|
57
|
+
if (result.isError !== true)
|
|
58
|
+
return result;
|
|
59
|
+
const diagnostic = result.diagnostics?.find((candidate) => candidate.code === "unsupported_image");
|
|
60
|
+
const mimeType = diagnostic === undefined
|
|
61
|
+
? undefined
|
|
62
|
+
: IMAGE_MIME_IN_MESSAGE.exec(diagnostic.message)?.[1];
|
|
63
|
+
if (mimeType === undefined || api.env === undefined)
|
|
64
|
+
return result;
|
|
65
|
+
// The sniffed bytes existed moments ago; resolve and re-read them.
|
|
66
|
+
const resolved = await api.env.absolutePath(args.path, context);
|
|
67
|
+
if (!resolved.ok)
|
|
68
|
+
return result;
|
|
69
|
+
const bytes = await api.env.readBinaryFile(resolved.value, context);
|
|
70
|
+
if (!bytes.ok)
|
|
71
|
+
return result;
|
|
72
|
+
const image = {
|
|
73
|
+
type: "image",
|
|
74
|
+
data: Buffer.from(bytes.value).toString("base64"),
|
|
75
|
+
mimeType,
|
|
76
|
+
};
|
|
77
|
+
return { output: [image] };
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
/** `read`, `write`, `edit`, `bash` — core-installed for every conversation. */
|
|
81
|
+
export const codingToolsExtension = defineExtension({
|
|
82
|
+
name: "sigrun.coding-tools",
|
|
83
|
+
tools: [imageReadTool, createWriteTool(), createEditTool(), createBashTool()],
|
|
84
|
+
});
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Search tools (find/grep/ls) — pure TS over ExecutionEnv
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
/** Result cap shared by `find` and `grep`; past it the walk stops early. */
|
|
89
|
+
const MAX_RESULTS = 200;
|
|
90
|
+
/** Walk depth cap: a deep tree cannot recurse without bound. */
|
|
91
|
+
const MAX_WALK_DEPTH = 64;
|
|
92
|
+
/** Per-line truncation for grep hits, so one huge line cannot flood context. */
|
|
93
|
+
const GREP_LINE_LIMIT_CHARS = 300;
|
|
94
|
+
/** Never traversed, never listed — git's own rule for `.git`. */
|
|
95
|
+
const GIT_DIRECTORY = ".git";
|
|
96
|
+
/** An explicit tool error: text content the model can read and recover from. */
|
|
97
|
+
function searchError(message) {
|
|
98
|
+
return { output: [{ type: "text", text: message }], isError: true };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Glob → anchored regex source (`*`, `?`, `**` per the subset above).
|
|
102
|
+
* A `**` run followed by a slash becomes an optional any-directories
|
|
103
|
+
* group — matching zero or more whole directories — so such patterns also
|
|
104
|
+
* match files directly under their prefix; a bare `**` matches anything
|
|
105
|
+
* including `/`.
|
|
106
|
+
*/
|
|
107
|
+
function globToRegExpSource(pattern) {
|
|
108
|
+
let source = "";
|
|
109
|
+
let i = 0;
|
|
110
|
+
while (i < pattern.length) {
|
|
111
|
+
const char = pattern[i];
|
|
112
|
+
if (char === "*") {
|
|
113
|
+
while (pattern[i + 1] === "*")
|
|
114
|
+
i++;
|
|
115
|
+
if (pattern[i + 1] === "/") {
|
|
116
|
+
source += "(?:.*/)?";
|
|
117
|
+
i++; // consume the slash (matched by the group)
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
source += ".*";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else if (char === "?") {
|
|
124
|
+
source += "[^/]";
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
128
|
+
}
|
|
129
|
+
i++;
|
|
130
|
+
}
|
|
131
|
+
return source;
|
|
132
|
+
}
|
|
133
|
+
/** Parse `.gitignore` text into ordered rules (see the subset above). */
|
|
134
|
+
export function parseGitignore(text) {
|
|
135
|
+
const rules = [];
|
|
136
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
137
|
+
let line = rawLine.replace(/\s+$/, "");
|
|
138
|
+
if (line === "" || line.startsWith("#"))
|
|
139
|
+
continue;
|
|
140
|
+
const negated = line.startsWith("!");
|
|
141
|
+
if (negated)
|
|
142
|
+
line = line.slice(1);
|
|
143
|
+
const dirOnly = line.endsWith("/");
|
|
144
|
+
if (dirOnly)
|
|
145
|
+
line = line.slice(0, -1);
|
|
146
|
+
if (line === "")
|
|
147
|
+
continue;
|
|
148
|
+
const anchored = line.includes("/");
|
|
149
|
+
const pattern = line.startsWith("/") ? line.slice(1) : line;
|
|
150
|
+
if (pattern === "")
|
|
151
|
+
continue;
|
|
152
|
+
rules.push({
|
|
153
|
+
negated,
|
|
154
|
+
dirOnly,
|
|
155
|
+
anchored,
|
|
156
|
+
matcher: new RegExp(`^${globToRegExpSource(pattern)}$`),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return rules;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Every component-boundary suffix of `path`, itself first:
|
|
163
|
+
* `a/b/c` → `["a/b/c", "b/c", "c"]` — what an unanchored rule can match.
|
|
164
|
+
*/
|
|
165
|
+
function componentSuffixes(path) {
|
|
166
|
+
const suffixes = [];
|
|
167
|
+
let candidate = path;
|
|
168
|
+
for (;;) {
|
|
169
|
+
suffixes.push(candidate);
|
|
170
|
+
const slash = candidate.indexOf("/");
|
|
171
|
+
if (slash === -1)
|
|
172
|
+
return suffixes;
|
|
173
|
+
candidate = candidate.slice(slash + 1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Whether one rule matches `relativePath` (`/`-joined, from the ignore file's dir). */
|
|
177
|
+
function ruleMatches(rule, relativePath, isDirectory) {
|
|
178
|
+
if (rule.dirOnly && !isDirectory)
|
|
179
|
+
return false;
|
|
180
|
+
const candidates = rule.anchored
|
|
181
|
+
? [relativePath]
|
|
182
|
+
: componentSuffixes(relativePath);
|
|
183
|
+
return candidates.some((candidate) => rule.matcher.test(candidate));
|
|
184
|
+
}
|
|
185
|
+
/** Last-matching-rule verdict for one exact path (git's precedence). */
|
|
186
|
+
function lastMatchIgnored(rules, path, isDirectory) {
|
|
187
|
+
let ignored = false;
|
|
188
|
+
for (const rule of rules) {
|
|
189
|
+
if (ruleMatches(rule, path, isDirectory)) {
|
|
190
|
+
ignored = !rule.negated;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return ignored;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Whether `relativePath` (`/`-joined, from the ignore file's dir) is
|
|
197
|
+
* ignored: an ignored ANCESTOR DIRECTORY ignores everything below it — a
|
|
198
|
+
* negated pattern cannot re-include files under an excluded directory
|
|
199
|
+
* (git's documented rule) — otherwise the last matching rule for the path
|
|
200
|
+
* itself wins.
|
|
201
|
+
*/
|
|
202
|
+
export function isIgnoredPath(rules, relativePath, isDirectory) {
|
|
203
|
+
const segments = relativePath.split("/");
|
|
204
|
+
for (let end = 1; end < segments.length; end++) {
|
|
205
|
+
const ancestor = segments.slice(0, end).join("/");
|
|
206
|
+
if (lastMatchIgnored(rules, ancestor, true))
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
return lastMatchIgnored(rules, relativePath, isDirectory);
|
|
210
|
+
}
|
|
211
|
+
/** Read the walk root's `.gitignore`; absent file means no filtering. */
|
|
212
|
+
async function loadGitignore(env, rootDir, context) {
|
|
213
|
+
const read = await env.readTextFile(`${rootDir}/.gitignore`, context);
|
|
214
|
+
if (read.ok)
|
|
215
|
+
return parseGitignore(read.value);
|
|
216
|
+
if (read.error.code === "not_found")
|
|
217
|
+
return [];
|
|
218
|
+
throw new Error(`Failed to read .gitignore: ${read.error.message}`, {
|
|
219
|
+
cause: read.error,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/** Strip `prefix + "/"` from an absolute path; `undefined` when not inside. */
|
|
223
|
+
function stripPathPrefix(path, prefix) {
|
|
224
|
+
if (path === prefix)
|
|
225
|
+
return "";
|
|
226
|
+
if (path.startsWith(`${prefix}/`))
|
|
227
|
+
return path.slice(prefix.length + 1);
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Depth-first walk of `rootDir` through `env.listDir`, in sorted name order.
|
|
232
|
+
* Prunes `.git`, ignored directories, and directories past the depth cap;
|
|
233
|
+
* skips ignored files; does not descend into symlinks. The visitor returns
|
|
234
|
+
* (or resolves) `false` to stop the whole walk — how result caps end it.
|
|
235
|
+
*/
|
|
236
|
+
async function walkTree(env, rootDir, rules, context, visit) {
|
|
237
|
+
const stack = [
|
|
238
|
+
{ dir: rootDir, relative: "", depth: 0 },
|
|
239
|
+
];
|
|
240
|
+
while (stack.length > 0) {
|
|
241
|
+
const frame = stack.pop();
|
|
242
|
+
const listed = await env.listDir(frame.dir, context);
|
|
243
|
+
if (!listed.ok) {
|
|
244
|
+
if (listed.error.code === "not_found")
|
|
245
|
+
continue;
|
|
246
|
+
throw new Error(`Failed to list ${frame.dir}: ${listed.error.message}`, { cause: listed.error });
|
|
247
|
+
}
|
|
248
|
+
const entries = [...listed.value].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
249
|
+
const subdirs = [];
|
|
250
|
+
for (const info of entries) {
|
|
251
|
+
if (info.name === GIT_DIRECTORY)
|
|
252
|
+
continue;
|
|
253
|
+
const relative = frame.relative === "" ? info.name : `${frame.relative}/${info.name}`;
|
|
254
|
+
const isDirectory = info.kind === "directory";
|
|
255
|
+
if (isIgnoredPath(rules, relative, isDirectory))
|
|
256
|
+
continue;
|
|
257
|
+
if (!(await visit({ relative, info })))
|
|
258
|
+
return;
|
|
259
|
+
if (isDirectory && frame.depth < MAX_WALK_DEPTH) {
|
|
260
|
+
subdirs.push({ dir: info.path, relative, depth: frame.depth + 1 });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Stack pop-order is reversed; pushing reversed restores sorted order.
|
|
264
|
+
for (const subdir of subdirs.reverse())
|
|
265
|
+
stack.push(subdir);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Resolve the shared search preamble: env check, `path` resolution (default
|
|
270
|
+
* `.`), directory check, and the `.gitignore` load. Returns an error text
|
|
271
|
+
* on failure — a search over a bad path is a recoverable tool error.
|
|
272
|
+
*/
|
|
273
|
+
async function resolveSearchRoot(env, path, context) {
|
|
274
|
+
if (env === undefined) {
|
|
275
|
+
return { error: "no workspace environment is configured" };
|
|
276
|
+
}
|
|
277
|
+
const resolved = await env.absolutePath(path ?? ".", context);
|
|
278
|
+
if (!resolved.ok) {
|
|
279
|
+
return {
|
|
280
|
+
error: `could not resolve ${path ?? "."}: ${resolved.error.message}`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const info = await env.fileInfo(resolved.value, context);
|
|
284
|
+
if (!info.ok) {
|
|
285
|
+
return { error: `${path ?? "."}: ${info.error.message}` };
|
|
286
|
+
}
|
|
287
|
+
if (info.value.kind !== "directory") {
|
|
288
|
+
return { error: `${path ?? "."} is not a directory` };
|
|
289
|
+
}
|
|
290
|
+
const rules = await loadGitignore(env, resolved.value, context);
|
|
291
|
+
const prefix = stripPathPrefix(resolved.value, env.cwd) ?? "";
|
|
292
|
+
return { dir: resolved.value, env, rules, prefix };
|
|
293
|
+
}
|
|
294
|
+
/** One output line for a walked entry, prefixed for the conversation cwd. */
|
|
295
|
+
function outputPath(root, relative) {
|
|
296
|
+
return root.prefix === "" ? relative : `${root.prefix}/${relative}`;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Whether a find pattern matches one result path (the cwd-relative path the
|
|
300
|
+
* model sees): a pattern containing `/` matches the whole path; without one
|
|
301
|
+
* it matches the name at any depth — the same convenience split as
|
|
302
|
+
* unanchored gitignore rules.
|
|
303
|
+
*/
|
|
304
|
+
function globMatches(pattern, path) {
|
|
305
|
+
const matcher = new RegExp(`^${globToRegExpSource(pattern)}$`);
|
|
306
|
+
return pattern.includes("/")
|
|
307
|
+
? matcher.test(path)
|
|
308
|
+
: componentSuffixes(path).some((suffix) => matcher.test(suffix));
|
|
309
|
+
}
|
|
310
|
+
/** The `find` tool: glob-match paths under a root, gitignore-filtered. */
|
|
311
|
+
export const findTool = defineTool({
|
|
312
|
+
name: "find",
|
|
313
|
+
description: "Find files and directories by glob pattern (*, ?, **). Without a / the pattern matches the name at any depth; with one it matches the path from the search root. Results honor .gitignore.",
|
|
314
|
+
parameters: Type.Object({
|
|
315
|
+
pattern: Type.String({
|
|
316
|
+
description: "Glob pattern, e.g. *.ts or src/**/*.test.ts",
|
|
317
|
+
}),
|
|
318
|
+
path: Type.Optional(Type.String({
|
|
319
|
+
description: "Directory to search in (relative or absolute); defaults to the working directory",
|
|
320
|
+
})),
|
|
321
|
+
}),
|
|
322
|
+
async execute(args, api, context) {
|
|
323
|
+
const root = await resolveSearchRoot(api.env, args.path, context);
|
|
324
|
+
if ("error" in root)
|
|
325
|
+
return searchError(`find: ${root.error}.`);
|
|
326
|
+
const matches = [];
|
|
327
|
+
let capped = false;
|
|
328
|
+
await walkTree(root.env, root.dir, root.rules, context, (entry) => {
|
|
329
|
+
if (!globMatches(args.pattern, outputPath(root, entry.relative))) {
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
if (matches.length >= MAX_RESULTS) {
|
|
333
|
+
capped = true;
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
matches.push(outputPath(root, entry.relative));
|
|
337
|
+
return true;
|
|
338
|
+
});
|
|
339
|
+
const text = matches.length === 0 ? "No matches found." : matches.join("\n");
|
|
340
|
+
return capped
|
|
341
|
+
? {
|
|
342
|
+
output: [
|
|
343
|
+
{
|
|
344
|
+
type: "text",
|
|
345
|
+
text: `${text}\n…(capped at ${MAX_RESULTS} results)`,
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
diagnostics: [
|
|
349
|
+
{
|
|
350
|
+
severity: "info",
|
|
351
|
+
code: "truncated",
|
|
352
|
+
message: `find: capped at ${MAX_RESULTS} results`,
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
}
|
|
356
|
+
: { output: [{ type: "text", text }] };
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
/** Strict UTF-8 decode: `undefined` marks the binary files grep skips. */
|
|
360
|
+
const strictUtf8 = new TextDecoder("utf-8", { fatal: true });
|
|
361
|
+
/**
|
|
362
|
+
* The largest file grep will read whole into memory. Bigger files are
|
|
363
|
+
* skipped like binary files (a whole-file regex read on a huge file is a
|
|
364
|
+
* memory cliff; the chunked-read refactor is a follow-up).
|
|
365
|
+
*/
|
|
366
|
+
export const GREP_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
367
|
+
/**
|
|
368
|
+
* The `grep` tool: regex over file contents, gitignore-filtered. Files over
|
|
369
|
+
* `GREP_MAX_FILE_BYTES` are skipped with a note, same as binary files.
|
|
370
|
+
*/
|
|
371
|
+
export const grepTool = defineTool({
|
|
372
|
+
name: "grep",
|
|
373
|
+
description: "Search file contents with a regular expression (JavaScript syntax). Returns matching lines as path:line:text. Skips binary (non-UTF-8) files and files over 10 MB, and honors .gitignore.",
|
|
374
|
+
parameters: Type.Object({
|
|
375
|
+
pattern: Type.String({
|
|
376
|
+
description: "Regular expression to search for, e.g. TODO|FIXME",
|
|
377
|
+
}),
|
|
378
|
+
path: Type.Optional(Type.String({
|
|
379
|
+
description: "Directory to search in (relative or absolute); defaults to the working directory",
|
|
380
|
+
})),
|
|
381
|
+
}),
|
|
382
|
+
async execute(args, api, context) {
|
|
383
|
+
let regex;
|
|
384
|
+
try {
|
|
385
|
+
regex = new RegExp(args.pattern);
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
return searchError(`grep: invalid pattern ${JSON.stringify(args.pattern)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
389
|
+
}
|
|
390
|
+
const root = await resolveSearchRoot(api.env, args.path, context);
|
|
391
|
+
if ("error" in root)
|
|
392
|
+
return searchError(`grep: ${root.error}.`);
|
|
393
|
+
const matches = [];
|
|
394
|
+
let binarySkipped = 0;
|
|
395
|
+
let oversizedSkipped = 0;
|
|
396
|
+
let capped = false;
|
|
397
|
+
await walkTree(root.env, root.dir, root.rules, context, async (entry) => {
|
|
398
|
+
if (entry.info.kind === "directory")
|
|
399
|
+
return true;
|
|
400
|
+
// Size cap before the whole-file read: over the cap, skip with a
|
|
401
|
+
// note (same reporting slot as binary skips).
|
|
402
|
+
if (entry.info.size > GREP_MAX_FILE_BYTES) {
|
|
403
|
+
oversizedSkipped += 1;
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
// Binary read + strict decode: non-UTF-8 files are skipped, not
|
|
407
|
+
// searched as mojibake.
|
|
408
|
+
const read = await root.env.readBinaryFile(entry.info.path, context);
|
|
409
|
+
if (!read.ok) {
|
|
410
|
+
if (read.error.code === "not_found")
|
|
411
|
+
return true; // raced away
|
|
412
|
+
throw new Error(`Failed to read ${entry.relative}: ${read.error.message}`, { cause: read.error });
|
|
413
|
+
}
|
|
414
|
+
let text;
|
|
415
|
+
try {
|
|
416
|
+
text = strictUtf8.decode(read.value);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
binarySkipped += 1;
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
const lines = text.split(/\r?\n/);
|
|
423
|
+
for (let index = 0; index < lines.length; index++) {
|
|
424
|
+
const line = lines[index];
|
|
425
|
+
if (!regex.test(line))
|
|
426
|
+
continue;
|
|
427
|
+
if (matches.length >= MAX_RESULTS) {
|
|
428
|
+
capped = true;
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
const shown = line.length > GREP_LINE_LIMIT_CHARS
|
|
432
|
+
? `${line.slice(0, GREP_LINE_LIMIT_CHARS)}…`
|
|
433
|
+
: line;
|
|
434
|
+
matches.push(`${outputPath(root, entry.relative)}:${index + 1}:${shown}`);
|
|
435
|
+
}
|
|
436
|
+
return true;
|
|
437
|
+
});
|
|
438
|
+
const summary = matches.length === 0 ? "No matches found." : matches.join("\n");
|
|
439
|
+
const note = `${binarySkipped > 0 ? ` (${binarySkipped} binary file(s) skipped)` : ""}` +
|
|
440
|
+
`${oversizedSkipped > 0 ? ` (${oversizedSkipped} file(s) over ${GREP_MAX_FILE_BYTES / (1024 * 1024)} MB skipped)` : ""}` +
|
|
441
|
+
`${capped ? ` (capped at ${MAX_RESULTS} matches)` : ""}`;
|
|
442
|
+
return {
|
|
443
|
+
output: [{ type: "text", text: note === "" ? summary : `${summary}${note}` }],
|
|
444
|
+
...(capped
|
|
445
|
+
? {
|
|
446
|
+
diagnostics: [
|
|
447
|
+
{
|
|
448
|
+
severity: "info",
|
|
449
|
+
code: "truncated",
|
|
450
|
+
message: `grep: capped at ${MAX_RESULTS} matches`,
|
|
451
|
+
},
|
|
452
|
+
],
|
|
453
|
+
}
|
|
454
|
+
: {}),
|
|
455
|
+
};
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
/** The `ls` tool: one directory's entries, gitignore-filtered. */
|
|
459
|
+
export const lsTool = defineTool({
|
|
460
|
+
name: "ls",
|
|
461
|
+
description: "List one directory's entries (kind and name per line). Results honor .gitignore.",
|
|
462
|
+
parameters: Type.Object({
|
|
463
|
+
path: Type.Optional(Type.String({
|
|
464
|
+
description: "Directory path to list (relative or absolute); defaults to the working directory",
|
|
465
|
+
})),
|
|
466
|
+
}),
|
|
467
|
+
async execute(args, api, context) {
|
|
468
|
+
if (api.env === undefined) {
|
|
469
|
+
return searchError("ls: no workspace environment is configured.");
|
|
470
|
+
}
|
|
471
|
+
const env = api.env;
|
|
472
|
+
const resolved = await env.absolutePath(args.path ?? ".", context);
|
|
473
|
+
if (!resolved.ok) {
|
|
474
|
+
return searchError(`ls: could not resolve ${args.path ?? "."}: ${resolved.error.message}.`);
|
|
475
|
+
}
|
|
476
|
+
const listed = await env.listDir(resolved.value, context);
|
|
477
|
+
if (!listed.ok) {
|
|
478
|
+
return searchError(`ls: ${args.path ?? "."}: ${listed.error.message}.`);
|
|
479
|
+
}
|
|
480
|
+
const rules = await loadGitignore(env, resolved.value, context);
|
|
481
|
+
const lines = [...listed.value]
|
|
482
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
483
|
+
.filter((info) => info.name !== GIT_DIRECTORY &&
|
|
484
|
+
!isIgnoredPath(rules, info.name, info.kind === "directory"))
|
|
485
|
+
.map((info) => `${info.kind}\t${info.name}`);
|
|
486
|
+
return {
|
|
487
|
+
output: [
|
|
488
|
+
{
|
|
489
|
+
type: "text",
|
|
490
|
+
text: lines.length === 0 ? "(empty)" : lines.join("\n"),
|
|
491
|
+
},
|
|
492
|
+
],
|
|
493
|
+
};
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
/** `find`, `grep`, `ls` — core-installed for every conversation. */
|
|
497
|
+
export const searchToolsExtension = defineExtension({
|
|
498
|
+
name: "sigrun.search-tools",
|
|
499
|
+
tools: [findTool, grepTool, lsTool],
|
|
500
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static workspace resolution (spec §4.5).
|
|
3
|
+
*
|
|
4
|
+
* pi-durable calls `HarnessOptions.env` per tool call to build the
|
|
5
|
+
* conversation's `ExecutionEnv`. sigrun routes that through
|
|
6
|
+
* `workspaceService.forConversation(conversation, prefs)`, a thin wrapper
|
|
7
|
+
* whose signature survives a swap from static config to a dynamic
|
|
8
|
+
* resolver: v1 answers from the static local workspace path carried in
|
|
9
|
+
* `prefs`; a future resolver replaces the *implementation*
|
|
10
|
+
* and nothing downstream changes.
|
|
11
|
+
*
|
|
12
|
+
* v1 is deliberately static: every conversation resolves to the same local
|
|
13
|
+
* workspace. Per-conversation `agent.cwd` (the committed `pi.agent`
|
|
14
|
+
* document's directory within the workspace) is applied by the `toHarnessEnv`
|
|
15
|
+
* adapter below: pi-durable passes it through `EnvTarget.cwd` and applies
|
|
16
|
+
* nothing itself, so the adapter must root the env at it.
|
|
17
|
+
*/
|
|
18
|
+
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
|
|
19
|
+
import { isAbsolute, resolve } from "node:path";
|
|
20
|
+
/**
|
|
21
|
+
* Build the static workspace service.
|
|
22
|
+
*
|
|
23
|
+
* v1 resolves every conversation from `prefs.defaultWorkspace` — the spec's
|
|
24
|
+
* static configuration — so the service carries no state of its own. The
|
|
25
|
+
* returned env is an upstream `NodeExecutionEnv` (pi-durable's local Node
|
|
26
|
+
* `ExecutionEnv`): its `FileSystem.id` is the stable `"node:local"` (every
|
|
27
|
+
* local environment sees the same files), and its `Shell` runs commands
|
|
28
|
+
* rooted at the workspace `cwd`. A fresh instance per call matches how
|
|
29
|
+
* upstream builds envs; equal ids see the same files whatever their `cwd`.
|
|
30
|
+
*/
|
|
31
|
+
export function createWorkspaceService() {
|
|
32
|
+
return {
|
|
33
|
+
async forConversation(_conversation, prefs) {
|
|
34
|
+
const workspace = prefs.defaultWorkspace;
|
|
35
|
+
if (workspace.type !== "local") {
|
|
36
|
+
// Fail loud: v1 knows only the static local workspace; any other
|
|
37
|
+
// type reaching here means the preferences are not v1's.
|
|
38
|
+
throw new Error(`Unsupported workspace type: ${JSON.stringify(workspace.type)}`);
|
|
39
|
+
}
|
|
40
|
+
return new NodeExecutionEnv({ cwd: workspace.path });
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The default workspace preferences for a configured local workspace path:
|
|
46
|
+
* exactly the spec §4.5 static shape.
|
|
47
|
+
*/
|
|
48
|
+
export function defaultWorkspacePrefs(workspacePath) {
|
|
49
|
+
return { defaultWorkspace: { type: "local", path: workspacePath } };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Adapt the service to the `HarnessOptions.env` builder pi-durable invokes
|
|
53
|
+
* per tool call. pi-durable passes the conversation's committed `agent.cwd`
|
|
54
|
+
* through `EnvTarget.cwd` and applies nothing itself (verified:
|
|
55
|
+
* dist/harness/harness.js:137-138) — this adapter roots the resolved env
|
|
56
|
+
* there, so tools see the conversation's directory. `agent.cwd` is a
|
|
57
|
+
* directory within the environment's file system: an absolute path is
|
|
58
|
+
* assigned as-is, a relative one is joined to the env's base (the workspace
|
|
59
|
+
* root the service resolved), because upstream's Node env would otherwise
|
|
60
|
+
* anchor a bare relative `cwd` at the process directory. Without a cwd, the
|
|
61
|
+
* env stays at the workspace root.
|
|
62
|
+
*/
|
|
63
|
+
export function toHarnessEnv(service, prefs) {
|
|
64
|
+
return async (target, _context) => {
|
|
65
|
+
const env = await service.forConversation({ id: target.conversationId }, prefs);
|
|
66
|
+
if (target.cwd != null) {
|
|
67
|
+
env.cwd = isAbsolute(target.cwd)
|
|
68
|
+
? target.cwd
|
|
69
|
+
: resolve(env.cwd, target.cwd);
|
|
70
|
+
}
|
|
71
|
+
return env;
|
|
72
|
+
};
|
|
73
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deploy entry (Task 13) — the composition the Deployment's container
|
|
3
|
+
* command runs. The composition itself lives in `src/serve.ts` (extracted
|
|
4
|
+
* in Task 5 so the CLI's `run` command can reuse it); this entry is the
|
|
5
|
+
* serve-only invocation of it: boot via `serve(process.env)`, attach the
|
|
6
|
+
* SIGTERM/SIGINT graceful shutdown (close the server first — drain
|
|
7
|
+
* in-flight requests — then the event-bus shutdown sequence, then the
|
|
8
|
+
* harness; exit 0 on a clean close, 1 on any failure), and fail loud on
|
|
9
|
+
* any boot error (exit 1 — a wedged deploy must crash, never serve a
|
|
10
|
+
* broken process). The deploy-entry test asserts exactly this.
|
|
11
|
+
*
|
|
12
|
+
* `npm start` runs this via tsx — the DEV shape (the repo's internal
|
|
13
|
+
* imports use `.js` specifiers resolving to `.ts` sources, which stock
|
|
14
|
+
* node cannot resolve). Deployed entries run the BUILT form instead
|
|
15
|
+
* (plain `node dist/index.js`; the Dockerfile CMD, the chart's container
|
|
16
|
+
* command, and docker-compose all do — see CHANGELOG's packaging entry).
|
|
17
|
+
*/
|
|
18
|
+
import { serve } from "./serve.js";
|
|
19
|
+
async function main() {
|
|
20
|
+
const booted = await serve(process.env);
|
|
21
|
+
// A second signal (Ctrl-C twice, or SIGTERM+SIGINT) must not re-enter
|
|
22
|
+
// shutdown (see BootedServer.shutdownOnce); the guard makes the first
|
|
23
|
+
// signal win.
|
|
24
|
+
const stop = () => {
|
|
25
|
+
void booted
|
|
26
|
+
.shutdownOnce()
|
|
27
|
+
.then(() => process.exit(0))
|
|
28
|
+
.catch((error) => {
|
|
29
|
+
console.error("sigrun shutdown error:", error);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
process.on("SIGTERM", stop);
|
|
34
|
+
process.on("SIGINT", stop);
|
|
35
|
+
}
|
|
36
|
+
main().catch((error) => {
|
|
37
|
+
console.error("sigrun boot failed:", error);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
});
|