@sema-agent/core 5.41.0 → 5.42.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.
@@ -4669,12 +4669,15 @@ export interface EngineNotice {
4669
4669
  * value IS in force the prepare refuses with the same code as `TaskResult.errorCode` (one
4670
4670
  * fact, one code, two loudness dialects); `detail: { raw }`.
4671
4671
  *
4672
- * - `"task.user_steer_undrained"` (#259) — user steers/follow-ups whose receipts said "queued"
4673
- * were still in the queues at agent_end: the run ended before any turn could drain them. They
4674
- * are NOT redelivered (a steer aimed at a finished run must not fire at the next one — unlike
4675
- * ENGINE notes, which pend per session); the notice is the loud half of the #257 contract's
4676
- * "accepted = enqueued, not consumed" sentence; `detail: { steer, followUp, taskId? }`.
4677
- * Per-run, at most once (the terminal sweep is a single site).
4672
+ * - `"task.user_steer_undrained"` / `"task.user_followup_undrained"` (#259) — user steers /
4673
+ * follow-ups whose receipts said "queued" were still in their queue at agent_end: the run
4674
+ * ended before any turn could drain them. One notice PER family (a consumer routing on `code`
4675
+ * alone must not mistake a stranded follow-up for a stranded steer same split as the settled
4676
+ * frame's two count keys). They are NOT redelivered (a steer aimed at a finished run must not
4677
+ * fire at the next one unlike ENGINE notes, which pend per session); the loud half of the
4678
+ * #257 contract's "accepted = enqueued, not consumed" sentence;
4679
+ * `detail: { steer, taskId? }` / `{ followUp, taskId? }`. Per-run, at most once per family
4680
+ * (the terminal sweep is a single site).
4678
4681
  *
4679
4682
  * Deliberately NOT a notice family: brain retry/reconnect liveness (a rate limit, a 5xx, a
4680
4683
  * transient network failure being retried). Those are per-attempt liveness frames with their own
@@ -4709,6 +4712,16 @@ export declare function __resetMalformedNoticeSeatAnnouncement(): void;
4709
4712
  * · an ABSENT seat prints the historic `console.warn` line verbatim (byte-compat loudness).
4710
4713
  */
4711
4714
  export declare function deliverEngineNotice(onNotice: ((notice: EngineNotice) => void) | undefined, notice: EngineNotice): void;
4715
+ /**
4716
+ * #259 loud half, one notice PER FAMILY (`task.user_steer_undrained` / `task.user_followup_undrained`):
4717
+ * a consumer routing on `code` alone must never mistake a stranded follow-up for a stranded steer, so
4718
+ * the code carries exactly the semantics its name claims — the same two-key split the settled frame
4719
+ * uses. Pure (the terminal sweep race window is not constructible deterministically; this seam is).
4720
+ */
4721
+ export declare function undrainedUserInputNotices(counts: {
4722
+ steer: number;
4723
+ followUp: number;
4724
+ }, taskId?: string): EngineNotice[];
4712
4725
  /** Runtime dependencies shared across tasks. */
4713
4726
  export interface RunnerDeps {
4714
4727
  brain: Brain;
@@ -49,3 +49,23 @@ export function deliverEngineNotice(onNotice, notice) {
49
49
  }
50
50
  console.warn(notice.message);
51
51
  }
52
+ export function undrainedUserInputNotices(counts, taskId) {
53
+ const tid = taskId !== undefined ? { taskId } : {};
54
+ const tail = `accepted as "queued" were never consumed — the run ended first. They are NOT redelivered; re-send against a live run if still wanted.`;
55
+ const out = [];
56
+ if (counts.steer > 0) {
57
+ out.push({
58
+ code: "task.user_steer_undrained",
59
+ message: `${counts.steer} user steer(s) ${tail}`,
60
+ detail: { steer: counts.steer, ...tid },
61
+ });
62
+ }
63
+ if (counts.followUp > 0) {
64
+ out.push({
65
+ code: "task.user_followup_undrained",
66
+ message: `${counts.followUp} user follow-up(s) ${tail}`,
67
+ detail: { followUp: counts.followUp, ...tid },
68
+ });
69
+ }
70
+ return out;
71
+ }
package/dist/index.d.ts CHANGED
@@ -93,7 +93,7 @@ export { READ_FACE_DEFAULT_DENY_ENTRIES, READ_FACE_BUILTIN_DENY_TABLE, READ_DENY
93
93
  export { deploymentReadFaceClampNotice, resolveReadFace, type ReadFace, type ReadFaceInputs } from "./tools/fs/index.js";
94
94
  export { WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable, compileWriteProtection, type WriteProtectedEntry, type WriteProtectedRow, type WriteProtectedKind, type WriteProtectedHit, type WriteProtectionMatcher, } from "./core/write-protect.js";
95
95
  export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, toolResultContentSegment, MAX_MINTED_TOOL_RESULT_REF_CHARS, type ToolResultProvenance, assertToolResultProvenanceMatch, normalizeToolResultProvenance, toolResultProvenanceOf, ToolResultRefConflictError, TOOL_RESULT_REF_CONFLICT_CODE, type ToolResultStore, type ToolResultSlice, type ToolResultDeletionReport, } from "./core/tool-result-store.js";
96
- export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, LEGACY_PENDING_STEER_INPUT_ID, type ActorAssertion, type PendingSteerEntry, type PendingSteerInput, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, type RiskDescriptor, type ProbeCause, type ProbeCauseOperands, type CheckpointStore, type CheckpointSummary, type Checkpoint, type CheckpointToken, type CheckpointGate, type CheckpointState, type SerializedCheckpointState, type CheckpointFaultMode, type PendingAction, type ResumeOutcome, type ResolvedOutcome, type ReopenReason, type ResolveExpectation, type SafetyAxis, type RealApprovalGateBit, type ResourceLedger, type ResourceLimitReason, type PlatformLimitReason, } from "./core/checkpoint-store.js";
96
+ export { InMemoryCheckpointStore, CheckpointError, mintCheckpointId, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, LEGACY_PENDING_STEER_INPUT_ID, type ActorAssertion, type PendingSteerEntry, type PendingSteerInput, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, type RiskDescriptor, type ProbeCause, type ProbeCauseOperands, type CheckpointStore, type CheckpointSummary, type Checkpoint, type CheckpointToken, type CheckpointGate, type CheckpointState, type SerializedCheckpointState, type CheckpointFaultMode, type PendingAction, type ResumeOutcome, type ResolvedOutcome, type ReopenReason, type ResolveExpectation, type SafetyAxis, type RealApprovalGateBit, type ResourceLedger, type ResourceLimitReason, type PlatformLimitReason, } from "./core/checkpoint-store.js";
97
97
  export { InMemoryUsageWindowStore, GLOBAL_USAGE_KEY, EMPTY_USAGE_WINDOW_RECORD, chargeUsageRecord, readUsageRecord, usageRetryAfterMs, resolveUsageWindows, type UsageWindow, type UsageWindowStore, type UsageWindowReading, type UsageWindowRecord, type UsageSlot, type UsageBucketRow, } from "./core/usage-window-store.js";
98
98
  export { FileUsageWindowStore } from "./stores/file/usage-window-store.js";
99
99
  export { ENV_LIFETIME_SUSPEND_MARGIN_MS, USAGE_WINDOW_REAP_MARGIN_MS } from "./core/runner/prepare-task.js";
package/dist/index.js CHANGED
@@ -73,7 +73,7 @@ export { READ_FACE_DEFAULT_DENY_ENTRIES, READ_FACE_BUILTIN_DENY_TABLE, READ_DENY
73
73
  export { deploymentReadFaceClampNotice, resolveReadFace } from "./tools/fs/index.js";
74
74
  export { WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable, compileWriteProtection, } from "./core/write-protect.js";
75
75
  export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, toolResultContentSegment, MAX_MINTED_TOOL_RESULT_REF_CHARS, assertToolResultProvenanceMatch, normalizeToolResultProvenance, toolResultProvenanceOf, ToolResultRefConflictError, TOOL_RESULT_REF_CONFLICT_CODE, } from "./core/tool-result-store.js";
76
- export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, LEGACY_PENDING_STEER_INPUT_ID, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, } from "./core/checkpoint-store.js";
76
+ export { InMemoryCheckpointStore, CheckpointError, mintCheckpointId, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, LEGACY_PENDING_STEER_INPUT_ID, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, } from "./core/checkpoint-store.js";
77
77
  export { InMemoryUsageWindowStore, GLOBAL_USAGE_KEY, EMPTY_USAGE_WINDOW_RECORD, chargeUsageRecord, readUsageRecord, usageRetryAfterMs, resolveUsageWindows, } from "./core/usage-window-store.js";
78
78
  export { FileUsageWindowStore } from "./stores/file/usage-window-store.js";
79
79
  export { ENV_LIFETIME_SUSPEND_MARGIN_MS, USAGE_WINDOW_REAP_MARGIN_MS } from "./core/runner/prepare-task.js";
@@ -2,6 +2,14 @@ import type { AgentTool, ExecutionEnv } from "../../internal/harness-types.js";
2
2
  import type { ToolEffect } from "../../core/types.js";
3
3
  import type { ReadDenyMatcher } from "./read-deny.js";
4
4
  import type { ReadFace } from "./read-face.js";
5
+ /**
6
+ * The Grep card's details assembly, PURE over the engine text ({mode, offset} from the request) —
7
+ * exported so the text→structured mapping is pinnable with synthetic texts (the byte-truncation and
8
+ * fenced-partial shapes are impractical to construct through a live tool call). Follow-up #313 will
9
+ * replace the text parsing with structured rows from runGrep; until then this seam is the honesty
10
+ * boundary.
11
+ */
12
+ export declare function grepDetailFields(text: string, mode: "files_with_matches" | "content" | "count", offset?: number): Record<string, unknown>;
5
13
  export declare function createGrepTool(env: ExecutionEnv, rootCanonical: string, additionalRoots?: readonly string[], readDeny?: ReadDenyMatcher, readFace?: ReadFace): AgentTool;
6
14
  export declare function createGlobTool(env: ExecutionEnv, rootCanonical: string, additionalRoots?: readonly string[], readDeny?: ReadDenyMatcher, readFace?: ReadFace): AgentTool;
7
15
  /** Static side-effect class of every hand tool, by name (design/44 §3). Used by prepare-task to (a) feed
@@ -2,6 +2,72 @@ import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
3
  import { resolveKey, violationText, violationDetails } from "./safety.js";
4
4
  import { runGrepDetailed, runGlobDetailed, splitAbsoluteGlobPattern, invalidGlobTokens } from "./search.js";
5
+ export function grepDetailFields(text, mode, offset) {
6
+ const rows = text.startsWith("No matches.")
7
+ ? []
8
+ : text
9
+ .split("\n")
10
+ .filter((l) => l.length > 0 &&
11
+ l !== "No matches." &&
12
+ !l.startsWith("…[") &&
13
+ !l.startsWith("[offset") &&
14
+ !l.startsWith("[note") &&
15
+ !l.startsWith("[!]") &&
16
+ !l.startsWith("<<<UNTRUSTED ") &&
17
+ !l.startsWith("<<<END UNTRUSTED "));
18
+ const contentPathOf = (l) => {
19
+ const m = /^(.*?):(\d+):/.exec(l);
20
+ return m ? m[1] : l;
21
+ };
22
+ const byteTruncated = text.includes("…[output truncated at ");
23
+ const truncatedFlag = byteTruncated ? { truncated: true } : {};
24
+ const capMarker = /^…\[capped at (\d+) of (\d+)\]$/m.exec(text);
25
+ const cappedAt = capMarker ? Number(capMarker[1]) : undefined;
26
+ const capTotal = capMarker ? Number(capMarker[2]) : undefined;
27
+ const appliedOffset = typeof offset === "number" && offset > 0 ? { appliedOffset: offset } : {};
28
+ const appliedLimit = cappedAt !== undefined ? { appliedLimit: cappedAt } : {};
29
+ let detailFields;
30
+ if (mode === "files_with_matches") {
31
+ detailFields = { filenames: rows, numFiles: rows.length, totalFiles: capTotal ?? rows.length, ...appliedLimit, ...appliedOffset };
32
+ }
33
+ else if (mode === "count") {
34
+ const filenames = [...new Set(rows.map((l) => (l.includes(":") ? l.slice(0, l.lastIndexOf(":")) : l)))];
35
+ let numMatches = 0;
36
+ let malformed = false;
37
+ for (const l of rows) {
38
+ const m = /:(\d+)$/.exec(l);
39
+ if (m)
40
+ numMatches += Number(m[1]);
41
+ else
42
+ malformed = true;
43
+ }
44
+ detailFields = {
45
+ filenames,
46
+ numFiles: filenames.length,
47
+ content: rows.join("\n"),
48
+ ...(malformed || byteTruncated ? {} : { numMatches }),
49
+ ...truncatedFlag,
50
+ ...appliedLimit,
51
+ ...appliedOffset,
52
+ };
53
+ }
54
+ else {
55
+ const filenames = [...new Set(rows.map(contentPathOf))];
56
+ const joined = rows.join("\n");
57
+ const GREP_CONTENT_PREVIEW_CHARS = 16_000;
58
+ detailFields = {
59
+ filenames,
60
+ numFiles: filenames.length,
61
+ content: joined.length > GREP_CONTENT_PREVIEW_CHARS ? `${joined.slice(0, GREP_CONTENT_PREVIEW_CHARS)}\n…[truncated — full text in the tool output]` : joined,
62
+ numLines: rows.length,
63
+ ...(byteTruncated ? {} : { totalLines: capTotal ?? rows.length }),
64
+ ...truncatedFlag,
65
+ ...appliedLimit,
66
+ ...appliedOffset,
67
+ };
68
+ }
69
+ return detailFields;
70
+ }
5
71
  export function createGrepTool(env, rootCanonical, additionalRoots, readDeny, readFace) {
6
72
  return defineTool({
7
73
  name: "Grep",
@@ -95,64 +161,7 @@ export function createGrepTool(env, rootCanonical, additionalRoots, readDeny, re
95
161
  if (text.startsWith("Error (grep)") || text.startsWith("Error (Grep)"))
96
162
  return errorResult(text);
97
163
  const mode = a.output_mode ?? "files_with_matches";
98
- const rows = text.startsWith("No matches.")
99
- ? []
100
- : text
101
- .split("\n")
102
- .filter((l) => l.length > 0 &&
103
- !l.startsWith("…[") &&
104
- !l.startsWith("[offset") &&
105
- !l.startsWith("[note") &&
106
- !l.startsWith("[!]") &&
107
- !l.startsWith("<<<UNTRUSTED ") &&
108
- !l.startsWith("<<<END UNTRUSTED "));
109
- const contentPathOf = (l) => {
110
- const m = /^(.*?):(\d+):/.exec(l);
111
- return m ? m[1] : l;
112
- };
113
- const capMarker = /^…\[capped at (\d+) of (\d+)\]$/m.exec(text);
114
- const cappedAt = capMarker ? Number(capMarker[1]) : undefined;
115
- const capTotal = capMarker ? Number(capMarker[2]) : undefined;
116
- const appliedOffset = typeof a.offset === "number" && a.offset > 0 ? { appliedOffset: a.offset } : {};
117
- const appliedLimit = cappedAt !== undefined ? { appliedLimit: cappedAt } : {};
118
- let detailFields;
119
- if (mode === "files_with_matches") {
120
- detailFields = { filenames: rows, numFiles: rows.length, totalFiles: capTotal ?? rows.length, ...appliedLimit, ...appliedOffset };
121
- }
122
- else if (mode === "count") {
123
- const filenames = [...new Set(rows.map((l) => (l.includes(":") ? l.slice(0, l.lastIndexOf(":")) : l)))];
124
- let numMatches = 0;
125
- let malformed = false;
126
- for (const l of rows) {
127
- const m = /:(\d+)$/.exec(l);
128
- if (m)
129
- numMatches += Number(m[1]);
130
- else
131
- malformed = true;
132
- }
133
- detailFields = {
134
- filenames,
135
- numFiles: filenames.length,
136
- content: rows.join("\n"),
137
- ...(malformed ? {} : { numMatches }),
138
- ...appliedLimit,
139
- ...appliedOffset,
140
- };
141
- }
142
- else {
143
- const filenames = [...new Set(rows.map(contentPathOf))];
144
- const joined = rows.join("\n");
145
- const GREP_CONTENT_PREVIEW_CHARS = 16_000;
146
- detailFields = {
147
- filenames,
148
- numFiles: filenames.length,
149
- content: joined.length > GREP_CONTENT_PREVIEW_CHARS ? `${joined.slice(0, GREP_CONTENT_PREVIEW_CHARS)}\n…[truncated — full text in the tool output]` : joined,
150
- numLines: rows.length,
151
- totalLines: capTotal ?? rows.length,
152
- ...appliedLimit,
153
- ...appliedOffset,
154
- };
155
- }
164
+ const detailFields = grepDetailFields(text, mode, a.offset);
156
165
  return {
157
166
  content: text,
158
167
  details: { type: "grep", mode, ...detailFields, ...(grepRun.degraded ?? {}), ...(grepRun.withheld !== undefined ? { withheld: grepRun.withheld } : {}) },
@@ -1,4 +1,9 @@
1
1
  import type { ExecutionEnv } from "../../internal/harness-types.js";
2
+ /** backlog #310 — the JS scanner's own output ceiling, in bytes of emitted content rows. Pinned to the
3
+ * ripgrep leg's figure ({@link MAX_EXEC_OUTPUT_BYTES}, the exec pipe's rolling-tail bound) so the two
4
+ * legs cannot disagree about how much one search may return; see the collection site in {@link jsGrep}
5
+ * for why this leg bounds the HEAD where the pipe bounds the tail. */
6
+ export declare const JS_GREP_OUTPUT_MAX_BYTES: number;
2
7
  /** Directories never worth crawling (design/64 §10.3) — dependency/build/cache trees, PLUS the VCS
3
8
  * metadata directories CC names explicitly (RB-200 F1, 220 @368402: `Ok_ = [".git",".svn",".hg",
4
9
  * ".bzr",".jj",".sl"]` — git/svn/mercurial/bazaar/jujutsu/sapling). `.bzr`/`.jj`/`.sl` are additive here
@@ -153,6 +158,10 @@ export interface JsGrepGuards {
153
158
  budgetMs?: number;
154
159
  /** Longest line/content (chars) a gray-zone pattern may be matched against. */
155
160
  longLineLimit?: number;
161
+ /** backlog #310 — bytes of content rows this scan may accumulate before it stops collecting and
162
+ * says so (default {@link JS_GREP_OUTPUT_MAX_BYTES}). A seam for the same reason the two above
163
+ * are: the boundary is pinnable at a testable size instead of only at an 8MB harness. */
164
+ outputMaxBytes?: number;
156
165
  }
157
166
  /** design/199 件B — what a traversal withheld under the sensitive-path read deny list, structured
158
167
  * (the prose note is the model-facing twin). Three shapes (§3.1): `pruned_count` = the JS walker
@@ -208,6 +217,8 @@ export interface GrepRunResult {
208
217
  * withheld / no deny judge in play. */
209
218
  withheld?: ReadDenyWithheld;
210
219
  }
220
+ /** The output modes the rg leg and {@link jsGrep} share. */
221
+ type GrepOutputMode = "content" | "files_with_matches" | "count";
211
222
  /**
212
223
  * backlog #303 (rescan-hardened form) — the rg leg's deny TRIPWIRE. rg's exclusion globs are spelled
213
224
  * from the pattern text and cannot express the win32 component-alias family (`.aws.` / `.aws ` —
@@ -220,21 +231,31 @@ export interface GrepRunResult {
220
231
  * ripgrep prints filenames verbatim, so a path component containing a NEWLINE splits one record
221
232
  * across physical lines whose fragments carry no `:line:` boundary — the filter kept them and the
222
233
  * deny-listed content passed (a protection hole, not a precision residual); and pruning every line
223
- * of a partial run fabricated a `No matches.` row inside the structured card. Text-splitting rg
224
- * records is unsalvageable without structured output (`--json`, backlog #306), so this function no
225
- * longer edits anything: it JUDGES. Any deny hit — or any line the format cannot account for —
234
+ * of a partial run fabricated a `No matches.` row inside the structured card. So this function no
235
+ * longer edits anything: it JUDGES. Any deny hit or any record the format cannot account for —
226
236
  * trips, and the caller abandons the rg run for the JS scanner, whose walk prunes with the
227
237
  * authoritative judge and needs no path parsing at all. rg stays the fast path for the common case
228
238
  * (no guarded entries in the result); the moment a guarded spelling is involved, the engine that
229
239
  * cannot mis-parse it owns the answer.
230
240
  *
231
- * Trip conditions by mode `files_with_matches`: every line IS a path, judged whole; `count`:
232
- * `path:N` (a line without the numeric tail is a split record ambiguous, trip); `content`:
233
- * judged at EVERY `:digits:` boundary and every `-digits-` boundary (a candidate cut inside match
234
- * text can over-trip safe: the fallback re-derives the exact answer), and a non-separator line
235
- * with NO boundary at all is a split record — trip. `--` separators and empty lines pass.
241
+ * backlog #306 what it judges is now ripgrep's OWN path field ({@link parseRgRecords}, `--null`),
242
+ * not a prefix cut at a guessed separator. Trip conditions, all three modes alike: a record whose
243
+ * path the deny judge names trips as `deny-hit`; a record with no path field at all (rg's binary-file
244
+ * notice, a no-filename row, an unterminated tail, output from an env that ignored `--null`) is
245
+ * unaccountable and trips as `ambiguous-record`same abandonment, same reason code as before.
246
+ * `dropIncompleteTail` mirrors {@link parseRgRecords}: on the partial legs the cut tail is dropped
247
+ * BEFORE judging, because judging a record the caller will never receive can only cost an
248
+ * unnecessary rescan — every record that does reach the output is still judged, which is what the
249
+ * rule is for. (A killed stream ending mid-record is an EXPECTED shape with a rule of its own; the
250
+ * unaccountable arm is for records that survive that rule.)
251
+ * `--` group separators and empty lines carry no path by construction and pass. The `:digits:` /
252
+ * `-digits-` boundary family this used to reason about is gone with the guessing: both of #303's
253
+ * declared residuals (over-tripping on a cluster inside match TEXT, and the dedup key folding two
254
+ * denied files whose own names carry a cluster) were artifacts of that decision, not of the judge.
236
255
  */
237
- export declare function rgOutputDenyTripwire(stdout: string, mode: "content" | "files_with_matches" | "count", judge: Pick<ReadDenyJudge, "matchPath">): {
256
+ export declare function rgOutputDenyTripwire(stdout: string, mode: GrepOutputMode, judge: Pick<ReadDenyJudge, "matchPath">, opts?: {
257
+ dropIncompleteTail?: boolean;
258
+ }): {
238
259
  trip: false;
239
260
  } | {
240
261
  trip: true;
@@ -285,3 +306,4 @@ export declare function runGlobDetailed(env: ExecutionEnv, root: string, pattern
285
306
  error?: string;
286
307
  withheld?: ReadDenyWithheld;
287
308
  }>;
309
+ export {};
@@ -1,9 +1,11 @@
1
1
  import { delimitUntrusted } from "../../core/untrusted-text.js";
2
+ import { MAX_EXEC_OUTPUT_BYTES } from "../../core/exec-output-tail.js";
2
3
  import { hasBinaryExtension, isAbsolutePathForm } from "./safety.js";
3
4
  const WALK_MAX_FILES = 5000;
4
5
  const WALK_MAX_DEPTH = 32;
5
6
  const GREP_DEFAULT_CAP = 250;
6
7
  const FILE_MAX_BYTES = 5 * 1024 * 1024;
8
+ export const JS_GREP_OUTPUT_MAX_BYTES = MAX_EXEC_OUTPUT_BYTES;
7
9
  export const DEFAULT_IGNORE_DIRS = new Set([
8
10
  "node_modules", ".git", ".hg", ".svn", ".bzr", ".jj", ".sl", "build", "dist", "out", "target",
9
11
  ".dart_tool", ".pub-cache", ".next", ".nuxt", ".gradle", ".idea", ".vscode", "Pods", ".venv", "venv",
@@ -840,6 +842,27 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
840
842
  const off = Math.min(100_000, Math.max(0, Math.floor(p.offset ?? 0)));
841
843
  const collectCap = cap + off;
842
844
  const out = [];
845
+ const outputMaxBytes = guards?.outputMaxBytes ?? JS_GREP_OUTPUT_MAX_BYTES;
846
+ let outBytes = 0;
847
+ let outputTruncated = false;
848
+ let rowsBeforeWindow = 0;
849
+ const pushRow = (row) => {
850
+ if (outputTruncated)
851
+ return;
852
+ if (rowsBeforeWindow < off) {
853
+ rowsBeforeWindow++;
854
+ return;
855
+ }
856
+ if (out.length >= cap)
857
+ return;
858
+ const size = Buffer.byteLength(row, "utf8") + 1;
859
+ if (outBytes + size > outputMaxBytes) {
860
+ outputTruncated = true;
861
+ return;
862
+ }
863
+ outBytes += size;
864
+ out.push(row);
865
+ };
843
866
  const fileMatches = [];
844
867
  const counts = [];
845
868
  let totalContent = 0;
@@ -888,14 +911,12 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
888
911
  if (p.only_matching && text !== undefined) {
889
912
  const parts = text.split("\n");
890
913
  for (let k = 0; k < parts.length; k++) {
891
- if (out.length < collectCap)
892
- out.push(`${relOut(f)}:${s + k}:${clipLine(parts[k])}`);
914
+ pushRow(`${relOut(f)}:${s + k}:${clipLine(parts[k])}`);
893
915
  }
894
916
  continue;
895
917
  }
896
918
  for (let j = Math.max(0, s - 1 - ctxB); j <= Math.min(lines.length - 1, eL - 1 + ctxA); j++) {
897
- if (out.length < collectCap)
898
- out.push(`${relOut(f)}:${j + 1}:${clipLine(lines[j])}`);
919
+ pushRow(`${relOut(f)}:${j + 1}:${clipLine(lines[j])}`);
899
920
  }
900
921
  }
901
922
  }
@@ -924,18 +945,16 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
924
945
  if (mode === "content") {
925
946
  if (p.only_matching) {
926
947
  for (const part of matchedParts(lines[i], p)) {
927
- if (out.length < collectCap)
928
- out.push(`${relOut(f)}:${i + 1}:${clipLine(part)}`);
948
+ pushRow(`${relOut(f)}:${i + 1}:${clipLine(part)}`);
929
949
  }
930
950
  }
931
951
  else if (ctx > 0) {
932
952
  for (let j = Math.max(0, i - ctxB); j <= Math.min(lines.length - 1, i + ctxA); j++) {
933
- if (out.length < collectCap)
934
- out.push(`${relOut(f)}:${j + 1}:${clipLine(lines[j])}`);
953
+ pushRow(`${relOut(f)}:${j + 1}:${clipLine(lines[j])}`);
935
954
  }
936
955
  }
937
- else if (out.length < collectCap) {
938
- out.push(`${relOut(f)}:${i + 1}:${clipLine(lines[i])}`);
956
+ else {
957
+ pushRow(`${relOut(f)}:${i + 1}:${clipLine(lines[i])}`);
939
958
  }
940
959
  }
941
960
  }
@@ -990,15 +1009,16 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
990
1009
  ? NO_MATCHES + offNote + typeNote + caveat
991
1010
  : body.join("\n") + (totalFiles > off + cap ? `\n…[capped at ${cap} of ${totalFiles}]` : "") + offNote + typeNote + caveat;
992
1011
  }
993
- const body = paged(out);
1012
+ const body = out;
1013
+ const byteNote = outputTruncated ? `\n…[output truncated at ${outputMaxBytes} bytes — narrow the pattern or set a smaller head_limit]` : "";
994
1014
  if (body.length === 0)
995
- return NO_MATCHES + offNote + typeNote + caveat;
996
- if (out.length < collectCap)
997
- return body.join("\n") + offNote + typeNote + caveat;
1015
+ return NO_MATCHES + byteNote + offNote + typeNote + caveat;
1016
+ if (out.length < cap)
1017
+ return body.join("\n") + byteNote + offNote + typeNote + caveat;
998
1018
  const marker = ctx > 0 || p.multiline || p.only_matching
999
1019
  ? `\n…[capped at ${cap}; ${totalContent}+ match(es) found, more output omitted]`
1000
1020
  : `\n…[capped at ${cap} of ${totalContent}]`;
1001
- return body.join("\n") + marker + offNote + typeNote + caveat;
1021
+ return body.join("\n") + marker + byteNote + offNote + typeNote + caveat;
1002
1022
  }
1003
1023
  const rgCache = new WeakMap();
1004
1024
  export function detectRipgrep(env) {
@@ -1012,79 +1032,89 @@ export function detectRipgrep(env) {
1012
1032
  }
1013
1033
  return cached;
1014
1034
  }
1015
- async function sortRgFilesByMtime(env, root, stdout, signal) {
1016
- const paths = stdout.split("\n").filter((l) => l.length > 0);
1017
- if (paths.length === 0)
1018
- return stdout;
1035
+ async function sortRgFilesByMtime(env, root, records, signal) {
1036
+ if (records.length === 0)
1037
+ return [...records];
1019
1038
  const rootPrefix = root.replace(/[\\/]+$/, "") + (root.includes("\\") ? "\\" : "/");
1020
1039
  const toAbs = (p) => {
1021
1040
  const stripped = p.startsWith("./") ? p.slice(2) : p;
1022
1041
  return isAbsolutePathForm(stripped) ? stripped : `${rootPrefix}${stripped}`;
1023
1042
  };
1043
+ const paths = records.map((r) => r.path ?? r.text);
1024
1044
  const infos = await Promise.all(paths.map((p) => env.fileInfo(toAbs(p), signal)));
1025
- const withMtime = paths.map((p, i) => {
1026
- const r = infos[i];
1027
- return { p, m: r.ok && typeof r.value.mtimeMs === "number" ? r.value.mtimeMs : undefined };
1045
+ const withMtime = records.map((r, i) => {
1046
+ const info = infos[i];
1047
+ return { r, p: paths[i], m: info.ok && typeof info.value.mtimeMs === "number" ? info.value.mtimeMs : undefined };
1028
1048
  });
1029
1049
  const sorted = withMtime.every((x) => x.m !== undefined)
1030
1050
  ? [...withMtime].sort((a, b) => b.m - a.m || (a.p < b.p ? -1 : a.p > b.p ? 1 : 0))
1031
1051
  : [...withMtime].sort((a, b) => (a.p < b.p ? -1 : a.p > b.p ? 1 : 0));
1032
- return sorted.map((x) => x.p).join("\n");
1052
+ return sorted.map((x) => x.r);
1053
+ }
1054
+ function rgRecordSeparator(mode, rest) {
1055
+ if (mode === "count")
1056
+ return /^\d+$/.test(rest) ? ":" : null;
1057
+ const m = /^\d+([:\-])/.exec(rest);
1058
+ return m === null ? null : m[1];
1059
+ }
1060
+ function parseRgRecords(stdout, mode, dropIncompleteTail = false) {
1061
+ if (stdout.length === 0)
1062
+ return [];
1063
+ if (mode === "files_with_matches") {
1064
+ if (!stdout.includes("\0")) {
1065
+ if (dropIncompleteTail)
1066
+ return [];
1067
+ return stdout.split("\n").filter((l) => l.length > 0).map((l) => ({ path: l, text: l }));
1068
+ }
1069
+ const parts = stdout.split("\0");
1070
+ const tail = parts.pop() ?? "";
1071
+ const records = parts.filter((p) => p.length > 0).map((p) => ({ path: p, text: p }));
1072
+ if (tail.length > 0 && !dropIncompleteTail)
1073
+ records.push({ text: tail });
1074
+ return records;
1075
+ }
1076
+ const lines = stdout.split("\n");
1077
+ if (!stdout.endsWith("\n") && dropIncompleteTail)
1078
+ lines.pop();
1079
+ const records = [];
1080
+ for (const line of lines) {
1081
+ if (line.length === 0)
1082
+ continue;
1083
+ const nul = line.indexOf("\0");
1084
+ const rest = nul < 0 ? "" : line.slice(nul + 1);
1085
+ const sep = nul < 0 ? null : rgRecordSeparator(mode, rest);
1086
+ if (nul < 0 || sep === null) {
1087
+ records.push({ text: line });
1088
+ continue;
1089
+ }
1090
+ const path = line.slice(0, nul);
1091
+ records.push({ path, text: `${path}${sep}${rest}` });
1092
+ }
1093
+ return records;
1033
1094
  }
1034
- function formatRgStdout(stdout, p, caveat = "") {
1095
+ function formatRgRecords(records, p, caveat = "") {
1035
1096
  const cap = p.head_limit === 0 ? Infinity : Math.max(1, Math.floor(p.head_limit ?? GREP_DEFAULT_CAP));
1036
1097
  const off = Math.max(0, Math.floor(p.offset ?? 0));
1037
- const lines = stdout.split("\n").filter((l) => l.length > 0);
1038
- const capped = lines.slice(off, off + cap);
1098
+ const capped = records.slice(off, off + cap);
1039
1099
  if (capped.length === 0)
1040
1100
  return NO_MATCHES + (off > 0 ? `\n[offset ${off}]` : "") + caveat;
1041
- return (capped.join("\n") +
1042
- (lines.length > off + cap ? `\n…[capped at ${cap} of ${lines.length}]` : "") +
1101
+ return (capped.map((r) => r.text).join("\n") +
1102
+ (records.length > off + cap ? `\n…[capped at ${cap} of ${records.length}]` : "") +
1043
1103
  (off > 0 ? `\n[offset ${off}]` : "") +
1044
1104
  caveat);
1045
1105
  }
1046
- export function rgOutputDenyTripwire(stdout, mode, judge) {
1106
+ export function rgOutputDenyTripwire(stdout, mode, judge, opts = {}) {
1047
1107
  if (stdout.length === 0)
1048
1108
  return { trip: false };
1049
- const judgeBoundaries = (line, boundary) => {
1050
- let sawBoundary = false;
1051
- for (let m = boundary.exec(line); m !== null; m = boundary.exec(line)) {
1052
- sawBoundary = true;
1053
- const prefix = line.slice(0, m.index);
1054
- if (prefix.length === 0)
1109
+ for (const record of parseRgRecords(stdout, mode, opts.dropIncompleteTail === true)) {
1110
+ if (record.path === undefined) {
1111
+ if (record.text === "--")
1055
1112
  continue;
1056
- const hit = judge.matchPath(prefix);
1057
- if (hit !== null)
1058
- return hit.pattern;
1059
- }
1060
- return sawBoundary ? null : "";
1061
- };
1062
- for (const line of stdout.split("\n")) {
1063
- if (line.length === 0 || line === "--")
1064
- continue;
1065
- if (mode === "files_with_matches") {
1066
- const h = judge.matchPath(line);
1067
- if (h !== null)
1068
- return { trip: true, reason: "deny-hit", pattern: h.pattern };
1069
- }
1070
- else if (mode === "count") {
1071
- const m = /^(.*):\d+$/.exec(line);
1072
- if (m === null)
1073
- return { trip: true, reason: "ambiguous-record" };
1074
- const h = judge.matchPath(m[1]);
1075
- if (h !== null)
1076
- return { trip: true, reason: "deny-hit", pattern: h.pattern };
1077
- }
1078
- else {
1079
- const colon = judgeBoundaries(line, /:\d+:/g);
1080
- if (colon !== null && colon !== "")
1081
- return { trip: true, reason: "deny-hit", pattern: colon };
1082
- const dash = judgeBoundaries(line, /-\d+-/g);
1083
- if (dash !== null && dash !== "")
1084
- return { trip: true, reason: "deny-hit", pattern: dash };
1085
- if (colon === "" && dash === "")
1086
- return { trip: true, reason: "ambiguous-record" };
1113
+ return { trip: true, reason: "ambiguous-record" };
1087
1114
  }
1115
+ const hit = judge.matchPath(record.path);
1116
+ if (hit !== null)
1117
+ return { trip: true, reason: "deny-hit", pattern: hit.pattern };
1088
1118
  }
1089
1119
  return { trip: false };
1090
1120
  }
@@ -1113,7 +1143,7 @@ async function jsGrepFallback(env, root, p, signal, reason, deny) {
1113
1143
  }
1114
1144
  export async function rgGrepDetailed(env, root, p, signal, deny) {
1115
1145
  const mode = p.output_mode ?? "files_with_matches";
1116
- const flags = ["--no-messages", "--no-require-git", "--hidden"];
1146
+ const flags = ["--null", "--no-messages", "--no-require-git", "--hidden"];
1117
1147
  for (const d of VCS_DIRS)
1118
1148
  flags.push("--glob", `!${d}`);
1119
1149
  flags.push("--max-columns", "500", "--max-columns-preview");
@@ -1155,12 +1185,15 @@ export async function rgGrepDetailed(env, root, p, signal, deny) {
1155
1185
  const partial = err.partialStdout ?? "";
1156
1186
  if (partial.trim().length > 0) {
1157
1187
  if (deny !== undefined) {
1158
- const trip = rgOutputDenyTripwire(partial, mode, deny);
1188
+ const trip = rgOutputDenyTripwire(partial, mode, deny, { dropIncompleteTail: true });
1159
1189
  if (trip.trip)
1160
1190
  return jsGrepDenyTripFallback(env, root, p, signal, trip, deny);
1161
1191
  }
1192
+ const records = parseRgRecords(partial, mode, true);
1193
+ if (records.length === 0)
1194
+ return jsGrepFallback(env, root, p, signal, "timed out before completing a result", deny);
1162
1195
  return {
1163
- text: `${delimitUntrusted("ripgrep partial output", formatRgStdout(partial, p))}\n…[ripgrep timed out after producing partial output — results may be incomplete]`,
1196
+ text: `${delimitUntrusted("ripgrep partial output", formatRgRecords(records, p))}\n…[ripgrep timed out after producing partial output — results may be incomplete]`,
1164
1197
  degraded: { partial: true, reason: "ripgrep timed out" },
1165
1198
  };
1166
1199
  }
@@ -1187,12 +1220,15 @@ export async function rgGrepDetailed(env, root, p, signal, deny) {
1187
1220
  if (exitCode >= 2) {
1188
1221
  if (stdout.trim().length > 0) {
1189
1222
  if (deny !== undefined) {
1190
- const trip = rgOutputDenyTripwire(stdout, mode, deny);
1223
+ const trip = rgOutputDenyTripwire(stdout, mode, deny, { dropIncompleteTail: true });
1191
1224
  if (trip.trip)
1192
1225
  return jsGrepDenyTripFallback(env, root, p, signal, trip, deny);
1193
1226
  }
1227
+ const records = parseRgRecords(stdout, mode, true);
1228
+ if (records.length === 0)
1229
+ return jsGrepFallback(env, root, p, signal, `exited with code ${exitCode} and produced no complete result`, deny);
1194
1230
  return {
1195
- text: `${delimitUntrusted("ripgrep partial output", formatRgStdout(stdout, p))}\n…[ripgrep exited with an error after producing partial output — results may be incomplete]`,
1231
+ text: `${delimitUntrusted("ripgrep partial output", formatRgRecords(records, p))}\n…[ripgrep exited with an error after producing partial output — results may be incomplete]`,
1196
1232
  degraded: { partial: true, reason: `ripgrep exited with code ${exitCode}` },
1197
1233
  };
1198
1234
  }
@@ -1203,9 +1239,10 @@ export async function rgGrepDetailed(env, root, p, signal, deny) {
1203
1239
  if (trip.trip)
1204
1240
  return jsGrepDenyTripFallback(env, root, p, signal, trip, deny);
1205
1241
  }
1206
- const orderedStdout = mode === "files_with_matches" ? await sortRgFilesByMtime(env, root, stdout, signal) : stdout;
1242
+ const parsed = parseRgRecords(stdout, mode);
1243
+ const ordered = mode === "files_with_matches" ? await sortRgFilesByMtime(env, root, parsed, signal) : parsed;
1207
1244
  const d = await denyDisclosure();
1208
- return { text: formatRgStdout(orderedStdout, p) + d.note, ...(d.withheld !== undefined ? { withheld: d.withheld } : {}) };
1245
+ return { text: formatRgRecords(ordered, p) + d.note, ...(d.withheld !== undefined ? { withheld: d.withheld } : {}) };
1209
1246
  }
1210
1247
  async function rgDenyExistenceProbe(env, root, deny, target, signal) {
1211
1248
  const probeFlags = ["--files", "--hidden", "--no-require-git", "--no-messages"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.41.0",
3
+ "version": "5.42.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",