@danypops/papyrus 0.11.4 → 0.13.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 +16 -2
- package/extension/src/active-task-continuation.ts +6 -0
- package/extension/src/artifact-browser.ts +13 -7
- package/extension/src/artifact-status-presentation.ts +53 -0
- package/extension/src/context-budget.ts +173 -0
- package/extension/src/context-view.ts +172 -0
- package/extension/src/docs.ts +6 -5
- package/extension/src/domain-tools.ts +108 -52
- package/extension/src/index.ts +124 -38
- package/extension/src/notes.ts +16 -4
- package/extension/src/rules.ts +7 -7
- package/extension/src/skill-catalog-footprint.ts +183 -0
- package/extension/src/skills.ts +2 -3
- package/extension/src/task-focus-events.ts +57 -0
- package/extension/src/task-widget.ts +13 -1
- package/extension/src/tasks.ts +51 -15
- 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 +107 -0
- package/extension/src/tool-rendering/render-model.ts +406 -0
- package/package.json +4 -2
- package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
- package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
- package/src/adapters/sqlite-artifact-store.ts +20 -11
- package/src/adapters/sqlite-discourse-store.ts +325 -0
- package/src/adapters/sqlite-graph-projection-store.ts +41 -0
- package/src/adapters/sqlite-task-focus-store.ts +34 -15
- package/src/authority-registry.ts +115 -0
- package/src/cli.ts +904 -124
- package/src/constants.ts +77 -5
- package/src/conversation-journal-service.ts +87 -0
- package/src/db.ts +285 -33
- package/src/domain/artifact-event.ts +99 -0
- package/src/domain/conversation-journal.ts +168 -0
- package/src/domain/discourse-store.ts +142 -0
- package/src/domain/graph-projection.ts +74 -0
- package/src/domain/skill-definition.ts +57 -8
- package/src/domain/task-event.ts +4 -0
- package/src/domain-services.ts +201 -40
- package/src/graph-projection-service.ts +103 -0
- package/src/id-migration.ts +200 -0
- package/src/module-registry.ts +53 -0
- package/src/modules/docs.ts +77 -0
- package/src/modules/graph-projection.ts +82 -0
- package/src/modules/notes.ts +76 -0
- package/src/modules/rules.ts +81 -0
- package/src/modules/skills.ts +113 -0
- package/src/modules/tasks.ts +164 -0
- package/src/ops.ts +142 -15
- package/src/ports/artifact-scope-store.ts +20 -0
- package/src/ports/artifact-store.ts +10 -5
- package/src/ports/conversation-journal-store.ts +17 -0
- package/src/ports/graph-projection-store.ts +15 -0
- package/src/ports/task-focus-store.ts +62 -20
- package/src/service.ts +218 -223
- package/src/skill-execution.ts +169 -75
- package/src/task-service.ts +70 -38
package/extension/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
TASK_DRIVER_MAX_TURNS,
|
|
16
16
|
TASK_DRIVER_MAX_UNCHANGED_TURNS,
|
|
17
17
|
PAPYRUS_CONTEXT_INJECTION_CHANNEL,
|
|
18
|
+
CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
|
|
18
19
|
} from "../../src/constants.ts";
|
|
19
20
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
20
21
|
import type { GateResult } from "../../src/domain/gate.ts";
|
|
@@ -26,9 +27,21 @@ import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanI
|
|
|
26
27
|
import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
|
|
27
28
|
import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
|
|
28
29
|
import { buildContextInjection } from "./context-injection-telemetry.ts";
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
import { buildContextBreakdown, computeContextBudget, computeRuleBudget } from "./context-budget.ts";
|
|
31
|
+
import { showContextView } from "./context-view.ts";
|
|
32
|
+
import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
|
|
33
|
+
import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
|
|
34
|
+
import {
|
|
35
|
+
createArtifactDetails,
|
|
36
|
+
createArtifactListDetails,
|
|
37
|
+
createGraphDetails,
|
|
38
|
+
createModelContent,
|
|
39
|
+
createPreviewDetails,
|
|
40
|
+
} from "./tool-rendering/render-model.ts";
|
|
41
|
+
|
|
42
|
+
function text(value: string, details: unknown = {}) {
|
|
43
|
+
const modelContent = createModelContent(value);
|
|
44
|
+
return { content: [{ type: "text" as const, text: modelContent.text }], details };
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
// ---------------------------------------------------------------------------
|
|
@@ -47,7 +60,12 @@ export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjec
|
|
|
47
60
|
const focus = row.active ? theme.fg("accent", row.focusStatus === "paused" ? "Ⅱ" : "▶") : " ";
|
|
48
61
|
const presentation = TASK_STATUS_PRESENTATION[row.task.status as TaskStatus];
|
|
49
62
|
const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : theme.fg("muted", "?");
|
|
50
|
-
|
|
63
|
+
// Task containment is a DAG: a task with more than one parent is only ever shown once in
|
|
64
|
+
// this bounded tree (under whichever parent this walk reached first). Flag it rather than
|
|
65
|
+
// silently hiding that it also lives elsewhere -- see /tasks graph's composition view for
|
|
66
|
+
// the full multi-parent picture.
|
|
67
|
+
const multiParent = row.parentCount > 1 ? theme.fg("dim", ` ⥂${row.parentCount}`) : "";
|
|
68
|
+
lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}${multiParent}`, width, "…"));
|
|
51
69
|
}
|
|
52
70
|
return lines;
|
|
53
71
|
}
|
|
@@ -58,6 +76,7 @@ class TaskOverlay {
|
|
|
58
76
|
private tui: any | undefined;
|
|
59
77
|
private snapshot: TaskGraph = { nodes: [], rootIds: [] };
|
|
60
78
|
private projectRoot: string | undefined;
|
|
79
|
+
private sessionId: string | undefined;
|
|
61
80
|
|
|
62
81
|
setUI(ctx: ExtensionUIContext): void {
|
|
63
82
|
if (ctx !== this.uiCtx) {
|
|
@@ -68,11 +87,14 @@ class TaskOverlay {
|
|
|
68
87
|
}
|
|
69
88
|
|
|
70
89
|
setProjectRoot(projectRoot: string): void { this.projectRoot = projectRoot; }
|
|
90
|
+
// Scopes the widget's "active" glyph to this Pi session's own Focus, so a second
|
|
91
|
+
// concurrent agent's focused task never shows as active in this session's widget.
|
|
92
|
+
setSessionId(sessionId: string): void { this.sessionId = sessionId; }
|
|
71
93
|
|
|
72
94
|
async refresh(): Promise<void> {
|
|
73
95
|
if (!this.projectRoot) return;
|
|
74
96
|
try {
|
|
75
|
-
this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500, project_root: this.projectRoot });
|
|
97
|
+
this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500, project_root: this.projectRoot, session_id: this.sessionId });
|
|
76
98
|
} catch {
|
|
77
99
|
this.snapshot = { nodes: [], rootIds: [] };
|
|
78
100
|
}
|
|
@@ -124,6 +146,7 @@ class TaskOverlay {
|
|
|
124
146
|
this.tui = undefined;
|
|
125
147
|
this.uiCtx = undefined;
|
|
126
148
|
this.projectRoot = undefined;
|
|
149
|
+
this.sessionId = undefined;
|
|
127
150
|
}
|
|
128
151
|
}
|
|
129
152
|
|
|
@@ -132,6 +155,7 @@ class TaskOverlay {
|
|
|
132
155
|
// ---------------------------------------------------------------------------
|
|
133
156
|
|
|
134
157
|
export default async function (pi: ExtensionAPI) {
|
|
158
|
+
setTaskFocusEventBus(pi);
|
|
135
159
|
registerDomainTools(pi);
|
|
136
160
|
let contextInjectionSequence = 0;
|
|
137
161
|
const contextInjectionProducerId = randomUUID();
|
|
@@ -144,7 +168,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
144
168
|
const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
|
|
145
169
|
if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
|
|
146
170
|
try {
|
|
147
|
-
const
|
|
171
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
172
|
+
const active = await callService<Record<string, unknown>, ActiveTaskMarker | null>("tasks.active", { project_root: ctx.cwd, session_id: sessionId });
|
|
148
173
|
const decision = taskContinuation.evaluate(active, {
|
|
149
174
|
idle: ctx.isIdle(),
|
|
150
175
|
pendingMessages: ctx.hasPendingMessages(),
|
|
@@ -156,11 +181,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
156
181
|
display: false,
|
|
157
182
|
}, { triggerTurn: true, deliverAs: "nextTurn" });
|
|
158
183
|
} else if (decision.action === "pause") {
|
|
159
|
-
await callService("tasks.pause", {
|
|
184
|
+
const paused = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>("tasks.pause", {
|
|
160
185
|
actor: "system",
|
|
161
186
|
source: "task-continuation",
|
|
162
187
|
reason: automaticPauseReason(decision.reason),
|
|
188
|
+
session_id: sessionId,
|
|
163
189
|
});
|
|
190
|
+
emitTaskFocusEvent({ taskId: paused.artifact.id, sessionId, status: "paused" });
|
|
164
191
|
if (ctx.hasUI) ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input resumes it automatically.`, "warning");
|
|
165
192
|
}
|
|
166
193
|
} catch {
|
|
@@ -192,15 +219,17 @@ export default async function (pi: ExtensionAPI) {
|
|
|
192
219
|
template_id: Type.Optional(Type.String({ description: "skill/artifact-template id whose defaults and requirements apply" })),
|
|
193
220
|
project_root: Type.Optional(Type.String({ description: "required for Tasks; defaults to Pi cwd" })),
|
|
194
221
|
}),
|
|
222
|
+
renderCall(args, theme) { return renderPapyrusToolCall("Create artifact", args, theme); },
|
|
223
|
+
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
195
224
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
196
225
|
try {
|
|
197
226
|
const a = await callService<Record<string, unknown>, Artifact>("artifact.create", {
|
|
198
227
|
...params,
|
|
199
228
|
...(params.kind === "task" ? { project_root: params.project_root ?? ctx.cwd } : {}),
|
|
200
229
|
});
|
|
201
|
-
return text(`Created ${a.id} [${a.kind}|${a.status}] ${a.title}`,
|
|
230
|
+
return text(`Created ${a.id} [${a.kind}|${a.status}] ${a.title}`, createArtifactDetails("artifact.create", a));
|
|
202
231
|
} catch (e) {
|
|
203
|
-
|
|
232
|
+
throw new Error(`papyrus_create failed: ${e instanceof Error ? e.message : e}`);
|
|
204
233
|
}
|
|
205
234
|
},
|
|
206
235
|
});
|
|
@@ -215,14 +244,16 @@ export default async function (pi: ExtensionAPI) {
|
|
|
215
244
|
text: Type.Optional(Type.String({ description: "substring across title and body" })),
|
|
216
245
|
limit: Type.Optional(Type.Number()),
|
|
217
246
|
}),
|
|
247
|
+
renderCall(args, theme) { return renderPapyrusToolCall("Query artifacts", args, theme); },
|
|
248
|
+
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
218
249
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
219
250
|
try {
|
|
220
251
|
const rows = await callService<Record<string, unknown>, Artifact[]>("artifact.query", { ...params, limit: params.limit ?? 50 });
|
|
221
|
-
if (rows.length === 0) return text("No artifacts found.");
|
|
222
|
-
const lines = rows.map((
|
|
223
|
-
return text(`${rows.length} artifact(s):\n\n${lines.join("\n")}`,
|
|
252
|
+
if (rows.length === 0) return text("No artifacts found.", createArtifactListDetails("artifact.query", rows));
|
|
253
|
+
const lines = rows.map((row, index) => `${index + 1}. ${row.id} [${row.kind}|${row.status}] ${row.title}`);
|
|
254
|
+
return text(`${rows.length} artifact(s):\n\n${lines.join("\n")}`, createArtifactListDetails("artifact.query", rows));
|
|
224
255
|
} catch (e) {
|
|
225
|
-
|
|
256
|
+
throw new Error(`papyrus_query failed: ${e instanceof Error ? e.message : e}`);
|
|
226
257
|
}
|
|
227
258
|
},
|
|
228
259
|
});
|
|
@@ -231,11 +262,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
231
262
|
name: "papyrus_graph",
|
|
232
263
|
label: "Papyrus Graph",
|
|
233
264
|
description:
|
|
234
|
-
"Link artifacts with typed edges (any kind → any kind), view subgraph, or
|
|
265
|
+
"Link artifacts with typed edges (any kind → any kind), view subgraph, update status, or read the mutation event log. " +
|
|
235
266
|
"RELATIONS: references, implements, follows, depends_on, documents, blocks, supersedes, relates_to, gates, triggers, contains, part_of. " +
|
|
236
|
-
"ACTIONS: link (from+relation+to),
|
|
267
|
+
"ACTIONS: link (from+relation+to), unlink (from+relation+to — idempotent, no error if already absent; for Task depends_on/contains prefer the tasks tool's undepend/uncontain), " +
|
|
268
|
+
"tree (id → bounded BFS subgraph), status (id+status → lifecycle), " +
|
|
269
|
+
"history (who did what, when — requires id, actor, or session_id).",
|
|
237
270
|
parameters: Type.Object({
|
|
238
|
-
action: Type.String({ description: "link | tree | status" }),
|
|
271
|
+
action: Type.String({ description: "link | unlink | tree | status | history" }),
|
|
239
272
|
from: Type.Optional(Type.String()),
|
|
240
273
|
relation: Type.Optional(Type.String()),
|
|
241
274
|
to: Type.Optional(Type.String()),
|
|
@@ -243,37 +276,57 @@ export default async function (pi: ExtensionAPI) {
|
|
|
243
276
|
status: Type.Optional(Type.String()),
|
|
244
277
|
depth: Type.Optional(Type.Number({ description: "tree traversal depth; bounded by a hard ceiling" })),
|
|
245
278
|
max_nodes: Type.Optional(Type.Number({ description: "tree node cap; bounded by a hard ceiling" })),
|
|
279
|
+
actor: Type.Optional(Type.String({ description: "history: filter by actor" })),
|
|
280
|
+
session_id: Type.Optional(Type.String({ description: "history: filter by session" })),
|
|
281
|
+
since: Type.Optional(Type.String({ description: "history: RFC3339 lower bound" })),
|
|
282
|
+
limit: Type.Optional(Type.Number({ description: "history: bounded page size" })),
|
|
246
283
|
}),
|
|
284
|
+
renderCall(args, theme) { return renderPapyrusToolCall("Artifact graph", args, theme); },
|
|
285
|
+
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
247
286
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
248
287
|
try {
|
|
249
288
|
if (params.action === "link") {
|
|
250
289
|
await callService("graph.link", { from: params.from!, relation: params.relation!, to: params.to! });
|
|
251
|
-
|
|
290
|
+
const output = `Linked ${params.from} --${params.relation}--> ${params.to}`;
|
|
291
|
+
return text(output, createPreviewDetails("graph.link", "Artifact relationship", output));
|
|
292
|
+
}
|
|
293
|
+
if (params.action === "unlink") {
|
|
294
|
+
const result = await callService<Record<string, unknown>, { removed: boolean }>("graph.unlink", { from: params.from!, relation: params.relation!, to: params.to! });
|
|
295
|
+
const output = result.removed ? `Unlinked ${params.from} --${params.relation}--> ${params.to}` : `No such relationship: ${params.from} --${params.relation}--> ${params.to}`;
|
|
296
|
+
return text(output, createPreviewDetails("graph.unlink", "Artifact relationship", output));
|
|
252
297
|
}
|
|
253
298
|
if (params.action === "tree") {
|
|
254
299
|
const root = params.id ?? params.from;
|
|
255
|
-
if (!root)
|
|
300
|
+
if (!root) throw new Error("missing id for tree");
|
|
256
301
|
const a = await callService<Record<string, unknown>, Artifact | null>("graph.tree", {
|
|
257
302
|
id: root,
|
|
258
303
|
depth: params.depth,
|
|
259
304
|
max_nodes: params.max_nodes,
|
|
260
305
|
});
|
|
261
|
-
if (!a)
|
|
262
|
-
const edges =
|
|
263
|
-
if (edges.length === 0) return text(`${a.title} — no edges
|
|
306
|
+
if (!a) throw new Error(`artifact ${root} not found`);
|
|
307
|
+
const edges = a.edges ?? [];
|
|
308
|
+
if (edges.length === 0) return text(`${a.title} — no edges`, createGraphDetails("graph.tree", [a], []));
|
|
264
309
|
return text(
|
|
265
|
-
`Subgraph from ${a.title} (${edges.length} edges):\n\n${edges.map((
|
|
266
|
-
|
|
310
|
+
`Subgraph from ${a.title} (${edges.length} edges):\n\n${edges.map((edge: any) => ` ${edge.from} --${edge.relation}--> ${edge.to}`).join("\n")}`,
|
|
311
|
+
createGraphDetails("graph.tree", [a], edges),
|
|
267
312
|
);
|
|
268
313
|
}
|
|
269
314
|
if (params.action === "status") {
|
|
270
315
|
const a = await callService<Record<string, unknown>, Artifact | null>("graph.status", { id: params.id!, status: params.status! });
|
|
271
|
-
if (!a)
|
|
272
|
-
return text(`Updated ${a.id} → [${a.status}]`,
|
|
316
|
+
if (!a) throw new Error(`artifact ${params.id} not found`);
|
|
317
|
+
return text(`Updated ${a.id} → [${a.status}]`, createArtifactDetails("graph.status", a));
|
|
273
318
|
}
|
|
274
|
-
|
|
319
|
+
if (params.action === "history") {
|
|
320
|
+
const page = await callService<Record<string, unknown>, { events: Array<Record<string, unknown>> }>("graph.history", {
|
|
321
|
+
id: params.id, actor: params.actor, session_id: params.session_id, since: params.since, limit: params.limit,
|
|
322
|
+
});
|
|
323
|
+
if (page.events.length === 0) return text("No recorded events.", createPreviewDetails("graph.history", "Mutation event log", "No recorded events."));
|
|
324
|
+
const output = page.events.map((event) => `${event["occurredAt"]} ${event["artifactId"]} ${event["type"]} · ${event["actor"]}/${event["source"]}`).join("\n");
|
|
325
|
+
return text(output, createPreviewDetails("graph.history", "Mutation event log", output));
|
|
326
|
+
}
|
|
327
|
+
throw new Error(`unknown action: ${params.action}; use link, tree, status, or history`);
|
|
275
328
|
} catch (e) {
|
|
276
|
-
|
|
329
|
+
throw new Error(`papyrus_graph failed: ${e instanceof Error ? e.message : e}`);
|
|
277
330
|
}
|
|
278
331
|
},
|
|
279
332
|
});
|
|
@@ -288,6 +341,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
288
341
|
depth: Type.Optional(Type.Number({ description: "edge traversal depth" })),
|
|
289
342
|
max_nodes: Type.Optional(Type.Number({ description: "maximum traversed nodes" })),
|
|
290
343
|
}),
|
|
344
|
+
renderCall(args, theme) { return renderPapyrusToolCall("Show artifact", args, theme); },
|
|
345
|
+
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
291
346
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
292
347
|
try {
|
|
293
348
|
const a = await callService<Record<string, unknown>, Artifact | null>("artifact.show", {
|
|
@@ -296,21 +351,21 @@ export default async function (pi: ExtensionAPI) {
|
|
|
296
351
|
depth: params.depth,
|
|
297
352
|
max_nodes: params.max_nodes,
|
|
298
353
|
});
|
|
299
|
-
if (!a)
|
|
354
|
+
if (!a) throw new Error(`artifact ${params.id} not found`);
|
|
300
355
|
let out = `${a.id} [${a.kind}|${a.status}]\n${a.title}\n\n${a.body}`;
|
|
301
356
|
if (Object.keys(a.extra).length > 0) {
|
|
302
357
|
out += `\n\nMetadata:\n${formatMetadata(a.extra).map((line) => ` ${line}`).join("\n")}`;
|
|
303
358
|
}
|
|
304
|
-
if (
|
|
305
|
-
out += `\n\nEdges:\n${
|
|
359
|
+
if (a.edges?.length) {
|
|
360
|
+
out += `\n\nEdges:\n${a.edges.map((edge) => ` ${edge.from} --${edge.relation}--> ${edge.to}`).join("\n")}`;
|
|
306
361
|
}
|
|
307
362
|
if (params.run_gates) {
|
|
308
363
|
const results = await callService<Record<string, unknown>, GateResult[]>("gates.run", { id: params.id });
|
|
309
|
-
out += `\n\nGates:\n${results.map((
|
|
364
|
+
out += `\n\nGates:\n${results.map((gate) => ` ${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n")}`;
|
|
310
365
|
}
|
|
311
|
-
return text(out,
|
|
366
|
+
return text(out, createArtifactDetails("artifact.show", a));
|
|
312
367
|
} catch (e) {
|
|
313
|
-
|
|
368
|
+
throw new Error(`papyrus_show failed: ${e instanceof Error ? e.message : e}`);
|
|
314
369
|
}
|
|
315
370
|
},
|
|
316
371
|
});
|
|
@@ -331,6 +386,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
331
386
|
description: "Browse and manage Papyrus tasks (interactive)",
|
|
332
387
|
handler: async (_args, ctx) => {
|
|
333
388
|
overlay?.setProjectRoot(ctx.cwd);
|
|
389
|
+
overlay?.setSessionId(ctx.sessionManager.getSessionId());
|
|
334
390
|
await tasksModule.showTasks(ctx);
|
|
335
391
|
await overlay?.refresh();
|
|
336
392
|
},
|
|
@@ -355,6 +411,31 @@ export default async function (pi: ExtensionAPI) {
|
|
|
355
411
|
description: "Browse and invoke Papyrus skills and templates (interactive)",
|
|
356
412
|
handler: async (_args, ctx) => { await skillsModule.showSkills(ctx); },
|
|
357
413
|
});
|
|
414
|
+
pi.registerCommand("context", {
|
|
415
|
+
description: "Structured, per-segment breakdown of the context window: real usage against the model's window, drilling into Papyrus Rules and the Pi-native skill catalog",
|
|
416
|
+
handler: async (_args, ctx) => {
|
|
417
|
+
try {
|
|
418
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
419
|
+
const [rules, taskSummary] = await Promise.all([
|
|
420
|
+
callService<Record<string, unknown>, Array<Pick<Artifact, "id" | "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
|
|
421
|
+
callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId }),
|
|
422
|
+
]);
|
|
423
|
+
const { skills } = computeContextBudget(rules, ctx.cwd);
|
|
424
|
+
const ruleBudget = computeRuleBudget(rules);
|
|
425
|
+
const usage = ctx.getContextUsage?.();
|
|
426
|
+
const breakdown = buildContextBreakdown({
|
|
427
|
+
totalTokens: usage?.tokens ?? null,
|
|
428
|
+
contextWindow: ctx.model?.contextWindow ?? null,
|
|
429
|
+
ruleBudget,
|
|
430
|
+
taskEstimatedTokens: taskSummary ? Math.ceil(taskSummary.length / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) : 0,
|
|
431
|
+
skills,
|
|
432
|
+
});
|
|
433
|
+
await showContextView(ctx, breakdown, ruleBudget);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
ctx.ui.notify(`Context breakdown failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
});
|
|
358
439
|
|
|
359
440
|
// ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
|
|
360
441
|
|
|
@@ -363,9 +444,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
363
444
|
overlay ??= new TaskOverlay();
|
|
364
445
|
overlay.setUI(ctx.ui);
|
|
365
446
|
overlay.setProjectRoot(ctx.cwd);
|
|
447
|
+
overlay.setSessionId(ctx.sessionManager.getSessionId());
|
|
366
448
|
await overlay.refresh();
|
|
367
449
|
});
|
|
368
450
|
|
|
451
|
+
pi.on("session_before_compact", () => { taskContinuation.onCompaction(); });
|
|
369
452
|
pi.on("session_compact", async () => { await overlay?.refresh(); });
|
|
370
453
|
pi.on("session_tree", async () => { await overlay?.refresh(); });
|
|
371
454
|
pi.on("session_shutdown", async () => { overlay?.dispose(); overlay = undefined; });
|
|
@@ -381,13 +464,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
381
464
|
// agent_settled is intentionally later than agent_end: Pi guarantees that
|
|
382
465
|
// retry, compaction retry, and queued follow-up processing have finished.
|
|
383
466
|
|
|
384
|
-
pi.on("input", async (event) => {
|
|
467
|
+
pi.on("input", async (event, ctx) => {
|
|
385
468
|
if (event.source === "extension") return;
|
|
386
469
|
taskContinuation.onHumanInput();
|
|
387
470
|
try {
|
|
388
|
-
const
|
|
471
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
472
|
+
const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string; pauseReason?: string } | null>("tasks.focused", { session_id: sessionId });
|
|
389
473
|
if (focus && shouldResumeFocusOnHumanInput(focus.status, focus.pauseReason)) {
|
|
390
|
-
await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation" });
|
|
474
|
+
await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation", session_id: sessionId });
|
|
475
|
+
emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId, status: "unpaused" });
|
|
391
476
|
}
|
|
392
477
|
} catch {
|
|
393
478
|
// The daemon may be unavailable during startup, reload, or shutdown.
|
|
@@ -402,9 +487,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
402
487
|
|
|
403
488
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
404
489
|
try {
|
|
490
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
405
491
|
const [rules, summary] = await Promise.all([
|
|
406
|
-
callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd }),
|
|
407
|
-
callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd }),
|
|
492
|
+
callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
|
|
493
|
+
callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId }),
|
|
408
494
|
]);
|
|
409
495
|
const injection = buildContextInjection({
|
|
410
496
|
basePrompt: event.systemPrompt ?? "",
|
package/extension/src/notes.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { NOTE_LIST_MAX_LIMIT } from "../../src/constants.ts";
|
|
2
3
|
import { NOTE_DISPOSITIONS } from "../../src/note-service.ts";
|
|
3
4
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
4
5
|
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
6
|
+
import { NOTE_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
5
7
|
import { callService } from "./service-client.ts";
|
|
6
8
|
|
|
7
|
-
const NOTE_GLYPHS: Record<string, string> = { draft: "○", active: "●", archived: "■" };
|
|
8
|
-
|
|
9
9
|
export function noteRowMeta(note: Artifact): string {
|
|
10
10
|
const history = Array.isArray(note.extra["noteHistory"]) ? note.extra["noteHistory"].length : 0;
|
|
11
11
|
return `${history} event${history === 1 ? "" : "s"}`;
|
|
@@ -17,6 +17,18 @@ export function noteCaptureInput(request: string, projectRoot: string): Record<s
|
|
|
17
17
|
return { body, project_root: projectRoot, actor: "human", source: "note-command" };
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The generic artifact browser (extension/src/artifact-browser.ts) requests a fixed 500-row
|
|
22
|
+
* page by default, but notes.list enforces its own tighter NOTE_LIST_MAX_LIMIT (200) — an
|
|
23
|
+
* unqualified /notes call exceeded that bound and the browser surfaced the daemon's rejection
|
|
24
|
+
* as an opaque extension error instead of ever rendering. Passing an explicit limit here that
|
|
25
|
+
* respects the Notes-specific bound is the fix; the generic browser's default stays as-is
|
|
26
|
+
* since no other kind's list operation has a bound below 500.
|
|
27
|
+
*/
|
|
28
|
+
export function noteListInput(projectRoot: string): Record<string, unknown> {
|
|
29
|
+
return { project_root: projectRoot, limit: NOTE_LIST_MAX_LIMIT };
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
export async function captureNote(request: string, ctx: ExtensionCommandContext): Promise<Artifact | null> {
|
|
21
33
|
const input = noteCaptureInput(request, ctx.cwd);
|
|
22
34
|
if (!input) {
|
|
@@ -38,9 +50,9 @@ export async function showNotes(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
38
50
|
kind: "note",
|
|
39
51
|
title: "Notes inbox",
|
|
40
52
|
listOperation: "notes.list",
|
|
41
|
-
listInput:
|
|
53
|
+
listInput: noteListInput(ctx.cwd),
|
|
42
54
|
statusOrder: ["draft", "active", "archived"],
|
|
43
|
-
|
|
55
|
+
presentation: NOTE_STATUS_PRESENTATION,
|
|
44
56
|
rowMeta: noteRowMeta,
|
|
45
57
|
actions: (note) => [
|
|
46
58
|
"Show details",
|
package/extension/src/rules.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
3
3
|
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
4
|
+
import { RULE_STATUS_PRESENTATION, severityColor } from "./artifact-status-presentation.ts";
|
|
4
5
|
import { callService } from "./service-client.ts";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const severity = typeof rule.extra["severity"] === "string" ? rule.extra["severity"].toUpperCase() : "INFO";
|
|
7
|
+
export function ruleRowMeta(rule: Artifact, theme: Theme): string {
|
|
8
|
+
const severity = typeof rule.extra["severity"] === "string" ? rule.extra["severity"] : "info";
|
|
9
|
+
const severityText = theme.fg(severityColor(severity), severity.toUpperCase());
|
|
10
10
|
const condition = typeof rule.extra["condition"] === "string" ? `when ${rule.extra["condition"]}` : "always";
|
|
11
|
-
return `${
|
|
11
|
+
return `${severityText} · ${condition}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function ruleInjectionPreview(rule: Pick<Artifact, "title" | "body" | "extra">): string {
|
|
@@ -23,7 +23,7 @@ export async function showRules(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
23
23
|
title: "Rules",
|
|
24
24
|
listOperation: "rules.list",
|
|
25
25
|
statusOrder: ["active", "deprecated"],
|
|
26
|
-
|
|
26
|
+
presentation: RULE_STATUS_PRESENTATION,
|
|
27
27
|
rowMeta: ruleRowMeta,
|
|
28
28
|
actions: (rule) => ["Show details", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
|
|
29
29
|
handleAction: async (choice, rule, commandCtx) => {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "../../src/constants.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pi-native skills (SKILL.md) carry a real, permanent context tax independent of Papyrus:
|
|
7
|
+
* per Pi's own docs, every discovered skill's name+description is injected into the system
|
|
8
|
+
* prompt unconditionally at startup (the Agent Skills spec's "catalog" tier, ~50-100 tokens
|
|
9
|
+
* per skill). This module measures that tax by replicating Pi's own documented discovery
|
|
10
|
+
* rules (docs/skills.md "Locations" section) directly against the filesystem, rather than
|
|
11
|
+
* trying to parse it back out of the assembled system prompt -- Pi does not document (and
|
|
12
|
+
* this repo must not depend on) the exact wire format it uses to inject the catalog, so
|
|
13
|
+
* re-deriving the same inputs Pi itself reads is the robust approach, not a fragile one.
|
|
14
|
+
* Package-declared skills (pi.skills in package.json / packages' own skills/ directories)
|
|
15
|
+
* are deliberately out of scope: enumerating every installed package for skill declarations
|
|
16
|
+
* is a materially larger, slower scan than reading a handful of known directories, and this
|
|
17
|
+
* tool is a budget estimate, not an exhaustive audit.
|
|
18
|
+
*/
|
|
19
|
+
export interface SkillCatalogEntry {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
location: string;
|
|
23
|
+
characters: number;
|
|
24
|
+
estimatedTokens: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SkillCatalogFootprint {
|
|
28
|
+
entries: SkillCatalogEntry[];
|
|
29
|
+
totalCharacters: number;
|
|
30
|
+
totalEstimatedTokens: number;
|
|
31
|
+
scannedDirectories: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const SKILL_SCAN_MAX_DEPTH = 6;
|
|
35
|
+
export const SKILL_SCAN_MAX_DIRECTORIES = 2000;
|
|
36
|
+
export const SKILL_SCAN_MAX_SKILLS = 500;
|
|
37
|
+
|
|
38
|
+
function unquote(value: string): string {
|
|
39
|
+
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
40
|
+
return value.slice(1, -1);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extracts `name` and `description` from a SKILL.md's YAML frontmatter, tolerating the
|
|
47
|
+
* folded (`>`) and literal (`|`) block-scalar forms real-world skills commonly use for
|
|
48
|
+
* multi-line descriptions. Deliberately not a general YAML parser -- only the two fields
|
|
49
|
+
* the Agent Skills spec requires are extracted; anything else in the frontmatter is ignored.
|
|
50
|
+
*/
|
|
51
|
+
export function parseSkillFrontmatter(content: string): { name: string; description: string } | null {
|
|
52
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
53
|
+
if (!match) return null;
|
|
54
|
+
const lines = match[1]!.split(/\r?\n/);
|
|
55
|
+
let name = "";
|
|
56
|
+
let description = "";
|
|
57
|
+
for (let index = 0; index < lines.length; index++) {
|
|
58
|
+
const line = lines[index]!;
|
|
59
|
+
const nameMatch = line.match(/^name:\s*(.*)$/);
|
|
60
|
+
if (nameMatch) {
|
|
61
|
+
name = unquote(nameMatch[1]!.trim());
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const descriptionMatch = line.match(/^description:\s*(.*)$/);
|
|
65
|
+
if (!descriptionMatch) continue;
|
|
66
|
+
const rest = descriptionMatch[1]!.trim();
|
|
67
|
+
if (rest === ">" || rest === ">-" || rest === "|" || rest === "|-") {
|
|
68
|
+
const collected: string[] = [];
|
|
69
|
+
let cursor = index + 1;
|
|
70
|
+
while (cursor < lines.length && (lines[cursor] === "" || /^\s+/.test(lines[cursor]!))) {
|
|
71
|
+
collected.push(lines[cursor]!.trim());
|
|
72
|
+
cursor++;
|
|
73
|
+
}
|
|
74
|
+
description = collected.join(rest.startsWith("|") ? "\n" : " ").trim();
|
|
75
|
+
index = cursor - 1;
|
|
76
|
+
} else {
|
|
77
|
+
description = unquote(rest);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!name || !description) return null;
|
|
81
|
+
return { name, description };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True at the filesystem root on POSIX (`/`) and Windows (`C:\`, `D:\`, ...). */
|
|
85
|
+
function isFilesystemRoot(path: string): boolean {
|
|
86
|
+
return dirname(path) === path;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Global and project skill directories per Pi's own documented discovery rules, plus any
|
|
91
|
+
* explicit paths configured in settings.json's `skills` array. Project directories are
|
|
92
|
+
* collected walking from `cwd` up to the git repository root (or filesystem root when not
|
|
93
|
+
* in a repo), matching "up to git repo root, or filesystem root when not in a repo" exactly.
|
|
94
|
+
*/
|
|
95
|
+
export function discoverSkillDirectories(homeDirectory: string, cwd: string, settingsSkills: readonly string[] = []): string[] {
|
|
96
|
+
const directories = [join(homeDirectory, ".pi", "agent", "skills"), join(homeDirectory, ".agents", "skills")];
|
|
97
|
+
let current = cwd;
|
|
98
|
+
for (let depth = 0; depth < SKILL_SCAN_MAX_DIRECTORIES; depth++) {
|
|
99
|
+
directories.push(join(current, ".pi", "skills"), join(current, ".agents", "skills"));
|
|
100
|
+
if (existsSync(join(current, ".git")) || isFilesystemRoot(current)) break;
|
|
101
|
+
current = dirname(current);
|
|
102
|
+
}
|
|
103
|
+
directories.push(...settingsSkills);
|
|
104
|
+
return [...new Set(directories)];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface ScanContext {
|
|
108
|
+
entries: SkillCatalogEntry[];
|
|
109
|
+
seenLocations: Set<string>;
|
|
110
|
+
directoriesVisited: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Root-level .md files count as individual skills only in these two locations, per Pi's docs. */
|
|
114
|
+
function allowsRootMarkdownFiles(directory: string): boolean {
|
|
115
|
+
return directory.endsWith(join(".pi", "agent", "skills")) || directory.endsWith(join(".pi", "skills"));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function recordSkillFile(context: ScanContext, path: string): void {
|
|
119
|
+
if (context.seenLocations.has(path) || context.entries.length >= SKILL_SCAN_MAX_SKILLS) return;
|
|
120
|
+
let content: string;
|
|
121
|
+
try {
|
|
122
|
+
content = readFileSync(path, "utf8");
|
|
123
|
+
} catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const parsed = parseSkillFrontmatter(content);
|
|
127
|
+
if (!parsed) return;
|
|
128
|
+
context.seenLocations.add(path);
|
|
129
|
+
const characters = parsed.name.length + parsed.description.length;
|
|
130
|
+
context.entries.push({
|
|
131
|
+
name: parsed.name,
|
|
132
|
+
description: parsed.description,
|
|
133
|
+
location: path,
|
|
134
|
+
characters,
|
|
135
|
+
estimatedTokens: Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function walk(context: ScanContext, directory: string, depth: number, allowRootMarkdown: boolean): void {
|
|
140
|
+
if (depth > SKILL_SCAN_MAX_DEPTH || context.directoriesVisited >= SKILL_SCAN_MAX_DIRECTORIES) return;
|
|
141
|
+
context.directoriesVisited++;
|
|
142
|
+
let names: string[];
|
|
143
|
+
try {
|
|
144
|
+
names = readdirSync(directory);
|
|
145
|
+
} catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
for (const name of names) {
|
|
149
|
+
if (name === "node_modules" || name === ".git") continue;
|
|
150
|
+
const path = join(directory, name);
|
|
151
|
+
let stat: ReturnType<typeof statSync>;
|
|
152
|
+
try {
|
|
153
|
+
stat = statSync(path);
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (stat.isDirectory()) {
|
|
158
|
+
const skillFile = join(path, "SKILL.md");
|
|
159
|
+
if (existsSync(skillFile)) recordSkillFile(context, skillFile);
|
|
160
|
+
else walk(context, path, depth + 1, false);
|
|
161
|
+
} else if (allowRootMarkdown && depth === 0 && name.toLowerCase().endsWith(".md")) {
|
|
162
|
+
recordSkillFile(context, path);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Bounded, best-effort scan: a missing or unreadable directory is silently skipped, not an error. */
|
|
168
|
+
export function scanSkillCatalogFootprint(directories: readonly string[]): SkillCatalogFootprint {
|
|
169
|
+
const context: ScanContext = { entries: [], seenLocations: new Set(), directoriesVisited: 0 };
|
|
170
|
+
const scanned: string[] = [];
|
|
171
|
+
for (const directory of directories) {
|
|
172
|
+
if (!existsSync(directory) || !statSync(directory).isDirectory()) continue;
|
|
173
|
+
scanned.push(directory);
|
|
174
|
+
walk(context, directory, 0, allowsRootMarkdownFiles(directory));
|
|
175
|
+
}
|
|
176
|
+
const entries = context.entries.sort((a, b) => b.characters - a.characters);
|
|
177
|
+
return {
|
|
178
|
+
entries,
|
|
179
|
+
totalCharacters: entries.reduce((sum, entry) => sum + entry.characters, 0),
|
|
180
|
+
totalEstimatedTokens: entries.reduce((sum, entry) => sum + entry.estimatedTokens, 0),
|
|
181
|
+
scannedDirectories: scanned,
|
|
182
|
+
};
|
|
183
|
+
}
|
package/extension/src/skills.ts
CHANGED
|
@@ -3,11 +3,10 @@ import type { Artifact } from "../../src/domain/artifact.ts";
|
|
|
3
3
|
import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
|
|
4
4
|
import type { TaskGraph } from "../../src/task-service.ts";
|
|
5
5
|
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
6
|
+
import { SKILL_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
6
7
|
import { callService } from "./service-client.ts";
|
|
7
8
|
import { showTaskGraph } from "./task-graph.ts";
|
|
8
9
|
|
|
9
|
-
const SKILL_GLYPHS: Record<string, string> = { active: "●", deprecated: "○" };
|
|
10
|
-
|
|
11
10
|
function strings(value: unknown): string[] {
|
|
12
11
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
13
12
|
}
|
|
@@ -73,7 +72,7 @@ export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
73
72
|
title: "Skills",
|
|
74
73
|
listOperation: "skills.list",
|
|
75
74
|
statusOrder: ["active", "deprecated"],
|
|
76
|
-
|
|
75
|
+
presentation: SKILL_STATUS_PRESENTATION,
|
|
77
76
|
rowMeta: skillRowMeta,
|
|
78
77
|
actions: (skill) => [
|
|
79
78
|
"Show details",
|