Skip to content

feat(rag): grep/glob/line-read + abort recovery + auto-unlock (1.8.1) - #18

Merged
Rippy1911 merged 2 commits into
mainfrom
feat/rag-grep-error-recovery
Aug 1, 2026
Merged

feat(rag): grep/glob/line-read + abort recovery + auto-unlock (1.8.1)#18
Rippy1911 merged 2 commits into
mainfrom
feat/rag-grep-error-recovery

Conversation

@Rippy1911

Copy link
Copy Markdown
Owner

Why

Device RAG was unusable for codebase questions: fuzzy hash-vector rag_search only, no path:line, no glob, no line-range read, tools locked behind combo-rag, and empty hits that looked like “code not found.” Separately, Play Console runs showed bare Error: The operation was aborted. with no recovery path, Stop looking like a fatal Error, failed tools chips marked done, and empty vision worker critiques soft-lied as success.

What changed

Code search

  • rag_grep — literal/regex over the index → path:line + context; glob / maxMatches / caseInsensitive
  • rag_glob — list paths by glob
  • rag_read_filestartLine / endLine
  • Auto-attach RAG tools when chunkCount > 0 (no skill_read required)
  • Empty rag_search returns a hint to use rag_grep
  • Skip lockfiles; truncated 2500-file walk → lastError

Error / vision surfacing

  • Tool catch adds recovery hints for abort/timeout
  • Sidepanel: AbortError → status Stopped (not Error: …)
  • Tool chips: ok:false → status error
  • Empty vision worker → explicit [VISION WORKER FAILED] message

Verification

Must ship Evidence
Exact identifier → path:line packages/core/src/rag/grep.test.ts
Glob / regex / dedupe / truncate same
Abort recovery hint packages/core/src/agent/errorRecovery.test.ts
Suite 642 passed / 6 skipped
Build Chrome+Firefox yes, version 1.8.1

Test plan

  • Grant Device RAG on a repo, rag_grep({pattern:"buildWorkoutPlan", glob:"**/*.ts"}) returns path:line
  • Stop mid-run → status Stopped, no red Error bubble
  • Force a capture abort → tool chip status error with recovery hint

Play Console walkthrough prompt (facts from due diligence): portfolio _memory/aironcoach-play-console-combo-prompt.md.

Device RAG could not search code: only fuzzy hash-vector rag_search with no
path:line, no glob, no line-range read, and tools locked behind combo-rag the
model often skipped. Empty fuzzy hits looked like "code not found".

Adds rag_grep (literal/regex → path:line+context), rag_glob, and startLine/
endLine on rag_read_file. Auto-attaches the pack when an index exists. Empty
rag_search now hints to switch to rag_grep. Skips lockfiles; truncated 2500-file
walks land in lastError.

Also: tool AbortError/timeout results carry recovery hints; Stop no longer
renders as a red Error bubble; failed tool chips show status error; empty vision
worker replies as an explicit failure, not a soft placeholder.

Tests: 642 passing. Play Console walkthrough prompt lives in portfolio
_memory/aironcoach-play-console-combo-prompt.md.
@nextsolutions-studio

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🏅 Score: 78
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Wrong line numbers

grepChunks reports line as the 1-based offset inside the chunk (li + 1), not the file. Multi-chunk files therefore cite near-start lines for every later chunk, so rag_read_file({ startLine, endLine }) from a grep hit opens the wrong range. Overlap dedupe keys only path + trimmed text, so identical lines at different file offsets collapse to one match. Trigger: any indexed file split across more than one chunk (normal for real sources).

for (const [path, rows] of byPath) {
  rows.sort((a, b) => a.chunkIndex - b.chunkIndex);
  for (const row of rows) {
    const lines = row.content.split("\n");
    for (let li = 0; li < lines.length; li++) {
      const lineText = lines[li]!;
      matcher.lastIndex = 0;
      const m = matcher.exec(lineText);
      if (!m) continue;
      // Line numbers are approximate: chunking is character-based, so we only
      // know the offset within the chunk. Dedupe on the matched text itself,
      // since the same source line appears at a different offset in the next
      // overlapping chunk.
      const key = `${path}:${lineText.trim()}`;
      if (seen.has(key)) continue;
      seen.add(key);
      matches.push({
        path,
        line: li + 1,
        column: m.index + 1,
        text: lineText.trim().slice(0, 240),
        before: context > 0 ? lines.slice(Math.max(0, li - context), li).map((l) => l.trimEnd()) : [],
        after: context > 0 ? lines.slice(li + 1, li + 1 + context).map((l) => l.trimEnd()) : [],
      });
Inclusive endLine bug

rag_read_file documents endLine as 1-based inclusive, but uses lines.slice(from, to) with to = endLine. Array slice excludes the end index, so the last requested line is dropped (e.g. startLine 3, endLine 3 yields empty content). Agents following grep hits will systematically miss the cited line.

const startLine = typeof args.startLine === "number" ? Math.max(1, Math.floor(args.startLine)) : undefined;
const endLine = typeof args.endLine === "number" ? Math.floor(args.endLine) : undefined;
if (startLine != null || endLine != null) {
  const lines = file.content.split("\n");
  const from = (startLine ?? 1) - 1;
  const to = endLine != null ? endLine : lines.length;
  const slice = lines.slice(from, to);
  result = {
    ok: true,
    path: file.path,
    startLine: from + 1,
    endLine: Math.min(to, lines.length),
    totalLines: lines.length,
    content: slice.join("\n"),
    truncated: file.truncated || to < lines.length,
  };
Full corpus load

allChunks / allPaths always getAll() every IndexedDB chunk row into memory before filtering. On a maxed ~2500-file index this runs on each rag_grep / rag_glob, risking multi‑MB copies, GC pressure, and multi-second tool latency on weaker machines with no progressive limit or path index.

/** Distinct indexed paths, unbounded — glob filtering happens in memory. */
async allPaths(): Promise<string[]> {
  await this.getDb();
  const all = await idbReq<RagChunkRow[]>(this.store("chunks", "readonly").getAll());
  return [...new Set(all.map((c) => c.path))].sort();
}

/** Every chunk, for a literal scan. */
async allChunks(): Promise<RagChunkRow[]> {
  await this.getDb();
  return idbReq<RagChunkRow[]>(this.store("chunks", "readonly").getAll());
}
Abort hint over-match

toolErrorRecovery treats any message containing "abort" as a cancelled capture/run. Benign tool errors that only mention abort in passing get the retry/wait playbook instead of the real error, and the bare { tool } fallback still omits error/message text for non-abort failures beyond the separate error field merge.

export function toolErrorRecovery(tool: string, message: string): Record<string, unknown> {
  const lower = message.toLowerCase();
  if (lower.includes("aborted") || lower.includes("abort")) {
    return {
      tool,
      hint:
        `${tool} was cancelled — the run was stopped, the tab was mid-navigation, or a screenshot ` +
        `raced another capture. This is not a result about the page. Wait for the tab to settle ` +
        `(wait), make sure it is focused, then retry ${tool} once. If it aborts again, move on and ` +
        `tell the user rather than retrying.`,
    };
  }
  if (lower.includes("timeout") || lower.includes("timed out")) {
    return {
      tool,
      hint:
        `${tool} timed out — the page is slow or still loading. wait() for it to settle, then retry ` +
        `once with a narrower call (filter/offset) rather than the same broad one.`,
    };
  }
  return { tool };

Comment on lines +334 to +344
let matcher: RegExp;
try {
matcher = opts.regex
? new RegExp(opts.pattern, opts.caseInsensitive ? "gi" : "g")
: new RegExp(
opts.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
opts.caseInsensitive ? "gi" : "g",
);
} catch {
return { matches: [], scannedFiles: 0, truncated: false };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: An empty pattern compiles to a zero-length regex and matches every line until maxMatches, so rag_grep({pattern:""}) returns a full page of junk hits marked ok: true. Reject blank patterns up front the same way invalid regexes already fail closed. [possible issue, importance: 6]

Suggested change
let matcher: RegExp;
try {
matcher = opts.regex
? new RegExp(opts.pattern, opts.caseInsensitive ? "gi" : "g")
: new RegExp(
opts.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
opts.caseInsensitive ? "gi" : "g",
);
} catch {
return { matches: [], scannedFiles: 0, truncated: false };
}
if (!opts.pattern) {
return { matches: [], scannedFiles: 0, truncated: false };
}
let matcher: RegExp;
try {
matcher = opts.regex
? new RegExp(opts.pattern, opts.caseInsensitive ? "gi" : "g")
: new RegExp(
opts.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
opts.caseInsensitive ? "gi" : "g",
);
} catch {
return { matches: [], scannedFiles: 0, truncated: false };
}

Comment on lines +2217 to +2239
const out = grepChunks(chunks, {
pattern: String(args.pattern ?? ""),
regex: args.regex === true,
caseInsensitive: args.caseInsensitive === true,
glob: typeof args.glob === "string" ? args.glob : undefined,
maxMatches: typeof args.maxMatches === "number" ? args.maxMatches : 50,
context: typeof args.context === "number" ? args.context : 2,
});
result = {
ok: true,
...out,
...(out.matches.length === 0
? {
hint:
"No matches. Check the pattern (it is case-sensitive by default — try caseInsensitive:true), " +
"widen the glob, or list candidates with rag_glob.",
}
: out.truncated
? {
hint: `Stopped at ${out.matches.length} matches. Narrow with glob:"<pattern>" or a more specific pattern.`,
}
: {}),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: When pattern is missing/blank, the handler still returns ok: true and the generic “No matches” hint, which steers the model to widen the query instead of fixing the call. Validate required input and fail with ok: false so the model retries with a real pattern. [possible issue, importance: 7]

Suggested change
const out = grepChunks(chunks, {
pattern: String(args.pattern ?? ""),
regex: args.regex === true,
caseInsensitive: args.caseInsensitive === true,
glob: typeof args.glob === "string" ? args.glob : undefined,
maxMatches: typeof args.maxMatches === "number" ? args.maxMatches : 50,
context: typeof args.context === "number" ? args.context : 2,
});
result = {
ok: true,
...out,
...(out.matches.length === 0
? {
hint:
"No matches. Check the pattern (it is case-sensitive by default — try caseInsensitive:true), " +
"widen the glob, or list candidates with rag_glob.",
}
: out.truncated
? {
hint: `Stopped at ${out.matches.length} matches. Narrow with glob:"<pattern>" or a more specific pattern.`,
}
: {}),
};
const pattern = String(args.pattern ?? "").trim();
if (!pattern) {
result = { ok: false, error: "pattern is required" };
} else {
const out = grepChunks(chunks, {
pattern,
regex: args.regex === true,
caseInsensitive: args.caseInsensitive === true,
glob: typeof args.glob === "string" ? args.glob : undefined,
maxMatches: typeof args.maxMatches === "number" ? args.maxMatches : 50,
context: typeof args.context === "number" ? args.context : 2,
});
result = {
ok: true,
...out,
...(out.matches.length === 0
? {
hint:
"No matches. Check the pattern (it is case-sensitive by default — try caseInsensitive:true), " +
"widen the glob, or list candidates with rag_glob.",
}
: out.truncated
? {
hint: `Stopped at ${out.matches.length} matches. Narrow with glob:"<pattern>" or a more specific pattern.`,
}
: {}),
};
}

Comment thread packages/core/src/agent/loop.ts Outdated
Comment on lines +2279 to +2292
if (startLine != null || endLine != null) {
const lines = file.content.split("\n");
const from = (startLine ?? 1) - 1;
const to = endLine != null ? endLine : lines.length;
const slice = lines.slice(from, to);
result = {
ok: true,
path: file.path,
startLine: from + 1,
endLine: Math.min(to, lines.length),
totalLines: lines.length,
content: slice.join("\n"),
truncated: file.truncated || to < lines.length,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: endLine is documented as inclusive, but when startLine > endLine or startLine is past EOF, this still returns ok: true with empty content, which the model can treat as an authoritative empty file range. Clamp/validate the range and return a clear error so the agent re-reads with corrected bounds. [general, importance: 5]

Suggested change
if (startLine != null || endLine != null) {
const lines = file.content.split("\n");
const from = (startLine ?? 1) - 1;
const to = endLine != null ? endLine : lines.length;
const slice = lines.slice(from, to);
result = {
ok: true,
path: file.path,
startLine: from + 1,
endLine: Math.min(to, lines.length),
totalLines: lines.length,
content: slice.join("\n"),
truncated: file.truncated || to < lines.length,
};
if (startLine != null || endLine != null) {
const lines = file.content.split("\n");
const from = (startLine ?? 1) - 1;
const to = endLine != null ? endLine : lines.length;
if (from >= lines.length || (endLine != null && to < from + 1)) {
result = {
ok: false,
error: `invalid range startLine=${startLine ?? 1} endLine=${endLine ?? lines.length} for ${file.path} (${lines.length} lines)`,
totalLines: lines.length,
};
} else {
const slice = lines.slice(from, to);
result = {
ok: true,
path: file.path,
startLine: from + 1,
endLine: Math.min(to, lines.length),
totalLines: lines.length,
content: slice.join("\n"),
truncated: file.truncated || to < lines.length,
};
}

Review of the 1.8.1 grep surface found two correctness holes:
- grepChunks reported chunk-relative lines (wrong for any chunk but the
  first) and deduped on line text, collapsing genuinely repeated lines.
  Chunks now record startLine at index time; grep cites real file lines
  and dedupes overlap by line number. Pre-1.8.1 indexes are flagged
  lineIsEstimate instead of silently lying.
- rag_read_file sliced the chunk-joined snapshot, so line ranges did not
  match the real file. It now reads live from the granted folder when
  permission allows (source:"live") and falls back to the snapshot with
  an explicit note otherwise.
@Rippy1911
Rippy1911 merged commit fe2d647 into main Aug 1, 2026
1 check passed
@Rippy1911
Rippy1911 deleted the feat/rag-grep-error-recovery branch August 1, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants