@danypops/pi-papyrus 0.35.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/README.md +146 -0
- package/THIRD_PARTY_LICENSES.md +31 -0
- package/extension/src/active-task-continuation.ts +131 -0
- package/extension/src/artifact-browser.ts +227 -0
- package/extension/src/artifact-detail-format.ts +38 -0
- package/extension/src/artifact-detail-view.ts +121 -0
- package/extension/src/artifact-format.ts +79 -0
- package/extension/src/artifact-relationship-lines.ts +27 -0
- package/extension/src/artifact-status-presentation.ts +71 -0
- package/extension/src/base-prompt-breakdown.ts +55 -0
- package/extension/src/beautiful-mermaid-renderer.ts +69 -0
- package/extension/src/bounded-poll.ts +20 -0
- package/extension/src/context-budget.ts +501 -0
- package/extension/src/context-injection-telemetry.ts +84 -0
- package/extension/src/context-view.ts +222 -0
- package/extension/src/discuss-ask-layout.ts +193 -0
- package/extension/src/discuss-ask-view.ts +1301 -0
- package/extension/src/discuss.ts +132 -0
- package/extension/src/discussion-detail-view.ts +137 -0
- package/extension/src/docs.ts +58 -0
- package/extension/src/domain-tools.ts +891 -0
- package/extension/src/index.ts +777 -0
- package/extension/src/markdown.ts +60 -0
- package/extension/src/note-widget.ts +8 -0
- package/extension/src/notes.ts +100 -0
- package/extension/src/playbook-bridge.ts +91 -0
- package/extension/src/playbooks.ts +97 -0
- package/extension/src/rules.ts +51 -0
- package/extension/src/service-client.ts +28 -0
- package/extension/src/session-identity.ts +22 -0
- package/extension/src/skill-catalog-footprint.ts +183 -0
- package/extension/src/skills.ts +125 -0
- package/extension/src/task-detail-format.ts +108 -0
- package/extension/src/task-detail-view.ts +139 -0
- package/extension/src/task-focus-events.ts +57 -0
- package/extension/src/task-graph.ts +117 -0
- package/extension/src/task-presentation.ts +26 -0
- package/extension/src/task-widget.ts +68 -0
- package/extension/src/tasks.ts +422 -0
- package/extension/src/tool-rendering/artifact-card.ts +117 -0
- package/extension/src/tool-rendering/artifact-list.ts +179 -0
- package/extension/src/tool-rendering/index.ts +109 -0
- package/extension/src/tool-rendering/render-model.ts +410 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# @danypops/pi-papyrus
|
|
2
|
+
|
|
3
|
+
The Pi extension for Papyrus: native tools, TUI panels, and context injection over `@danypops/papyrus`'s authenticated loopback daemon.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
The `papyrus_*` tools are the low-level graph-store API:
|
|
8
|
+
|
|
9
|
+
- **`papyrus_query`** — filter by kind/status or search title and body
|
|
10
|
+
- **`papyrus_graph`** — link artifacts, perform bounded traversal, or read the mutation event log
|
|
11
|
+
- **`papyrus_show`** — read nested metadata and bounded edges, optionally running gates
|
|
12
|
+
|
|
13
|
+
Agent-facing domain tools own lifecycle invariants and sit above this store API:
|
|
14
|
+
|
|
15
|
+
- **`tasks`** — create/update/list/show/plan, manage the singleton active focus, replace evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort
|
|
16
|
+
- **`notes`** — capture/list/show deferred human intent, mark it consumed, promote it to an existing Task/Doc/Rule/Skill, or archive it with an explicit disposition
|
|
17
|
+
- **`docs`** — create/update/list/show, activate/archive/reopen, and document-safe graph links; Note mutations remain behind the Notes facade
|
|
18
|
+
- **`rules`** — create/update/list/show/preview, enable/disable, and attach governance gates to tasks
|
|
19
|
+
- **`skills`** — create/update/list/show/invoke/run, enable/disable, create compatibility templates, and atomically instantiate parameterized workflow runs
|
|
20
|
+
- **`playbooks`** — a completely different beast from Skills, not a subtype: a trigger and an ordered list of steps an agent reads and follows, never mechanically instantiated and never composed the way Skills call other Skills. create/update/list/show/invoke, enable/disable. A Playbook can declare named arguments (`{name, description?, required?}`, required defaults true); invoking with some unsupplied lists exactly which required ones are still missing and directs the agent to ask via `discuss` with `live:true` rather than guess
|
|
21
|
+
|
|
22
|
+
Every tool operation is registered in the daemon's `/api/v1/ops` registry; parity is verified in tests. The task consumer uses the `tasks.graph` operation, which returns task nodes with explicit parent, child, and dependency IDs rather than leaking SQLite rows or asking the UI to reconstruct relationships.
|
|
23
|
+
|
|
24
|
+
## Interactive frontends
|
|
25
|
+
|
|
26
|
+
- `/tasks` — project/focused-graph scope, task lifecycle, append-only history, gates, dependencies, and nested metadata
|
|
27
|
+
- `/note <request>` — directly capture one project-scoped deferred request without creating a Task
|
|
28
|
+
- `/notes` — searchable project Notes inbox with consume, promote, and disposition-aware archive actions
|
|
29
|
+
- `/docs` — searchable non-Note documents, lifecycle, details, edit, and graph links
|
|
30
|
+
- `/rules` — severity/condition rows, exact injection preview, edit, enable/disable, and task gating
|
|
31
|
+
- `/skills` — trigger/tools rows, edit, invocation into the editor, and artifact templates
|
|
32
|
+
- `/playbooks` — trigger/tools rows, edit, invocation into the editor, and graph links
|
|
33
|
+
- `/playbook <name>` — tab-completes active playbook titles and places that one's invocation directly in the editor, one step instead of browse-then-select; no argument falls back to the full `/playbooks` browser
|
|
34
|
+
|
|
35
|
+
All frontends use daemon-backed domain operations; none opens SQLite from the Pi process. **Show details** opens a bounded navigable view across Tasks, Notes, Docs, Rules, legacy Skills, templates, and workflow Skills. User-authored bodies render as width-aware Markdown with headings, emphasis, links, quotes, lists, tables, inline/fenced code, syntax highlighting, and every color/decorative style derived dynamically from the active Pi theme. Generated lifecycle, metadata, checklist, gate, history, and relationship sections keep explicit semantic theme colors; relationships render as a small Unicode graph via `beautiful-mermaid` when the neighbor set is real and within the routed-rendering bound, falling back to a plain, still name-resolved arrow list otherwise. `↑/↓` scrolls, `←/→` pans wide relationships, and Esc returns to the browser; non-interactive clients receive stable source text.
|
|
36
|
+
|
|
37
|
+
## Notes
|
|
38
|
+
|
|
39
|
+
Notes are project-scoped `doc/note` artifacts for human requests that should be considered later. Capturing a Note does not create work, inject the entire inbox into prompts, or imply acceptance. The agent can use the `notes` domain tool to list and consume open Notes, decide whether to create a Task, Doc, Rule, or Skill through its owning domain tool, then promote the Note by linking that artifact. Archive requires one of `completed`, `duplicate`, `declined`, or `superseded`; promote archives with a `promoted` disposition and target ID. Capture, consumption, and disposition provenance remain in bounded Note history.
|
|
40
|
+
|
|
41
|
+
The default inbox contains draft and consumed/active Notes, is bounded to 50 rows, and has a hard limit of 200. Bodies are capped at 10,000 characters. Generic document and graph lifecycle operations reject Note mutations so they cannot bypass disposition provenance.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
papyrus notes capture "Investigate the retry policy" --json
|
|
45
|
+
papyrus notes list --limit 25 --json
|
|
46
|
+
papyrus notes show <note-id> --json
|
|
47
|
+
papyrus notes consume <note-id> --json
|
|
48
|
+
# Create the resulting artifact with tasks/docs/rules/skills first, then:
|
|
49
|
+
papyrus notes promote <note-id> <target-id> --reason "Converted to tracked work" --json
|
|
50
|
+
papyrus notes archive <note-id> declined --reason "No longer relevant" --json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A persistent widget (matching the Task widget) shows a simple `Notes N` count, scoped by default to the current project (CWD), so a growing inbox is visible without opening `/notes`.
|
|
54
|
+
|
|
55
|
+
## Discuss
|
|
56
|
+
|
|
57
|
+
Discuss is a native, persistent deliberation, distinct from a one-shot ask: it survives across turns and sessions, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. A Discussion is a `doc` artifact with `subtype: "discussion"` -- real graph citizenship (edges, show/list) without a fifth enforced artifact kind. Its fine-grained lifecycle (`active`/`deferred`/`settled`) lives in `extra.discussion`, since Papyrus enforces status vocabulary per kind, not per subtype.
|
|
58
|
+
|
|
59
|
+
Rounds are a dedicated append-only child table (mirroring Task history's own shape): `open` records round 1, `reply` appends further rounds, refused once the Discussion is `deferred` or `settled` -- resume first. `defer` is explicitly non-blocking (paused, reason optional, resumable); `settle` is terminal, records an outcome, and archives the Doc. `block`/`unblock` manage the blocking relationship to a Task independently of `open`.
|
|
60
|
+
|
|
61
|
+
Blocking is real: `tasks.complete` is refused while any `active` Discussion has a `blocks` edge to that Task. A `deferred` Discussion does not block -- "we will get back to this" is distinct from "resolved."
|
|
62
|
+
|
|
63
|
+
`open`/`reply` can also pose a structured choice instead of (or alongside) free text: `options` (2-10 entries) plus `options_mode` -- `single` is mutually exclusive (exactly one pick), `multi` allows several. The Discussion remembers the pending choice (`extra.discussion.pendingOptions`/`pendingOptionsMode`) until a `reply` answers it with `selected`, validated against exactly what was offered and the mode's cardinality; a reply can also pose the *next* round's choice in the same call.
|
|
64
|
+
|
|
65
|
+
Run `/discuss` for the interactive panel: browse every Discussion (the real `active`/`deferred`/`settled` state shown per row, alongside any choice awaiting an answer), open a scrollable transcript showing what was posed and picked in each round, and reply/defer/resume/settle or block/unblock a task without leaving the TUI. Replying to a pending choice shows a real picker -- the native single-select list for `single`, or a checkbox multi-select for `multi`, since no built-in multi-select exists in the Pi extension UI. Both modes append a numbered "type your own answer" row -- a genuinely open answer is exactly as valid as any posed option. The multi-select picker supports a number key as a direct quick-select (jump straight to that row instead of scrolling), and steadily highlights checked rows while dimming the rest so the eye reads "what's chosen" independent of cursor position; its cursor row blinks to mark focus. It also auto-cancels after 30s of zero input -- the very first keystroke of any kind stops that countdown permanently for that prompt. Opening a *new* Discussion is left to the agent (same as Docs/Rules/Skills) -- `/discuss` browses and drives existing ones.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
papyrus discuss open --title "Naming" --actor alice --content "Should we rename this?" --blocks-json '["task-id"]' --json
|
|
69
|
+
papyrus discuss open --title "Which approach" --actor alice --content "Pick one" --options-json '["A","B"]' --options-mode single --json
|
|
70
|
+
papyrus discuss reply <discussion-id> --actor bob --content "I think so, here's why..." --json
|
|
71
|
+
papyrus discuss reply <discussion-id> --actor bob --content "Going with B" --selected-json '["B"]' --json
|
|
72
|
+
papyrus discuss defer <discussion-id> --reason "Waiting on design review" --json
|
|
73
|
+
papyrus discuss resume <discussion-id> --json
|
|
74
|
+
papyrus discuss settle <discussion-id> --settlement "Agreed: renaming to X" --json
|
|
75
|
+
papyrus discuss show <discussion-id> --json
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Tasks
|
|
79
|
+
|
|
80
|
+
Run `/tasks` for the interactive task panel:
|
|
81
|
+
|
|
82
|
+
- `/` filters; arrow keys navigate; Enter opens task actions; `s` switches among the persisted current-project, focused-root graph, and explicit all-projects views
|
|
83
|
+
- `g` opens the programmatic Unicode graph; Tab switches dependency/composition views and arrow keys pan
|
|
84
|
+
- routed graph layouts are bounded to 48 nodes/96 edges; larger graphs use a deterministic, box-drawn line fallback, and renderer failures are contained inside the viewport rather than escaping Pi
|
|
85
|
+
- advance the `todo → in-progress → review → done` lifecycle; failed review becomes `rejected`, retry returns to `in-progress`, and `canceled` is terminal
|
|
86
|
+
- use **focus** as the independent singleton Task selection that automatic continuation follows; focusing, pausing, or resuming never changes lifecycle
|
|
87
|
+
- starting nested effort moves todo ancestors to in-progress; submitting enters review; completing review checks both typed checklist proofs and executable gates
|
|
88
|
+
- passing review marks only that task done and focuses one deterministic ready successor while leaving the successor todo until effort starts
|
|
89
|
+
- successors are never auto-completed; fan-in, fan-out, diamonds, and disconnected DAGs remain explicit
|
|
90
|
+
- inspect deterministic execution layers, readiness, a box-drawn nested hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
|
|
91
|
+
- lifecycle colors are semantic and redundant with text/glyphs: To-Do grey, in-progress yellow, review blue, rejected orange, done green, and canceled red; `▶` marks active focus
|
|
92
|
+
- Show details keeps Checklist and Validation gates separate from incidental Metadata, renders bounded post-migration lifecycle history with actor/source/reason and gate evidence, then renders relationships as a Unicode box-drawing graph footer; `↑/↓` scrolls and `←/→` pans wide graphs
|
|
93
|
+
- the compact persistent widget shows the current scope label plus bounded open work in containment order and always retains active focus when it belongs to that scope, refreshed both on tool activity and a bounded background poll so a mutation from another session or a plain CLI call is reflected without needing a Papyrus-tool call to trigger it
|
|
94
|
+
|
|
95
|
+
Authenticated CLI parity covers the changed lifecycle and focus operations:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
papyrus tasks graph --json
|
|
99
|
+
papyrus tasks scope --json
|
|
100
|
+
papyrus tasks scope project --json
|
|
101
|
+
papyrus tasks scope graph <root-id> --json
|
|
102
|
+
papyrus tasks scope all --json
|
|
103
|
+
papyrus tasks assign-project <task-id> [project-root] --json
|
|
104
|
+
papyrus tasks active --json
|
|
105
|
+
papyrus tasks history <id> --json
|
|
106
|
+
papyrus tasks focus <id> --json
|
|
107
|
+
papyrus tasks focused --json
|
|
108
|
+
papyrus tasks pause --json
|
|
109
|
+
papyrus tasks unpause --json
|
|
110
|
+
papyrus tasks clear-focus --json
|
|
111
|
+
papyrus tasks update <id> --title "Revised title" --body "Revised body" --json
|
|
112
|
+
papyrus tasks update <id> --status todo --reason "created with legacy default" --json
|
|
113
|
+
papyrus tasks start <id> --json
|
|
114
|
+
papyrus tasks submit <id> --json
|
|
115
|
+
papyrus tasks complete <id> --json
|
|
116
|
+
papyrus tasks reject <id> --json
|
|
117
|
+
papyrus tasks retry <id> --json
|
|
118
|
+
papyrus tasks cancel <id> --json
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Task edits mutate the existing Papyrus-owned Task identity and append an `updated` event; title, body, and labels can be revised without canceling the Task or creating a replacement. Lifecycle, relationships, gates, checklist metadata, scope, and focus remain intact. The same `update` action provides a narrowly guarded recovery for Tasks accidentally created terminal by a legacy default: `status=todo` requires an audit reason, cannot be combined with content edits, only applies when `created` is the sole lifecycle event, and appends `creation_recovered` rather than rewriting history.
|
|
122
|
+
|
|
123
|
+
### Focus-driven automatic continuation
|
|
124
|
+
|
|
125
|
+
Automatic continuation is a property of the singleton Task focus, not a per-Task automation flag. An active focus continues at Pi's public `agent_settled` boundary when Pi is idle and has no queued messages. `tasks pause` preserves the focused Task while stopping continuation; `tasks unpause` resumes it; `tasks clear-focus` removes it. Replacing focus selects an existing Task rather than creating or canceling one.
|
|
126
|
+
|
|
127
|
+
Continuation is single-flight and bounded to 20 automatic turns or 6 unchanged Task snapshots. Reaching either bound persists a paused focus and records the reason in append-only Task history. Human input resumes only these automatically paused focuses; an explicit user pause remains paused.
|
|
128
|
+
|
|
129
|
+
Checklist criteria are an item-to-proof map. Every new item requires one or more typed references to inspectable evidence; proof presence does not imply that the evidence passed an executable gate:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
checklist: {
|
|
133
|
+
"Write failing skill-row tests": {
|
|
134
|
+
proof: [
|
|
135
|
+
{ type: "file", target: "test/frontends.test.ts" },
|
|
136
|
+
{ type: "symbol", target: "test/frontends.test.ts#skill row test" }
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Proof types are `file`, `symbol`, `code`, `test`, `command`, `artifact`, and `url`. Existing array checklists remain readable as legacy items with `proof: missing`; Papyrus does not invent evidence.
|
|
143
|
+
|
|
144
|
+
Papyrus also injects an Alef-style reconciliation block at `before_agent_start` while work remains: `Current`, `Desired`, `Verify`, and `Next`. The agent is explicitly instructed to ask **"Did we accomplish this task?"** and run review before marking it done. The injection disappears when every task is done or canceled.
|
|
145
|
+
|
|
146
|
+
After assembling each system-prompt addition, Papyrus emits a versioned `papyrus.context-injection.v1` observation on Pi's shared extension event bus. It contains only exact byte/character sizes, Rule count, a labeled token estimate, prompt share, sequence, and a SHA-256 payload fingerprint; Rule/Task text, prompts, project paths, and credentials are never included. Jittor can persist and assess these observations without Papyrus maintaining a second telemetry store.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Third-party licenses
|
|
2
|
+
|
|
3
|
+
## pi-ask-user (MIT)
|
|
4
|
+
|
|
5
|
+
`extension/src/discuss-ask-view.ts` and `extension/src/discuss-ask-layout.ts` are substantially
|
|
6
|
+
adapted from [pi-ask-user](https://github.com/edlsh/pi-ask-user), used here as the interactive
|
|
7
|
+
UI for Discuss's own `live:true` synchronous ask.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 Enzo Lucchesi
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
```
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export interface ActiveTaskMarker {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
updated_at: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ActiveTaskContinuationOptions {
|
|
8
|
+
maxTurns: number;
|
|
9
|
+
maxUnchangedTurns: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ActiveTaskContinuationState {
|
|
13
|
+
queued: boolean;
|
|
14
|
+
consecutiveTurns: number;
|
|
15
|
+
unchangedTurns: number;
|
|
16
|
+
pausedReason?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ActiveTaskContinuationDecision {
|
|
20
|
+
action: "continue" | "wait" | "pause";
|
|
21
|
+
reason: string;
|
|
22
|
+
prompt?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TITLE_LIMIT = 120;
|
|
26
|
+
const AUTOMATIC_PAUSE_PREFIX = "automatic continuation paused:";
|
|
27
|
+
|
|
28
|
+
export function automaticPauseReason(reason: string): string {
|
|
29
|
+
return `${AUTOMATIC_PAUSE_PREFIX} ${reason}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function shouldResumeFocusOnHumanInput(status: string, pauseReason?: string): boolean {
|
|
33
|
+
return status === "paused" && pauseReason?.startsWith(AUTOMATIC_PAUSE_PREFIX) === true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fingerprint(task: ActiveTaskMarker): string {
|
|
37
|
+
return `${task.id}:${task.updated_at}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function continuationPrompt(task: ActiveTaskMarker): string {
|
|
41
|
+
return [
|
|
42
|
+
"Continue the active Papyrus Task now; do not hand off merely because the previous Pi run settled.",
|
|
43
|
+
"Reconcile its lifecycle, take the next concrete action, use tools, submit it for review when implementation effort is ready, and run gates plus checklist review before completion.",
|
|
44
|
+
"Do not shrink the task's scope to whatever fits in this turn, and do not treat a status update or summary as a substitute for doing the work or as proof of completion.",
|
|
45
|
+
"If something blocks progress, do not reject or pause on the first obstacle -- only after it genuinely recurs, and only when the task truly cannot proceed without external input.",
|
|
46
|
+
`Active task: ${task.title.slice(0, TITLE_LIMIT)}`,
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class ActiveTaskContinuation {
|
|
51
|
+
private queued = false;
|
|
52
|
+
private consecutiveTurns = 0;
|
|
53
|
+
private unchangedTurns = 0;
|
|
54
|
+
private lastFingerprint: string | undefined;
|
|
55
|
+
private pausedReason: string | undefined;
|
|
56
|
+
|
|
57
|
+
constructor(private readonly options: ActiveTaskContinuationOptions) {
|
|
58
|
+
if (!Number.isInteger(options.maxTurns) || options.maxTurns < 1) throw new Error("maxTurns must be a positive integer");
|
|
59
|
+
if (!Number.isInteger(options.maxUnchangedTurns) || options.maxUnchangedTurns < 1) {
|
|
60
|
+
throw new Error("maxUnchangedTurns must be a positive integer");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
evaluate(task: ActiveTaskMarker | null, context: { idle: boolean; pendingMessages: boolean }): ActiveTaskContinuationDecision {
|
|
65
|
+
if (!context.idle) return { action: "wait", reason: "Pi is not settled" };
|
|
66
|
+
if (context.pendingMessages) return { action: "wait", reason: "Pi already has pending messages" };
|
|
67
|
+
if (this.queued) return { action: "wait", reason: "continuation already queued" };
|
|
68
|
+
if (!task) {
|
|
69
|
+
this.resetProgress();
|
|
70
|
+
return { action: "wait", reason: "no active task" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const currentFingerprint = fingerprint(task);
|
|
74
|
+
if (currentFingerprint !== this.lastFingerprint) {
|
|
75
|
+
this.lastFingerprint = currentFingerprint;
|
|
76
|
+
this.unchangedTurns = 0;
|
|
77
|
+
this.pausedReason = undefined;
|
|
78
|
+
} else {
|
|
79
|
+
this.unchangedTurns += 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (this.consecutiveTurns >= this.options.maxTurns) {
|
|
83
|
+
return this.pause(`automatic turn limit reached (${this.options.maxTurns})`);
|
|
84
|
+
}
|
|
85
|
+
if (this.unchangedTurns >= this.options.maxUnchangedTurns) {
|
|
86
|
+
return this.pause(`no task progress after ${this.options.maxUnchangedTurns} automatic turns`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.queued = true;
|
|
90
|
+
this.consecutiveTurns += 1;
|
|
91
|
+
return {
|
|
92
|
+
action: "continue",
|
|
93
|
+
reason: "an active task remains",
|
|
94
|
+
prompt: continuationPrompt(task),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
onAgentStart(): void {
|
|
99
|
+
this.queued = false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
onCompaction(): void {
|
|
103
|
+
this.queued = false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
onHumanInput(): void {
|
|
107
|
+
this.resetProgress();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
status(): ActiveTaskContinuationState {
|
|
111
|
+
return {
|
|
112
|
+
queued: this.queued,
|
|
113
|
+
consecutiveTurns: this.consecutiveTurns,
|
|
114
|
+
unchangedTurns: this.unchangedTurns,
|
|
115
|
+
...(this.pausedReason ? { pausedReason: this.pausedReason } : {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private pause(reason: string): ActiveTaskContinuationDecision {
|
|
120
|
+
this.pausedReason = reason;
|
|
121
|
+
return { action: "pause", reason };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private resetProgress(): void {
|
|
125
|
+
this.queued = false;
|
|
126
|
+
this.consecutiveTurns = 0;
|
|
127
|
+
this.unchangedTurns = 0;
|
|
128
|
+
this.lastFingerprint = undefined;
|
|
129
|
+
this.pausedReason = undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { SEED_RELATIONS, type Artifact, type OperationName } from "@danypops/papyrus";
|
|
5
|
+
import type { StatusPresentation } from "./artifact-status-presentation.ts";
|
|
6
|
+
import { artifactDetailsText } from "./artifact-detail-format.ts";
|
|
7
|
+
import { showArtifactDetailView } from "./artifact-detail-view.ts";
|
|
8
|
+
import { callService } from "./service-client.ts";
|
|
9
|
+
|
|
10
|
+
export { artifactDetailsText } from "./artifact-detail-format.ts";
|
|
11
|
+
|
|
12
|
+
const BROWSER_QUERY_LIMIT = 500;
|
|
13
|
+
const BROWSER_VISIBLE_ROWS = 20;
|
|
14
|
+
const DETAIL_GRAPH_DEPTH = 4;
|
|
15
|
+
const DETAIL_GRAPH_NODES = 100;
|
|
16
|
+
|
|
17
|
+
export interface ArtifactBrowserConfig {
|
|
18
|
+
kind: string;
|
|
19
|
+
title: string;
|
|
20
|
+
statusOrder: string[];
|
|
21
|
+
presentation: Record<string, StatusPresentation>;
|
|
22
|
+
listOperation?: OperationName;
|
|
23
|
+
listInput?: Record<string, unknown>;
|
|
24
|
+
rowMeta(row: Artifact, theme: Theme): string;
|
|
25
|
+
actions(row: Artifact): string[];
|
|
26
|
+
handleAction(choice: string, row: Artifact, ctx: ExtensionCommandContext): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function filterArtifactRows(rows: Artifact[], query: string): Artifact[] {
|
|
30
|
+
const needle = query.trim().toLowerCase();
|
|
31
|
+
if (!needle) return [...rows];
|
|
32
|
+
return rows.filter((row) => [
|
|
33
|
+
row.id,
|
|
34
|
+
row.title,
|
|
35
|
+
row.body,
|
|
36
|
+
row.subtype,
|
|
37
|
+
row.labels.join(" "),
|
|
38
|
+
JSON.stringify(row.extra),
|
|
39
|
+
].some((value) => value.toLowerCase().includes(needle)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function statusSummary(rows: Artifact[], order: string[]): Array<{ status: string; count: number }> {
|
|
43
|
+
const counts = new Map<string, number>();
|
|
44
|
+
for (const row of rows) counts.set(row.status, (counts.get(row.status) ?? 0) + 1);
|
|
45
|
+
return order.filter((status) => counts.has(status)).map((status) => ({ status, count: counts.get(status)! }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function loadArtifacts(config: ArtifactBrowserConfig): Promise<Artifact[]> {
|
|
49
|
+
return callService<Record<string, unknown>, Artifact[]>(config.listOperation ?? "artifact.query", {
|
|
50
|
+
kind: config.kind,
|
|
51
|
+
limit: BROWSER_QUERY_LIMIT,
|
|
52
|
+
...(config.listInput ?? {}),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ArtifactDetailLoader = (
|
|
57
|
+
operation: OperationName,
|
|
58
|
+
input: Record<string, unknown>,
|
|
59
|
+
) => Promise<Artifact | null>;
|
|
60
|
+
|
|
61
|
+
const loadArtifactDetails: ArtifactDetailLoader = (operation, input) =>
|
|
62
|
+
callService<Record<string, unknown>, Artifact | null>(operation, input);
|
|
63
|
+
|
|
64
|
+
export async function showArtifactDetails(
|
|
65
|
+
ctx: ExtensionCommandContext,
|
|
66
|
+
id: string,
|
|
67
|
+
operation: OperationName = "artifact.show",
|
|
68
|
+
input: Record<string, unknown> = {},
|
|
69
|
+
load: ArtifactDetailLoader = loadArtifactDetails,
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
try {
|
|
72
|
+
const artifact = await load(operation, {
|
|
73
|
+
id,
|
|
74
|
+
...input,
|
|
75
|
+
tree: true,
|
|
76
|
+
depth: DETAIL_GRAPH_DEPTH,
|
|
77
|
+
max_nodes: DETAIL_GRAPH_NODES,
|
|
78
|
+
});
|
|
79
|
+
if (!artifact) { ctx.ui.notify("Artifact not found", "error"); return; }
|
|
80
|
+
await showArtifactDetailView(ctx, artifact);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
ctx.ui.notify(`Show details failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function linkFromArtifact(ctx: ExtensionCommandContext, fromId: string, fixedRelation?: string): Promise<void> {
|
|
87
|
+
const target = await ctx.ui.input("Target artifact id:", "");
|
|
88
|
+
if (!target) return;
|
|
89
|
+
const relation = fixedRelation ?? await ctx.ui.select("Relation", [...SEED_RELATIONS]);
|
|
90
|
+
if (!relation) return;
|
|
91
|
+
try {
|
|
92
|
+
await callService("graph.link", { from: fromId, relation, to: target });
|
|
93
|
+
ctx.ui.notify(`Artifacts linked via ${relation}`, "info");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
ctx.ui.notify(`Link failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function setArtifactStatus(ctx: ExtensionCommandContext, id: string, status: string): Promise<void> {
|
|
100
|
+
try {
|
|
101
|
+
const artifact = await callService<Record<string, unknown>, Artifact | null>("graph.status", { id, status });
|
|
102
|
+
if (!artifact) { ctx.ui.notify("Artifact not found", "error"); return; }
|
|
103
|
+
ctx.ui.notify(`${artifact.title} → [${artifact.status}]`, "info");
|
|
104
|
+
} catch (error) {
|
|
105
|
+
ctx.ui.notify(`Status change failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function showArtifactBrowser(ctx: ExtensionCommandContext, config: ArtifactBrowserConfig): Promise<void> {
|
|
110
|
+
if (!ctx.hasUI) {
|
|
111
|
+
ctx.ui.notify(`/${config.kind}s requires interactive mode`, "warning");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
let rows = await loadArtifacts(config);
|
|
115
|
+
if (rows.length === 0) {
|
|
116
|
+
ctx.ui.notify(`No ${config.kind} artifacts yet. Ask the agent to create one.`, "info");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (;;) {
|
|
121
|
+
const selected = await renderPanel(ctx, rows, config);
|
|
122
|
+
if (selected === undefined) return;
|
|
123
|
+
if (selected === "refresh") { rows = await loadArtifacts(config); continue; }
|
|
124
|
+
const choices = config.actions(selected);
|
|
125
|
+
const choice = await ctx.ui.select(selected.title, choices);
|
|
126
|
+
if (!choice) continue;
|
|
127
|
+
await config.handleAction(choice, selected, ctx);
|
|
128
|
+
rows = await loadArtifacts(config);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderPanel(
|
|
133
|
+
ctx: ExtensionCommandContext,
|
|
134
|
+
rows: Artifact[],
|
|
135
|
+
config: ArtifactBrowserConfig,
|
|
136
|
+
): Promise<Artifact | "refresh" | undefined> {
|
|
137
|
+
return ctx.ui.custom<Artifact | "refresh" | undefined>((tui, theme, _keybindings, done) => {
|
|
138
|
+
const input = new Input();
|
|
139
|
+
let searchActive = false;
|
|
140
|
+
let filtered = [...rows];
|
|
141
|
+
let selectedIndex = 0;
|
|
142
|
+
|
|
143
|
+
function applyFilter(): void {
|
|
144
|
+
filtered = filterArtifactRows(rows, input.getValue());
|
|
145
|
+
selectedIndex = 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const header = {
|
|
149
|
+
invalidate() {},
|
|
150
|
+
render(width: number): string[] {
|
|
151
|
+
const title = theme.bold(config.title);
|
|
152
|
+
const hint = searchActive
|
|
153
|
+
? rawKeyHint("esc", "clear")
|
|
154
|
+
: [rawKeyHint("enter", "actions"), rawKeyHint("/", "filter"), rawKeyHint("r", "refresh"), rawKeyHint("esc", "close")]
|
|
155
|
+
.join(theme.fg("muted", " · "));
|
|
156
|
+
const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
|
|
157
|
+
const summary = statusSummary(rows, config.statusOrder)
|
|
158
|
+
.map(({ status, count }) => {
|
|
159
|
+
const presentation = config.presentation[status];
|
|
160
|
+
const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : status;
|
|
161
|
+
return `${glyph} ${count} ${status}`;
|
|
162
|
+
})
|
|
163
|
+
.join(", ");
|
|
164
|
+
return [
|
|
165
|
+
truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""),
|
|
166
|
+
truncateToWidth(theme.fg("muted", summary), width, ""),
|
|
167
|
+
];
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const list = {
|
|
172
|
+
invalidate() {},
|
|
173
|
+
render(width: number): string[] {
|
|
174
|
+
const lines = searchActive ? [...input.render(width), ""] : [""];
|
|
175
|
+
if (filtered.length === 0) return [...lines, theme.fg("muted", ` No matching ${config.kind}s`)];
|
|
176
|
+
const start = Math.max(0, Math.min(selectedIndex - Math.floor(BROWSER_VISIBLE_ROWS / 2), filtered.length - BROWSER_VISIBLE_ROWS));
|
|
177
|
+
const end = Math.min(start + BROWSER_VISIBLE_ROWS, filtered.length);
|
|
178
|
+
for (let index = start; index < end; index++) {
|
|
179
|
+
const row = filtered[index]!;
|
|
180
|
+
const selected = index === selectedIndex;
|
|
181
|
+
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
182
|
+
const presentation = config.presentation[row.status];
|
|
183
|
+
const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : "?";
|
|
184
|
+
const title = selected ? theme.bold(row.title) : row.title;
|
|
185
|
+
const meta = config.rowMeta(row, theme);
|
|
186
|
+
lines.push(truncateToWidth(`${cursor} ${glyph} ${title}${meta ? `${theme.fg("dim", " · ")}${meta}` : ""}`, width, ""));
|
|
187
|
+
}
|
|
188
|
+
lines.push(theme.fg("muted", ` ${selectedIndex + 1}/${filtered.length} ${config.kind}`));
|
|
189
|
+
return lines;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const container = new Container();
|
|
194
|
+
container.addChild(new Spacer(1));
|
|
195
|
+
container.addChild(new DynamicBorder());
|
|
196
|
+
container.addChild(new Spacer(1));
|
|
197
|
+
container.addChild(header);
|
|
198
|
+
container.addChild(new Spacer(1));
|
|
199
|
+
container.addChild(list);
|
|
200
|
+
container.addChild(new Spacer(1));
|
|
201
|
+
container.addChild(new DynamicBorder());
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
render: (width: number) => container.render(width),
|
|
205
|
+
invalidate: () => container.invalidate(),
|
|
206
|
+
handleInput(data: string) {
|
|
207
|
+
if (searchActive) {
|
|
208
|
+
if (data === "\x1b") { searchActive = false; applyFilter(); }
|
|
209
|
+
else if (data === "\r") searchActive = false;
|
|
210
|
+
else { input.handleInput(data); applyFilter(); }
|
|
211
|
+
tui.requestRender();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
switch (data) {
|
|
215
|
+
case "\x1b[A": selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(filtered.length, 1); break;
|
|
216
|
+
case "\x1b[B": selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1); break;
|
|
217
|
+
case "/": searchActive = true; break;
|
|
218
|
+
case "r": done("refresh"); return;
|
|
219
|
+
case "\r": { const row = filtered[selectedIndex]; if (row) done(row); return; }
|
|
220
|
+
case "\x1b": done(undefined); return;
|
|
221
|
+
default: return;
|
|
222
|
+
}
|
|
223
|
+
tui.requestRender();
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Artifact } from "@danypops/papyrus";
|
|
2
|
+
import { formatMetadata } from "./artifact-format.ts";
|
|
3
|
+
|
|
4
|
+
export interface ArtifactDetailContent {
|
|
5
|
+
title: string;
|
|
6
|
+
identity: string;
|
|
7
|
+
body: string;
|
|
8
|
+
labels: string[];
|
|
9
|
+
metadata: string[];
|
|
10
|
+
relationships: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function artifactDetailContent(artifact: Artifact, relationshipLines: string[] = []): ArtifactDetailContent {
|
|
14
|
+
return {
|
|
15
|
+
title: artifact.title,
|
|
16
|
+
identity: `${artifact.id} [${artifact.kind}|${artifact.status}]${artifact.subtype ? ` · ${artifact.subtype}` : ""}`,
|
|
17
|
+
body: artifact.body || "(no body)",
|
|
18
|
+
labels: [...artifact.labels],
|
|
19
|
+
metadata: Object.keys(artifact.extra).length > 0 ? formatMetadata(artifact.extra) : [],
|
|
20
|
+
relationships: relationshipLines,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Deliberately plain, unlike ArtifactDetailViewport's TUI body (renderMarkdownBody): this path
|
|
26
|
+
* feeds ctx.ui.notify(), used outside interactive mode (RPC, piped/non-terminal callers) where
|
|
27
|
+
* ANSI escape codes are noise or corruption, not formatting. Raw Markdown source stays readable
|
|
28
|
+
* as plain text either way. relationshipLines is still upgraded (a real graph or a resolved
|
|
29
|
+
* arrow list, never raw ids) since that needs no color to read.
|
|
30
|
+
*/
|
|
31
|
+
export function artifactDetailsText(artifact: Artifact, relationshipLines: string[] = []): string {
|
|
32
|
+
const content = artifactDetailContent(artifact, relationshipLines);
|
|
33
|
+
let output = `${content.title}\n${content.identity}\n\n${content.body}`;
|
|
34
|
+
if (content.labels.length > 0) output += `\n\nLabels: ${content.labels.join(", ")}`;
|
|
35
|
+
if (content.metadata.length > 0) output += `\n\nMetadata:\n${content.metadata.map((line) => ` ${line}`).join("\n")}`;
|
|
36
|
+
if (content.relationships.length > 0) output += `\n\nRelationships:\n${content.relationships.map((line) => ` ${line}`).join("\n")}`;
|
|
37
|
+
return output;
|
|
38
|
+
}
|