@devspecai/pi-plugin 0.0.0-stage → 0.10.3
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 +899 -0
- package/LICENSE +21 -0
- package/README.md +103 -2
- package/extensions/.gitkeep +0 -0
- package/extensions/core/activation-recovery.ts +135 -0
- package/extensions/core/active-deliveries.ts +171 -0
- package/extensions/core/active-session-plans.ts +374 -0
- package/extensions/core/adapter-overlap.ts +247 -0
- package/extensions/core/assistant-speech.ts +44 -0
- package/extensions/core/attach-context.ts +25 -0
- package/extensions/core/attachment-limits.ts +20 -0
- package/extensions/core/attachments.ts +487 -0
- package/extensions/core/auth-store.ts +91 -0
- package/extensions/core/automation-run-retry.ts +31 -0
- package/extensions/core/background-journal.ts +85 -0
- package/extensions/core/background-observer.ts +279 -0
- package/extensions/core/bond-recovery.ts +65 -0
- package/extensions/core/bond-state.ts +80 -0
- package/extensions/core/clear-conversation.ts +53 -0
- package/extensions/core/client-versions.ts +56 -0
- package/extensions/core/client.ts +704 -0
- package/extensions/core/command-offer.ts +88 -0
- package/extensions/core/command-runner.ts +62 -0
- package/extensions/core/command-turn.ts +88 -0
- package/extensions/core/commit-ledger.ts +195 -0
- package/extensions/core/commit-stamp.ts +98 -0
- package/extensions/core/config.ts +63 -0
- package/extensions/core/connection-context.ts +28 -0
- package/extensions/core/context-budget.ts +175 -0
- package/extensions/core/control-result-outbox.ts +80 -0
- package/extensions/core/control.ts +156 -0
- package/extensions/core/ctx-guard.ts +33 -0
- package/extensions/core/debug.ts +136 -0
- package/extensions/core/delivery-queue.ts +92 -0
- package/extensions/core/dispatch.ts +108 -0
- package/extensions/core/duck.ts +128 -0
- package/extensions/core/git-observer.ts +382 -0
- package/extensions/core/github-identity.ts +26 -0
- package/extensions/core/implementation-contract.ts +773 -0
- package/extensions/core/instructions.ts +40 -0
- package/extensions/core/interaction-events.ts +834 -0
- package/extensions/core/json-schema.ts +16 -0
- package/extensions/core/local-activity.ts +34 -0
- package/extensions/core/mcp-config-guard.ts +117 -0
- package/extensions/core/model-catalog.ts +76 -0
- package/extensions/core/model-surface.ts +125 -0
- package/extensions/core/notice-reply.ts +215 -0
- package/extensions/core/owner-inject.ts +148 -0
- package/extensions/core/post-session-receipt.ts +70 -0
- package/extensions/core/preempt.ts +100 -0
- package/extensions/core/project-choice.ts +145 -0
- package/extensions/core/project-scope.ts +335 -0
- package/extensions/core/project-selection-ui.ts +202 -0
- package/extensions/core/provenance-refs.ts +158 -0
- package/extensions/core/push-channel.ts +281 -0
- package/extensions/core/question-receipt-lifecycle.ts +158 -0
- package/extensions/core/recall.ts +121 -0
- package/extensions/core/remote-payload.ts +1329 -0
- package/extensions/core/remote-session-arg.ts +24 -0
- package/extensions/core/repository-context.ts +35 -0
- package/extensions/core/request-chain.ts +1244 -0
- package/extensions/core/result-projection.ts +939 -0
- package/extensions/core/result.ts +100 -0
- package/extensions/core/review-context.ts +70 -0
- package/extensions/core/review-watch.ts +94 -0
- package/extensions/core/room-access.ts +12 -0
- package/extensions/core/room-context.ts +178 -0
- package/extensions/core/room-reader.ts +308 -0
- package/extensions/core/room-store.ts +381 -0
- package/extensions/core/room-sync.ts +79 -0
- package/extensions/core/room-tools.ts +86 -0
- package/extensions/core/room-unread.ts +173 -0
- package/extensions/core/session-events.ts +62 -0
- package/extensions/core/session-polls.ts +292 -0
- package/extensions/core/settings.ts +39 -0
- package/extensions/core/status-line.ts +432 -0
- package/extensions/core/still-to-discuss.ts +124 -0
- package/extensions/core/subagent-fleet.ts +238 -0
- package/extensions/core/system-notice.ts +227 -0
- package/extensions/core/telemetry.ts +177 -0
- package/extensions/core/tool-selection.ts +72 -0
- package/extensions/core/trail-events.ts +247 -0
- package/extensions/core/trail-projection.ts +373 -0
- package/extensions/core/trail.ts +465 -0
- package/extensions/core/turn-lifecycle.ts +472 -0
- package/extensions/core/version-mismatch.ts +165 -0
- package/extensions/core/work-prompt.ts +44 -0
- package/extensions/core/work-state.ts +179 -0
- package/extensions/devspec-adapter-guard.ts +106 -0
- package/extensions/devspec-auth.ts +191 -0
- package/extensions/devspec-brand.ts +140 -0
- package/extensions/devspec-commit-ledger.ts +477 -0
- package/extensions/devspec-context.ts +146 -0
- package/extensions/devspec-memory.ts +136 -0
- package/extensions/devspec-provenance-assistance.ts +233 -0
- package/extensions/devspec-remote.ts +5152 -0
- package/extensions/devspec-tools.ts +552 -0
- package/extensions/devspec-work.ts +244 -0
- package/extensions/generated/README.md +18 -0
- package/extensions/generated/mcp-catalog.ts +15811 -0
- package/package.json +71 -4
- package/prompts/.gitkeep +0 -0
- package/runtime/commit-stamp.mjs +279 -0
- package/runtime/git-observer-shim.mjs +390 -0
- package/skills/.gitkeep +0 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, model-visible projections for structured DevSpec MCP results.
|
|
3
|
+
*
|
|
4
|
+
* Pi persists `details` for extension/session consumers, while provider adapters
|
|
5
|
+
* send tool-result `content` to the model. What the server returns reaches the
|
|
6
|
+
* model: exact reads stay lossless, a write's receipt passes every field through,
|
|
7
|
+
* an unrecognised result is shown whole, and a list shows every row the server
|
|
8
|
+
* returned. The only condensing is lossless or visibly declared — a list row is a
|
|
9
|
+
* card that names the exact read for its full record, and a receipt leaves out
|
|
10
|
+
* empty fields and values identical to the call's own arguments, and says which.
|
|
11
|
+
* There is no output budget: a field the server wants read is never dropped to
|
|
12
|
+
* save tokens (DevSpec item 5a9eaa57, convention 8958f7b1).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { disagreementBlock, reviewData } from "./review-context.ts";
|
|
16
|
+
|
|
17
|
+
export interface ProjectionInput {
|
|
18
|
+
toolName: string;
|
|
19
|
+
text: string;
|
|
20
|
+
details: unknown;
|
|
21
|
+
/** The arguments the call was sent with, when known: a receipt names fields equal to them instead of repeating them. */
|
|
22
|
+
args?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ProjectionFamily = "project" | "collection" | "help" | "ack" | "full" | "fallback";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A list card is a preview whose purpose is choosing which exact read to pay for.
|
|
29
|
+
* Its optional metadata (repositories, signals) yields to its required lines at
|
|
30
|
+
* this size and says so; the full record is always one exact read away.
|
|
31
|
+
*/
|
|
32
|
+
const CARD_BUDGET = 560;
|
|
33
|
+
const LINE_LIMIT = 240;
|
|
34
|
+
|
|
35
|
+
const COLLECTION_VERBS = new Set([
|
|
36
|
+
"list_projects",
|
|
37
|
+
"get_tasks",
|
|
38
|
+
"search_tasks",
|
|
39
|
+
"get_resources",
|
|
40
|
+
"search_resources",
|
|
41
|
+
"search_memories",
|
|
42
|
+
"get_decisions",
|
|
43
|
+
"get_conventions",
|
|
44
|
+
"get_recent_sessions",
|
|
45
|
+
"search_sessions",
|
|
46
|
+
"search_discussion_points",
|
|
47
|
+
"get_automation_runs",
|
|
48
|
+
"get_automations",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const FULL_READ_VERBS = new Set([
|
|
52
|
+
// Exact implementation/decision inputs are intentionally exempt from compact
|
|
53
|
+
// family budgets. Arbitrary truncation would not have a lossless continuation.
|
|
54
|
+
"get_task",
|
|
55
|
+
// A pull request's provenance umbrella is one exact entity read; every list in it
|
|
56
|
+
// is already bounded by the joins, and its `absent` sentences must reach the model whole.
|
|
57
|
+
"get_pull_request",
|
|
58
|
+
"get_memory",
|
|
59
|
+
"get_resource",
|
|
60
|
+
"get_testing_brief",
|
|
61
|
+
"get_task_history",
|
|
62
|
+
"get_task_siblings",
|
|
63
|
+
"get_automation",
|
|
64
|
+
"get_goals",
|
|
65
|
+
"get_project_goals",
|
|
66
|
+
"claim_work_item",
|
|
67
|
+
"get_session_transcript",
|
|
68
|
+
"get_discussion_point",
|
|
69
|
+
// A read-only consequence preview: who decides, what closes, any refusal and
|
|
70
|
+
// what is not affected. Every sentence is the decision input, so it stays whole.
|
|
71
|
+
"preview_supersession",
|
|
72
|
+
"get_project_instruction_rules",
|
|
73
|
+
"get_personal_instructions",
|
|
74
|
+
"get_workflow_rules",
|
|
75
|
+
"search_devspec_tools",
|
|
76
|
+
"validate_commit_reference",
|
|
77
|
+
// Projection does not receive call parameters, so conservatively keep both
|
|
78
|
+
// focused query/table reads and whole-schema reads lossless.
|
|
79
|
+
"get_database_schema",
|
|
80
|
+
// These mutate/claim, but their responses are read-like contracts containing
|
|
81
|
+
// briefing, permission, previews, resulting text, or mandatory instructions.
|
|
82
|
+
"create_session",
|
|
83
|
+
"claim_automation_run",
|
|
84
|
+
// Every review-queue entry is a decision waiting on a person; a dropped row
|
|
85
|
+
// reads as "nothing outstanding", so the whole queue survives projection.
|
|
86
|
+
"get_review_queue",
|
|
87
|
+
"import_instruction_rules",
|
|
88
|
+
"update_personal_instructions",
|
|
89
|
+
// Knowledge writes can return an exact preview/receipt or a new version id.
|
|
90
|
+
// Keep update_memory lossless: an acknowledgement alone would drop the
|
|
91
|
+
// preview that must be reviewed before an overrule, or its stale context.
|
|
92
|
+
"update_memory",
|
|
93
|
+
// Every connection-bound ledger mutation/read returns the authoritative
|
|
94
|
+
// revisioned object; there is no safe continuation for omitted state.
|
|
95
|
+
"manage_plan",
|
|
96
|
+
"manage_poll",
|
|
97
|
+
"manage_directed_question",
|
|
98
|
+
// Same shape: revisioned link mutations plus `list`, whose companion rows
|
|
99
|
+
// must reach the model whole — a silently dropped link reads as "not linked".
|
|
100
|
+
"manage_knowledge_links",
|
|
101
|
+
// Exact ownership receipts: keep who adopted, revision, scope and refusals.
|
|
102
|
+
// Already small; no generic acknowledgement projection (988ad64c).
|
|
103
|
+
"manage_protected_intent",
|
|
104
|
+
// A decision receipt: accepted, declined, refused, not_decided or unknown, with
|
|
105
|
+
// what happened and what the person can do next. Already small, and a refusal
|
|
106
|
+
// or an unconfirmed outcome must never read as done, so it stays whole (60fb0935).
|
|
107
|
+
"decide_proposal",
|
|
108
|
+
// A duplicate resolution either previews the exact snapshots, the planned
|
|
109
|
+
// closure and who decides, or returns its receipt. Projection cannot see which
|
|
110
|
+
// action was called, so both stay whole, as for preview_supersession (fce58a2f).
|
|
111
|
+
"resolve_duplicate",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
const ACK_VERBS = new Set([
|
|
115
|
+
"update_task",
|
|
116
|
+
"create_task",
|
|
117
|
+
"add_commit_reference",
|
|
118
|
+
"add_implementation_note",
|
|
119
|
+
"record_automation_run",
|
|
120
|
+
"create_automation",
|
|
121
|
+
"update_automation",
|
|
122
|
+
"delete_automation",
|
|
123
|
+
"create_resource",
|
|
124
|
+
"release_work_item",
|
|
125
|
+
"record_implementation",
|
|
126
|
+
"fail_work_item",
|
|
127
|
+
"report_progress",
|
|
128
|
+
"record_completed_work",
|
|
129
|
+
"reopen_task",
|
|
130
|
+
"spin_off_task",
|
|
131
|
+
// A receipt: committed with the closure and tense, or queued with a proposal_id.
|
|
132
|
+
"supersede_task",
|
|
133
|
+
"link_tasks",
|
|
134
|
+
"unlink_tasks",
|
|
135
|
+
"verify_task",
|
|
136
|
+
"record_criterion_verdicts",
|
|
137
|
+
"classify_criterion",
|
|
138
|
+
"record_memory",
|
|
139
|
+
"manage_discussion_point",
|
|
140
|
+
"supersede_memory",
|
|
141
|
+
"retract_memory",
|
|
142
|
+
"update_resource",
|
|
143
|
+
"supersede_resource",
|
|
144
|
+
"archive_resource",
|
|
145
|
+
"resolve_resource_flag",
|
|
146
|
+
"resolve_memory_flag",
|
|
147
|
+
// An explicit not-a-duplicate ruling returns a receipt, like the flag rulings (fce58a2f).
|
|
148
|
+
"record_duplicate_nonmatch",
|
|
149
|
+
"surface_session_tasks",
|
|
150
|
+
"reserve_work_items",
|
|
151
|
+
"write_project_instruction_rule",
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
const ROW_KEYS = [
|
|
155
|
+
"results", "items", "tasks", "memories", "resources", "projects", "goals", "sessions",
|
|
156
|
+
"automations", "runs", "reviews", "matches", "data",
|
|
157
|
+
] as const;
|
|
158
|
+
const COUNT_KEYS = ["total_matching", "total", "count"] as const;
|
|
159
|
+
const ACK_GATE_KEYS = [
|
|
160
|
+
"human_actions_required",
|
|
161
|
+
"manual_followup_steps",
|
|
162
|
+
"own_criteria_follow_through",
|
|
163
|
+
"brief_follow_through",
|
|
164
|
+
"blocked",
|
|
165
|
+
"is_blocked",
|
|
166
|
+
"claimed",
|
|
167
|
+
] as const;
|
|
168
|
+
|
|
169
|
+
type Row = Record<string, unknown>;
|
|
170
|
+
|
|
171
|
+
function record(value: unknown): Row | null {
|
|
172
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Row) : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function hasOwn(row: Row, key: string): boolean {
|
|
176
|
+
return Object.prototype.hasOwnProperty.call(row, key);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function scalar(value: unknown): string | undefined {
|
|
180
|
+
if (typeof value === "string") return value.trim() || undefined;
|
|
181
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function first(row: Row, keys: readonly string[]): string | undefined {
|
|
186
|
+
for (const key of keys) {
|
|
187
|
+
const value = scalar(row[key]);
|
|
188
|
+
if (value !== undefined) return value;
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function oneLine(value: unknown, max = LINE_LIMIT): string | undefined {
|
|
194
|
+
const raw = scalar(value);
|
|
195
|
+
if (!raw) return undefined;
|
|
196
|
+
const flat = raw.replace(/\s+/g, " ").trim();
|
|
197
|
+
return flat.length > max ? `${flat.slice(0, Math.max(0, max - 1))}…` : flat;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A sentence DevSpec wrote to be read (a label, an instruction): flattened to one line, never clipped. */
|
|
201
|
+
function whole(value: unknown): string | undefined {
|
|
202
|
+
const raw = scalar(value);
|
|
203
|
+
return raw ? raw.replace(/\s+/g, " ").trim() : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function json(value: unknown): string | null {
|
|
207
|
+
try {
|
|
208
|
+
const encoded = JSON.stringify(value, null, 2);
|
|
209
|
+
return encoded === undefined ? null : encoded;
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function safeDashboardUrl(value: unknown): string | undefined {
|
|
216
|
+
const raw = scalar(value);
|
|
217
|
+
if (!raw) return undefined;
|
|
218
|
+
try {
|
|
219
|
+
const parsed = new URL(raw);
|
|
220
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return undefined;
|
|
221
|
+
parsed.username = "";
|
|
222
|
+
parsed.password = "";
|
|
223
|
+
parsed.search = "";
|
|
224
|
+
parsed.hash = "";
|
|
225
|
+
return parsed.toString();
|
|
226
|
+
} catch {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function safeCiteUrl(value: unknown): string | undefined {
|
|
232
|
+
const raw = scalar(value);
|
|
233
|
+
if (!raw) return undefined;
|
|
234
|
+
try {
|
|
235
|
+
const parsed = new URL(raw);
|
|
236
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return undefined;
|
|
237
|
+
parsed.username = "";
|
|
238
|
+
parsed.password = "";
|
|
239
|
+
return parsed.toString();
|
|
240
|
+
} catch {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Required blocks are reserved before optional detail and are never dropped. */
|
|
246
|
+
function withinBudgetRequired(
|
|
247
|
+
requiredBlocks: readonly string[],
|
|
248
|
+
optionalBlocks: readonly string[],
|
|
249
|
+
budget: number,
|
|
250
|
+
family: string,
|
|
251
|
+
): string {
|
|
252
|
+
const required = requiredBlocks.filter((block) => block.trim().length > 0);
|
|
253
|
+
const requiredText = required.join("\n");
|
|
254
|
+
if (requiredText.length > budget) {
|
|
255
|
+
// Known required fields are individually bounded. Reaching this guard means
|
|
256
|
+
// the server exceeded its documented cardinality; preserve truth over a lie.
|
|
257
|
+
return `${requiredText}\n[Required ${family} data exceeded the ${budget}-character safety budget; nothing mandatory was hidden.]`;
|
|
258
|
+
}
|
|
259
|
+
const optional = optionalBlocks.filter((block) => block.trim().length > 0);
|
|
260
|
+
if (!optional.length) return requiredText;
|
|
261
|
+
const included = [...required];
|
|
262
|
+
let omitted = 0;
|
|
263
|
+
for (let index = 0; index < optional.length; index += 1) {
|
|
264
|
+
const block = optional[index];
|
|
265
|
+
const note = `[${omitted + 1 + optional.length - index - 1} optional ${family} block(s) omitted to preserve mandatory data within the ${budget}-character budget.]`;
|
|
266
|
+
if ([...included, block, note].join("\n").length <= budget) included.push(block);
|
|
267
|
+
else omitted += 1;
|
|
268
|
+
}
|
|
269
|
+
if (!omitted) return included.join("\n");
|
|
270
|
+
const note = `[${omitted} optional ${family} block(s) omitted to preserve mandatory data within the ${budget}-character budget.]`;
|
|
271
|
+
while (included.length > required.length && [...included, note].join("\n").length > budget) included.pop();
|
|
272
|
+
return [...included, note].join("\n");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function projectionFamily(toolName: string): ProjectionFamily {
|
|
276
|
+
if (toolName === "get_project_summary") return "project";
|
|
277
|
+
if (toolName === "devspec_help_search") return "help";
|
|
278
|
+
if (COLLECTION_VERBS.has(toolName)) return "collection";
|
|
279
|
+
if (FULL_READ_VERBS.has(toolName)) return "full";
|
|
280
|
+
if (ACK_VERBS.has(toolName)) return "ack";
|
|
281
|
+
return "fallback";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Where a response carries the record it is about. */
|
|
285
|
+
const NESTED_ENTITY_KEYS: readonly string[] = ["item", "task", "memory", "resource", "automation", "goal", "project", "session", "assignment", "run"];
|
|
286
|
+
|
|
287
|
+
function nestedEntity(root: Row): Row {
|
|
288
|
+
for (const key of NESTED_ENTITY_KEYS) {
|
|
289
|
+
const found = record(root[key]);
|
|
290
|
+
if (found) return found;
|
|
291
|
+
}
|
|
292
|
+
return root;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function boundedId(row: Row, outer: Row = row): string | undefined {
|
|
296
|
+
return oneLine(
|
|
297
|
+
first(row, ["id", "task_id", "memory_id", "resource_id", "project_id", "session_id", "run_id", "criterion_id"])
|
|
298
|
+
?? first(outer, ["id", "task_id", "memory_id", "resource_id", "project_id", "session_id", "run_id", "criterion_id"]),
|
|
299
|
+
90,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function signalLabels(row: Row): string[] {
|
|
304
|
+
const labels: string[] = [];
|
|
305
|
+
const review = Array.isArray(row.review_signals)
|
|
306
|
+
? row.review_signals
|
|
307
|
+
: Array.isArray(row.signals)
|
|
308
|
+
? row.signals
|
|
309
|
+
: [];
|
|
310
|
+
for (const raw of review.slice(0, 2)) {
|
|
311
|
+
const signal = typeof raw === "string" ? oneLine(raw, 80) : oneLine(first(record(raw) ?? {}, ["type", "signal_type", "kind"]), 80);
|
|
312
|
+
if (signal) labels.push(signal.replace(/_/g, " "));
|
|
313
|
+
}
|
|
314
|
+
if (row.claimed === false) labels.push("not claimed");
|
|
315
|
+
if (row.blocked === true || row.is_blocked === true) labels.push("blocked");
|
|
316
|
+
if (Array.isArray(row.human_actions_required) && row.human_actions_required.length > 0) {
|
|
317
|
+
labels.push(`${row.human_actions_required.length} human action${row.human_actions_required.length === 1 ? "" : "s"}`);
|
|
318
|
+
}
|
|
319
|
+
return [...new Set(labels)].slice(0, 4);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* A change someone has PROPOSED against this record, awaiting review
|
|
324
|
+
* (DevSpec item 38576d61). The card still shows the decided record; these lines
|
|
325
|
+
* say a change is waiting on it, who proposed it and which proposal.
|
|
326
|
+
*
|
|
327
|
+
* The server writes each `line` for exactly this purpose, so Pi renders what it
|
|
328
|
+
* was sent rather than composing its own sentence about proposals — the wording
|
|
329
|
+
* of what a queued write means belongs to the served contract, not to a plugin.
|
|
330
|
+
* Only when a row arrives without a `line` does this compose one from the parts.
|
|
331
|
+
*
|
|
332
|
+
* These go in a card's REQUIRED blocks: an annotation that is dropped under
|
|
333
|
+
* budget pressure is the bug this fixes, not a smaller version of it.
|
|
334
|
+
*/
|
|
335
|
+
function pendingProposalLines(row: Row, outer: Row): string[] {
|
|
336
|
+
const source = Array.isArray(row.pending_proposals)
|
|
337
|
+
? row.pending_proposals
|
|
338
|
+
: Array.isArray(outer.pending_proposals)
|
|
339
|
+
? outer.pending_proposals
|
|
340
|
+
: [];
|
|
341
|
+
const lines: string[] = [];
|
|
342
|
+
for (const raw of source) {
|
|
343
|
+
const entry = record(raw);
|
|
344
|
+
if (!entry) continue;
|
|
345
|
+
const served = whole(entry.line);
|
|
346
|
+
if (served) {
|
|
347
|
+
lines.push(served);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const what = oneLine(first(entry, ["what", "kind"]), 80) ?? "proposed change";
|
|
351
|
+
const by = oneLine(entry.by, 80);
|
|
352
|
+
const id = oneLine(entry.proposal_id, 90);
|
|
353
|
+
lines.push(
|
|
354
|
+
// Who it waits on is DevSpec's to say (contract 4.31.0: not always a Maintainer).
|
|
355
|
+
`\u26a0 ${what}${by ? ` by ${by}` : ""}, awaiting review${id ? ` (proposal ${id})` : ""}`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return lines;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Suggested work that is not agreed yet, or that a Maintainer declined (DevSpec
|
|
363
|
+
* decision 2ec0e288). The server attaches `agreement` only to such rows and writes
|
|
364
|
+
* its `line`, so Pi renders what it was sent, the same way as a pending proposal.
|
|
365
|
+
* A required block: dropping it under budget would show a suggestion as agreed work.
|
|
366
|
+
*/
|
|
367
|
+
function agreementLine(row: Row, outer: Row): string | null {
|
|
368
|
+
const entry = record(row.agreement) ?? record(outer.agreement);
|
|
369
|
+
if (!entry) return null;
|
|
370
|
+
const served = whole(entry.line);
|
|
371
|
+
if (served) return `\u26a0 ${served}`;
|
|
372
|
+
const state = oneLine(entry.state, 40);
|
|
373
|
+
return state ? `\u26a0 Not agreed work (${state}).` : null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Work that waits for an OK, or is an experiment (contract 4.31.0,
|
|
378
|
+
* core.waiting-work-stays-on-its-branch; ADR "Protecting intent" §6.2, §6.9).
|
|
379
|
+
* DevSpec writes the label ("Experiment", or "Waiting for <name>'s OK") and the
|
|
380
|
+
* instruction; Pi shows them as sent. Required wherever it appears: dropping it
|
|
381
|
+
* would let an agent merge waiting work into the tracking branch. The done
|
|
382
|
+
* blocker follows in the same required lines (see doneBlockerLine).
|
|
383
|
+
*/
|
|
384
|
+
function waitingLines(row: Row, outer: Row = row): string[] {
|
|
385
|
+
const waiting = record(row.waiting_for_ok) ?? record(outer.waiting_for_ok);
|
|
386
|
+
const lines: string[] = [];
|
|
387
|
+
if (waiting) {
|
|
388
|
+
const label = whole(waiting.label) ?? "Waiting for an OK";
|
|
389
|
+
const on = whole(waiting.waiting_on);
|
|
390
|
+
const instruction = whole(waiting.instruction);
|
|
391
|
+
lines.push(`\u26a0 ${label}${on && on !== label ? ` (${on})` : ""}${instruction ? ` — ${instruction}` : ""}`);
|
|
392
|
+
} else if (row.is_experiment === true || outer.is_experiment === true || row.badge === "Experiment" || outer.badge === "Experiment") {
|
|
393
|
+
const branch = oneLine(first(row, ["experiment_branch"]) ?? first(outer, ["experiment_branch"]), 160);
|
|
394
|
+
lines.push(`\u26a0 Experiment${branch ? ` (branch ${branch})` : ""}: build and push it on its own branch; never merge it into the tracking branch until it has the OK.`);
|
|
395
|
+
}
|
|
396
|
+
const blocker = doneBlockerLine(row, outer, lines.length > 0);
|
|
397
|
+
if (blocker) lines.push(blocker);
|
|
398
|
+
return lines;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* What still stands between the work and done: an OK, a merge into a Tracking
|
|
403
|
+
* branch (`not_landed`), or, once merged, a database change or a tracked
|
|
404
|
+
* deployment (`awaiting_delivery`, with what is `outstanding`). DevSpec writes the
|
|
405
|
+
* label ("Waiting to deploy to <target>"); Pi shows it as sent, plus the parts.
|
|
406
|
+
* When done follows is devspec://product/implementation-contract's to say, not
|
|
407
|
+
* Pi's. Required wherever it appears: dropping it let an agent report waiting
|
|
408
|
+
* work as done.
|
|
409
|
+
*/
|
|
410
|
+
function doneBlockerLine(row: Row, outer: Row, waitingShown: boolean): string | null {
|
|
411
|
+
const blocker = record(row.done_blocker) ?? record(outer.done_blocker);
|
|
412
|
+
if (!blocker) return null;
|
|
413
|
+
const layer = oneLine(blocker.layer, 40);
|
|
414
|
+
const label = whole(blocker.label) ?? "Not done yet";
|
|
415
|
+
const parts: string[] = [];
|
|
416
|
+
if (layer === "waiting_for_ok") {
|
|
417
|
+
// The waiting line above already says this, unless it merged before the OK.
|
|
418
|
+
if (blocker.merged !== true && waitingShown) return null;
|
|
419
|
+
if (blocker.merged === true) parts.push(`merged into ${oneLine(blocker.tracking_branch, 80) ?? "the tracking branch"}`);
|
|
420
|
+
} else if (layer === "not_landed") {
|
|
421
|
+
const repos = Array.isArray(blocker.unmerged_repos) ? blocker.unmerged_repos : [];
|
|
422
|
+
const names = repos.map((raw) => {
|
|
423
|
+
const repo = record(raw) ?? {};
|
|
424
|
+
const name = whole(repo.repo_name);
|
|
425
|
+
const branch = whole(repo.tracking_branch);
|
|
426
|
+
return name ? `${name}${branch ? ` \u2192 ${branch}` : ""}` : undefined;
|
|
427
|
+
}).filter((entry): entry is string => Boolean(entry));
|
|
428
|
+
if (names.length) parts.push(`unmerged: ${names.join(", ")}`);
|
|
429
|
+
} else if (layer === "awaiting_delivery") {
|
|
430
|
+
const outstanding = (Array.isArray(blocker.outstanding) ? blocker.outstanding : [])
|
|
431
|
+
.map((raw) => record(raw))
|
|
432
|
+
.filter((entry): entry is Row => entry !== null);
|
|
433
|
+
// Name the repository per entry only when the wait spans more than one.
|
|
434
|
+
const repoNames = new Set(outstanding.map((entry) => oneLine(entry.repo_name, 80)).filter(Boolean));
|
|
435
|
+
const perRepo = repoNames.size > 1;
|
|
436
|
+
const entries = outstanding.map((entry) => {
|
|
437
|
+
const status = oneLine(entry.status, 40)?.replace(/_/g, " ") ?? "pending";
|
|
438
|
+
const repo = perRepo ? oneLine(entry.repo_name, 80) : undefined;
|
|
439
|
+
let what: string;
|
|
440
|
+
if (entry.kind === "deployment") {
|
|
441
|
+
const targets = Array.isArray(entry.target_names)
|
|
442
|
+
? oneLine(entry.target_names.filter((name) => typeof name === "string").join(", "), 120)
|
|
443
|
+
: undefined;
|
|
444
|
+
what = `deployment${targets ? ` to ${targets}` : ""}`;
|
|
445
|
+
} else if (entry.kind === "database_change") {
|
|
446
|
+
const file = oneLine(entry.filename, 100);
|
|
447
|
+
const database = oneLine(entry.database_name, 80);
|
|
448
|
+
what = `database change${file ? ` ${file}` : ""}${database ? ` on ${database}` : ""}`;
|
|
449
|
+
} else {
|
|
450
|
+
what = oneLine(entry.kind, 40)?.replace(/_/g, " ") ?? "delivery";
|
|
451
|
+
}
|
|
452
|
+
return `${repo ? `${repo} ` : ""}${what} (${status})`;
|
|
453
|
+
});
|
|
454
|
+
if (entries.length) {
|
|
455
|
+
const only = repoNames.size === 1 ? [...repoNames][0] : undefined;
|
|
456
|
+
parts.push(`outstanding${only ? ` in ${only}` : ""}: ${entries.join("; ")}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return `\u26a0 done_blocker${layer ? ` (${layer})` : ""}: ${label}${parts.length ? ` \u2014 ${parts.join("; ")}` : ""}`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function card(raw: unknown, index: number): string {
|
|
463
|
+
const outer = record(raw) ?? { value: raw };
|
|
464
|
+
const row = nestedEntity(outer);
|
|
465
|
+
const id = boundedId(row, outer);
|
|
466
|
+
const title = oneLine(first(row, ["title", "name", "key", "subject"]), 140) ?? "Untitled";
|
|
467
|
+
const metadata: string[] = [];
|
|
468
|
+
for (const keys of [
|
|
469
|
+
["lifecycle", "lifecycle_state"],
|
|
470
|
+
["state", "status"],
|
|
471
|
+
["type", "item_type", "memory_type", "resource_type"],
|
|
472
|
+
["priority"],
|
|
473
|
+
["source", "source_type"],
|
|
474
|
+
["matched_by", "matchedBy"],
|
|
475
|
+
] as const) {
|
|
476
|
+
const value = oneLine(first(row, keys), 70);
|
|
477
|
+
if (value && !metadata.includes(value)) metadata.push(value);
|
|
478
|
+
}
|
|
479
|
+
// A requirement binds work that would break it, so it is labelled apart from
|
|
480
|
+
// ordinary knowledge (ADR "Protecting intent" §10), never left to read as one.
|
|
481
|
+
if (first(row, ["kind", "memory_kind"]) === "requirement" && !metadata.includes("requirement")) metadata.unshift("requirement");
|
|
482
|
+
const matchCount = Number(first(row, ["match_count", "matchCount"]));
|
|
483
|
+
if (Number.isFinite(matchCount) && matchCount > 0) {
|
|
484
|
+
const label = `${matchCount} match${matchCount === 1 ? "" : "es"}`;
|
|
485
|
+
if (!metadata.includes(label)) metadata.push(label);
|
|
486
|
+
}
|
|
487
|
+
const parentRow = record(row.parent) ?? record(row.brief);
|
|
488
|
+
const parent = oneLine(
|
|
489
|
+
first(row, ["parent_title", "brief_title", "parent_task_id", "parent_id"])
|
|
490
|
+
?? (parentRow ? first(parentRow, ["title", "name", "id"]) : undefined),
|
|
491
|
+
140,
|
|
492
|
+
);
|
|
493
|
+
const why = oneLine(
|
|
494
|
+
first(outer, ["why", "why_matched", "match_reason", "matching_passage", "intent", "snippet", "summary", "description_preview", "content_preview", "excerpt"])
|
|
495
|
+
?? first(row, ["why", "matching_passage", "intent", "summary", "snippet", "description_preview", "content_preview", "excerpt"]),
|
|
496
|
+
200,
|
|
497
|
+
);
|
|
498
|
+
const created = oneLine(first(row, ["created_at", "createdAt"]), 40);
|
|
499
|
+
const started = created ? undefined : oneLine(first(row, ["started_at", "startedAt"]), 40);
|
|
500
|
+
const age = created || started ? undefined : oneLine(first(row, ["age"]), 30);
|
|
501
|
+
const lastActivity = oneLine(first(row, ["last_activity_at", "lastActivityAt", "last_activity"]), 40);
|
|
502
|
+
const timeline = [created && `created ${created}`, started && `started ${started}`, age && `age ${age}`, lastActivity && `last activity ${lastActivity}`]
|
|
503
|
+
.filter((value): value is string => Boolean(value));
|
|
504
|
+
const signals = [...new Set([...signalLabels(outer), ...signalLabels(row)])];
|
|
505
|
+
const required = [`${index + 1}. ${id ? `[${id}] ` : ""}${title}${metadata.length ? ` — ${metadata.join(" · ")}` : ""}`];
|
|
506
|
+
// A card exists to decide whether an exact read is worth paying for. Parent,
|
|
507
|
+
// chronology, intent/matching passage and a pending proposal against the record
|
|
508
|
+
// are therefore selection inputs, not decorative fields that may disappear
|
|
509
|
+
// before optional repo/signal metadata.
|
|
510
|
+
if (parent) required.push(` parent/brief: ${parent}`);
|
|
511
|
+
if (timeline.length) required.push(` ${timeline.join(" · ")}`);
|
|
512
|
+
if (why) required.push(` ${why}`);
|
|
513
|
+
const agreement = agreementLine(row, outer);
|
|
514
|
+
if (agreement) required.push(` ${agreement}`);
|
|
515
|
+
for (const line of waitingLines(row, outer)) required.push(` ${line}`);
|
|
516
|
+
for (const line of pendingProposalLines(row, outer)) required.push(` ${line}`);
|
|
517
|
+
const disagreements = disagreementBlock(row, 1_600);
|
|
518
|
+
if (disagreements) required.push(disagreements);
|
|
519
|
+
const optional: string[] = [];
|
|
520
|
+
const repos = Array.isArray(row.repos) ? row.repos : [];
|
|
521
|
+
if (repos.length) {
|
|
522
|
+
const names = repos.slice(0, 2)
|
|
523
|
+
.map((entry) => oneLine(first(record(entry) ?? {}, ["full_name", "name", "url"]), 120))
|
|
524
|
+
.filter((entry): entry is string => Boolean(entry));
|
|
525
|
+
if (names.length) optional.push(` repos: ${names.join(", ")}`);
|
|
526
|
+
}
|
|
527
|
+
if (signals.length) optional.push(` signals: ${signals.join(", ")}`);
|
|
528
|
+
return withinBudgetRequired(required, optional, CARD_BUDGET, "card field");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
interface FoundRows {
|
|
532
|
+
rows: unknown[];
|
|
533
|
+
recognized: boolean;
|
|
534
|
+
briefCount: number;
|
|
535
|
+
/** The response's own top-level keys the rows came from; every other key is shown as a response note. */
|
|
536
|
+
keys: string[];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function genericRows(root: Row): FoundRows {
|
|
540
|
+
for (const key of ROW_KEYS) {
|
|
541
|
+
if (Array.isArray(root[key])) return { rows: root[key] as unknown[], recognized: true, briefCount: 0, keys: [key] };
|
|
542
|
+
}
|
|
543
|
+
for (const key of ["page", "result"] as const) {
|
|
544
|
+
const nested = record(root[key]);
|
|
545
|
+
if (!nested) continue;
|
|
546
|
+
const found = genericRows(nested);
|
|
547
|
+
if (found.recognized) return { ...found, keys: [key] };
|
|
548
|
+
}
|
|
549
|
+
const hasCount = [root, record(root.page)].some((container) =>
|
|
550
|
+
container && COUNT_KEYS.some((key) => typeof container[key] === "number" && Number.isFinite(container[key])),
|
|
551
|
+
);
|
|
552
|
+
return { rows: [], recognized: hasCount, briefCount: 0, keys: [] };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function collectionRows(toolName: string, root: Row): FoundRows {
|
|
556
|
+
if (toolName === "search_resources") {
|
|
557
|
+
const hasDocuments = Array.isArray(root.documents);
|
|
558
|
+
const hasArtifacts = Array.isArray(root.artifacts);
|
|
559
|
+
if (!hasDocuments && !hasArtifacts) return genericRows(root);
|
|
560
|
+
const documents = hasDocuments ? root.documents as unknown[] : [];
|
|
561
|
+
const artifacts = hasArtifacts ? root.artifacts as unknown[] : [];
|
|
562
|
+
const rows: unknown[] = [];
|
|
563
|
+
// The server searches these as two independent corpora; interleaving keeps
|
|
564
|
+
// both visible near the top of the list.
|
|
565
|
+
for (let index = 0; index < Math.max(documents.length, artifacts.length); index += 1) {
|
|
566
|
+
if (index < documents.length) rows.push(documents[index]);
|
|
567
|
+
if (index < artifacts.length) rows.push(artifacts[index]);
|
|
568
|
+
}
|
|
569
|
+
return { rows, recognized: true, briefCount: 0, keys: ["documents", "artifacts"] };
|
|
570
|
+
}
|
|
571
|
+
if (toolName !== "get_tasks") return genericRows(root);
|
|
572
|
+
const hasItems = Array.isArray(root.items);
|
|
573
|
+
const hasBriefs = Array.isArray(root.briefs);
|
|
574
|
+
if (!hasItems && !hasBriefs) return genericRows(root);
|
|
575
|
+
const combined: unknown[] = [];
|
|
576
|
+
const seen = new Set<string>();
|
|
577
|
+
let briefCount = 0;
|
|
578
|
+
// Page rows carry the caller's requested queue/chronological order. Briefs are
|
|
579
|
+
// contextual rollups, so they must never jump ahead and destroy that order.
|
|
580
|
+
for (const raw of hasItems ? root.items as unknown[] : []) {
|
|
581
|
+
const row = record(raw);
|
|
582
|
+
const id = row ? boundedId(nestedEntity(row), row) : undefined;
|
|
583
|
+
if (id && seen.has(id)) continue;
|
|
584
|
+
if (id) seen.add(id);
|
|
585
|
+
combined.push(raw);
|
|
586
|
+
}
|
|
587
|
+
for (const raw of hasBriefs ? root.briefs as unknown[] : []) {
|
|
588
|
+
const row = record(raw);
|
|
589
|
+
const id = row ? boundedId(nestedEntity(row), row) : undefined;
|
|
590
|
+
if (id && seen.has(id)) continue;
|
|
591
|
+
if (id) seen.add(id);
|
|
592
|
+
combined.push(raw);
|
|
593
|
+
briefCount += 1;
|
|
594
|
+
}
|
|
595
|
+
return { rows: combined, recognized: true, briefCount, keys: ["items", "briefs"] };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function totalRows(root: Row, found: number): number {
|
|
599
|
+
for (const container of [record(root.page), root]) {
|
|
600
|
+
if (!container) continue;
|
|
601
|
+
for (const key of COUNT_KEYS) {
|
|
602
|
+
const value = container[key];
|
|
603
|
+
if (typeof value === "number" && Number.isFinite(value)) return Math.max(value, found);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return found;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function fetchHint(toolName: string): string {
|
|
610
|
+
if (toolName.includes("task")) return "Fetch an exact item with get_task(id); rerun with a narrower filter or next offset/page for more.";
|
|
611
|
+
if (toolName.includes("memor") || ["get_decisions", "get_conventions"].includes(toolName)) {
|
|
612
|
+
return "Fetch an exact memory with get_memory(id); rerun the list/search with a narrower filter for more.";
|
|
613
|
+
}
|
|
614
|
+
if (toolName.includes("resource")) return "Fetch an exact resource with get_resource(id); rerun the list/search with a narrower filter for more.";
|
|
615
|
+
if (toolName.includes("project")) return "Use get_project_summary for an exact project; refine project scope or pagination for more.";
|
|
616
|
+
if (toolName === "search_sessions" || toolName === "get_recent_sessions") {
|
|
617
|
+
return "Call get_session_transcript with a session_id to read one room.";
|
|
618
|
+
}
|
|
619
|
+
return `Rerun ${toolName} with a narrower filter or pagination to fetch more; use the entity's exact get tool when available.`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Keys a collection projection renders itself: the page, its counts and its completeness. */
|
|
623
|
+
const COLLECTION_RENDERED_KEYS = new Set(["page", "pages", "count", "total", "total_matching", "incomplete", "unavailable_note", "query"]);
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Everything else the server put beside the rows — how to treat a disagreement,
|
|
627
|
+
* that a search ran on keywords alone, that bodies are left out, that an offset is
|
|
628
|
+
* past the end — reaches the model whole, in DevSpec's words.
|
|
629
|
+
*/
|
|
630
|
+
function collectionNotes(root: Row, found: FoundRows, countsKey: string | null): Row {
|
|
631
|
+
const notes: Row = {};
|
|
632
|
+
for (const [key, value] of Object.entries(root)) {
|
|
633
|
+
if (COLLECTION_RENDERED_KEYS.has(key) || found.keys.includes(key)) continue;
|
|
634
|
+
if (key === "summary" && countsKey) {
|
|
635
|
+
const summary = record(value);
|
|
636
|
+
if (summary) {
|
|
637
|
+
const { [countsKey]: _rendered, ...rest } = summary;
|
|
638
|
+
if (Object.keys(rest).length) notes.summary = rest;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
notes[key] = value;
|
|
643
|
+
}
|
|
644
|
+
return notes;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function collectionProjection(toolName: string, details: unknown): string | null {
|
|
648
|
+
const root = record(details) ?? (Array.isArray(details) ? { results: details } : null);
|
|
649
|
+
if (!root) return null;
|
|
650
|
+
const found = collectionRows(toolName, root);
|
|
651
|
+
if (!found.recognized) return null;
|
|
652
|
+
// Every row the server returned is shown. The page size is the caller's own
|
|
653
|
+
// `limit`, so a second, smaller page here would hide rows that the server's
|
|
654
|
+
// next offset then skips past.
|
|
655
|
+
const shown = found.rows;
|
|
656
|
+
const primaryCount = toolName === "get_tasks"
|
|
657
|
+
? Math.max(0, found.rows.length - found.briefCount)
|
|
658
|
+
: found.rows.length;
|
|
659
|
+
const total = totalRows(root, primaryCount);
|
|
660
|
+
const summary = record(root.summary);
|
|
661
|
+
const countsKey = summary ? (record(summary.ui_buckets) ? "ui_buckets" : record(summary.lifecycle_breakdown) ? "lifecycle_breakdown" : null) : null;
|
|
662
|
+
const counts = summary && countsKey ? record(summary[countsKey]) : null;
|
|
663
|
+
const heading = toolName === "get_tasks" && found.briefCount > 0
|
|
664
|
+
? `${toolName}: ${total} result${total === 1 ? "" : "s"}; ${primaryCount} on this page plus ${found.briefCount} contextual brief card(s).`
|
|
665
|
+
: `${toolName}: ${total} result${total === 1 ? "" : "s"}${primaryCount < total ? `; ${primaryCount} on this page` : ""}.`;
|
|
666
|
+
const blocks = [heading];
|
|
667
|
+
if (counts) {
|
|
668
|
+
const allCounts = Object.entries(counts)
|
|
669
|
+
.filter(([, value]) => typeof value === "number")
|
|
670
|
+
.map(([key, value]) => `${key.replace(/_/g, " ")}=${value}`);
|
|
671
|
+
if (allCounts.length) blocks.push(`Counts: ${allCounts.join(", ")}`);
|
|
672
|
+
}
|
|
673
|
+
// A search that could not reach part of its corpus says so (DevSpec item 0ab1054e):
|
|
674
|
+
// relay it, so a partial answer is never read as a complete one.
|
|
675
|
+
const incomplete = root.incomplete === true;
|
|
676
|
+
if (incomplete) blocks.push(`Incomplete: ${typeof root.unavailable_note === "string" ? whole(root.unavailable_note) : "part of this search could not be run; do not report that nothing matched."}`);
|
|
677
|
+
const notes = collectionNotes(root, found, countsKey);
|
|
678
|
+
if (Object.keys(notes).length) blocks.push(`Response notes (server data):\n${reviewData(notes)}`);
|
|
679
|
+
for (const [index, row] of shown.entries()) blocks.push(card(row, index));
|
|
680
|
+
if (total === 0 && found.briefCount === 0 && !incomplete) blocks.push("No matching entities returned (recognized empty result set).");
|
|
681
|
+
// DevSpec lists page with an exact continuation (item cc630fb8). Name it, so the
|
|
682
|
+
// model can fetch the rest rather than read one page as the whole list.
|
|
683
|
+
const page = record(root.page);
|
|
684
|
+
if (page && typeof page.next_offset === "number") {
|
|
685
|
+
blocks.push(`Next page: call ${toolName} again with offset ${page.next_offset}${page.total_is_lower_bound === true ? " (the count is a floor: the ranking stopped at its bound; narrow the query to reach beyond it)" : ""}.`);
|
|
686
|
+
} else if (page && page.total_is_lower_bound === true) {
|
|
687
|
+
blocks.push("The count is a floor: the ranking stopped at its bound. Narrow the query to reach beyond it.");
|
|
688
|
+
}
|
|
689
|
+
// search_resources pages its two corpora with one offset, each with its own page
|
|
690
|
+
// (DevSpec item 02526f46). The next offset is the further of the two.
|
|
691
|
+
const corpusPages = page ? null : record(root.pages);
|
|
692
|
+
if (corpusPages) {
|
|
693
|
+
const parts = Object.entries(corpusPages)
|
|
694
|
+
.map(([corpus, raw]) => [corpus, record(raw)] as const)
|
|
695
|
+
.filter((entry): entry is readonly [string, Row] => entry[1] !== null);
|
|
696
|
+
const next = parts.map(([, corpusPage]) => corpusPage.next_offset).filter((value): value is number => typeof value === "number");
|
|
697
|
+
const floor = parts.some(([, corpusPage]) => corpusPage.total_is_lower_bound === true);
|
|
698
|
+
const counts = parts.map(([corpus, corpusPage]) => `${corpus} ${corpusPage.total_matching}${corpusPage.total_is_lower_bound === true ? "+" : ""}`).join(", ");
|
|
699
|
+
if (next.length) blocks.push(`Next page: call ${toolName} again with offset ${Math.max(...next)} (matches: ${counts}).`);
|
|
700
|
+
if (floor) blocks.push("A count marked + is a floor: the ranking stopped at its bound. Narrow the query, or browse with get_resources, to reach beyond it.");
|
|
701
|
+
}
|
|
702
|
+
blocks.push(fetchHint(toolName));
|
|
703
|
+
return blocks.join("\n");
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function helpProjection(details: unknown): string | null {
|
|
707
|
+
const root = record(details);
|
|
708
|
+
if (!root || !Array.isArray(root.results)) return null;
|
|
709
|
+
const query = oneLine(root.query, 240);
|
|
710
|
+
const page = record(root.page);
|
|
711
|
+
const offset = page && typeof page.offset === "number" ? page.offset : 0;
|
|
712
|
+
const pageTotal = page && typeof page.total_matching === "number" ? page.total_matching : undefined;
|
|
713
|
+
const total = pageTotal ?? (typeof root.count === "number" ? Math.max(root.count, root.results.length) : root.results.length);
|
|
714
|
+
const floor = page?.total_is_lower_bound === true;
|
|
715
|
+
// Every section the server returned, each whole: its page size is the caller's.
|
|
716
|
+
const shown = root.results as unknown[];
|
|
717
|
+
const blocks = [`DevSpec help${query ? ` for “${query}”` : ""}: ${floor ? "at least " : ""}${total} section${total === 1 ? "" : "s"}; ${shown.length} on this page${offset > 0 ? ` from ${offset + 1}` : ""}.`];
|
|
718
|
+
for (const [index, raw] of shown.entries()) {
|
|
719
|
+
const hit = record(raw);
|
|
720
|
+
if (!hit) continue;
|
|
721
|
+
const doc = whole(first(hit, ["doc", "document", "title"])) ?? "DevSpec help";
|
|
722
|
+
const section = whole(first(hit, ["section", "section_title", "heading"]));
|
|
723
|
+
const category = whole(hit.category);
|
|
724
|
+
const url = safeCiteUrl(hit.url);
|
|
725
|
+
const content = scalar(first(hit, ["content", "text", "body"]));
|
|
726
|
+
const lines = [`${index + 1}. ${doc}${section ? ` — ${section}` : ""}${category ? ` [${category}]` : ""}`];
|
|
727
|
+
if (url) lines.push(` URL: ${url}`);
|
|
728
|
+
if (content) lines.push(content);
|
|
729
|
+
blocks.push(lines.join("\n"));
|
|
730
|
+
}
|
|
731
|
+
// Pages continue from the last section shown (DevSpec item 02526f46).
|
|
732
|
+
if (page && typeof page.next_offset === "number") {
|
|
733
|
+
blocks.push(`More sections: call devspec_help_search again with offset ${page.next_offset}.`);
|
|
734
|
+
}
|
|
735
|
+
blocks.push("Cite the returned URL in the answer.");
|
|
736
|
+
return blocks.join("\n");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function completeProjectSummary(details: unknown): string | null {
|
|
740
|
+
const root = record(details);
|
|
741
|
+
if (!root) return null;
|
|
742
|
+
const omitted: string[] = [];
|
|
743
|
+
function safe(value: unknown, path = ""): unknown {
|
|
744
|
+
if (Array.isArray(value)) return value.map((v, i) => safe(v, `${path}[${i}]`));
|
|
745
|
+
const row = record(value);
|
|
746
|
+
if (!row) return value;
|
|
747
|
+
return Object.fromEntries(Object.entries(row).flatMap(([key, v]) => {
|
|
748
|
+
const field = path ? `${path}.${key}` : key;
|
|
749
|
+
// Redaction is explicit and semantic, never a content-size quota.
|
|
750
|
+
if (/^(password|token|access_token|refresh_token|api_key|authorization|connection_string|email)$/i.test(key)
|
|
751
|
+
|| (!path && ["autopilot", "local_plugin_settings"].includes(key))
|
|
752
|
+
|| (path === "execution" && key === "test_commands")) {
|
|
753
|
+
omitted.push(field); return [];
|
|
754
|
+
}
|
|
755
|
+
if (/url$/i.test(key) && typeof v === "string" && /^https?:/.test(v)) {
|
|
756
|
+
const cleaned = safeDashboardUrl(v);
|
|
757
|
+
if (cleaned !== v) omitted.push(`${field} (URL credentials/query/fragment)`);
|
|
758
|
+
return [[key, cleaned ?? null]];
|
|
759
|
+
}
|
|
760
|
+
return [[key, safe(v, field)]];
|
|
761
|
+
}));
|
|
762
|
+
}
|
|
763
|
+
const data = safe(root);
|
|
764
|
+
const json = JSON.stringify(data, null, 2).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e");
|
|
765
|
+
return `Project summary data (complete, except explicitly listed security/retired compatibility fields).\n<devspec-project-data>\n${json}\n</devspec-project-data>${omitted.length ? `\nOmitted security/retired compatibility fields:\n<devspec-omitted-fields>\n${JSON.stringify(omitted).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e")}\n</devspec-omitted-fields>` : ""}`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function conflictBlocks(root: Row): string[] {
|
|
769
|
+
const disagreements = disagreementBlock(root);
|
|
770
|
+
const blocks: string[] = disagreements ? [disagreements] : [];
|
|
771
|
+
if (root.claimed === false) blocks.push("Gate — claim rejected/not acquired.");
|
|
772
|
+
if (root.blocked === true || root.is_blocked === true) blocks.push("Gate — result is blocked.");
|
|
773
|
+
return blocks;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Response fields a receipt renders in their own block, whole, instead of in the field dump. */
|
|
777
|
+
const ACK_OWN_BLOCK_KEYS = new Set(["own_criteria_follow_through", "brief_follow_through", "manual_followup_steps", "human_actions_required"]);
|
|
778
|
+
|
|
779
|
+
/** null, a blank string, an empty list or an empty object: a field that says nothing. */
|
|
780
|
+
function isEmptyValue(value: unknown): boolean {
|
|
781
|
+
if (value === null || value === undefined) return true;
|
|
782
|
+
if (typeof value === "string") return value.trim() === "";
|
|
783
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
784
|
+
const row = record(value);
|
|
785
|
+
return row !== null && Object.keys(row).length === 0;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
789
|
+
if (a === b) return true;
|
|
790
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
|
|
791
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
792
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
793
|
+
return a.every((value, index) => sameValue(value, b[index]));
|
|
794
|
+
}
|
|
795
|
+
const left = a as Row;
|
|
796
|
+
const right = b as Row;
|
|
797
|
+
const keys = Object.keys(left);
|
|
798
|
+
return keys.length === Object.keys(right).length && keys.every((key) => hasOwn(right, key) && sameValue(left[key], right[key]));
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** A value the server returned that is exactly what the call sent (a description, an intent): known to the model already. */
|
|
802
|
+
function isEcho(args: Row | undefined, key: string, value: unknown): boolean {
|
|
803
|
+
if (!args || !hasOwn(args, key) || typeof value === "boolean" || typeof value === "number") return false;
|
|
804
|
+
return sameValue(args[key], value);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** The read that shows the stored record a receipt is about. */
|
|
808
|
+
function readToolFor(toolName: string): string {
|
|
809
|
+
if (toolName.includes("memory")) return "get_memory";
|
|
810
|
+
if (toolName.includes("resource")) return "get_resource";
|
|
811
|
+
if (toolName.includes("automation")) return "get_automation";
|
|
812
|
+
if (toolName.includes("discussion_point")) return "get_discussion_point";
|
|
813
|
+
if (toolName.includes("instruction_rule")) return "get_project_instruction_rules";
|
|
814
|
+
return "get_task";
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
interface ReceiptFields {
|
|
818
|
+
shown: Row;
|
|
819
|
+
empty: string[];
|
|
820
|
+
echoed: string[];
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Every top-level field of a write's response, so a field the server adds later
|
|
825
|
+
* reaches the model with no Pi change. Two kinds of field are left out, and the
|
|
826
|
+
* receipt names how many and which: an empty one (null, blank, [] or {}) and one
|
|
827
|
+
* identical to the argument the call sent. The record's own read shows both.
|
|
828
|
+
*/
|
|
829
|
+
function receiptFields(root: Row, args: Row | undefined): ReceiptFields {
|
|
830
|
+
const shown: Row = {};
|
|
831
|
+
const empty: string[] = [];
|
|
832
|
+
const echoed: string[] = [];
|
|
833
|
+
for (const [key, value] of Object.entries(root)) {
|
|
834
|
+
if (ACK_OWN_BLOCK_KEYS.has(key)) continue;
|
|
835
|
+
if (isEmptyValue(value)) {
|
|
836
|
+
empty.push(key);
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
if (isEcho(args, key, value)) {
|
|
840
|
+
echoed.push(key);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const nested = NESTED_ENTITY_KEYS.includes(key) ? record(value) : null;
|
|
844
|
+
if (!nested) {
|
|
845
|
+
shown[key] = value;
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
const kept: Row = {};
|
|
849
|
+
for (const [inner, innerValue] of Object.entries(nested)) {
|
|
850
|
+
if (isEmptyValue(innerValue)) empty.push(`${key}.${inner}`);
|
|
851
|
+
else if (isEcho(args, inner, innerValue)) echoed.push(`${key}.${inner}`);
|
|
852
|
+
else kept[inner] = innerValue;
|
|
853
|
+
}
|
|
854
|
+
shown[key] = kept;
|
|
855
|
+
}
|
|
856
|
+
return { shown, empty, echoed };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function followThroughBlock(kind: "own" | "brief", value: unknown): string {
|
|
860
|
+
const label = kind === "own" ? "Own-criteria follow-through" : "Brief follow-through";
|
|
861
|
+
if (value === false) return `${label}: none requested.`;
|
|
862
|
+
return `${label} (act on it before you report):\n${reviewData(value)}`;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function manualFollowUpBlock(root: Row): string | null {
|
|
866
|
+
const steps: Row = {};
|
|
867
|
+
let count = 0;
|
|
868
|
+
for (const key of ["human_actions_required", "manual_followup_steps"] as const) {
|
|
869
|
+
if (Array.isArray(root[key]) && (root[key] as unknown[]).length > 0) {
|
|
870
|
+
steps[key] = root[key];
|
|
871
|
+
count += (root[key] as unknown[]).length;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (!count) return null;
|
|
875
|
+
return `Manual/human follow-up (${count}):\n${reviewData(steps)}`;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function ackProjection(toolName: string, details: unknown, args?: Row): string | null {
|
|
879
|
+
const root = record(details);
|
|
880
|
+
if (!root) return null;
|
|
881
|
+
const entity = nestedEntity(root);
|
|
882
|
+
const id = boundedId(entity, root);
|
|
883
|
+
const title = oneLine(first(entity, ["title", "name", "subject", "key"]), 160);
|
|
884
|
+
const state = oneLine(first(entity, ["lifecycle", "lifecycle_state", "state", "status"]) ?? first(root, ["lifecycle", "lifecycle_state", "state", "status"]), 80);
|
|
885
|
+
const outcome = oneLine(first(root, ["outcome", "result", "disposition"]) ?? first(entity, ["outcome", "result", "disposition"]), 100);
|
|
886
|
+
const recognizedGate = ACK_GATE_KEYS.some((key) => hasOwn(root, key) || hasOwn(entity, key));
|
|
887
|
+
if (!id && !title && !state && !outcome && !recognizedGate) return null;
|
|
888
|
+
|
|
889
|
+
const blocks: string[] = [];
|
|
890
|
+
const parent = oneLine(first(entity, ["parent_task_id", "parent_id", "parent_title", "brief_title"])
|
|
891
|
+
?? first(root, ["parent_task_id", "parent_id", "parent_title", "brief_title"]), 140);
|
|
892
|
+
const facts = [id && `id=${id}`, title && `title=${title}`, state && `state=${state}`, outcome && `outcome=${outcome}`].filter(Boolean);
|
|
893
|
+
blocks.push(`${toolName}: ${facts.length ? facts.join(" ") : "recognized response (see the fields below)"}.`);
|
|
894
|
+
if (parent) blocks.push(`Parent/brief: ${parent}`);
|
|
895
|
+
// Work that waits for an OK, is an experiment, or is not done yet: said in a
|
|
896
|
+
// line first, and its fields are in the response below as well.
|
|
897
|
+
blocks.push(...waitingLines(entity, root));
|
|
898
|
+
blocks.push(...conflictBlocks(root));
|
|
899
|
+
if (entity !== root) blocks.push(...conflictBlocks(entity));
|
|
900
|
+
if (hasOwn(root, "own_criteria_follow_through") && root.own_criteria_follow_through != null) {
|
|
901
|
+
blocks.push(followThroughBlock("own", root.own_criteria_follow_through));
|
|
902
|
+
}
|
|
903
|
+
if (hasOwn(root, "brief_follow_through") && root.brief_follow_through != null) {
|
|
904
|
+
blocks.push(followThroughBlock("brief", root.brief_follow_through));
|
|
905
|
+
}
|
|
906
|
+
const manual = manualFollowUpBlock(root);
|
|
907
|
+
if (manual) blocks.push(manual);
|
|
908
|
+
const fields = receiptFields(root, args);
|
|
909
|
+
const leftOut = [
|
|
910
|
+
fields.empty.length ? `${fields.empty.length} empty field(s) left out` : null,
|
|
911
|
+
fields.echoed.length ? `left out as identical to what you sent: ${fields.echoed.join(", ")}` : null,
|
|
912
|
+
].filter((part): part is string => part !== null);
|
|
913
|
+
const heading = leftOut.length
|
|
914
|
+
? `DevSpec's response (every other field; ${leftOut.join("; ")} — ${readToolFor(toolName)} shows the stored record):`
|
|
915
|
+
: "DevSpec's response (every field):";
|
|
916
|
+
blocks.push(`${heading}\n${reviewData(fields.shown)}`);
|
|
917
|
+
return blocks.join("\n");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** A result no family recognises reaches the model whole: a field DevSpec adds is never cut off here. */
|
|
921
|
+
function wholeFallback(text: string, details: unknown): string {
|
|
922
|
+
if (details != null) return json(details) ?? (text || "(unserializable structured payload)");
|
|
923
|
+
return text || "(empty response)";
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Project a successful result. The caller must retain `details` unchanged. */
|
|
927
|
+
export function projectModelText(input: ProjectionInput): string {
|
|
928
|
+
const family = projectionFamily(input.toolName);
|
|
929
|
+
if (family === "full") {
|
|
930
|
+
return input.details == null
|
|
931
|
+
? input.text || "(empty response)"
|
|
932
|
+
: (json(input.details) ?? input.text) || "(empty response)";
|
|
933
|
+
}
|
|
934
|
+
if (family === "project") return completeProjectSummary(input.details) ?? input.text;
|
|
935
|
+
if (family === "collection") return collectionProjection(input.toolName, input.details) ?? wholeFallback(input.text, input.details);
|
|
936
|
+
if (family === "help") return helpProjection(input.details) ?? wholeFallback(input.text, input.details);
|
|
937
|
+
if (family === "ack") return ackProjection(input.toolName, input.details, input.args) ?? wholeFallback(input.text, input.details);
|
|
938
|
+
return wholeFallback(input.text, input.details);
|
|
939
|
+
}
|