pi-onedev-toolkit 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-09-05
4
+
5
+ ### Changed
6
+
7
+ - `issue`, `pull`, and `build` `list` actions now return a one-line-per-item digest instead of raw JSON (raw list items embed full Markdown descriptions); pass `full: true` for the raw payload. Digests fetch internally up to 512 KB; larger or non-JSON payloads fall back to the normal bounded raw output.
8
+ - Query parameter descriptions now include common OneDev query examples (`open`, `submitted by "user"`, `Assignees is "user"`, `order by`), so most queries no longer need the 8–11 KB grammar dump from `query_description`.
9
+ - `onedev_context` gains `list_projects` to discover accessible projects without activating a domain tool; it works from any repository once TOD is configured.
10
+
11
+ ### Fixed
12
+
13
+ - One-shot watch notifications can no longer be lost when the emission consumer throws.
14
+ - Watch polls record `lastError` (visible in `onedev_watch list`) instead of failing silently, and clear it on recovery.
15
+ - Pull-attention watches read state only from top-level fields, so a nested build status can no longer masquerade as the PR state and cause spurious or missed wakes.
16
+ - Watch snapshot calls are bounded to 10 seconds so one slow TOD call cannot stall other watches.
17
+
18
+ ## 0.2.2 — 2026-09-04
19
+
20
+ ### Changed
21
+
22
+ - Compressed model-visible tool schemas and bundled workflow prompts without changing tool names, actions, validations, or approval gates; tests now enforce schema and skill byte budgets.
23
+ - Model-visible output now defaults to 16 KB for normal results and 32 KB for PR patches, build logs, and build-spec schemas. `max_bytes` can request up to 128 KB; internal watch polling remains independent.
24
+
3
25
  ## 0.2.1 — 2026-09-03
4
26
 
5
27
  ### Fixed
package/README.md CHANGED
@@ -28,7 +28,7 @@ When the current directory is a OneDev repository, the status line shows the act
28
28
 
29
29
  | Tool | Domains | Covers |
30
30
  | --- | --- | --- |
31
- | `onedev_context` (always active) | — | Readiness, authenticated health, login, and setup guidance |
31
+ | `onedev_context` (always active) | — | Readiness, authenticated health, login, setup guidance, and accessible project discovery |
32
32
  | `onedev_tools` (always active) | — | Activate only the issue, pull, build, and watch domains needed now |
33
33
  | `onedev_issue` | issue | Query/get/comment issues, change fields/labels/state, log work, issue branches |
34
34
  | `onedev_pull` | pull | PR metadata/comments/code comments/labels/builds/patch, create/edit, reply/resolve review comments, approve, request changes, merge/discard |
@@ -37,6 +37,8 @@ When the current directory is a OneDev repository, the status line shows the act
37
37
 
38
38
  Lazy by design: domain tools activate only through `onedev_tools`, keeping the default tool list and system prompt light. Activation performs the first authenticated check and returns the server, project, and login context in the same response.
39
39
 
40
+ Model-visible issue, PR, and build output defaults to 16 KB. PR patches, build logs, and build-spec schemas default to 32 KB. Use `max_bytes` to raise or lower a call's budget up to 128 KB; truncation keeps the payload head/tail and is reported explicitly. List actions instead return one-line-per-item digests by default (pass `full` for the raw payload), so bulk queries stay small without losing items; payloads beyond the 512 KB internal fetch fall back to the normal bounded raw output. Internal watch polling remains separately bounded and is never copied into the model context.
41
+
40
42
  Remote and local mutations use Pi's real interactive approval UI. An approval can apply once, for the session, or persist for the exact server/project/operation in `~/.pi/agent/onedev-toolkit.json`; remove that file to revoke all saved approvals. Non-interactive sessions reject unapproved mutations. `build run` in local mode uses an isolated temporary Git index so TOD cannot leave the user's index staged; `check_spec` is treated as a mutation because TOD may upgrade `.onedev-buildspec.yml`.
41
43
 
42
44
  Watches are session-scoped and one-shot. They use one timer, bounded polling, expiration and backoff, and wake the agent with metadata only. The agent retrieves logs, comments, or build details with the corresponding read tool. At most ten watches may be active; session changes and shutdown clear them.
@@ -60,6 +62,12 @@ Attachment download is intentionally not exposed: current TOD releases may send
60
62
  - `onedev-issue-to-pr` — gather issue context, branch, implement, present for review, then push and open the PR
61
63
  - `onedev-pr-review` — full-context PR review with line-anchored findings; submits only on explicit request
62
64
 
65
+ If you use only tools and slash commands, omit both workflow entries from every prompt while keeping the extension and tools:
66
+
67
+ ```json
68
+ { "source": "npm:pi-onedev-toolkit", "skills": [] }
69
+ ```
70
+
63
71
  ## Development
64
72
 
65
73
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onedev-toolkit",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Lightweight OneDev integration for Pi via TOD: repo-aware issue, pull request, build, and watch tools",
5
5
  "type": "module",
6
6
  "author": {
@@ -53,11 +53,12 @@
53
53
  "typebox": "*"
54
54
  },
55
55
  "devDependencies": {
56
- "@earendil-works/pi-ai": "0.84.1",
57
- "@earendil-works/pi-coding-agent": "0.84.1",
56
+ "@earendil-works/pi-ai": "0.85.0",
57
+ "@earendil-works/pi-server": "0.85.0",
58
+ "@earendil-works/pi-coding-agent": "0.85.0",
58
59
  "@types/node": "^22.19.0",
59
60
  "@types/proper-lockfile": "4.1.4",
60
- "typebox": "1.3.12",
61
+ "typebox": "1.3.25",
61
62
  "typescript": "^7.0.2",
62
63
  "vitest": "^4.1.10"
63
64
  },
@@ -1,60 +1,26 @@
1
1
  ---
2
2
  name: onedev-issue-to-pr
3
- description: Implement a OneDev issue end to end - gather issue context, branch, implement, then (after user approval) push and open a linked pull request. Use when the user asks to work on, implement, or fix a OneDev issue.
3
+ description: "Implement a OneDev issue and open a linked PR only after user approval."
4
4
  ---
5
5
 
6
- # OneDev issue to pull request
6
+ # OneDev Issue to Pull Request
7
7
 
8
- ## Prerequisites
8
+ ## Rules
9
9
 
10
- - Call `onedev_tools` with domains `["issue", "pull"]` first; note your login name from the returned context. If activation reports a readiness problem, use `onedev_context` `setup` and stop until it is resolved.
11
- - The current repository belongs to the issue's project (plain refs like `123` resolve against it).
10
+ - Activate `issue` and `pull` together with `onedev_tools`; note the returned login. On readiness failure, use `onedev_context setup` and stop.
11
+ - Plain refs use the current repository's OneDev project. Never guess a missing ref or project.
12
+ - Preserve unrelated local changes. If checkout is dirty, report and stop; never stash automatically.
13
+ - Do not push, comment, or open a PR before the workflow checkpoint and explicit user approval. Toolkit mutations still require Pi UI approval.
12
14
 
13
15
  ## Workflow
14
16
 
15
- 1. **Resolve the issue reference.** Use the reference from the prompt, or derive it
16
- with `onedev_issue` action `current_reference`. If neither works, stop and report.
17
+ 1. Resolve the prompt ref or use `onedev_issue current_reference`; stop if neither works.
18
+ 2. Prepare the branch with `onedev_issue checkout` using `for_write: true`.
19
+ 3. Read `onedev_issue get` and `get_comments`; use author/login context and derive acceptance criteria from the full discussion.
20
+ 4. When failure evidence matters, activate `build`; inspect build metadata and start `get_log` with `max_bytes=32000`, raising it only if truncation hides required evidence. Follow same-commit dependency failures as the likely root cause; different-commit failures are inherited.
21
+ 5. Implement the smallest complete change and run the narrowest relevant test. Optionally run local OneDev CI with `onedev_build run mode=local`.
22
+ 6. **Stop for review.** Show the change, checks, and any issue comment you intend to post. Do not submit anything.
23
+ 7. After explicit approval, read `onedev_pull commit_message_requirement` and `title_description_requirement`; empty output means no extra requirement. Commit and push with local Git, then create the PR against the intended target branch. Link the issue in the description when the issue branch does not imply it. Post only the approved issue comment.
24
+ 8. For long CI, activate `watch` and start `onedev_watch watch_build`; re-read the build after the metadata-only notification.
17
25
 
18
- 2. **Prepare the checkout** (unless the user says otherwise):
19
- `onedev_issue` action `checkout` with `ref` and `for_write: true`.
20
- It creates the server-side issue branch and sets up tracking.
21
- If the working copy is dirty, report it and stop — do not stash on your own.
22
-
23
- 3. **Read the full issue context** — never work from the prompt alone:
24
- - `onedev_issue` `get` (title, description, fields, submitter)
25
- - `onedev_issue` `get_comments`
26
- - Match your login name against submitter/comment authors to understand your role.
27
- - If a failed build is part of the issue, gather evidence with `onedev_build`
28
- after activating the `build` domain (`get`, `get_log`, and a named
29
- `get_unit_test_report` when useful) before planning changes. Follow `Dependency build … failed`
30
- chains: same-commit dependency failures are the real target; different-commit
31
- ones mean the failure is inherited.
32
-
33
- 4. **Implement the work.** Code changes, or an explanatory issue comment if no code
34
- is needed. Run the smallest relevant test. Optionally validate CI locally with
35
- `onedev_build` `run` (`mode: "local"`) before committing.
36
-
37
- 5. **STOP — present for review.** Summarize the change and the comment you intend
38
- to post. Do not push or post anything yet. This is the mandatory checkpoint.
39
-
40
- 6. **After explicit user approval, submit:**
41
- - Compose the commit message against the server requirements:
42
- `onedev_pull` `commit_message_requirement` (and `title_description_requirement`
43
- before creating the PR). Empty output means no requirement.
44
- - `git add -A && git commit && git push` on the issue branch.
45
- - Create the PR: `onedev_pull` `create` with `title`, `description`, and
46
- `target_branch` (source branch defaults to the current branch). The issue link
47
- is inferred from the branch; state it in the description if not.
48
- - Post the drafted comment with `onedev_issue` `add_comment` if one was prepared.
49
-
50
- 7. **For long-running CI**, activate `watch` and use `onedev_watch` `watch_build`
51
- after obtaining a build reference. The notification is one-shot and metadata-only;
52
- inspect the build with `onedev_build` when it fires.
53
-
54
- ## Notes
55
-
56
- - Write comment text in Markdown.
57
- - Reference entities as `#123` (current project), `project#123`, or `PROJ-123`.
58
- - If blocked mid-workflow, report the command and error, and stop.
59
- - Toolkit mutations still open Pi's interactive approval UI. Do not try to bypass
60
- it with a confirmation parameter.
26
+ Use Markdown for comments and `#123`, `project#123`, or `PROJ-123` for entities. On failure, report the command/error and stop; never invent a confirmation parameter.
@@ -1,71 +1,28 @@
1
1
  ---
2
2
  name: onedev-pr-review
3
- description: Review a OneDev pull request - gather PR context, builds, and fixed issues, read the review-scoped patch, draft findings with line-anchored code comments, and submit the review or merge only on explicit request. Use when the user asks to review, address feedback on, or merge a OneDev pull request.
3
+ description: "Review a OneDev PR; publish findings or merge only when asked."
4
4
  ---
5
5
 
6
- # OneDev pull request review
6
+ # OneDev Pull Request Review
7
7
 
8
- ## Prerequisites
8
+ ## Rules
9
9
 
10
- - Call `onedev_tools` with domains `["pull", "issue", "build"]`; note your login name. If activation reports a readiness problem, use `onedev_context` `setup` and stop until it is resolved.
11
- - The current repository belongs to the PR's source or target project.
10
+ - Activate `pull`, `issue`, and `build` together with `onedev_tools`; note the returned login. On readiness failure, use `onedev_context setup` and stop.
11
+ - Resolve the exact PR in its source/target repository. Never review from a patch alone.
12
+ - Analysis is read-only. Draft first; publish findings, change review state, or merge only after explicit user request and Pi UI approval.
13
+ - Never alter another user's code comments.
12
14
 
13
- ## Gather context (never review from the patch alone)
15
+ ## Workflow
14
16
 
15
- 1. **Resolve the PR reference** from the prompt or `onedev_pull` `current_reference`.
17
+ 1. Resolve the prompt ref or use `onedev_pull current_reference`.
18
+ 2. Checkout for inspection; use `for_write: true` only when changing code. Dirty checkout means report and stop.
19
+ 3. Read PR metadata, comments, code comments, and builds. Query fixed issues with `fixed in pull request "<ref>"`, then read their bodies/comments as the primary specification. Match authors against the returned login.
20
+ 4. Investigate every failed build. Start `onedev_build get_log` with `max_bytes=32000`; raise it only if truncation hides required evidence. Follow same-commit dependency failures before blaming the current job.
21
+ 5. Read `onedev_pull get_patch` with `for_code_review: true` and `max_bytes=32000`; inspect checkout files and callers when context is missing.
22
+ 6. Keep only concrete correctness, regression, security, data-loss, error-handling, or coverage findings. Triage prior discussion: reply where needed, resolve only addressed comments you own, and leave unrelated comments alone.
23
+ 7. Draft line findings with file/right-side lines, outside-patch findings as one overall comment, and `approve` or `request_changes` only when acting as a pending reviewer.
24
+ 8. **Stop and present** findings, replies, outcome, and merge plan. Submit nothing.
25
+ 9. On explicit request, publish the approved draft. For merge, satisfy `commit_message_requirement`; squash messages should reflect PR and fixed issues. Ask before rewriting commits for rebase-based conflict resolution.
26
+ 10. When waiting, activate `watch` and use `watch_pull_attention`; re-read PR state after its metadata-only notification.
16
27
 
17
- 2. **Checkout** for inspection: `onedev_pull` `checkout` (`for_write: true` when you
18
- will change code). Dirty working copy → report and stop.
19
-
20
- 3. **Read everything:**
21
- - `onedev_pull` `get` — title, description, source/target, merge strategy, status,
22
- submitter, reviewers, review state.
23
- - `onedev_pull` `get_comments` and `get_code_comments` — note code-comment `id`s,
24
- file, line range, resolution state, replies.
25
- - Fixed issues: `onedev_issue` `list` with query `fixed in pull request "<ref>"`,
26
- then `get` / `get_comments` on each. Treat fixed issues as the primary spec
27
- for what the PR should accomplish.
28
- - `onedev_pull` `get_builds` — investigate every `FAILED` build with
29
- `onedev_build` `get` / `get_log` before reviewing code.
30
- - Match your login name against authors to understand your role
31
- (pending reviewer, prior commenter, outsider).
32
-
33
- ## Review
34
-
35
- 1. Read the review-scoped patch: `onedev_pull` `get_patch` with
36
- `for_code_review: true`. Read files in the checkout when the patch lacks context.
37
- Check correctness, edge cases, security, style, and test coverage.
38
-
39
- 2. **Triage prior discussion:** resolve addressed code comments, reply where new
40
- information is needed, leave unrelated unresolved ones alone. Never modify
41
- another user's code comments. Use `reply_code_comment`, `resolve_code_comment`,
42
- or `unresolve_code_comment` with the comment `id`.
43
-
44
- 3. **Draft findings:**
45
- - Line-anchored findings → `onedev_pull` `add_code_comment` (`file`, `from_line`,
46
- optional `to_line` — must be an added or context line visible in the patch).
47
- - Outside-patch findings → one overall PR comment with file paths and lines.
48
- - Review outcome if you are a pending reviewer: `approve` or `request_changes`
49
- with a `summary`.
50
-
51
- 4. **STOP — present the draft** (findings, replies, outcomes, merge plan).
52
- Submit nothing yet.
53
-
54
- 5. **On explicit request only:**
55
- - Post findings/comments/review outcome.
56
- - Merge: the toolkit asks for real interactive approval. For squash merges,
57
- compose the commit message from PR title/description + fixed issues,
58
- satisfying `commit_message_requirement` for the target project/branch.
59
- - Resolving merge conflicts: fetch the target branch, merge with the PR's
60
- strategy without committing, resolve, verify clean `git status`, commit.
61
- For rebase-based strategies, ask for direction before rewriting commits.
62
-
63
- 6. **When waiting on updates**, activate `watch` and use `onedev_watch`
64
- `watch_pull_attention`. It wakes once for a head/state/review/comment/build
65
- signal change and includes no comment bodies; re-read the PR after it fires.
66
-
67
- ## Notes
68
-
69
- - Comment text is Markdown; entity refs are `#123`, `project#123`, or `PROJ-123`.
70
- - Toolkit mutations still open Pi's interactive approval UI. Do not add or invent
71
- a confirmation parameter.
28
+ Use Markdown and `#123`, `project#123`, or `PROJ-123` refs. Never invent a confirmation parameter.
@@ -19,6 +19,7 @@ export interface OneDevWatchInfo {
19
19
  intervalSeconds: number;
20
20
  expiresAt: string;
21
21
  failures: number;
22
+ lastError?: string;
22
23
  }
23
24
 
24
25
  export interface AddOneDevWatchOptions {
@@ -62,6 +63,7 @@ interface WatchRecord {
62
63
  expiresAt: number;
63
64
  nextPollAt: number;
64
65
  failures: number;
66
+ lastError?: string;
65
67
  baseline: WatchSnapshot;
66
68
  }
67
69
 
@@ -77,6 +79,8 @@ const MAX_INTERVAL_SECONDS = 300;
77
79
  const MAX_TTL_MINUTES = 24 * 60;
78
80
  const MAX_WATCHES = 10;
79
81
  const MAX_BACKOFF_MS = 5 * 60_000;
82
+
83
+ const SNAPSHOT_TIMEOUT_MS = 10_000;
80
84
  const TERMINAL_BUILD_STATUSES = new Set([
81
85
  "SUCCESSFUL",
82
86
  "FAILED",
@@ -163,6 +167,33 @@ function firstBoolean(value: JsonValue, keys: readonly string[]): boolean | unde
163
167
  return found;
164
168
  }
165
169
 
170
+ function topLevelString(
171
+ value: JsonValue,
172
+ keys: readonly string[],
173
+ ): string | undefined {
174
+ if (typeof value !== "object" || value === null || Array.isArray(value))
175
+ return undefined;
176
+ for (const key of keys) {
177
+ const child = value[key];
178
+ if (typeof child === "string" && child.trim() !== "") return child.trim();
179
+ if (typeof child === "number") return String(child);
180
+ }
181
+ return undefined;
182
+ }
183
+
184
+ function topLevelBoolean(
185
+ value: JsonValue,
186
+ keys: readonly string[],
187
+ ): boolean | undefined {
188
+ if (typeof value !== "object" || value === null || Array.isArray(value))
189
+ return undefined;
190
+ for (const key of keys) {
191
+ const child = value[key];
192
+ if (typeof child === "boolean") return child;
193
+ }
194
+ return undefined;
195
+ }
196
+
166
197
  function unresolvedCodeComments(
167
198
  value: JsonValue,
168
199
  ): Array<{ id: string | number; replies: number }> {
@@ -335,6 +366,7 @@ export class OneDevSourceWatchManager {
335
366
  intervalSeconds: record.intervalMs / 1_000,
336
367
  expiresAt: new Date(record.expiresAt).toISOString(),
337
368
  failures: record.failures,
369
+ lastError: record.lastError,
338
370
  };
339
371
  }
340
372
 
@@ -353,6 +385,7 @@ export class OneDevSourceWatchManager {
353
385
  const output = await runTod(this.deps.exec, ["build", "get", ref], {
354
386
  cwd: context.cwd,
355
387
  signal,
388
+ timeoutMs: SNAPSHOT_TIMEOUT_MS,
356
389
  maxOutputBytes: 512_000,
357
390
  });
358
391
  const value = json(output.text, `build ${ref}`);
@@ -368,16 +401,19 @@ export class OneDevSourceWatchManager {
368
401
  runTod(this.deps.exec, ["pr", "get", ref], {
369
402
  cwd: context.cwd,
370
403
  signal,
404
+ timeoutMs: SNAPSHOT_TIMEOUT_MS,
371
405
  maxOutputBytes: 512_000,
372
406
  }),
373
407
  runTod(this.deps.exec, ["pr", "get-code-comments", ref], {
374
408
  cwd: context.cwd,
375
409
  signal,
410
+ timeoutMs: SNAPSHOT_TIMEOUT_MS,
376
411
  maxOutputBytes: 512_000,
377
412
  }),
378
413
  runTod(this.deps.exec, ["pr", "get-builds", ref], {
379
414
  cwd: context.cwd,
380
415
  signal,
416
+ timeoutMs: SNAPSHOT_TIMEOUT_MS,
381
417
  maxOutputBytes: 512_000,
382
418
  }),
383
419
  ]);
@@ -397,17 +433,16 @@ export class OneDevSourceWatchManager {
397
433
  "requestedChanges",
398
434
  ]);
399
435
  const selected = {
400
- state: firstString(pullValue, ["status", "state"]) ?? null,
436
+ state: topLevelString(pullValue, ["status", "state"]) ?? null,
401
437
  head:
402
- firstString(pullValue, [
403
- "headCommitHash",
404
- "sourceCommitHash",
405
- "headCommit",
406
- "sourceCommit",
438
+ topLevelString(pullValue, [
439
+ "headCommitHash",
440
+ "sourceCommitHash",
441
+ "headCommit",
442
+ "sourceCommit",
407
443
  ]) ?? null,
408
- mergeable: firstBoolean(pullValue, ["mergeable"]) ?? null,
409
- conflicted:
410
- firstBoolean(pullValue, ["hasConflicts", "conflicted"]) ?? null,
444
+ mergeable: topLevelBoolean(pullValue, ["mergeable"]) ?? null,
445
+ conflicted: topLevelBoolean(pullValue, ["hasConflicts", "conflicted"]) ?? null,
411
446
  unresolved,
412
447
  reviews,
413
448
  buildSignal,
@@ -496,21 +531,28 @@ export class OneDevSourceWatchManager {
496
531
  if (generation !== this.#generation || !this.#records.has(record.id)) break;
497
532
  if (this.#changed(record, snapshot)) {
498
533
  this.#records.delete(record.id);
499
- this.emit(this.#emission(record, snapshot));
534
+ try {
535
+ this.emit(this.#emission(record, snapshot));
536
+ } catch {
537
+ // a throwing consumer must not re-enter the poll-failure path
538
+ }
500
539
  continue;
501
540
  }
502
541
  record.failures = 0;
542
+ record.lastError = undefined;
503
543
  record.baseline = snapshot;
504
544
  record.nextPollAt = this.now() + record.intervalMs;
505
- } catch {
506
- record.failures += 1;
507
- record.nextPollAt =
508
- this.now() +
509
- Math.min(
510
- record.intervalMs * 2 ** Math.min(record.failures, 6),
511
- MAX_BACKOFF_MS,
512
- );
513
- }
545
+ } catch (error) {
546
+ record.failures += 1;
547
+ record.lastError =
548
+ error instanceof Error ? error.message : String(error);
549
+ record.nextPollAt =
550
+ this.now() +
551
+ Math.min(
552
+ record.intervalMs * 2 ** Math.min(record.failures, 6),
553
+ MAX_BACKOFF_MS,
554
+ );
555
+ }
514
556
  if (record.expiresAt <= now) this.#records.delete(record.id);
515
557
  }
516
558
  } finally {
@@ -6,11 +6,16 @@ import { StringEnum } from "@earendil-works/pi-ai";
6
6
  import { Type, type Static } from "typebox";
7
7
  import {
8
8
  confirmMutation,
9
+ DEFAULT_LARGE_MODEL_OUTPUT_BYTES,
10
+ digestBuilds,
11
+ modelOutputBytes,
12
+ modelOutputLimit,
9
13
  pushRepeated,
10
14
  QUERY_DESCRIPTION,
11
15
  REF_DESCRIPTION,
12
16
  requireParam,
13
17
  runInContext,
18
+ runListInContext,
14
19
  toolResult,
15
20
  withFooter,
16
21
  type ToolDeps,
@@ -59,6 +64,22 @@ export interface BuildParams {
59
64
  query?: string;
60
65
  count?: number;
61
66
  offset?: number;
67
+ full?: boolean;
68
+ max_bytes?: number;
69
+ }
70
+
71
+ /**
72
+ * Local unless another mode or an explicit branch/tag is given. Names the
73
+ * non-obvious defaulting rule shared by the arg builder and the isolated
74
+ * index path; both call sites must resolve it identically.
75
+ */
76
+ function isLocalRunMode(
77
+ params: Pick<BuildParams, "mode" | "branch" | "tag">,
78
+ ): boolean {
79
+ return (
80
+ params.mode === "local" ||
81
+ (params.mode === undefined && !params.branch && !params.tag)
82
+ );
62
83
  }
63
84
 
64
85
  export function buildBuildArgs(params: BuildParams): string[] {
@@ -112,10 +133,7 @@ export function buildBuildArgs(params: BuildParams): string[] {
112
133
  if (params.mode === "tag" && params.branch !== undefined) {
113
134
  throw new Error("tag run mode does not accept branch");
114
135
  }
115
- if (
116
- params.mode === "local" ||
117
- (params.mode === undefined && !params.branch && !params.tag)
118
- ) {
136
+ if (isLocalRunMode(params)) {
119
137
  args.push("--local");
120
138
  } else if (params.mode === "branch" || params.branch !== undefined) {
121
139
  args.push("--branch", requireParam(params.branch, action, "branch"));
@@ -154,6 +172,7 @@ async function runLocalBuildWithIsolatedIndex(
154
172
  deps: ToolDeps,
155
173
  args: string[],
156
174
  signal?: AbortSignal,
175
+ maxOutputBytes?: number,
157
176
  ): Promise<TodOutput> {
158
177
  if (process.platform === "win32") {
159
178
  throw new TodError(
@@ -183,7 +202,7 @@ async function runLocalBuildWithIsolatedIndex(
183
202
  cwd: context.cwd,
184
203
  signal,
185
204
  timeoutMs: BUILD_RUN_TIMEOUT_MS,
186
- maxOutputBytes: 24_000,
205
+ maxOutputBytes: modelOutputLimit(maxOutputBytes, 24_000),
187
206
  environment: { GIT_INDEX_FILE: isolatedIndex },
188
207
  });
189
208
  } catch (error) {
@@ -201,51 +220,36 @@ async function runLocalBuildWithIsolatedIndex(
201
220
 
202
221
  export function registerBuildTool(pi: ExtensionAPI, deps: ToolDeps): void {
203
222
  const parameters = Type.Object({
204
- action: StringEnum(BUILD_ACTIONS, {
205
- description:
206
- "list, get, get_log, get_code_problems, get_unit_test_report, get_changes_since_success, run, check_spec (may upgrade .onedev-buildspec.yml), get_spec_schema, query_description",
207
- }),
223
+ action: StringEnum(BUILD_ACTIONS),
208
224
  ref: Type.Optional(Type.String({ description: REF_DESCRIPTION })),
209
- report_name: Type.Optional(
210
- Type.String({ description: "Report name quoted in the build log" }),
211
- ),
225
+ report_name: Type.Optional(Type.String()),
212
226
  severity: Type.Optional(StringEnum(SEVERITY_LEVELS)),
213
- job: Type.Optional(
214
- Type.String({ description: "Job name defined in .onedev-buildspec.yml" }),
215
- ),
227
+ job: Type.Optional(Type.String()),
216
228
  mode: Type.Optional(
217
- StringEnum(RUN_MODES, {
218
- description:
219
- "run: local (uncommitted changes), branch, or tag; default local",
220
- }),
221
- ),
222
- branch: Type.Optional(
223
- Type.String({ description: "run with mode=branch: branch to run against" }),
224
- ),
225
- tag: Type.Optional(
226
- Type.String({ description: "run with mode=tag: tag to run against" }),
229
+ StringEnum(RUN_MODES, { description: "Default: local" }),
227
230
  ),
231
+ branch: Type.Optional(Type.String()),
232
+ tag: Type.Optional(Type.String()),
228
233
  params: Type.Optional(
229
- Type.Array(Type.String(), {
230
- description: 'Job parameters as "key=value", repeatable (-p)',
231
- }),
234
+ Type.Array(Type.String(), { description: '"key=value" parameters' }),
232
235
  ),
233
236
  project: Type.Optional(
234
- Type.String({
235
- description: "Project path for list; defaults to the current project",
236
- }),
237
+ Type.String({ description: "Default: current project" }),
237
238
  ),
238
239
  query: Type.Optional(Type.String({ description: QUERY_DESCRIPTION })),
239
240
  count: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
240
241
  offset: Type.Optional(Type.Integer({ minimum: 0 })),
242
+ full: Type.Optional(
243
+ Type.Boolean({ description: "Raw JSON instead of digest" }),
244
+ ),
245
+ max_bytes: modelOutputBytes(),
241
246
  }, { additionalProperties: false });
242
247
  type Params = Static<typeof parameters>;
243
248
 
244
249
  pi.registerTool({
245
250
  name: "onedev_build",
246
251
  label: "OneDev Builds",
247
- description:
248
- "Inspect OneDev CI/CD: query builds, read logs and code-problem reports, diff changes since the last success, validate the build spec, and run jobs (including against uncommitted local changes).",
252
+ description: "Read OneDev builds, logs, reports, and changes; validate or run jobs.",
249
253
  parameters,
250
254
  async execute(_toolCallId, params: Params, signal, _onUpdate, ctx) {
251
255
  const isRun = params.action === "run";
@@ -266,18 +270,34 @@ export function registerBuildTool(pi: ExtensionAPI, deps: ToolDeps): void {
266
270
  });
267
271
  }
268
272
  // ponytail: build run streams the full log; preserve bounded metadata and the failure tail
269
- const isLocalRun =
270
- isRun &&
271
- (params.mode === "local" ||
272
- (params.mode === undefined && !params.branch && !params.tag));
273
+ const isLocalRun = isRun && isLocalRunMode(params);
273
274
  let output: TodOutput;
274
- if (isLocalRun) {
275
- output = await runLocalBuildWithIsolatedIndex(deps, args, signal);
275
+ if (params.action === "list") {
276
+ output = await runListInContext(deps, args, {
277
+ signal,
278
+ full: params.full,
279
+ maxOutputBytes: params.max_bytes,
280
+ digest: digestBuilds,
281
+ });
282
+ } else if (isLocalRun) {
283
+ output = await runLocalBuildWithIsolatedIndex(
284
+ deps,
285
+ args,
286
+ signal,
287
+ params.max_bytes,
288
+ );
276
289
  } else {
277
290
  output = await runInContext(deps, args, {
278
291
  signal,
279
292
  timeoutMs: isRun ? BUILD_RUN_TIMEOUT_MS : undefined,
280
- maxOutputBytes: isRun ? 24_000 : undefined,
293
+ maxOutputBytes:
294
+ params.max_bytes ??
295
+ (isRun
296
+ ? 24_000
297
+ : params.action === "get_log" ||
298
+ params.action === "get_spec_schema"
299
+ ? DEFAULT_LARGE_MODEL_OUTPUT_BYTES
300
+ : undefined),
281
301
  });
282
302
  }
283
303
  return toolResult(
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
2
3
  import type {
3
4
  ContextRefreshOptions,
4
5
  OneDevSessionContext,
@@ -10,6 +11,7 @@ import type {
10
11
  import {
11
12
  TodError,
12
13
  runTod,
14
+ truncateMiddle,
13
15
  type CommandExecutor,
14
16
  type TodOutput,
15
17
  } from "../tod.js";
@@ -24,12 +26,32 @@ export interface ToolDeps {
24
26
  approvals: MutationApprovalManager;
25
27
  }
26
28
 
29
+ export const DEFAULT_MODEL_OUTPUT_BYTES = 16_000;
30
+ export const DEFAULT_LARGE_MODEL_OUTPUT_BYTES = 32_000;
31
+ export const MAX_MODEL_OUTPUT_BYTES = 128_000;
32
+
27
33
  export const REF_DESCRIPTION =
28
- "OneDev reference: plain number (123), #number, project#number (myproject#123), or issue key (PROJ-123). Plain numbers resolve against the current repository's project.";
34
+ "Ref: 123, #123, project#123, or PROJ-123; plain numbers use the current project.";
29
35
  export const FIELDS_DESCRIPTION =
30
- 'Issue field assignments as "key=value" strings, repeatable. Discover valid fields and values with the valid_fields action.';
36
+ '"key=value" assignments; use valid_fields to discover values.';
31
37
  export const QUERY_DESCRIPTION =
32
- "OneDev query, e.g. '\"State\" is \"Open\"' or 'assignee is me'. Syntax reference: query_description action.";
38
+ 'OneDev query; e.g. \'open\', \'submitted by "user"\', \'Assignees is "user"\', \'order by "Submit Date" desc\'; full syntax: query_description.';
39
+
40
+ export function modelOutputBytes() {
41
+ return Type.Optional(
42
+ Type.Integer({ minimum: 1_000, maximum: MAX_MODEL_OUTPUT_BYTES }),
43
+ );
44
+ }
45
+
46
+ export function modelOutputLimit(
47
+ requested: number | undefined,
48
+ fallback = DEFAULT_MODEL_OUTPUT_BYTES,
49
+ ): number {
50
+ return Math.min(
51
+ MAX_MODEL_OUTPUT_BYTES,
52
+ Math.max(1_000, requested ?? fallback),
53
+ );
54
+ }
33
55
 
34
56
  export function toolResult(
35
57
  text: string,
@@ -65,7 +87,7 @@ export async function runInContext(
65
87
  cwd: context.cwd,
66
88
  signal: options.signal,
67
89
  timeoutMs: options.timeoutMs,
68
- maxOutputBytes: options.maxOutputBytes,
90
+ maxOutputBytes: modelOutputLimit(options.maxOutputBytes),
69
91
  });
70
92
  }
71
93
 
@@ -89,6 +111,236 @@ export function pushRepeated(
89
111
  for (const value of values ?? []) args.push(flag, value);
90
112
  }
91
113
 
114
+ /**
115
+ * One-line-per-item digests for list results. Raw OneDev list payloads embed
116
+ * full Markdown descriptions (~2 KB per item); digests keep bulk queries in a
117
+ * few KB so pagination stays lossless. Digests return undefined when the
118
+ * payload is not a JSON object array so callers fall back to the raw text.
119
+ */
120
+ export const DIGEST_FETCH_BYTES = 512_000;
121
+
122
+ type DigestItem = Record<string, unknown>;
123
+
124
+ function parseDigestItems(text: string): DigestItem[] | undefined {
125
+ let parsed: unknown;
126
+ try {
127
+ parsed = JSON.parse(text);
128
+ } catch {
129
+ return undefined;
130
+ }
131
+ if (!Array.isArray(parsed)) return undefined;
132
+ return parsed.every(
133
+ (item) => typeof item === "object" && item !== null && !Array.isArray(item),
134
+ )
135
+ ? (parsed as DigestItem[])
136
+ : undefined;
137
+ }
138
+
139
+ function stringAt(
140
+ item: DigestItem,
141
+ keys: readonly string[],
142
+ ): string | undefined {
143
+ for (const key of keys) {
144
+ const value = item[key];
145
+ if (typeof value === "number") return String(value);
146
+ if (typeof value === "string" && value.trim() !== "") return value.trim();
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ function listAt(item: DigestItem, keys: readonly string[]): string[] {
152
+ for (const key of keys) {
153
+ const value = item[key];
154
+ if (Array.isArray(value) && value.length > 0) return value.map(String);
155
+ }
156
+ return [];
157
+ }
158
+
159
+ function clip(value: string, maxChars: number): string {
160
+ if (value.length <= maxChars) return value;
161
+ let end = maxChars - 1;
162
+ const previous = value.charCodeAt(end - 1);
163
+ if (previous >= 0xd800 && previous <= 0xdbff) end -= 1;
164
+ return `${value.slice(0, Math.max(end, 0))}…`;
165
+ }
166
+
167
+ function day(value: string | undefined): string | undefined {
168
+ return value === undefined ? undefined : value.slice(0, 10);
169
+ }
170
+
171
+ function digestLine(
172
+ ref: string,
173
+ tags: ReadonlyArray<string | undefined>,
174
+ summary: string,
175
+ trailer: ReadonlyArray<string | undefined>,
176
+ ): string {
177
+ const one = (value: string): string => value.replace(/\s+/g, " ");
178
+ const bracketed = tags
179
+ .filter((tag): tag is string => tag !== undefined)
180
+ .map(one);
181
+ const filled = trailer
182
+ .filter((part): part is string => part !== undefined)
183
+ .map(one);
184
+ const label = one(ref);
185
+ const head =
186
+ bracketed.length > 0 ? `${label} [${bracketed.join(" ")}]` : label;
187
+ const text = one(summary);
188
+ return `${head}${text !== "" ? ` ${text}` : ""}${filled.length > 0 ? ` · ${filled.join(" · ")}` : ""}`;
189
+ }
190
+
191
+ export function digestIssues(text: string): string | undefined {
192
+ const items = parseDigestItems(text);
193
+ if (!items) return undefined;
194
+ if (items.length === 0) return "(no issues)";
195
+ return items
196
+ .map((item) => {
197
+ const assignees = listAt(item, ["Assignees"]);
198
+ return digestLine(
199
+ stringAt(item, ["reference", "number"]) ?? "#?",
200
+ [
201
+ stringAt(item, ["state", "status"]),
202
+ ...listAt(item, ["Priority"]),
203
+ ...listAt(item, ["Type"]),
204
+ ],
205
+ clip(stringAt(item, ["title"]) ?? "(untitled)", 120),
206
+ [
207
+ stringAt(item, ["submitter"]),
208
+ day(stringAt(item, ["submitDate"])),
209
+ assignees.length > 0
210
+ ? `assigned: ${assignees.join(", ")}`
211
+ : undefined,
212
+ ],
213
+ );
214
+ })
215
+ .join("\n");
216
+ }
217
+
218
+ export function digestPulls(text: string): string | undefined {
219
+ const items = parseDigestItems(text);
220
+ if (!items) return undefined;
221
+ if (items.length === 0) return "(no pull requests)";
222
+ return items
223
+ .map((item) => {
224
+ const flow = [
225
+ stringAt(item, ["sourceBranch"]),
226
+ stringAt(item, ["targetBranch"]),
227
+ ]
228
+ .filter((branch): branch is string => branch !== undefined)
229
+ .join("→");
230
+ return digestLine(
231
+ stringAt(item, ["reference", "number"]) ?? "#?",
232
+ [stringAt(item, ["status", "state"])],
233
+ clip(stringAt(item, ["title"]) ?? "(untitled)", 120),
234
+ [
235
+ flow !== "" ? flow : undefined,
236
+ stringAt(item, ["submitter"]),
237
+ day(stringAt(item, ["submitDate"])),
238
+ ],
239
+ );
240
+ })
241
+ .join("\n");
242
+ }
243
+
244
+ export function digestBuilds(text: string): string | undefined {
245
+ const items = parseDigestItems(text);
246
+ if (!items) return undefined;
247
+ if (items.length === 0) return "(no builds)";
248
+ return items
249
+ .map((item) =>
250
+ digestLine(
251
+ stringAt(item, ["reference", "number"]) ?? "#?",
252
+ [stringAt(item, ["status"])],
253
+ clip(stringAt(item, ["jobName"]) ?? "(unknown job)", 80),
254
+ [
255
+ stringAt(item, ["refName"])?.replace("refs/heads/", ""),
256
+ stringAt(item, ["agent", "submitter"]),
257
+ day(stringAt(item, ["submitDate"])),
258
+ ],
259
+ ),
260
+ )
261
+ .join("\n");
262
+ }
263
+
264
+ export function digestProjects(text: string): string | undefined {
265
+ const items = parseDigestItems(text);
266
+ if (!items) return undefined;
267
+ if (items.length === 0) return "(no projects)";
268
+ return items
269
+ .map((item) => {
270
+ const features = [
271
+ item.codeManagement === true ? "code" : undefined,
272
+ item.issueManagement === true ? "issues" : undefined,
273
+ ].filter((feature): feature is string => feature !== undefined);
274
+ return digestLine(
275
+ stringAt(item, ["path"]) ?? "#?",
276
+ [],
277
+ clip(
278
+ stringAt(item, ["description"]) ?? stringAt(item, ["name"]) ?? "",
279
+ 80,
280
+ ),
281
+ [features.length > 0 ? `[${features.join("+")}]` : undefined],
282
+ );
283
+ })
284
+ .join("\n");
285
+ }
286
+
287
+ /**
288
+ * Fetch a whole bounded list payload, then reduce it to a digest unless the
289
+ * caller opted into raw output. The internal fetch budget exceeds the
290
+ * model-visible cap on purpose: the digest, not the payload, reaches the
291
+ * model.
292
+ */
293
+ export async function runListInContext(
294
+ deps: ToolDeps,
295
+ args: readonly string[],
296
+ options: {
297
+ signal?: AbortSignal;
298
+ full?: boolean;
299
+ maxOutputBytes?: number;
300
+ digest: (text: string) => string | undefined;
301
+ },
302
+ ): Promise<TodOutput> {
303
+ const context = deps.context();
304
+ if (context.status !== "ready" || !context.project) {
305
+ throw new TodError(
306
+ context.problem ??
307
+ "OneDev tools require a repository whose project can be inferred from a OneDev remote",
308
+ );
309
+ }
310
+ if (options.full === true) {
311
+ return runTod(deps.exec, args, {
312
+ cwd: context.cwd,
313
+ signal: options.signal,
314
+ maxOutputBytes: modelOutputLimit(options.maxOutputBytes),
315
+ });
316
+ }
317
+ const output = await runTod(deps.exec, args, {
318
+ cwd: context.cwd,
319
+ signal: options.signal,
320
+ maxOutputBytes: DIGEST_FETCH_BYTES,
321
+ });
322
+ const limit = modelOutputLimit(options.maxOutputBytes);
323
+ const digested = options.digest(output.text);
324
+ if (
325
+ digested !== undefined &&
326
+ Buffer.byteLength(digested, "utf8") <= limit
327
+ ) {
328
+ return {
329
+ text: digested,
330
+ truncated: false,
331
+ originalBytes: output.originalBytes,
332
+ };
333
+ }
334
+ // no digest, or one that outgrew the budget: the final model-visible
335
+ // text is still bounded by the normal output limit
336
+ const bounded = truncateMiddle(digested ?? output.text, limit);
337
+ return {
338
+ text: bounded.text,
339
+ truncated: bounded.truncated || output.truncated,
340
+ originalBytes: output.originalBytes,
341
+ };
342
+ }
343
+
92
344
  export function confirmMutation(
93
345
  deps: ToolDeps,
94
346
  ctx: ExtensionContext,
@@ -111,7 +363,7 @@ export function withFooter(
111
363
  ): string {
112
364
  const parts = [body || "(no output)"];
113
365
  if (truncated)
114
- parts.push("(output truncated; narrow the query or fetch a single entity)");
366
+ parts.push("(output truncated; narrow the query or raise max_bytes)");
115
367
  parts.push(contextFooter(context));
116
368
  return parts.filter(Boolean).join("\n\n");
117
369
  }
@@ -2,8 +2,15 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import { Type } from "typebox";
4
4
  import { formatContext, setupGuidance } from "../context.js";
5
+ import { runTod } from "../tod.js";
5
6
  import type { OneDevSourceWatchManager } from "../source-watch.js";
6
- import { toolResult, type ToolDeps } from "./common.js";
7
+ import {
8
+ DIGEST_FETCH_BYTES,
9
+ digestProjects,
10
+ toolResult,
11
+ withFooter,
12
+ type ToolDeps,
13
+ } from "./common.js";
7
14
  import { registerBuildTool } from "./build.js";
8
15
  import { registerIssueTool } from "./issue.js";
9
16
  import { registerPullTool } from "./pull.js";
@@ -43,11 +50,20 @@ function registerContextTool(pi: ExtensionAPI, deps: ToolDeps): void {
43
50
  pi.registerTool({
44
51
  name: "onedev_context",
45
52
  label: "OneDev Context",
46
- description:
47
- "Inspect OneDev readiness without loading domain tools. Use health for an authenticated re-check and setup for actionable configuration guidance.",
53
+ description: "Inspect OneDev readiness, health, login, or setup.",
48
54
  parameters: Type.Object(
49
55
  {
50
- action: StringEnum(["current", "health", "login_name", "setup"]),
56
+ action: StringEnum([
57
+ "current",
58
+ "health",
59
+ "login_name",
60
+ "setup",
61
+ "list_projects",
62
+ ]),
63
+ count: Type.Optional(
64
+ Type.Integer({ minimum: 1, maximum: 100, description: "Default: 25" }),
65
+ ),
66
+ offset: Type.Optional(Type.Integer({ minimum: 0 })),
51
67
  },
52
68
  { additionalProperties: false },
53
69
  ),
@@ -58,6 +74,31 @@ function registerContextTool(pi: ExtensionAPI, deps: ToolDeps): void {
58
74
  if (params.action === "setup") {
59
75
  return toolResult(setupGuidance(deps.context()));
60
76
  }
77
+ if (params.action === "list_projects") {
78
+ const context = deps.context();
79
+ if (
80
+ context.status === "not_configured" ||
81
+ context.status === "unavailable"
82
+ ) {
83
+ return toolResult(setupGuidance(context));
84
+ }
85
+ const args = ["project", "list", "--count", String(params.count ?? 25)];
86
+ if (params.offset !== undefined)
87
+ args.push("--offset", String(params.offset));
88
+ const output = await runTod(deps.exec, args, {
89
+ cwd: context.cwd,
90
+ signal,
91
+ maxOutputBytes: DIGEST_FETCH_BYTES,
92
+ });
93
+ const digested = digestProjects(output.text);
94
+ return toolResult(
95
+ withFooter(
96
+ digested ?? output.text,
97
+ context,
98
+ digested === undefined && output.truncated,
99
+ ),
100
+ );
101
+ }
61
102
  const context = await deps.refreshContext({
62
103
  verifyAuth: true,
63
104
  force: true,
@@ -85,8 +126,7 @@ export function registerOnedevTools(
85
126
  pi.registerTool({
86
127
  name: "onedev_tools",
87
128
  label: "OneDev Tools",
88
- description:
89
- "Activate only the OneDev domains needed for the current task. Returns authenticated server, project, and login context with the activation result.",
129
+ description: "Activate needed OneDev domains and return authenticated context.",
90
130
  promptGuidelines: [
91
131
  "Use onedev_tools before an unavailable OneDev domain operation.",
92
132
  ],
@@ -97,7 +137,6 @@ export function registerOnedevTools(
97
137
  minItems: 1,
98
138
  maxItems: 4,
99
139
  uniqueItems: true,
100
- description: "Activate issue, pull, build, and/or watch in one call",
101
140
  }),
102
141
  },
103
142
  { additionalProperties: false },
@@ -4,11 +4,14 @@ import { Type, type Static } from "typebox";
4
4
  import {
5
5
  FIELDS_DESCRIPTION,
6
6
  confirmMutation,
7
+ digestIssues,
8
+ modelOutputBytes,
7
9
  pushRepeated,
8
10
  QUERY_DESCRIPTION,
9
11
  REF_DESCRIPTION,
10
12
  requireParam,
11
13
  runInContext,
14
+ runListInContext,
12
15
  toolResult,
13
16
  withFooter,
14
17
  type ToolDeps,
@@ -65,6 +68,8 @@ export interface IssueParams {
65
68
  query?: string;
66
69
  count?: number;
67
70
  offset?: number;
71
+ full?: boolean;
72
+ max_bytes?: number;
68
73
  }
69
74
 
70
75
  export function buildIssueArgs(params: IssueParams): string[] {
@@ -161,66 +166,54 @@ export function buildIssueArgs(params: IssueParams): string[] {
161
166
 
162
167
  export function registerIssueTool(pi: ExtensionAPI, deps: ToolDeps): void {
163
168
  const parameters = Type.Object({
164
- action: StringEnum(ISSUE_ACTIONS, {
165
- description:
166
- "list (query issues), get, get_comments, create, edit, change_state, add_comment, link, log_work, create_branch, checkout (branch locally), current_reference (issue for current branch), valid_fields, valid_links, query_description",
167
- }),
169
+ action: StringEnum(ISSUE_ACTIONS),
168
170
  ref: Type.Optional(Type.String({ description: REF_DESCRIPTION })),
169
171
  title: Type.Optional(Type.String()),
170
- description: Type.Optional(Type.String({ description: "Markdown body" })),
172
+ description: Type.Optional(Type.String()),
171
173
  state: Type.Optional(
172
- Type.String({
173
- description:
174
- "Target state, e.g. Open or Released; discover via valid_fields",
175
- }),
176
- ),
177
- comment: Type.Optional(
178
- Type.String({ description: "Markdown note attached to the action" }),
174
+ Type.String({ description: "Use valid_fields to discover states" }),
179
175
  ),
180
- content: Type.Optional(Type.String({ description: "Markdown comment text" })),
176
+ comment: Type.Optional(Type.String()),
177
+ content: Type.Optional(Type.String()),
181
178
  link_name: Type.Optional(
182
- Type.String({ description: "Issue link name; discover via valid_links" }),
183
- ),
184
- target_ref: Type.Optional(
185
- Type.String({ description: "Target issue reference for the link action" }),
186
- ),
187
- hours: Type.Optional(
188
- Type.Integer({ minimum: 1, description: "Hours to log for log_work" }),
179
+ Type.String({ description: "Use valid_links to discover names" }),
189
180
  ),
181
+ target_ref: Type.Optional(Type.String()),
182
+ hours: Type.Optional(Type.Integer({ minimum: 1 })),
190
183
  fields: Type.Optional(
191
184
  Type.Array(Type.String(), { description: FIELDS_DESCRIPTION }),
192
185
  ),
193
186
  iterations: Type.Optional(Type.Array(Type.String())),
194
187
  own_estimated_time: Type.Optional(
195
- Type.Number({ minimum: 0, description: "Estimated hours" }),
188
+ Type.Number({ minimum: 0, description: "Hours" }),
196
189
  ),
197
190
  confidential: Type.Optional(Type.Boolean()),
198
191
  for_write: Type.Optional(
199
- Type.Boolean({ description: "checkout: set up the branch for pushing" }),
192
+ Type.Boolean({ description: "Prepare checkout for push" }),
200
193
  ),
201
194
  project: Type.Optional(
202
- Type.String({
203
- description:
204
- "Project path for list/create; defaults to the current project",
205
- }),
195
+ Type.String({ description: "Default: current project" }),
206
196
  ),
207
197
  query: Type.Optional(Type.String({ description: QUERY_DESCRIPTION })),
208
198
  count: Type.Optional(
209
199
  Type.Integer({
210
200
  minimum: 1,
211
201
  maximum: 100,
212
- description: "Page size for list (default 25, max 100)",
202
+ description: "Default: 25",
213
203
  }),
214
204
  ),
215
205
  offset: Type.Optional(Type.Integer({ minimum: 0 })),
206
+ full: Type.Optional(
207
+ Type.Boolean({ description: "Raw JSON instead of digest" }),
208
+ ),
209
+ max_bytes: modelOutputBytes(),
216
210
  }, { additionalProperties: false });
217
211
  type Params = Static<typeof parameters>;
218
212
 
219
213
  pi.registerTool({
220
214
  name: "onedev_issue",
221
215
  label: "OneDev Issues",
222
- description:
223
- "Read and edit OneDev issues: query, inspect, comment, change fields/labels/state, log work, manage issue branches. Runs in the current repository's project; plain numbers resolve against it.",
216
+ description: "Read/manage OneDev issues, comments, fields, work, and branches.",
224
217
  parameters,
225
218
  async execute(_toolCallId, params: Params, signal, _onUpdate, ctx) {
226
219
  const args = buildIssueArgs(params);
@@ -236,7 +229,18 @@ export function registerIssueTool(pi: ExtensionAPI, deps: ToolDeps): void {
236
229
  signal,
237
230
  });
238
231
  }
239
- const output = await runInContext(deps, args, { signal });
232
+ const output =
233
+ params.action === "list"
234
+ ? await runListInContext(deps, args, {
235
+ signal,
236
+ full: params.full,
237
+ maxOutputBytes: params.max_bytes,
238
+ digest: digestIssues,
239
+ })
240
+ : await runInContext(deps, args, {
241
+ signal,
242
+ maxOutputBytes: params.max_bytes,
243
+ });
240
244
  return toolResult(
241
245
  withFooter(output.text, deps.context(), output.truncated),
242
246
  {
package/src/tools/pull.ts CHANGED
@@ -3,11 +3,15 @@ import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import { Type, type Static } from "typebox";
4
4
  import {
5
5
  confirmMutation,
6
+ DEFAULT_LARGE_MODEL_OUTPUT_BYTES,
7
+ digestPulls,
8
+ modelOutputBytes,
6
9
  pushRepeated,
7
10
  QUERY_DESCRIPTION,
8
11
  REF_DESCRIPTION,
9
12
  requireParam,
10
13
  runInContext,
14
+ runListInContext,
11
15
  toolResult,
12
16
  withFooter,
13
17
  type ToolDeps,
@@ -86,6 +90,8 @@ export interface PullParams {
86
90
  query?: string;
87
91
  count?: number;
88
92
  offset?: number;
93
+ full?: boolean;
94
+ max_bytes?: number;
89
95
  }
90
96
 
91
97
  export function buildPullArgs(params: PullParams): string[] {
@@ -232,92 +238,56 @@ export function buildPullArgs(params: PullParams): string[] {
232
238
 
233
239
  export function registerPullTool(pi: ExtensionAPI, deps: ToolDeps): void {
234
240
  const parameters = Type.Object({
235
- action: StringEnum(PULL_ACTIONS, {
236
- description:
237
- "list, get, get_comments, get_code_comments, get_builds, get_patch, create, edit, add_comment, add_code_comment, reply_code_comment, resolve_code_comment, unresolve_code_comment, approve, request_changes, merge, discard, checkout, current_reference, valid_labels, query_description, title_description_requirement, commit_message_requirement",
238
- }),
241
+ action: StringEnum(PULL_ACTIONS),
239
242
  ref: Type.Optional(Type.String({ description: REF_DESCRIPTION })),
240
243
  title: Type.Optional(Type.String()),
241
- description: Type.Optional(Type.String({ description: "Markdown body" })),
242
- content: Type.Optional(Type.String({ description: "Markdown comment text" })),
243
- summary: Type.Optional(
244
- Type.String({
245
- description: "Markdown review summary for approve/request_changes",
246
- }),
247
- ),
248
- comment: Type.Optional(
249
- Type.String({ description: "Markdown note for discard" }),
250
- ),
251
- commit_message: Type.Optional(
252
- Type.String({
253
- description: "Commit message for merge (required by squash merges)",
254
- }),
255
- ),
256
- file: Type.Optional(
257
- Type.String({ description: "add_code_comment: file path in the PR patch" }),
258
- ),
244
+ description: Type.Optional(Type.String()),
245
+ content: Type.Optional(Type.String()),
246
+ summary: Type.Optional(Type.String()),
247
+ comment: Type.Optional(Type.String()),
248
+ commit_message: Type.Optional(Type.String()),
249
+ file: Type.Optional(Type.String()),
259
250
  from_line: Type.Optional(
260
- Type.Integer({
261
- minimum: 1,
262
- description: "add_code_comment: 1-based start line (right side of patch)",
263
- }),
264
- ),
265
- to_line: Type.Optional(
266
- Type.Integer({
267
- minimum: 1,
268
- description: "add_code_comment: 1-based end line; defaults to from_line",
269
- }),
270
- ),
271
- comment_id: Type.Optional(
272
- Type.Integer({
273
- minimum: 1,
274
- description:
275
- "Code comment id for reply_code_comment, resolve_code_comment, or unresolve_code_comment",
276
- }),
251
+ Type.Integer({ minimum: 1, description: "Right-side line" }),
277
252
  ),
253
+ to_line: Type.Optional(Type.Integer({ minimum: 1 })),
254
+ comment_id: Type.Optional(Type.Integer({ minimum: 1 })),
278
255
  source_branch: Type.Optional(Type.String()),
279
256
  target_branch: Type.Optional(Type.String()),
280
257
  source_project: Type.Optional(Type.String()),
281
258
  target_project: Type.Optional(Type.String()),
282
- assignees: Type.Optional(
283
- Type.Array(Type.String(), { description: "User logins, repeatable" }),
284
- ),
285
- reviewers: Type.Optional(
286
- Type.Array(Type.String(), { description: "User logins, repeatable" }),
287
- ),
288
- remove_reviewers: Type.Optional(
289
- Type.Array(Type.String(), { description: "edit: user logins to remove" }),
290
- ),
259
+ assignees: Type.Optional(Type.Array(Type.String())),
260
+ reviewers: Type.Optional(Type.Array(Type.String())),
261
+ remove_reviewers: Type.Optional(Type.Array(Type.String())),
291
262
  labels: Type.Optional(
292
- Type.Array(Type.String(), {
293
- description: "Label names; discover via valid_labels",
294
- }),
263
+ Type.Array(Type.String(), { description: "Use valid_labels" }),
295
264
  ),
296
265
  merge_strategy: Type.Optional(Type.String()),
297
266
  auto_merge: Type.Optional(Type.Boolean()),
298
267
  auto_merge_commit_message: Type.Optional(Type.String()),
299
268
  for_code_review: Type.Optional(
300
- Type.Boolean({ description: "get_patch: review-scoped patch" }),
269
+ Type.Boolean({ description: "Review-scoped patch" }),
301
270
  ),
302
271
  for_write: Type.Optional(
303
- Type.Boolean({ description: "checkout: set up the branch for pushing" }),
272
+ Type.Boolean({ description: "Prepare checkout for push" }),
304
273
  ),
305
274
  project: Type.Optional(
306
- Type.String({
307
- description: "Project path for list; defaults to the current project",
308
- }),
275
+ Type.String({ description: "Default: current project" }),
309
276
  ),
310
277
  query: Type.Optional(Type.String({ description: QUERY_DESCRIPTION })),
311
278
  count: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
312
279
  offset: Type.Optional(Type.Integer({ minimum: 0 })),
280
+ full: Type.Optional(
281
+ Type.Boolean({ description: "Raw JSON instead of digest" }),
282
+ ),
283
+ max_bytes: modelOutputBytes(),
313
284
  }, { additionalProperties: false });
314
285
  type Params = Static<typeof parameters>;
315
286
 
316
287
  pi.registerTool({
317
288
  name: "onedev_pull",
318
289
  label: "OneDev Pull Requests",
319
- description:
320
- "Read and edit OneDev pull requests: metadata, labels, comments, line-anchored code comments, builds, patch, review outcomes, and guarded merge/discard. Runs in the current repository's project.",
290
+ description: "Read/manage OneDev PRs, patch, comments, reviews, and guarded merge.",
321
291
  parameters,
322
292
  async execute(_toolCallId, params: Params, signal, _onUpdate, ctx) {
323
293
  const args = buildPullArgs(params);
@@ -334,7 +304,22 @@ export function registerPullTool(pi: ExtensionAPI, deps: ToolDeps): void {
334
304
  signal,
335
305
  });
336
306
  }
337
- const output = await runInContext(deps, args, { signal });
307
+ const output =
308
+ params.action === "list"
309
+ ? await runListInContext(deps, args, {
310
+ signal,
311
+ full: params.full,
312
+ maxOutputBytes: params.max_bytes,
313
+ digest: digestPulls,
314
+ })
315
+ : await runInContext(deps, args, {
316
+ signal,
317
+ maxOutputBytes:
318
+ params.max_bytes ??
319
+ (params.action === "get_patch"
320
+ ? DEFAULT_LARGE_MODEL_OUTPUT_BYTES
321
+ : undefined),
322
+ });
338
323
  return toolResult(
339
324
  withFooter(output.text, deps.context(), output.truncated),
340
325
  {
@@ -11,8 +11,7 @@ export function registerWatchTool(
11
11
  pi.registerTool({
12
12
  name: "onedev_watch",
13
13
  label: "OneDev Watch",
14
- description:
15
- "Create session-scoped one-shot watches for a build result or pull-request attention, or list/stop active watches. Notifications contain bounded metadata; use read tools for details.",
14
+ description: "Start/list/stop one-shot build or PR-attention watches.",
16
15
  parameters: Type.Object(
17
16
  {
18
17
  action: StringEnum([
@@ -21,12 +20,8 @@ export function registerWatchTool(
21
20
  "list",
22
21
  "stop",
23
22
  ]),
24
- ref: Type.Optional(
25
- Type.String({ description: "Build or pull request reference to watch" }),
26
- ),
27
- watch_id: Type.Optional(
28
- Type.String({ description: "Watch id required by stop" }),
29
- ),
23
+ ref: Type.Optional(Type.String()),
24
+ watch_id: Type.Optional(Type.String()),
30
25
  interval_seconds: Type.Optional(
31
26
  Type.Integer({ minimum: 15, maximum: 300, default: 30 }),
32
27
  ),