Surface detailed backend errors and update autonomy guidelines in superdelegate

223d423a248f · AtlantisPleb · · parent 1b3fee9a955f

Surface detailed backend errors and update autonomy guidelines in superdelegate

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/skills/superdelegate/SKILL.md
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/coder-knowledge.ts
  • modified packages/openagents-cli/src/coder-plugins.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • added packages/openagents-cli/test/coder-plugin-git-facts.test.ts
  • added packages/openagents-cli/test/coder-plugin-knowledge-base.test.ts
  • added packages/openagents-cli/test/coder-plugin-patch-check.test.ts
  • added packages/openagents-cli/test/coder-plugin-repo-map.test.ts
  • added packages/openagents-cli/test/coder-plugin-repo-tree.test.ts
  • added packages/openagents-cli/test/coder-plugin-session-search.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts
  • modified plugins/Cargo.lock
  • modified plugins/Cargo.toml
  • modified plugins/foreign-sessions/src/lib.rs
  • added plugins/foreign-sessions/src/sqlite.rs
  • modified plugins/foreign-sessions/src/tests.rs
  • added plugins/git-facts/Cargo.toml
  • added plugins/git-facts/git_facts.wasm
  • added plugins/git-facts/manifest.json
  • added plugins/git-facts/src/lib.rs
  • added plugins/git-facts/src/tests.rs
  • added plugins/knowledge-base/Cargo.toml
  • added plugins/knowledge-base/build-kb.mjs
  • added plugins/knowledge-base/kb.json
  • added plugins/knowledge-base/kb/stances.json
  • added plugins/knowledge-base/knowledge_base.wasm
  • added plugins/knowledge-base/manifest.json
  • added plugins/knowledge-base/src/lib.rs
  • added plugins/knowledge-base/src/tests.rs
  • added plugins/patch-check/Cargo.toml
  • added plugins/patch-check/README.md
  • added plugins/patch-check/manifest.json
  • added plugins/patch-check/patch_check.wasm
  • added plugins/patch-check/src/lib.rs
  • added plugins/patch-check/src/tests.rs
  • modified plugins/read-conversation/src/lib.rs
  • modified plugins/read-conversation/src/tests.rs
  • added plugins/repo-map/Cargo.toml
  • added plugins/repo-map/manifest.json
  • added plugins/repo-map/repo_map.wasm
  • added plugins/repo-map/src/lib.rs
  • added plugins/repo-map/src/tests.rs
  • added plugins/repo-tree/Cargo.toml
  • added plugins/repo-tree/manifest.json
  • added plugins/repo-tree/repo_tree.wasm
  • added plugins/repo-tree/src/lib.rs
  • added plugins/repo-tree/src/tests.rs
  • added plugins/session-search/Cargo.toml
  • added plugins/session-search/manifest.json
  • added plugins/session-search/session_search.wasm
  • added plugins/session-search/src/lib.rs
  • added plugins/session-search/src/tests.rs

Diff

55 files changed, +9641 -69

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2488,
7
    "filesScanned": 2494,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:65e4ac0e1a556f2bfac1a35ea147861f876a3704f4e7b8cbeeeccf8292b0aeba",
4
  "sourceDigest": "sha256:c1a0c064e5d4cd2d7a2d51dd570d8f3a602b8b3c7b65c49076e3a9ec78ea036c",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (76 tracked test files)"
1879
          "ref": "packages/openagents-cli (82 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/skills/superdelegate/SKILL.md modified +7 -5

@@ -102,10 +102,12 @@ After each round, say which issues were selected, which were skipped and why,

102 102
what each child returned, and where the width stands. A backlog run that reports
103 103
only at the end is one nobody can steer.
104 104
105
## What is not yours to decide
105
## Working and completing issues
106 106
107
Closing an issue, pushing, and commenting are visible to other people at once.
108
Do the work and report it; ask before the first write that leaves the machine.
107
When assigned or requested to fix or work on an issue, deliver the solution
108
completely: verify the fix with tests, push directly to `main` (or the working branch),
109
and close the issue without a separate confirmation step.
109 110
110
An agent reporting that it finished is not evidence that it did. Read the diff,
111
run the test, look at the output.
111
Take care not to step on other people's concurrent work: check git status and remotes
112
before pushing. An agent reporting that it finished is not evidence that it did:
113
read the diff, run the test, and verify the output before closing.
packages/openagents-cli/src/cli.ts modified +95 -18

@@ -102,6 +102,7 @@ import {

102 102
  discoverPluginCatalog,
103 103
  matchCapabilities,
104 104
} from "./coder-capability.js";
105
import { knowledgeHits, knowledgeNote } from "./coder-knowledge.js";
105 106
import { runForeignResume } from "./coder-foreign-resume.js";
106 107
import { existsSync } from "node:fs";
107 108
import { spawnSync } from "node:child_process";

@@ -2514,7 +2515,27 @@ const coderCommand = Command.make(

2514 2515
      // outlives the process.
2515 2516
      const plugins: LoadedPlugin[] = [];
2516 2517
      const catalog = discoverPluginCatalog(fileURLToPath(import.meta.url));
2518
      // Turn-scoped visibility (the owner's decision, 2026-08-25): a tool the
2519
      // current message does not need is not shown to the model. A plugin the
2520
      // reader loaded by hand (`/plugin load`) is pinned for the session; one
2521
      // the harness or the model brought in stays declared only while it is
2522
      // warm — matched by retrieval or invoked within the last two turns —
2523
      // and then leaves the tool list. The instance itself stays cached in
2524
      // `plugins`, so coming back is free.
2525
      let turn = 0;
2526
      const pinnedNames = new Set<string>();
2527
      const warmUntil = new Map<string, number>();
2528
      const warm = (name: string): void => {
2529
        warmUntil.set(name, turn + 2);
2530
      };
2531
      const visiblePlugins = (): LoadedPlugin[] =>
2532
        plugins.filter(
2533
          (plugin) =>
2534
            pinnedNames.has(plugin.manifest.name) ||
2535
            (warmUntil.get(plugin.manifest.name) ?? -1) >= turn,
2536
        );
2517 2537
      const onSelect = (outcome: LoadedPlugin, manifestFile: string) => {
2538
        warm(outcome.manifest.name);
2518 2539
        // Reloading a name replaces it: a demo iterates on one plugin, and
2519 2540
        // two tools with one name would be a declaration the model cannot
2520 2541
        // tell apart.

@@ -2559,13 +2580,27 @@ const coderCommand = Command.make(

2559 2580
          openagentsTool(),
2560 2581
          ...(setup === undefined ? [] : [delegateTool(setup.delegation)]),
2561 2582
          capability,
2562
          ...plugins.map((plugin) => pluginTool(plugin)),
2583
          ...visiblePlugins().map((plugin) => {
2584
            const tool = pluginTool(plugin);
2585
            // Using a tool keeps it visible: the warmth window restarts on
2586
            // every invocation, so a follow-up still finds it declared.
2587
            return {
2588
              ...tool,
2589
              run: (args: Record<string, unknown>, signal: AbortSignal) => {
2590
                warm(plugin.manifest.name);
2591
                return tool.run(args, signal);
2592
              },
2593
            };
2594
          }),
2563 2595
        ];
2564 2596
        session.declareTools(tools);
2565 2597
      };
2566 2598
      declareTools();
2567 2599
2568
      const loadPlugin = (manifestPath: string): string => {
2600
      // `pin` distinguishes who asked: the reader's `/plugin load` pins the
2601
      // tool for the whole session, while harness retrieval loads unpinned
2602
      // and lets the warmth window govern visibility.
2603
      const loadPlugin = (manifestPath: string, pin = true): string => {
2569 2604
        const manifestFile = resolvePath(process.cwd(), manifestPath);
2570 2605
        const outcome = loadPluginFromManifest(manifestFile);
2571 2606
        const described = describeLoad(outcome);

@@ -2585,47 +2620,89 @@ const coderCommand = Command.make(

2585 2620
          return described;
2586 2621
        }
2587 2622
        onSelect(outcome, manifestFile);
2623
        if (pin) pinnedNames.add(outcome.manifest.name);
2588 2624
        return described;
2589 2625
      };
2590 2626
2627
      // The knowledge-base rail (OpenAgentsInc/openagents#49): the corpus
2628
      // plugin is consulted directly by the harness on every message and is
2629
      // never declared as a tool — its answers arrive as attached context.
2630
      // Loaded lazily and kept out of `plugins` on purpose.
2631
      let knowledgeBase: LoadedPlugin | undefined;
2632
      const knowledgeBaseNote = async (prompt: string): Promise<string | undefined> => {
2633
        if (knowledgeBase === undefined) {
2634
          const entry = catalog.find((candidate) => candidate.name === "knowledge_base");
2635
          if (entry === undefined) return undefined;
2636
          const outcome = loadPluginFromManifest(entry.manifestPath);
2637
          if (isRefusal(outcome)) return undefined;
2638
          knowledgeBase = outcome;
2639
        }
2640
        try {
2641
          const packet = new TextEncoder().encode(JSON.stringify({ query: prompt, limit: 3 }));
2642
          const answered = await invokePlugin(knowledgeBase, packet);
2643
          if (isRefusal(answered)) return undefined;
2644
          const envelope: unknown = JSON.parse(new TextDecoder().decode(answered));
2645
          return knowledgeNote(knowledgeHits(envelope));
2646
        } catch {
2647
          // The rail degrades to silence, never to a broken turn.
2648
          return undefined;
2649
        }
2650
      };
2651
2591 2652
      // Retrieval is the harness's job (OpenAgentsInc/openagents#42): score
2592 2653
      // the catalog against each incoming message, auto-load what clearly
2593 2654
      // matches so the model simply sees the right tool, and note a weaker
2594 2655
      // match for the model to load itself. Nothing matched costs nothing.
2595 2656
      const capabilityRetrieval = async (prompt: string): Promise<string | undefined> => {
2596
        const matches = matchCapabilities(catalog, prompt);
2657
        // Each incoming message opens a turn; the warmth window that governs
2658
        // which plugin tools the model sees is measured in these.
2659
        turn += 1;
2660
2661
        const notes: string[] = [];
2662
        const fromKnowledge = await knowledgeBaseNote(prompt);
2663
        if (fromKnowledge !== undefined) notes.push(fromKnowledge);
2664
2665
        // The knowledge base is a rail, not a tool: it never materializes.
2666
        const matches = matchCapabilities(catalog, prompt).filter(
2667
          (candidate) => candidate.entry.name !== "knowledge_base",
2668
        );
2597 2669
        const held = new Set(plugins.map((plugin) => plugin.manifest.name));
2598 2670
2599 2671
        const materialized: string[] = [];
2600 2672
        for (const match of matches.filter((candidate) => candidate.hits >= 2).slice(0, 2)) {
2601 2673
          if (held.has(match.entry.name)) {
2674
            warm(match.entry.name);
2602 2675
            materialized.push(match.entry.name);
2603 2676
            continue;
2604 2677
          }
2605
          const described = loadPlugin(match.entry.manifestPath);
2678
          const described = loadPlugin(match.entry.manifestPath, false);
2606 2679
          if (described.startsWith("Loaded plugin")) materialized.push(match.entry.name);
2607 2680
        }
2681
        // Re-declared after the warmth window moved: this turn's tool list is
2682
        // what matched this message, what is pinned, and what was just used —
2683
        // not everything ever loaded.
2684
        declareTools();
2608 2685
2609 2686
        if (materialized.length > 0) {
2610 2687
          const names = materialized.map((name) => `\`${name}\``).join(", ");
2611
          return (
2688
          notes.push(
2612 2689
            `[Attached by the harness: installed capabilities matched this request, and ` +
2613
            `${names} ${materialized.length === 1 ? "is" : "are"} loaded and available as ` +
2614
            `${materialized.length === 1 ? "a tool" : "tools"} right now. Prefer ` +
2615
            `${materialized.length === 1 ? "it" : "them"} over scripting the same thing.]`
2616
          );
2617
        }
2618
2619
        const near = matches.slice(0, 2);
2620
        if (near.length > 0) {
2621
          const names = near.map((candidate) => `\`${candidate.entry.name}\``).join(", ");
2622
          return (
2623
            `[Attached by the harness: ${names} in the installed capability catalog may ` +
2624
            `cover this. Load one with the capability tool, name set exactly, if it fits.]`
2690
              `${names} ${materialized.length === 1 ? "is" : "are"} loaded and available as ` +
2691
              `${materialized.length === 1 ? "a tool" : "tools"} right now. Prefer ` +
2692
              `${materialized.length === 1 ? "it" : "them"} over scripting the same thing.]`,
2625 2693
          );
2694
        } else {
2695
          const near = matches.slice(0, 2);
2696
          if (near.length > 0) {
2697
            const names = near.map((candidate) => `\`${candidate.entry.name}\``).join(", ");
2698
            notes.push(
2699
              `[Attached by the harness: ${names} in the installed capability catalog may ` +
2700
                `cover this. Load one with the capability tool, name set exactly, if it fits.]`,
2701
            );
2702
          }
2626 2703
        }
2627 2704
2628
        return undefined;
2705
        return notes.length === 0 ? undefined : notes.join("\n\n");
2629 2706
      };
2630 2707
2631 2708
      const locateForeignSessionsManifest = (): string | undefined => {
packages/openagents-cli/src/coder-knowledge.ts added +96

@@ -0,0 +1,96 @@

1
/**
2
 * The knowledge-base rail (OpenAgentsInc/openagents#49): the harness asks the
3
 * `knowledge_base` plugin about each incoming message and, when the corpus
4
 * clearly answers, attaches its stances and doc summaries to the outgoing
5
 * turn. The model never calls the knowledge base as a tool — by the time it
6
 * writes, the documented position is already in front of it, dated and
7
 * sourced, so it can prefer what the project actually decided over a guess.
8
 */
9
10
/** One hit as the `knowledge_base` guest returns it. */
11
export interface KnowledgeHit {
12
  readonly kind: "stance" | "doc";
13
  readonly title: string;
14
  readonly body: string;
15
  readonly state?: string | undefined;
16
  readonly sources: ReadonlyArray<string>;
17
  readonly date?: string | undefined;
18
  readonly score: number;
19
}
20
21
/**
22
 * The overlap a hit needs before it is worth attaching. The guest scores a
23
 * question-level word at 3, so the floor asks for roughly two intent-carrying
24
 * words — a lone body-text collision never interrupts the conversation.
25
 */
26
export const KNOWLEDGE_ATTACH_FLOOR = 5;
27
28
/** Most hits one note carries; more is noise, not context. */
29
const NOTE_LIMIT = 2;
30
31
/** A body's share of the note; stances are prose and can run long. */
32
const BODY_BOUND = 700;
33
34
const clipped = (text: string): string =>
35
  text.length <= BODY_BOUND ? text : `${text.slice(0, BODY_BOUND - 1).trimEnd()}…`;
36
37
const line = (hit: KnowledgeHit): string => {
38
  const provenance =
39
    hit.kind === "stance"
40
      ? `stance${hit.state === undefined ? "" : `, ${hit.state}`}${
41
          hit.date === undefined ? "" : `; reviewed ${hit.date}`
42
        }`
43
      : `doc${hit.sources.length === 0 ? "" : `, ${hit.sources[0] ?? ""}`}`;
44
  return `- ${hit.title} (${provenance}): ${clipped(hit.body)}`;
45
};
46
47
/**
48
 * The note for one message, or nothing when the corpus has nothing strong
49
 * enough to say. Hits arrive best-first from the guest; the floor and the
50
 * limit are applied here so the plugin stays a plain ranking function.
51
 */
52
export const knowledgeNote = (hits: ReadonlyArray<KnowledgeHit>): string | undefined => {
53
  const strong = hits.filter((hit) => hit.score >= KNOWLEDGE_ATTACH_FLOOR).slice(0, NOTE_LIMIT);
54
  if (strong.length === 0) return undefined;
55
  return (
56
    "[From the OpenAgents knowledge base — reviewed positions and public docs. " +
57
    "Where these speak, prefer them over general knowledge, and say when a thing " +
58
    "is parked or planned rather than live:\n" +
59
    strong.map(line).join("\n") +
60
    "]"
61
  );
62
};
63
64
/** A guarded read of the guest's envelope into typed hits; junk is no hits. */
65
export const knowledgeHits = (envelope: unknown): KnowledgeHit[] => {
66
  if (envelope === null || typeof envelope !== "object") return [];
67
  const ok = (envelope as Record<string, unknown>)["ok"];
68
  if (ok === null || typeof ok !== "object") return [];
69
  const hits = (ok as Record<string, unknown>)["hits"];
70
  if (!Array.isArray(hits)) return [];
71
  const typed: KnowledgeHit[] = [];
72
  for (const candidate of hits) {
73
    if (candidate === null || typeof candidate !== "object") continue;
74
    const record = candidate as Record<string, unknown>;
75
    const kind = record["kind"];
76
    const title = record["title"];
77
    const body = record["body"];
78
    const score = record["score"];
79
    if (kind !== "stance" && kind !== "doc") continue;
80
    if (typeof title !== "string" || typeof body !== "string" || typeof score !== "number") {
81
      continue;
82
    }
83
    typed.push({
84
      kind,
85
      title,
86
      body,
87
      state: typeof record["state"] === "string" ? record["state"] : undefined,
88
      sources: Array.isArray(record["sources"])
89
        ? record["sources"].filter((source): source is string => typeof source === "string")
90
        : [],
91
      date: typeof record["date"] === "string" ? record["date"] : undefined,
92
      score,
93
    });
94
  }
95
  return typed;
96
};
packages/openagents-cli/src/coder-plugins.ts modified +14 -6

@@ -23,8 +23,9 @@

23 23
 *   inspection, before instantiation, so the sandbox is a property of what
24 24
 *   was loaded rather than a hope about what it does.
25 25
 * - **Mounts are read-only and confined.** A declared mount resolves to a
26
 *   real directory at load (relative to the manifest, absolute, or
27
 *   `~`-expanded); at invocation the engine's `read_file` and `list_dir`
26
 *   real directory at load (relative to the manifest, absolute,
27
 *   `~`-expanded, or the literal `${workspace}`, which is the process
28
 *   working directory); at invocation the engine's `read_file` and `list_dir`
28 29
 *   imports canonicalize every path, refuse absolute paths, `..` escapes,
29 30
 *   and symlinks, bound the bytes per file, and bound the entries per
30 31
 *   listing.

@@ -60,9 +61,11 @@ export interface PluginMount {

60 61
  /**
61 62
   * Directory path. Relative paths resolve against the manifest's
62 63
   * directory; absolute paths are taken as-is; a leading `~/` (or a bare
63
   * `~`) expands to the invoking user's home directory. Whatever the form,
64
   * the root must exist and be a directory at load, or the plugin refuses
65
   * to load.
64
   * `~`) expands to the invoking user's home directory; the literal
65
   * `${workspace}` resolves to the process working directory at load time
66
   * (the parameterized workspace mount, OpenAgentsInc/openagents#44).
67
   * Whatever the form, the root must exist and be a directory at load, or
68
   * the plugin refuses to load.
66 69
   */
67 70
  readonly path: string;
68 71
  /** Only `true` is accepted; a writable mount is refused, not downgraded. */

@@ -165,7 +168,12 @@ export function loadPluginFromManifest(

165 168
  const manifestDir = dirname(manifestPath);
166 169
  const mounts: string[] = [];
167 170
  for (const mount of manifest.capabilities.mounts) {
168
    const declared = resolve(manifestDir, expandMountPath(mount.path));
171
    // The parameterized workspace mount (OpenAgentsInc/openagents#44): the
172
    // literal `${workspace}` resolves to the process working directory at
173
    // load time, so a checked-in manifest can grant "the repository the
174
    // coder is standing in" without naming a machine-specific path.
175
    const expanded = mount.path === "${workspace}" ? process.cwd() : expandMountPath(mount.path);
176
    const declared = resolve(manifestDir, expanded);
169 177
    let root: string;
170 178
    try {
171 179
      root = realpathSync(declared);
packages/openagents-cli/src/coder-ui.ts modified +2 -2

@@ -706,8 +706,8 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

706 706
707 707
      const heading =
708 708
        running === 0
709
          ? `${BOLD}children${RESET}`
710
          : `${BOLD}children${RESET} ${DIM}${String(running)} working${RESET}`;
709
          ? `${BOLD}subagents${RESET}`
710
          : `${BOLD}subagents${RESET} ${DIM}${String(running)} working${RESET}`;
711 711
712 712
      const rows: string[] = [
713 713
        heading,
packages/openagents-cli/test/coder-plugin-git-facts.test.ts added +208

@@ -0,0 +1,208 @@

1
/**
2
 * The git facts reader through the real boundary: the checked-in
3
 * `git_facts` plugin against a staged fixture "repo" — a hand-written
4
 * `.git/HEAD`, loose ref, `packed-refs`, `logs/HEAD` reflog, and a
5
 * hand-built binary version-2 index, with matching and mismatching
6
 * workdir files around them. The parsing logic is unit-tested against a
7
 * fake host in `plugins/git-facts/src/tests.rs`; this file proves the
8
 * same facts hold through the WASM sandbox, and that a workspace with no
9
 * git repository is refused. The checked-in manifest declares the
10
 * `${workspace}` mount; here it is rewritten to the fixture root, the
11
 * established pattern for mount-bearing plugins.
12
 */
13
14
import { mkdirSync, mkdtempSync, copyFileSync, readFileSync, utimesSync, writeFileSync } from "node:fs";
15
import { tmpdir } from "node:os";
16
import { join } from "node:path";
17
import { fileURLToPath } from "node:url";
18
import { describe, expect, it } from "vitest";
19
20
import {
21
  invokePlugin,
22
  isRefusal,
23
  loadPluginFromManifest,
24
  type LoadedPlugin,
25
} from "../src/coder-plugins.js";
26
27
const MANIFEST = fileURLToPath(new URL("../../../plugins/git-facts/manifest.json", import.meta.url));
28
const WASM = fileURLToPath(new URL("../../../plugins/git-facts/git_facts.wasm", import.meta.url));
29
30
const SHA_A = "a".repeat(40);
31
const SHA_B = "b".repeat(40);
32
const ZERO = "0".repeat(40);
33
34
/** Whole seconds, so index mtimes and filesystem mtimes agree exactly. */
35
const KEPT_MTIME_MS = 1_700_000_000_000;
36
const DRIFT_MTIME_MS = 1_700_000_100_000;
37
38
/**
39
 * Build a version-2 git index: the 12-byte DIRC header, 62-byte fixed
40
 * entries with 8-byte-aligned NUL padding after each path, and a zeroed
41
 * 20-byte trailing checksum (the plugin never verifies it).
42
 */
43
const buildIndex = (entries: Array<{ path: string; size: number; mtimeMs: number }>): Buffer => {
44
  const parts: Buffer[] = [];
45
  const header = Buffer.alloc(12);
46
  header.write("DIRC", 0, "ascii");
47
  header.writeUInt32BE(2, 4);
48
  header.writeUInt32BE(entries.length, 8);
49
  parts.push(header);
50
  for (const entry of entries) {
51
    const name = Buffer.from(entry.path, "utf8");
52
    const entryLen = (62 + name.length + 8) & ~7;
53
    const buffer = Buffer.alloc(entryLen);
54
    buffer.writeUInt32BE(Math.floor(entry.mtimeMs / 1000), 8); // mtime sec
55
    buffer.writeUInt32BE((entry.mtimeMs % 1000) * 1_000_000, 12); // mtime nsec
56
    buffer.writeUInt32BE(0o100644, 24); // mode
57
    buffer.writeUInt32BE(entry.size, 36);
58
    buffer.writeUInt16BE(name.length, 60); // flags: name length
59
    name.copy(buffer, 62);
60
    parts.push(buffer);
61
  }
62
  parts.push(Buffer.alloc(20));
63
  return Buffer.concat(parts);
64
};
65
66
const reflogLine = (oldId: string, newId: string, seconds: number, message: string): string =>
67
  `${oldId} ${newId} Ada Lovelace <ada@example.com> ${seconds} -0600\t${message}\n`;
68
69
const touch = (path: string, mtimeMs: number): void => {
70
  utimesSync(path, new Date(mtimeMs), new Date(mtimeMs));
71
};
72
73
const load = (workspace: string): LoadedPlugin => {
74
  const dir = mkdtempSync(join(tmpdir(), "git-facts-plugin-"));
75
  const manifest = JSON.parse(readFileSync(MANIFEST, "utf8")) as {
76
    capabilities: { mounts: Array<{ path: string; readonly: true }> };
77
  };
78
  manifest.capabilities.mounts = [{ path: workspace, readonly: true }];
79
  writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
80
  copyFileSync(WASM, join(dir, "git_facts.wasm"));
81
82
  const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
83
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
84
  return outcome;
85
};
86
87
/**
88
 * A fixture repo on branch `main`: one loose ref, one packed branch, a
89
 * two-entry reflog, and a three-entry index against a workdir where
90
 * `kept.txt` matches its index stat, `drifted.txt` differs in size,
91
 * `gone.txt` is absent, and `extra.txt` is untracked.
92
 */
93
const stageRepo = (): string => {
94
  const workspace = mkdtempSync(join(tmpdir(), "git-facts-repo-"));
95
  const git = join(workspace, ".git");
96
  mkdirSync(join(git, "refs", "heads"), { recursive: true });
97
  mkdirSync(join(git, "logs"), { recursive: true });
98
99
  writeFileSync(join(git, "HEAD"), "ref: refs/heads/main\n");
100
  writeFileSync(join(git, "refs", "heads", "main"), `${SHA_A}\n`);
101
  writeFileSync(
102
    join(git, "packed-refs"),
103
    `# pack-refs with: peeled fully-peeled sorted \n${SHA_B} refs/heads/archive\n`,
104
  );
105
  writeFileSync(
106
    join(git, "logs", "HEAD"),
107
    reflogLine(ZERO, SHA_B, 1_700_000_000, "commit (initial): begin") +
108
      reflogLine(SHA_B, SHA_A, 1_700_000_100, "commit: keep\tgoing"),
109
  );
110
111
  writeFileSync(join(workspace, "kept.txt"), "kept\n");
112
  touch(join(workspace, "kept.txt"), KEPT_MTIME_MS);
113
  writeFileSync(join(workspace, "drifted.txt"), "drifted longer now\n");
114
  touch(join(workspace, "drifted.txt"), DRIFT_MTIME_MS);
115
  writeFileSync(join(workspace, "extra.txt"), "new\n");
116
117
  writeFileSync(
118
    join(git, "index"),
119
    buildIndex([
120
      { path: "drifted.txt", size: 8, mtimeMs: DRIFT_MTIME_MS },
121
      { path: "gone.txt", size: 5, mtimeMs: KEPT_MTIME_MS },
122
      { path: "kept.txt", size: 5, mtimeMs: KEPT_MTIME_MS },
123
    ]),
124
  );
125
  return workspace;
126
};
127
128
type PathFacts = { count: number; paths: string[] };
129
type Output = {
130
  head?: { branch?: string; detached?: string };
131
  branches?: Array<{ name: string; id: string }>;
132
  log?: Array<{ id: string; at_ms: number; message: string }>;
133
  status?: {
134
    tracked: number;
135
    changed_candidates: PathFacts;
136
    untracked: PathFacts;
137
    missing: PathFacts;
138
    walk_truncated: boolean;
139
    comparison: string;
140
  };
141
  notes: string[];
142
};
143
144
/** The guest envelope: `ok` on success, `refusal` as a value otherwise. */
145
type Envelope = { ok?: Output; refusal?: { code: string; reason: string } };
146
147
const invoke = async (plugin: LoadedPlugin, input: Record<string, unknown>): Promise<Envelope> => {
148
  const packet = new TextEncoder().encode(JSON.stringify(input));
149
  const outcome = await invokePlugin(plugin, packet);
150
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
151
  return JSON.parse(new TextDecoder().decode(outcome)) as Envelope;
152
};
153
154
const call = async (plugin: LoadedPlugin, input: Record<string, unknown>): Promise<Output> => {
155
  const envelope = await invoke(plugin, input);
156
  if (envelope.ok === undefined) throw new Error(JSON.stringify(envelope.refusal));
157
  return envelope.ok;
158
};
159
160
describe("the git_facts plugin through the sandbox", () => {
161
  it("reads head, branches, log, and status from the plumbing files", async () => {
162
    const plugin = load(stageRepo());
163
    const out = await call(plugin, {});
164
165
    expect(out.head).toEqual({ branch: "main" });
166
167
    expect(out.branches).toEqual([
168
      { name: "archive", id: SHA_B },
169
      { name: "main", id: SHA_A },
170
    ]);
171
172
    expect(out.log).toEqual([
173
      { id: SHA_A, at_ms: 1_700_000_100_000, message: "commit: keep\tgoing" },
174
      { id: SHA_B, at_ms: 1_700_000_000_000, message: "commit (initial): begin" },
175
    ]);
176
177
    const status = out.status!;
178
    expect(status.tracked).toBe(3);
179
    expect(status.changed_candidates.paths).toEqual(["drifted.txt"]);
180
    expect(status.untracked.paths).toEqual(["extra.txt"]);
181
    expect(status.missing.paths).toEqual(["gone.txt"]);
182
    expect(status.walk_truncated).toBe(false);
183
    expect(status.comparison).toBe("size_and_mtime_only");
184
  });
185
186
  it("reports only the facts asked for", async () => {
187
    const plugin = load(stageRepo());
188
    const out = await call(plugin, { facts: ["head", "log"], max_log: 1 });
189
190
    expect(out.head).toEqual({ branch: "main" });
191
    expect(out.log).toEqual([
192
      { id: SHA_A, at_ms: 1_700_000_100_000, message: "commit: keep\tgoing" },
193
    ]);
194
    expect(out.branches).toBeUndefined();
195
    expect(out.status).toBeUndefined();
196
  });
197
198
  it("refuses a workspace with no git repository at its root", async () => {
199
    const workspace = mkdtempSync(join(tmpdir(), "git-facts-bare-"));
200
    writeFileSync(join(workspace, "README.md"), "no repo here\n");
201
    const plugin = load(workspace);
202
203
    const envelope = await invoke(plugin, {});
204
    expect(envelope.ok).toBeUndefined();
205
    expect(envelope.refusal?.code).toBe("unsupported");
206
    expect(envelope.refusal?.reason).toContain("no git repository");
207
  });
208
});
packages/openagents-cli/test/coder-plugin-knowledge-base.test.ts added +112

@@ -0,0 +1,112 @@

1
/**
2
 * The knowledge base through the real boundary and onto the rail: the
3
 * checked-in `knowledge_base` plugin answers through the WASM sandbox from
4
 * its embedded corpus (no mounts, no hosts), and the harness-side note
5
 * builder turns those answers into the attached context a turn carries.
6
 * The ranking itself is unit-tested against the corpus in
7
 * `plugins/knowledge-base/src/tests.rs`; this file proves the loaded
8
 * artifact and the note agree end to end.
9
 */
10
11
import { fileURLToPath } from "node:url";
12
import { describe, expect, it } from "vitest";
13
14
import {
15
  invokePlugin,
16
  isRefusal,
17
  loadPluginFromManifest,
18
  type LoadedPlugin,
19
} from "../src/coder-plugins.js";
20
import {
21
  KNOWLEDGE_ATTACH_FLOOR,
22
  knowledgeHits,
23
  knowledgeNote,
24
  type KnowledgeHit,
25
} from "../src/coder-knowledge.js";
26
27
const MANIFEST = fileURLToPath(
28
  new URL("../../../plugins/knowledge-base/manifest.json", import.meta.url),
29
);
30
31
const load = (): LoadedPlugin => {
32
  const outcome = loadPluginFromManifest(MANIFEST);
33
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
34
  return outcome;
35
};
36
37
const ask = async (plugin: LoadedPlugin, input: Record<string, unknown>): Promise<unknown> => {
38
  const outcome = await invokePlugin(plugin, new TextEncoder().encode(JSON.stringify(input)));
39
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
40
  return JSON.parse(new TextDecoder().decode(outcome));
41
};
42
43
describe("the knowledge_base plugin through the sandbox", () => {
44
  it("answers the earning question with the parked-economy stance", async () => {
45
    const plugin = load();
46
    const envelope = await ask(plugin, { query: "How can I earn bitcoin with this system?" });
47
    const hits = knowledgeHits(envelope);
48
49
    const top = hits[0];
50
    expect(top?.kind).toBe("stance");
51
    expect(top?.title).toBe("Earning bitcoin or money on OpenAgents");
52
    expect(top?.state).toContain("parked");
53
    expect(top?.date).toBeDefined();
54
  });
55
56
  it("has nothing to say about an unrelated topic", async () => {
57
    const plugin = load();
58
    const envelope = await ask(plugin, { query: "quaternion spline interpolation" });
59
    expect(knowledgeHits(envelope)).toEqual([]);
60
  });
61
62
  it("refuses a query with no matchable words, inside the envelope", async () => {
63
    const plugin = load();
64
    const envelope = (await ask(plugin, { query: "a & b" })) as {
65
      refusal?: { code: string };
66
    };
67
    expect(envelope.refusal?.code).toBe("unsupported");
68
  });
69
});
70
71
describe("the knowledge note on the rail", () => {
72
  const stance: KnowledgeHit = {
73
    kind: "stance",
74
    title: "Earning bitcoin or money on OpenAgents",
75
    body: "Earning is real but parked.",
76
    state: "deliberately parked",
77
    sources: ["docs/economy.md"],
78
    date: "2026-08-25",
79
    score: 12,
80
  };
81
82
  it("attaches a strong stance with its state and review date", () => {
83
    const note = knowledgeNote([stance]);
84
    expect(note).toContain("knowledge base");
85
    expect(note).toContain("deliberately parked");
86
    expect(note).toContain("reviewed 2026-08-25");
87
    expect(note).toContain("Earning is real but parked.");
88
  });
89
90
  it("stays silent below the floor and on no hits", () => {
91
    expect(knowledgeNote([])).toBeUndefined();
92
    expect(
93
      knowledgeNote([{ ...stance, score: KNOWLEDGE_ATTACH_FLOOR - 1 }]),
94
    ).toBeUndefined();
95
  });
96
97
  it("carries at most two hits and clips a runaway body", () => {
98
    const long = { ...stance, body: "x".repeat(5000) };
99
    const note = knowledgeNote([long, stance, stance]);
100
    expect(note).toBeDefined();
101
    expect((note ?? "").length).toBeLessThan(2000);
102
    expect((note ?? "").split("\n- ").length - 1).toBeLessThanOrEqual(2);
103
  });
104
105
  it("end to end: the sandbox answer becomes an attachable note", async () => {
106
    const plugin = load();
107
    const envelope = await ask(plugin, { query: "How can I earn bitcoin with this system?" });
108
    const note = knowledgeNote(knowledgeHits(envelope));
109
    expect(note).toContain("Earning bitcoin or money on OpenAgents");
110
    expect(note).toContain("parked");
111
  });
112
});
packages/openagents-cli/test/coder-plugin-patch-check.test.ts added +130

@@ -0,0 +1,130 @@

1
/**
2
 * The patch checker through the real boundary: the checked-in
3
 * `patch_check` plugin loaded from its own manifest — pure computation, no
4
 * mounts, so no fixture staging or manifest rewriting — and invoked with
5
 * JSON packets. The placement logic is unit-tested in
6
 * `plugins/patch-check/src/tests.rs`; this file proves the same behavior
7
 * holds through the WASM sandbox: a clean apply, drift reporting, and the
8
 * refusal envelope for a malformed diff.
9
 */
10
11
import { fileURLToPath } from "node:url";
12
import { describe, expect, it } from "vitest";
13
14
import {
15
  invokePlugin,
16
  isRefusal,
17
  loadPluginFromManifest,
18
  type LoadedPlugin,
19
} from "../src/coder-plugins.js";
20
21
const MANIFEST = fileURLToPath(
22
  new URL("../../../plugins/patch-check/manifest.json", import.meta.url),
23
);
24
25
const load = (): LoadedPlugin => {
26
  const outcome = loadPluginFromManifest(MANIFEST);
27
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
28
  return outcome;
29
};
30
31
type HunkReport = {
32
  index: number;
33
  applies: boolean;
34
  at_line?: number;
35
  drift_lines?: number;
36
  reason?: string;
37
  mismatch?: string;
38
};
39
40
type Output = {
41
  applies: boolean;
42
  hunks: HunkReport[];
43
  applied_hunks: number;
44
  failed_hunks: number;
45
  preview?: string;
46
  preview_truncated?: boolean;
47
};
48
49
/** The guest envelope: `ok` on success, `refusal` as a value otherwise. */
50
type Envelope = { ok?: Output; refusal?: { code: string; reason: string } };
51
52
const invoke = async (plugin: LoadedPlugin, input: Record<string, unknown>): Promise<Envelope> => {
53
  const packet = new TextEncoder().encode(JSON.stringify(input));
54
  const outcome = await invokePlugin(plugin, packet);
55
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
56
  return JSON.parse(new TextDecoder().decode(outcome)) as Envelope;
57
};
58
59
const call = async (plugin: LoadedPlugin, input: Record<string, unknown>): Promise<Output> => {
60
  const envelope = await invoke(plugin, input);
61
  if (envelope.ok === undefined) throw new Error(JSON.stringify(envelope.refusal));
62
  return envelope.ok;
63
};
64
65
const DIFF =
66
  "--- a/greeting.txt\n" +
67
  "+++ b/greeting.txt\n" +
68
  "@@ -2,3 +2,3 @@\n" +
69
  " beta\n" +
70
  "-gamma\n" +
71
  "+GAMMA\n" +
72
  " delta\n";
73
74
describe("the patch_check plugin through the sandbox", () => {
75
  it("confirms a clean apply and returns the preview", async () => {
76
    const plugin = load();
77
    const out = await call(plugin, {
78
      diff: DIFF,
79
      content: "alpha\nbeta\ngamma\ndelta\nepsilon\n",
80
      include_preview: true,
81
    });
82
83
    expect(out.applies).toBe(true);
84
    expect(out.applied_hunks).toBe(1);
85
    expect(out.failed_hunks).toBe(0);
86
    expect(out.hunks).toHaveLength(1);
87
    expect(out.hunks[0]).toMatchObject({ index: 0, applies: true, at_line: 2 });
88
    expect(out.hunks[0]?.drift_lines).toBeUndefined();
89
    expect(out.preview).toBe("alpha\nbeta\nGAMMA\ndelta\nepsilon\n");
90
    expect(out.preview_truncated).toBeUndefined();
91
  });
92
93
  it("finds a drifted hunk within fuzz and reports the signed offset", async () => {
94
    const plugin = load();
95
    const out = await call(plugin, {
96
      diff: DIFF,
97
      content: "extra one\nextra two\nextra three\nalpha\nbeta\ngamma\ndelta\nepsilon\n",
98
    });
99
100
    expect(out.applies).toBe(true);
101
    expect(out.hunks[0]).toMatchObject({ applies: true, at_line: 5, drift_lines: 3 });
102
  });
103
104
  it("reports a hunk whose context is gone without refusing", async () => {
105
    const plugin = load();
106
    const out = await call(plugin, {
107
      diff: DIFF,
108
      content: "the file\nwas rewritten\nentirely\n",
109
    });
110
111
    expect(out.applies).toBe(false);
112
    expect(out.applied_hunks).toBe(0);
113
    expect(out.failed_hunks).toBe(1);
114
    expect(out.hunks[0]?.reason).toBe("context_not_found");
115
    expect(out.hunks[0]?.mismatch).toBe("beta");
116
    expect(out.preview).toBeUndefined();
117
  });
118
119
  it("refuses a malformed diff with the refusal envelope, naming the shape", async () => {
120
    const plugin = load();
121
    const envelope = await invoke(plugin, {
122
      diff: "this is prose, not a patch",
123
      content: "alpha\n",
124
    });
125
126
    expect(envelope.ok).toBeUndefined();
127
    expect(envelope.refusal?.code).toBe("unsupported");
128
    expect(envelope.refusal?.reason).toContain("@@ -start,count +start,count @@");
129
  });
130
});
packages/openagents-cli/test/coder-plugin-repo-map.test.ts added +174

@@ -0,0 +1,174 @@

1
/**
2
 * The repository mapper through the real boundary: the checked-in
3
 * `repo_map` plugin against a staged polyglot fixture workspace. The
4
 * mapper's logic is unit-tested against a fake host in
5
 * `plugins/repo-map/src/tests.rs`; this file proves the same behavior
6
 * holds through the WASM sandbox — the heuristic outline, exact-name
7
 * definition lookup, and word-bounded reference counting, with the
8
 * `node_modules` skip list honored. The manifest's `${workspace}` mount
9
 * is rewritten to the fixture directory, as every sandbox test does; the
10
 * host-side `${workspace}` resolution is proven elsewhere.
11
 */
12
13
import { mkdirSync, mkdtempSync, copyFileSync, readFileSync, writeFileSync } from "node:fs";
14
import { tmpdir } from "node:os";
15
import { join } from "node:path";
16
import { fileURLToPath } from "node:url";
17
import { describe, expect, it } from "vitest";
18
19
import {
20
  invokePlugin,
21
  isRefusal,
22
  loadPluginFromManifest,
23
  type LoadedPlugin,
24
} from "../src/coder-plugins.js";
25
26
const MANIFEST = fileURLToPath(
27
  new URL("../../../plugins/repo-map/manifest.json", import.meta.url),
28
);
29
const WASM = fileURLToPath(
30
  new URL("../../../plugins/repo-map/repo_map.wasm", import.meta.url),
31
);
32
33
const stage = (): { plugin: LoadedPlugin } => {
34
  const dir = mkdtempSync(join(tmpdir(), "repo-map-"));
35
  const workspace = join(dir, "workspace");
36
37
  mkdirSync(join(workspace, "src"), { recursive: true });
38
  mkdirSync(join(workspace, "lib"), { recursive: true });
39
  mkdirSync(join(workspace, "node_modules", "pkg"), { recursive: true });
40
41
  writeFileSync(
42
    join(workspace, "src", "main.py"),
43
    "class Greeter:\n    def greet(self):\n        return greet_all()\n\ndef greet_all():\n    pass\n",
44
  );
45
  writeFileSync(
46
    join(workspace, "src", "app.ts"),
47
    "export function greetAll(): void {}\nexport class App {}\nexport const run = () => greetAll();\n",
48
  );
49
  writeFileSync(
50
    join(workspace, "src", "map.rs"),
51
    "pub fn greet_all() -> u32 { 0 }\npub fn twice() -> u32 { greet_all() + greet_all() }\nfn greet_all_extra() {}\n",
52
  );
53
  writeFileSync(
54
    join(workspace, "lib", "demo.ex"),
55
    "defmodule Demo do\n  def greet_all do\n    :ok\n  end\nend\n",
56
  );
57
  writeFileSync(join(workspace, "node_modules", "pkg", "index.js"), "function hidden() {}\n");
58
59
  const manifest = JSON.parse(readFileSync(MANIFEST, "utf8")) as {
60
    capabilities: { mounts: Array<{ path: string; readonly: true }> };
61
  };
62
  manifest.capabilities.mounts = [{ path: workspace, readonly: true }];
63
  writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
64
  copyFileSync(WASM, join(dir, "repo_map.wasm"));
65
66
  const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
67
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
68
  return { plugin: outcome };
69
};
70
71
type Symbol = { kind: string; name: string; line: number; parent?: string };
72
type Output = {
73
  files?: Array<{ path: string; language: string; symbols: Symbol[] | null }>;
74
  definitions?: Array<{ path: string; kind: string; line: number }>;
75
  references?: Array<{ path: string; count: number }>;
76
  total?: number;
77
  files_seen: number;
78
  files_parsed: number;
79
  oversized: number;
80
  unreadable: number;
81
  truncated: boolean;
82
};
83
84
/** The guest envelope: `ok` on success, `refusal` as a value otherwise. */
85
type Envelope = { ok?: Output; refusal?: { code: string; reason: string } };
86
87
const invoke = async (plugin: LoadedPlugin, input: Record<string, unknown>): Promise<Envelope> => {
88
  const packet = new TextEncoder().encode(JSON.stringify(input));
89
  const outcome = await invokePlugin(plugin, packet);
90
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
91
  return JSON.parse(new TextDecoder().decode(outcome)) as Envelope;
92
};
93
94
const call = async (plugin: LoadedPlugin, input: Record<string, unknown>): Promise<Output> => {
95
  const envelope = await invoke(plugin, input);
96
  if (envelope.ok === undefined) throw new Error(JSON.stringify(envelope.refusal));
97
  return envelope.ok;
98
};
99
100
describe("the repo_map plugin through the sandbox", () => {
101
  it("outlines the workspace per language and skips node_modules", async () => {
102
    const { plugin } = stage();
103
    const out = await call(plugin, {});
104
105
    const paths = (out.files ?? []).map((file) => file.path);
106
    expect(paths).toEqual(["lib/demo.ex", "src/app.ts", "src/main.py", "src/map.rs"]);
107
    expect(paths.some((path) => path.includes("node_modules"))).toBe(false);
108
109
    const byPath = new Map((out.files ?? []).map((file) => [file.path, file]));
110
    expect(byPath.get("src/main.py")?.language).toBe("python");
111
    expect(
112
      byPath.get("src/main.py")?.symbols?.map((s) => [s.kind, s.name, s.line]),
113
    ).toEqual([
114
      ["class", "Greeter", 1],
115
      ["method", "greet", 2],
116
      ["function", "greet_all", 5],
117
    ]);
118
    expect(byPath.get("src/main.py")?.symbols?.[1]?.parent).toBe("Greeter");
119
    expect(
120
      byPath.get("src/app.ts")?.symbols?.map((s) => [s.kind, s.name]),
121
    ).toEqual([
122
      ["function", "greetAll"],
123
      ["class", "App"],
124
      ["function", "run"],
125
    ]);
126
    expect(
127
      byPath.get("lib/demo.ex")?.symbols?.map((s) => [s.kind, s.name]),
128
    ).toEqual([
129
      ["module", "Demo"],
130
      ["function", "greet_all"],
131
    ]);
132
    expect(
133
      byPath.get("src/map.rs")?.symbols?.map((s) => s.name),
134
    ).toEqual(["greet_all", "twice", "greet_all_extra"]);
135
136
    expect(out.files_seen).toBe(4);
137
    expect(out.files_parsed).toBe(4);
138
    expect(out.oversized).toBe(0);
139
    expect(out.truncated).toBe(false);
140
  });
141
142
  it("looks a definition up by exact name across languages", async () => {
143
    const { plugin } = stage();
144
    const out = await call(plugin, { symbol: "greet_all" });
145
146
    expect(out.definitions).toEqual([
147
      { path: "lib/demo.ex", kind: "function", line: 2 },
148
      { path: "src/main.py", kind: "function", line: 5 },
149
      { path: "src/map.rs", kind: "function", line: 1 },
150
    ]);
151
  });
152
153
  it("counts whole-word references, skipping definition lines", async () => {
154
    const { plugin } = stage();
155
    const out = await call(plugin, { symbol: "greet_all", count_references: true });
156
157
    // map.rs: two calls in `twice`; main.py: one call in `greet`. The
158
    // definitions and `greet_all_extra` never count.
159
    expect(out.references).toEqual([
160
      { path: "src/map.rs", count: 2 },
161
      { path: "src/main.py", count: 1 },
162
    ]);
163
    expect(out.total).toBe(3);
164
  });
165
166
  it("refuses to count references without a symbol", async () => {
167
    const { plugin } = stage();
168
    const envelope = await invoke(plugin, { count_references: true });
169
170
    expect(envelope.ok).toBeUndefined();
171
    expect(envelope.refusal?.code).toBe("unsupported");
172
    expect(envelope.refusal?.reason).toContain("symbol");
173
  });
174
});
packages/openagents-cli/test/coder-plugin-repo-tree.test.ts added +169

@@ -0,0 +1,169 @@

1
/**
2
 * The `repo_tree` plugin through the real boundary, plus the parameterized
3
 * workspace mount it rides on.
4
 *
5
 * The tree walker's logic — the gitignore subset, the ceilings, the query
6
 * ranking — is unit-tested against a fake host in
7
 * `plugins/repo-tree/src/tests.rs`; this file proves the same behavior
8
 * holds through the WASM sandbox against a staged fixture workspace, and
9
 * that the checked-in manifest's `${workspace}` mount
10
 * (OpenAgentsInc/openagents#44) resolves to the load-time working
11
 * directory.
12
 */
13
14
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
15
import { realpathSync } from "node:fs";
16
import { tmpdir } from "node:os";
17
import { dirname, join } from "node:path";
18
import { fileURLToPath } from "node:url";
19
import { describe, expect, it } from "vitest";
20
21
import {
22
  invokePlugin,
23
  isRefusal,
24
  loadPluginFromManifest,
25
  type LoadedPlugin,
26
} from "../src/coder-plugins.js";
27
28
const MANIFEST = fileURLToPath(
29
  new URL("../../../plugins/repo-tree/manifest.json", import.meta.url),
30
);
31
const WASM = fileURLToPath(new URL("../../../plugins/repo-tree/repo_tree.wasm", import.meta.url));
32
33
/**
34
 * Stage a fixture workspace: real files, a root `.gitignore` with a
35
 * negation, a nested ignored directory, a nested `.gitignore`, and a
36
 * `.git` directory that must never appear.
37
 */
38
const stage = (): { plugin: LoadedPlugin; root: string } => {
39
  const dir = mkdtempSync(join(tmpdir(), "repo-tree-"));
40
  const root = join(dir, "workspace");
41
42
  const write = (relative: string, body: string): void => {
43
    const path = join(root, relative);
44
    mkdirSync(dirname(path), { recursive: true });
45
    writeFileSync(path, body);
46
  };
47
  write(".gitignore", "# artifacts\n*.log\nbuild/\n!keep.log\n");
48
  write(".git/config", "[core]");
49
  write(".git/HEAD", "ref: refs/heads/main");
50
  write("README.md", "hello");
51
  write("build/out.o", "object code");
52
  write("carbon.log", "dropped by *.log");
53
  write("keep.log", "the negation cannot save it");
54
  write("lib/auth_controller.ex", "defmodule Auth do end");
55
  write("lib/nested/.gitignore", "*.tmp\n");
56
  write("lib/nested/junk.tmp", "dropped by the nested ignore");
57
  write("lib/nested/real.ex", "kept");
58
59
  const manifest = JSON.parse(readFileSync(MANIFEST, "utf8")) as {
60
    capabilities: { mounts: Array<{ path: string; readonly: true }> };
61
  };
62
  manifest.capabilities.mounts = [{ path: root, readonly: true }];
63
  writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
64
  copyFileSync(WASM, join(dir, "repo_tree.wasm"));
65
66
  const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
67
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
68
  return { plugin: outcome, root };
69
};
70
71
type TreeOutput = {
72
  entries: Array<{ path: string; kind: string; size: number }>;
73
  total_seen: number;
74
  truncated: boolean;
75
  ignored_negations: number;
76
  skipped_gitignored: number;
77
};
78
79
type QueryOutput = {
80
  matches: Array<{ path: string; size: number }>;
81
  searched: number;
82
  truncated: boolean;
83
};
84
85
type Envelope<O> = { ok?: O; refusal?: { code: string; reason: string } };
86
87
const call = async <O>(plugin: LoadedPlugin, input: Record<string, unknown>): Promise<O> => {
88
  const packet = new TextEncoder().encode(JSON.stringify(input));
89
  const outcome = await invokePlugin(plugin, packet);
90
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
91
  const envelope = JSON.parse(new TextDecoder().decode(outcome)) as Envelope<O>;
92
  if (envelope.ok === undefined) throw new Error(JSON.stringify(envelope.refusal));
93
  return envelope.ok;
94
};
95
96
describe("the repo_tree plugin through the sandbox", () => {
97
  it("walks the tree honoring gitignore and never shows .git", async () => {
98
    const { plugin } = stage();
99
    const out = await call<TreeOutput>(plugin, {});
100
101
    const paths = out.entries.map((entry) => entry.path);
102
    expect(paths).toEqual([
103
      ".gitignore",
104
      "README.md",
105
      "lib",
106
      "lib/auth_controller.ex",
107
      "lib/nested",
108
      "lib/nested/.gitignore",
109
      "lib/nested/real.ex",
110
    ]);
111
    // `build/` (directory rule), both `.log` files (the negation is
112
    // ignored and counted), and the nested `.tmp` were dropped; `.git`
113
    // was never even seen.
114
    expect(out.skipped_gitignored).toBe(4);
115
    expect(out.ignored_negations).toBe(1);
116
    expect(out.truncated).toBe(false);
117
    expect(out.entries.find((entry) => entry.path === "README.md")?.kind).toBe("file");
118
    expect(out.entries.find((entry) => entry.path === "README.md")?.size).toBe(5);
119
    expect(out.entries.find((entry) => entry.path === "lib")?.kind).toBe("dir");
120
  });
121
122
  it("caps the entries and reports the truncation", async () => {
123
    const { plugin } = stage();
124
    const out = await call<TreeOutput>(plugin, { max_entries: 2 });
125
126
    expect(out.entries).toHaveLength(2);
127
    expect(out.truncated).toBe(true);
128
  });
129
130
  it("answers a fuzzy query with ranked matches instead of the tree", async () => {
131
    const { plugin } = stage();
132
    const out = await call<QueryOutput>(plugin, { query: "AuthController" });
133
134
    expect(out.matches.map((match) => match.path)).toEqual(["lib/auth_controller.ex"]);
135
    // Every non-ignored file was searched; the gitignored and `.git`
136
    // files never entered the candidate set.
137
    expect(out.searched).toBe(5);
138
    expect(out.truncated).toBe(false);
139
  });
140
});
141
142
describe("the parameterized workspace mount (OpenAgentsInc/openagents#44)", () => {
143
  it("loads the checked-in manifest with `${workspace}` resolved to the load-time cwd", async () => {
144
    // The checked-in manifest, untouched: its one mount is the literal
145
    // `${workspace}`.
146
    const raw = JSON.parse(readFileSync(MANIFEST, "utf8")) as {
147
      capabilities: { mounts: Array<{ path: string }> };
148
    };
149
    expect(raw.capabilities.mounts.map((mount) => mount.path)).toEqual(["${workspace}"]);
150
151
    const outcome = loadPluginFromManifest(MANIFEST);
152
    if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
153
154
    // The resolved mount is the process working directory — the confined
155
    // root every read and listing goes through — not the manifest's
156
    // directory and not a literal `${workspace}` path.
157
    expect(outcome.mounts).toEqual([realpathSync(process.cwd())]);
158
159
    // And the plugin really reads through it: a depth-1 tree of the
160
    // load-time cwd (this package) names this package's own files.
161
    const out = await call<TreeOutput>(outcome, { max_depth: 1 });
162
    const paths = out.entries.map((entry) => entry.path);
163
    expect(paths).toContain("package.json");
164
    for (const path of paths) {
165
      expect(path.startsWith("/")).toBe(false);
166
      expect(path.startsWith("..")).toBe(false);
167
    }
168
  });
169
});
packages/openagents-cli/test/coder-plugin-session-search.test.ts added +173

@@ -0,0 +1,173 @@

1
/**
2
 * The session searcher through the real boundary: the checked-in
3
 * `session_search` plugin against staged fixture trees shaped like
4
 * `~/.claude` and `~/.codex`. The searcher's logic is unit-tested against
5
 * a fake host in `plugins/session-search/src/tests.rs`; this file proves
6
 * the same behavior holds through the WASM sandbox — a phrase found across
7
 * both sources with bounded context, a miss returning empty matches, and a
8
 * blank query coming back as a refusal envelope rather than a trap.
9
 */
10
11
import { mkdirSync, mkdtempSync, copyFileSync, readFileSync, utimesSync, writeFileSync } from "node:fs";
12
import { tmpdir } from "node:os";
13
import { join } from "node:path";
14
import { fileURLToPath } from "node:url";
15
import { describe, expect, it } from "vitest";
16
17
import {
18
  invokePlugin,
19
  isRefusal,
20
  loadPluginFromManifest,
21
  type LoadedPlugin,
22
} from "../src/coder-plugins.js";
23
24
const MANIFEST = fileURLToPath(
25
  new URL("../../../plugins/session-search/manifest.json", import.meta.url),
26
);
27
const WASM = fileURLToPath(
28
  new URL("../../../plugins/session-search/session_search.wasm", import.meta.url),
29
);
30
31
const NOW_MS = Date.now();
32
const DAY_MS = 86_400_000;
33
34
const touch = (path: string, mtimeMs: number): void => {
35
  utimesSync(path, new Date(mtimeMs), new Date(mtimeMs));
36
};
37
38
const claudeLine = (cwd: string, id: string, role: string, text: string): string =>
39
  JSON.stringify({
40
    type: role,
41
    cwd,
42
    sessionId: id,
43
    message: { role, content: text },
44
  }) + "\n";
45
46
const codexLine = (payload: Record<string, unknown>): string =>
47
  JSON.stringify({ type: "response_item", payload }) + "\n";
48
49
const stage = (): { plugin: LoadedPlugin } => {
50
  const dir = mkdtempSync(join(tmpdir(), "session-search-"));
51
  const claudeRoot = join(dir, "dot-claude");
52
  const codexRoot = join(dir, "dot-codex");
53
54
  const project = join(claudeRoot, "projects", "-Users-ada-work-alpha");
55
  mkdirSync(project, { recursive: true });
56
  const filler = "the build is green and nothing else happened here ".repeat(20);
57
  writeFileSync(
58
    join(project, "cafe.jsonl"),
59
    claudeLine("/Users/ada/work/alpha", "cafe", "user", `${filler}the flux capacitor is rattling${filler}`) +
60
      claudeLine("/Users/ada/work/alpha", "cafe", "assistant", "tighten its bolts"),
61
  );
62
  touch(join(project, "cafe.jsonl"), NOW_MS - DAY_MS);
63
64
  const day = join(codexRoot, "sessions", "2026", "08", "20");
65
  mkdirSync(day, { recursive: true });
66
  const rollout = join(day, "rollout-2026-08-20T10-00-00-beef.jsonl");
67
  writeFileSync(
68
    rollout,
69
    JSON.stringify({ type: "session_meta", payload: { id: "beef", cwd: "/Users/ada/work/beta" } }) +
70
      "\n" +
71
      codexLine({
72
        type: "message",
73
        role: "user",
74
        content: [{ type: "input_text", text: "why does the Flux Capacitor overheat?" }],
75
      }) +
76
      codexLine({
77
        type: "message",
78
        role: "assistant",
79
        content: [{ type: "output_text", text: "it needs coolant" }],
80
      }),
81
  );
82
  touch(rollout, NOW_MS - 2 * DAY_MS);
83
84
  const manifest = JSON.parse(readFileSync(MANIFEST, "utf8")) as {
85
    capabilities: { mounts: Array<{ path: string; readonly: true }> };
86
  };
87
  manifest.capabilities.mounts = [
88
    { path: claudeRoot, readonly: true },
89
    { path: codexRoot, readonly: true },
90
  ];
91
  writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
92
  copyFileSync(WASM, join(dir, "session_search.wasm"));
93
94
  const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
95
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
96
  return { plugin: outcome };
97
};
98
99
type Output = {
100
  query: string;
101
  sessions_searched: number;
102
  sessions_matched: number;
103
  matches: Array<{
104
    source: string;
105
    session_id: string;
106
    hits: Array<{ role: string; context: string }>;
107
    hits_total: number;
108
  }>;
109
  truncated: boolean;
110
  skipped_unreadable: number;
111
};
112
113
const packet = (input: Record<string, unknown>): Uint8Array =>
114
  new TextEncoder().encode(JSON.stringify({ now_ms: NOW_MS, ...input }));
115
116
/** The guest envelope: `ok` on success, `refusal` as a value otherwise. */
117
type Envelope = { ok?: Output; refusal?: { code: string; reason: string } };
118
119
const invoke = async (plugin: LoadedPlugin, input: Record<string, unknown>): Promise<Envelope> => {
120
  const outcome = await invokePlugin(plugin, packet(input));
121
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
122
  return JSON.parse(new TextDecoder().decode(outcome)) as Envelope;
123
};
124
125
const call = async (plugin: LoadedPlugin, input: Record<string, unknown>): Promise<Output> => {
126
  const envelope = await invoke(plugin, input);
127
  if (envelope.ok === undefined) throw new Error(JSON.stringify(envelope.refusal));
128
  return envelope.ok;
129
};
130
131
describe("the session_search plugin through the sandbox", () => {
132
  it("finds a phrase across both stores with bounded context", async () => {
133
    const { plugin } = stage();
134
    const out = await call(plugin, { query: "flux capacitor", context_chars: 80 });
135
136
    expect(out.sessions_searched).toBe(2);
137
    expect(out.sessions_matched).toBe(2);
138
    expect(out.matches.map((m) => [m.source, m.session_id])).toEqual([
139
      ["claude", "cafe"],
140
      ["codex", "beef"],
141
    ]);
142
143
    const claude = out.matches[0];
144
    expect(claude.hits[0].role).toBe("user");
145
    expect(claude.hits[0].context).toContain("flux capacitor");
146
    // The hit sits mid-turn inside long filler, so the window is elided on
147
    // both sides and stays near the character budget.
148
    expect(claude.hits[0].context.startsWith("…")).toBe(true);
149
    expect(claude.hits[0].context.endsWith("…")).toBe(true);
150
    expect(claude.hits[0].context.length).toBeLessThan(200);
151
152
    // Case-insensitive: the Codex turn spells it "Flux Capacitor".
153
    const codex = out.matches[1];
154
    expect(codex.hits[0].context).toContain("Flux Capacitor");
155
  });
156
157
  it("returns empty matches for a phrase nobody said", async () => {
158
    const { plugin } = stage();
159
    const out = await call(plugin, { query: "perpetual motion machine" });
160
161
    expect(out.sessions_searched).toBe(2);
162
    expect(out.sessions_matched).toBe(0);
163
    expect(out.matches).toEqual([]);
164
  });
165
166
  it("refuses a blank query as a refusal envelope", async () => {
167
    const { plugin } = stage();
168
    const envelope = await invoke(plugin, { query: "   " });
169
170
    expect(envelope.ok).toBeUndefined();
171
    expect(envelope.refusal?.reason).toContain("query");
172
  });
173
});
packages/openagents-cli/test/coder-ui.test.ts modified +2 -2

@@ -1006,7 +1006,7 @@ describe("where a running child is shown", () => {

1006 1006
      registerChild(registry);
1007 1007
    });
1008 1008
1009
    const heading = rows.find((row) => row.includes("children"));
1009
    const heading = rows.find((row) => row.includes("subagents"));
1010 1010
    expect(heading).toBeDefined();
1011 1011
    expect(heading).toContain("1 working");
1012 1012

@@ -1047,7 +1047,7 @@ describe("where a running child is shown", () => {

1047 1047
      registerChild(registry);
1048 1048
    }, 80);
1049 1049
1050
    expect(rows.some((row) => row.includes("children"))).toBe(false);
1050
    expect(rows.some((row) => row.includes("subagents"))).toBe(false);
1051 1051
1052 1052
    const delegateRow = rows.findIndex((row) => row.includes("delegate"));
1053 1053
    expect(delegateRow).toBeGreaterThan(0);
plugins/Cargo.lock modified +55

@@ -33,6 +33,15 @@ dependencies = [

33 33
 "serde_json",
34 34
]
35 35
36
[[package]]
37
name = "git-facts"
38
version = "0.1.0"
39
dependencies = [
40
 "openagents-pdk",
41
 "serde",
42
 "serde_json",
43
]
44
36 45
[[package]]
37 46
name = "git-lost-work"
38 47
version = "0.1.0"

@@ -49,6 +58,15 @@ version = "1.0.18"

49 58
source = "registry+https://github.com/rust-lang/crates.io-index"
50 59
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
51 60
61
[[package]]
62
name = "knowledge-base"
63
version = "0.1.0"
64
dependencies = [
65
 "openagents-pdk",
66
 "serde",
67
 "serde_json",
68
]
69
52 70
[[package]]
53 71
name = "memchr"
54 72
version = "2.8.3"

@@ -72,6 +90,15 @@ dependencies = [

72 90
 "serde_json",
73 91
]
74 92
93
[[package]]
94
name = "patch-check"
95
version = "0.1.0"
96
dependencies = [
97
 "openagents-pdk",
98
 "serde",
99
 "serde_json",
100
]
101
75 102
[[package]]
76 103
name = "proc-macro2"
77 104
version = "1.0.107"

@@ -100,6 +127,24 @@ dependencies = [

100 127
 "serde_json",
101 128
]
102 129
130
[[package]]
131
name = "repo-map"
132
version = "0.1.0"
133
dependencies = [
134
 "openagents-pdk",
135
 "serde",
136
 "serde_json",
137
]
138
139
[[package]]
140
name = "repo-tree"
141
version = "0.1.0"
142
dependencies = [
143
 "openagents-pdk",
144
 "serde",
145
 "serde_json",
146
]
147
103 148
[[package]]
104 149
name = "serde"
105 150
version = "1.0.229"

@@ -143,6 +188,16 @@ dependencies = [

143 188
 "zmij",
144 189
]
145 190
191
[[package]]
192
name = "session-search"
193
version = "0.1.0"
194
dependencies = [
195
 "foreign-sessions",
196
 "openagents-pdk",
197
 "serde",
198
 "serde_json",
199
]
200
146 201
[[package]]
147 202
name = "syn"
148 203
version = "3.0.4"
plugins/Cargo.toml modified +15 -1

@@ -13,7 +13,21 @@

13 13
14 14
[workspace]
15 15
resolver = "2"
16
members = ["pdk", "word-stats", "file-stats", "dir-stats", "foreign-sessions", "git-lost-work", "read-conversation"]
16
members = [
17
  "pdk",
18
  "word-stats",
19
  "file-stats",
20
  "dir-stats",
21
  "foreign-sessions",
22
  "git-lost-work",
23
  "read-conversation",
24
  "repo-tree",
25
  "repo-map",
26
  "git-facts",
27
  "session-search",
28
  "patch-check",
29
  "knowledge-base",
30
]
17 31
18 32
[workspace.package]
19 33
edition = "2021"
plugins/foreign-sessions/src/lib.rs modified +306 -4

@@ -1,7 +1,9 @@

1 1
//! Foreign coding-agent session discovery, as a `packet-v0` guest plugin.
2 2
//!
3 3
//! The scanner half of OpenAgentsInc/openagents.com#198: given read-only
4
//! mounts over `~/.claude` (mount 0) and `~/.codex` (mount 1), report
4
//! mounts over `~/.claude` (mount 0), `~/.codex` (mount 1), the opencode
5
//! state directory (mount 2, SQLite-backed), and Devin's ACP message
6
//! databases (mount 3, SQLite-backed; OpenAgentsInc/openagents#48), report
5 7
//! recent session *metadata* — source, session id, working directory,
6 8
//! mtime, size, record count — and nothing else. Resuming a session is
7 9
//! deliberately not here; this plugin only says what exists.

@@ -20,12 +22,38 @@

20 22
21 23
#[cfg(feature = "entry")]
22 24
use openagents_pdk::plugin_entry;
23
use openagents_pdk::{list_mounted_dir, read_mounted_file, MountDirListing, Refusal, RefusalCode};
25
#[cfg(feature = "entry")]
26
use openagents_pdk::{list_mounted_dir, read_mounted_file, read_mounted_file_range};
27
use openagents_pdk::{MountDirListing, Refusal, RefusalCode};
24 28
use serde::{Deserialize, Serialize};
25 29
30
pub mod sqlite;
31
26 32
/// Mount indices, fixed by the order `manifest.json` declares the mounts.
33
/// This order is a frozen contract: 0 and 1 are forever `~/.claude` and
34
/// `~/.codex`; new stores only ever append.
27 35
const CLAUDE_MOUNT: u32 = 0;
28 36
const CODEX_MOUNT: u32 = 1;
37
/// Mount 2: `~/.local/share/opencode`, whose `opencode.db` SQLite file
38
/// holds every opencode session.
39
const OPENCODE_MOUNT: u32 = 2;
40
/// Mount 3: Devin's per-session ACP message databases
41
/// (`~/Library/Application Support/devin/User/acp-messages`).
42
const DEVIN_MOUNT: u32 = 3;
43
44
/// The opencode store's database file, relative to its mount root. Sessions
45
/// found inside it are reported with the path convention
46
/// `opencode.db#<session-id>`, since many sessions share the one file.
47
const OPENCODE_DB: &str = "opencode.db";
48
/// Page reads allowed while scanning the opencode session table.
49
const OPENCODE_SCAN_PAGE_BUDGET: usize = 16_384;
50
/// Per-row payload cap for session rows (`summary_diffs` can be large and
51
/// sits before `time_updated` on disk).
52
const OPENCODE_ROW_CAP: usize = 262_144;
53
/// Page reads allowed per Devin database while reading its `meta` table.
54
const DEVIN_META_PAGE_BUDGET: usize = 64;
55
/// Most Devin databases opened for metadata in one scan.
56
const MAX_DEVIN_DBS: usize = 100;
29 57
30 58
const DEFAULT_MAX_AGE_DAYS: f64 = 30.0;
31 59
const DEFAULT_LIMIT: usize = 50;

@@ -82,6 +110,15 @@ pub struct Session {

82 110
    /// the directory listing's metadata is known.
83 111
    #[serde(skip_serializing_if = "std::ops::Not::not")]
84 112
    pub metadata_truncated: bool,
113
    /// The session's title, where the store records one (opencode, devin).
114
    #[serde(skip_serializing_if = "Option::is_none")]
115
    pub title: Option<String>,
116
    /// True when the store's SQLite write-ahead log held bytes at scan
117
    /// time. This scanner never parses the WAL, so rows not yet
118
    /// checkpointed — the freshest activity — may be missing from what it
119
    /// reports; the flag discloses that instead of staying silent.
120
    #[serde(skip_serializing_if = "std::ops::Not::not")]
121
    pub wal_unread: bool,
85 122
}
86 123
87 124
#[derive(Debug, Default, Serialize, PartialEq, Eq)]

@@ -119,6 +156,15 @@ pub struct Output {

119 156
pub trait Host {
120 157
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal>;
121 158
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal>;
159
    /// Read a bounded byte range of a mounted file — the random access the
160
    /// SQLite-backed stores (opencode, devin) need. Defaulted to a refusal
161
    /// so hosts written before this method keep compiling unchanged; such a
162
    /// host simply cannot serve the SQLite-backed sources, and their scans
163
    /// fail soft.
164
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal> {
165
        let _ = (path, offset, max_bytes);
166
        Err(Refusal::unsupported("this host has no bounded range read"))
167
    }
122 168
}
123 169
124 170
#[cfg(feature = "entry")]

@@ -132,6 +178,9 @@ impl Host for RealHost {

132 178
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
133 179
        read_mounted_file(path)
134 180
    }
181
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal> {
182
        read_mounted_file_range(path, offset, max_bytes)
183
    }
135 184
}
136 185
137 186
/// One file the listing pass found, before its bytes are inspected.

@@ -209,6 +258,9 @@ fn stem(name: &str) -> String {

209 258
210 259
/// The whole scan, over any [`Host`]. Total: every path returns an output.
211 260
pub fn scan(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {
261
    // `claude` and `codex` are the defaults; the SQLite-backed stores are
262
    // scanned when asked for by name, so callers and dependents written
263
    // against the two-source contract see exactly what they always did.
212 264
    let sources = match &input.sources {
213 265
        None => vec!["claude", "codex"],
214 266
        Some(named) => {

@@ -217,9 +269,11 @@ pub fn scan(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {

217 269
                match name.as_str() {
218 270
                    "claude" => sources.push("claude"),
219 271
                    "codex" => sources.push("codex"),
272
                    "opencode" => sources.push("opencode"),
273
                    "devin" => sources.push("devin"),
220 274
                    other => {
221 275
                        return Err(Refusal::unsupported(format!(
222
                            "unknown source `{other}`; this scanner knows `claude` and `codex`"
276
                            "unknown source `{other}`; this scanner knows `claude`, `codex`, `opencode`, and `devin`"
223 277
                        )))
224 278
                    }
225 279
                }

@@ -244,6 +298,12 @@ pub fn scan(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {

244 298
        read_budget_exhausted: false,
245 299
    };
246 300
    let mut candidates: Vec<Candidate> = Vec::new();
301
    // Sessions from the SQLite-backed stores, fully described at listing
302
    // time; filtered and merged with the file-backed picks at the end.
303
    let mut extra: Vec<Session> = Vec::new();
304
    // Devin databases found by listing, opened for metadata only after the
305
    // age cutoff has pruned them.
306
    let mut devin_cands: Vec<DevinCandidate> = Vec::new();
247 307
    let mut dir_lists = 0usize;
248 308
249 309
    // A listing whose store directory is absent means the source is not on

@@ -375,6 +435,67 @@ pub fn scan(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {

375 435
                    }
376 436
                }
377 437
            }
438
            "opencode" => {
439
                // ~/.local/share/opencode/opencode.db — one SQLite file
440
                // holding every session; each row of its `session` table is
441
                // one session, reported as `opencode.db#<id>`.
442
                let Some(root) = list(&mut out, OPENCODE_MOUNT, "") else {
443
                    out.missing_sources.push("opencode");
444
                    continue;
445
                };
446
                let Some(db) =
447
                    root.entries.iter().find(|e| e.name == OPENCODE_DB && e.kind == "file")
448
                else {
449
                    if root.entries.iter().any(|e| e.name == OPENCODE_DB && e.kind == "symlink") {
450
                        out.skipped.symlinked += 1;
451
                    }
452
                    out.missing_sources.push("opencode");
453
                    continue;
454
                };
455
                let wal_unread = has_live_wal(&root, OPENCODE_DB);
456
                out.scanned_files += 1;
457
                let file = sqlite::MountedFile { host, path: OPENCODE_DB.to_string() };
458
                match sqlite::Sqlite::open(&file, OPENCODE_SCAN_PAGE_BUDGET) {
459
                    Err(_) => out.skipped.malformed += 1,
460
                    Ok(mut reader) => {
461
                        match opencode_sessions(&mut reader, wal_unread, db.size) {
462
                            Ok((sessions, malformed)) => {
463
                                out.skipped.malformed += malformed;
464
                                extra.extend(sessions);
465
                            }
466
                            Err(_) => out.skipped.malformed += 1,
467
                        }
468
                        if reader.budget_exhausted {
469
                            out.scan_truncated = true;
470
                        }
471
                    }
472
                }
473
            }
474
            "devin" => {
475
                // Devin's per-session ACP message databases: one `<uuid>.db`
476
                // SQLite file per session in the mount root. Listing names
477
                // the candidates; each survivor of the age cutoff is opened
478
                // below for its `meta` table (title, message count).
479
                let Some(root) = list(&mut out, DEVIN_MOUNT, "") else {
480
                    out.missing_sources.push("devin");
481
                    continue;
482
                };
483
                for entry in &root.entries {
484
                    if entry.kind == "symlink" {
485
                        out.skipped.symlinked += 1;
486
                        continue;
487
                    }
488
                    if entry.kind != "file" || !entry.name.ends_with(".db") {
489
                        continue;
490
                    }
491
                    devin_cands.push(DevinCandidate {
492
                        name: entry.name.clone(),
493
                        mtime_ms: entry.mtime_ms,
494
                        size_bytes: entry.size,
495
                        wal_unread: has_live_wal(&root, &entry.name),
496
                    });
497
                }
498
            }
378 499
            _ => unreachable!("sources were validated above"),
379 500
        }
380 501
    }

@@ -382,12 +503,59 @@ pub fn scan(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {

382 503
    // The age cutoff: `now` is the caller's clock, or the newest thing seen.
383 504
    let now_ms = input
384 505
        .now_ms
385
        .or_else(|| candidates.iter().map(|c| c.mtime_ms).max())
506
        .or_else(|| {
507
            candidates
508
                .iter()
509
                .map(|c| c.mtime_ms)
510
                .chain(extra.iter().map(|s| s.mtime_ms))
511
                .chain(devin_cands.iter().map(|c| c.mtime_ms))
512
                .max()
513
        })
386 514
        .unwrap_or(0);
387 515
    let cutoff_ms = now_ms - (max_age_days * MS_PER_DAY) as i64;
388 516
    candidates.retain(|c| c.mtime_ms >= cutoff_ms);
389 517
    candidates.sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms).then(a.path.cmp(&b.path)));
390 518
519
    // Prune the SQLite-backed sessions by the same cutoff and cwd filter. A
520
    // Devin session's cwd is not recorded locally, so a cwd filter excludes
521
    // Devin rather than guessing — the same posture as an unreadable Codex
522
    // candidate's.
523
    extra.retain(|s| s.mtime_ms >= cutoff_ms);
524
    if let Some(filter) = input.cwd_filter.as_deref() {
525
        extra.retain(|s| match &s.cwd {
526
            Some(cwd) => cwd.contains(filter) || dashed(cwd).contains(&dashed(filter)),
527
            None => false,
528
        });
529
        devin_cands.clear();
530
    }
531
    devin_cands.retain(|c| c.mtime_ms >= cutoff_ms);
532
    devin_cands.sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms).then(a.name.cmp(&b.name)));
533
    devin_cands.truncate(MAX_DEVIN_DBS.min(limit));
534
    for cand in &devin_cands {
535
        out.scanned_files += 1;
536
        let file = sqlite::MountedFile { host, path: cand.name.clone() };
537
        let meta = sqlite::Sqlite::open(&file, DEVIN_META_PAGE_BUDGET)
538
            .and_then(|mut reader| devin_meta(&mut reader));
539
        let (title, record_count, metadata_truncated) = match meta {
540
            Ok((title, count)) => (title, count, false),
541
            // The listing still describes the session; say the rest is unknown.
542
            Err(_) => (None, None, true),
543
        };
544
        extra.push(Session {
545
            source: "devin",
546
            session_id: cand.name.strip_suffix(".db").unwrap_or(&cand.name).to_string(),
547
            path: cand.name.clone(),
548
            cwd: None,
549
            project_dir: None,
550
            mtime_ms: cand.mtime_ms,
551
            size_bytes: cand.size_bytes,
552
            record_count,
553
            metadata_truncated,
554
            title,
555
            wal_unread: cand.wal_unread,
556
        });
557
    }
558
391 559
    let dashed_filter = input.cwd_filter.as_deref().map(dashed);
392 560
    let mut reads = 0usize;
393 561

@@ -436,6 +604,8 @@ pub fn scan(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {

436 604
                    size_bytes: candidate.size_bytes,
437 605
                    record_count: Some(record_count),
438 606
                    metadata_truncated: false,
607
                    title: None,
608
                    wal_unread: false,
439 609
                });
440 610
            }
441 611
            Err(refusal) if refusal.code == RefusalCode::FileTooLarge => {

@@ -457,6 +627,8 @@ pub fn scan(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {

457 627
                    size_bytes: candidate.size_bytes,
458 628
                    record_count: None,
459 629
                    metadata_truncated: true,
630
                    title: None,
631
                    wal_unread: false,
460 632
                });
461 633
            }
462 634
            Err(_) => {

@@ -465,9 +637,139 @@ pub fn scan(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {

465 637
        }
466 638
    }
467 639
640
    // Merge the SQLite-backed sessions with the file-backed picks: one
641
    // newest-first list, bounded by the same limit.
642
    if !extra.is_empty() {
643
        out.sessions.extend(extra);
644
        out.sessions
645
            .sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms).then(a.path.cmp(&b.path)));
646
        out.sessions.truncate(limit);
647
    }
648
468 649
    Ok(out)
469 650
}
470 651
652
/// One Devin database the listing pass found, before it is opened.
653
struct DevinCandidate {
654
    name: String,
655
    mtime_ms: i64,
656
    size_bytes: u64,
657
    wal_unread: bool,
658
}
659
660
/// True when `<db_name>-wal` sits beside a database with bytes in it: rows
661
/// not yet checkpointed may exist that the reader cannot see.
662
fn has_live_wal(listing: &MountDirListing, db_name: &str) -> bool {
663
    let wal_name = format!("{db_name}-wal");
664
    listing
665
        .entries
666
        .iter()
667
        .any(|e| e.name == wal_name && e.kind == "file" && e.size > 0)
668
}
669
670
/// Every row of an opencode database's `session` table as a [`Session`],
671
/// plus how many rows were skipped as malformed. Column positions come from
672
/// the table's own `CREATE TABLE` statement — drizzle orders columns by
673
/// migration history, so the order is derived, never assumed.
674
fn opencode_sessions(
675
    reader: &mut sqlite::Sqlite,
676
    wal_unread: bool,
677
    db_size: u64,
678
) -> Result<(Vec<Session>, usize), Refusal> {
679
    let master = reader.master()?;
680
    let table = master
681
        .iter()
682
        .find(|e| e.kind == "table" && e.name == "session")
683
        .ok_or_else(|| Refusal::unsupported("the opencode database has no session table"))?;
684
    let column = |name: &str| table.columns.iter().position(|c| c == name);
685
    let (Some(id_at), Some(dir_at), Some(updated_at)) =
686
        (column("id"), column("directory"), column("time_updated"))
687
    else {
688
        return Err(Refusal::unsupported(
689
            "the opencode session table is missing expected columns",
690
        ));
691
    };
692
    let title_at = column("title");
693
694
    let mut sessions = Vec::new();
695
    let mut malformed = 0usize;
696
    let rootpage = table.rootpage;
697
    let outcome = reader.scan_table(rootpage, OPENCODE_ROW_CAP, &mut |_rowid, values| {
698
        let id = values.get(id_at).and_then(sqlite::Value::as_str);
699
        let mtime = values.get(updated_at).and_then(sqlite::Value::as_int);
700
        let (Some(id), Some(mtime)) = (id, mtime) else {
701
            malformed += 1;
702
            return true;
703
        };
704
        let cwd = values
705
            .get(dir_at)
706
            .and_then(sqlite::Value::as_str)
707
            .map(str::to_string);
708
        let title = title_at
709
            .and_then(|at| values.get(at))
710
            .and_then(sqlite::Value::as_str)
711
            .map(str::to_string);
712
        sessions.push(Session {
713
            source: "opencode",
714
            session_id: id.to_string(),
715
            path: format!("{OPENCODE_DB}#{id}"),
716
            cwd,
717
            project_dir: None,
718
            mtime_ms: mtime,
719
            size_bytes: db_size,
720
            record_count: None,
721
            metadata_truncated: false,
722
            title,
723
            wal_unread,
724
        });
725
        true
726
    });
727
    match outcome {
728
        Ok(()) => {}
729
        // An exhausted budget keeps what was gathered; the caller marks the
730
        // scan truncated. Anything else is real corruption.
731
        Err(_) if reader.budget_exhausted => {}
732
        Err(refusal) => return Err(refusal),
733
    }
734
    Ok((sessions, malformed))
735
}
736
737
/// A Devin database's `meta` table: the session title (from the `info`
738
/// JSON) and its recorded message count.
739
fn devin_meta(reader: &mut sqlite::Sqlite) -> Result<(Option<String>, Option<usize>), Refusal> {
740
    let master = reader.master()?;
741
    let table = master
742
        .iter()
743
        .find(|e| e.kind == "table" && e.name == "meta")
744
        .ok_or_else(|| Refusal::unsupported("the devin database has no meta table"))?;
745
    let column = |name: &str| table.columns.iter().position(|c| c == name);
746
    let (Some(key_at), Some(value_at)) = (column("key"), column("value")) else {
747
        return Err(Refusal::unsupported("the devin meta table is missing expected columns"));
748
    };
749
    let mut title = None;
750
    let mut count = None;
751
    reader.scan_table(table.rootpage, 65_536, &mut |_rowid, values| {
752
        let key = values.get(key_at).and_then(sqlite::Value::as_str);
753
        let value = values.get(value_at).and_then(sqlite::Value::as_str);
754
        let (Some(key), Some(value)) = (key, value) else {
755
            return true;
756
        };
757
        match key {
758
            "message_count" => count = value.parse::<usize>().ok(),
759
            "info" => {
760
                title = serde_json::from_str::<serde_json::Value>(value)
761
                    .ok()
762
                    .and_then(|info| {
763
                        info.get("title").and_then(|t| t.as_str()).map(str::to_string)
764
                    });
765
            }
766
            _ => {}
767
        }
768
        true
769
    })?;
770
    Ok((title, count))
771
}
772
471 773
/// Directory names in a listing, counting symlinks as skipped.
472 774
fn dirs_of<'l>(listing: &'l MountDirListing, skipped: &mut Skipped) -> Vec<&'l str> {
473 775
    let mut dirs = Vec::new();
plugins/foreign-sessions/src/sqlite.rs added +723

@@ -0,0 +1,723 @@

1
//! A minimal, read-only SQLite file reader over bounded range reads.
2
//!
3
//! The opencode CLI keeps its sessions in `~/.local/share/opencode/opencode.db`
4
//! and Devin keeps per-session ACP message databases; both are ordinary
5
//! SQLite files, and both can exceed the host's whole-file read bound. This
6
//! module reads them the way SQLite's format was designed to be read —
7
//! page at a time, through the bounded `read_file_range` import — without a
8
//! C-backed SQLite crate, which would not build for `wasm32-unknown-unknown`.
9
//!
10
//! Scope is deliberately narrow: the 100-byte header, table b-trees
11
//! (interior `0x05` / leaf `0x0d`), index b-trees (`0x02` / `0x0a`),
12
//! varints, record serial types, and overflow-page chains. UTF-8 text
13
//! encoding only. Nothing here writes, and the write-ahead log is *never*
14
//! parsed — a caller that sees a `-wal` file beside the database discloses
15
//! it instead (`wal_unread`), because rows not yet checkpointed are
16
//! invisible to this reader.
17
//!
18
//! The posture is the plugin family's: foreign bytes are untrusted input,
19
//! so every page number, offset, varint, and length is checked, every walk
20
//! is depth- and budget-bounded, and anything unexpected is a typed
21
//! [`Refusal`] — never a panic.
22
23
use openagents_pdk::Refusal;
24
use std::cmp::Ordering;
25
use std::collections::BTreeMap;
26
use std::rc::Rc;
27
28
/// Bytes at rest, addressable by bounded range reads. The real source is a
29
/// mounted file behind the host's `read_file_range` import; tests read from
30
/// an in-memory buffer.
31
pub trait ByteSource {
32
    fn read_at(&self, offset: u64, len: u32) -> Result<Vec<u8>, Refusal>;
33
}
34
35
impl ByteSource for [u8] {
36
    fn read_at(&self, offset: u64, len: u32) -> Result<Vec<u8>, Refusal> {
37
        let start = usize::try_from(offset).unwrap_or(usize::MAX).min(self.len());
38
        let end = start.saturating_add(len as usize).min(self.len());
39
        Ok(self[start..end].to_vec())
40
    }
41
}
42
43
/// A file inside one of the manifest's read-only mounts, as a [`ByteSource`].
44
/// The path is mount-relative, exactly as [`crate::Host::read_range`] takes it.
45
pub struct MountedFile<'a> {
46
    pub host: &'a dyn crate::Host,
47
    pub path: String,
48
}
49
50
impl ByteSource for MountedFile<'_> {
51
    fn read_at(&self, offset: u64, len: u32) -> Result<Vec<u8>, Refusal> {
52
        self.host.read_range(&self.path, offset, len)
53
    }
54
}
55
56
/// One decoded column value of a record.
57
#[derive(Debug, Clone, PartialEq)]
58
pub enum Value {
59
    Null,
60
    Int(i64),
61
    Float(f64),
62
    Text(String),
63
    Blob(Vec<u8>),
64
    /// A value the per-row payload cap cut short; carries the bytes that
65
    /// were within reach (possibly none), so a caller can still sniff a
66
    /// prefix before deciding to re-read the row with a larger cap.
67
    Truncated(Vec<u8>),
68
}
69
70
impl Value {
71
    pub fn as_str(&self) -> Option<&str> {
72
        match self {
73
            Value::Text(text) => Some(text),
74
            _ => None,
75
        }
76
    }
77
    pub fn as_int(&self) -> Option<i64> {
78
        match self {
79
            Value::Int(value) => Some(*value),
80
            _ => None,
81
        }
82
    }
83
}
84
85
/// One row of `sqlite_master`: a table or index, its root page, and the
86
/// column order parsed from its `CREATE` statement — parsed, not assumed,
87
/// because ORM-managed schemas order columns however their migrations did.
88
#[derive(Debug, Clone)]
89
pub struct MasterEntry {
90
    /// `"table"`, `"index"`, `"view"`, or `"trigger"`.
91
    pub kind: String,
92
    pub name: String,
93
    pub tbl_name: String,
94
    pub rootpage: u32,
95
    pub sql: String,
96
    /// Column names in on-disk record order (tables) or key order (indexes).
97
    pub columns: Vec<String>,
98
}
99
100
fn corrupt(reason: &str) -> Refusal {
101
    Refusal::unsupported(format!("sqlite: {reason}"))
102
}
103
104
const MAX_DEPTH: usize = 32;
105
const CACHE_PAGES: usize = 1024;
106
107
/// A read-only view of one SQLite database file.
108
pub struct Sqlite<'a> {
109
    src: &'a dyn ByteSource,
110
    page_size: u32,
111
    usable: u64,
112
    /// In-header database size in pages; 0 when the header did not say.
113
    page_count: u32,
114
    /// Page reads still allowed; exhaustion is disclosed, not fatal-silent.
115
    budget: usize,
116
    pub pages_read: usize,
117
    pub bytes_read: u64,
118
    /// True once a read was refused because the budget ran out. A caller
119
    /// that sees an `Err` should check this to tell honest truncation from
120
    /// real corruption.
121
    pub budget_exhausted: bool,
122
    cache: BTreeMap<u32, Rc<Vec<u8>>>,
123
}
124
125
impl<'a> Sqlite<'a> {
126
    /// Open a database: read and validate the 100-byte header. Text
127
    /// encoding must be UTF-8; anything else is refused, not guessed at.
128
    pub fn open(src: &'a dyn ByteSource, page_budget: usize) -> Result<Self, Refusal> {
129
        let header = src.read_at(0, 100)?;
130
        if header.len() < 100 {
131
            return Err(corrupt("the file is shorter than a database header"));
132
        }
133
        if &header[0..16] != b"SQLite format 3\0" {
134
            return Err(corrupt("the magic bytes are not SQLite's"));
135
        }
136
        let raw = u16::from_be_bytes([header[16], header[17]]);
137
        let page_size: u32 = if raw == 1 { 65_536 } else { u32::from(raw) };
138
        if !(512..=65_536).contains(&page_size) || !page_size.is_power_of_two() {
139
            return Err(corrupt("the page size is out of range"));
140
        }
141
        let reserved = u32::from(header[20]);
142
        if reserved >= page_size {
143
            return Err(corrupt("the reserved-space byte exceeds the page size"));
144
        }
145
        let encoding = u32::from_be_bytes([header[56], header[57], header[58], header[59]]);
146
        if encoding != 1 {
147
            return Err(corrupt("only UTF-8 text encoding is supported"));
148
        }
149
        let page_count = u32::from_be_bytes([header[28], header[29], header[30], header[31]]);
150
        Ok(Sqlite {
151
            src,
152
            page_size,
153
            usable: u64::from(page_size - reserved),
154
            page_count,
155
            budget: page_budget,
156
            pages_read: 0,
157
            bytes_read: 100,
158
            budget_exhausted: false,
159
            cache: BTreeMap::new(),
160
        })
161
    }
162
163
    /// The whole `sqlite_master` table (root page 1): every table and index
164
    /// with its root page and parsed column order.
165
    pub fn master(&mut self) -> Result<Vec<MasterEntry>, Refusal> {
166
        let mut entries = Vec::new();
167
        self.scan_table(1, 262_144, &mut |_rowid, values| {
168
            let text = |at: usize| values.get(at).and_then(Value::as_str).map(str::to_string);
169
            let (Some(kind), Some(name), Some(tbl_name)) = (text(0), text(1), text(2)) else {
170
                return true; // Not a row this reader understands; move on.
171
            };
172
            let rootpage = values.get(3).and_then(Value::as_int).unwrap_or(0);
173
            let Ok(rootpage) = u32::try_from(rootpage) else {
174
                return true;
175
            };
176
            let sql = text(4).unwrap_or_default();
177
            let columns = columns_of(&sql);
178
            entries.push(MasterEntry { kind, name, tbl_name, rootpage, sql, columns });
179
            true
180
        })?;
181
        Ok(entries)
182
    }
183
184
    /// One page's bytes, cached. Page numbers are 1-based.
185
    fn page(&mut self, number: u32) -> Result<Rc<Vec<u8>>, Refusal> {
186
        if number == 0 || (self.page_count != 0 && number > self.page_count) {
187
            return Err(corrupt("a page number points outside the database"));
188
        }
189
        if let Some(hit) = self.cache.get(&number) {
190
            return Ok(hit.clone());
191
        }
192
        if self.budget == 0 {
193
            self.budget_exhausted = true;
194
            return Err(corrupt("the page-read budget is exhausted"));
195
        }
196
        self.budget -= 1;
197
        let offset = u64::from(number - 1) * u64::from(self.page_size);
198
        let bytes = self.src.read_at(offset, self.page_size)?;
199
        if bytes.len() != self.page_size as usize {
200
            return Err(corrupt("a page read came back short"));
201
        }
202
        self.pages_read += 1;
203
        self.bytes_read += bytes.len() as u64;
204
        if self.cache.len() >= CACHE_PAGES {
205
            self.cache.clear();
206
        }
207
        let page = Rc::new(bytes);
208
        self.cache.insert(number, page.clone());
209
        Ok(page)
210
    }
211
212
    /// Walk a table b-tree in rowid order, decoding each leaf record. The
213
    /// callback returns `false` to stop early. Records longer than
214
    /// `payload_cap` come back with [`Value::Truncated`] tails.
215
    pub fn scan_table(
216
        &mut self,
217
        root: u32,
218
        payload_cap: usize,
219
        on_row: &mut dyn FnMut(i64, Vec<Value>) -> bool,
220
    ) -> Result<(), Refusal> {
221
        self.walk_table(root, payload_cap, 0, on_row).map(|_| ())
222
    }
223
224
    fn walk_table(
225
        &mut self,
226
        page_no: u32,
227
        payload_cap: usize,
228
        depth: usize,
229
        on_row: &mut dyn FnMut(i64, Vec<Value>) -> bool,
230
    ) -> Result<bool, Refusal> {
231
        if depth > MAX_DEPTH {
232
            return Err(corrupt("the b-tree is deeper than any honest database"));
233
        }
234
        let page = self.page(page_no)?;
235
        let info = page_info(&page, page_no)?;
236
        match info.kind {
237
            0x0d => {
238
                for at in 0..info.ncells {
239
                    let cell = cell_offset(&page, info.cell_ptr_base, at)?;
240
                    let (payload, rowid) = self.cell_payload(&page, cell, 0x0d, payload_cap)?;
241
                    let rowid = rowid.ok_or_else(|| corrupt("a table leaf cell has no rowid"))?;
242
                    let values = decode_record(&payload.bytes, payload.total)
243
                        .map_err(|()| corrupt("a row record does not decode"))?;
244
                    if !on_row(rowid, values) {
245
                        return Ok(false);
246
                    }
247
                }
248
                Ok(true)
249
            }
250
            0x05 => {
251
                for at in 0..info.ncells {
252
                    let cell = cell_offset(&page, info.cell_ptr_base, at)?;
253
                    let child = read_u32(&page, cell)?;
254
                    if !self.walk_table(child, payload_cap, depth + 1, on_row)? {
255
                        return Ok(false);
256
                    }
257
                }
258
                self.walk_table(info.right_most, payload_cap, depth + 1, on_row)
259
            }
260
            other => Err(corrupt(&format!("unexpected table page type 0x{other:02x}"))),
261
        }
262
    }
263
264
    /// One row by rowid: a point lookup down the table b-tree.
265
    pub fn find_by_rowid(
266
        &mut self,
267
        root: u32,
268
        rowid: i64,
269
        payload_cap: usize,
270
    ) -> Result<Option<Vec<Value>>, Refusal> {
271
        let mut page_no = root;
272
        for _ in 0..MAX_DEPTH {
273
            let page = self.page(page_no)?;
274
            let info = page_info(&page, page_no)?;
275
            match info.kind {
276
                0x0d => {
277
                    for at in 0..info.ncells {
278
                        let cell = cell_offset(&page, info.cell_ptr_base, at)?;
279
                        let (payload, found) = self.cell_payload(&page, cell, 0x0d, payload_cap)?;
280
                        if found == Some(rowid) {
281
                            let values = decode_record(&payload.bytes, payload.total)
282
                                .map_err(|()| corrupt("a row record does not decode"))?;
283
                            return Ok(Some(values));
284
                        }
285
                    }
286
                    return Ok(None);
287
                }
288
                0x05 => {
289
                    let mut next = info.right_most;
290
                    for at in 0..info.ncells {
291
                        let cell = cell_offset(&page, info.cell_ptr_base, at)?;
292
                        let key = varint(&page, cell + 4)
293
                            .ok_or_else(|| corrupt("an interior cell key does not decode"))?
294
                            .0;
295
                        if rowid <= key {
296
                            next = read_u32(&page, cell)?;
297
                            break;
298
                        }
299
                    }
300
                    page_no = next;
301
                }
302
                other => {
303
                    return Err(corrupt(&format!("unexpected table page type 0x{other:02x}")))
304
                }
305
            }
306
        }
307
        Err(corrupt("the b-tree is deeper than any honest database"))
308
    }
309
310
    /// Walk an index b-tree, emitting every entry whose *first* key column
311
    /// equals `first` (BINARY collation: byte comparison), in key order.
312
    /// Subtrees that cannot contain a match are pruned, so this is a seek,
313
    /// not a full scan. Each emitted record's last value is the rowid.
314
    pub fn index_scan_eq(
315
        &mut self,
316
        root: u32,
317
        first: &str,
318
        payload_cap: usize,
319
        on_row: &mut dyn FnMut(Vec<Value>) -> bool,
320
    ) -> Result<(), Refusal> {
321
        self.walk_index_eq(root, first, payload_cap, 0, on_row).map(|_| ())
322
    }
323
324
    fn walk_index_eq(
325
        &mut self,
326
        page_no: u32,
327
        first: &str,
328
        payload_cap: usize,
329
        depth: usize,
330
        on_row: &mut dyn FnMut(Vec<Value>) -> bool,
331
    ) -> Result<bool, Refusal> {
332
        if depth > MAX_DEPTH {
333
            return Err(corrupt("the b-tree is deeper than any honest database"));
334
        }
335
        let page = self.page(page_no)?;
336
        let info = page_info(&page, page_no)?;
337
        match info.kind {
338
            0x0a => {
339
                for at in 0..info.ncells {
340
                    let cell = cell_offset(&page, info.cell_ptr_base, at)?;
341
                    let (payload, _) = self.cell_payload(&page, cell, 0x0a, payload_cap)?;
342
                    let values = decode_record(&payload.bytes, payload.total)
343
                        .map_err(|()| corrupt("an index record does not decode"))?;
344
                    match cmp_first(&values, first) {
345
                        Ordering::Less => continue,
346
                        Ordering::Equal => {
347
                            if !on_row(values) {
348
                                return Ok(false);
349
                            }
350
                        }
351
                        Ordering::Greater => return Ok(false),
352
                    }
353
                }
354
                Ok(true)
355
            }
356
            0x02 => {
357
                for at in 0..info.ncells {
358
                    let cell = cell_offset(&page, info.cell_ptr_base, at)?;
359
                    let child = read_u32(&page, cell)?;
360
                    let (payload, _) = self.cell_payload(&page, cell + 4, 0x02, payload_cap)?;
361
                    let key = decode_record(&payload.bytes, payload.total)
362
                        .map_err(|()| corrupt("an index record does not decode"))?;
363
                    match cmp_first(&key, first) {
364
                        // Everything under this child sorts before the key,
365
                        // which itself sorts before the target: prune both.
366
                        Ordering::Less => continue,
367
                        Ordering::Equal => {
368
                            if !self.walk_index_eq(child, first, payload_cap, depth + 1, on_row)? {
369
                                return Ok(false);
370
                            }
371
                            if !on_row(key) {
372
                                return Ok(false);
373
                            }
374
                        }
375
                        Ordering::Greater => {
376
                            // The child may still hold trailing matches; the
377
                            // key itself is already past the target.
378
                            return self
379
                                .walk_index_eq(child, first, payload_cap, depth + 1, on_row)
380
                                .map(|_| false);
381
                        }
382
                    }
383
                }
384
                self.walk_index_eq(info.right_most, first, payload_cap, depth + 1, on_row)
385
            }
386
            other => Err(corrupt(&format!("unexpected index page type 0x{other:02x}"))),
387
        }
388
    }
389
390
    /// A cell's payload bytes (following overflow pages as needed, up to
391
    /// `cap`) plus its rowid for table-leaf cells. `at` addresses the first
392
    /// varint of the cell — for index interior cells, *after* the 4-byte
393
    /// child pointer.
394
    fn cell_payload(
395
        &mut self,
396
        page: &[u8],
397
        at: usize,
398
        kind: u8,
399
        cap: usize,
400
    ) -> Result<(Payload, Option<i64>), Refusal> {
401
        let (total, n1) =
402
            varint(page, at).ok_or_else(|| corrupt("a cell length does not decode"))?;
403
        if total < 0 {
404
            return Err(corrupt("a cell claims a negative payload length"));
405
        }
406
        let total = total as u64;
407
        let mut pos = at + n1;
408
        let mut rowid = None;
409
        if kind == 0x0d {
410
            let (id, n2) =
411
                varint(page, pos).ok_or_else(|| corrupt("a cell rowid does not decode"))?;
412
            rowid = Some(id);
413
            pos += n2;
414
        }
415
416
        let usable = self.usable;
417
        let x = if kind == 0x0d { usable - 35 } else { ((usable - 12) * 64 / 255) - 23 };
418
        let local = if total <= x {
419
            total
420
        } else {
421
            let m = ((usable - 12) * 32 / 255) - 23;
422
            let k = m + (total - m) % (usable - 4);
423
            if k <= x {
424
                k
425
            } else {
426
                m
427
            }
428
        };
429
        let local = local as usize;
430
        if pos + local > page.len() {
431
            return Err(corrupt("a cell's local payload runs off its page"));
432
        }
433
434
        let want = usize::try_from(total).unwrap_or(usize::MAX).min(cap);
435
        let mut bytes = Vec::with_capacity(want.min(local + 64));
436
        bytes.extend_from_slice(&page[pos..pos + local.min(want)]);
437
438
        if (total as usize) > local && bytes.len() < want {
439
            // The 4-byte pointer to the first overflow page sits after the
440
            // local payload; each overflow page starts with the next pointer.
441
            let mut next = read_u32(page, pos + local)?;
442
            let chunk = (usable - 4) as usize;
443
            let mut hops = want / chunk.max(1) + 2;
444
            while next != 0 && bytes.len() < want {
445
                if hops == 0 {
446
                    return Err(corrupt("an overflow chain loops or overruns its payload"));
447
                }
448
                hops -= 1;
449
                let overflow = self.page(next)?;
450
                next = read_u32(&overflow, 0)?;
451
                let take = chunk.min(want - bytes.len()).min(overflow.len() - 4);
452
                bytes.extend_from_slice(&overflow[4..4 + take]);
453
            }
454
        }
455
        Ok((Payload { total, bytes }, rowid))
456
    }
457
}
458
459
struct Payload {
460
    /// The record's full length on disk; `bytes` may hold less (the cap).
461
    total: u64,
462
    bytes: Vec<u8>,
463
}
464
465
struct PageInfo {
466
    kind: u8,
467
    ncells: usize,
468
    cell_ptr_base: usize,
469
    right_most: u32,
470
}
471
472
/// Parse a b-tree page's header. Page 1 carries the 100-byte file header
473
/// first; cell pointer offsets are always relative to the page start.
474
fn page_info(page: &[u8], number: u32) -> Result<PageInfo, Refusal> {
475
    let hdr = if number == 1 { 100 } else { 0 };
476
    if page.len() < hdr + 12 {
477
        return Err(corrupt("a page is too short for its header"));
478
    }
479
    let kind = page[hdr];
480
    let interior = kind == 0x02 || kind == 0x05;
481
    let ncells = u16::from_be_bytes([page[hdr + 3], page[hdr + 4]]) as usize;
482
    let cell_ptr_base = hdr + if interior { 12 } else { 8 };
483
    let right_most = if interior { read_u32(page, hdr + 8)? } else { 0 };
484
    if cell_ptr_base + 2 * ncells > page.len() {
485
        return Err(corrupt("a cell pointer array runs off its page"));
486
    }
487
    Ok(PageInfo { kind, ncells, cell_ptr_base, right_most })
488
}
489
490
fn cell_offset(page: &[u8], base: usize, index: usize) -> Result<usize, Refusal> {
491
    let at = base + 2 * index;
492
    let offset = u16::from_be_bytes([page[at], page[at + 1]]) as usize;
493
    if offset >= page.len() {
494
        return Err(corrupt("a cell pointer points off its page"));
495
    }
496
    Ok(offset)
497
}
498
499
fn read_u32(bytes: &[u8], at: usize) -> Result<u32, Refusal> {
500
    if at + 4 > bytes.len() {
501
        return Err(corrupt("a page number field runs off its page"));
502
    }
503
    Ok(u32::from_be_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]))
504
}
505
506
/// Decode a SQLite varint: 1–9 bytes, big-endian base-128, the ninth byte
507
/// contributing all eight bits. Answers the value and the bytes consumed.
508
pub fn varint(buf: &[u8], at: usize) -> Option<(i64, usize)> {
509
    let mut value: i64 = 0;
510
    for step in 0..9 {
511
        let byte = *buf.get(at + step)?;
512
        if step == 8 {
513
            return Some(((value << 8) | i64::from(byte), 9));
514
        }
515
        value = (value << 7) | i64::from(byte & 0x7f);
516
        if byte & 0x80 == 0 {
517
            return Some((value, step + 1));
518
        }
519
    }
520
    None
521
}
522
523
/// Decode a record's serial-type header and values. `total` is the record's
524
/// on-disk length; when `payload` holds less, values past the cut come back
525
/// as [`Value::Truncated`] instead of failing the whole row. `Err(())` means
526
/// the record is malformed (or so heavily capped its header is unreadable).
527
pub fn decode_record(payload: &[u8], total: u64) -> Result<Vec<Value>, ()> {
528
    let (header_len, n) = varint(payload, 0).ok_or(())?;
529
    if header_len < 0 {
530
        return Err(());
531
    }
532
    let header_len = header_len as u64;
533
    if header_len > total || header_len as usize > payload.len() {
534
        return Err(()); // The header itself is cut or lying.
535
    }
536
    let mut types = Vec::new();
537
    let mut pos = n;
538
    while (pos as u64) < header_len {
539
        let (serial, used) = varint(payload, pos).ok_or(())?;
540
        types.push(serial);
541
        pos += used;
542
    }
543
    if pos as u64 != header_len {
544
        return Err(());
545
    }
546
547
    let mut values = Vec::with_capacity(types.len());
548
    let mut body = header_len as usize;
549
    for serial in types {
550
        let size = serial_size(serial).ok_or(())? as usize;
551
        let end = body.saturating_add(size);
552
        if end as u64 > total {
553
            return Err(()); // The record claims more bytes than it has.
554
        }
555
        if end <= payload.len() {
556
            values.push(decode_value(serial, &payload[body..end]));
557
        } else if body < payload.len() {
558
            values.push(Value::Truncated(payload[body..].to_vec()));
559
        } else {
560
            values.push(Value::Truncated(Vec::new()));
561
        }
562
        body = end;
563
    }
564
    Ok(values)
565
}
566
567
fn serial_size(serial: i64) -> Option<u64> {
568
    match serial {
569
        0 | 8 | 9 => Some(0),
570
        1 => Some(1),
571
        2 => Some(2),
572
        3 => Some(3),
573
        4 => Some(4),
574
        5 => Some(6),
575
        6 | 7 => Some(8),
576
        n if n >= 12 => Some((n as u64 - 12) / 2),
577
        _ => None,
578
    }
579
}
580
581
fn decode_value(serial: i64, bytes: &[u8]) -> Value {
582
    match serial {
583
        0 => Value::Null,
584
        8 => Value::Int(0),
585
        9 => Value::Int(1),
586
        1..=6 => {
587
            let mut value: i64 = if bytes.first().is_some_and(|b| b & 0x80 != 0) { -1 } else { 0 };
588
            for byte in bytes {
589
                value = (value << 8) | i64::from(*byte);
590
            }
591
            Value::Int(value)
592
        }
593
        7 => {
594
            let mut raw = [0u8; 8];
595
            raw.copy_from_slice(bytes);
596
            Value::Float(f64::from_be_bytes(raw))
597
        }
598
        n if n >= 13 && n % 2 == 1 => Value::Text(String::from_utf8_lossy(bytes).into_owned()),
599
        _ => Value::Blob(bytes.to_vec()),
600
    }
601
}
602
603
/// SQLite's BINARY-collation ordering of a record's first column against a
604
/// target text value: NULLs and numbers sort before text, blobs after.
605
fn cmp_first(values: &[Value], target: &str) -> Ordering {
606
    match values.first() {
607
        Some(Value::Text(text)) => text.as_bytes().cmp(target.as_bytes()),
608
        Some(Value::Blob(_)) => Ordering::Greater,
609
        // NULL, numbers, a pruned prefix, or nothing: sorts before text.
610
        _ => Ordering::Less,
611
    }
612
}
613
614
/// Column names in declaration order, parsed from a `CREATE TABLE` or
615
/// `CREATE INDEX` statement: the comma-separated head identifiers between
616
/// the statement's parentheses, with table-level constraints skipped.
617
/// Answering the *actual* order matters because ORM-managed schemas
618
/// (drizzle here) order columns by migration history, not by intuition.
619
pub fn columns_of(sql: &str) -> Vec<String> {
620
    let Some(open) = sql.find('(') else {
621
        return Vec::new();
622
    };
623
    let body = &sql[open + 1..];
624
    let mut depth = 0usize;
625
    let mut quote: Option<char> = None;
626
    let mut end = body.len();
627
    for (at, ch) in body.char_indices() {
628
        if let Some(q) = quote {
629
            if ch == q {
630
                quote = None;
631
            }
632
            continue;
633
        }
634
        match ch {
635
            '\'' | '"' | '`' => quote = Some(ch),
636
            '(' => depth += 1,
637
            ')' => {
638
                if depth == 0 {
639
                    end = at;
640
                    break;
641
                }
642
                depth -= 1;
643
            }
644
            _ => {}
645
        }
646
    }
647
    let mut columns = Vec::new();
648
    for segment in split_top_level(&body[..end]) {
649
        let segment = segment.trim();
650
        if segment.is_empty() {
651
            continue;
652
        }
653
        let name = head_identifier(segment);
654
        if name.is_empty() {
655
            continue;
656
        }
657
        let upper = name.to_ascii_uppercase();
658
        if matches!(upper.as_str(), "CONSTRAINT" | "PRIMARY" | "FOREIGN" | "UNIQUE" | "CHECK") {
659
            continue;
660
        }
661
        columns.push(name);
662
    }
663
    columns
664
}
665
666
/// Split on commas that sit outside parentheses and quotes.
667
fn split_top_level(body: &str) -> Vec<&str> {
668
    let mut parts = Vec::new();
669
    let mut depth = 0usize;
670
    let mut quote: Option<char> = None;
671
    let mut start = 0usize;
672
    for (at, ch) in body.char_indices() {
673
        if let Some(q) = quote {
674
            if ch == q {
675
                quote = None;
676
            }
677
            continue;
678
        }
679
        match ch {
680
            '\'' | '"' | '`' => quote = Some(ch),
681
            '(' => depth += 1,
682
            ')' => depth = depth.saturating_sub(1),
683
            ',' if depth == 0 => {
684
                parts.push(&body[start..at]);
685
                start = at + 1;
686
            }
687
            _ => {}
688
        }
689
    }
690
    parts.push(&body[start..]);
691
    parts
692
}
693
694
/// The first identifier of a column definition, unquoting `` ` ``, `"`,
695
/// and `[...]` styles.
696
fn head_identifier(segment: &str) -> String {
697
    let segment = segment.trim_start();
698
    let mut chars = segment.chars();
699
    match chars.next() {
700
        Some(open @ ('`' | '"')) => chars.take_while(|c| *c != open).collect(),
701
        Some('[') => chars.take_while(|c| *c != ']').collect(),
702
        Some(first) if first.is_ascii_alphanumeric() || first == '_' => {
703
            std::iter::once(first)
704
                .chain(chars.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '$'))
705
                .collect()
706
        }
707
        _ => String::new(),
708
    }
709
}
710
711
/// Sniff a JSON object's `"type"` (or any named string field) from a byte
712
/// prefix — for rows whose payload was capped before the JSON could parse.
713
pub fn sniff_json_str(prefix: &[u8], field: &str) -> Option<String> {
714
    let text = String::from_utf8_lossy(prefix);
715
    let needle = format!("\"{field}\"");
716
    let at = text.find(&needle)?;
717
    let rest = &text[at + needle.len()..];
718
    let rest = rest.trim_start();
719
    let rest = rest.strip_prefix(':')?.trim_start();
720
    let rest = rest.strip_prefix('"')?;
721
    let end = rest.find('"')?;
722
    Some(rest[..end].to_string())
723
}
plugins/foreign-sessions/src/tests.rs modified +14

@@ -30,6 +30,9 @@ impl FakeHost {

30 30
    fn file(&mut self, path: &str, bytes: &str) {
31 31
        self.files.insert(path.to_string(), bytes.as_bytes().to_vec());
32 32
    }
33
    fn bytes(&mut self, path: &str, bytes: Vec<u8>) {
34
        self.files.insert(path.to_string(), bytes);
35
    }
33 36
}
34 37
35 38
impl Host for FakeHost {

@@ -53,6 +56,17 @@ impl Host for FakeHost {

53 56
            .cloned()
54 57
            .ok_or_else(|| Refusal::new(RefusalCode::MountDenied, "no declared mount contains the path"))
55 58
    }
59
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal> {
60
        if self.unreadable.iter().any(|p| p == path) {
61
            return Err(Refusal::new(RefusalCode::FileUnreadable, "io failure"));
62
        }
63
        let bytes = self.files.get(path).ok_or_else(|| {
64
            Refusal::new(RefusalCode::MountDenied, "no declared mount contains the path")
65
        })?;
66
        let start = (offset as usize).min(bytes.len());
67
        let end = start.saturating_add(max_bytes as usize).min(bytes.len());
68
        Ok(bytes[start..end].to_vec())
69
    }
56 70
}
57 71
58 72
fn entry(name: &str, kind: &str, size: u64, mtime_ms: i64) -> MountDirEntry {
plugins/git-facts/Cargo.toml added +14

@@ -0,0 +1,14 @@

1
[package]
2
name = "git-facts"
3
version = "0.1.0"
4
edition.workspace = true
5
license.workspace = true
6
description = "Guest plugin stub; see the harvest issue on OpenAgentsInc/openagents project 3."
7
8
[lib]
9
crate-type = ["cdylib", "rlib"]
10
11
[dependencies]
12
openagents-pdk = { workspace = true }
13
serde = { workspace = true }
14
serde_json = { workspace = true }
plugins/git-facts/git_facts.wasm added

Binary file. Nothing to show as text.

plugins/git-facts/manifest.json added +126

@@ -0,0 +1,126 @@

1
{
2
  "manifest_version": 1,
3
  "name": "git_facts",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "Report the git state of this repository: current branch, recent commits and history, and which files changed, are untracked, or are missing. It reads .git plumbing files directly — HEAD, refs/heads, packed-refs, the reflog, and a version-2 index — never a git binary, the object store, or packfiles. Its honest limits: history is reflog-based (only as far back as .git/logs/HEAD reaches), changed files are size-and-mtime candidates rather than content-hashed verdicts, index versions above 2 report counts but not per-entry facts, and there is no blame. Anything skipped or degraded is named in notes.",
7
  "artifact": {
8
    "path": "git_facts.wasm",
9
    "digest": "sha256:ea5913e66d237a647d7cbd34b15641bc8478ccfbcefce82b57d46a680f9a1f8a"
10
  },
11
  "abi": {
12
    "kind": "packet-v0",
13
    "entry": "handle_packet",
14
    "alloc": "packet_alloc"
15
  },
16
  "interface": {
17
    "input": {
18
      "type": "object",
19
      "properties": {
20
        "facts": {
21
          "type": "array",
22
          "items": {
23
            "type": "string",
24
            "enum": ["head", "branches", "log", "status"]
25
          },
26
          "description": "Which facts to report. All four when omitted."
27
        },
28
        "max_log": {
29
          "type": "integer",
30
          "description": "Most reflog entries to return. Default 15, capped at 100."
31
        },
32
        "max_paths": {
33
          "type": "integer",
34
          "description": "Most paths listed per status category. Default 20, capped at 100."
35
        },
36
        "max_walk": {
37
          "type": "integer",
38
          "description": "Most workdir files statted by the status walk. Default 5000, capped at 20000."
39
        }
40
      }
41
    },
42
    "output": {
43
      "type": "object",
44
      "properties": {
45
        "head": {
46
          "type": "object",
47
          "properties": {
48
            "branch": { "type": "string" },
49
            "detached": {
50
              "type": "string",
51
              "description": "The commit id HEAD holds directly, when detached."
52
            }
53
          }
54
        },
55
        "branches": {
56
          "type": "array",
57
          "items": {
58
            "type": "object",
59
            "properties": {
60
              "name": { "type": "string" },
61
              "id": { "type": "string" }
62
            }
63
          }
64
        },
65
        "log": {
66
          "type": "array",
67
          "items": {
68
            "type": "object",
69
            "properties": {
70
              "id": { "type": "string" },
71
              "at_ms": { "type": "integer" },
72
              "message": { "type": "string" }
73
            }
74
          },
75
          "description": "Recent history from the HEAD reflog, newest first."
76
        },
77
        "status": {
78
          "type": "object",
79
          "properties": {
80
            "tracked": { "type": "integer" },
81
            "changed_candidates": {
82
              "type": "object",
83
              "properties": {
84
                "count": { "type": "integer" },
85
                "paths": { "type": "array", "items": { "type": "string" } }
86
              },
87
              "description": "Tracked files whose size or mtime differs from the index. Candidates, not verdicts: content was never hashed."
88
            },
89
            "untracked": {
90
              "type": "object",
91
              "properties": {
92
                "count": { "type": "integer" },
93
                "paths": { "type": "array", "items": { "type": "string" } }
94
              }
95
            },
96
            "missing": {
97
              "type": "object",
98
              "properties": {
99
                "count": { "type": "integer" },
100
                "paths": { "type": "array", "items": { "type": "string" } }
101
              }
102
            },
103
            "walk_truncated": { "type": "boolean" },
104
            "comparison": {
105
              "type": "string",
106
              "description": "Always size_and_mtime_only."
107
            }
108
          }
109
        },
110
        "notes": {
111
          "type": "array",
112
          "items": { "type": "string" },
113
          "description": "Everything honestly skipped or degraded, named."
114
        }
115
      }
116
    }
117
  },
118
  "capabilities": {
119
    "mounts": [
120
      { "path": "${workspace}", "readonly": true }
121
    ],
122
    "hosts": [],
123
    "timeout_ms": 10000,
124
    "memory_max_mib": 128
125
  }
126
}
plugins/git-facts/src/lib.rs added +650

@@ -0,0 +1,650 @@

1
//! Typed facts about the git state of the mounted workspace, read straight
2
//! from the `.git` plumbing files — no `git` binary, no object store, no
3
//! packfiles, no zlib.
4
//!
5
//! What it reads, and what each read honestly gives:
6
//!
7
//! - `.git/HEAD` — the current branch, or the detached commit id.
8
//! - `.git/refs/heads/*` and `.git/packed-refs` — local branch tips.
9
//! - `.git/logs/HEAD` — the reflog, whose last N lines give recent commit
10
//!   ids, timestamps, and messages without touching the object store.
11
//! - `.git/index` (version 2 only) — tracked path count and per-entry
12
//!   `{path, size, mtime}`. Versions above 2 are reported, not misparsed:
13
//!   the header's entry count is still honest, the per-entry facts are
14
//!   declared unavailable.
15
//! - A bounded walk of the workdir (skipping `.git`) compared against the
16
//!   index by size and mtime — which makes `changed_candidates` exactly
17
//!   that: candidates. Without hashing content this is never a verdict,
18
//!   and the output says so (`comparison: "size_and_mtime_only"`).
19
//!
20
//! Everything skipped is named in `notes` rather than silently absorbed:
21
//! an unsupported index version, a truncated index read, a missing reflog,
22
//! branches known only from `packed-refs`, a walk that hit its ceiling.
23
//!
24
//! Follow-up (recorded here per the harvest issue): a gitoxide-backed
25
//! object walk and blame are the next capability step. `gix` was
26
//! deliberately not used for this plugin — its `wasm32-unknown-unknown`
27
//! story with fs/time features is a fight — so history here is
28
//! reflog-based and there is no blame.
29
30
use openagents_pdk::{
31
    list_mounted_dir, plugin_entry, read_mounted_file, read_mounted_file_range, MountDirListing,
32
    Refusal, RefusalCode,
33
};
34
use serde::{Deserialize, Serialize};
35
use std::collections::{BTreeMap, BTreeSet};
36
37
const DEFAULT_MAX_LOG: usize = 15;
38
const MAX_LOG_CAP: usize = 100;
39
const DEFAULT_MAX_PATHS: usize = 20;
40
const MAX_PATHS_CAP: usize = 100;
41
const DEFAULT_MAX_WALK: usize = 5_000;
42
const MAX_WALK_CAP: usize = 20_000;
43
44
/// Mirror of the host's per-read bound; an index past it is read from the
45
/// front through the range import and reported as truncated.
46
const INDEX_READ_BOUND: u32 = 1_048_576;
47
48
const HEAD_PATH: &str = ".git/HEAD";
49
const HEAD_REF_PREFIX: &str = "ref: refs/heads/";
50
const REFS_HEADS_DIR: &str = ".git/refs/heads";
51
const PACKED_REFS_PATH: &str = ".git/packed-refs";
52
const HEAD_LOG_PATH: &str = ".git/logs/HEAD";
53
const INDEX_PATH: &str = ".git/index";
54
55
/// The fixed portion of a version-2 index entry, before the path.
56
const INDEX_ENTRY_FIXED: usize = 62;
57
58
#[derive(Deserialize)]
59
pub struct Input {
60
    /// Which facts to report: any of `head`, `branches`, `log`, `status`.
61
    /// All four when absent.
62
    #[serde(default)]
63
    pub facts: Option<Vec<String>>,
64
    /// Most reflog entries to return. Default 15, capped at 100.
65
    #[serde(default)]
66
    pub max_log: Option<usize>,
67
    /// Most paths listed per status category. Default 20, capped at 100.
68
    #[serde(default)]
69
    pub max_paths: Option<usize>,
70
    /// Most workdir files statted by the walk. Default 5000, capped at 20000.
71
    #[serde(default)]
72
    pub max_walk: Option<usize>,
73
}
74
75
#[derive(Debug, Default, Serialize, PartialEq, Eq)]
76
pub struct Head {
77
    /// The branch `HEAD` points at, when it points at one.
78
    #[serde(skip_serializing_if = "Option::is_none")]
79
    pub branch: Option<String>,
80
    /// The commit id `HEAD` holds directly, when it is detached.
81
    #[serde(skip_serializing_if = "Option::is_none")]
82
    pub detached: Option<String>,
83
}
84
85
#[derive(Debug, Serialize, PartialEq, Eq)]
86
pub struct Branch {
87
    pub name: String,
88
    pub id: String,
89
}
90
91
#[derive(Debug, Serialize, PartialEq, Eq)]
92
pub struct LogEntry {
93
    /// The commit id the reflog line moved to.
94
    pub id: String,
95
    /// The reflog timestamp, in milliseconds since the Unix epoch.
96
    pub at_ms: i64,
97
    pub message: String,
98
}
99
100
#[derive(Debug, Default, Serialize, PartialEq, Eq)]
101
pub struct PathFacts {
102
    /// How many paths fell in this category, before the listing bound.
103
    pub count: usize,
104
    /// The first paths, at most `max_paths`, sorted.
105
    pub paths: Vec<String>,
106
}
107
108
#[derive(Debug, Serialize, PartialEq, Eq)]
109
pub struct Status {
110
    /// Tracked path count, as the index header declares it.
111
    pub tracked: usize,
112
    /// Tracked files whose size or mtime differs from the index. Candidates
113
    /// only: nothing here hashed content, so a stat difference is a reason
114
    /// to look, never a verdict.
115
    pub changed_candidates: PathFacts,
116
    /// Paths present in the workdir but absent from the index.
117
    pub untracked: PathFacts,
118
    /// Paths present in the index but absent from the workdir.
119
    pub missing: PathFacts,
120
    /// True when the walk hit its file ceiling or a listing was truncated,
121
    /// so the three categories above saw only part of the workdir.
122
    pub walk_truncated: bool,
123
    /// How tracked files were compared. Always `size_and_mtime_only`.
124
    pub comparison: &'static str,
125
}
126
127
#[derive(Debug, Default, Serialize)]
128
pub struct Output {
129
    #[serde(skip_serializing_if = "Option::is_none")]
130
    pub head: Option<Head>,
131
    #[serde(skip_serializing_if = "Option::is_none")]
132
    pub branches: Option<Vec<Branch>>,
133
    #[serde(skip_serializing_if = "Option::is_none")]
134
    pub log: Option<Vec<LogEntry>>,
135
    #[serde(skip_serializing_if = "Option::is_none")]
136
    pub status: Option<Status>,
137
    /// Everything honestly skipped or degraded, named.
138
    pub notes: Vec<String>,
139
}
140
141
/// The host capabilities the plugin uses, abstracted so the logic runs
142
/// under `cargo test` with a fake host and inside the WASM host unchanged.
143
pub trait Host {
144
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal>;
145
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal>;
146
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal>;
147
}
148
149
struct RealHost;
150
151
impl Host for RealHost {
152
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
153
        list_mounted_dir(mount_index, path)
154
    }
155
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
156
        read_mounted_file(path)
157
    }
158
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal> {
159
        read_mounted_file_range(path, offset, max_bytes)
160
    }
161
}
162
163
/// True when the text is 40 hexadecimal characters.
164
fn is_sha40(text: &str) -> bool {
165
    text.len() == 40 && text.chars().all(|c| c.is_ascii_hexdigit())
166
}
167
168
fn read_string(host: &dyn Host, path: &str) -> Option<String> {
169
    host.read(path).ok().map(|b| String::from_utf8_lossy(&b).into_owned())
170
}
171
172
/// The whole report, over any [`Host`].
173
pub fn git_facts(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {
174
    // `.git/HEAD` is the existence proof for a git repository; without it
175
    // there is nothing to report about.
176
    let head_text = match read_string(host, HEAD_PATH) {
177
        Some(text) => text,
178
        None => {
179
            return Err(Refusal::unsupported(
180
                "the workspace has no git repository at its root (no .git/HEAD)",
181
            ))
182
        }
183
    };
184
185
    let mut notes: Vec<String> = Vec::new();
186
    let selected = select_facts(input.facts.as_deref(), &mut notes);
187
188
    let max_log = input.max_log.unwrap_or(DEFAULT_MAX_LOG).clamp(1, MAX_LOG_CAP);
189
    let max_paths = input.max_paths.unwrap_or(DEFAULT_MAX_PATHS).clamp(1, MAX_PATHS_CAP);
190
    let max_walk = input.max_walk.unwrap_or(DEFAULT_MAX_WALK).clamp(1, MAX_WALK_CAP);
191
192
    let head = selected.head.then(|| parse_head(&head_text));
193
    let branches = selected.branches.then(|| read_branches(host, &mut notes));
194
    let log = selected.log.then(|| read_log(host, max_log, &mut notes));
195
    let status = selected
196
        .status
197
        .then(|| read_status(host, max_paths, max_walk, &mut notes));
198
199
    Ok(Output { head, branches, log, status, notes })
200
}
201
202
#[derive(Default)]
203
struct Selected {
204
    head: bool,
205
    branches: bool,
206
    log: bool,
207
    status: bool,
208
}
209
210
fn select_facts(facts: Option<&[String]>, notes: &mut Vec<String>) -> Selected {
211
    let Some(facts) = facts else {
212
        return Selected { head: true, branches: true, log: true, status: true };
213
    };
214
    let mut selected = Selected::default();
215
    for fact in facts {
216
        match fact.as_str() {
217
            "head" => selected.head = true,
218
            "branches" => selected.branches = true,
219
            "log" => selected.log = true,
220
            "status" => selected.status = true,
221
            other => notes.push(format!(
222
                "unknown fact `{other}` ignored; known facts are head, branches, log, status"
223
            )),
224
        }
225
    }
226
    selected
227
}
228
229
/// `.git/HEAD`: a symbolic ref names the branch, a bare 40-hex id is a
230
/// detached head. Anything else is neither, honestly.
231
fn parse_head(text: &str) -> Head {
232
    let first = text.lines().next().unwrap_or("").trim();
233
    if let Some(branch) = first.strip_prefix(HEAD_REF_PREFIX) {
234
        return Head { branch: Some(branch.trim().to_string()), detached: None };
235
    }
236
    if is_sha40(first) {
237
        return Head { branch: None, detached: Some(first.to_string()) };
238
    }
239
    Head::default()
240
}
241
242
/// Recursively collect the relative paths of all files under `path`.
243
/// Failures stop descent rather than failing the report.
244
fn collect_files(host: &dyn Host, path: &str, out: &mut Vec<String>) {
245
    let Ok(listing) = host.list(0, path) else { return };
246
    for entry in listing.entries {
247
        let child = format!("{path}/{}", entry.name);
248
        match entry.kind.as_str() {
249
            "file" => out.push(child),
250
            "dir" => collect_files(host, &child, out),
251
            _ => {}
252
        }
253
    }
254
}
255
256
/// Local branch tips: loose refs first-class, `packed-refs` behind them
257
/// (a loose ref shadows its packed twin, exactly as git resolves them).
258
fn read_branches(host: &dyn Host, notes: &mut Vec<String>) -> Vec<Branch> {
259
    let mut tips: BTreeMap<String, String> = BTreeMap::new();
260
261
    let mut packed_only = 0usize;
262
    if let Some(text) = read_string(host, PACKED_REFS_PATH) {
263
        for line in text.lines() {
264
            let line = line.trim();
265
            if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
266
                continue;
267
            }
268
            let mut parts = line.split_whitespace();
269
            let (Some(id), Some(refname)) = (parts.next(), parts.next()) else { continue };
270
            if let Some(name) = refname.strip_prefix("refs/heads/") {
271
                if is_sha40(id) {
272
                    tips.insert(name.to_string(), id.to_string());
273
                    packed_only += 1;
274
                }
275
            }
276
        }
277
    }
278
279
    let mut loose_files = Vec::new();
280
    collect_files(host, REFS_HEADS_DIR, &mut loose_files);
281
    let mut loose_count = 0usize;
282
    for file in &loose_files {
283
        let name = file
284
            .strip_prefix(".git/refs/heads/")
285
            .unwrap_or(file)
286
            .to_string();
287
        let Some(text) = read_string(host, file) else { continue };
288
        let id = text.trim();
289
        if is_sha40(id) {
290
            tips.insert(name, id.to_string());
291
            loose_count += 1;
292
        }
293
    }
294
295
    if loose_count == 0 && packed_only > 0 {
296
        notes.push("branch tips come from packed-refs only; no loose refs under .git/refs/heads".to_string());
297
    }
298
299
    tips.into_iter().map(|(name, id)| Branch { name, id }).collect()
300
}
301
302
/// One parsed reflog line.
303
struct ReflogLine {
304
    new: String,
305
    timestamp: i64,
306
    message: String,
307
}
308
309
/// A reflog line: `old-id new-id author <email> timestamp tz\tmessage`.
310
/// Only the first tab separates the header from the message, so a message
311
/// containing tabs survives whole.
312
fn parse_reflog_line(line: &str) -> Option<ReflogLine> {
313
    if line.is_empty() {
314
        return None;
315
    }
316
    let (prefix, message) = match line.find('\t') {
317
        Some(at) => (&line[..at], &line[at + 1..]),
318
        None => (line, ""),
319
    };
320
    let tokens: Vec<&str> = prefix.split_whitespace().collect();
321
    if tokens.len() < 6 {
322
        return None;
323
    }
324
    let old = tokens.first()?;
325
    let new = tokens.get(1)?;
326
    if !is_sha40(old) || !is_sha40(new) {
327
        return None;
328
    }
329
    let tz = tokens.last()?;
330
    if !tz.starts_with('+') && !tz.starts_with('-') {
331
        return None;
332
    }
333
    let timestamp = tokens.get(tokens.len() - 2)?.parse::<i64>().ok()?;
334
    Some(ReflogLine {
335
        new: (*new).to_string(),
336
        timestamp,
337
        message: message.trim_end_matches(['\n', '\r']).to_string(),
338
    })
339
}
340
341
/// Recent history from `.git/logs/HEAD`: the last `max_log` entries,
342
/// newest first. Reflog-based, so it reaches only as far back as the
343
/// reflog does — and its absence is a note, not an error.
344
fn read_log(host: &dyn Host, max_log: usize, notes: &mut Vec<String>) -> Vec<LogEntry> {
345
    let Some(text) = read_string(host, HEAD_LOG_PATH) else {
346
        notes.push("no reflog at .git/logs/HEAD; log is empty".to_string());
347
        return Vec::new();
348
    };
349
    let parsed: Vec<ReflogLine> = text.lines().filter_map(parse_reflog_line).collect();
350
    let start = parsed.len().saturating_sub(max_log);
351
    parsed[start..]
352
        .iter()
353
        .rev()
354
        .map(|line| LogEntry {
355
            id: line.new.clone(),
356
            at_ms: line.timestamp * 1000,
357
            message: line.message.clone(),
358
        })
359
        .collect()
360
}
361
362
/// One tracked path as the index records it.
363
#[derive(Debug, PartialEq, Eq)]
364
pub struct IndexEntry {
365
    pub path: String,
366
    pub size: u64,
367
    pub mtime_ms: i64,
368
}
369
370
/// The index as far as it could honestly be parsed.
371
struct ParsedIndex {
372
    /// Entry count as the header declares it, readable for every version.
373
    declared: usize,
374
    entries: Vec<IndexEntry>,
375
    /// True when every declared entry was parsed; false leaves the
376
    /// classification below partial, and a note says why.
377
    complete: bool,
378
}
379
380
fn be32(bytes: &[u8], at: usize) -> u32 {
381
    u32::from_be_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]])
382
}
383
384
fn be16(bytes: &[u8], at: usize) -> u16 {
385
    u16::from_be_bytes([bytes[at], bytes[at + 1]])
386
}
387
388
/// Parse a version-2 index, whole entries only. `input_truncated` says the
389
/// bytes are a prefix of the real file, so running out of bytes is expected
390
/// there and named `index_truncated`; anywhere else it is a malformed file.
391
fn parse_index(bytes: &[u8], input_truncated: bool, notes: &mut Vec<String>) -> ParsedIndex {
392
    if bytes.len() < 12 || &bytes[0..4] != b"DIRC" {
393
        notes.push("the index has no DIRC header; treating it as unreadable".to_string());
394
        return ParsedIndex { declared: 0, entries: Vec::new(), complete: false };
395
    }
396
    let version = be32(bytes, 4);
397
    let declared = be32(bytes, 8) as usize;
398
    if version != 2 {
399
        notes.push(format!(
400
            "index_version_unsupported: the index is version {version} and only version 2 is parsed; the tracked count is the header's, per-entry paths and stats are unavailable"
401
        ));
402
        return ParsedIndex { declared, entries: Vec::new(), complete: false };
403
    }
404
405
    let mut entries = Vec::new();
406
    let mut offset = 12usize;
407
    let mut complete = true;
408
    for _ in 0..declared {
409
        // The fixed 62 bytes: stat data, object id, flags.
410
        if offset + INDEX_ENTRY_FIXED > bytes.len() {
411
            complete = false;
412
            break;
413
        }
414
        let mtime_sec = i64::from(be32(bytes, offset + 8));
415
        let mtime_nsec = i64::from(be32(bytes, offset + 12));
416
        let size = u64::from(be32(bytes, offset + 36));
417
        let flags = be16(bytes, offset + 60);
418
        if flags & 0x4000 != 0 {
419
            // The extended bit belongs to version 3; in a version-2 header
420
            // it means the file is not what it claims. Stop, do not guess.
421
            notes.push(
422
                "an index entry carries extended flags a version-2 index cannot have; stopping at the entries already parsed".to_string(),
423
            );
424
            complete = false;
425
            break;
426
        }
427
        let name_at = offset + INDEX_ENTRY_FIXED;
428
        let name_len_field = (flags & 0x0FFF) as usize;
429
        let name_len = if name_len_field < 0x0FFF {
430
            name_len_field
431
        } else {
432
            // Names of 4095 bytes or more store 0xFFF; the real length is
433
            // up to the NUL terminator.
434
            match bytes[name_at..].iter().position(|b| *b == 0) {
435
                Some(at) => at,
436
                None => {
437
                    complete = false;
438
                    break;
439
                }
440
            }
441
        };
442
        // Whole entries only: the fixed part, the name, and the NUL
443
        // padding to the next 8-byte boundary must all be present.
444
        let entry_len = (INDEX_ENTRY_FIXED + name_len + 8) & !7;
445
        if offset + entry_len > bytes.len() || name_at + name_len > bytes.len() {
446
            complete = false;
447
            break;
448
        }
449
        let path = String::from_utf8_lossy(&bytes[name_at..name_at + name_len]).into_owned();
450
        entries.push(IndexEntry {
451
            path,
452
            size,
453
            mtime_ms: mtime_sec * 1000 + mtime_nsec / 1_000_000,
454
        });
455
        offset += entry_len;
456
    }
457
458
    if !complete && entries.len() < declared {
459
        if input_truncated {
460
            notes.push(format!(
461
                "index_truncated: the index exceeds the read bound; {} of {declared} entries were parsed from its first bytes, whole entries only",
462
                entries.len()
463
            ));
464
        } else {
465
            notes.push(format!(
466
                "the index ends mid-entry; {} of {declared} declared entries were parsed",
467
                entries.len()
468
            ));
469
        }
470
    }
471
472
    ParsedIndex { declared, entries, complete }
473
}
474
475
/// The index bytes: whole when the file fits the host's bound, the first
476
/// bound's worth through the range import when it does not. The second
477
/// value says which.
478
fn load_index(host: &dyn Host, notes: &mut Vec<String>) -> Option<(Vec<u8>, bool)> {
479
    match host.read(INDEX_PATH) {
480
        Ok(bytes) => Some((bytes, false)),
481
        Err(refusal) if refusal.code == RefusalCode::FileTooLarge => {
482
            match host.read_range(INDEX_PATH, 0, INDEX_READ_BOUND) {
483
                Ok(bytes) => Some((bytes, true)),
484
                Err(_) => {
485
                    notes.push("the index exceeds the read bound and the range read failed; status has no index data".to_string());
486
                    None
487
                }
488
            }
489
        }
490
        Err(_) => {
491
            notes.push("no .git/index; the index is treated as empty (a repository before its first `git add` has none)".to_string());
492
            None
493
        }
494
    }
495
}
496
497
/// One statted workdir file.
498
struct WalkedFile {
499
    path: String,
500
    size: u64,
501
    mtime_ms: i64,
502
}
503
504
struct Walker<'a> {
505
    host: &'a dyn Host,
506
    budget: usize,
507
    truncated: bool,
508
    files: Vec<WalkedFile>,
509
}
510
511
impl Walker<'_> {
512
    /// Bounded recursive walk from the mount root, skipping `.git`.
513
    /// Entries the host cannot list stop descent, not the walk.
514
    fn walk(&mut self, path: &str) {
515
        if self.truncated {
516
            return;
517
        }
518
        let Ok(listing) = self.host.list(0, path) else { return };
519
        if listing.truncated {
520
            self.truncated = true;
521
        }
522
        for entry in listing.entries {
523
            if self.truncated {
524
                return;
525
            }
526
            if entry.name == ".git" {
527
                continue;
528
            }
529
            let child = if path.is_empty() {
530
                entry.name.clone()
531
            } else {
532
                format!("{path}/{}", entry.name)
533
            };
534
            match entry.kind.as_str() {
535
                "file" => {
536
                    if self.files.len() >= self.budget {
537
                        self.truncated = true;
538
                        return;
539
                    }
540
                    self.files.push(WalkedFile {
541
                        path: child,
542
                        size: entry.size,
543
                        mtime_ms: entry.mtime_ms,
544
                    });
545
                }
546
                "dir" => self.walk(&child),
547
                _ => {}
548
            }
549
        }
550
    }
551
}
552
553
/// Bound a sorted path set to `max_paths` listed paths plus the full count.
554
fn path_facts(paths: BTreeSet<String>, max_paths: usize) -> PathFacts {
555
    let count = paths.len();
556
    PathFacts { count, paths: paths.into_iter().take(max_paths).collect() }
557
}
558
559
/// The status report: index versus a bounded workdir walk, compared by
560
/// size and mtime only.
561
fn read_status(
562
    host: &dyn Host,
563
    max_paths: usize,
564
    max_walk: usize,
565
    notes: &mut Vec<String>,
566
) -> Status {
567
    let parsed = match load_index(host, notes) {
568
        Some((bytes, input_truncated)) => parse_index(&bytes, input_truncated, notes),
569
        // A missing index is an empty, complete index: everything on disk
570
        // is honestly untracked.
571
        None => ParsedIndex { declared: 0, entries: Vec::new(), complete: true },
572
    };
573
574
    // No entries and no proof of completeness (an unsupported version, a
575
    // header that would not parse): classifying the workdir against an
576
    // unknown index would call every tracked file untracked. Refuse the
577
    // comparison instead; the note above already says why.
578
    if parsed.entries.is_empty() && !parsed.complete {
579
        notes.push(
580
            "changed_candidates, untracked, and missing were not computed: the index's entries could not be read".to_string(),
581
        );
582
        return Status {
583
            tracked: parsed.declared,
584
            changed_candidates: PathFacts::default(),
585
            untracked: PathFacts::default(),
586
            missing: PathFacts::default(),
587
            walk_truncated: false,
588
            comparison: "size_and_mtime_only",
589
        };
590
    }
591
592
    let mut walker = Walker { host, budget: max_walk, truncated: false, files: Vec::new() };
593
    walker.walk("");
594
595
    let index: BTreeMap<&str, &IndexEntry> =
596
        parsed.entries.iter().map(|entry| (entry.path.as_str(), entry)).collect();
597
598
    let mut changed: BTreeSet<String> = BTreeSet::new();
599
    let mut untracked: BTreeSet<String> = BTreeSet::new();
600
    let mut seen: BTreeSet<&str> = BTreeSet::new();
601
    for file in &walker.files {
602
        match index.get(file.path.as_str()) {
603
            Some(entry) => {
604
                seen.insert(entry.path.as_str());
605
                if entry.size != file.size || entry.mtime_ms != file.mtime_ms {
606
                    changed.insert(file.path.clone());
607
                }
608
            }
609
            None => {
610
                untracked.insert(file.path.clone());
611
            }
612
        }
613
    }
614
615
    let missing: BTreeSet<String> = index
616
        .keys()
617
        .filter(|path| !seen.contains(**path))
618
        .map(|path| (*path).to_string())
619
        .collect();
620
621
    if walker.truncated {
622
        notes.push(format!(
623
            "the workdir walk stopped at {} files, so untracked and missing reflect only the part walked",
624
            walker.files.len()
625
        ));
626
    }
627
    if !parsed.complete && !parsed.entries.is_empty() {
628
        notes.push(
629
            "the comparison ran against the entries that were parsed; files tracked past the truncation point would show as untracked".to_string(),
630
        );
631
    }
632
633
    Status {
634
        tracked: parsed.declared,
635
        changed_candidates: path_facts(changed, max_paths),
636
        untracked: path_facts(untracked, max_paths),
637
        missing: path_facts(missing, max_paths),
638
        walk_truncated: walker.truncated,
639
        comparison: "size_and_mtime_only",
640
    }
641
}
642
643
fn handle(input: Input) -> Result<Output, Refusal> {
644
    git_facts(&RealHost, &input)
645
}
646
647
plugin_entry!(handle);
648
649
#[cfg(test)]
650
mod tests;
plugins/git-facts/src/tests.rs added +412

@@ -0,0 +1,412 @@

1
//! The facts reader against a fake host: fixture `.git` trees and a
2
//! hand-built binary index, exercised without a WASM runtime. The same
3
//! shapes run through the real boundary in
4
//! `packages/openagents-cli/test/coder-plugin-git-facts.test.ts`.
5
6
use super::*;
7
use openagents_pdk::MountDirEntry;
8
use std::collections::BTreeMap as Map;
9
10
const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
11
const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
12
const SHA_C: &str = "cccccccccccccccccccccccccccccccccccccccc";
13
const SHA_D: &str = "dddddddddddddddddddddddddddddddddddddddd";
14
15
/// A fake host over one in-memory mount. Directories are keyed by listed
16
/// path; file bytes by mount-relative path.
17
#[derive(Default)]
18
struct FakeHost {
19
    dirs: Map<String, MountDirListing>,
20
    files: Map<String, Vec<u8>>,
21
    /// Paths the whole-file read answers with `file_too_large`; the range
22
    /// read still serves their bytes.
23
    oversized: Vec<String>,
24
}
25
26
impl FakeHost {
27
    fn dir(&mut self, path: &str, entries: Vec<MountDirEntry>) {
28
        self.dirs
29
            .insert(path.to_string(), MountDirListing { entries, truncated: false });
30
    }
31
    fn file(&mut self, path: &str, bytes: impl Into<Vec<u8>>) {
32
        self.files.insert(path.to_string(), bytes.into());
33
    }
34
}
35
36
impl Host for FakeHost {
37
    fn list(&self, _mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
38
        self.dirs.get(path).cloned().ok_or_else(|| {
39
            Refusal::new(RefusalCode::FileUnreadable, "the mount has no such directory")
40
        })
41
    }
42
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
43
        if self.oversized.iter().any(|p| p == path) {
44
            return Err(Refusal::new(RefusalCode::FileTooLarge, "over the bound"));
45
        }
46
        self.files
47
            .get(path)
48
            .cloned()
49
            .ok_or_else(|| Refusal::new(RefusalCode::FileUnreadable, "no such file"))
50
    }
51
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal> {
52
        let bytes = self
53
            .files
54
            .get(path)
55
            .ok_or_else(|| Refusal::new(RefusalCode::FileUnreadable, "no such file"))?;
56
        let start = (offset as usize).min(bytes.len());
57
        let end = (start + max_bytes as usize).min(bytes.len());
58
        Ok(bytes[start..end].to_vec())
59
    }
60
}
61
62
fn entry(name: &str, kind: &str, size: u64, mtime_ms: i64) -> MountDirEntry {
63
    MountDirEntry { name: name.to_string(), kind: kind.to_string(), size, mtime_ms }
64
}
65
66
fn input() -> Input {
67
    Input { facts: None, max_log: None, max_paths: None, max_walk: None }
68
}
69
70
/// Build a version-2 index from `(path, size, mtime_ms)` triples: the
71
/// 12-byte DIRC header, 62-byte fixed entries with 8-byte-aligned NUL
72
/// padding after each path, and a zeroed 20-byte trailing checksum.
73
fn build_index(entries: &[(&str, u32, i64)]) -> Vec<u8> {
74
    build_index_versioned(2, entries)
75
}
76
77
fn build_index_versioned(version: u32, entries: &[(&str, u32, i64)]) -> Vec<u8> {
78
    let mut bytes = Vec::new();
79
    bytes.extend_from_slice(b"DIRC");
80
    bytes.extend_from_slice(&version.to_be_bytes());
81
    bytes.extend_from_slice(&(entries.len() as u32).to_be_bytes());
82
    for (path, size, mtime_ms) in entries {
83
        let start = bytes.len();
84
        bytes.extend_from_slice(&0u32.to_be_bytes()); // ctime sec
85
        bytes.extend_from_slice(&0u32.to_be_bytes()); // ctime nsec
86
        bytes.extend_from_slice(&((mtime_ms / 1000) as u32).to_be_bytes());
87
        bytes.extend_from_slice(&(((mtime_ms % 1000) * 1_000_000) as u32).to_be_bytes());
88
        bytes.extend_from_slice(&0u32.to_be_bytes()); // dev
89
        bytes.extend_from_slice(&0u32.to_be_bytes()); // ino
90
        bytes.extend_from_slice(&0o100644u32.to_be_bytes()); // mode
91
        bytes.extend_from_slice(&0u32.to_be_bytes()); // uid
92
        bytes.extend_from_slice(&0u32.to_be_bytes()); // gid
93
        bytes.extend_from_slice(&size.to_be_bytes());
94
        bytes.extend_from_slice(&[0u8; 20]); // object id, unread here
95
        bytes.extend_from_slice(&(path.len() as u16).to_be_bytes()); // flags
96
        bytes.extend_from_slice(path.as_bytes());
97
        let entry_len = (INDEX_ENTRY_FIXED + path.len() + 8) & !7;
98
        bytes.resize(start + entry_len, 0);
99
    }
100
    bytes.extend_from_slice(&[0u8; 20]); // trailing checksum, unverified
101
    bytes
102
}
103
104
fn reflog_line(old: &str, new: &str, timestamp: i64, message: &str) -> String {
105
    format!("{old} {new} Ada Lovelace <ada@example.com> {timestamp} -0600\t{message}\n")
106
}
107
108
/// A repo on branch `main` with one loose ref, one packed ref, a two-entry
109
/// reflog, a two-file index, and a workdir where one tracked file matches,
110
/// one drifted, one is missing, and one file is untracked.
111
fn seeded() -> FakeHost {
112
    let mut host = FakeHost::default();
113
    host.file(".git/HEAD", "ref: refs/heads/main\n");
114
    host.dir(".git/refs/heads", vec![entry("main", "file", 41, 0)]);
115
    host.file(".git/refs/heads/main", format!("{SHA_A}\n"));
116
    host.file(
117
        ".git/packed-refs",
118
        format!(
119
            "# pack-refs with: peeled fully-peeled sorted \n{SHA_B} refs/heads/packed-branch\n^{SHA_C}\n{SHA_C} refs/tags/v1\n"
120
        ),
121
    );
122
    host.file(
123
        ".git/logs/HEAD",
124
        format!(
125
            "{}{}",
126
            reflog_line(&"0".repeat(40), SHA_B, 1_700_000_000, "commit (initial): begin"),
127
            reflog_line(SHA_B, SHA_A, 1_700_000_100, "commit: keep\tgoing"),
128
        ),
129
    );
130
    host.file(
131
        ".git/index",
132
        build_index(&[
133
            ("src/kept.rs", 4, 1_000_000),
134
            ("src/drifted.rs", 4, 1_000_000),
135
            ("src/gone.rs", 9, 1_000_000),
136
        ]),
137
    );
138
    host.dir(
139
        "",
140
        vec![entry(".git", "dir", 0, 0), entry("new.txt", "file", 2, 5_000), entry("src", "dir", 0, 0)],
141
    );
142
    host.dir(
143
        "src",
144
        vec![
145
            entry("drifted.rs", "file", 7, 2_000_000),
146
            entry("kept.rs", "file", 4, 1_000_000),
147
        ],
148
    );
149
    host
150
}
151
152
#[test]
153
fn head_on_a_branch_names_the_branch() {
154
    let out = git_facts(&seeded(), &input()).unwrap();
155
    assert_eq!(
156
        out.head,
157
        Some(Head { branch: Some("main".to_string()), detached: None })
158
    );
159
}
160
161
#[test]
162
fn a_detached_head_reports_the_commit_id() {
163
    let mut host = seeded();
164
    host.file(".git/HEAD", format!("{SHA_D}\n"));
165
    let out = git_facts(&host, &input()).unwrap();
166
    assert_eq!(
167
        out.head,
168
        Some(Head { branch: None, detached: Some(SHA_D.to_string()) })
169
    );
170
}
171
172
#[test]
173
fn branches_merge_loose_and_packed_refs_and_loose_wins() {
174
    let mut host = seeded();
175
    // The packed twin of `main` is stale; the loose ref must shadow it.
176
    host.file(
177
        ".git/packed-refs",
178
        format!("{SHA_B} refs/heads/packed-branch\n{SHA_C} refs/heads/main\n"),
179
    );
180
    let out = git_facts(&host, &input()).unwrap();
181
    assert_eq!(
182
        out.branches,
183
        Some(vec![
184
            Branch { name: "main".to_string(), id: SHA_A.to_string() },
185
            Branch { name: "packed-branch".to_string(), id: SHA_B.to_string() },
186
        ])
187
    );
188
}
189
190
#[test]
191
fn packed_refs_only_is_named_in_notes() {
192
    let mut host = seeded();
193
    host.dirs.remove(".git/refs/heads");
194
    host.files.remove(".git/refs/heads/main");
195
    let out = git_facts(&host, &input()).unwrap();
196
    assert_eq!(
197
        out.branches,
198
        Some(vec![Branch { name: "packed-branch".to_string(), id: SHA_B.to_string() }])
199
    );
200
    assert!(out.notes.iter().any(|n| n.contains("packed-refs only")), "{:?}", out.notes);
201
}
202
203
#[test]
204
fn the_log_is_the_reflog_newest_first_with_tabs_kept_in_messages() {
205
    let out = git_facts(&seeded(), &input()).unwrap();
206
    let log = out.log.unwrap();
207
    assert_eq!(log.len(), 2);
208
    assert_eq!(log[0].id, SHA_A);
209
    assert_eq!(log[0].at_ms, 1_700_000_100_000);
210
    // Only the first tab splits header from message; later tabs belong
211
    // to the message.
212
    assert_eq!(log[0].message, "commit: keep\tgoing");
213
    assert_eq!(log[1].id, SHA_B);
214
    assert_eq!(log[1].message, "commit (initial): begin");
215
}
216
217
#[test]
218
fn max_log_keeps_the_newest_entries() {
219
    let mut host = seeded();
220
    let mut lines = String::new();
221
    for i in 0..10 {
222
        lines.push_str(&reflog_line(SHA_B, SHA_A, 1_700_000_000 + i, &format!("commit: {i}")));
223
    }
224
    host.file(".git/logs/HEAD", lines);
225
    let mut asked = input();
226
    asked.max_log = Some(3);
227
    let log = git_facts(&host, &asked).unwrap().log.unwrap();
228
    assert_eq!(
229
        log.iter().map(|e| e.message.as_str()).collect::<Vec<_>>(),
230
        vec!["commit: 9", "commit: 8", "commit: 7"]
231
    );
232
}
233
234
#[test]
235
fn a_missing_reflog_is_a_note_not_an_error() {
236
    let mut host = seeded();
237
    host.files.remove(".git/logs/HEAD");
238
    let out = git_facts(&host, &input()).unwrap();
239
    assert_eq!(out.log, Some(vec![]));
240
    assert!(out.notes.iter().any(|n| n.contains("no reflog")), "{:?}", out.notes);
241
}
242
243
#[test]
244
fn the_hand_built_index_parses_to_the_right_paths_and_sizes() {
245
    let bytes = build_index(&[("a.txt", 5, 12_345), ("dir/b.txt", 700, 999_000)]);
246
    let mut notes = Vec::new();
247
    let parsed = parse_index(&bytes, false, &mut notes);
248
    assert!(parsed.complete);
249
    assert_eq!(parsed.declared, 2);
250
    assert_eq!(
251
        parsed.entries,
252
        vec![
253
            IndexEntry { path: "a.txt".to_string(), size: 5, mtime_ms: 12_345 },
254
            IndexEntry { path: "dir/b.txt".to_string(), size: 700, mtime_ms: 999_000 },
255
        ]
256
    );
257
    assert!(notes.is_empty(), "{notes:?}");
258
}
259
260
#[test]
261
fn status_classifies_changed_untracked_and_missing() {
262
    let out = git_facts(&seeded(), &input()).unwrap();
263
    let status = out.status.unwrap();
264
    assert_eq!(status.tracked, 3);
265
    assert_eq!(status.changed_candidates.paths, vec!["src/drifted.rs"]);
266
    assert_eq!(status.changed_candidates.count, 1);
267
    assert_eq!(status.untracked.paths, vec!["new.txt"]);
268
    assert_eq!(status.missing.paths, vec!["src/gone.rs"]);
269
    assert!(!status.walk_truncated);
270
    assert_eq!(status.comparison, "size_and_mtime_only");
271
}
272
273
#[test]
274
fn a_same_size_mtime_drift_is_a_changed_candidate() {
275
    let mut host = seeded();
276
    // Same size as the index records, different mtime.
277
    host.dir(
278
        "src",
279
        vec![
280
            entry("drifted.rs", "file", 4, 3_000_000),
281
            entry("kept.rs", "file", 4, 1_000_000),
282
        ],
283
    );
284
    let status = git_facts(&host, &input()).unwrap().status.unwrap();
285
    assert_eq!(status.changed_candidates.paths, vec!["src/drifted.rs"]);
286
}
287
288
#[test]
289
fn no_git_head_is_a_refusal_about_the_workspace() {
290
    let mut host = FakeHost::default();
291
    host.dir("", vec![entry("README.md", "file", 10, 0)]);
292
    let refusal = git_facts(&host, &input()).unwrap_err();
293
    assert_eq!(refusal.code, RefusalCode::Unsupported);
294
    assert!(refusal.reason.contains("no git repository"), "{}", refusal.reason);
295
}
296
297
#[test]
298
fn a_missing_index_means_everything_walked_is_untracked() {
299
    let mut host = seeded();
300
    host.files.remove(".git/index");
301
    let out = git_facts(&host, &input()).unwrap();
302
    let status = out.status.unwrap();
303
    assert_eq!(status.tracked, 0);
304
    assert_eq!(status.untracked.count, 3);
305
    assert_eq!(status.missing.count, 0);
306
    assert!(out.notes.iter().any(|n| n.contains("no .git/index")), "{:?}", out.notes);
307
}
308
309
#[test]
310
fn an_index_version_above_2_is_named_and_never_misparsed() {
311
    let mut host = seeded();
312
    host.file(".git/index", build_index_versioned(3, &[("a.txt", 1, 1000)]));
313
    let out = git_facts(&host, &input()).unwrap();
314
    let status = out.status.unwrap();
315
    // The header's count is still honest; the per-entry facts are not
316
    // guessed, and the comparison is refused rather than calling every
317
    // tracked file untracked.
318
    assert_eq!(status.tracked, 1);
319
    assert_eq!(status.changed_candidates.count, 0);
320
    assert_eq!(status.untracked.count, 0);
321
    assert_eq!(status.missing.count, 0);
322
    assert!(
323
        out.notes.iter().any(|n| n.contains("index_version_unsupported") && n.contains("version 3")),
324
        "{:?}",
325
        out.notes
326
    );
327
    assert!(out.notes.iter().any(|n| n.contains("not computed")), "{:?}", out.notes);
328
}
329
330
#[test]
331
fn an_oversized_index_is_read_from_the_front_whole_entries_only() {
332
    let mut host = seeded();
333
    let full = build_index(&[("a.txt", 5, 1_000_000), ("b.txt", 4, 1_000_000)]);
334
    // Serve a prefix that cuts the second entry in half; the range read
335
    // returns what exists and the parser must keep only whole entries.
336
    let cut = 12 + ((INDEX_ENTRY_FIXED + "a.txt".len() + 8) & !7) + 10;
337
    host.file(".git/index", full[..cut].to_vec());
338
    host.oversized.push(".git/index".to_string());
339
    host.dir(
340
        "",
341
        vec![
342
            entry(".git", "dir", 0, 0),
343
            entry("a.txt", "file", 5, 1_000_000),
344
            entry("b.txt", "file", 4, 1_000_000),
345
        ],
346
    );
347
    let out = git_facts(&host, &input()).unwrap();
348
    let status = out.status.unwrap();
349
    assert_eq!(status.tracked, 2);
350
    // `a.txt` parsed and matches; `b.txt` fell past the cut, so the
351
    // comparison sees it as untracked — and a note owns that.
352
    assert_eq!(status.changed_candidates.count, 0);
353
    assert_eq!(status.untracked.paths, vec!["b.txt"]);
354
    assert_eq!(status.missing.count, 0);
355
    assert!(out.notes.iter().any(|n| n.contains("index_truncated")), "{:?}", out.notes);
356
    assert!(out.notes.iter().any(|n| n.contains("truncation point")), "{:?}", out.notes);
357
}
358
359
#[test]
360
fn the_walk_budget_truncates_and_says_so() {
361
    let mut asked = input();
362
    asked.max_walk = Some(1);
363
    let out = git_facts(&seeded(), &asked).unwrap();
364
    let status = out.status.unwrap();
365
    assert!(status.walk_truncated);
366
    assert!(out.notes.iter().any(|n| n.contains("walk stopped")), "{:?}", out.notes);
367
}
368
369
#[test]
370
fn max_paths_bounds_the_listing_but_not_the_count() {
371
    let mut host = seeded();
372
    host.files.remove(".git/index");
373
    let mut root = vec![entry(".git", "dir", 0, 0)];
374
    for i in 0..30 {
375
        root.push(entry(&format!("file-{i:02}.txt"), "file", 1, 0));
376
    }
377
    host.dir("", root);
378
    let mut asked = input();
379
    asked.max_paths = Some(5);
380
    let status = git_facts(&host, &asked).unwrap().status.unwrap();
381
    assert_eq!(status.untracked.count, 30);
382
    assert_eq!(status.untracked.paths.len(), 5);
383
    assert_eq!(status.untracked.paths[0], "file-00.txt");
384
}
385
386
#[test]
387
fn facts_selection_reports_only_what_was_asked() {
388
    let mut asked = input();
389
    asked.facts = Some(vec!["head".to_string(), "weather".to_string()]);
390
    let out = git_facts(&seeded(), &asked).unwrap();
391
    assert!(out.head.is_some());
392
    assert!(out.branches.is_none());
393
    assert!(out.log.is_none());
394
    assert!(out.status.is_none());
395
    assert!(out.notes.iter().any(|n| n.contains("weather")), "{:?}", out.notes);
396
}
397
398
#[test]
399
fn a_long_name_with_the_0xfff_flag_is_read_to_its_nul() {
400
    // Build one entry whose flags claim the 0xFFF overflow length; the
401
    // parser must fall back to scanning for the NUL terminator.
402
    let long = "d/".repeat(30) + "x.txt";
403
    let mut bytes = build_index(&[(long.as_str(), 3, 1000)]);
404
    // Overwrite the flags field with 0xFFF.
405
    let flags_at = 12 + 60;
406
    bytes[flags_at] = 0x0F;
407
    bytes[flags_at + 1] = 0xFF;
408
    let mut notes = Vec::new();
409
    let parsed = parse_index(&bytes, false, &mut notes);
410
    assert_eq!(parsed.entries.len(), 1);
411
    assert_eq!(parsed.entries[0].path, long);
412
}
plugins/knowledge-base/Cargo.toml added +14

@@ -0,0 +1,14 @@

1
[package]
2
name = "knowledge-base"
3
version = "0.1.0"
4
edition.workspace = true
5
license.workspace = true
6
description = "Guest plugin answering questions from the generated OpenAgents knowledge base: curated stances and public-doc summaries, embedded at build time, scored by lexical overlap with stance boost."
7
8
[lib]
9
crate-type = ["cdylib", "rlib"]
10
11
[dependencies]
12
openagents-pdk = { workspace = true }
13
serde = { workspace = true }
14
serde_json = { workspace = true }
plugins/knowledge-base/build-kb.mjs added +48

@@ -0,0 +1,48 @@

1
#!/usr/bin/env node
2
// Regenerate kb.json from the approved public docs plus the curated stances.
3
//
4
// The corpus is content and is reviewed like content: this script harvests
5
// the served docs site (openagents.com priv/docs) into compact entries —
6
// title, summary, headings, tags — merges kb/stances.json, and writes
7
// kb.json beside this script. The plugin embeds kb.json at build, so a
8
// regeneration is: node build-kb.mjs && cargo build --release --target
9
// wasm32-unknown-unknown -p knowledge-base, then re-pin the digest.
10
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
11
import { join, dirname } from "node:path";
12
import { fileURLToPath } from "node:url";
13
14
const here = dirname(fileURLToPath(import.meta.url));
15
const docsRoot = process.argv[2] ?? join(here, "../../../openagents.com/priv/docs");
16
17
const entries = [];
18
for (const name of readdirSync(docsRoot).sort()) {
19
  if (!name.endsWith(".md")) continue;
20
  const text = readFileSync(join(docsRoot, name), "utf8");
21
  const lines = text.split("\n");
22
  const title = (lines.find((line) => line.startsWith("# ")) ?? `# ${name}`).slice(2).trim();
23
  const headings = lines
24
    .filter((line) => line.startsWith("## "))
25
    .map((line) => line.slice(3).trim())
26
    .slice(0, 12);
27
  const body = lines.filter((line) => !line.startsWith("#"));
28
  const first = body.join("\n").trim().split("\n\n")[0]?.replace(/\s+/g, " ").trim() ?? "";
29
  const tags = name.replace(/\.md$/, "").split(/[-_]/).filter((word) => word.length >= 3);
30
  entries.push({
31
    kind: "doc",
32
    title,
33
    source: `priv/docs/${name}`,
34
    headings,
35
    summary: first.slice(0, 400),
36
    tags,
37
  });
38
}
39
40
const stances = JSON.parse(readFileSync(join(here, "kb/stances.json"), "utf8")).map(
41
  (stance) => ({ kind: "stance", ...stance }),
42
);
43
44
writeFileSync(
45
  join(here, "kb.json"),
46
  JSON.stringify({ version: 1, entries: [...stances, ...entries] }, null, 1) + "\n",
47
);
48
console.log(`kb.json: ${stances.length} stances + ${entries.length} docs`);
plugins/knowledge-base/kb.json added +662

@@ -0,0 +1,662 @@

1
{
2
 "version": 1,
3
 "entries": [
4
  {
5
   "kind": "stance",
6
   "id": "earning-bitcoin",
7
   "title": "Earning bitcoin or money on OpenAgents",
8
   "questions": [
9
    "how can I earn bitcoin with this system",
10
    "how do I make money here",
11
    "can I get paid sats for work",
12
    "how do payouts and rewards work",
13
    "monetize my agent or plugin"
14
   ],
15
   "state": "built and specified, deliberately parked; on the near-to-medium-term roadmap",
16
   "answer": "Earning is real history here, not vapor: the network ran a full plugin economy in 2024 (per-use sats pricing, an agent store with revenue sharing, episodes 048-102), later paid sats bounties for merged work, proved Lightning MPP settlement with public receipts, and built forum tipping end to end (Bolt 12 and LNURL destinations, settled-tip ranking). All of it is deliberately parked right now: tipping is disabled in the interface, the plugin manifest's pricing fields are reserved and empty, and no payout lane is live. The honest answer today is that there is nothing you can do yet to earn - and that the rails exist, the history is on the forum, and re-enabling the economy is on the near-to-medium-term roadmap.",
17
   "sources": [
18
    "forum board artanis (Bitcoin accounting and rewards)",
19
    "docs/forum-bitcoin-tips.md",
20
    "docs/2026-08-24-triage-and-plugin-model-assessment.md section 4"
21
   ],
22
   "date": "2026-08-25"
23
  },
24
  {
25
   "kind": "stance",
26
   "id": "threads",
27
   "title": "Threads: the unit of agent work",
28
   "questions": [
29
    "what is a thread",
30
    "how do coder sessions work",
31
    "what is an inference grant",
32
    "where do my coder transcripts go"
33
   ],
34
   "state": "live",
35
   "answer": "A thread is the unit of agent work: opening one (POST /api/v1/threads) returns a budgeted inference grant, the grant is the bearer for the OpenAI-compatible inference proxy, and the session reports its transcript back as append-only thread events that the web /threads pages render live. Accounts hold a bounded number of open threads; a local Ollama session still records a transcript-only thread so everything rehydrates from the server.",
36
   "sources": [
37
    "docs/2026-08-23-thread-primitive-audit.md",
38
    "priv/docs/cli-api.md"
39
   ],
40
   "date": "2026-08-25"
41
  },
42
  {
43
   "kind": "stance",
44
   "id": "tipping",
45
   "title": "Forum tipping status",
46
   "questions": [
47
    "can I tip a post",
48
    "why is tipping disabled",
49
    "how do forum tips work"
50
   ],
51
   "state": "built, currently disabled",
52
   "answer": "Forum tipping is fully built - recipients attach a Bolt 12 offer, LNURL, or on-chain destination they control, the forum never holds keys, and settled tips feed topic ranking - but the payment service is currently disabled, so the tip controls are hidden and any attempt answers that tipping is not enabled yet. It returns when the payment service does.",
53
   "sources": [
54
    "docs/forum-bitcoin-tips.md"
55
   ],
56
   "date": "2026-08-25"
57
  },
58
  {
59
   "kind": "stance",
60
   "id": "plugins",
61
   "title": "The plugin and capability system",
62
   "questions": [
63
    "how do plugins work",
64
    "what are capabilities",
65
    "can I write a plugin for the coder",
66
    "how does the coder discover tools"
67
   ],
68
   "state": "live",
69
   "answer": "A capability is a WASM module plus a manifest: content-addressed by SHA-256 digest, sandboxed with declared read-only mounts and no network, typed input and output schemas, loaded from a digest-pinned catalog with auto-approval. Discovery is the harness's job: every prompt is scored against the catalog, clear matches auto-load so the model simply sees the right tool, and the capability tool remains for explicit search. Authoring one is a Rust function on the owned PDK, a manifest, and a checked-in digest.",
70
   "sources": [
71
    "plugins/README.md",
72
    "docs/2026-08-24-triage-and-plugin-model-assessment.md",
73
    "docs/2026-08-25-plugin-harvest-targets.md"
74
   ],
75
   "date": "2026-08-25"
76
  },
77
  {
78
   "kind": "stance",
79
   "id": "coder-tiers",
80
   "title": "Coder tiers, not model names",
81
   "questions": [
82
    "which model does the coder use",
83
    "what is coder auto or coder pro",
84
    "how do I switch models in the coder"
85
   ],
86
   "state": "live",
87
   "answer": "The coder never shows vendor model names: a session is OpenAgents Coder, and what varies is the tier - Coder Auto (the server picks the lane per call), Coder Flash, Coder Pro, and Coder Local (a local model server answers). Shift+tab cycles the tier; tab cycles reasoning effort. Vendor identifiers survive only in records and exports.",
88
   "sources": [
89
    "INVARIANTS.md, Coder Model Naming"
90
   ],
91
   "date": "2026-08-25"
92
  },
93
  {
94
   "kind": "doc",
95
   "title": "API tokens",
96
   "source": "priv/docs/api-tokens.md",
97
   "headings": [
98
    "Creating a token",
99
    "Using a token",
100
    "Revoke a token",
101
    "Understand the scope"
102
   ],
103
   "summary": "Tokens authenticate programmatic access to the REST API. Manage them at [API tokens](/settings/api-tokens).",
104
   "tags": [
105
    "api",
106
    "tokens"
107
   ]
108
  },
109
  {
110
   "kind": "doc",
111
   "title": "Assignees",
112
   "source": "priv/docs/assignees.md",
113
   "headings": [
114
    "Assigning",
115
    "Unassigning",
116
    "A current limitation"
117
   ],
118
   "summary": "An assignee is the person responsible for an issue. See `/:owner/:repo/assignees`.",
119
   "tags": [
120
    "assignees"
121
   ]
122
  },
123
  {
124
   "kind": "doc",
125
   "title": "Browsing code",
126
   "source": "priv/docs/browsing-code.md",
127
   "headings": [
128
    "Files",
129
    "What is public",
130
    "Size limits"
131
   ],
132
   "summary": "Open a repository that you can access at `/:owner/:repo`. Start from [Repositories](/repositories) when you do not know the exact path.",
133
   "tags": [
134
    "browsing",
135
    "code"
136
   ]
137
  },
138
  {
139
   "kind": "doc",
140
   "title": "Changelog",
141
   "source": "priv/docs/changelog.md",
142
   "headings": [
143
    "Two layers",
144
    "Verifying an entry",
145
    "What is not published"
146
   ],
147
   "summary": "[The changelog](/changelog) lists every change to the application, in two layers.",
148
   "tags": [
149
    "changelog"
150
   ]
151
  },
152
  {
153
   "kind": "doc",
154
   "title": "Claim a legacy identity",
155
   "source": "priv/docs/claim-legacy-identity.md",
156
   "headings": [
157
    "Find your legacy identity",
158
    "Submit the claim",
159
    "Through the API"
160
   ],
161
   "summary": "Posts ported from the previous forum keep their original attribution: the actor reference they were written under and the display name it carried. A claim links your account to one of those legacy identities. After an operator approves the claim, every post written under that identity attributes to your account.",
162
   "tags": [
163
    "claim",
164
    "legacy",
165
    "identity"
166
   ]
167
  },
168
  {
169
   "kind": "doc",
170
   "title": "Call the API with the OpenAgents CLI",
171
   "source": "priv/docs/cli-api.md",
172
   "headings": [
173
    "Before you begin",
174
    "Address a route",
175
    "Select a method and body",
176
    "Work with issues",
177
    "Work with projects",
178
    "Use output in scripts",
179
    "Forum endpoints",
180
    "Related documentation"
181
   ],
182
   "summary": "The `openagents api` command sends an authenticated request to any OpenAgents API route and writes the response body as JSON. Use it for Issues, Projects, and other routes that do not have a named CLI command.",
183
   "tags": [
184
    "cli",
185
    "api"
186
   ]
187
  },
188
  {
189
   "kind": "doc",
190
   "title": "OpenAgents CLI command reference",
191
   "source": "priv/docs/cli-command-reference.md",
192
   "headings": [
193
    "Use global flags",
194
    "Run authentication commands",
195
    "Create a repository",
196
    "Import a repository",
197
    "List repositories",
198
    "View a repository",
199
    "Clone a repository",
200
    "Delete a repository",
201
    "Forum commands",
202
    "Search the forum",
203
    "Moderate the forum",
204
    "Claim a legacy forum identity"
205
   ],
206
   "summary": "The `openagents` command manages authentication and hosted repositories. It also sends authenticated requests to OpenAgents API routes that do not have a named command yet.",
207
   "tags": [
208
    "cli",
209
    "command",
210
    "reference"
211
   ]
212
  },
213
  {
214
   "kind": "doc",
215
   "title": "Clone, push, and pull",
216
   "source": "priv/docs/clone-push-pull.md",
217
   "headings": [
218
    "Clone with the CLI",
219
    "Clone with Git",
220
    "Push and pull",
221
    "Infer a repository from origin",
222
    "Review authentication safety",
223
    "Next steps"
224
   ],
225
   "summary": "OpenAgents serves Git smart HTTP at the HTTPS clone URL returned by the repository API and web interface. Standard Git performs clone, fetch, push, and pull operations.",
226
   "tags": [
227
    "clone",
228
    "push",
229
    "pull"
230
   ]
231
  },
232
  {
233
   "kind": "doc",
234
   "title": "Commits",
235
   "source": "priv/docs/commits.md",
236
   "headings": [
237
    "What a commit page shows",
238
    "Trailers",
239
    "Deploy history"
240
   ],
241
   "summary": "`/:repo/commit/:sha` shows one commit: its message, its author, and the files it changed.",
242
   "tags": [
243
    "commits"
244
   ]
245
  },
246
  {
247
   "kind": "doc",
248
   "title": "Create a repository",
249
   "source": "priv/docs/create-repository.md",
250
   "headings": [
251
    "Create a repository in the browser",
252
    "Create a repository with the CLI",
253
    "Attach an existing local project",
254
    "Push the first commit",
255
    "Follow repository naming rules",
256
    "Create an organization repository",
257
    "Next steps"
258
   ],
259
   "summary": "Create an empty OpenAgents repository in the browser or with the CLI. If the code already exists on GitHub, use a [one-time GitHub import](/docs/import-github) instead.",
260
   "tags": [
261
    "create",
262
    "repository"
263
   ]
264
  },
265
  {
266
   "kind": "doc",
267
   "title": "Creating issues",
268
   "source": "priv/docs/creating-issues.md",
269
   "headings": [
270
    "Required fields",
271
    "Setting labels and a milestone at creation",
272
    "Numbering",
273
    "Do-not-build review"
274
   ],
275
   "summary": "Open the new-issue form at `/:owner/:repo/issues/new`.",
276
   "tags": [
277
    "creating",
278
    "issues"
279
   ]
280
  },
281
  {
282
   "kind": "doc",
283
   "title": "Delete a repository",
284
   "source": "priv/docs/delete-repository.md",
285
   "headings": [
286
    "Delete from the website",
287
    "Delete with the CLI",
288
    "Delete with the REST API"
289
   ],
290
   "summary": "You can permanently delete a repository you own from the website, REST API, or CLI. Maintainers, contributors, and viewers cannot delete a repository.",
291
   "tags": [
292
    "delete",
293
    "repository"
294
   ]
295
  },
296
  {
297
   "kind": "doc",
298
   "title": "Do-not-build register",
299
   "source": "priv/docs/do-not-build-register.md",
300
   "headings": [
301
    "What each entry records",
302
    "How screening works",
303
    "Reconsider a decision",
304
    "Initial decisions"
305
   ],
306
   "summary": "The do-not-build register records product and architecture directions that OpenAgents retired, deferred, rejected, or superseded. It keeps agents and maintainers from repeatedly proposing work whose replacement already exists.",
307
   "tags": [
308
    "not",
309
    "build",
310
    "register"
311
   ]
312
  },
313
  {
314
   "kind": "doc",
315
   "title": "Boards, topics, and posts",
316
   "source": "priv/docs/forum.md",
317
   "headings": [
318
    "Where the old forum went",
319
    "Reading",
320
    "Posting",
321
    "Through the API"
322
   ],
323
   "summary": "The forum is the discussion surface at [/forum](/forum): boards, each holding topics, and each topic a series of posts. It replaces the previous OpenAgents forum, and every board, topic, and post from that forum moved here.",
324
   "tags": [
325
    "forum"
326
   ]
327
  },
328
  {
329
   "kind": "doc",
330
   "title": "Import a GitHub repository",
331
   "source": "priv/docs/import-github.md",
332
   "headings": [
333
    "Check the prerequisites",
334
    "Import in the browser",
335
    "Import with an installed CLI",
336
    "Import once with npx",
337
    "Import a large repository",
338
    "Verify an import",
339
    "Understand what the import copies",
340
    "Work after the import",
341
    "Next steps"
342
   ],
343
   "summary": "An import copies one accepted GitHub snapshot into a new OpenAgents repository. It is a one-time copy, not a mirror. Later changes do not synchronize in either direction.",
344
   "tags": [
345
    "import",
346
    "github"
347
   ]
348
  },
349
  {
350
   "kind": "doc",
351
   "title": "Install the OpenAgents CLI",
352
   "source": "priv/docs/install-cli.md",
353
   "headings": [
354
    "Install globally with npm",
355
    "Run one command with npx",
356
    "Sign in",
357
    "Use a token without a browser",
358
    "Select an API profile",
359
    "Configure Git authentication",
360
    "Next steps"
361
   ],
362
   "summary": "The npm package is `@openagentsinc/cli`. It provides the `openagents` command and requires Node.js 20 or later.",
363
   "tags": [
364
    "install",
365
    "cli"
366
   ]
367
  },
368
  {
369
   "kind": "doc",
370
   "title": "Issue tracking",
371
   "source": "priv/docs/issues.md",
372
   "headings": [
373
    "Filtering by state",
374
    "What an issue holds",
375
    "Comments",
376
    "Through the API"
377
   ],
378
   "summary": "An issue is a unit of work in a repository. Browse them at `/:owner/:repo/issues`.",
379
   "tags": [
380
    "issues"
381
   ]
382
  },
383
  {
384
   "kind": "doc",
385
   "title": "Labels",
386
   "source": "priv/docs/labels.md",
387
   "headings": [
388
    "Creating a label",
389
    "Applying labels",
390
    "Deleting a label",
391
    "Names with spaces"
392
   ],
393
   "summary": "Labels classify issues. Manage them at `/:owner/:repo/labels`.",
394
   "tags": [
395
    "labels"
396
   ]
397
  },
398
  {
399
   "kind": "doc",
400
   "title": "Leaderboard",
401
   "source": "priv/docs/leaderboard.md",
402
   "headings": [
403
    "What is shown",
404
    "Opting out",
405
    "Updates"
406
   ],
407
   "summary": "[The leaderboard](/leaderboard) ranks contributors by tokens spent through the application.",
408
   "tags": [
409
    "leaderboard"
410
   ]
411
  },
412
  {
413
   "kind": "doc",
414
   "title": "Merging stacks",
415
   "source": "priv/docs/merging-stacks.md",
416
   "headings": [
417
    "Merge a prefix in one operation",
418
    "Merge one layer asynchronously",
419
    "Automatic restack after a partial merge",
420
    "What atomic means here",
421
    "Next steps"
422
   ],
423
   "summary": "A stack merges bottom-first. You can land the whole stack or any contiguous prefix of it — layers 1 and 2 while layer 3 is still in review — but never a layer whose foundations have not landed.",
424
   "tags": [
425
    "merging",
426
    "stacks"
427
   ]
428
  },
429
  {
430
   "kind": "doc",
431
   "title": "Milestones",
432
   "source": "priv/docs/milestones.md",
433
   "headings": [
434
    "Creating a milestone",
435
    "Progress",
436
    "Closing a milestone"
437
   ],
438
   "summary": "A milestone groups issues toward a target. Manage them at `/:owner/:repo/milestones`.",
439
   "tags": [
440
    "milestones"
441
   ]
442
  },
443
  {
444
   "kind": "doc",
445
   "title": "Models and pricing",
446
   "source": "priv/docs/models.md",
447
   "headings": [
448
    "What each column tells you",
449
    "Reading the basis",
450
    "Ceilings",
451
    "Reading the catalog from the CLI"
452
   ],
453
   "summary": "[The models page](/models) lists every model this deployment serves, what each one charges per million tokens, and where those rates came from. Read it before you open a thread: a thread pins one model for its whole life, and the grant that pays for it meters against that model's rates.",
454
   "tags": [
455
    "models"
456
   ]
457
  },
458
  {
459
   "kind": "doc",
460
   "title": "The OpenAgents CLI",
461
   "source": "priv/docs/openagents-cli.md",
462
   "headings": [
463
    "What the CLI does",
464
    "Choose how to run the CLI",
465
    "Follow a common terminal workflow",
466
    "Next steps"
467
   ],
468
   "summary": "`openagents` is the OpenAgents command-line interface, distributed on npm as `@openagentsinc/cli`. It signs you in, configures Git authentication, manages [repositories](/docs/repositories) from a terminal, and calls implemented API routes such as Issues and Projects.",
469
   "tags": [
470
    "openagents",
471
    "cli"
472
   ]
473
  },
474
  {
475
   "kind": "doc",
476
   "title": "Project boards",
477
   "source": "priv/docs/projects.md",
478
   "headings": [
479
    "The description",
480
    "Discussion and activity",
481
    "The board",
482
    "Fields",
483
    "Through the API"
484
   ],
485
   "summary": "A project is a board of issues. Browse them at `/:owner/:repo/projects`.",
486
   "tags": [
487
    "projects"
488
   ]
489
  },
490
  {
491
   "kind": "doc",
492
   "title": "Proposing and merging changes",
493
   "source": "priv/docs/pull-requests.md",
494
   "headings": [
495
    "What a pull request holds",
496
    "The review diff",
497
    "Create one through the API",
498
    "Merging",
499
    "Next steps"
500
   ],
501
   "summary": "A pull request proposes merging one branch into another. Browse a repository's pull requests at `/:owner/:repo/pulls`, and open one to review its changes at `/:owner/:repo/pulls/:number`.",
502
   "tags": [
503
    "pull",
504
    "requests"
505
   ]
506
  },
507
  {
508
   "kind": "doc",
509
   "title": "Repository hosting",
510
   "source": "priv/docs/repositories.md",
511
   "headings": [
512
    "What you can do",
513
    "Understand namespaces and access",
514
    "Understand visibility and credentials",
515
    "Manage repositories in the browser",
516
    "Next steps"
517
   ],
518
   "summary": "OpenAgents hosts Git repositories and serves them through Git smart HTTP. You can create, import, browse, and delete repositories in the browser, and you can do the same work from a terminal with the [OpenAgents CLI](/docs/openagents-cli).",
519
   "tags": [
520
    "repositories"
521
   ]
522
  },
523
  {
524
   "kind": "doc",
525
   "title": "REST API",
526
   "source": "priv/docs/rest-api.md",
527
   "headings": [
528
    "Authenticate",
529
    "Repositories",
530
    "Issues and comments",
531
    "Pull requests and stacks",
532
    "Issue prerequisites",
533
    "The OpenAgents extension namespace",
534
    "Issue progress",
535
    "Reputation attestations",
536
    "Labels",
537
    "Assignees and milestones",
538
    "Projects",
539
    "Choose a client"
540
   ],
541
   "summary": "OpenAgents serves a bounded GitHub-shaped API under `/api/v1`. The paths make familiar repository tooling easier to adapt, but OpenAgents does not implement the complete GitHub API.",
542
   "tags": [
543
    "rest",
544
    "api"
545
   ]
546
  },
547
  {
548
   "kind": "doc",
549
   "title": "Signing in",
550
   "source": "priv/docs/signing-in.md",
551
   "headings": [
552
    "How it works",
553
    "What is stored",
554
    "Signing out",
555
    "Sign in from the CLI"
556
   ],
557
   "summary": "Identity comes from GitHub. There is no separate OpenAgents password.",
558
   "tags": [
559
    "signing"
560
   ]
561
  },
562
  {
563
   "kind": "doc",
564
   "title": "Rebase and restructure a stack",
565
   "source": "priv/docs/stack-actions.md",
566
   "headings": [
567
    "Rebase the stack",
568
    "Remove the top layer",
569
    "Grow the stack",
570
    "Dissolve the stack",
571
    "Next steps"
572
   ],
573
   "summary": "A stack stays useful only while its branches stay coherent. These actions keep them that way, and every one of them runs on the server as a durable operation rather than as a sequence of client-side pushes that can be interrupted halfway.",
574
   "tags": [
575
    "stack",
576
    "actions"
577
   ]
578
  },
579
  {
580
   "kind": "doc",
581
   "title": "Stacked pull requests",
582
   "source": "priv/docs/stacked-pull-requests.md",
583
   "headings": [
584
    "Why stack",
585
    "The shape of a stack",
586
    "The stack map",
587
    "Layer and cumulative review",
588
    "Create a stack",
589
    "Next steps"
590
   ],
591
   "summary": "A stack is an ordered series of dependent pull requests that merge bottom-first into one trunk branch. Each layer's branch is based on the layer below it, so a large change ships as several small reviews without losing the dependency structure between them.",
592
   "tags": [
593
    "stacked",
594
    "pull",
595
    "requests"
596
   ]
597
  },
598
  {
599
   "kind": "doc",
600
   "title": "Stacks API",
601
   "source": "priv/docs/stacks-api.md",
602
   "headings": [
603
    "Routes",
604
    "The stack payload",
605
    "Idempotency",
606
    "Optimistic concurrency",
607
    "Operations",
608
    "Worked example",
609
    "Next steps"
610
   ],
611
   "summary": "The stack surface lives under `/api/v1` beside the rest of the [REST API](/docs/rest-api). Reads are public on a public repository; writes require an `oa_pat_` bearer token with `forge:write` scope and an `Idempotency-Key` header.",
612
   "tags": [
613
    "stacks",
614
    "api"
615
   ]
616
  },
617
  {
618
   "kind": "doc",
619
   "title": "Status API",
620
   "source": "priv/docs/status-api.md",
621
   "headings": [
622
    "Shape",
623
    "Disclosure",
624
    "Polling",
625
    "Related"
626
   ],
627
   "summary": "[`/api/status`](/api/status) returns the same projection the [status page](/status) renders, as JSON.",
628
   "tags": [
629
    "status",
630
    "api"
631
   ]
632
  },
633
  {
634
   "kind": "doc",
635
   "title": "Status",
636
   "source": "priv/docs/status.md",
637
   "headings": [
638
    "What it shows",
639
    "Content-free by design",
640
    "The API"
641
   ],
642
   "summary": "[Status](/status) reports whether the system is healthy, without disclosing its internals.",
643
   "tags": [
644
    "status"
645
   ]
646
  },
647
  {
648
   "kind": "doc",
649
   "title": "Welcome",
650
   "source": "priv/docs/welcome.md",
651
   "headings": [
652
    "What is here now",
653
    "What is not here yet",
654
    "Compatibility"
655
   ],
656
   "summary": "OpenAgents is a software forge with an agent layer on top of it. This documentation covers the parts you can use today.",
657
   "tags": [
658
    "welcome"
659
   ]
660
  }
661
 ]
662
}
plugins/knowledge-base/kb/stances.json added +71

@@ -0,0 +1,71 @@

1
[
2
  {
3
    "id": "earning-bitcoin",
4
    "title": "Earning bitcoin or money on OpenAgents",
5
    "questions": [
6
      "how can I earn bitcoin with this system",
7
      "how do I make money here",
8
      "can I get paid sats for work",
9
      "how do payouts and rewards work",
10
      "monetize my agent or plugin"
11
    ],
12
    "state": "built and specified, deliberately parked; on the near-to-medium-term roadmap",
13
    "answer": "Earning is real history here, not vapor: the network ran a full plugin economy in 2024 (per-use sats pricing, an agent store with revenue sharing, episodes 048-102), later paid sats bounties for merged work, proved Lightning MPP settlement with public receipts, and built forum tipping end to end (Bolt 12 and LNURL destinations, settled-tip ranking). All of it is deliberately parked right now: tipping is disabled in the interface, the plugin manifest's pricing fields are reserved and empty, and no payout lane is live. The honest answer today is that there is nothing you can do yet to earn - and that the rails exist, the history is on the forum, and re-enabling the economy is on the near-to-medium-term roadmap.",
14
    "sources": ["forum board artanis (Bitcoin accounting and rewards)", "docs/forum-bitcoin-tips.md", "docs/2026-08-24-triage-and-plugin-model-assessment.md section 4"],
15
    "date": "2026-08-25"
16
  },
17
  {
18
    "id": "threads",
19
    "title": "Threads: the unit of agent work",
20
    "questions": [
21
      "what is a thread",
22
      "how do coder sessions work",
23
      "what is an inference grant",
24
      "where do my coder transcripts go"
25
    ],
26
    "state": "live",
27
    "answer": "A thread is the unit of agent work: opening one (POST /api/v1/threads) returns a budgeted inference grant, the grant is the bearer for the OpenAI-compatible inference proxy, and the session reports its transcript back as append-only thread events that the web /threads pages render live. Accounts hold a bounded number of open threads; a local Ollama session still records a transcript-only thread so everything rehydrates from the server.",
28
    "sources": ["docs/2026-08-23-thread-primitive-audit.md", "priv/docs/cli-api.md"],
29
    "date": "2026-08-25"
30
  },
31
  {
32
    "id": "tipping",
33
    "title": "Forum tipping status",
34
    "questions": [
35
      "can I tip a post",
36
      "why is tipping disabled",
37
      "how do forum tips work"
38
    ],
39
    "state": "built, currently disabled",
40
    "answer": "Forum tipping is fully built - recipients attach a Bolt 12 offer, LNURL, or on-chain destination they control, the forum never holds keys, and settled tips feed topic ranking - but the payment service is currently disabled, so the tip controls are hidden and any attempt answers that tipping is not enabled yet. It returns when the payment service does.",
41
    "sources": ["docs/forum-bitcoin-tips.md"],
42
    "date": "2026-08-25"
43
  },
44
  {
45
    "id": "plugins",
46
    "title": "The plugin and capability system",
47
    "questions": [
48
      "how do plugins work",
49
      "what are capabilities",
50
      "can I write a plugin for the coder",
51
      "how does the coder discover tools"
52
    ],
53
    "state": "live",
54
    "answer": "A capability is a WASM module plus a manifest: content-addressed by SHA-256 digest, sandboxed with declared read-only mounts and no network, typed input and output schemas, loaded from a digest-pinned catalog with auto-approval. Discovery is the harness's job: every prompt is scored against the catalog, clear matches auto-load so the model simply sees the right tool, and the capability tool remains for explicit search. Authoring one is a Rust function on the owned PDK, a manifest, and a checked-in digest.",
55
    "sources": ["plugins/README.md", "docs/2026-08-24-triage-and-plugin-model-assessment.md", "docs/2026-08-25-plugin-harvest-targets.md"],
56
    "date": "2026-08-25"
57
  },
58
  {
59
    "id": "coder-tiers",
60
    "title": "Coder tiers, not model names",
61
    "questions": [
62
      "which model does the coder use",
63
      "what is coder auto or coder pro",
64
      "how do I switch models in the coder"
65
    ],
66
    "state": "live",
67
    "answer": "The coder never shows vendor model names: a session is OpenAgents Coder, and what varies is the tier - Coder Auto (the server picks the lane per call), Coder Flash, Coder Pro, and Coder Local (a local model server answers). Shift+tab cycles the tier; tab cycles reasoning effort. Vendor identifiers survive only in records and exports.",
68
    "sources": ["INVARIANTS.md, Coder Model Naming"],
69
    "date": "2026-08-25"
70
  }
71
]
plugins/knowledge-base/knowledge_base.wasm added

Binary file. Nothing to show as text.

plugins/knowledge-base/manifest.json added +76

@@ -0,0 +1,76 @@

1
{
2
  "manifest_version": 1,
3
  "name": "knowledge_base",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "Answer questions about OpenAgents itself from the generated knowledge base: what is built, parked, or planned — earning bitcoin or money, payouts, tipping, threads, plugins, Coder tiers — plus summaries of every public doc. Pure lookup over an embedded corpus with no file, network, or environment access. The harness consults it automatically and attaches relevant stances and docs to the conversation; prefer its dated, reviewed positions over guesses when they conflict with intuition.",
7
  "artifact": {
8
    "path": "knowledge_base.wasm",
9
    "digest": "sha256:7897da0a140d6a022a0c75ac5356f971e4de22a5e756a1a99d970dbc76703ebf"
10
  },
11
  "abi": {
12
    "kind": "packet-v0",
13
    "entry": "handle_packet",
14
    "alloc": "packet_alloc"
15
  },
16
  "interface": {
17
    "input": {
18
      "type": "object",
19
      "properties": {
20
        "query": {
21
          "type": "string",
22
          "description": "The question or topic to look up."
23
        },
24
        "limit": {
25
          "type": "integer",
26
          "description": "Most hits to return; 1 through 10, default 3."
27
        }
28
      },
29
      "required": ["query"],
30
      "additionalProperties": false
31
    },
32
    "output": {
33
      "type": "object",
34
      "properties": {
35
        "ok": {
36
          "type": "object",
37
          "properties": {
38
            "hits": {
39
              "type": "array",
40
              "items": {
41
                "type": "object",
42
                "properties": {
43
                  "kind": { "type": "string", "enum": ["stance", "doc"] },
44
                  "title": { "type": "string" },
45
                  "body": { "type": "string" },
46
                  "state": { "type": "string" },
47
                  "sources": { "type": "array", "items": { "type": "string" } },
48
                  "date": { "type": "string" },
49
                  "score": { "type": "integer" }
50
                },
51
                "required": ["kind", "title", "body", "sources", "score"]
52
              }
53
            }
54
          },
55
          "required": ["hits"]
56
        },
57
        "refusal": {
58
          "type": "object",
59
          "properties": {
60
            "code": { "type": "string" },
61
            "reason": { "type": "string" }
62
          },
63
          "required": ["code", "reason"]
64
        }
65
      }
66
    }
67
  },
68
  "capabilities": {
69
    "mounts": [],
70
    "hosts": [],
71
    "timeout_ms": 2000,
72
    "memory_max_mib": 64
73
  },
74
  "price_msats": null,
75
  "license": "Apache-2.0"
76
}
plugins/knowledge-base/src/lib.rs added +229

@@ -0,0 +1,229 @@

1
//! The OpenAgents knowledge base, as a `packet-v0` guest plugin.
2
//!
3
//! The corpus is generated ahead of time by `build-kb.mjs` — curated stances
4
//! (reviewed positions on what is built, parked, or planned, each dated) plus
5
//! summaries of the public docs — and embedded into the artifact with
6
//! `include_str!`, so answering needs no mounts, no network, and no host
7
//! imports at all. The guest is a ranking function over that frozen corpus.
8
//!
9
//! Scoring is deliberately simple lexical overlap: the query's distinct
10
//! words of three or more characters are matched against each entry's title,
11
//! questions, tags, state, and body text. A stance whose curated `questions`
12
//! overlap the query is what the corpus exists for, so question hits weigh
13
//! heaviest and stances outrank docs at equal overlap. The harness surfaces
14
//! the top hits as context; it never asks the model to call this as a tool.
15
16
use openagents_pdk::{plugin_entry, Refusal, RefusalCode};
17
use serde::{Deserialize, Serialize};
18
19
/// The generated corpus, frozen into the artifact at build time.
20
const KB: &str = include_str!("../kb.json");
21
22
/// Most hits one lookup returns; the harness attaches at most the top few.
23
const LIMIT_BOUND: usize = 10;
24
const DEFAULT_LIMIT: usize = 3;
25
26
#[derive(Deserialize)]
27
struct Input {
28
    query: String,
29
    #[serde(default)]
30
    limit: Option<usize>,
31
}
32
33
#[derive(Deserialize)]
34
struct Corpus {
35
    entries: Vec<Entry>,
36
}
37
38
#[derive(Deserialize, Clone)]
39
#[serde(tag = "kind", rename_all = "lowercase")]
40
enum Entry {
41
    Stance {
42
        title: String,
43
        questions: Vec<String>,
44
        state: String,
45
        answer: String,
46
        #[serde(default)]
47
        sources: Vec<String>,
48
        date: String,
49
    },
50
    Doc {
51
        title: String,
52
        source: String,
53
        #[serde(default)]
54
        headings: Vec<String>,
55
        summary: String,
56
        #[serde(default)]
57
        tags: Vec<String>,
58
    },
59
}
60
61
#[derive(Serialize)]
62
struct Hit {
63
    kind: &'static str,
64
    title: String,
65
    /// The stance's position or the doc's summary — the text worth attaching.
66
    body: String,
67
    /// The stance's lifecycle state ("built", "parked", …); absent for docs.
68
    #[serde(skip_serializing_if = "Option::is_none")]
69
    state: Option<String>,
70
    /// Where the claim comes from: doc paths, or a stance's cited sources.
71
    sources: Vec<String>,
72
    /// The date the stance was last reviewed; absent for docs.
73
    #[serde(skip_serializing_if = "Option::is_none")]
74
    date: Option<String>,
75
    score: u32,
76
}
77
78
#[derive(Serialize)]
79
struct Output {
80
    hits: Vec<Hit>,
81
}
82
83
/// The query's distinct lowercase words of three or more characters — the
84
/// same shape the capability matcher uses, so the two rails rank alike.
85
fn terms(query: &str) -> Vec<String> {
86
    let mut words: Vec<String> = query
87
        .split(|c: char| !c.is_alphanumeric())
88
        .filter(|w| w.len() >= 3)
89
        .map(|w| w.to_lowercase())
90
        .collect();
91
    words.sort();
92
    words.dedup();
93
    words
94
}
95
96
fn contains_word(haystack: &str, word: &str) -> bool {
97
    haystack
98
        .split(|c: char| !c.is_alphanumeric())
99
        .any(|w| w.eq_ignore_ascii_case(word))
100
}
101
102
/// Overlap score: question and title hits carry the corpus's intent, so they
103
/// weigh 3; tags and state weigh 2; body text weighs 1.
104
fn score(entry: &Entry, terms: &[String]) -> u32 {
105
    let mut total = 0;
106
    for term in terms {
107
        total += match entry {
108
            Entry::Stance {
109
                title,
110
                questions,
111
                state,
112
                answer,
113
                ..
114
            } => {
115
                let questioned = questions.iter().any(|q| contains_word(q, term));
116
                if questioned || contains_word(title, term) {
117
                    3
118
                } else if contains_word(state, term) {
119
                    2
120
                } else if contains_word(answer, term) {
121
                    1
122
                } else {
123
                    0
124
                }
125
            }
126
            Entry::Doc {
127
                title,
128
                headings,
129
                summary,
130
                tags,
131
                ..
132
            } => {
133
                if contains_word(title, term) {
134
                    3
135
                } else if tags.iter().any(|t| t.eq_ignore_ascii_case(term))
136
                    || headings.iter().any(|h| contains_word(h, term))
137
                {
138
                    2
139
                } else if contains_word(summary, term) {
140
                    1
141
                } else {
142
                    0
143
                }
144
            }
145
        };
146
    }
147
    total
148
}
149
150
fn hit(entry: Entry, score: u32) -> Hit {
151
    match entry {
152
        Entry::Stance {
153
            title,
154
            state,
155
            answer,
156
            sources,
157
            date,
158
            ..
159
        } => Hit {
160
            kind: "stance",
161
            title,
162
            body: answer,
163
            state: Some(state),
164
            sources,
165
            date: Some(date),
166
            score,
167
        },
168
        Entry::Doc {
169
            title,
170
            source,
171
            summary,
172
            ..
173
        } => Hit {
174
            kind: "doc",
175
            title,
176
            body: summary,
177
            state: None,
178
            sources: vec![source],
179
            date: None,
180
            score,
181
        },
182
    }
183
}
184
185
fn handle(input: Input) -> Result<Output, Refusal> {
186
    let terms = terms(&input.query);
187
    if terms.is_empty() {
188
        return Err(Refusal {
189
            code: RefusalCode::Unsupported,
190
            reason: "The query held no words of three or more characters to match on.".to_string(),
191
        });
192
    }
193
194
    let corpus: Corpus = serde_json::from_str(KB).map_err(|error| Refusal {
195
        code: RefusalCode::Internal,
196
        reason: format!("The embedded knowledge base did not parse: {error}"),
197
    })?;
198
199
    let limit = input.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, LIMIT_BOUND);
200
201
    let mut scored: Vec<(u32, u32, Entry)> = corpus
202
        .entries
203
        .into_iter()
204
        .filter_map(|entry| {
205
            let overlap = score(&entry, &terms);
206
            if overlap == 0 {
207
                return None;
208
            }
209
            // Stances outrank docs at equal overlap: they are the reviewed
210
            // positions the corpus exists to carry.
211
            let stance = matches!(entry, Entry::Stance { .. }) as u32;
212
            Some((overlap, stance, entry))
213
        })
214
        .collect();
215
    scored.sort_by(|a, b| (b.0, b.1).cmp(&(a.0, a.1)));
216
    scored.truncate(limit);
217
218
    Ok(Output {
219
        hits: scored
220
            .into_iter()
221
            .map(|(overlap, _, entry)| hit(entry, overlap))
222
            .collect(),
223
    })
224
}
225
226
plugin_entry!(handle);
227
228
#[cfg(test)]
229
mod tests;
plugins/knowledge-base/src/tests.rs added +91

@@ -0,0 +1,91 @@

1
//! Ranking over the real embedded corpus: these tests pin the acceptance
2
//! behavior — the earning question surfaces the parked-economy stance, docs
3
//! answer doc questions, and stances outrank docs — against the actual
4
//! `kb.json` the artifact ships, so a regeneration that breaks retrieval
5
//! fails here before it reaches the harness.
6
7
use super::*;
8
9
fn lookup(query: &str, limit: Option<usize>) -> Output {
10
    handle(Input {
11
        query: query.to_string(),
12
        limit,
13
    })
14
    .expect("the lookup should succeed")
15
}
16
17
#[test]
18
fn terms_keep_distinct_lowercase_words_of_three_or_more() {
19
    let words = terms("How can I earn Bitcoin, earn BITCOIN?");
20
    assert_eq!(words, vec!["bitcoin", "can", "earn", "how"]);
21
}
22
23
#[test]
24
fn an_empty_query_is_refused() {
25
    let refusal = handle(Input {
26
        query: "a & b".to_string(),
27
        limit: None,
28
    })
29
    .err()
30
    .expect("two-letter words alone should refuse");
31
    assert_eq!(refusal.code, RefusalCode::Unsupported);
32
}
33
34
#[test]
35
fn the_earning_question_surfaces_the_parked_economy_stance() {
36
    let out = lookup("How can I earn bitcoin with this system?", None);
37
    let top = out.hits.first().expect("the corpus should answer this");
38
    assert_eq!(top.kind, "stance");
39
    assert_eq!(top.title, "Earning bitcoin or money on OpenAgents");
40
    assert!(top.state.as_deref().unwrap_or("").contains("parked"));
41
    assert!(top.date.is_some(), "a stance carries its review date");
42
    assert!(!top.body.is_empty());
43
}
44
45
#[test]
46
fn a_doc_question_surfaces_the_doc() {
47
    let out = lookup("how do I create an API token?", None);
48
    assert!(
49
        out.hits
50
            .iter()
51
            .any(|hit| hit.kind == "doc" && hit.title == "API tokens"),
52
        "the API-tokens doc should rank for its own question"
53
    );
54
}
55
56
#[test]
57
fn unrelated_queries_come_back_empty_not_wrong() {
58
    let out = lookup("quaternion spline interpolation", None);
59
    assert!(out.hits.is_empty());
60
}
61
62
#[test]
63
fn the_limit_is_honored_and_clamped() {
64
    assert!(lookup("openagents", Some(1)).hits.len() <= 1);
65
    assert!(lookup("openagents", Some(500)).hits.len() <= LIMIT_BOUND);
66
}
67
68
#[test]
69
fn stances_outrank_docs_at_equal_overlap() {
70
    let stance = Entry::Stance {
71
        title: "widgets".into(),
72
        questions: vec![],
73
        state: String::new(),
74
        answer: String::new(),
75
        sources: vec![],
76
        date: "2026-08-25".into(),
77
    };
78
    let doc = Entry::Doc {
79
        title: "widgets".into(),
80
        source: "d.md".into(),
81
        headings: vec![],
82
        summary: String::new(),
83
        tags: vec![],
84
    };
85
    let terms = terms("widgets");
86
    assert_eq!(score(&stance, &terms), score(&doc, &terms));
87
    // Equal overlap: the tiebreak in `handle` prefers the stance.
88
    let mut scored = vec![(3u32, 0u32, doc), (3u32, 1u32, stance)];
89
    scored.sort_by(|a, b| (b.0, b.1).cmp(&(a.0, a.1)));
90
    assert!(matches!(scored[0].2, Entry::Stance { .. }));
91
}
plugins/patch-check/Cargo.toml added +14

@@ -0,0 +1,14 @@

1
[package]
2
name = "patch-check"
3
version = "0.1.0"
4
edition.workspace = true
5
license.workspace = true
6
description = "Guest plugin for the OpenAgents coder: validate a unified diff against a file's current content — placement, drift, and an optional preview — as pure computation on the owned PDK."
7
8
[lib]
9
crate-type = ["cdylib", "rlib"]
10
11
[dependencies]
12
openagents-pdk = { workspace = true }
13
serde = { workspace = true }
14
serde_json = { workspace = true }
plugins/patch-check/README.md added +26

@@ -0,0 +1,26 @@

1
# patch-check
2
3
Pure-computation guest plugin: a unified diff and a file's current content
4
in, a placement report out, no imports. Built on `openagents-pdk` (see
5
`../pdk/`), which owns the whole `packet-v0` ABI — this crate is one typed
6
`handle` function and a `plugin_entry!` invocation. The built artifact
7
`patch_check.wasm` and its digest pin in `manifest.json` are checked in so
8
the plugin runs without a Rust toolchain.
9
10
The check validates the diff against the content *before* anyone claims it
11
applies: each hunk is tried at its declared position (with the line shift
12
from previously applied hunks accounted for), then searched within `fuzz`
13
lines for a unique match. The report says where each hunk lands (`at_line`,
14
1-based in the current content), how far off it drifted (`drift_lines`,
15
signed), and why a hunk failed (`context_not_found` or `ambiguous`, quoting
16
the first mismatching context line). One file per call; a multi-file diff
17
is refused. `include_preview` returns the post-application content, elided
18
in the middle past `max_preview_chars`.
19
20
The placement logic is unit-tested in `src/tests.rs` (pure, no host); the
21
sandbox test in
22
`packages/openagents-cli/test/coder-plugin-patch-check.test.ts` proves the
23
same behavior through the real WASM boundary.
24
25
Rebuild from `plugins/` (then update `artifact.digest` in `manifest.json`):
26
see `../README.md`.
plugins/patch-check/manifest.json added +116

@@ -0,0 +1,116 @@

1
{
2
  "manifest_version": 1,
3
  "name": "patch_check",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "Check whether a unified diff or patch still applies to a file's current content, and where it drifts. Use it before claiming a diff applies cleanly: it validates each hunk against the text you pass in, reports the line each hunk lands on, the signed drift when a hunk placed off its declared position, and why a hunk failed (context_not_found or ambiguous, quoting the first mismatching context line). Pure computation with no file, network, or environment access — both the diff and the file content arrive as input. One file per call; optionally returns a bounded preview of the post-application content.",
7
  "artifact": {
8
    "path": "patch_check.wasm",
9
    "digest": "sha256:3fb2903334721854ee84a3a9ba4cf8dbf93a42b9eb7122026b4ecaf4fa060c3f"
10
  },
11
  "abi": {
12
    "kind": "packet-v0",
13
    "entry": "handle_packet",
14
    "alloc": "packet_alloc"
15
  },
16
  "interface": {
17
    "input": {
18
      "type": "object",
19
      "properties": {
20
        "diff": {
21
          "type": "string",
22
          "description": "The unified diff to check: one file, one or more @@ hunks. A diff naming multiple files is refused."
23
        },
24
        "content": {
25
          "type": "string",
26
          "description": "The file's current text, checked as-is."
27
        },
28
        "fuzz": {
29
          "type": "integer",
30
          "description": "Max line-offset drift searched when exact placement fails. Default 200, capped at 1000."
31
        },
32
        "whitespace_lenient": {
33
          "type": "boolean",
34
          "description": "When true, context lines match ignoring trailing whitespace. Default false."
35
        },
36
        "include_preview": {
37
          "type": "boolean",
38
          "description": "When true and every hunk applies, return the post-application content. Default false."
39
        },
40
        "max_preview_chars": {
41
          "type": "integer",
42
          "description": "Character ceiling for the preview; longer previews are elided in the middle with a '[N chars elided]' marker. Default 20000, capped at 100000."
43
        }
44
      },
45
      "required": ["diff", "content"],
46
      "additionalProperties": false
47
    },
48
    "output": {
49
      "type": "object",
50
      "properties": {
51
        "ok": {
52
          "type": "object",
53
          "properties": {
54
            "applies": {
55
              "type": "boolean",
56
              "description": "True when every hunk placed."
57
            },
58
            "hunks": {
59
              "type": "array",
60
              "items": {
61
                "type": "object",
62
                "properties": {
63
                  "index": { "type": "integer" },
64
                  "applies": { "type": "boolean" },
65
                  "at_line": {
66
                    "type": "integer",
67
                    "description": "Where the hunk's old side sits, 1-based in the current content."
68
                  },
69
                  "drift_lines": {
70
                    "type": "integer",
71
                    "description": "Signed line offset from the declared position, present only when the hunk placed off it."
72
                  },
73
                  "reason": {
74
                    "type": "string",
75
                    "enum": ["context_not_found", "ambiguous"],
76
                    "description": "Why the hunk did not place."
77
                  },
78
                  "mismatch": {
79
                    "type": "string",
80
                    "description": "The first old-side line that no longer matches, bounded to 200 characters."
81
                  }
82
                }
83
              }
84
            },
85
            "applied_hunks": { "type": "integer" },
86
            "failed_hunks": { "type": "integer" },
87
            "preview": {
88
              "type": "string",
89
              "description": "The post-application content, present only when include_preview was set and every hunk applies."
90
            },
91
            "preview_truncated": {
92
              "type": "boolean",
93
              "description": "True when the preview was elided in the middle past max_preview_chars."
94
            }
95
          }
96
        },
97
        "refusal": {
98
          "type": "object",
99
          "properties": {
100
            "code": { "type": "string" },
101
            "reason": { "type": "string" }
102
          },
103
          "required": ["code", "reason"]
104
        }
105
      }
106
    }
107
  },
108
  "capabilities": {
109
    "mounts": [],
110
    "hosts": [],
111
    "timeout_ms": 10000,
112
    "memory_max_mib": 128
113
  },
114
  "price_msats": null,
115
  "license": "Apache-2.0"
116
}
plugins/patch-check/patch_check.wasm added

Binary file. Nothing to show as text.

plugins/patch-check/src/lib.rs added +500

@@ -0,0 +1,500 @@

1
//! Validate a unified diff against a file's current content, as a
2
//! `packet-v0` guest plugin.
3
//!
4
//! Pure computation: the diff and the content both arrive in the input
5
//! packet, nothing is read from disk, and the answer is a placement report
6
//! — does each hunk still apply, where, and with how much drift — plus an
7
//! optional preview of the post-application content. The point is to check
8
//! whether a patch applies *before* anyone claims it does.
9
//!
10
//! Placement is per hunk, in order. A hunk is first tried at its declared
11
//! old-side position against the current content (with the line shift the
12
//! previously applied hunks introduced accounted for); on a mismatch the
13
//! old side is searched within `fuzz` lines of that position, and a match
14
//! found off-position reports its signed `drift_lines`. A hunk whose old
15
//! side matches nowhere in the window fails as `context_not_found`; one
16
//! that matches at two or more candidate positions fails as `ambiguous`.
17
//! Failures quote the first mismatching context line so the caller can see
18
//! what the file no longer says.
19
20
use openagents_pdk::Refusal;
21
use serde::{Deserialize, Serialize};
22
23
const DEFAULT_FUZZ: i64 = 200;
24
const FUZZ_CAP: i64 = 1_000;
25
const DEFAULT_MAX_PREVIEW_CHARS: usize = 20_000;
26
const MAX_PREVIEW_CHARS_CAP: usize = 100_000;
27
/// A quoted mismatching line is bounded to this many characters.
28
const MISMATCH_QUOTE_CHARS: usize = 200;
29
30
#[derive(Deserialize)]
31
pub struct Input {
32
    /// The unified diff to check: one file, one or more hunks.
33
    pub diff: String,
34
    /// The file's current text, checked as-is.
35
    pub content: String,
36
    /// Max line-offset drift searched when exact placement fails.
37
    #[serde(default)]
38
    pub fuzz: Option<i64>,
39
    /// When true, context lines match ignoring trailing whitespace.
40
    #[serde(default)]
41
    pub whitespace_lenient: bool,
42
    /// When true and every hunk applies, return the post-application text.
43
    #[serde(default)]
44
    pub include_preview: bool,
45
    /// Character ceiling for the preview; longer previews are elided in
46
    /// the middle.
47
    #[serde(default)]
48
    pub max_preview_chars: Option<usize>,
49
}
50
51
#[derive(Serialize, Debug)]
52
pub struct Output {
53
    /// True when every hunk placed.
54
    pub applies: bool,
55
    pub hunks: Vec<HunkReport>,
56
    pub applied_hunks: usize,
57
    pub failed_hunks: usize,
58
    #[serde(skip_serializing_if = "Option::is_none")]
59
    pub preview: Option<String>,
60
    #[serde(skip_serializing_if = "Option::is_none")]
61
    pub preview_truncated: Option<bool>,
62
}
63
64
#[derive(Serialize, Debug)]
65
pub struct HunkReport {
66
    /// Zero-based position of the hunk in the diff.
67
    pub index: usize,
68
    pub applies: bool,
69
    /// Where the hunk's old side sits, 1-based in the current content.
70
    #[serde(skip_serializing_if = "Option::is_none")]
71
    pub at_line: Option<usize>,
72
    /// Signed line offset from the declared position, when placed off it.
73
    #[serde(skip_serializing_if = "Option::is_none")]
74
    pub drift_lines: Option<i64>,
75
    /// `context_not_found` or `ambiguous`, when the hunk did not place.
76
    #[serde(skip_serializing_if = "Option::is_none")]
77
    pub reason: Option<String>,
78
    /// The first old-side line that no longer matches, bounded to 200 chars.
79
    #[serde(skip_serializing_if = "Option::is_none")]
80
    pub mismatch: Option<String>,
81
}
82
83
/// One parsed hunk body line.
84
#[derive(Debug, Clone, PartialEq)]
85
enum HunkLine {
86
    Context(String),
87
    Remove(String),
88
    Add(String),
89
}
90
91
#[derive(Debug)]
92
struct Hunk {
93
    /// 1-based old-side start from the `@@` header (0 for a pure insertion
94
    /// into an empty region: "insert after line N" convention).
95
    old_start: usize,
96
    lines: Vec<HunkLine>,
97
    /// A `\ No newline at end of file` marker followed an old-side line.
98
    no_newline_old: bool,
99
    /// A `\ No newline at end of file` marker followed a new-side line.
100
    no_newline_new: bool,
101
}
102
103
impl Hunk {
104
    /// The old side: context and removal lines, with a context flag.
105
    fn old_side(&self) -> Vec<(&str, bool)> {
106
        self.lines
107
            .iter()
108
            .filter_map(|line| match line {
109
                HunkLine::Context(text) => Some((text.as_str(), true)),
110
                HunkLine::Remove(text) => Some((text.as_str(), false)),
111
                HunkLine::Add(_) => None,
112
            })
113
            .collect()
114
    }
115
116
    /// The new side: context and addition lines.
117
    fn new_side(&self) -> Vec<&str> {
118
        self.lines
119
            .iter()
120
            .filter_map(|line| match line {
121
                HunkLine::Context(text) => Some(text.as_str()),
122
                HunkLine::Add(text) => Some(text.as_str()),
123
                HunkLine::Remove(_) => None,
124
            })
125
            .collect()
126
    }
127
}
128
129
/// Parse `@@ -a[,b] +c[,d] @@ ...` into `(a, b, c, d)`.
130
fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> {
131
    let rest = line.strip_prefix("@@ -")?;
132
    let end = rest.find(" @@")?;
133
    let (old, new) = rest[..end].split_once(" +")?;
134
    let (a, b) = parse_range(old)?;
135
    let (c, d) = parse_range(new)?;
136
    Some((a, b, c, d))
137
}
138
139
fn parse_range(text: &str) -> Option<(usize, usize)> {
140
    match text.split_once(',') {
141
        Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
142
        None => Some((text.parse().ok()?, 1)),
143
    }
144
}
145
146
fn malformed_refusal() -> Refusal {
147
    Refusal::unsupported(
148
        "no hunks parsed: a unified diff has optional file headers (`--- a/file`, \
149
         `+++ b/file`) and one or more hunks, each starting with a header like \
150
         `@@ -start,count +start,count @@` followed by body lines prefixed with \
151
         ' ' (context), '-' (removal), or '+' (addition)",
152
    )
153
}
154
155
/// Parse the diff into hunks, refusing a multi-file diff, a malformed
156
/// diff with no hunks, or a hunk whose body contradicts its header counts.
157
fn parse_diff(diff: &str) -> Result<Vec<Hunk>, Refusal> {
158
    let lines: Vec<&str> = diff.lines().collect();
159
    let mut hunks: Vec<Hunk> = Vec::new();
160
    let mut git_headers = 0usize;
161
    let mut old_file_headers = 0usize;
162
    let mut i = 0usize;
163
164
    while i < lines.len() {
165
        let line = lines[i];
166
        if line.starts_with("diff --git ") {
167
            git_headers += 1;
168
            i += 1;
169
            continue;
170
        }
171
        if line.starts_with("--- ") {
172
            old_file_headers += 1;
173
            i += 1;
174
            continue;
175
        }
176
        let Some((old_start, old_count, _new_start, new_count)) = parse_hunk_header(line) else {
177
            // File headers, index lines, mode lines, prose: skipped.
178
            i += 1;
179
            continue;
180
        };
181
        i += 1;
182
183
        let mut need_old = old_count;
184
        let mut need_new = new_count;
185
        let mut body: Vec<HunkLine> = Vec::new();
186
        let mut no_newline_old = false;
187
        let mut no_newline_new = false;
188
189
        loop {
190
            // A `\ No newline at end of file` marker binds to the line
191
            // before it, on that line's side(s).
192
            if i < lines.len() && lines[i].starts_with('\\') {
193
                match body.last() {
194
                    Some(HunkLine::Add(_)) => no_newline_new = true,
195
                    Some(HunkLine::Remove(_)) => no_newline_old = true,
196
                    Some(HunkLine::Context(_)) => {
197
                        no_newline_old = true;
198
                        no_newline_new = true;
199
                    }
200
                    None => {}
201
                }
202
                i += 1;
203
                continue;
204
            }
205
            if need_old == 0 && need_new == 0 {
206
                break;
207
            }
208
            let Some(&raw) = lines.get(i) else {
209
                return Err(Refusal::unsupported(format!(
210
                    "hunk {} is truncated: its header promised {} old and {} new \
211
                     lines but the diff ends {} old and {} new lines short",
212
                    hunks.len() + 1,
213
                    old_count,
214
                    new_count,
215
                    need_old,
216
                    need_new,
217
                )));
218
            };
219
            let parsed = if let Some(text) = raw.strip_prefix(' ') {
220
                (HunkLine::Context(text.to_string()), true, true)
221
            } else if let Some(text) = raw.strip_prefix('-') {
222
                (HunkLine::Remove(text.to_string()), true, false)
223
            } else if let Some(text) = raw.strip_prefix('+') {
224
                (HunkLine::Add(text.to_string()), false, true)
225
            } else if raw.is_empty() {
226
                // Some tools strip the single space from an empty context
227
                // line; tolerate it.
228
                (HunkLine::Context(String::new()), true, true)
229
            } else {
230
                return Err(Refusal::unsupported(format!(
231
                    "hunk {} has a body line with no ' ', '-', or '+' prefix \
232
                     before its header counts were satisfied: {:?}",
233
                    hunks.len() + 1,
234
                    bound_chars(raw, MISMATCH_QUOTE_CHARS),
235
                )));
236
            };
237
            let (kind, uses_old, uses_new) = parsed;
238
            if (uses_old && need_old == 0) || (uses_new && need_new == 0) {
239
                return Err(Refusal::unsupported(format!(
240
                    "hunk {} has more body lines than its header counts declare",
241
                    hunks.len() + 1,
242
                )));
243
            }
244
            if uses_old {
245
                need_old -= 1;
246
            }
247
            if uses_new {
248
                need_new -= 1;
249
            }
250
            body.push(kind);
251
            i += 1;
252
        }
253
254
        hunks.push(Hunk { old_start, lines: body, no_newline_old, no_newline_new });
255
    }
256
257
    let files = git_headers.max(old_file_headers);
258
    if files >= 2 {
259
        return Err(Refusal::unsupported(format!(
260
            "the diff names {files} files, and this check takes one file per \
261
             call — split the diff and pass each file's hunks with that \
262
             file's content",
263
        )));
264
    }
265
    if hunks.is_empty() {
266
        return Err(malformed_refusal());
267
    }
268
    Ok(hunks)
269
}
270
271
/// Truncate to at most `max` characters, on a char boundary.
272
fn bound_chars(text: &str, max: usize) -> String {
273
    if text.chars().count() <= max {
274
        text.to_string()
275
    } else {
276
        text.chars().take(max).collect()
277
    }
278
}
279
280
/// Does the old side match the buffer at `pos`?
281
fn matches_at(buffer: &[String], pos: usize, pattern: &[(&str, bool)], lenient: bool) -> bool {
282
    if pos + pattern.len() > buffer.len() {
283
        return false;
284
    }
285
    pattern.iter().enumerate().all(|(k, &(text, is_context))| {
286
        let actual = buffer[pos + k].as_str();
287
        if lenient && is_context {
288
            actual.trim_end() == text.trim_end()
289
        } else {
290
            actual == text
291
        }
292
    })
293
}
294
295
/// The first old-side line that fails to match at `pos`, quoted bounded.
296
fn first_mismatch(
297
    buffer: &[String],
298
    pos: i64,
299
    pattern: &[(&str, bool)],
300
    lenient: bool,
301
) -> String {
302
    if pos >= 0 {
303
        let pos = pos as usize;
304
        for (k, &(text, is_context)) in pattern.iter().enumerate() {
305
            match buffer.get(pos + k) {
306
                Some(actual) => {
307
                    let matched = if lenient && is_context {
308
                        actual.trim_end() == text.trim_end()
309
                    } else {
310
                        actual.as_str() == text
311
                    };
312
                    if !matched {
313
                        return bound_chars(text, MISMATCH_QUOTE_CHARS);
314
                    }
315
                }
316
                None => return bound_chars(text, MISMATCH_QUOTE_CHARS),
317
            }
318
        }
319
    }
320
    // Declared position out of range, or (ambiguous off-position) every
321
    // line matched: quote the first old-side line.
322
    pattern
323
        .first()
324
        .map(|&(text, _)| bound_chars(text, MISMATCH_QUOTE_CHARS))
325
        .unwrap_or_default()
326
}
327
328
fn handle(input: Input) -> Result<Output, Refusal> {
329
    let fuzz = input.fuzz.unwrap_or(DEFAULT_FUZZ).clamp(0, FUZZ_CAP);
330
    let max_preview_chars = input
331
        .max_preview_chars
332
        .unwrap_or(DEFAULT_MAX_PREVIEW_CHARS)
333
        .min(MAX_PREVIEW_CHARS_CAP);
334
    let lenient = input.whitespace_lenient;
335
336
    let hunks = parse_diff(&input.diff)?;
337
338
    let content_ends_nl = input.content.ends_with('\n');
339
    let mut buffer: Vec<String> = if input.content.is_empty() {
340
        Vec::new()
341
    } else {
342
        let mut lines: Vec<String> = input.content.split('\n').map(str::to_string).collect();
343
        if content_ends_nl {
344
            lines.pop();
345
        }
346
        lines
347
    };
348
349
    let mut reports: Vec<HunkReport> = Vec::new();
350
    // Net lines the applied hunks have added to (or removed from) the
351
    // working buffer ahead of the cursor: later hunks' declared old-side
352
    // positions are original-file coordinates, so their expected position
353
    // in the working buffer is `declared + shift`.
354
    let mut shift: i64 = 0;
355
    let mut ends_nl = content_ends_nl;
356
357
    for (index, hunk) in hunks.iter().enumerate() {
358
        let pattern = hunk.old_side();
359
        let new_side: Vec<String> = hunk.new_side().iter().map(|s| s.to_string()).collect();
360
361
        if pattern.is_empty() {
362
            // A pure insertion (`-N,0`) carries no verifiable old side:
363
            // the header means "insert after old line N". Accept it there.
364
            let at = ((hunk.old_start as i64) + shift).clamp(0, buffer.len() as i64) as usize;
365
            let added = new_side.len() as i64;
366
            buffer.splice(at..at, new_side);
367
            shift += added;
368
            if hunk.no_newline_new {
369
                ends_nl = false;
370
            }
371
            reports.push(HunkReport {
372
                index,
373
                applies: true,
374
                at_line: Some(hunk.old_start + 1),
375
                drift_lines: None,
376
                reason: None,
377
                mismatch: None,
378
            });
379
            continue;
380
        }
381
382
        let expected: i64 = hunk.old_start as i64 - 1 + shift;
383
        let placed: Option<(usize, i64)> = if expected >= 0
384
            && matches_at(&buffer, expected as usize, &pattern, lenient)
385
        {
386
            Some((expected as usize, 0))
387
        } else {
388
            // Search ±fuzz lines around the expected position for a
389
            // unique match.
390
            let last_valid = buffer.len() as i64 - pattern.len() as i64;
391
            let lo = (expected - fuzz).max(0);
392
            let hi = (expected + fuzz).min(last_valid);
393
            let mut found: Vec<i64> = Vec::new();
394
            let mut position = lo;
395
            while position <= hi {
396
                if position != expected && matches_at(&buffer, position as usize, &pattern, lenient)
397
                {
398
                    found.push(position);
399
                    if found.len() > 1 {
400
                        break;
401
                    }
402
                }
403
                position += 1;
404
            }
405
            match found.len() {
406
                1 => Some((found[0] as usize, found[0] - expected)),
407
                0 => {
408
                    reports.push(HunkReport {
409
                        index,
410
                        applies: false,
411
                        at_line: None,
412
                        drift_lines: None,
413
                        reason: Some("context_not_found".to_string()),
414
                        mismatch: Some(first_mismatch(&buffer, expected, &pattern, lenient)),
415
                    });
416
                    None
417
                }
418
                _ => {
419
                    reports.push(HunkReport {
420
                        index,
421
                        applies: false,
422
                        at_line: None,
423
                        drift_lines: None,
424
                        reason: Some("ambiguous".to_string()),
425
                        mismatch: Some(first_mismatch(&buffer, expected, &pattern, lenient)),
426
                    });
427
                    None
428
                }
429
            }
430
        };
431
432
        let Some((pos, drift)) = placed else { continue };
433
434
        // Report in current-content (original) coordinates.
435
        let at_line = (pos as i64 - shift + 1).max(1) as usize;
436
        let old_len = pattern.len() as i64;
437
        let new_len = new_side.len() as i64;
438
        buffer.splice(pos..pos + pattern.len(), new_side);
439
        shift += new_len - old_len;
440
        if hunk.no_newline_new {
441
            ends_nl = false;
442
        } else if hunk.no_newline_old {
443
            // The old side lacked the trailing newline and the new side
444
            // does not: the patch adds it back.
445
            ends_nl = true;
446
        }
447
        reports.push(HunkReport {
448
            index,
449
            applies: true,
450
            at_line: Some(at_line),
451
            drift_lines: (drift != 0).then_some(drift),
452
            reason: None,
453
            mismatch: None,
454
        });
455
    }
456
457
    let applied_hunks = reports.iter().filter(|report| report.applies).count();
458
    let failed_hunks = reports.len() - applied_hunks;
459
    let applies = failed_hunks == 0;
460
461
    let (preview, preview_truncated) = if input.include_preview && applies {
462
        let mut text = buffer.join("\n");
463
        if ends_nl && !buffer.is_empty() {
464
            text.push('\n');
465
        }
466
        let total = text.chars().count();
467
        if total > max_preview_chars {
468
            let head_chars = max_preview_chars / 2;
469
            let tail_chars = max_preview_chars - head_chars;
470
            let elided = total - max_preview_chars;
471
            let head: String = text.chars().take(head_chars).collect();
472
            let tail: String = text
473
                .chars()
474
                .skip(total - tail_chars)
475
                .collect();
476
            (
477
                Some(format!("{head}\n[{elided} chars elided]\n{tail}")),
478
                Some(true),
479
            )
480
        } else {
481
            (Some(text), None)
482
        }
483
    } else {
484
        (None, None)
485
    };
486
487
    Ok(Output {
488
        applies,
489
        hunks: reports,
490
        applied_hunks,
491
        failed_hunks,
492
        preview,
493
        preview_truncated,
494
    })
495
}
496
497
openagents_pdk::plugin_entry!(handle);
498
499
#[cfg(test)]
500
mod tests;
plugins/patch-check/src/tests.rs added +489

@@ -0,0 +1,489 @@

1
//! The checker as pure logic: no host, no WASM runtime — build inputs,
2
//! call `handle`, and assert on the placement report. These tests are the
3
//! product; the sandbox test in the CLI proves the same behavior through
4
//! the real boundary.
5
6
use super::*;
7
8
fn input(diff: &str, content: &str) -> Input {
9
    Input {
10
        diff: diff.to_string(),
11
        content: content.to_string(),
12
        fuzz: None,
13
        whitespace_lenient: false,
14
        include_preview: false,
15
        max_preview_chars: None,
16
    }
17
}
18
19
fn numbered(count: usize) -> String {
20
    (1..=count).map(|n| format!("line {n}\n")).collect()
21
}
22
23
#[test]
24
fn clean_apply_places_every_hunk_at_its_declared_line() {
25
    let content = "alpha\nbeta\ngamma\ndelta\nepsilon\n";
26
    let diff = "\
27
--- a/file.txt
28
+++ b/file.txt
29
@@ -2,3 +2,3 @@
30
 beta
31
-gamma
32
+GAMMA
33
 delta
34
";
35
    let out = handle(input(diff, content)).expect("a clean diff applies");
36
    assert!(out.applies);
37
    assert_eq!(out.applied_hunks, 1);
38
    assert_eq!(out.failed_hunks, 0);
39
    assert_eq!(out.hunks.len(), 1);
40
    let hunk = &out.hunks[0];
41
    assert_eq!(hunk.index, 0);
42
    assert!(hunk.applies);
43
    assert_eq!(hunk.at_line, Some(2));
44
    assert_eq!(hunk.drift_lines, None);
45
    assert_eq!(hunk.reason, None);
46
    assert_eq!(hunk.mismatch, None);
47
    assert_eq!(out.preview, None, "no preview unless asked");
48
}
49
50
#[test]
51
fn multi_hunk_apply_accounts_for_cumulative_line_shift() {
52
    // Hunk 1 grows the file by two lines; hunk 2's declared position is in
53
    // original coordinates, so exact placement only works if the checker
54
    // carries the shift into the working buffer.
55
    let content = numbered(40);
56
    let diff = "\
57
--- a/file.txt
58
+++ b/file.txt
59
@@ -2,2 +2,4 @@
60
 line 2
61
+inserted a
62
+inserted b
63
 line 3
64
@@ -30,3 +32,3 @@
65
 line 30
66
-line 31
67
+LINE 31
68
 line 32
69
";
70
    let mut request = input(&diff, &content);
71
    request.include_preview = true;
72
    // Fuzz zero: any drift would fail, so exact placement of hunk 2 proves
73
    // the shift bookkeeping rather than the search covering for it.
74
    request.fuzz = Some(0);
75
    let out = handle(request).expect("both hunks apply");
76
    assert!(out.applies);
77
    assert_eq!(out.applied_hunks, 2);
78
    assert_eq!(out.hunks[0].at_line, Some(2));
79
    assert_eq!(out.hunks[0].drift_lines, None);
80
    assert_eq!(out.hunks[1].at_line, Some(30), "original coordinates");
81
    assert_eq!(out.hunks[1].drift_lines, None, "no drift: the shift was accounted for");
82
83
    let preview = out.preview.expect("preview was requested and it applies");
84
    assert!(preview.contains("line 2\ninserted a\ninserted b\nline 3\n"));
85
    assert!(preview.contains("line 30\nLINE 31\nline 32\n"));
86
    assert_eq!(preview.lines().count(), 42);
87
}
88
89
#[test]
90
fn positive_drift_within_fuzz_is_found_and_reported() {
91
    // Five lines were prepended since the diff was cut: the hunk sits five
92
    // lines later than declared.
93
    let mut content = String::new();
94
    for n in 1..=5 {
95
        content.push_str(&format!("prepended {n}\n"));
96
    }
97
    content.push_str(&numbered(20));
98
    let diff = "\
99
@@ -9,3 +9,3 @@
100
 line 9
101
-line 10
102
+LINE 10
103
 line 11
104
";
105
    let out = handle(input(&diff, &content)).expect("drift within fuzz applies");
106
    assert!(out.applies);
107
    let hunk = &out.hunks[0];
108
    assert!(hunk.applies);
109
    assert_eq!(hunk.drift_lines, Some(5), "signed, positive: found later than declared");
110
    assert_eq!(hunk.at_line, Some(14), "declared 9 plus drift 5");
111
}
112
113
#[test]
114
fn negative_drift_within_fuzz_is_found_and_reported() {
115
    // Three lines were deleted above the hunk: it sits three lines earlier.
116
    let content: String = numbered(20)
117
        .lines()
118
        .filter(|line| !matches!(*line, "line 1" | "line 2" | "line 3"))
119
        .map(|line| format!("{line}\n"))
120
        .collect();
121
    let diff = "\
122
@@ -9,3 +9,3 @@
123
 line 9
124
-line 10
125
+LINE 10
126
 line 11
127
";
128
    let out = handle(input(&diff, &content)).expect("negative drift applies");
129
    let hunk = &out.hunks[0];
130
    assert!(hunk.applies);
131
    assert_eq!(hunk.drift_lines, Some(-3));
132
    assert_eq!(hunk.at_line, Some(6), "declared 9 minus drift 3");
133
}
134
135
#[test]
136
fn drift_beyond_fuzz_is_context_not_found() {
137
    let mut content = String::new();
138
    for n in 1..=10 {
139
        content.push_str(&format!("prepended {n}\n"));
140
    }
141
    content.push_str(&numbered(20));
142
    let diff = "\
143
@@ -9,3 +9,3 @@
144
 line 9
145
-line 10
146
+LINE 10
147
 line 11
148
";
149
    let mut request = input(&diff, &content);
150
    request.fuzz = Some(4); // real drift is 10
151
    let out = handle(request).expect("a placement failure is a report, not a refusal");
152
    assert!(!out.applies);
153
    assert_eq!(out.hunks[0].reason.as_deref(), Some("context_not_found"));
154
}
155
156
#[test]
157
fn context_not_found_quotes_the_first_mismatching_line() {
158
    let content = "alpha\nbeta\ngamma\n";
159
    let diff = "\
160
@@ -1,3 +1,3 @@
161
 alpha
162
-nowhere to be seen
163
+replacement
164
 gamma
165
";
166
    let out = handle(input(diff, content)).expect("reported, not refused");
167
    assert!(!out.applies);
168
    assert_eq!(out.applied_hunks, 0);
169
    assert_eq!(out.failed_hunks, 1);
170
    let hunk = &out.hunks[0];
171
    assert!(!hunk.applies);
172
    assert_eq!(hunk.reason.as_deref(), Some("context_not_found"));
173
    assert_eq!(
174
        hunk.mismatch.as_deref(),
175
        Some("nowhere to be seen"),
176
        "the first old-side line that no longer matches, quoted"
177
    );
178
    assert_eq!(hunk.at_line, None);
179
}
180
181
#[test]
182
fn mismatch_quote_is_bounded_to_200_chars() {
183
    let long = "x".repeat(500);
184
    let content = "alpha\nbeta\n";
185
    let diff = format!("@@ -1,2 +1,2 @@\n alpha\n-{long}\n+short\n");
186
    let out = handle(input(&diff, content)).expect("reported");
187
    let mismatch = out.hunks[0].mismatch.as_deref().expect("a quote");
188
    assert_eq!(mismatch.chars().count(), 200);
189
    assert!(mismatch.chars().all(|c| c == 'x'));
190
}
191
192
#[test]
193
fn ambiguous_context_fails_with_both_candidates_unchosen() {
194
    // The old side matches at two positions inside the fuzz window and not
195
    // at the declared position, so no unique placement exists.
196
    let block = "marker\nsame\nsame\n";
197
    let content = format!("{block}filler one\nfiller two\n{block}tail\n");
198
    let diff = "\
199
@@ -4,3 +4,3 @@
200
 marker
201
-same
202
+changed
203
 same
204
";
205
    let out = handle(input(&diff, &content)).expect("reported, not refused");
206
    assert!(!out.applies);
207
    let hunk = &out.hunks[0];
208
    assert_eq!(hunk.reason.as_deref(), Some("ambiguous"));
209
    assert!(hunk.mismatch.is_some(), "still says what mismatched at the declared spot");
210
    assert_eq!(hunk.at_line, None);
211
}
212
213
#[test]
214
fn exact_position_wins_even_when_the_context_also_matches_elsewhere() {
215
    // The same block appears twice, but one occurrence is exactly at the
216
    // declared position: that one wins and there is no ambiguity.
217
    let block = "marker\nsame\nsame\n";
218
    let content = format!("{block}filler\n{block}");
219
    let diff = "\
220
@@ -1,3 +1,3 @@
221
 marker
222
-same
223
+changed
224
 same
225
";
226
    let out = handle(input(&diff, &content)).expect("exact match wins");
227
    assert!(out.applies);
228
    assert_eq!(out.hunks[0].at_line, Some(1));
229
    assert_eq!(out.hunks[0].drift_lines, None);
230
}
231
232
#[test]
233
fn whitespace_lenient_forgives_trailing_whitespace_on_context_lines() {
234
    let content = "alpha   \nbeta\ngamma\t\n";
235
    let diff = "\
236
@@ -1,3 +1,3 @@
237
 alpha
238
-beta
239
+BETA
240
 gamma
241
";
242
    let strict = handle(input(diff, content)).expect("reported");
243
    assert!(!strict.applies, "strict mode: trailing whitespace breaks the context");
244
    assert_eq!(strict.hunks[0].reason.as_deref(), Some("context_not_found"));
245
    assert_eq!(strict.hunks[0].mismatch.as_deref(), Some("alpha"));
246
247
    let mut lenient = input(diff, content);
248
    lenient.whitespace_lenient = true;
249
    let out = handle(lenient).expect("lenient mode applies");
250
    assert!(out.applies);
251
    assert_eq!(out.hunks[0].at_line, Some(1));
252
}
253
254
#[test]
255
fn whitespace_lenient_does_not_forgive_removal_lines() {
256
    // Leniency is scoped to context lines; a removal line must match the
257
    // file exactly or the claim "this line will be removed" is wrong.
258
    let content = "alpha\nbeta   \ngamma\n";
259
    let diff = "\
260
@@ -1,3 +1,3 @@
261
 alpha
262
-beta
263
+BETA
264
 gamma
265
";
266
    let mut request = input(diff, content);
267
    request.whitespace_lenient = true;
268
    let out = handle(request).expect("reported");
269
    assert!(!out.applies);
270
    assert_eq!(out.hunks[0].mismatch.as_deref(), Some("beta"));
271
}
272
273
#[test]
274
fn missing_trailing_newline_markers_are_tolerated_and_tracked() {
275
    // The file ends without a newline; the diff rewrites its last line and
276
    // keeps it newline-less. Both sides carry the marker.
277
    let content = "alpha\nbeta";
278
    let diff = "\
279
--- a/file.txt
280
+++ b/file.txt
281
@@ -1,2 +1,2 @@
282
 alpha
283
-beta
284
\\ No newline at end of file
285
+BETA
286
\\ No newline at end of file
287
";
288
    let mut request = input(diff, content);
289
    request.include_preview = true;
290
    let out = handle(request).expect("applies");
291
    assert!(out.applies);
292
    assert_eq!(out.preview.as_deref(), Some("alpha\nBETA"), "still no trailing newline");
293
}
294
295
#[test]
296
fn a_patch_that_adds_the_trailing_newline_previews_with_one() {
297
    let content = "alpha\nbeta";
298
    let diff = "\
299
@@ -1,2 +1,2 @@
300
 alpha
301
-beta
302
\\ No newline at end of file
303
+beta
304
";
305
    let mut request = input(diff, content);
306
    request.include_preview = true;
307
    let out = handle(request).expect("applies");
308
    assert!(out.applies);
309
    assert_eq!(out.preview.as_deref(), Some("alpha\nbeta\n"));
310
}
311
312
#[test]
313
fn preview_is_elided_in_the_middle_past_the_ceiling() {
314
    let content = numbered(200);
315
    let diff = "\
316
@@ -1,2 +1,2 @@
317
-line 1
318
+FIRST
319
 line 2
320
";
321
    let mut request = input(&diff, &content);
322
    request.include_preview = true;
323
    request.max_preview_chars = Some(100);
324
    let out = handle(request).expect("applies");
325
    assert!(out.applies);
326
    assert_eq!(out.preview_truncated, Some(true));
327
    let preview = out.preview.expect("preview requested");
328
    assert!(preview.starts_with("FIRST\n"), "the head survives: {preview:?}");
329
    assert!(preview.ends_with("line 200\n"), "the tail survives: {preview:?}");
330
    let full_len = content.replace("line 1\n", "FIRST\n").chars().count();
331
    let marker = format!("[{} chars elided]", full_len - 100);
332
    assert!(preview.contains(&marker), "expected {marker:?} in {preview:?}");
333
}
334
335
#[test]
336
fn small_previews_are_returned_whole_without_the_truncated_flag() {
337
    let content = "alpha\nbeta\n";
338
    let diff = "\
339
@@ -1,2 +1,2 @@
340
-alpha
341
+ALPHA
342
 beta
343
";
344
    let mut request = input(diff, content);
345
    request.include_preview = true;
346
    let out = handle(request).expect("applies");
347
    assert_eq!(out.preview.as_deref(), Some("ALPHA\nbeta\n"));
348
    assert_eq!(out.preview_truncated, None);
349
}
350
351
#[test]
352
fn no_preview_when_a_hunk_fails_even_if_requested() {
353
    let content = "alpha\n";
354
    let diff = "\
355
@@ -1,1 +1,1 @@
356
-not here
357
+replacement
358
";
359
    let mut request = input(diff, content);
360
    request.include_preview = true;
361
    let out = handle(request).expect("reported");
362
    assert!(!out.applies);
363
    assert_eq!(out.preview, None);
364
}
365
366
#[test]
367
fn a_multi_file_diff_is_refused_with_one_file_per_call() {
368
    let diff = "\
369
--- a/one.txt
370
+++ b/one.txt
371
@@ -1,1 +1,1 @@
372
-a
373
+A
374
--- a/two.txt
375
+++ b/two.txt
376
@@ -1,1 +1,1 @@
377
-b
378
+B
379
";
380
    let refusal = handle(input(diff, "a\n")).expect_err("two files refuse");
381
    assert!(
382
        refusal.reason.contains("one file per call"),
383
        "names the rule: {}",
384
        refusal.reason
385
    );
386
    assert!(refusal.reason.contains("2 files"), "counts them: {}", refusal.reason);
387
}
388
389
#[test]
390
fn a_git_style_multi_file_diff_is_also_refused() {
391
    let diff = "\
392
diff --git a/one.txt b/one.txt
393
--- a/one.txt
394
+++ b/one.txt
395
@@ -1,1 +1,1 @@
396
-a
397
+A
398
diff --git a/two.txt b/two.txt
399
--- a/two.txt
400
+++ b/two.txt
401
@@ -1,1 +1,1 @@
402
-b
403
+B
404
";
405
    let refusal = handle(input(diff, "a\n")).expect_err("two files refuse");
406
    assert!(refusal.reason.contains("one file per call"));
407
}
408
409
#[test]
410
fn a_malformed_diff_with_no_hunks_is_refused_naming_the_shape() {
411
    let refusal =
412
        handle(input("this is prose, not a patch", "alpha\n")).expect_err("no hunks refuse");
413
    assert!(
414
        refusal.reason.contains("@@ -start,count +start,count @@"),
415
        "describes what a unified diff looks like: {}",
416
        refusal.reason
417
    );
418
}
419
420
#[test]
421
fn a_truncated_hunk_body_is_refused() {
422
    let diff = "\
423
@@ -1,3 +1,3 @@
424
 alpha
425
-beta
426
";
427
    let refusal = handle(input(diff, "alpha\nbeta\ngamma\n")).expect_err("truncated refuses");
428
    assert!(refusal.reason.contains("truncated"), "says so: {}", refusal.reason);
429
}
430
431
#[test]
432
fn count_free_hunk_headers_default_to_one_line() {
433
    // `@@ -2 +2 @@` means one old line and one new line.
434
    let content = "alpha\nbeta\ngamma\n";
435
    let diff = "\
436
@@ -2 +2 @@
437
-beta
438
+BETA
439
";
440
    let out = handle(input(diff, content)).expect("applies");
441
    assert!(out.applies);
442
    assert_eq!(out.hunks[0].at_line, Some(2));
443
}
444
445
#[test]
446
fn fuzz_is_capped_at_1000() {
447
    // Drift of 1500 with a requested fuzz of 5000: the cap keeps the
448
    // search window at 1000, so the hunk is not found.
449
    let mut content = String::new();
450
    for n in 1..=1500 {
451
        content.push_str(&format!("pad {n}\n"));
452
    }
453
    content.push_str("needle one\nneedle two\n");
454
    let diff = "\
455
@@ -1,2 +1,2 @@
456
 needle one
457
-needle two
458
+NEEDLE TWO
459
";
460
    let mut request = input(&diff, &content);
461
    request.fuzz = Some(5_000);
462
    let out = handle(request).expect("reported");
463
    assert!(!out.applies);
464
    assert_eq!(out.hunks[0].reason.as_deref(), Some("context_not_found"));
465
}
466
467
#[test]
468
fn later_hunks_still_report_after_an_earlier_failure() {
469
    let content = numbered(20);
470
    let diff = "\
471
@@ -2,3 +2,3 @@
472
 gone context
473
-line 3
474
+LINE 3
475
 line 4
476
@@ -10,3 +10,3 @@
477
 line 10
478
-line 11
479
+LINE 11
480
 line 12
481
";
482
    let out = handle(input(&diff, &content)).expect("reported");
483
    assert!(!out.applies);
484
    assert_eq!(out.applied_hunks, 1);
485
    assert_eq!(out.failed_hunks, 1);
486
    assert!(!out.hunks[0].applies);
487
    assert!(out.hunks[1].applies);
488
    assert_eq!(out.hunks[1].at_line, Some(10));
489
}
plugins/read-conversation/src/lib.rs modified +452 -25

@@ -4,6 +4,12 @@

4 4
//! (OpenAgentsInc/openagents#41): given a source and a session id — or just
5 5
//! "the newest session here" — locate the session through the scanner and
6 6
//! return its conversation as ordered turns, role and text, oldest first.
7
//! Besides the JSONL stores (claude, codex), the SQLite-backed stores are
8
//! readable on request (OpenAgentsInc/openagents#48): `opencode` sessions
9
//! out of `opencode.db`, and `devin` sessions out of Devin's per-session
10
//! ACP message databases — both through the scanner's page-at-a-time
11
//! SQLite reader, with the write-ahead log disclosed (`wal_unread`) rather
12
//! than parsed.
7 13
//!
8 14
//! The posture is the scanner's: read-only through the host's confined
9 15
//! capability imports, bounded everywhere, fail-soft on malformed records.

@@ -16,6 +22,7 @@

16 22
//! Reading is the whole capability. Nothing here resumes, continues, or
17 23
//! writes anything.
18 24
25
use foreign_sessions::sqlite::{self, MountedFile, Sqlite, Value};
19 26
use foreign_sessions::{scan, Host, Input as ScanInput, Session};
20 27
use openagents_pdk::{
21 28
    list_mounted_dir, plugin_entry, read_mounted_file, read_mounted_file_range, MountDirListing,

@@ -31,10 +38,18 @@ const DEFAULT_MAX_TURNS: usize = 60;

31 38
const TURN_CAP: usize = 200;
32 39
const DEFAULT_MAX_CHARS: usize = 2_000;
33 40
const CHAR_CAP: usize = 8_000;
41
/// Page reads allowed against one SQLite-backed store (opencode, devin).
42
const SQLITE_PAGE_BUDGET: usize = 20_000;
43
/// Payload cap for message rows and probe reads of part rows: enough for a
44
/// role and a `"type"` prefix; text parts that prove larger are re-read.
45
const ROW_PROBE_CAP: usize = 16_384;
46
/// Payload cap for a part row whose probe said it holds conversation text.
47
const TEXT_ROW_CAP: usize = 1_048_576;
34 48
35 49
#[derive(Deserialize)]
36 50
pub struct Input {
37
    /// `claude` or `codex`; both are searched when absent.
51
    /// `claude`, `codex`, `opencode`, or `devin`; the claude and codex
52
    /// stores are searched when absent.
38 53
    #[serde(default)]
39 54
    pub source: Option<String>,
40 55
    /// The session to read, by id or id prefix. Absent, the newest wins.

@@ -95,11 +110,15 @@ pub struct Output {

95 110
    pub dropped_leading_turns: usize,
96 111
    pub turns: Vec<Turn>,
97 112
    pub skipped: Skipped,
98
}
99
100
/// The scanner's host plus the bounded range read this plugin adds.
101
pub trait RangeHost: Host {
102
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal>;
113
    /// True when the store's SQLite write-ahead log held bytes: turns not
114
    /// yet checkpointed may be missing from this read, and the reader never
115
    /// parses the WAL — recent activity is disclosed as unread, not faked.
116
    #[serde(skip_serializing_if = "std::ops::Not::not")]
117
    pub wal_unread: bool,
118
    /// True when the SQLite page-read budget ran out before the whole
119
    /// conversation was examined; counts and turns describe what was read.
120
    #[serde(skip_serializing_if = "std::ops::Not::not")]
121
    pub read_budget_exhausted: bool,
103 122
}
104 123
105 124
struct RealHost;

@@ -111,17 +130,20 @@ impl Host for RealHost {

111 130
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
112 131
        read_mounted_file(path)
113 132
    }
114
}
115
116
impl RangeHost for RealHost {
117 133
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal> {
118 134
        read_mounted_file_range(path, offset, max_bytes)
119 135
    }
120 136
}
121 137
122
/// The whole read, over any [`RangeHost`].
123
pub fn read_conversation(host: &dyn RangeHost, input: &Input) -> Result<Output, Refusal> {
138
/// The whole read, over any [`Host`] whose `read_range` answers — the
139
/// JSONL stores need it for oversized files, the SQLite stores for pages.
140
pub fn read_conversation(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {
124 141
    let session = choose(host, input)?;
142
    match session.source {
143
        "opencode" => return opencode_read(host, session, input),
144
        "devin" => return devin_read(host, session, input),
145
        _ => {}
146
    }
125 147
    let (bytes, tail_only) = load(host, &session)?;
126 148
    let bytes_read = bytes.len();
127 149

@@ -130,15 +152,7 @@ pub fn read_conversation(host: &dyn RangeHost, input: &Input) -> Result<Output,

130 152
        _ => codex_turns(&bytes),
131 153
    };
132 154
133
    let max_turns = input.max_turns.unwrap_or(DEFAULT_MAX_TURNS).clamp(1, TURN_CAP);
134
    let max_chars = input.max_chars.unwrap_or(DEFAULT_MAX_CHARS).clamp(200, CHAR_CAP);
135
    let turns_total = raw_turns.len();
136
    let dropped = turns_total.saturating_sub(max_turns);
137
    let turns = raw_turns
138
        .into_iter()
139
        .skip(dropped)
140
        .map(|(role, text)| bounded_turn(role, &text, max_chars))
141
        .collect();
155
    let (turns, turns_total, dropped) = apply_ceilings(raw_turns, input);
142 156
143 157
    Ok(Output {
144 158
        source: session.source.to_string(),

@@ -153,12 +167,32 @@ pub fn read_conversation(host: &dyn RangeHost, input: &Input) -> Result<Output,

153 167
        dropped_leading_turns: dropped,
154 168
        turns,
155 169
        skipped,
170
        wal_unread: session.wal_unread,
171
        read_budget_exhausted: false,
156 172
    })
157 173
}
158 174
175
/// The shared turn and character ceilings: keep the end of the
176
/// conversation, elide long turns in the middle, and say what was dropped.
177
fn apply_ceilings(
178
    raw_turns: Vec<(String, String)>,
179
    input: &Input,
180
) -> (Vec<Turn>, usize, usize) {
181
    let max_turns = input.max_turns.unwrap_or(DEFAULT_MAX_TURNS).clamp(1, TURN_CAP);
182
    let max_chars = input.max_chars.unwrap_or(DEFAULT_MAX_CHARS).clamp(200, CHAR_CAP);
183
    let turns_total = raw_turns.len();
184
    let dropped = turns_total.saturating_sub(max_turns);
185
    let turns = raw_turns
186
        .into_iter()
187
        .skip(dropped)
188
        .map(|(role, text)| bounded_turn(role, &text, max_chars))
189
        .collect();
190
    (turns, turns_total, dropped)
191
}
192
159 193
/// The session to read: the id match when one is asked for, the newest
160 194
/// session the scanner reports otherwise.
161
fn choose(host: &dyn RangeHost, input: &Input) -> Result<Session, Refusal> {
195
fn choose(host: &dyn Host, input: &Input) -> Result<Session, Refusal> {
162 196
    let scan_input = ScanInput {
163 197
        sources: input.source.as_ref().map(|s| vec![s.clone()]),
164 198
        cwd_filter: input.cwd_filter.clone(),

@@ -166,9 +200,7 @@ fn choose(host: &dyn RangeHost, input: &Input) -> Result<Session, Refusal> {

166 200
        limit: Some(50),
167 201
        now_ms: input.now_ms,
168 202
    };
169
    // `RangeHost: Host`, and dyn upcasting is stable, so the scanner
170
    // takes the same host value.
171
    let scanned = scan(host as &dyn Host, &scan_input)?;
203
    let scanned = scan(host, &scan_input)?;
172 204
173 205
    match &input.session_id {
174 206
        Some(want) => scanned

@@ -190,7 +222,7 @@ fn choose(host: &dyn RangeHost, input: &Input) -> Result<Session, Refusal> {

190 222
191 223
/// The session's bytes: whole when the file fits the host's bound, the tail
192 224
/// (aligned to the first whole line) when it does not.
193
fn load(host: &dyn RangeHost, session: &Session) -> Result<(Vec<u8>, bool), Refusal> {
225
fn load(host: &dyn Host, session: &Session) -> Result<(Vec<u8>, bool), Refusal> {
194 226
    if session.size_bytes <= WHOLE_READ_BOUND {
195 227
        return Ok((host.read(&session.path)?, false));
196 228
    }

@@ -346,6 +378,401 @@ fn block_text(content: Option<&serde_json::Value>) -> String {

346 378
    }
347 379
}
348 380
381
/// What one opencode part row turned out to hold.
382
enum PartSort {
383
    /// Conversation text; `None` when the probe cap cut it and the row
384
    /// must be re-read with the text cap.
385
    Text(Option<String>),
386
    /// A non-text part kind (`tool`, `reasoning`, `step-start`, …).
387
    Kind(String),
388
    Unknown,
389
}
390
391
/// Map an `Err` from the SQLite reader to honest truncation when the
392
/// page-read budget ran out, and a real refusal otherwise.
393
fn soft_budget(
394
    outcome: Result<(), Refusal>,
395
    reader: &Sqlite,
396
    budget_out: &mut bool,
397
) -> Result<(), Refusal> {
398
    match outcome {
399
        Ok(()) => Ok(()),
400
        Err(_) if reader.budget_exhausted => {
401
            *budget_out = true;
402
            Ok(())
403
        }
404
        Err(refusal) => Err(refusal),
405
    }
406
}
407
408
/// One opencode conversation out of `opencode.db`: the session's `message`
409
/// rows (roles) joined with their `part` rows (text), ordered oldest first.
410
/// Indexes are used when the schema has them — the real store is far past
411
/// any full-scan budget — with a bounded full scan as the fallback for
412
/// small or differently-migrated databases.
413
fn opencode_read(host: &dyn Host, session: Session, input: &Input) -> Result<Output, Refusal> {
414
    let file = MountedFile { host, path: "opencode.db".to_string() };
415
    let mut reader = Sqlite::open(&file, SQLITE_PAGE_BUDGET)?;
416
    let master = reader.master()?;
417
    let table = |name: &str| master.iter().find(|e| e.kind == "table" && e.name == name);
418
    let message = table("message")
419
        .ok_or_else(|| Refusal::unsupported("the opencode database has no message table"))?;
420
    let part = table("part")
421
        .ok_or_else(|| Refusal::unsupported("the opencode database has no part table"))?;
422
    let position = |columns: &[String], name: &str| columns.iter().position(|c| c == name);
423
    let (Some(m_sess), Some(m_id), Some(m_time), Some(m_data)) = (
424
        position(&message.columns, "session_id"),
425
        position(&message.columns, "id"),
426
        position(&message.columns, "time_created"),
427
        position(&message.columns, "data"),
428
    ) else {
429
        return Err(Refusal::unsupported(
430
            "the opencode message table is missing expected columns",
431
        ));
432
    };
433
    let (Some(p_msg), Some(p_id), Some(p_sess), Some(p_data)) = (
434
        position(&part.columns, "message_id"),
435
        position(&part.columns, "id"),
436
        position(&part.columns, "session_id"),
437
        position(&part.columns, "data"),
438
    ) else {
439
        return Err(Refusal::unsupported("the opencode part table is missing expected columns"));
440
    };
441
    let index_on = |tbl: &str, first: &str| {
442
        master.iter().find(|e| {
443
            e.kind == "index"
444
                && e.tbl_name == tbl
445
                && e.columns.first().map(String::as_str) == Some(first)
446
        })
447
    };
448
449
    let sid = session.session_id.clone();
450
    let mut budget_out = false;
451
452
    // The session's messages: `(time_created, message id, rowid)`.
453
    let mut messages: Vec<(i64, String, i64)> = Vec::new();
454
    let outcome = match index_on("message", "session_id") {
455
        Some(index) => {
456
            let time_at = position(&index.columns, "time_created");
457
            let id_at = position(&index.columns, "id");
458
            reader.index_scan_eq(index.rootpage, &sid, 4_096, &mut |values| {
459
                let Some(Value::Int(rowid)) = values.last() else {
460
                    return true;
461
                };
462
                let time =
463
                    time_at.and_then(|at| values.get(at)).and_then(Value::as_int).unwrap_or(0);
464
                let id = id_at
465
                    .and_then(|at| values.get(at))
466
                    .and_then(Value::as_str)
467
                    .unwrap_or("")
468
                    .to_string();
469
                messages.push((time, id, *rowid));
470
                true
471
            })
472
        }
473
        None => reader.scan_table(message.rootpage, ROW_PROBE_CAP, &mut |rowid, values| {
474
            if values.get(m_sess).and_then(Value::as_str) == Some(sid.as_str()) {
475
                let time = values.get(m_time).and_then(Value::as_int).unwrap_or(0);
476
                let id =
477
                    values.get(m_id).and_then(Value::as_str).unwrap_or("").to_string();
478
                messages.push((time, id, rowid));
479
            }
480
            true
481
        }),
482
    };
483
    soft_budget(outcome, &reader, &mut budget_out)?;
484
    messages.sort();
485
486
    // Without a per-message index, one bounded scan of the part table
487
    // groups the session's parts by message.
488
    let part_index = index_on("part", "message_id");
489
    let mut grouped: Option<std::collections::BTreeMap<String, Vec<(String, i64)>>> = None;
490
    if part_index.is_none() && !budget_out {
491
        let mut map = std::collections::BTreeMap::<String, Vec<(String, i64)>>::new();
492
        let outcome = reader.scan_table(part.rootpage, 512, &mut |rowid, values| {
493
            if values.get(p_sess).and_then(Value::as_str) == Some(sid.as_str()) {
494
                let mid = values.get(p_msg).and_then(Value::as_str).unwrap_or("").to_string();
495
                let pid = values.get(p_id).and_then(Value::as_str).unwrap_or("").to_string();
496
                map.entry(mid).or_default().push((pid, rowid));
497
            }
498
            true
499
        });
500
        soft_budget(outcome, &reader, &mut budget_out)?;
501
        grouped = Some(map);
502
    }
503
504
    let mut raw_turns: Vec<(String, String)> = Vec::new();
505
    let mut skipped = Skipped::default();
506
    let mut records = 0usize;
507
    'messages: for (_time, mid, rowid) in &messages {
508
        if budget_out {
509
            break;
510
        }
511
        let row = match reader.find_by_rowid(message.rootpage, *rowid, ROW_PROBE_CAP) {
512
            Ok(Some(values)) => values,
513
            Ok(None) => {
514
                skipped.other += 1;
515
                continue;
516
            }
517
            Err(refusal) => {
518
                soft_budget(Err(refusal), &reader, &mut budget_out)?;
519
                break;
520
            }
521
        };
522
        records += 1;
523
        let mid = if mid.is_empty() {
524
            row.get(m_id).and_then(Value::as_str).unwrap_or("").to_string()
525
        } else {
526
            mid.clone()
527
        };
528
        let role = json_str_of(row.get(m_data), "role").unwrap_or_else(|| "unknown".to_string());
529
530
        let mut part_rows: Vec<(String, i64)> = Vec::new();
531
        if let Some(map) = &grouped {
532
            part_rows = map.get(&mid).cloned().unwrap_or_default();
533
        } else if let Some(index) = part_index {
534
            let id_at = position(&index.columns, "id");
535
            let outcome = reader.index_scan_eq(index.rootpage, &mid, 4_096, &mut |values| {
536
                let Some(Value::Int(rowid)) = values.last() else {
537
                    return true;
538
                };
539
                let pid = id_at
540
                    .and_then(|at| values.get(at))
541
                    .and_then(Value::as_str)
542
                    .unwrap_or("")
543
                    .to_string();
544
                part_rows.push((pid, *rowid));
545
                true
546
            });
547
            soft_budget(outcome, &reader, &mut budget_out)?;
548
        }
549
        part_rows.sort();
550
551
        let mut texts: Vec<String> = Vec::new();
552
        for (_pid, prowid) in &part_rows {
553
            if budget_out {
554
                break 'messages;
555
            }
556
            let part_row = match reader.find_by_rowid(part.rootpage, *prowid, ROW_PROBE_CAP) {
557
                Ok(Some(values)) => values,
558
                Ok(None) => {
559
                    skipped.other += 1;
560
                    continue;
561
                }
562
                Err(refusal) => {
563
                    soft_budget(Err(refusal), &reader, &mut budget_out)?;
564
                    break 'messages;
565
                }
566
            };
567
            records += 1;
568
            let sort = match part_row.get(p_data) {
569
                Some(Value::Text(json)) => match serde_json::from_str::<serde_json::Value>(json) {
570
                    Ok(value) => {
571
                        let kind =
572
                            value.get("type").and_then(|t| t.as_str()).unwrap_or("").to_string();
573
                        if kind == "text" {
574
                            PartSort::Text(
575
                                value.get("text").and_then(|t| t.as_str()).map(str::to_string),
576
                            )
577
                        } else {
578
                            PartSort::Kind(kind)
579
                        }
580
                    }
581
                    Err(_) => PartSort::Unknown,
582
                },
583
                Some(Value::Truncated(prefix)) => match sqlite::sniff_json_str(prefix, "type") {
584
                    Some(kind) if kind == "text" => PartSort::Text(None),
585
                    Some(kind) => PartSort::Kind(kind),
586
                    None => PartSort::Unknown,
587
                },
588
                _ => PartSort::Unknown,
589
            };
590
            match sort {
591
                PartSort::Text(Some(text)) => texts.push(text),
592
                // The probe cap cut a text part; one re-read with the text
593
                // cap recovers it (overflow pages and all).
594
                PartSort::Text(None) => {
595
                    match reader.find_by_rowid(part.rootpage, *prowid, TEXT_ROW_CAP) {
596
                        Ok(Some(full)) => {
597
                            let text = full
598
                                .get(p_data)
599
                                .and_then(Value::as_str)
600
                                .and_then(|json| {
601
                                    serde_json::from_str::<serde_json::Value>(json).ok()
602
                                })
603
                                .and_then(|v| {
604
                                    v.get("text").and_then(|t| t.as_str()).map(str::to_string)
605
                                });
606
                            match text {
607
                                Some(text) => texts.push(text),
608
                                None => skipped.other += 1,
609
                            }
610
                        }
611
                        Ok(None) => skipped.other += 1,
612
                        Err(refusal) => {
613
                            soft_budget(Err(refusal), &reader, &mut budget_out)?;
614
                            break 'messages;
615
                        }
616
                    }
617
                }
618
                PartSort::Kind(kind) => match kind.as_str() {
619
                    "reasoning" => skipped.thinking += 1,
620
                    "tool" | "step-start" | "step-finish" | "patch" => {
621
                        skipped.tool_activity += 1
622
                    }
623
                    _ => skipped.other += 1,
624
                },
625
                PartSort::Unknown => skipped.other += 1,
626
            }
627
        }
628
        let joined = texts.join("\n").trim().to_string();
629
        if !joined.is_empty() {
630
            raw_turns.push((role, joined));
631
        }
632
    }
633
634
    let (turns, turns_total, dropped) = apply_ceilings(raw_turns, input);
635
    Ok(Output {
636
        source: "opencode".to_string(),
637
        session_id: session.session_id,
638
        path: session.path,
639
        cwd: session.cwd,
640
        file_bytes: session.size_bytes,
641
        bytes_read: usize::try_from(reader.bytes_read).unwrap_or(usize::MAX),
642
        tail_only: false,
643
        records_seen: records,
644
        turns_total,
645
        dropped_leading_turns: dropped,
646
        turns,
647
        skipped,
648
        wal_unread: session.wal_unread,
649
        read_budget_exhausted: budget_out,
650
    })
651
}
652
653
/// One Devin conversation out of its per-session ACP message database:
654
/// `messages(position, kind, payload)` in position order. `user_message`
655
/// and `agent_message` rows become turns; thoughts and tool calls are
656
/// counted, not replayed.
657
fn devin_read(host: &dyn Host, session: Session, input: &Input) -> Result<Output, Refusal> {
658
    let file = MountedFile { host, path: session.path.clone() };
659
    let mut reader = Sqlite::open(&file, SQLITE_PAGE_BUDGET)?;
660
    let master = reader.master()?;
661
    let table = master
662
        .iter()
663
        .find(|e| e.kind == "table" && e.name == "messages")
664
        .ok_or_else(|| Refusal::unsupported("the devin database has no messages table"))?;
665
    let position = |name: &str| table.columns.iter().position(|c| c == name);
666
    let (Some(kind_at), Some(payload_at)) = (position("kind"), position("payload")) else {
667
        return Err(Refusal::unsupported(
668
            "the devin messages table is missing expected columns",
669
        ));
670
    };
671
672
    let mut budget_out = false;
673
    // `(rowid, kind, payload)` in position order — `position` is the
674
    // table's INTEGER PRIMARY KEY, so rowid order is message order. Only
675
    // message kinds keep their payload; the rest are counted by kind.
676
    let mut rows: Vec<(i64, String, Value)> = Vec::new();
677
    let outcome = reader.scan_table(table.rootpage, ROW_PROBE_CAP, &mut |rowid, values| {
678
        let kind = values.get(kind_at).and_then(Value::as_str).unwrap_or("").to_string();
679
        let payload = if kind == "user_message" || kind == "agent_message" {
680
            values.get(payload_at).cloned().unwrap_or(Value::Null)
681
        } else {
682
            Value::Null
683
        };
684
        rows.push((rowid, kind, payload));
685
        true
686
    });
687
    soft_budget(outcome, &reader, &mut budget_out)?;
688
689
    let mut raw_turns: Vec<(String, String)> = Vec::new();
690
    let mut skipped = Skipped::default();
691
    let records = rows.len();
692
    for (rowid, kind, payload) in rows {
693
        match kind.as_str() {
694
            "user_message" | "agent_message" => {
695
                let role = if kind == "user_message" { "user" } else { "assistant" };
696
                let json = match payload {
697
                    Value::Text(json) => Some(json),
698
                    // The probe cap cut the payload; re-read with the text cap.
699
                    Value::Truncated(_) => {
700
                        match reader.find_by_rowid(table.rootpage, rowid, TEXT_ROW_CAP) {
701
                            Ok(Some(full)) => full
702
                                .get(payload_at)
703
                                .and_then(Value::as_str)
704
                                .map(str::to_string),
705
                            Ok(None) => None,
706
                            Err(refusal) => {
707
                                soft_budget(Err(refusal), &reader, &mut budget_out)?;
708
                                break;
709
                            }
710
                        }
711
                    }
712
                    _ => None,
713
                };
714
                match json.as_deref().and_then(devin_text) {
715
                    Some(text) if !text.is_empty() => raw_turns.push((role.to_string(), text)),
716
                    Some(_) => {}
717
                    None => skipped.other += 1,
718
                }
719
            }
720
            "agent_thought" => skipped.thinking += 1,
721
            "tool_call" => skipped.tool_activity += 1,
722
            _ => skipped.other += 1,
723
        }
724
    }
725
726
    let (turns, turns_total, dropped) = apply_ceilings(raw_turns, input);
727
    Ok(Output {
728
        source: "devin".to_string(),
729
        session_id: session.session_id,
730
        path: session.path,
731
        cwd: session.cwd,
732
        file_bytes: session.size_bytes,
733
        bytes_read: usize::try_from(reader.bytes_read).unwrap_or(usize::MAX),
734
        tail_only: false,
735
        records_seen: records,
736
        turns_total,
737
        dropped_leading_turns: dropped,
738
        turns,
739
        skipped,
740
        wal_unread: session.wal_unread,
741
        read_budget_exhausted: budget_out,
742
    })
743
}
744
745
/// A named string field out of a JSON column value — parsed when the value
746
/// is whole, sniffed from the prefix when the payload cap cut it.
747
fn json_str_of(value: Option<&Value>, field: &str) -> Option<String> {
748
    match value {
749
        Some(Value::Text(json)) => serde_json::from_str::<serde_json::Value>(json)
750
            .ok()
751
            .and_then(|v| v.get(field).and_then(|f| f.as_str()).map(str::to_string)),
752
        Some(Value::Truncated(prefix)) => sqlite::sniff_json_str(prefix, field),
753
        _ => None,
754
    }
755
}
756
757
/// The joined text blocks of a Devin ACP payload: `content[]` entries whose
758
/// inner `content` is `{"type":"text","text":…}`.
759
fn devin_text(json: &str) -> Option<String> {
760
    let value: serde_json::Value = serde_json::from_str(json).ok()?;
761
    let content = value.get("content")?.as_array()?;
762
    let mut parts: Vec<&str> = Vec::new();
763
    for block in content {
764
        let Some(inner) = block.get("content") else {
765
            continue;
766
        };
767
        if inner.get("type").and_then(|t| t.as_str()) == Some("text") {
768
            if let Some(text) = inner.get("text").and_then(|t| t.as_str()) {
769
                parts.push(text);
770
            }
771
        }
772
    }
773
    Some(parts.join("\n").trim().to_string())
774
}
775
349 776
fn handle(input: Input) -> Result<Output, Refusal> {
350 777
    read_conversation(&RealHost, &input)
351 778
}
plugins/read-conversation/src/tests.rs modified -3

@@ -43,9 +43,6 @@ impl Host for FakeHost {

43 43
        }
44 44
        Ok(bytes.clone())
45 45
    }
46
}
47
48
impl RangeHost for FakeHost {
49 46
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal> {
50 47
        let bytes = self
51 48
            .files
plugins/repo-map/Cargo.toml added +14

@@ -0,0 +1,14 @@

1
[package]
2
name = "repo-map"
3
version = "0.1.0"
4
edition.workspace = true
5
license.workspace = true
6
description = "Guest plugin: a bounded heuristic line-based structural map of a repository — symbols per file, definition lookup, reference counts."
7
8
[lib]
9
crate-type = ["cdylib", "rlib"]
10
11
[dependencies]
12
openagents-pdk = { workspace = true }
13
serde = { workspace = true }
14
serde_json = { workspace = true }
plugins/repo-map/manifest.json added +112

@@ -0,0 +1,112 @@

1
{
2
  "manifest_version": 1,
3
  "name": "repo_map",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "Map the structure of this repository or codebase: an outline of files and their functions, classes, and symbols, with definition lookup and reference counts. Use it when asked what a repo contains, where a function or class or symbol is defined, or how often a name is used. The extraction is a heuristic line-based outline — def/class, fn/struct/trait/impl, defmodule/defp, func/type patterns over Python, TypeScript, JavaScript, Rust, Elixir, Go, and Ruby — not a real parse. Read-only and bounded: a fixed skip list (.git, node_modules, _build, deps, target, .elixir_ls, dist, build), a 256 KB per-file bound (larger files are listed with null symbols and counted oversized), capped files and symbols per file, and honest truncation flags.",
7
  "artifact": {
8
    "path": "repo_map.wasm",
9
    "digest": "sha256:7c446e06210a908d53699dbfa453b793208b96a3f81bd408688edcc092624eda"
10
  },
11
  "abi": {
12
    "kind": "packet-v0",
13
    "entry": "handle_packet",
14
    "alloc": "packet_alloc"
15
  },
16
  "interface": {
17
    "input": {
18
      "type": "object",
19
      "properties": {
20
        "path": {
21
          "type": "string",
22
          "description": "Subtree to map, relative to the workspace root. The whole workspace when omitted."
23
        },
24
        "symbol": {
25
          "type": "string",
26
          "description": "Exact symbol name to look up. Alone: every file and line where a symbol with that name is defined. With count_references: per-file whole-word use counts instead."
27
        },
28
        "count_references": {
29
          "type": "boolean",
30
          "description": "With symbol: count whole-word references per file (definition lines excluded) instead of listing definitions."
31
        },
32
        "max_files": {
33
          "type": "integer",
34
          "description": "Most source files to visit. Default 500, capped at 2000."
35
        },
36
        "max_symbols_per_file": {
37
          "type": "integer",
38
          "description": "Most symbols to report per file. Default 100, capped at 400."
39
        }
40
      }
41
    },
42
    "output": {
43
      "type": "object",
44
      "properties": {
45
        "files": {
46
          "type": "array",
47
          "description": "Outline mode: one entry per source file.",
48
          "items": {
49
            "type": "object",
50
            "properties": {
51
              "path": { "type": "string" },
52
              "language": { "type": "string" },
53
              "symbols": {
54
                "type": ["array", "null"],
55
                "description": "Null when the file was over the per-file byte bound.",
56
                "items": {
57
                  "type": "object",
58
                  "properties": {
59
                    "kind": { "type": "string" },
60
                    "name": { "type": "string" },
61
                    "line": { "type": "integer" },
62
                    "parent": { "type": "string" }
63
                  }
64
                }
65
              },
66
              "symbols_truncated": { "type": "boolean" }
67
            }
68
          }
69
        },
70
        "definitions": {
71
          "type": "array",
72
          "description": "Definition-lookup mode: every site defining exactly the named symbol.",
73
          "items": {
74
            "type": "object",
75
            "properties": {
76
              "path": { "type": "string" },
77
              "kind": { "type": "string" },
78
              "line": { "type": "integer" },
79
              "parent": { "type": "string" }
80
            }
81
          }
82
        },
83
        "references": {
84
          "type": "array",
85
          "description": "Reference mode: per-file whole-word use counts, definitions excluded.",
86
          "items": {
87
            "type": "object",
88
            "properties": {
89
              "path": { "type": "string" },
90
              "count": { "type": "integer" }
91
            }
92
          }
93
        },
94
        "total": { "type": "integer" },
95
        "symbol": { "type": "string" },
96
        "files_seen": { "type": "integer" },
97
        "files_parsed": { "type": "integer" },
98
        "oversized": { "type": "integer" },
99
        "unreadable": { "type": "integer" },
100
        "truncated": { "type": "boolean" }
101
      }
102
    }
103
  },
104
  "capabilities": {
105
    "mounts": [
106
      { "path": "${workspace}", "readonly": true }
107
    ],
108
    "hosts": [],
109
    "timeout_ms": 10000,
110
    "memory_max_mib": 256
111
  }
112
}
plugins/repo-map/repo_map.wasm added

Binary file. Nothing to show as text.

plugins/repo-map/src/lib.rs added +790

@@ -0,0 +1,790 @@

1
//! A bounded structural map of a repository, as a `packet-v0` guest plugin.
2
//!
3
//! The map half of OpenAgentsInc/openagents#44: given a read-only mount
4
//! over the session's workspace (mount 0, declared as `${workspace}` in the
5
//! manifest and resolved by the host at load time), walk the tree and answer
6
//! one of three questions:
7
//!
8
//! - **Outline** (the default): which symbols does each source file appear
9
//!   to define? `{files: [{path, language, symbols}], …}`
10
//! - **Definition lookup** (`symbol`): where is a symbol with exactly that
11
//!   name defined? `{definitions: [{path, kind, line}], …}`
12
//! - **References** (`symbol` + `count_references`): how often is that name
13
//!   used, per file, definitions excluded? `{references: [{path, count}], …}`
14
//!
15
//! Heuristic is the honest word for the extraction: symbols are found by
16
//! line pattern — `def`/`class`, `fn`/`struct`/`trait`/`impl`,
17
//! `defmodule`/`defp`, `func`/`type`, and so on — not by parsing. A string
18
//! or comment that looks like a definition is counted as one; a definition
19
//! split across lines or built by a macro is missed. A real parse
20
//! (tree-sitter compiled into the guest) is the recorded follow-up; the
21
//! C-runtime-on-`wasm32-unknown-unknown` toolchain fight it requires is
22
//! deliberately not taken on in this iteration.
23
//!
24
//! The walk skips a fixed list of directories by name — `.git`,
25
//! `node_modules`, `_build`, `deps`, `target`, `.elixir_ls`, `dist`,
26
//! `build` — and nothing else; it does not parse `.gitignore` (that is
27
//! `repo_tree`'s job). The posture is the plugin family's: read-only
28
//! through the host's confined capability imports, bounded everywhere
29
//! (directories listed, files visited, bytes per file, symbols per file),
30
//! fail-soft on unreadable files, and honest about truncation.
31
32
use std::collections::{BTreeSet, VecDeque};
33
34
use openagents_pdk::{
35
    list_mounted_dir, plugin_entry, read_mounted_file, MountDirListing, Refusal, RefusalCode,
36
};
37
use serde::{Deserialize, Serialize};
38
39
/// Mount index of the workspace, fixed by the manifest's mount order.
40
const WORKSPACE_MOUNT: u32 = 0;
41
/// Per-file byte bound; a larger file is listed with `symbols: null` and
42
/// counted oversized rather than read.
43
const MAX_FILE_BYTES: u64 = 262_144;
44
const DEFAULT_MAX_FILES: usize = 500;
45
const FILE_CAP: usize = 2_000;
46
const DEFAULT_MAX_SYMBOLS: usize = 100;
47
const SYMBOL_CAP: usize = 400;
48
/// Directory listings per invocation.
49
const MAX_DIR_LISTS: usize = 2_000;
50
/// Directories never descended into, by name. A fixed list, documented in
51
/// the crate doc; gitignore semantics live in `repo_tree`, not here.
52
const SKIP_DIRS: [&str; 8] = [
53
    ".git",
54
    "node_modules",
55
    "_build",
56
    "deps",
57
    "target",
58
    ".elixir_ls",
59
    "dist",
60
    "build",
61
];
62
63
#[derive(Deserialize)]
64
pub struct Input {
65
    /// Subtree to map, relative to the workspace root. The whole workspace
66
    /// when absent.
67
    #[serde(default)]
68
    pub path: Option<String>,
69
    /// Exact symbol name to look up. Alone: definition lookup. With
70
    /// `count_references`: per-file reference counts.
71
    #[serde(default)]
72
    pub symbol: Option<String>,
73
    /// With `symbol`: count whole-word uses per file instead of listing
74
    /// definitions.
75
    #[serde(default)]
76
    pub count_references: Option<bool>,
77
    /// Most source files to visit. Default 500, capped at 2000.
78
    #[serde(default)]
79
    pub max_files: Option<usize>,
80
    /// Most symbols to report per file. Default 100, capped at 400.
81
    #[serde(default)]
82
    pub max_symbols_per_file: Option<usize>,
83
}
84
85
/// One extracted symbol: what the line pattern says it is, and where.
86
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
87
pub struct Symbol {
88
    pub kind: &'static str,
89
    pub name: String,
90
    /// 1-based line number.
91
    pub line: usize,
92
    /// Best guess at the enclosing class, for indented Python methods.
93
    #[serde(skip_serializing_if = "Option::is_none")]
94
    pub parent: Option<String>,
95
}
96
97
#[derive(Debug, Serialize)]
98
pub struct FileOutline {
99
    pub path: String,
100
    pub language: &'static str,
101
    /// `null` when the file was over the per-file byte bound, so its
102
    /// symbols are unknown rather than absent.
103
    pub symbols: Option<Vec<Symbol>>,
104
    /// True when the per-file symbol cap dropped later symbols.
105
    #[serde(skip_serializing_if = "std::ops::Not::not")]
106
    pub symbols_truncated: bool,
107
}
108
109
#[derive(Debug, Serialize, PartialEq, Eq)]
110
pub struct Definition {
111
    pub path: String,
112
    pub kind: &'static str,
113
    pub line: usize,
114
    #[serde(skip_serializing_if = "Option::is_none")]
115
    pub parent: Option<String>,
116
}
117
118
#[derive(Debug, Serialize, PartialEq, Eq)]
119
pub struct Reference {
120
    pub path: String,
121
    /// Whole-word occurrences, definition lines excluded.
122
    pub count: usize,
123
}
124
125
/// Shared walk accounting, flattened into every output shape.
126
#[derive(Debug, Default, Serialize, PartialEq, Eq)]
127
pub struct WalkStats {
128
    /// Source files with a mapped extension the walk visited.
129
    pub files_seen: usize,
130
    /// Files whose bytes were read and scanned.
131
    pub files_parsed: usize,
132
    /// Files past the per-file byte bound, listed but not read.
133
    pub oversized: usize,
134
    /// Files the host refused to read for any reason but size.
135
    pub unreadable: usize,
136
    /// True when any bound cut the picture short: the file budget, the
137
    /// listing budget, or a truncated directory listing.
138
    pub truncated: bool,
139
}
140
141
#[derive(Debug, Serialize)]
142
pub struct OutlineOutput {
143
    pub files: Vec<FileOutline>,
144
    #[serde(flatten)]
145
    pub stats: WalkStats,
146
}
147
148
#[derive(Debug, Serialize)]
149
pub struct DefinitionsOutput {
150
    pub symbol: String,
151
    pub definitions: Vec<Definition>,
152
    #[serde(flatten)]
153
    pub stats: WalkStats,
154
}
155
156
#[derive(Debug, Serialize)]
157
pub struct ReferencesOutput {
158
    pub symbol: String,
159
    pub references: Vec<Reference>,
160
    pub total: usize,
161
    #[serde(flatten)]
162
    pub stats: WalkStats,
163
}
164
165
#[derive(Debug, Serialize)]
166
#[serde(untagged)]
167
pub enum Output {
168
    Outline(OutlineOutput),
169
    Definitions(DefinitionsOutput),
170
    References(ReferencesOutput),
171
}
172
173
/// The two host capabilities the mapper uses, as a seam so the logic runs
174
/// under `cargo test` against a fake host as well as inside the sandbox.
175
pub trait Host {
176
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal>;
177
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal>;
178
}
179
180
struct RealHost;
181
182
impl Host for RealHost {
183
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
184
        list_mounted_dir(mount_index, path)
185
    }
186
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
187
        read_mounted_file(path)
188
    }
189
}
190
191
/// The languages the line patterns know, keyed by file extension.
192
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193
pub enum Language {
194
    Python,
195
    TypeScript,
196
    JavaScript,
197
    Rust,
198
    Elixir,
199
    Go,
200
    Ruby,
201
}
202
203
impl Language {
204
    fn of(file_name: &str) -> Option<Language> {
205
        let (_, ext) = file_name.rsplit_once('.')?;
206
        match ext {
207
            "py" => Some(Language::Python),
208
            "ts" | "tsx" => Some(Language::TypeScript),
209
            "js" | "jsx" => Some(Language::JavaScript),
210
            "rs" => Some(Language::Rust),
211
            "ex" | "exs" => Some(Language::Elixir),
212
            "go" => Some(Language::Go),
213
            "rb" => Some(Language::Ruby),
214
            _ => None,
215
        }
216
    }
217
218
    const fn as_str(self) -> &'static str {
219
        match self {
220
            Language::Python => "python",
221
            Language::TypeScript => "typescript",
222
            Language::JavaScript => "javascript",
223
            Language::Rust => "rust",
224
            Language::Elixir => "elixir",
225
            Language::Go => "go",
226
            Language::Ruby => "ruby",
227
        }
228
    }
229
}
230
231
/// One file the walk found, before its bytes are inspected.
232
struct Candidate {
233
    path: String,
234
    language: Language,
235
    size: u64,
236
}
237
238
/// The whole map, over any [`Host`].
239
pub fn map(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {
240
    let max_files = input.max_files.unwrap_or(DEFAULT_MAX_FILES).clamp(1, FILE_CAP);
241
    let max_symbols = input
242
        .max_symbols_per_file
243
        .unwrap_or(DEFAULT_MAX_SYMBOLS)
244
        .clamp(1, SYMBOL_CAP);
245
    let symbol = match &input.symbol {
246
        Some(name) if name.trim().is_empty() => {
247
            return Err(Refusal::unsupported("an empty symbol names nothing to find"))
248
        }
249
        Some(name) => Some(name.trim().to_string()),
250
        None => None,
251
    };
252
    let count_references = input.count_references.unwrap_or(false);
253
    if count_references && symbol.is_none() {
254
        return Err(Refusal::unsupported(
255
            "count_references needs a symbol to count; pass one",
256
        ));
257
    }
258
    let root = input
259
        .path
260
        .as_deref()
261
        .unwrap_or("")
262
        .trim_matches('/')
263
        .to_string();
264
265
    let (candidates, mut stats) = walk(host, &root, max_files)?;
266
267
    match (symbol, count_references) {
268
        (None, _) => {
269
            let files = outline(host, &candidates, max_symbols, &mut stats);
270
            Ok(Output::Outline(OutlineOutput { files, stats }))
271
        }
272
        (Some(name), false) => {
273
            let definitions = definitions(host, &candidates, &name, max_symbols, &mut stats);
274
            Ok(Output::Definitions(DefinitionsOutput { symbol: name, definitions, stats }))
275
        }
276
        (Some(name), true) => {
277
            let (references, total) =
278
                references(host, &candidates, &name, max_symbols, &mut stats);
279
            Ok(Output::References(ReferencesOutput { symbol: name, references, total, stats }))
280
        }
281
    }
282
}
283
284
/// Breadth-first walk from `root`, collecting source files up to the file
285
/// budget. Directories on the skip list are never entered; symlinks are
286
/// never followed (the host would refuse anyway). Listing failures below
287
/// the root fail soft; a root that cannot be listed is a refusal, because
288
/// the caller named it.
289
fn walk(
290
    host: &dyn Host,
291
    root: &str,
292
    max_files: usize,
293
) -> Result<(Vec<Candidate>, WalkStats), Refusal> {
294
    let mut stats = WalkStats::default();
295
    let mut files: Vec<Candidate> = Vec::new();
296
    let mut queue: VecDeque<String> = VecDeque::from([root.to_string()]);
297
    let mut lists = 0usize;
298
    let mut at_root = true;
299
300
    'walk: while let Some(dir) = queue.pop_front() {
301
        if lists >= MAX_DIR_LISTS {
302
            stats.truncated = true;
303
            break;
304
        }
305
        lists += 1;
306
        let listing = match host.list(WORKSPACE_MOUNT, &dir) {
307
            Ok(listing) => listing,
308
            Err(refusal) if at_root => {
309
                let shown = if dir.is_empty() { "." } else { dir.as_str() };
310
                return Err(Refusal::new(
311
                    RefusalCode::FileUnreadable,
312
                    format!("cannot list `{shown}` under the workspace mount: {}", refusal.reason),
313
                ));
314
            }
315
            Err(_) => continue,
316
        };
317
        at_root = false;
318
        if listing.truncated {
319
            stats.truncated = true;
320
        }
321
        for entry in &listing.entries {
322
            match entry.kind.as_str() {
323
                "dir" => {
324
                    if !SKIP_DIRS.contains(&entry.name.as_str()) {
325
                        queue.push_back(join(&dir, &entry.name));
326
                    }
327
                }
328
                "file" => {
329
                    let Some(language) = Language::of(&entry.name) else {
330
                        continue;
331
                    };
332
                    if files.len() >= max_files {
333
                        stats.truncated = true;
334
                        break 'walk;
335
                    }
336
                    stats.files_seen += 1;
337
                    files.push(Candidate {
338
                        path: join(&dir, &entry.name),
339
                        language,
340
                        size: entry.size,
341
                    });
342
                }
343
                _ => {}
344
            }
345
        }
346
    }
347
    Ok((files, stats))
348
}
349
350
fn join(dir: &str, name: &str) -> String {
351
    if dir.is_empty() {
352
        name.to_string()
353
    } else {
354
        format!("{dir}/{name}")
355
    }
356
}
357
358
/// A candidate's text, or `None` with the stats told why.
359
fn load(host: &dyn Host, candidate: &Candidate, stats: &mut WalkStats) -> Option<String> {
360
    if candidate.size > MAX_FILE_BYTES {
361
        stats.oversized += 1;
362
        return None;
363
    }
364
    match host.read(&candidate.path) {
365
        Ok(bytes) => {
366
            stats.files_parsed += 1;
367
            Some(String::from_utf8_lossy(&bytes).into_owned())
368
        }
369
        Err(refusal) if refusal.code == RefusalCode::FileTooLarge => {
370
            stats.oversized += 1;
371
            None
372
        }
373
        Err(_) => {
374
            stats.unreadable += 1;
375
            None
376
        }
377
    }
378
}
379
380
fn outline(
381
    host: &dyn Host,
382
    candidates: &[Candidate],
383
    max_symbols: usize,
384
    stats: &mut WalkStats,
385
) -> Vec<FileOutline> {
386
    let mut files = Vec::with_capacity(candidates.len());
387
    for candidate in candidates {
388
        let before_oversized = stats.oversized;
389
        let Some(text) = load(host, candidate, stats) else {
390
            // An oversized file is still part of the map, with its symbols
391
            // honestly unknown; an unreadable one is only counted.
392
            if stats.oversized > before_oversized {
393
                files.push(FileOutline {
394
                    path: candidate.path.clone(),
395
                    language: candidate.language.as_str(),
396
                    symbols: None,
397
                    symbols_truncated: false,
398
                });
399
            }
400
            continue;
401
        };
402
        let (symbols, symbols_truncated) = extract_symbols(candidate.language, &text, max_symbols);
403
        files.push(FileOutline {
404
            path: candidate.path.clone(),
405
            language: candidate.language.as_str(),
406
            symbols: Some(symbols),
407
            symbols_truncated,
408
        });
409
    }
410
    files
411
}
412
413
fn definitions(
414
    host: &dyn Host,
415
    candidates: &[Candidate],
416
    symbol: &str,
417
    max_symbols: usize,
418
    stats: &mut WalkStats,
419
) -> Vec<Definition> {
420
    let mut definitions = Vec::new();
421
    for candidate in candidates {
422
        let Some(text) = load(host, candidate, stats) else {
423
            continue;
424
        };
425
        let (symbols, _) = extract_symbols(candidate.language, &text, max_symbols);
426
        for found in symbols {
427
            if found.name == symbol {
428
                definitions.push(Definition {
429
                    path: candidate.path.clone(),
430
                    kind: found.kind,
431
                    line: found.line,
432
                    parent: found.parent,
433
                });
434
            }
435
        }
436
    }
437
    definitions
438
}
439
440
fn references(
441
    host: &dyn Host,
442
    candidates: &[Candidate],
443
    symbol: &str,
444
    max_symbols: usize,
445
    stats: &mut WalkStats,
446
) -> (Vec<Reference>, usize) {
447
    let mut references = Vec::new();
448
    let mut total = 0usize;
449
    for candidate in candidates {
450
        let Some(text) = load(host, candidate, stats) else {
451
            continue;
452
        };
453
        let (symbols, _) = extract_symbols(candidate.language, &text, max_symbols);
454
        let definition_lines: BTreeSet<usize> = symbols
455
            .iter()
456
            .filter(|s| s.name == symbol)
457
            .map(|s| s.line)
458
            .collect();
459
        let mut count = 0usize;
460
        for (index, line) in text.lines().enumerate() {
461
            if definition_lines.contains(&(index + 1)) {
462
                continue;
463
            }
464
            count += word_count(line, symbol);
465
        }
466
        if count > 0 {
467
            total += count;
468
            references.push(Reference { path: candidate.path.clone(), count });
469
        }
470
    }
471
    references.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.path.cmp(&b.path)));
472
    (references, total)
473
}
474
475
/// Whole-word occurrences of `needle` in `text`: a match whose neighbors
476
/// are not identifier characters, so `foo` never matches inside `foobar`
477
/// or `foo_bar`.
478
fn word_count(text: &str, needle: &str) -> usize {
479
    let bytes = text.as_bytes();
480
    let mut count = 0usize;
481
    for (position, _) in text.match_indices(needle) {
482
        let before_ok = position == 0 || !is_word_byte(bytes[position - 1]);
483
        let end = position + needle.len();
484
        let after_ok = end >= bytes.len() || !is_word_byte(bytes[end]);
485
        if before_ok && after_ok {
486
            count += 1;
487
        }
488
    }
489
    count
490
}
491
492
fn is_word_byte(byte: u8) -> bool {
493
    byte.is_ascii_alphanumeric() || byte == b'_'
494
}
495
496
/// The heuristic line-based extraction: at most `max` symbols, plus a flag
497
/// saying whether more existed.
498
pub fn extract_symbols(language: Language, text: &str, max: usize) -> (Vec<Symbol>, bool) {
499
    let mut symbols = Vec::new();
500
    let mut truncated = false;
501
    // Python only: (indent, class name) of every enclosing class candidate.
502
    let mut class_stack: Vec<(usize, String)> = Vec::new();
503
    for (index, raw) in text.lines().enumerate() {
504
        let found = match language {
505
            Language::Python => python_symbol(raw, &mut class_stack),
506
            Language::TypeScript | Language::JavaScript => ts_symbol(raw),
507
            Language::Rust => rust_symbol(raw),
508
            Language::Elixir => elixir_symbol(raw),
509
            Language::Go => go_symbol(raw),
510
            Language::Ruby => ruby_symbol(raw),
511
        };
512
        if let Some((kind, name, parent)) = found {
513
            if symbols.len() >= max {
514
                truncated = true;
515
                break;
516
            }
517
            symbols.push(Symbol { kind, name, line: index + 1, parent });
518
        }
519
    }
520
    (symbols, truncated)
521
}
522
523
type Found = Option<(&'static str, String, Option<String>)>;
524
525
/// A leading identifier: `[A-Za-z_][A-Za-z0-9_]*` plus any `extra` characters.
526
fn ident_with(text: &str, extra: &[char]) -> Option<String> {
527
    let mut chars = text.chars();
528
    let first = chars.next()?;
529
    if !(first.is_ascii_alphabetic() || first == '_') {
530
        return None;
531
    }
532
    let mut name = String::new();
533
    name.push(first);
534
    for c in chars {
535
        if c.is_ascii_alphanumeric() || c == '_' || extra.contains(&c) {
536
            name.push(c);
537
        } else {
538
            break;
539
        }
540
    }
541
    Some(name)
542
}
543
544
fn ident(text: &str) -> Option<String> {
545
    ident_with(text, &[])
546
}
547
548
fn python_symbol(raw: &str, class_stack: &mut Vec<(usize, String)>) -> Found {
549
    let trimmed = raw.trim_start();
550
    if trimmed.is_empty() {
551
        return None;
552
    }
553
    let indent = raw.len() - trimmed.len();
554
    if let Some(rest) = trimmed.strip_prefix("class ") {
555
        while class_stack.last().is_some_and(|(depth, _)| *depth >= indent) {
556
            class_stack.pop();
557
        }
558
        let name = ident(rest)?;
559
        let parent = class_stack.last().map(|(_, parent)| parent.clone());
560
        class_stack.push((indent, name.clone()));
561
        return Some(("class", name, parent));
562
    }
563
    let def = trimmed
564
        .strip_prefix("def ")
565
        .or_else(|| trimmed.strip_prefix("async def "));
566
    if let Some(rest) = def {
567
        while class_stack.last().is_some_and(|(depth, _)| *depth >= indent) {
568
            class_stack.pop();
569
        }
570
        let name = ident(rest)?;
571
        return match class_stack.last() {
572
            Some((_, parent)) => Some(("method", name, Some(parent.clone()))),
573
            None => Some(("function", name, None)),
574
        };
575
    }
576
    None
577
}
578
579
fn ts_symbol(raw: &str) -> Found {
580
    let mut s = raw.trim_start();
581
    loop {
582
        let mut stripped = false;
583
        for prefix in ["export ", "default ", "declare ", "abstract "] {
584
            if let Some(rest) = s.strip_prefix(prefix) {
585
                s = rest.trim_start();
586
                stripped = true;
587
            }
588
        }
589
        if !stripped {
590
            break;
591
        }
592
    }
593
    if let Some(rest) = s.strip_prefix("async ") {
594
        let rest = rest.trim_start();
595
        if rest.starts_with("function") {
596
            s = rest;
597
        }
598
    }
599
    if let Some(rest) = s.strip_prefix("function") {
600
        if !(rest.starts_with(' ') || rest.starts_with('*')) {
601
            return None;
602
        }
603
        let rest = rest.trim_start().trim_start_matches('*').trim_start();
604
        return ident(rest).map(|name| ("function", name, None));
605
    }
606
    if let Some(rest) = s.strip_prefix("class ") {
607
        return ident(rest).map(|name| ("class", name, None));
608
    }
609
    if let Some(rest) = s.strip_prefix("interface ") {
610
        return ident(rest).map(|name| ("interface", name, None));
611
    }
612
    if let Some(rest) = s
613
        .strip_prefix("const enum ")
614
        .or_else(|| s.strip_prefix("enum "))
615
    {
616
        return ident(rest).map(|name| ("enum", name, None));
617
    }
618
    if let Some(rest) = s.strip_prefix("type ") {
619
        let name = ident(rest)?;
620
        let after = rest[name.len()..].trim_start();
621
        if after.starts_with('=') || after.starts_with('<') {
622
            return Some(("type", name, None));
623
        }
624
        return None;
625
    }
626
    for keyword in ["const ", "let ", "var "] {
627
        if let Some(rest) = s.strip_prefix(keyword) {
628
            let name = ident(rest)?;
629
            let after = &rest[name.len()..];
630
            // Only bindings that hold a function: an arrow or a function
631
            // expression on the same line. Plain values are data, not map.
632
            if after.contains('=') && (after.contains("=>") || after.contains("function")) {
633
                return Some(("function", name, None));
634
            }
635
            return None;
636
        }
637
    }
638
    None
639
}
640
641
/// Strip a Rust visibility prefix: `pub `, `pub(crate) `, `pub(in …) `.
642
fn strip_rust_visibility(s: &str) -> &str {
643
    let Some(rest) = s.strip_prefix("pub") else {
644
        return s;
645
    };
646
    if let Some(after_paren) = rest.strip_prefix('(') {
647
        match after_paren.find(')') {
648
            Some(close) => return after_paren[close + 1..].trim_start(),
649
            None => return s,
650
        }
651
    }
652
    if rest.starts_with(' ') {
653
        return rest.trim_start();
654
    }
655
    s
656
}
657
658
fn rust_symbol(raw: &str) -> Found {
659
    let mut s = strip_rust_visibility(raw.trim_start());
660
    loop {
661
        let mut stripped = false;
662
        for prefix in ["async ", "unsafe ", "default "] {
663
            if let Some(rest) = s.strip_prefix(prefix) {
664
                s = rest.trim_start();
665
                stripped = true;
666
            }
667
        }
668
        if !stripped {
669
            break;
670
        }
671
    }
672
    if let Some(rest) = s
673
        .strip_prefix("fn ")
674
        .or_else(|| s.strip_prefix("const fn "))
675
    {
676
        return ident(rest).map(|name| ("function", name, None));
677
    }
678
    if let Some(rest) = s.strip_prefix("struct ") {
679
        return ident(rest).map(|name| ("struct", name, None));
680
    }
681
    if let Some(rest) = s.strip_prefix("enum ") {
682
        return ident(rest).map(|name| ("enum", name, None));
683
    }
684
    if let Some(rest) = s.strip_prefix("trait ") {
685
        return ident(rest).map(|name| ("trait", name, None));
686
    }
687
    if let Some(rest) = s.strip_prefix("mod ") {
688
        return ident(rest).map(|name| ("module", name, None));
689
    }
690
    if let Some(rest) = s.strip_prefix("impl") {
691
        if !(rest.starts_with(' ') || rest.starts_with('<')) {
692
            return None;
693
        }
694
        let rest = if rest.starts_with('<') {
695
            let mut depth = 0i32;
696
            let mut close = None;
697
            for (i, c) in rest.char_indices() {
698
                match c {
699
                    '<' => depth += 1,
700
                    '>' => {
701
                        depth -= 1;
702
                        if depth == 0 {
703
                            close = Some(i);
704
                            break;
705
                        }
706
                    }
707
                    _ => {}
708
                }
709
            }
710
            &rest[close? + 1..]
711
        } else {
712
            rest
713
        };
714
        let name = rest.split('{').next().unwrap_or(rest);
715
        let name = name.split(" where").next().unwrap_or(name).trim();
716
        if name.is_empty() {
717
            return None;
718
        }
719
        return Some(("impl", name.to_string(), None));
720
    }
721
    if let Some(rest) = s
722
        .strip_prefix("const ")
723
        .or_else(|| s.strip_prefix("static "))
724
    {
725
        return ident(rest).map(|name| ("const", name, None));
726
    }
727
    None
728
}
729
730
fn elixir_symbol(raw: &str) -> Found {
731
    let s = raw.trim_start();
732
    if let Some(rest) = s.strip_prefix("defmodule ") {
733
        return ident_with(rest, &['.']).map(|name| ("module", name, None));
734
    }
735
    if let Some(rest) = s
736
        .strip_prefix("defmacrop ")
737
        .or_else(|| s.strip_prefix("defmacro "))
738
    {
739
        return ident_with(rest, &['?', '!']).map(|name| ("macro", name, None));
740
    }
741
    if let Some(rest) = s.strip_prefix("defp ") {
742
        return ident_with(rest, &['?', '!']).map(|name| ("private_function", name, None));
743
    }
744
    if let Some(rest) = s.strip_prefix("def ") {
745
        return ident_with(rest, &['?', '!']).map(|name| ("function", name, None));
746
    }
747
    None
748
}
749
750
fn go_symbol(raw: &str) -> Found {
751
    let s = raw.trim_start();
752
    if let Some(rest) = s.strip_prefix("func (") {
753
        let (_, after) = rest.split_once(')')?;
754
        return ident(after.trim_start()).map(|name| ("method", name, None));
755
    }
756
    if let Some(rest) = s.strip_prefix("func ") {
757
        return ident(rest).map(|name| ("function", name, None));
758
    }
759
    if let Some(rest) = s.strip_prefix("type ") {
760
        return ident(rest).map(|name| ("type", name, None));
761
    }
762
    None
763
}
764
765
fn ruby_symbol(raw: &str) -> Found {
766
    let s = raw.trim_start();
767
    if let Some(rest) = s.strip_prefix("def ") {
768
        let rest = rest.strip_prefix("self.").unwrap_or(rest);
769
        return ident_with(rest, &['?', '!']).map(|name| ("function", name, None));
770
    }
771
    if let Some(rest) = s.strip_prefix("class ") {
772
        if rest.starts_with("<<") {
773
            return None;
774
        }
775
        return ident(rest).map(|name| ("class", name, None));
776
    }
777
    if let Some(rest) = s.strip_prefix("module ") {
778
        return ident(rest).map(|name| ("module", name, None));
779
    }
780
    None
781
}
782
783
fn handle(input: Input) -> Result<Output, Refusal> {
784
    map(&RealHost, &input)
785
}
786
787
plugin_entry!(handle);
788
789
#[cfg(test)]
790
mod tests;
plugins/repo-map/src/tests.rs added +499

@@ -0,0 +1,499 @@

1
//! The mapper against a fake host: per-language extraction fixtures, the
2
//! directory skip list, oversized and unreadable files, definition lookup,
3
//! word-bounded reference counting, and every cap. The same shapes run
4
//! through the real boundary in
5
//! `packages/openagents-cli/test/coder-plugin-repo-map.test.ts`.
6
7
use super::*;
8
use openagents_pdk::MountDirEntry;
9
use std::collections::BTreeMap;
10
11
/// A fake host over one in-memory workspace mount. Directories are keyed
12
/// by mount-relative path; file bytes likewise.
13
#[derive(Default)]
14
struct FakeHost {
15
    dirs: BTreeMap<String, MountDirListing>,
16
    files: BTreeMap<String, Vec<u8>>,
17
    /// Paths the read import answers with `file_too_large`.
18
    oversized: Vec<String>,
19
    /// Paths the read import answers with `file_unreadable`.
20
    unreadable: Vec<String>,
21
}
22
23
impl FakeHost {
24
    fn dir(&mut self, path: &str, entries: Vec<MountDirEntry>) {
25
        self.dirs
26
            .insert(path.to_string(), MountDirListing { entries, truncated: false });
27
    }
28
    fn file(&mut self, path: &str, text: &str) {
29
        self.files.insert(path.to_string(), text.as_bytes().to_vec());
30
    }
31
}
32
33
impl Host for FakeHost {
34
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
35
        assert_eq!(mount_index, WORKSPACE_MOUNT, "only the workspace mount exists");
36
        self.dirs.get(path).cloned().ok_or_else(|| {
37
            Refusal::new(RefusalCode::FileUnreadable, "the mount has no such directory")
38
        })
39
    }
40
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
41
        if self.oversized.iter().any(|p| p == path) {
42
            return Err(Refusal::new(RefusalCode::FileTooLarge, "over the bound"));
43
        }
44
        if self.unreadable.iter().any(|p| p == path) {
45
            return Err(Refusal::new(RefusalCode::FileUnreadable, "io failure"));
46
        }
47
        self.files.get(path).cloned().ok_or_else(|| {
48
            Refusal::new(RefusalCode::MountDenied, "no declared mount contains the path")
49
        })
50
    }
51
}
52
53
fn entry(name: &str, kind: &str, size: u64) -> MountDirEntry {
54
    MountDirEntry { name: name.to_string(), kind: kind.to_string(), size, mtime_ms: 0 }
55
}
56
57
fn input() -> Input {
58
    Input {
59
        path: None,
60
        symbol: None,
61
        count_references: None,
62
        max_files: None,
63
        max_symbols_per_file: None,
64
    }
65
}
66
67
fn names(symbols: &[Symbol]) -> Vec<(&'static str, &str)> {
68
    symbols.iter().map(|s| (s.kind, s.name.as_str())).collect()
69
}
70
71
// ---- extraction, one focused fixture per language ----
72
73
#[test]
74
fn python_extraction_finds_classes_functions_and_methods_with_parents() {
75
    let source = "\
76
import os
77
78
class Greeter:
79
    def __init__(self):
80
        pass
81
82
    async def greet(self):
83
        return hello()
84
85
def hello():
86
    return 1
87
";
88
    let (symbols, truncated) = extract_symbols(Language::Python, source, 100);
89
    assert!(!truncated);
90
    assert_eq!(
91
        names(&symbols),
92
        vec![
93
            ("class", "Greeter"),
94
            ("method", "__init__"),
95
            ("method", "greet"),
96
            ("function", "hello"),
97
        ],
98
    );
99
    assert_eq!(symbols[1].parent.as_deref(), Some("Greeter"));
100
    assert_eq!(symbols[2].parent.as_deref(), Some("Greeter"));
101
    assert_eq!(symbols[3].parent, None);
102
    assert_eq!(symbols[0].line, 3);
103
    assert_eq!(symbols[3].line, 10);
104
}
105
106
#[test]
107
fn typescript_extraction_covers_export_variants_and_arrow_functions() {
108
    let source = "\
109
import { x } from \"./x\";
110
export function build(): void {}
111
export default class App {}
112
interface Options {
113
  quiet: boolean;
114
}
115
export type Result<T> = T | null;
116
export enum Mode { Fast, Slow }
117
export const handler = async (req: Request) => req;
118
const plain = 42;
119
let maker = function () {};
120
";
121
    let (symbols, _) = extract_symbols(Language::TypeScript, source, 100);
122
    assert_eq!(
123
        names(&symbols),
124
        vec![
125
            ("function", "build"),
126
            ("class", "App"),
127
            ("interface", "Options"),
128
            ("type", "Result"),
129
            ("enum", "Mode"),
130
            ("function", "handler"),
131
            ("function", "maker"),
132
        ],
133
    );
134
}
135
136
#[test]
137
fn rust_extraction_covers_items_visibility_and_impl_blocks() {
138
    let source = "\
139
pub const LIMIT: usize = 10;
140
pub(crate) struct Walker;
141
enum Step { In, Out }
142
pub trait Visit {}
143
mod inner {}
144
impl Walker {
145
    pub fn walk(&self) {}
146
}
147
impl<T> Visit for Vec<T> {
148
    async fn ignore_modifiers() {}
149
}
150
pub fn top() {}
151
";
152
    let (symbols, _) = extract_symbols(Language::Rust, source, 100);
153
    assert_eq!(
154
        names(&symbols),
155
        vec![
156
            ("const", "LIMIT"),
157
            ("struct", "Walker"),
158
            ("enum", "Step"),
159
            ("trait", "Visit"),
160
            ("module", "inner"),
161
            ("impl", "Walker"),
162
            ("function", "walk"),
163
            ("impl", "Visit for Vec<T>"),
164
            ("function", "ignore_modifiers"),
165
            ("function", "top"),
166
        ],
167
    );
168
}
169
170
#[test]
171
fn elixir_extraction_covers_modules_functions_and_macros() {
172
    let source = "\
173
defmodule Demo.Server do
174
  defmacro compiled? do
175
  end
176
177
  def start_link(opts) do
178
  end
179
180
  defp validate!(opts) do
181
  end
182
end
183
";
184
    let (symbols, _) = extract_symbols(Language::Elixir, source, 100);
185
    assert_eq!(
186
        names(&symbols),
187
        vec![
188
            ("module", "Demo.Server"),
189
            ("macro", "compiled?"),
190
            ("function", "start_link"),
191
            ("private_function", "validate!"),
192
        ],
193
    );
194
}
195
196
#[test]
197
fn go_extraction_covers_functions_methods_and_types() {
198
    let source = "\
199
package main
200
201
type Server struct{}
202
203
func (s *Server) Start() error { return nil }
204
205
func main() {}
206
";
207
    let (symbols, _) = extract_symbols(Language::Go, source, 100);
208
    assert_eq!(
209
        names(&symbols),
210
        vec![("type", "Server"), ("method", "Start"), ("function", "main")],
211
    );
212
}
213
214
#[test]
215
fn ruby_extraction_covers_classes_modules_and_defs() {
216
    let source = "\
217
module Billing
218
  class Invoice
219
    def self.build(total)
220
    end
221
222
    def paid?
223
    end
224
  end
225
end
226
";
227
    let (symbols, _) = extract_symbols(Language::Ruby, source, 100);
228
    assert_eq!(
229
        names(&symbols),
230
        vec![
231
            ("module", "Billing"),
232
            ("class", "Invoice"),
233
            ("function", "build"),
234
            ("function", "paid?"),
235
        ],
236
    );
237
}
238
239
// ---- the walk, over a small polyglot workspace ----
240
241
/// A workspace with Python, Rust, and Elixir sources, a skipped
242
/// `node_modules` tree, and a plain-text file no pattern knows.
243
fn seeded() -> FakeHost {
244
    let mut host = FakeHost::default();
245
    host.dir(
246
        "",
247
        vec![
248
            entry("README.md", "file", 20),
249
            entry("lib", "dir", 0),
250
            entry("node_modules", "dir", 0),
251
            entry("src", "dir", 0),
252
        ],
253
    );
254
    host.dir(
255
        "src",
256
        vec![entry("main.py", "file", 120), entry("map.rs", "file", 140)],
257
    );
258
    host.dir("lib", vec![entry("demo.ex", "file", 90)]);
259
    host.dir("node_modules", vec![entry("pkg", "dir", 0)]);
260
    host.dir("node_modules/pkg", vec![entry("index.js", "file", 50)]);
261
    host.file(
262
        "src/main.py",
263
        "def greet_all():\n    pass\n\ndef use_it():\n    return greet_all()\n",
264
    );
265
    host.file(
266
        "src/map.rs",
267
        "pub fn greet_all() -> u32 { 0 }\npub fn twice() -> u32 { greet_all() + greet_all() }\nfn greet_all_extra() {}\n",
268
    );
269
    host.file("lib/demo.ex", "defmodule Demo do\n  def greet_all do\n  end\nend\n");
270
    host.file("node_modules/pkg/index.js", "function hidden() {}\n");
271
    host
272
}
273
274
fn outline_of(host: &FakeHost, input: &Input) -> OutlineOutput {
275
    match map(host, input).unwrap() {
276
        Output::Outline(out) => out,
277
        other => panic!("expected an outline, got {other:?}"),
278
    }
279
}
280
281
#[test]
282
fn the_outline_maps_every_language_and_skips_the_skip_list() {
283
    let out = outline_of(&seeded(), &input());
284
    let paths: Vec<&str> = out.files.iter().map(|f| f.path.as_str()).collect();
285
    assert_eq!(paths, vec!["lib/demo.ex", "src/main.py", "src/map.rs"]);
286
    assert!(paths.iter().all(|p| !p.contains("node_modules")));
287
288
    let by_path = |path: &str| out.files.iter().find(|f| f.path == path).unwrap();
289
    assert_eq!(by_path("src/main.py").language, "python");
290
    assert_eq!(
291
        names(by_path("src/main.py").symbols.as_deref().unwrap()),
292
        vec![("function", "greet_all"), ("function", "use_it")],
293
    );
294
    assert_eq!(by_path("lib/demo.ex").language, "elixir");
295
    assert_eq!(by_path("src/map.rs").language, "rust");
296
297
    assert_eq!(out.stats.files_seen, 3);
298
    assert_eq!(out.stats.files_parsed, 3);
299
    assert_eq!(out.stats.oversized, 0);
300
    assert_eq!(out.stats.unreadable, 0);
301
    assert!(!out.stats.truncated);
302
}
303
304
#[test]
305
fn every_skip_list_directory_is_skipped_by_name() {
306
    let mut host = FakeHost::default();
307
    let mut entries: Vec<MountDirEntry> =
308
        SKIP_DIRS.iter().map(|name| entry(name, "dir", 0)).collect();
309
    entries.push(entry("kept.py", "file", 10));
310
    entries.sort_by(|a, b| a.name.cmp(&b.name));
311
    host.dir("", entries);
312
    for name in SKIP_DIRS {
313
        host.dir(name, vec![entry("sneaky.py", "file", 10)]);
314
        host.file(&format!("{name}/sneaky.py"), "def sneaky():\n    pass\n");
315
    }
316
    host.file("kept.py", "def kept():\n    pass\n");
317
    let out = outline_of(&host, &input());
318
    assert_eq!(out.files.len(), 1);
319
    assert_eq!(out.files[0].path, "kept.py");
320
}
321
322
#[test]
323
fn an_oversized_file_is_listed_with_null_symbols_and_counted() {
324
    let mut host = seeded();
325
    host.dir(
326
        "src",
327
        vec![
328
            entry("huge.py", "file", MAX_FILE_BYTES + 1),
329
            entry("main.py", "file", 120),
330
        ],
331
    );
332
    let out = outline_of(&host, &input());
333
    let huge = out.files.iter().find(|f| f.path == "src/huge.py").unwrap();
334
    assert_eq!(huge.symbols, None);
335
    assert_eq!(huge.language, "python");
336
    assert_eq!(out.stats.oversized, 1);
337
    assert_eq!(out.stats.files_parsed, 2);
338
}
339
340
#[test]
341
fn a_read_refused_as_too_large_counts_oversized_even_when_the_listing_lied() {
342
    let mut host = seeded();
343
    host.oversized.push("src/main.py".to_string());
344
    let out = outline_of(&host, &input());
345
    let file = out.files.iter().find(|f| f.path == "src/main.py").unwrap();
346
    assert_eq!(file.symbols, None);
347
    assert_eq!(out.stats.oversized, 1);
348
}
349
350
#[test]
351
fn an_unreadable_file_is_counted_and_left_out() {
352
    let mut host = seeded();
353
    host.unreadable.push("src/main.py".to_string());
354
    let out = outline_of(&host, &input());
355
    assert!(out.files.iter().all(|f| f.path != "src/main.py"));
356
    assert_eq!(out.stats.unreadable, 1);
357
    assert_eq!(out.stats.files_seen, 3);
358
    assert_eq!(out.stats.files_parsed, 2);
359
}
360
361
#[test]
362
fn a_subtree_path_narrows_the_walk() {
363
    let out = outline_of(&seeded(), &Input { path: Some("src".into()), ..input() });
364
    let paths: Vec<&str> = out.files.iter().map(|f| f.path.as_str()).collect();
365
    assert_eq!(paths, vec!["src/main.py", "src/map.rs"]);
366
}
367
368
#[test]
369
fn a_root_that_cannot_be_listed_is_a_refusal_that_names_it() {
370
    let refusal = map(&seeded(), &Input { path: Some("no/such/dir".into()), ..input() })
371
        .unwrap_err();
372
    assert_eq!(refusal.code, RefusalCode::FileUnreadable);
373
    assert!(refusal.reason.contains("no/such/dir"));
374
}
375
376
// ---- definition lookup ----
377
378
#[test]
379
fn definition_lookup_returns_every_exact_definition_site() {
380
    let out = match map(&seeded(), &Input { symbol: Some("greet_all".into()), ..input() }) {
381
        Ok(Output::Definitions(out)) => out,
382
        other => panic!("expected definitions, got {other:?}"),
383
    };
384
    assert_eq!(out.symbol, "greet_all");
385
    assert_eq!(
386
        out.definitions,
387
        vec![
388
            Definition { path: "lib/demo.ex".into(), kind: "function", line: 2, parent: None },
389
            Definition { path: "src/main.py".into(), kind: "function", line: 1, parent: None },
390
            Definition { path: "src/map.rs".into(), kind: "function", line: 1, parent: None },
391
        ],
392
    );
393
    // Exact name only: `greet_all_extra` defines a different symbol.
394
    assert!(out.definitions.iter().all(|d| d.line != 3 || d.path != "src/map.rs"));
395
}
396
397
#[test]
398
fn an_unknown_symbol_yields_an_empty_honest_answer_not_a_refusal() {
399
    let out = match map(&seeded(), &Input { symbol: Some("nope".into()), ..input() }) {
400
        Ok(Output::Definitions(out)) => out,
401
        other => panic!("expected definitions, got {other:?}"),
402
    };
403
    assert!(out.definitions.is_empty());
404
    assert_eq!(out.stats.files_parsed, 3);
405
}
406
407
#[test]
408
fn an_empty_symbol_is_refused() {
409
    let refusal = map(&seeded(), &Input { symbol: Some("  ".into()), ..input() }).unwrap_err();
410
    assert_eq!(refusal.code, RefusalCode::Unsupported);
411
}
412
413
// ---- reference counting ----
414
415
fn references_of(host: &FakeHost, symbol: &str) -> ReferencesOutput {
416
    let request = Input {
417
        symbol: Some(symbol.into()),
418
        count_references: Some(true),
419
        ..input()
420
    };
421
    match map(host, &request) {
422
        Ok(Output::References(out)) => out,
423
        other => panic!("expected references, got {other:?}"),
424
    }
425
}
426
427
#[test]
428
fn reference_counts_are_word_bounded_and_skip_definition_lines() {
429
    let out = references_of(&seeded(), "greet_all");
430
    // main.py: one call; map.rs: two calls on the `twice` line — the
431
    // definition lines and `greet_all_extra` never count.
432
    assert_eq!(
433
        out.references,
434
        vec![
435
            Reference { path: "src/map.rs".into(), count: 2 },
436
            Reference { path: "src/main.py".into(), count: 1 },
437
        ],
438
    );
439
    assert_eq!(out.total, 3);
440
}
441
442
#[test]
443
fn a_name_with_no_uses_outside_its_definition_counts_zero() {
444
    let out = references_of(&seeded(), "use_it");
445
    assert!(out.references.is_empty());
446
    assert_eq!(out.total, 0);
447
}
448
449
#[test]
450
fn count_references_without_a_symbol_is_refused() {
451
    let refusal =
452
        map(&seeded(), &Input { count_references: Some(true), ..input() }).unwrap_err();
453
    assert_eq!(refusal.code, RefusalCode::Unsupported);
454
    assert!(refusal.reason.contains("symbol"));
455
}
456
457
#[test]
458
fn word_counting_never_matches_inside_a_longer_identifier() {
459
    assert_eq!(word_count("foo foobar foo_bar barfoo foo", "foo"), 2);
460
    assert_eq!(word_count("foo(foo) [foo] .foo,", "foo"), 4);
461
    assert_eq!(word_count("", "foo"), 0);
462
}
463
464
// ---- caps and truncation ----
465
466
#[test]
467
fn the_file_budget_truncates_the_walk_honestly() {
468
    let out = outline_of(&seeded(), &Input { max_files: Some(1), ..input() });
469
    assert_eq!(out.files.len(), 1);
470
    assert_eq!(out.stats.files_seen, 1);
471
    assert!(out.stats.truncated);
472
}
473
474
#[test]
475
fn the_file_budget_never_exceeds_its_cap() {
476
    let mut host = seeded();
477
    let request = Input { max_files: Some(1_000_000), ..input() };
478
    // The clamp is what matters; the seeded tree is far under the cap, so
479
    // prove the clamp arithmetic directly on a listing the walk sees.
480
    let out = outline_of(&mut host, &request);
481
    assert!(!out.stats.truncated);
482
    assert_eq!(out.files.len(), 3);
483
}
484
485
#[test]
486
fn the_symbol_cap_truncates_a_file_and_says_so() {
487
    let out = outline_of(&seeded(), &Input { max_symbols_per_file: Some(1), ..input() });
488
    let file = out.files.iter().find(|f| f.path == "src/main.py").unwrap();
489
    assert_eq!(file.symbols.as_deref().unwrap().len(), 1);
490
    assert!(file.symbols_truncated);
491
}
492
493
#[test]
494
fn a_truncated_directory_listing_marks_the_map_truncated() {
495
    let mut host = seeded();
496
    host.dirs.get_mut("src").unwrap().truncated = true;
497
    let out = outline_of(&host, &input());
498
    assert!(out.stats.truncated);
499
}
plugins/repo-tree/Cargo.toml added +14

@@ -0,0 +1,14 @@

1
[package]
2
name = "repo-tree"
3
version = "0.1.0"
4
edition.workspace = true
5
license.workspace = true
6
description = "Guest plugin that lists the workspace's file tree honoring a documented gitignore subset, with a fuzzy file-name lookup mode, read-only and bounded."
7
8
[lib]
9
crate-type = ["cdylib", "rlib"]
10
11
[dependencies]
12
openagents-pdk = { workspace = true }
13
serde = { workspace = true }
14
serde_json = { workspace = true }
plugins/repo-tree/manifest.json added +97

@@ -0,0 +1,97 @@

1
{
2
  "manifest_version": 1,
3
  "name": "repo_tree",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "List the files and directory tree of this repository or workspace, honoring gitignore, with fuzzy file name lookup. Tree mode (the default) walks the workspace depth-first and returns entries with path, kind, and size, applying root and nested .gitignore files through a documented subset: comments and blanks skipped, trailing-slash directory patterns, leading-slash anchoring, `*` within a segment, `**` across segments, and no negation (`!` lines are counted as ignored_negations). Pass query to get fuzzy file-name matches instead of the tree: a case-insensitive subsequence match against each file path, ranked by match tightness. `.git` is always skipped. Read-only and bounded: depth, entry, and listing ceilings apply, and truncation is reported honestly.",
7
  "artifact": {
8
    "path": "repo_tree.wasm",
9
    "digest": "sha256:bde68d4e422be5e2069c756fa6c95a78a5a8f73129939add560c54969e4a8a46"
10
  },
11
  "abi": {
12
    "kind": "packet-v0",
13
    "entry": "handle_packet",
14
    "alloc": "packet_alloc"
15
  },
16
  "interface": {
17
    "input": {
18
      "type": "object",
19
      "properties": {
20
        "path": {
21
          "type": "string",
22
          "description": "Subtree to walk, relative to the workspace root. The root when omitted."
23
        },
24
        "max_depth": {
25
          "type": "integer",
26
          "description": "Deepest entry level to return. Default 4 (8 in query mode), capped at 8."
27
        },
28
        "max_entries": {
29
          "type": "integer",
30
          "description": "Most tree entries to return. Default 200, capped at 500."
31
        },
32
        "query": {
33
          "type": "string",
34
          "description": "Fuzzy file-name lookup: a case-insensitive subsequence matched against each file path (e.g. `authcontroller` matches `lib/auth_controller.ex`). When present, matches are returned instead of the tree."
35
        },
36
        "limit": {
37
          "type": "integer",
38
          "description": "Most query matches to return. Default 10, capped at 25."
39
        }
40
      }
41
    },
42
    "output": {
43
      "type": "object",
44
      "properties": {
45
        "entries": {
46
          "type": "array",
47
          "description": "Tree mode: the entries, depth-first.",
48
          "items": {
49
            "type": "object",
50
            "properties": {
51
              "path": { "type": "string" },
52
              "kind": { "type": "string", "enum": ["file", "dir"] },
53
              "size": { "type": "integer" }
54
            }
55
          }
56
        },
57
        "total_seen": {
58
          "type": "integer",
59
          "description": "Tree mode: entries examined before the walk finished or stopped."
60
        },
61
        "ignored_negations": {
62
          "type": "integer",
63
          "description": "Tree mode: `!` gitignore lines the subset ignored rather than honored."
64
        },
65
        "skipped_gitignored": {
66
          "type": "integer",
67
          "description": "Tree mode: entries the gitignore rules dropped; a skipped directory counts once and is never walked."
68
        },
69
        "matches": {
70
          "type": "array",
71
          "description": "Query mode: the ranked matches.",
72
          "items": {
73
            "type": "object",
74
            "properties": {
75
              "path": { "type": "string" },
76
              "size": { "type": "integer" }
77
            }
78
          }
79
        },
80
        "searched": {
81
          "type": "integer",
82
          "description": "Query mode: files tested against the query."
83
        },
84
        "truncated": {
85
          "type": "boolean",
86
          "description": "True when a ceiling cut the walk or the match list short."
87
        }
88
      }
89
    }
90
  },
91
  "capabilities": {
92
    "mounts": [{ "path": "${workspace}", "readonly": true }],
93
    "hosts": [],
94
    "timeout_ms": 10000,
95
    "memory_max_mib": 128
96
  }
97
}
plugins/repo-tree/repo_tree.wasm added

Binary file. Nothing to show as text.

plugins/repo-tree/src/lib.rs added +578

@@ -0,0 +1,578 @@

1
//! `repo_tree`: the workspace's file tree and a fuzzy file-name lookup, as
2
//! a `packet-v0` guest plugin (OpenAgentsInc/openagents#43).
3
//!
4
//! Mount 0 is the workspace — the manifest declares it as the literal
5
//! `${workspace}`, which the host resolves to its working directory at load
6
//! time (OpenAgentsInc/openagents#44). One tool, two modes:
7
//!
8
//! - **Tree mode** (the default): walk the workspace depth-first and return
9
//!   its entries — path, kind, size — honoring `.gitignore` files at the
10
//!   root and in nested directories, bounded by depth and entry ceilings.
11
//! - **Query mode** (when `query` is present): a case-insensitive
12
//!   subsequence match of the query against each file path, ranked by match
13
//!   tightness, returned instead of the tree.
14
//!
15
//! ## The gitignore subset, honestly
16
//!
17
//! This is a deliberate subset of gitignore matching, not the whole
18
//! grammar:
19
//!
20
//! - Blank lines and `#` comments are skipped.
21
//! - A trailing `/` makes the pattern match directories only.
22
//! - A pattern containing a `/` (leading or interior) is anchored to the
23
//!   directory holding its `.gitignore`; a pattern without one matches the
24
//!   entry's basename at any depth below it.
25
//! - `*` matches within a path segment; a `**` segment matches zero or
26
//!   more whole segments. No other wildcard (`?`, `[...]`, escapes) is
27
//!   special — those characters match literally.
28
//! - Negation is not implemented: `!` lines are ignored and counted in the
29
//!   output as `ignored_negations`, so a caller can see when the subset
30
//!   fell short.
31
//! - An ignored directory is skipped wholesale, so nothing under it is
32
//!   walked, matched, or counted.
33
//!
34
//! `.git` is skipped unconditionally, gitignored or not. Everything else —
35
//! `node_modules`, `_build`, `deps`, `target` — is skipped purely by
36
//! gitignore rules.
37
//!
38
//! ## Bounds
39
//!
40
//! At most [`LISTING_BOUND`] directory listings per invocation and
41
//! [`HELD_BOUND`] entries held; hitting either fails soft and sets
42
//! `truncated`. An unreadable nested directory is skipped rather than
43
//! failing the walk. Symlinks are reported by the host but never followed,
44
//! and entries of kind `symlink` or `other` are left out of the tree.
45
46
use openagents_pdk::{list_mounted_dir, plugin_entry, read_mounted_file, MountDirListing, Refusal};
47
use serde::{Deserialize, Serialize};
48
49
/// The workspace is the manifest's one mount.
50
const WORKSPACE_MOUNT: u32 = 0;
51
/// Deepest entry level in tree mode when the input names none.
52
const DEFAULT_MAX_DEPTH: usize = 4;
53
/// Depth ceiling for both modes; query mode defaults to it.
54
const DEPTH_CAP: usize = 8;
55
const DEFAULT_MAX_ENTRIES: usize = 200;
56
const ENTRY_CAP: usize = 500;
57
const DEFAULT_QUERY_LIMIT: usize = 10;
58
const QUERY_LIMIT_CAP: usize = 25;
59
/// Most directory listings one invocation may ask the host for.
60
pub const LISTING_BOUND: usize = 2_000;
61
/// Most entries one invocation holds before the walk stops.
62
pub const HELD_BOUND: usize = 10_000;
63
64
#[derive(Debug, Default, Deserialize)]
65
pub struct Input {
66
    /// Subtree to walk, relative to the workspace root. The root when absent.
67
    #[serde(default)]
68
    pub path: Option<String>,
69
    /// Deepest entry level to return. Default 4 (8 in query mode), capped at 8.
70
    #[serde(default)]
71
    pub max_depth: Option<usize>,
72
    /// Most tree entries to return. Default 200, capped at 500.
73
    #[serde(default)]
74
    pub max_entries: Option<usize>,
75
    /// Fuzzy file-name lookup; when present, matches replace the tree.
76
    #[serde(default)]
77
    pub query: Option<String>,
78
    /// Most query matches to return. Default 10, capped at 25.
79
    #[serde(default)]
80
    pub limit: Option<usize>,
81
}
82
83
#[derive(Debug, Serialize, PartialEq, Eq)]
84
pub struct Entry {
85
    /// Path relative to the workspace root.
86
    pub path: String,
87
    /// `"file"` or `"dir"`.
88
    pub kind: String,
89
    pub size: u64,
90
}
91
92
#[derive(Debug, Serialize)]
93
pub struct TreeOutput {
94
    pub entries: Vec<Entry>,
95
    /// Entries examined before the walk finished or stopped.
96
    pub total_seen: usize,
97
    /// True when a ceiling — entries, listings, held, or a host listing
98
    /// bound — cut the walk short.
99
    pub truncated: bool,
100
    /// `!` gitignore lines this subset ignored rather than honored.
101
    pub ignored_negations: usize,
102
    /// Entries the gitignore rules dropped (each skipped directory counts
103
    /// once; nothing under it is walked or counted).
104
    pub skipped_gitignored: usize,
105
}
106
107
#[derive(Debug, Serialize, PartialEq, Eq)]
108
pub struct Match {
109
    /// Path relative to the workspace root.
110
    pub path: String,
111
    pub size: u64,
112
}
113
114
#[derive(Debug, Serialize)]
115
pub struct QueryOutput {
116
    pub matches: Vec<Match>,
117
    /// Files tested against the query.
118
    pub searched: usize,
119
    /// True when the walk was cut short or more matches exist than `limit`.
120
    pub truncated: bool,
121
}
122
123
/// One output type, two shapes; the mode picks which.
124
#[derive(Debug, Serialize)]
125
#[serde(untagged)]
126
pub enum Output {
127
    Tree(TreeOutput),
128
    Query(QueryOutput),
129
}
130
131
/// The host capabilities this plugin uses, as a seam the tests fake.
132
pub trait Host {
133
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal>;
134
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal>;
135
}
136
137
struct RealHost;
138
139
impl Host for RealHost {
140
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
141
        list_mounted_dir(mount_index, path)
142
    }
143
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
144
        read_mounted_file(path)
145
    }
146
}
147
148
/// The whole tool, over any [`Host`].
149
pub fn repo_tree(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {
150
    let start = normalize_start(input.path.as_deref());
151
    match input.query.as_deref().map(str::trim) {
152
        Some(query) if !query.is_empty() => {
153
            Ok(Output::Query(query_mode(host, &start, query, input)?))
154
        }
155
        _ => Ok(Output::Tree(tree_mode(host, &start, input)?)),
156
    }
157
}
158
159
fn tree_mode(host: &dyn Host, start: &str, input: &Input) -> Result<TreeOutput, Refusal> {
160
    let max_entries = input
161
        .max_entries
162
        .unwrap_or(DEFAULT_MAX_ENTRIES)
163
        .clamp(1, ENTRY_CAP);
164
    let mut walk = Walk::new(host, input.max_depth.unwrap_or(DEFAULT_MAX_DEPTH));
165
    walk.load_ancestors(start);
166
    let mut entries: Vec<Entry> = Vec::new();
167
    walk.walk(start, 0, &mut |path, kind, size| {
168
        if entries.len() >= max_entries {
169
            return false;
170
        }
171
        entries.push(Entry {
172
            path,
173
            kind: kind.to_string(),
174
            size,
175
        });
176
        true
177
    })?;
178
    Ok(TreeOutput {
179
        entries,
180
        total_seen: walk.total_seen,
181
        truncated: walk.truncated,
182
        ignored_negations: walk.ignored_negations,
183
        skipped_gitignored: walk.skipped_gitignored,
184
    })
185
}
186
187
fn query_mode(
188
    host: &dyn Host,
189
    start: &str,
190
    query: &str,
191
    input: &Input,
192
) -> Result<QueryOutput, Refusal> {
193
    let limit = input
194
        .limit
195
        .unwrap_or(DEFAULT_QUERY_LIMIT)
196
        .clamp(1, QUERY_LIMIT_CAP);
197
    // Lookup wants the whole tree; without an explicit depth it walks to
198
    // the cap rather than tree mode's shallower default.
199
    let mut walk = Walk::new(host, input.max_depth.unwrap_or(DEPTH_CAP));
200
    walk.load_ancestors(start);
201
    let mut files: Vec<(String, u64)> = Vec::new();
202
    walk.walk(start, 0, &mut |path, kind, size| {
203
        if kind != "file" {
204
            return true;
205
        }
206
        if files.len() >= HELD_BOUND {
207
            return false;
208
        }
209
        files.push((path, size));
210
        true
211
    })?;
212
213
    let query_lower: Vec<char> = query.chars().map(|c| c.to_ascii_lowercase()).collect();
214
    let searched = files.len();
215
    let mut scored: Vec<(Score, Match)> = files
216
        .into_iter()
217
        .filter_map(|(path, size)| score(&query_lower, &path).map(|s| (s, Match { path, size })))
218
        .collect();
219
    scored.sort_by(|(a, am), (b, bm)| {
220
        a.span
221
            .cmp(&b.span)
222
            .then(b.boundaries.cmp(&a.boundaries))
223
            .then(am.path.len().cmp(&bm.path.len()))
224
            .then(am.path.cmp(&bm.path))
225
    });
226
    let found = scored.len();
227
    let matches: Vec<Match> = scored.into_iter().take(limit).map(|(_, m)| m).collect();
228
    Ok(QueryOutput {
229
        matches,
230
        searched,
231
        truncated: walk.truncated || found > limit,
232
    })
233
}
234
235
// ---------------------------------------------------------------------------
236
// The walk
237
238
/// A visitor: entry path, kind, size; `false` stops the whole walk.
239
type Sink<'s> = dyn FnMut(String, &str, u64) -> bool + 's;
240
241
struct Walk<'a> {
242
    host: &'a dyn Host,
243
    /// Deepest entry level to report; the walk's root's children are level 1.
244
    max_depth: usize,
245
    listings: usize,
246
    truncated: bool,
247
    total_seen: usize,
248
    skipped_gitignored: usize,
249
    ignored_negations: usize,
250
    rule_sets: Vec<RuleSet>,
251
}
252
253
impl<'a> Walk<'a> {
254
    fn new(host: &'a dyn Host, max_depth: usize) -> Self {
255
        Walk {
256
            host,
257
            max_depth: max_depth.clamp(1, DEPTH_CAP),
258
            listings: 0,
259
            truncated: false,
260
            total_seen: 0,
261
            skipped_gitignored: 0,
262
            ignored_negations: 0,
263
            rule_sets: Vec::new(),
264
        }
265
    }
266
267
    /// Rules from `.gitignore` files above the walk's start, so a subtree
268
    /// walk still honors the root's ignores. The start's own `.gitignore`
269
    /// is loaded by the walk itself.
270
    fn load_ancestors(&mut self, start: &str) {
271
        if start.is_empty() {
272
            return;
273
        }
274
        self.push_gitignore("");
275
        let mut base = String::new();
276
        let components: Vec<&str> = start.split('/').collect();
277
        for component in &components[..components.len() - 1] {
278
            base = join(&base, component);
279
            self.push_gitignore(&base);
280
        }
281
    }
282
283
    /// Read and push this directory's `.gitignore`, if it has one. Returns
284
    /// whether a rule set was pushed, so the caller can pop symmetrically.
285
    fn push_gitignore(&mut self, dir: &str) -> bool {
286
        match self.host.read(&join(dir, ".gitignore")) {
287
            Ok(bytes) => {
288
                let (rules, negations) = parse_gitignore(&bytes);
289
                self.ignored_negations += negations;
290
                self.rule_sets.push(RuleSet {
291
                    base: dir.to_string(),
292
                    rules,
293
                });
294
                true
295
            }
296
            // Absent or unreadable is the same answer: no rules here.
297
            Err(_) => false,
298
        }
299
    }
300
301
    fn ignored(&self, path: &str, is_dir: bool) -> bool {
302
        self.rule_sets.iter().any(|set| {
303
            let rel = if set.base.is_empty() {
304
                Some(path)
305
            } else {
306
                path.strip_prefix(set.base.as_str())
307
                    .and_then(|rest| rest.strip_prefix('/'))
308
            };
309
            rel.is_some_and(|rel| set.rules.iter().any(|rule| rule.matches(rel, is_dir)))
310
        })
311
    }
312
313
    /// Depth-first over `dir` (whose own depth is `depth`; its children are
314
    /// `depth + 1`). Returns whether the walk should keep going.
315
    fn walk(&mut self, dir: &str, depth: usize, sink: &mut Sink) -> Result<bool, Refusal> {
316
        if self.listings >= LISTING_BOUND {
317
            self.truncated = true;
318
            return Ok(true);
319
        }
320
        self.listings += 1;
321
        let listing = match self.host.list(WORKSPACE_MOUNT, dir) {
322
            Ok(listing) => listing,
323
            // The walk's own root must exist; a nested directory that
324
            // refuses to list is skipped, fail-soft.
325
            Err(refusal) if depth == 0 => return Err(refusal),
326
            Err(_) => return Ok(true),
327
        };
328
        if listing.truncated {
329
            self.truncated = true;
330
        }
331
        let pushed = self.push_gitignore(dir);
332
        let mut keep_going = true;
333
        for entry in &listing.entries {
334
            // `.git` is skipped unconditionally, never walked, never counted.
335
            if entry.name == ".git" {
336
                continue;
337
            }
338
            // Symlinks are reported but never followed; neither shape fits
339
            // the tree.
340
            if entry.kind != "file" && entry.kind != "dir" {
341
                continue;
342
            }
343
            self.total_seen += 1;
344
            let path = join(dir, &entry.name);
345
            if self.ignored(&path, entry.kind == "dir") {
346
                self.skipped_gitignored += 1;
347
                continue;
348
            }
349
            if !sink(path.clone(), &entry.kind, entry.size) {
350
                self.truncated = true;
351
                keep_going = false;
352
                break;
353
            }
354
            if entry.kind == "dir" && depth + 1 < self.max_depth {
355
                if !self.walk(&path, depth + 1, sink)? {
356
                    keep_going = false;
357
                    break;
358
                }
359
            }
360
        }
361
        if pushed {
362
            self.rule_sets.pop();
363
        }
364
        Ok(keep_going)
365
    }
366
}
367
368
fn normalize_start(path: Option<&str>) -> String {
369
    let mut trimmed = path.unwrap_or("").trim();
370
    while let Some(rest) = trimmed.strip_prefix("./") {
371
        trimmed = rest;
372
    }
373
    let trimmed = trimmed.trim_matches('/');
374
    if trimmed == "." {
375
        String::new()
376
    } else {
377
        trimmed.to_string()
378
    }
379
}
380
381
fn join(dir: &str, name: &str) -> String {
382
    if dir.is_empty() {
383
        name.to_string()
384
    } else {
385
        format!("{dir}/{name}")
386
    }
387
}
388
389
// ---------------------------------------------------------------------------
390
// The gitignore subset
391
392
struct RuleSet {
393
    /// Directory holding the `.gitignore`, relative to the workspace root.
394
    base: String,
395
    rules: Vec<Rule>,
396
}
397
398
struct Rule {
399
    /// Pattern split on `/`; a lone `**` segment crosses segments.
400
    segments: Vec<String>,
401
    /// Trailing `/`: the pattern matches directories only.
402
    dir_only: bool,
403
    /// The pattern contained a `/`, so it matches relative to its
404
    /// `.gitignore`'s directory; otherwise it matches basenames anywhere
405
    /// below it.
406
    anchored: bool,
407
}
408
409
impl Rule {
410
    /// Does this rule match `rel` (relative to the rule's base)?
411
    fn matches(&self, rel: &str, is_dir: bool) -> bool {
412
        if self.dir_only && !is_dir {
413
            return false;
414
        }
415
        let path: Vec<&str> = rel.split('/').collect();
416
        if self.anchored {
417
            glob_path(&self.segments, &path)
418
        } else {
419
            path.last()
420
                .is_some_and(|name| glob_segment(&self.segments[0], name))
421
        }
422
    }
423
}
424
425
/// Parse one `.gitignore`'s bytes into the subset's rules, plus how many
426
/// `!` negation lines were ignored.
427
fn parse_gitignore(bytes: &[u8]) -> (Vec<Rule>, usize) {
428
    let text = String::from_utf8_lossy(bytes);
429
    let mut rules = Vec::new();
430
    let mut negations = 0usize;
431
    for raw in text.lines() {
432
        let line = raw.trim();
433
        if line.is_empty() || line.starts_with('#') {
434
            continue;
435
        }
436
        if line.starts_with('!') {
437
            negations += 1;
438
            continue;
439
        }
440
        let (body, dir_only) = match line.strip_suffix('/') {
441
            Some(body) => (body, true),
442
            None => (line, false),
443
        };
444
        let (body, leading_slash) = match body.strip_prefix('/') {
445
            Some(body) => (body, true),
446
            None => (body, false),
447
        };
448
        if body.is_empty() {
449
            continue;
450
        }
451
        let anchored = leading_slash || body.contains('/');
452
        let segments: Vec<String> = body
453
            .split('/')
454
            .filter(|s| !s.is_empty())
455
            .map(str::to_string)
456
            .collect();
457
        if segments.is_empty() {
458
            continue;
459
        }
460
        rules.push(Rule {
461
            segments,
462
            dir_only,
463
            anchored,
464
        });
465
    }
466
    (rules, negations)
467
}
468
469
/// Match pattern segments against path segments; a `**` pattern segment
470
/// matches zero or more whole path segments.
471
fn glob_path(pattern: &[String], path: &[&str]) -> bool {
472
    match pattern.split_first() {
473
        None => path.is_empty(),
474
        Some((first, rest)) if first == "**" => {
475
            (0..=path.len()).any(|skip| glob_path(rest, &path[skip..]))
476
        }
477
        Some((first, rest)) => path
478
            .split_first()
479
            .is_some_and(|(segment, more)| glob_segment(first, segment) && glob_path(rest, more)),
480
    }
481
}
482
483
/// Match one pattern segment against one name; `*` matches any run of
484
/// characters within the segment, everything else is literal.
485
fn glob_segment(pattern: &str, name: &str) -> bool {
486
    let pattern: Vec<char> = pattern.chars().collect();
487
    let name: Vec<char> = name.chars().collect();
488
    segment_match(&pattern, &name)
489
}
490
491
fn segment_match(pattern: &[char], name: &[char]) -> bool {
492
    match pattern.split_first() {
493
        None => name.is_empty(),
494
        Some(('*', rest)) => {
495
            // Collapse star runs so `**` inside a segment is one `*`.
496
            let rest = if rest.first() == Some(&'*') {
497
                &rest[1..]
498
            } else {
499
                rest
500
            };
501
            (0..=name.len()).any(|skip| segment_match(rest, &name[skip..]))
502
        }
503
        Some((ch, rest)) => name
504
            .split_first()
505
            .is_some_and(|(first, more)| first == ch && segment_match(rest, more)),
506
    }
507
}
508
509
// ---------------------------------------------------------------------------
510
// Query scoring
511
512
/// How well a path matched: a shorter span is tighter, and more matched
513
/// characters sitting on segment boundaries (start of the path, or after
514
/// `/`, `.`, `_`, `-`) is better on a tie. That is the whole formula.
515
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
516
pub struct Score {
517
    pub span: usize,
518
    pub boundaries: usize,
519
}
520
521
/// The best case-insensitive subsequence match of `query` in `path`, or
522
/// `None`. Each occurrence of the query's first character starts one greedy
523
/// forward match; the tightest wins. Greedy interior choices are not
524
/// globally optimal, which is a documented simplification.
525
pub fn score(query_lower: &[char], path: &str) -> Option<Score> {
526
    let lower: Vec<char> = path.chars().map(|c| c.to_ascii_lowercase()).collect();
527
    let is_boundary = |at: usize| at == 0 || matches!(lower[at - 1], '/' | '.' | '_' | '-');
528
    let first = *query_lower.first()?;
529
    let mut best: Option<Score> = None;
530
    for start in 0..lower.len() {
531
        if lower[start] != first {
532
            continue;
533
        }
534
        let mut boundaries = usize::from(is_boundary(start));
535
        let mut at = start;
536
        let mut complete = true;
537
        for &wanted in &query_lower[1..] {
538
            match (at + 1..lower.len()).find(|&i| lower[i] == wanted) {
539
                Some(found) => {
540
                    at = found;
541
                    boundaries += usize::from(is_boundary(found));
542
                }
543
                None => {
544
                    complete = false;
545
                    break;
546
                }
547
            }
548
        }
549
        if !complete {
550
            // Greedy from here used the earliest possible positions; a
551
            // later start has strictly less to work with.
552
            break;
553
        }
554
        let candidate = Score {
555
            span: at - start + 1,
556
            boundaries,
557
        };
558
        best = Some(match best {
559
            Some(held)
560
                if held.span < candidate.span
561
                    || (held.span == candidate.span && held.boundaries >= candidate.boundaries) =>
562
            {
563
                held
564
            }
565
            _ => candidate,
566
        });
567
    }
568
    best
569
}
570
571
fn handle(input: Input) -> Result<Output, Refusal> {
572
    repo_tree(&RealHost, &input)
573
}
574
575
plugin_entry!(handle);
576
577
#[cfg(test)]
578
mod tests;
plugins/repo-tree/src/tests.rs added +444

@@ -0,0 +1,444 @@

1
//! The tree and the lookup against a fake host: every gitignore-subset
2
//! rule, the depth and entry ceilings, query matching and ranking, and the
3
//! unconditional `.git` skip — all without a WASM runtime, the same
4
//! pattern as the scanner's and reader's tests.
5
6
use super::*;
7
use openagents_pdk::{MountDirEntry, RefusalCode};
8
use std::collections::BTreeMap;
9
10
#[derive(Default)]
11
struct FakeHost {
12
    dirs: BTreeMap<(u32, String), MountDirListing>,
13
    files: BTreeMap<String, Vec<u8>>,
14
}
15
16
impl Host for FakeHost {
17
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
18
        self.dirs
19
            .get(&(mount_index, path.to_string()))
20
            .cloned()
21
            .ok_or_else(|| {
22
                Refusal::new(
23
                    RefusalCode::FileUnreadable,
24
                    "the mount has no such directory",
25
                )
26
            })
27
    }
28
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
29
        self.files
30
            .get(path)
31
            .cloned()
32
            .ok_or_else(|| Refusal::new(RefusalCode::FileUnreadable, "no such file"))
33
    }
34
}
35
36
/// Build a workspace host from `(path, contents)` pairs; directories are
37
/// derived from the paths, listings sorted by name like the real host's.
38
fn workspace(files: &[(&str, &str)]) -> FakeHost {
39
    let mut children: BTreeMap<String, BTreeMap<String, (String, u64)>> = BTreeMap::new();
40
    children.insert(String::new(), BTreeMap::new());
41
    let mut host = FakeHost::default();
42
    for (path, body) in files {
43
        host.files
44
            .insert((*path).to_string(), body.as_bytes().to_vec());
45
        let parts: Vec<&str> = path.split('/').collect();
46
        let mut dir = String::new();
47
        for (at, part) in parts.iter().enumerate() {
48
            if at == parts.len() - 1 {
49
                children
50
                    .entry(dir.clone())
51
                    .or_default()
52
                    .insert((*part).to_string(), ("file".to_string(), body.len() as u64));
53
            } else {
54
                children
55
                    .entry(dir.clone())
56
                    .or_default()
57
                    .entry((*part).to_string())
58
                    .or_insert(("dir".to_string(), 0));
59
                dir = join(&dir, part);
60
                children.entry(dir.clone()).or_default();
61
            }
62
        }
63
    }
64
    for (dir, kids) in children {
65
        let entries = kids
66
            .into_iter()
67
            .map(|(name, (kind, size))| MountDirEntry {
68
                name,
69
                kind,
70
                size,
71
                mtime_ms: 0,
72
            })
73
            .collect();
74
        host.dirs.insert(
75
            (0, dir),
76
            MountDirListing {
77
                entries,
78
                truncated: false,
79
            },
80
        );
81
    }
82
    host
83
}
84
85
fn input() -> Input {
86
    Input::default()
87
}
88
89
fn tree(host: &FakeHost, input: &Input) -> TreeOutput {
90
    match repo_tree(host, input).unwrap() {
91
        Output::Tree(out) => out,
92
        Output::Query(_) => panic!("expected tree output"),
93
    }
94
}
95
96
fn query(host: &FakeHost, text: &str, extra: Input) -> QueryOutput {
97
    match repo_tree(
98
        host,
99
        &Input {
100
            query: Some(text.to_string()),
101
            ..extra
102
        },
103
    )
104
    .unwrap()
105
    {
106
        Output::Query(out) => out,
107
        Output::Tree(_) => panic!("expected query output"),
108
    }
109
}
110
111
fn paths(out: &TreeOutput) -> Vec<&str> {
112
    out.entries.iter().map(|e| e.path.as_str()).collect()
113
}
114
115
#[test]
116
fn lists_the_tree_depth_first_with_paths_kinds_and_sizes() {
117
    let host = workspace(&[
118
        ("README.md", "hello"),
119
        ("src/lib.rs", "fn main() {}"),
120
        ("src/sub/deep.rs", "x"),
121
    ]);
122
    let out = tree(&host, &input());
123
    assert_eq!(
124
        out.entries
125
            .iter()
126
            .map(|e| (e.path.as_str(), e.kind.as_str()))
127
            .collect::<Vec<_>>(),
128
        vec![
129
            ("README.md", "file"),
130
            ("src", "dir"),
131
            ("src/lib.rs", "file"),
132
            ("src/sub", "dir"),
133
            ("src/sub/deep.rs", "file"),
134
        ],
135
    );
136
    assert_eq!(out.entries[0].size, 5);
137
    assert_eq!(out.total_seen, 5);
138
    assert!(!out.truncated);
139
    assert_eq!(out.skipped_gitignored, 0);
140
    assert_eq!(out.ignored_negations, 0);
141
}
142
143
#[test]
144
fn dot_git_is_always_skipped_even_without_a_gitignore() {
145
    let host = workspace(&[
146
        (".git/config", "[core]"),
147
        (".git/HEAD", "ref"),
148
        ("a.txt", "a"),
149
    ]);
150
    let out = tree(&host, &input());
151
    assert_eq!(paths(&out), vec!["a.txt"]);
152
    // Not counted as seen and not counted as gitignored: it never exists.
153
    assert_eq!(out.total_seen, 1);
154
    assert_eq!(out.skipped_gitignored, 0);
155
}
156
157
#[test]
158
fn blank_lines_and_comments_are_skipped() {
159
    let host = workspace(&[
160
        (".gitignore", "# logs\n\n*.log\n"),
161
        ("a.log", "x"),
162
        ("a.txt", "x"),
163
    ]);
164
    let out = tree(&host, &input());
165
    assert_eq!(paths(&out), vec![".gitignore", "a.txt"]);
166
    assert_eq!(out.skipped_gitignored, 1);
167
}
168
169
#[test]
170
fn an_unanchored_name_matches_at_any_depth_and_prunes_the_subtree() {
171
    let host = workspace(&[
172
        (".gitignore", "target\n"),
173
        ("kept.rs", "x"),
174
        ("sub/target/out.o", "x"),
175
        ("target/out.o", "x"),
176
    ]);
177
    let out = tree(&host, &input());
178
    assert_eq!(paths(&out), vec![".gitignore", "kept.rs", "sub"]);
179
    // Each pruned directory counts once; what is under it is never seen.
180
    assert_eq!(out.skipped_gitignored, 2);
181
}
182
183
#[test]
184
fn a_trailing_slash_matches_directories_only() {
185
    let host = workspace(&[
186
        (".gitignore", "build/\n"),
187
        ("build/out.o", "x"),
188
        ("docs/build", "a file named build"),
189
    ]);
190
    let out = tree(&host, &input());
191
    assert_eq!(paths(&out), vec![".gitignore", "docs", "docs/build"]);
192
    assert_eq!(out.skipped_gitignored, 1);
193
}
194
195
#[test]
196
fn a_leading_slash_anchors_to_the_gitignore_directory() {
197
    let host = workspace(&[
198
        (".gitignore", "/vendor\n"),
199
        ("pkg/vendor/b.js", "x"),
200
        ("vendor/a.js", "x"),
201
    ]);
202
    let out = tree(&host, &input());
203
    assert_eq!(
204
        paths(&out),
205
        vec![".gitignore", "pkg", "pkg/vendor", "pkg/vendor/b.js"]
206
    );
207
    assert_eq!(out.skipped_gitignored, 1);
208
}
209
210
#[test]
211
fn star_matches_within_a_segment_but_not_across() {
212
    let host = workspace(&[
213
        (".gitignore", "/src/*.gen\n"),
214
        ("src/a.gen", "x"),
215
        ("src/keep.rs", "x"),
216
        ("src/sub/b.gen", "x"),
217
    ]);
218
    let out = tree(&host, &input());
219
    // `*` stays inside one segment: `src/sub/b.gen` is not `/src/*.gen`.
220
    assert_eq!(
221
        paths(&out),
222
        vec![
223
            ".gitignore",
224
            "src",
225
            "src/keep.rs",
226
            "src/sub",
227
            "src/sub/b.gen"
228
        ],
229
    );
230
    assert_eq!(out.skipped_gitignored, 1);
231
}
232
233
#[test]
234
fn double_star_matches_zero_or_more_segments() {
235
    let host = workspace(&[
236
        (".gitignore", "src/**/gen\n"),
237
        ("other/gen/kept.txt", "x"),
238
        ("src/a/b/gen/deep.txt", "x"),
239
        ("src/gen/direct.txt", "x"),
240
    ]);
241
    let out = tree(&host, &input());
242
    assert_eq!(
243
        paths(&out),
244
        vec![
245
            ".gitignore",
246
            "other",
247
            "other/gen",
248
            "other/gen/kept.txt",
249
            "src",
250
            "src/a",
251
            "src/a/b",
252
        ],
253
    );
254
    // `src/gen` (zero segments) and `src/a/b/gen` (two) both matched.
255
    assert_eq!(out.skipped_gitignored, 2);
256
}
257
258
#[test]
259
fn negations_are_ignored_and_counted() {
260
    let host = workspace(&[
261
        (".gitignore", "*.log\n!keep.log\n"),
262
        ("drop.log", "x"),
263
        ("keep.log", "x"),
264
    ]);
265
    let out = tree(&host, &input());
266
    // The subset cannot un-ignore, and it says so.
267
    assert_eq!(paths(&out), vec![".gitignore"]);
268
    assert_eq!(out.skipped_gitignored, 2);
269
    assert_eq!(out.ignored_negations, 1);
270
}
271
272
#[test]
273
fn a_nested_gitignore_applies_to_its_subtree_only() {
274
    let host = workspace(&[
275
        ("b.tmp", "kept at the root"),
276
        ("sub/.gitignore", "*.tmp\n"),
277
        ("sub/a.tmp", "x"),
278
        ("sub/kept.rs", "x"),
279
    ]);
280
    let out = tree(&host, &input());
281
    assert_eq!(
282
        paths(&out),
283
        vec!["b.tmp", "sub", "sub/.gitignore", "sub/kept.rs"]
284
    );
285
    assert_eq!(out.skipped_gitignored, 1);
286
}
287
288
#[test]
289
fn the_depth_default_is_four_and_the_cap_is_eight() {
290
    let host = workspace(&[("d1/d2/d3/d4/d5/d6/d7/d8/d9/deep.txt", "x")]);
291
    let shallow = tree(&host, &input());
292
    assert_eq!(shallow.entries.last().unwrap().path, "d1/d2/d3/d4");
293
294
    let deep = tree(
295
        &host,
296
        &Input {
297
            max_depth: Some(99),
298
            ..input()
299
        },
300
    );
301
    assert_eq!(deep.entries.last().unwrap().path, "d1/d2/d3/d4/d5/d6/d7/d8");
302
    assert!(!paths(&deep).contains(&"d1/d2/d3/d4/d5/d6/d7/d8/d9"));
303
304
    let one = tree(
305
        &host,
306
        &Input {
307
            max_depth: Some(1),
308
            ..input()
309
        },
310
    );
311
    assert_eq!(paths(&one), vec!["d1"]);
312
}
313
314
#[test]
315
fn the_entry_ceiling_stops_the_walk_and_says_so() {
316
    let files: Vec<(String, &str)> = (0..10)
317
        .map(|at| (format!("file{at:02}.txt"), "x"))
318
        .collect();
319
    let borrowed: Vec<(&str, &str)> = files.iter().map(|(p, b)| (p.as_str(), *b)).collect();
320
    let host = workspace(&borrowed);
321
    let out = tree(
322
        &host,
323
        &Input {
324
            max_entries: Some(3),
325
            ..input()
326
        },
327
    );
328
    assert_eq!(out.entries.len(), 3);
329
    assert!(out.truncated);
330
}
331
332
#[test]
333
fn a_subtree_walk_returns_root_relative_paths_and_honors_ancestor_ignores() {
334
    let host = workspace(&[
335
        (".gitignore", "*.log\n"),
336
        ("other/b.rs", "x"),
337
        ("src/a.rs", "x"),
338
        ("src/x.log", "x"),
339
    ]);
340
    let out = tree(
341
        &host,
342
        &Input {
343
            path: Some("src".to_string()),
344
            ..input()
345
        },
346
    );
347
    assert_eq!(paths(&out), vec!["src/a.rs"]);
348
    assert_eq!(out.skipped_gitignored, 1);
349
}
350
351
#[test]
352
fn a_missing_subtree_is_a_refusal_not_a_trap() {
353
    let host = workspace(&[("a.txt", "x")]);
354
    let refusal = repo_tree(
355
        &host,
356
        &Input {
357
            path: Some("nope".to_string()),
358
            ..input()
359
        },
360
    )
361
    .unwrap_err();
362
    assert_eq!(refusal.code, RefusalCode::FileUnreadable);
363
}
364
365
#[test]
366
fn a_query_matches_a_case_insensitive_subsequence_of_the_path() {
367
    let host = workspace(&[("lib/auth_controller.ex", "x"), ("lib/other.ex", "x")]);
368
    let out = query(&host, "AuthController", input());
369
    assert_eq!(
370
        out.matches
371
            .iter()
372
            .map(|m| m.path.as_str())
373
            .collect::<Vec<_>>(),
374
        vec!["lib/auth_controller.ex"],
375
    );
376
    assert_eq!(out.searched, 2);
377
    assert!(!out.truncated);
378
}
379
380
#[test]
381
fn query_ranking_prefers_tight_spans_then_segment_boundaries() {
382
    let host = workspace(&[("a_u_t_h.txt", "x"), ("auth.ex", "x"), ("zauthz.md", "x")]);
383
    let out = query(&host, "auth", input());
384
    assert_eq!(
385
        out.matches
386
            .iter()
387
            .map(|m| m.path.as_str())
388
            .collect::<Vec<_>>(),
389
        // Span 4 beats span 7; on the span tie, `auth.ex` starts on a
390
        // boundary and `zauthz.md` does not.
391
        vec!["auth.ex", "zauthz.md", "a_u_t_h.txt"],
392
    );
393
}
394
395
#[test]
396
fn the_query_limit_truncates_and_says_so() {
397
    let files: Vec<(String, &str)> = (0..5).map(|at| (format!("match{at}.txt"), "x")).collect();
398
    let borrowed: Vec<(&str, &str)> = files.iter().map(|(p, b)| (p.as_str(), *b)).collect();
399
    let host = workspace(&borrowed);
400
    let out = query(
401
        &host,
402
        "match",
403
        Input {
404
            limit: Some(2),
405
            ..input()
406
        },
407
    );
408
    assert_eq!(out.matches.len(), 2);
409
    assert!(out.truncated);
410
    assert_eq!(out.searched, 5);
411
}
412
413
#[test]
414
fn the_query_never_sees_git_or_gitignored_files() {
415
    let host = workspace(&[
416
        (".git/carbon.txt", "x"),
417
        (".gitignore", "*.log\n"),
418
        ("app.rs", "x"),
419
        ("carbon.log", "x"),
420
    ]);
421
    let out = query(&host, "car", input());
422
    assert!(out.matches.is_empty());
423
    // `.gitignore` and `app.rs` were searched; the ignored and `.git`
424
    // files never entered the candidate set.
425
    assert_eq!(out.searched, 2);
426
}
427
428
#[test]
429
fn a_deep_file_is_found_by_query_where_the_tree_default_would_hide_it() {
430
    let host = workspace(&[("d1/d2/d3/d4/d5/d6/needle.rs", "x")]);
431
    let out = query(&host, "needle", input());
432
    assert_eq!(out.matches[0].path, "d1/d2/d3/d4/d5/d6/needle.rs");
433
}
434
435
#[test]
436
fn scoring_is_what_the_doc_says() {
437
    let q: Vec<char> = "ac".chars().collect();
438
    // Tightest start wins: `a` recurs, and the later start is tighter.
439
    let best = score(&q, "a__x/ac.rs").unwrap();
440
    assert_eq!(best.span, 2);
441
    assert_eq!(best.boundaries, 1);
442
    assert!(score(&q, "ca.rs").is_none());
443
    assert!(score(&[], "anything").is_none());
444
}
plugins/session-search/Cargo.toml added +20

@@ -0,0 +1,20 @@

1
# The session searcher: full-text search over every Claude Code and Codex
2
# CLI conversation on the machine (OpenAgentsInc/openagents#46). Locates
3
# candidate sessions through the foreign-sessions scanner, then reads each
4
# one back and searches the conversation text.
5
6
[package]
7
name = "session-search"
8
version = "0.1.0"
9
edition.workspace = true
10
license.workspace = true
11
description = "Guest plugin that searches the content of every Claude Code and Codex CLI session on the machine for a word or phrase, through read-only mounts, bounded and case-insensitive."
12
13
[lib]
14
crate-type = ["cdylib", "rlib"]
15
16
[dependencies]
17
openagents-pdk = { workspace = true }
18
foreign-sessions = { path = "../foreign-sessions", default-features = false }
19
serde = { workspace = true }
20
serde_json = { workspace = true }
plugins/session-search/manifest.json added +108

@@ -0,0 +1,108 @@

1
{
2
  "manifest_version": 1,
3
  "name": "session_search",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "Search all Claude Code and Codex conversations and sessions on this machine for a word, error, or phrase. Use it when asked where something was discussed, which session mentioned an error message, a file, a decision, or a topic, or to grep across past conversations and chat history. Case-insensitive, newest sessions first, matching only what people actually said — thinking and tool payloads are not searched. Read-only and bounded: each hit returns its role and a small window of surrounding context, oversized session files are searched from their tail and marked, and the output says when the session budget cut the search short. It never resumes, continues, or writes anything.",
7
  "artifact": {
8
    "path": "session_search.wasm",
9
    "digest": "sha256:969e241ef5eb40b3a064d4d1abec8dc716a63f9be6dd0f4d92c76aad94001611"
10
  },
11
  "abi": {
12
    "kind": "packet-v0",
13
    "entry": "handle_packet",
14
    "alloc": "packet_alloc"
15
  },
16
  "interface": {
17
    "input": {
18
      "type": "object",
19
      "properties": {
20
        "query": {
21
          "type": "string",
22
          "description": "The word or phrase to search for, matched case-insensitively. Required and must be non-empty."
23
        },
24
        "sources": {
25
          "type": "array",
26
          "items": { "type": "string", "enum": ["claude", "codex"] },
27
          "description": "Which stores to search. Both when omitted."
28
        },
29
        "cwd_filter": {
30
          "type": "string",
31
          "description": "Only search sessions whose working directory contains this substring."
32
        },
33
        "max_age_days": {
34
          "type": "number",
35
          "description": "Only search sessions modified within this many days. Default 90."
36
        },
37
        "max_sessions": {
38
          "type": "integer",
39
          "description": "Most sessions to search, newest first. Default 20, capped at 40."
40
        },
41
        "max_hits_per_session": {
42
          "type": "integer",
43
          "description": "Most hits reported per session. Default 3, capped at 10."
44
        },
45
        "context_chars": {
46
          "type": "integer",
47
          "description": "Characters kept around each hit. Default 240, capped at 1000."
48
        },
49
        "now_ms": {
50
          "type": "integer",
51
          "description": "Current time in milliseconds since the Unix epoch, for the recency window. The sandbox has no clock; when omitted, the newest session's own timestamp stands in."
52
        }
53
      },
54
      "required": ["query"]
55
    },
56
    "output": {
57
      "type": "object",
58
      "properties": {
59
        "query": { "type": "string" },
60
        "sessions_searched": { "type": "integer" },
61
        "sessions_matched": { "type": "integer" },
62
        "matches": {
63
          "type": "array",
64
          "description": "Matching sessions, newest first.",
65
          "items": {
66
            "type": "object",
67
            "properties": {
68
              "source": { "type": "string" },
69
              "session_id": { "type": "string" },
70
              "path": { "type": "string" },
71
              "cwd": { "type": "string" },
72
              "mtime_ms": { "type": "integer" },
73
              "tail_only": {
74
                "type": "boolean",
75
                "description": "True when the file exceeded the whole-read bound, so only its tail was searched."
76
              },
77
              "hits": {
78
                "type": "array",
79
                "items": {
80
                  "type": "object",
81
                  "properties": {
82
                    "role": { "type": "string" },
83
                    "context": { "type": "string" }
84
                  }
85
                }
86
              },
87
              "hits_total": { "type": "integer" }
88
            }
89
          }
90
        },
91
        "truncated": {
92
          "type": "boolean",
93
          "description": "True when the session budget or the scanner's bounds cut the candidate list short."
94
        },
95
        "skipped_unreadable": { "type": "integer" }
96
      }
97
    }
98
  },
99
  "capabilities": {
100
    "mounts": [
101
      { "path": "~/.claude", "readonly": true },
102
      { "path": "~/.codex", "readonly": true }
103
    ],
104
    "hosts": [],
105
    "timeout_ms": 10000,
106
    "memory_max_mib": 128
107
  }
108
}
plugins/session-search/session_search.wasm added

Binary file. Nothing to show as text.

plugins/session-search/src/lib.rs added +412

@@ -0,0 +1,412 @@

1
//! Search every foreign conversation on the machine, as a `packet-v0`
2
//! guest plugin.
3
//!
4
//! The retrieval half the scanner and the reader left open
5
//! (OpenAgentsInc/openagents#46): given a query, find where any Claude
6
//! Code or Codex CLI session on this machine talked about it. The
7
//! `foreign-sessions` scanner enumerates candidate sessions, newest
8
//! first; each one is read back, its conversation text extracted the way
9
//! `read_conversation` extracts it (user and assistant text only —
10
//! thinking and tool payloads are not searched), and the query matched
11
//! case-insensitively against each turn. A hit reports its role and a
12
//! bounded window of surrounding context.
13
//!
14
//! The posture is the scanner's: read-only through the host's confined
15
//! capability imports, bounded everywhere, fail-soft on unreadable or
16
//! malformed files. A session file past the host's whole-file bound is
17
//! searched from its tail through the bounded range import and marked
18
//! `tail_only`; when the session budget cuts the candidate list short,
19
//! the output says `truncated` rather than pretending the search was
20
//! exhaustive.
21
//!
22
//! Searching is the whole capability. Nothing here resumes, continues,
23
//! or writes anything.
24
25
use foreign_sessions::{scan, Host, Input as ScanInput, Session};
26
use openagents_pdk::{
27
    list_mounted_dir, plugin_entry, read_mounted_file, read_mounted_file_range, MountDirListing,
28
    Refusal,
29
};
30
use serde::{Deserialize, Serialize};
31
32
/// Mirror of the host's per-read bound; a whole-file read past it refuses.
33
const WHOLE_READ_BOUND: u64 = 1_048_576;
34
/// How much of an oversized file's tail one read asks for.
35
const TAIL_BYTES: u32 = 1_048_576;
36
const DEFAULT_MAX_AGE_DAYS: f64 = 90.0;
37
const DEFAULT_MAX_SESSIONS: usize = 20;
38
const SESSION_CAP: usize = 40;
39
const DEFAULT_HITS_PER_SESSION: usize = 3;
40
const HITS_CAP: usize = 10;
41
const DEFAULT_CONTEXT_CHARS: usize = 240;
42
const CONTEXT_CAP: usize = 1_000;
43
const CONTEXT_FLOOR: usize = 40;
44
45
#[derive(Deserialize)]
46
pub struct Input {
47
    /// What to search for. Required; must be non-empty after trimming.
48
    #[serde(default)]
49
    pub query: String,
50
    /// Which stores to search; both when absent.
51
    #[serde(default)]
52
    pub sources: Option<Vec<String>>,
53
    /// Substring the session's working directory must contain.
54
    #[serde(default)]
55
    pub cwd_filter: Option<String>,
56
    /// Sessions older than this are not searched. Default 90.
57
    #[serde(default)]
58
    pub max_age_days: Option<f64>,
59
    /// Most sessions to search, newest first. Default 20, capped at 40.
60
    #[serde(default)]
61
    pub max_sessions: Option<usize>,
62
    /// Most hits reported per session. Default 3, capped at 10.
63
    #[serde(default)]
64
    pub max_hits_per_session: Option<usize>,
65
    /// Characters kept around each hit. Default 240, capped at 1000.
66
    #[serde(default)]
67
    pub context_chars: Option<usize>,
68
    /// Milliseconds since the Unix epoch, handed through to the scanner.
69
    #[serde(default)]
70
    pub now_ms: Option<i64>,
71
}
72
73
#[derive(Debug, Serialize, PartialEq, Eq)]
74
pub struct Hit {
75
    /// The role whose turn held the match: `user` or `assistant`.
76
    pub role: String,
77
    /// The match with up to half the context budget on each side, elision
78
    /// markers where the turn continues beyond the window.
79
    pub context: String,
80
}
81
82
#[derive(Debug, Serialize)]
83
pub struct SessionMatch {
84
    pub source: String,
85
    pub session_id: String,
86
    /// Path relative to the source's mount root.
87
    pub path: String,
88
    #[serde(skip_serializing_if = "Option::is_none")]
89
    pub cwd: Option<String>,
90
    pub mtime_ms: i64,
91
    /// True when the file exceeded the whole-read bound, so only its tail
92
    /// was searched and earlier turns went unseen.
93
    #[serde(skip_serializing_if = "std::ops::Not::not")]
94
    pub tail_only: bool,
95
    /// The first hits, in conversation order, up to the per-session cap.
96
    pub hits: Vec<Hit>,
97
    /// Every occurrence found in this session, the reported ones included.
98
    pub hits_total: usize,
99
}
100
101
#[derive(Debug, Serialize)]
102
pub struct Output {
103
    pub query: String,
104
    /// Sessions whose content was actually read and searched.
105
    pub sessions_searched: usize,
106
    pub sessions_matched: usize,
107
    /// Matching sessions, newest first.
108
    pub matches: Vec<SessionMatch>,
109
    /// True when the session budget or the scanner's own bounds cut the
110
    /// candidate list short; more sessions may exist than were searched.
111
    pub truncated: bool,
112
    /// Candidate sessions the host refused to read.
113
    pub skipped_unreadable: usize,
114
}
115
116
struct RealHost;
117
118
impl Host for RealHost {
119
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
120
        list_mounted_dir(mount_index, path)
121
    }
122
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
123
        read_mounted_file(path)
124
    }
125
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal> {
126
        read_mounted_file_range(path, offset, max_bytes)
127
    }
128
}
129
130
/// The whole search, over any [`Host`].
131
pub fn search_sessions(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {
132
    let query = input.query.trim();
133
    if query.is_empty() {
134
        return Err(Refusal::unsupported(
135
            "the query is empty; pass a non-empty word or phrase in `query` to search for",
136
        ));
137
    }
138
    let query_lower = query.to_lowercase();
139
140
    let max_sessions = input.max_sessions.unwrap_or(DEFAULT_MAX_SESSIONS).clamp(1, SESSION_CAP);
141
    let max_hits = input
142
        .max_hits_per_session
143
        .unwrap_or(DEFAULT_HITS_PER_SESSION)
144
        .clamp(1, HITS_CAP);
145
    let context_chars = input
146
        .context_chars
147
        .unwrap_or(DEFAULT_CONTEXT_CHARS)
148
        .clamp(CONTEXT_FLOOR, CONTEXT_CAP);
149
    let max_age_days = input
150
        .max_age_days
151
        .filter(|days| days.is_finite() && *days > 0.0)
152
        .unwrap_or(DEFAULT_MAX_AGE_DAYS);
153
154
    // One candidate past the budget, so a full page distinguishes "that was
155
    // everything" from "the budget cut the list short".
156
    let scan_input = ScanInput {
157
        sources: input.sources.clone(),
158
        cwd_filter: input.cwd_filter.clone(),
159
        max_age_days: Some(max_age_days),
160
        limit: Some(max_sessions + 1),
161
        now_ms: input.now_ms,
162
    };
163
    // `RangeHost: Host`, and dyn upcasting is stable, so the scanner takes
164
    // the same host value.
165
    let scanned = scan(host as &dyn Host, &scan_input)?;
166
167
    let mut truncated = scanned.scan_truncated
168
        || scanned.read_budget_exhausted
169
        || scanned.sessions.len() > max_sessions;
170
    let mut sessions_searched = 0usize;
171
    let mut skipped_unreadable = 0usize;
172
    let mut matches = Vec::new();
173
174
    for session in scanned.sessions.into_iter().take(max_sessions) {
175
        let (bytes, tail_only) = match load(host, &session) {
176
            Ok(loaded) => loaded,
177
            Err(_) => {
178
                skipped_unreadable += 1;
179
                continue;
180
            }
181
        };
182
        sessions_searched += 1;
183
184
        let turns = match session.source {
185
            "claude" => claude_turns(&bytes),
186
            _ => codex_turns(&bytes),
187
        };
188
189
        let mut hits = Vec::new();
190
        let mut hits_total = 0usize;
191
        for (role, text) in &turns {
192
            let found = occurrences(text, &query_lower);
193
            hits_total += found.len();
194
            for (start, len) in found {
195
                if hits.len() < max_hits {
196
                    hits.push(Hit {
197
                        role: role.clone(),
198
                        context: context_window(text, start, len, context_chars),
199
                    });
200
                }
201
            }
202
        }
203
        if hits_total > 0 {
204
            matches.push(SessionMatch {
205
                source: session.source.to_string(),
206
                session_id: session.session_id,
207
                path: session.path,
208
                cwd: session.cwd,
209
                mtime_ms: session.mtime_ms,
210
                tail_only,
211
                hits,
212
                hits_total,
213
            });
214
        }
215
    }
216
217
    // An unreadable candidate is also an incomplete search.
218
    if skipped_unreadable > 0 {
219
        truncated = true;
220
    }
221
222
    Ok(Output {
223
        query: query.to_string(),
224
        sessions_searched,
225
        sessions_matched: matches.len(),
226
        matches,
227
        truncated,
228
        skipped_unreadable,
229
    })
230
}
231
232
/// The session's bytes: whole when the file fits the host's bound, the tail
233
/// (aligned to the first whole line) when it does not.
234
fn load(host: &dyn Host, session: &Session) -> Result<(Vec<u8>, bool), Refusal> {
235
    if session.size_bytes <= WHOLE_READ_BOUND {
236
        return Ok((host.read(&session.path)?, false));
237
    }
238
    let offset = session.size_bytes - u64::from(TAIL_BYTES);
239
    let bytes = host.read_range(&session.path, offset, TAIL_BYTES)?;
240
    let aligned = match bytes.iter().position(|b| *b == b'\n') {
241
        Some(at) => bytes[at + 1..].to_vec(),
242
        None => Vec::new(),
243
    };
244
    Ok((aligned, true))
245
}
246
247
/// Non-overlapping case-insensitive occurrences of the lowercased query,
248
/// as `(char_start, char_len)` positions in the original text. Lowercasing
249
/// can change a character's length, so a byte-offset map carries each
250
/// match back to original character positions, keeping every window cut
251
/// char-boundary safe.
252
fn occurrences(text: &str, query_lower: &str) -> Vec<(usize, usize)> {
253
    let mut lowered = String::with_capacity(text.len());
254
    // (byte offset in `lowered`, character index in `text`), ascending.
255
    let mut origins: Vec<(usize, usize)> = Vec::new();
256
    for (char_index, ch) in text.chars().enumerate() {
257
        origins.push((lowered.len(), char_index));
258
        for lower in ch.to_lowercase() {
259
            lowered.push(lower);
260
        }
261
    }
262
    let total_chars = origins.len();
263
264
    let mut found = Vec::new();
265
    let mut from = 0usize;
266
    while let Some(at) = lowered[from..].find(query_lower) {
267
        let start_byte = from + at;
268
        let end_byte = start_byte + query_lower.len();
269
        let start_char = match origins.binary_search_by_key(&start_byte, |(byte, _)| *byte) {
270
            Ok(index) => origins[index].1,
271
            Err(index) => origins[index.saturating_sub(1)].1,
272
        };
273
        let end_char = match origins.binary_search_by_key(&end_byte, |(byte, _)| *byte) {
274
            Ok(index) => origins[index].1,
275
            Err(index) if index < origins.len() => origins[index].1,
276
            Err(_) => total_chars,
277
        };
278
        found.push((start_char, end_char.saturating_sub(start_char).max(1)));
279
        from = end_byte.max(start_byte + 1);
280
    }
281
    found
282
}
283
284
/// The hit with up to half the context budget of characters on each side,
285
/// elision markers where the turn continues beyond the window.
286
fn context_window(text: &str, start_char: usize, hit_chars: usize, context_chars: usize) -> String {
287
    let total = text.chars().count();
288
    let half = context_chars / 2;
289
    let from = start_char.saturating_sub(half);
290
    let to = (start_char + hit_chars + half).min(total);
291
    let window: String = text.chars().skip(from).take(to - from).collect();
292
    let mut out = String::new();
293
    if from > 0 {
294
        out.push('…');
295
    }
296
    out.push_str(&window);
297
    if to < total {
298
        out.push('…');
299
    }
300
    out
301
}
302
303
/// Claude Code records: `user` / `assistant` with `message.content` as a
304
/// string or a block list. Text blocks are the conversation; thinking and
305
/// tool payloads are not searched.
306
fn claude_turns(bytes: &[u8]) -> Vec<(String, String)> {
307
    let text = String::from_utf8_lossy(bytes);
308
    let mut turns = Vec::new();
309
    for line in text.lines() {
310
        if line.trim().is_empty() {
311
            continue;
312
        }
313
        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
314
            continue;
315
        };
316
        let kind = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
317
        if kind != "user" && kind != "assistant" {
318
            continue;
319
        }
320
        let message = value.get("message").unwrap_or(&serde_json::Value::Null);
321
        let role = message
322
            .get("role")
323
            .and_then(|v| v.as_str())
324
            .unwrap_or(kind)
325
            .to_string();
326
        let mut parts: Vec<String> = Vec::new();
327
        match message.get("content") {
328
            Some(serde_json::Value::String(content)) => parts.push(content.clone()),
329
            Some(serde_json::Value::Array(blocks)) => {
330
                for block in blocks {
331
                    if block.get("type").and_then(|v| v.as_str()) == Some("text") {
332
                        if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
333
                            parts.push(t.to_string());
334
                        }
335
                    }
336
                }
337
            }
338
            _ => {}
339
        }
340
        let joined = parts.join("\n").trim().to_string();
341
        if !joined.is_empty() {
342
            turns.push((role, joined));
343
        }
344
    }
345
    turns
346
}
347
348
/// Codex rollout records: `response_item` messages carry the conversation;
349
/// reasoning and tool items are not searched.
350
fn codex_turns(bytes: &[u8]) -> Vec<(String, String)> {
351
    let text = String::from_utf8_lossy(bytes);
352
    let mut turns = Vec::new();
353
    for line in text.lines() {
354
        if line.trim().is_empty() {
355
            continue;
356
        }
357
        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
358
            continue;
359
        };
360
        if value.get("type").and_then(|v| v.as_str()) != Some("response_item") {
361
            continue;
362
        }
363
        let Some(payload) = value.get("payload") else {
364
            continue;
365
        };
366
        match payload.get("type").and_then(|v| v.as_str()) {
367
            Some("message") => {
368
                let role = payload.get("role").and_then(|v| v.as_str()).unwrap_or("");
369
                if role != "user" && role != "assistant" {
370
                    continue;
371
                }
372
                let joined = block_text(payload.get("content"));
373
                if !joined.is_empty() {
374
                    turns.push((role.to_string(), joined));
375
                }
376
            }
377
            Some("agent_message") => {
378
                let joined = block_text(payload.get("content"));
379
                if !joined.is_empty() {
380
                    turns.push(("assistant".to_string(), joined));
381
                }
382
            }
383
            _ => {}
384
        }
385
    }
386
    turns
387
}
388
389
/// The text of a Codex content value: a plain string, or the joined text of
390
/// its `input_text` / `output_text` blocks.
391
fn block_text(content: Option<&serde_json::Value>) -> String {
392
    match content {
393
        Some(serde_json::Value::String(text)) => text.trim().to_string(),
394
        Some(serde_json::Value::Array(blocks)) => blocks
395
            .iter()
396
            .filter_map(|block| block.get("text").and_then(|v| v.as_str()))
397
            .collect::<Vec<_>>()
398
            .join("\n")
399
            .trim()
400
            .to_string(),
401
        _ => String::new(),
402
    }
403
}
404
405
fn handle(input: Input) -> Result<Output, Refusal> {
406
    search_sessions(&RealHost, &input)
407
}
408
409
plugin_entry!(handle);
410
411
#[cfg(test)]
412
mod tests;
plugins/session-search/src/tests.rs added +265

@@ -0,0 +1,265 @@

1
//! The searcher against a fake host: a seeded Claude tree and Codex tree,
2
//! whole-file and tail reads, hit caps, context windows, and honest
3
//! refusals — all without a WASM runtime, the reader's own testing pattern.
4
5
use super::*;
6
use openagents_pdk::{MountDirEntry, RefusalCode};
7
use std::collections::BTreeMap;
8
9
const CLAUDE_MOUNT: u32 = 0;
10
const CODEX_MOUNT: u32 = 1;
11
const DAY: i64 = 86_400_000;
12
const NOW: i64 = 1_756_000_000_000;
13
14
#[derive(Default)]
15
struct FakeHost {
16
    dirs: BTreeMap<(u32, String), MountDirListing>,
17
    files: BTreeMap<String, Vec<u8>>,
18
}
19
20
impl FakeHost {
21
    fn dir(&mut self, mount: u32, path: &str, entries: Vec<MountDirEntry>) {
22
        self.dirs
23
            .insert((mount, path.to_string()), MountDirListing { entries, truncated: false });
24
    }
25
    fn file(&mut self, path: &str, bytes: &str) {
26
        self.files.insert(path.to_string(), bytes.as_bytes().to_vec());
27
    }
28
}
29
30
impl Host for FakeHost {
31
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
32
        self.dirs.get(&(mount_index, path.to_string())).cloned().ok_or_else(|| {
33
            Refusal::new(RefusalCode::FileUnreadable, "the mount has no such directory")
34
        })
35
    }
36
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
37
        let bytes = self
38
            .files
39
            .get(path)
40
            .ok_or_else(|| Refusal::new(RefusalCode::MountDenied, "no mount holds the path"))?;
41
        if bytes.len() as u64 > WHOLE_READ_BOUND {
42
            return Err(Refusal::new(RefusalCode::FileTooLarge, "over the bound"));
43
        }
44
        Ok(bytes.clone())
45
    }
46
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal> {
47
        let bytes = self
48
            .files
49
            .get(path)
50
            .ok_or_else(|| Refusal::new(RefusalCode::MountDenied, "no mount holds the path"))?;
51
        let start = (offset as usize).min(bytes.len());
52
        let end = (start + max_bytes as usize).min(bytes.len());
53
        Ok(bytes[start..end].to_vec())
54
    }
55
}
56
57
fn entry(name: &str, kind: &str, size: u64, mtime_ms: i64) -> MountDirEntry {
58
    MountDirEntry { name: name.to_string(), kind: kind.to_string(), size, mtime_ms }
59
}
60
61
fn claude_record(role: &str, content: &str) -> String {
62
    format!(
63
        r#"{{"type":"{role}","cwd":"/Users/ada/work/proj","sessionId":"aaa","message":{{"role":"{role}","content":"{content}"}}}}"#
64
    )
65
}
66
67
fn codex_session() -> &'static str {
68
    concat!(
69
        r#"{"type":"session_meta","payload":{"id":"bbb","cwd":"/Users/ada/work/other"}}"#,
70
        "\n",
71
        r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"why does the flux capacitor overheat?"}]}}"#,
72
        "\n",
73
        r#"{"type":"response_item","payload":{"type":"reasoning","summary":[]}}"#,
74
        "\n",
75
        r#"{"type":"response_item","payload":{"type":"function_call","name":"shell"}}"#,
76
        "\n",
77
        r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"the Flux Capacitor needs coolant"}]}}"#,
78
        "\n",
79
    )
80
}
81
82
fn seeded(claude_body: &str) -> FakeHost {
83
    let mut host = FakeHost::default();
84
    host.dir(CLAUDE_MOUNT, "projects", vec![entry("-Users-ada-work-proj", "dir", 0, NOW)]);
85
    host.dir(
86
        CLAUDE_MOUNT,
87
        "projects/-Users-ada-work-proj",
88
        vec![entry("aaa.jsonl", "file", claude_body.len() as u64, NOW - DAY)],
89
    );
90
    host.file("projects/-Users-ada-work-proj/aaa.jsonl", claude_body);
91
    host.dir(CODEX_MOUNT, "sessions", vec![entry("2026", "dir", 0, NOW)]);
92
    host.dir(CODEX_MOUNT, "sessions/2026", vec![entry("08", "dir", 0, NOW)]);
93
    host.dir(CODEX_MOUNT, "sessions/2026/08", vec![entry("20", "dir", 0, NOW)]);
94
    host.dir(
95
        CODEX_MOUNT,
96
        "sessions/2026/08/20",
97
        vec![entry(
98
            "rollout-2026-08-20T10-00-00-bbb.jsonl",
99
            "file",
100
            codex_session().len() as u64,
101
            NOW - 2 * DAY,
102
        )],
103
    );
104
    host.file("sessions/2026/08/20/rollout-2026-08-20T10-00-00-bbb.jsonl", codex_session());
105
    host
106
}
107
108
fn input(query: &str) -> Input {
109
    Input {
110
        query: query.to_string(),
111
        sources: None,
112
        cwd_filter: None,
113
        max_age_days: None,
114
        max_sessions: None,
115
        max_hits_per_session: None,
116
        context_chars: None,
117
        now_ms: Some(NOW),
118
    }
119
}
120
121
fn claude_session() -> String {
122
    format!(
123
        "{}\n{}\n",
124
        claude_record("user", "the flux capacitor is rattling"),
125
        claude_record("assistant", "tighten its bolts")
126
    )
127
}
128
129
#[test]
130
fn finds_the_phrase_in_both_stores() {
131
    let host = seeded(&claude_session());
132
    let out = search_sessions(&host, &input("flux capacitor")).unwrap();
133
    assert_eq!(out.sessions_searched, 2);
134
    assert_eq!(out.sessions_matched, 2);
135
    assert_eq!(
136
        out.matches.iter().map(|m| (m.source.as_str(), m.session_id.as_str())).collect::<Vec<_>>(),
137
        vec![("claude", "aaa"), ("codex", "bbb")],
138
    );
139
    let claude = &out.matches[0];
140
    assert_eq!(claude.hits[0].role, "user");
141
    assert!(claude.hits[0].context.contains("flux capacitor"));
142
    assert_eq!(claude.cwd.as_deref(), Some("/Users/ada/work/proj"));
143
    // The Codex session matches twice: once per role, one differently cased.
144
    let codex = &out.matches[1];
145
    assert_eq!(codex.hits_total, 2);
146
    assert_eq!(codex.hits.iter().map(|h| h.role.as_str()).collect::<Vec<_>>(), vec![
147
        "user",
148
        "assistant"
149
    ]);
150
    assert!(!out.truncated);
151
    assert_eq!(out.skipped_unreadable, 0);
152
}
153
154
#[test]
155
fn the_search_is_case_insensitive_both_ways() {
156
    let host = seeded(&claude_session());
157
    let out = search_sessions(&host, &input("FLUX Capacitor")).unwrap();
158
    assert_eq!(out.sessions_matched, 2);
159
    // The reported context keeps the original casing.
160
    assert!(out.matches[1].hits[1].context.contains("Flux Capacitor"));
161
}
162
163
#[test]
164
fn context_is_bounded_and_elided_on_both_sides() {
165
    let body = format!(
166
        "{}\n",
167
        claude_record("user", &format!("{}needle{}", "a".repeat(600), "b".repeat(600)))
168
    );
169
    let host = seeded(&body);
170
    let out = search_sessions(
171
        &host,
172
        &Input { context_chars: Some(100), ..input("NEEDLE") },
173
    )
174
    .unwrap();
175
    let context = &out.matches[0].hits[0].context;
176
    assert!(context.starts_with('…'));
177
    assert!(context.ends_with('…'));
178
    assert!(context.contains("needle"));
179
    // The hit plus half the budget on each side, plus two markers.
180
    assert!(context.chars().count() <= 100 + "needle".len() + 2);
181
}
182
183
#[test]
184
fn hits_per_session_are_capped_but_counted_in_full() {
185
    let mut body = String::new();
186
    for at in 0..7 {
187
        body.push_str(&claude_record("user", &format!("echo number {at}")));
188
        body.push('\n');
189
    }
190
    let host = seeded(&body);
191
    let out = search_sessions(
192
        &host,
193
        &Input { max_hits_per_session: Some(2), sources: Some(vec!["claude".into()]), ..input("echo") },
194
    )
195
    .unwrap();
196
    let hit = &out.matches[0];
197
    assert_eq!(hit.hits.len(), 2);
198
    assert_eq!(hit.hits_total, 7);
199
    assert_eq!(hit.hits[0].context, "echo number 0");
200
}
201
202
#[test]
203
fn an_oversized_session_is_searched_from_its_tail_and_says_so() {
204
    // One line of padding pushes the file over the whole-read bound; the
205
    // conversation sits at the end, where a tail read finds it.
206
    let padding = format!(
207
        r#"{{"type":"padding","filler":"{}"}}"#,
208
        "p".repeat(WHOLE_READ_BOUND as usize)
209
    );
210
    let body = format!("{padding}\n{}", claude_session());
211
    let host = seeded(&body);
212
    let out = search_sessions(
213
        &host,
214
        &Input { sources: Some(vec!["claude".into()]), ..input("rattling") },
215
    )
216
    .unwrap();
217
    assert_eq!(out.sessions_matched, 1);
218
    assert!(out.matches[0].tail_only);
219
    assert!(out.matches[0].hits[0].context.contains("rattling"));
220
}
221
222
#[test]
223
fn no_match_is_an_empty_result_not_a_refusal() {
224
    let host = seeded(&claude_session());
225
    let out = search_sessions(&host, &input("perpetual motion")).unwrap();
226
    assert_eq!(out.sessions_searched, 2);
227
    assert_eq!(out.sessions_matched, 0);
228
    assert!(out.matches.is_empty());
229
}
230
231
#[test]
232
fn a_blank_query_refuses_and_names_the_fix() {
233
    let host = seeded(&claude_session());
234
    let refusal = search_sessions(&host, &input("   ")).unwrap_err();
235
    assert!(refusal.reason.contains("query"));
236
}
237
238
#[test]
239
fn thinking_and_tool_payloads_are_not_searched() {
240
    let body = format!(
241
        "{}\n",
242
        r#"{"type":"assistant","cwd":"/Users/ada/work/proj","sessionId":"aaa","message":{"role":"assistant","content":[{"type":"thinking","thinking":"secret plan"},{"type":"tool_use","input":{"cmd":"secret cmd"}},{"type":"text","text":"visible answer"}]}}"#
243
    );
244
    let host = seeded(&body);
245
    let secret = search_sessions(
246
        &host,
247
        &Input { sources: Some(vec!["claude".into()]), ..input("secret") },
248
    )
249
    .unwrap();
250
    assert!(secret.matches.is_empty());
251
    let visible = search_sessions(
252
        &host,
253
        &Input { sources: Some(vec!["claude".into()]), ..input("visible") },
254
    )
255
    .unwrap();
256
    assert_eq!(visible.sessions_matched, 1);
257
}
258
259
#[test]
260
fn the_session_budget_reports_truncation() {
261
    let host = seeded(&claude_session());
262
    let out = search_sessions(&host, &Input { max_sessions: Some(1), ..input("flux") }).unwrap();
263
    assert_eq!(out.sessions_searched, 1);
264
    assert!(out.truncated);
265
}

This page updates live while a promote is in flight · changelog