@sigrun/sigrun 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +90 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. 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
+ });