pi-advisor-flow 0.1.9 → 0.2.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.
@@ -1,53 +1,204 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ advisorToolResultMaxBytesRef,
4
+ advisorToolResultMaxLinesRef,
5
+ DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES,
6
+ DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES,
7
+ } from "./config.js";
2
8
 
3
- export const textFrom = (content: unknown): string => {
4
- return (typeof content === "string" ? [content] : Array.isArray(content) ? content : [])
5
- .map((part: unknown) =>
6
- typeof part === "string"
7
- ? part
8
- : (part as { type?: string; text?: string })?.type === "text"
9
- ? (part as { text?: string }).text ?? ""
10
- : ""
11
- )
12
- .join("\n")
13
- .trim();
14
- };
15
-
16
- export const recentConversation = (ctx: ExtensionContext, maxChars = 15000): string => {
17
- if (maxChars === 0) return "";
18
- const entries: string[] = [];
19
- for (const entry of ctx.sessionManager.getBranch()) {
20
- if (entry.type === "message") {
21
- const msg = entry.message;
22
- if (msg.role === "user") {
23
- const text = textFrom(msg.content);
24
- if (text) entries.push(`User: ${text}`);
25
- } else if (msg.role === "assistant") {
26
- const parts: string[] = [];
27
- const text = textFrom(msg.content);
28
- if (text) parts.push(text);
29
- if (Array.isArray(msg.content)) {
30
- for (const part of msg.content) {
31
- if (part && typeof part === "object" && part.type === "toolCall") {
32
- const tc = part as { name?: string; arguments?: unknown };
33
- const argsStr = JSON.stringify(tc.arguments);
34
- parts.push(`[Tool Call: ${tc.name}(${argsStr})]`);
35
- }
36
- }
37
- }
38
- if (parts.length > 0) {
39
- entries.push(`Executor: ${parts.join("\n")}`);
40
- }
41
- } else if (msg.role === "toolResult" || (msg as any).role === "tool") {
42
- const tr = msg as { toolName?: string; isError?: boolean; content?: unknown };
43
- const text = textFrom(tr.content);
44
- const status = tr.isError ? "Error " : "";
45
- entries.push(`[Tool Result for ${tr.toolName || "unknown"}] (${status}output):\n${text}`);
46
- }
47
- } else if (entry.type === "compaction") {
48
- entries.push(`[System Compaction Summary]: ${entry.summary}`);
9
+ type RecordValue = Record<string, unknown>;
10
+
11
+ const isRecord = (value: unknown): value is RecordValue =>
12
+ Boolean(value) && typeof value === "object";
13
+
14
+ const contentParts = (content: unknown): unknown[] => {
15
+ if (typeof content === "string") {
16
+ return [content];
17
+ }
18
+ return Array.isArray(content) ? content : [];
19
+ };
20
+
21
+ const textFromPart = (part: unknown): string => {
22
+ if (typeof part === "string") {
23
+ return part;
24
+ }
25
+ if (!isRecord(part) || part.type !== "text") {
26
+ return "";
27
+ }
28
+ return typeof part.text === "string" ? part.text : "";
29
+ };
30
+
31
+ export const textFrom = (content: unknown): string =>
32
+ contentParts(content).map(textFromPart).join("\n").trim();
33
+
34
+ const byteLength = (value: string) => Buffer.byteLength(value, "utf8");
35
+
36
+ export interface ToolResultTruncation {
37
+ content: string;
38
+ omittedLines: number;
39
+ totalBytes: number;
40
+ totalLines: number;
41
+ truncated: boolean;
42
+ }
43
+
44
+ export const capToolResult = (
45
+ value: string,
46
+ maxLines = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES,
47
+ maxBytes = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES
48
+ ): ToolResultTruncation => {
49
+ const lines = value.split("\n");
50
+ const totalLines = lines.length;
51
+ const totalBytes = byteLength(value);
52
+ if ((maxLines === 0 || maxBytes === 0) && value.length > 0) {
53
+ return {
54
+ content: "[Tool result omitted: configured limit is zero]",
55
+ omittedLines: totalLines,
56
+ totalBytes,
57
+ totalLines,
58
+ truncated: true,
59
+ };
60
+ }
61
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
62
+ return {
63
+ content: value,
64
+ omittedLines: 0,
65
+ totalBytes,
66
+ totalLines,
67
+ truncated: false,
68
+ };
69
+ }
70
+
71
+ const marker = "[... omitted tool-result section ...]";
72
+ const availableBytes = Math.max(0, maxBytes - byteLength(marker) - 2);
73
+ const headCount = Math.max(1, Math.floor(Math.max(1, maxLines - 1) / 2));
74
+ const tailCount = Math.max(1, Math.max(1, maxLines - 1) - headCount);
75
+ const head: string[] = [];
76
+ let headBytes = 0;
77
+ for (const line of lines.slice(0, headCount)) {
78
+ const next = headBytes + byteLength(line) + (head.length ? 1 : 0);
79
+ if (head.length && next > Math.ceil(availableBytes / 2)) {
80
+ break;
81
+ }
82
+ head.push(line);
83
+ headBytes = next;
84
+ }
85
+ const tail: string[] = [];
86
+ let tailBytes = 0;
87
+ const tailStart = Math.max(head.length, lines.length - tailCount);
88
+ for (const line of lines.slice(tailStart)) {
89
+ const next = tailBytes + byteLength(line) + (tail.length ? 1 : 0);
90
+ if (tail.length && next > Math.floor(availableBytes / 2)) {
91
+ break;
92
+ }
93
+ tail.push(line);
94
+ tailBytes = next;
95
+ }
96
+ const content = [...head, marker, ...tail].join("\n");
97
+ return {
98
+ content,
99
+ omittedLines: Math.max(0, totalLines - head.length - tail.length),
100
+ totalBytes,
101
+ totalLines,
102
+ truncated: true,
103
+ };
104
+ };
105
+
106
+ const assistantEntry = (message: RecordValue): string | undefined => {
107
+ const parts: string[] = [];
108
+ const text = textFrom(message.content);
109
+ if (text) {
110
+ parts.push(text);
111
+ }
112
+ for (const part of contentParts(message.content)) {
113
+ if (!isRecord(part) || part.type !== "toolCall") {
114
+ continue;
49
115
  }
116
+ parts.push(
117
+ `[Tool Call: ${String(part.name)}(${JSON.stringify(part.arguments)})]`
118
+ );
119
+ }
120
+ return parts.length > 0 ? `Executor: ${parts.join("\n")}` : undefined;
121
+ };
122
+
123
+ const toolResultEntry = (
124
+ message: RecordValue,
125
+ toolResultMaxLines: number,
126
+ toolResultMaxBytes: number
127
+ ): string => {
128
+ const status = message.isError ? "Error " : "";
129
+ const capped = capToolResult(
130
+ textFrom(message.content),
131
+ toolResultMaxLines,
132
+ toolResultMaxBytes
133
+ );
134
+ const toolName =
135
+ typeof message.toolName === "string" ? message.toolName : "unknown";
136
+ return `[Tool Result for ${toolName}] (${status}output):\n${capped.content}`;
137
+ };
138
+
139
+ const conversationEntry = (
140
+ entry: unknown,
141
+ toolResultMaxLines: number,
142
+ toolResultMaxBytes: number
143
+ ): string | undefined => {
144
+ if (!isRecord(entry)) {
145
+ return;
146
+ }
147
+ if (entry.type === "compaction" && typeof entry.summary === "string") {
148
+ return `[System Compaction Summary]: ${entry.summary}`;
149
+ }
150
+ if (entry.type !== "message" || !isRecord(entry.message)) {
151
+ return;
152
+ }
153
+ const message = entry.message;
154
+ if (message.role === "user") {
155
+ const text = textFrom(message.content);
156
+ return text ? `User: ${text}` : undefined;
157
+ }
158
+ if (message.role === "assistant") {
159
+ return assistantEntry(message);
50
160
  }
51
- const joined = entries.join("\n\n");
52
- return joined.length > maxChars ? joined.slice(-maxChars) : joined;
161
+ if (message.role === "toolResult" || message.role === "tool") {
162
+ return toolResultEntry(message, toolResultMaxLines, toolResultMaxBytes);
163
+ }
164
+ };
165
+
166
+ const selectRecentEntries = (entries: string[], maxChars: number): string => {
167
+ const separator = "\n\n";
168
+ const joined = entries.join(separator);
169
+ if (joined.length <= maxChars || maxChars === Number.MAX_SAFE_INTEGER) {
170
+ return joined;
171
+ }
172
+ const selected: string[] = [];
173
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
174
+ const candidate = [entries[index], ...selected].join(separator);
175
+ if (candidate.length <= maxChars || selected.length === 0) {
176
+ selected.unshift(entries[index]);
177
+ } else {
178
+ break;
179
+ }
180
+ }
181
+ const omitted = entries.length - selected.length;
182
+ const suffix = selected.length
183
+ ? `${separator}${selected.join(separator)}`
184
+ : "";
185
+ return `[Older context omitted: ${omitted} complete entr${omitted === 1 ? "y" : "ies"}]${suffix}`;
186
+ };
187
+
188
+ export const recentConversation = (
189
+ ctx: ExtensionContext,
190
+ maxChars = 15_000,
191
+ toolResultMaxLines = advisorToolResultMaxLinesRef,
192
+ toolResultMaxBytes = advisorToolResultMaxBytesRef
193
+ ): string => {
194
+ if (maxChars === 0) {
195
+ return "";
196
+ }
197
+ const entries = ctx.sessionManager
198
+ .getBranch()
199
+ .map((entry) =>
200
+ conversationEntry(entry, toolResultMaxLines, toolResultMaxBytes)
201
+ )
202
+ .filter((entry): entry is string => entry !== undefined);
203
+ return selectRecentEntries(entries, maxChars);
53
204
  };
package/src/herdr.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  import net from "node:net";
2
+ import { advisorHerdrIntegrationRef } from "./config.js";
2
3
 
3
4
  const SOURCE = "pi-advisor:advisor-activity";
4
5
  const BLOCK_SOURCE = "pi-advisor:advisor-block";
6
+ const NOTIFICATION_SOURCE = "pi-advisor:advisor-notification";
5
7
  const HERDR_PI_SOURCE = "herdr:pi";
6
- let sequence = Date.now() * 1_000;
7
-
8
- const nextSequence = () => ++sequence;
8
+ let sequence = Date.now() * 1000;
9
+ const nextSequence = () => {
10
+ sequence += 1;
11
+ return sequence;
12
+ };
9
13
 
10
- type HerdrRequest = {
14
+ export interface HerdrMetadataRequest {
11
15
  id: string;
12
16
  method: "pane.report_metadata";
13
17
  params: {
@@ -19,17 +23,56 @@ type HerdrRequest = {
19
23
  clear_state_labels?: true;
20
24
  seq: number;
21
25
  };
22
- };
23
-
26
+ }
27
+ export interface HerdrNotificationRequest {
28
+ id: string;
29
+ method: "notification.show";
30
+ params: {
31
+ title: string;
32
+ body: string;
33
+ position: "top-left";
34
+ sound: "request";
35
+ };
36
+ }
37
+ export type HerdrRequest = HerdrMetadataRequest | HerdrNotificationRequest;
24
38
  type Report = (request: HerdrRequest) => void;
25
39
 
40
+ const isControlCharacter = (character: string) =>
41
+ character <= "\u001f" || character === "\u007f";
42
+
43
+ const cleanNotification = (value: string, max: number) =>
44
+ [...value]
45
+ .map((character) => (isControlCharacter(character) ? " " : character))
46
+ .join("")
47
+ .replace(/\s+/g, " ")
48
+ .trim()
49
+ .slice(0, max);
50
+
51
+ export const createHerdrNotificationRequest = (
52
+ title: string,
53
+ body: string
54
+ ): HerdrNotificationRequest => ({
55
+ id: `${NOTIFICATION_SOURCE}:${nextSequence()}`,
56
+ method: "notification.show",
57
+ params: {
58
+ body: cleanNotification(body, 240),
59
+ position: "top-left",
60
+ sound: "request",
61
+ title: cleanNotification(title, 80),
62
+ },
63
+ });
64
+
26
65
  const sendToHerdr: Report = (request) => {
27
- if (process.env.HERDR_ENV !== "1") return;
66
+ if (process.env.HERDR_ENV !== "1") {
67
+ return;
68
+ }
28
69
  const paneId = process.env.HERDR_PANE_ID;
29
70
  const socketPath = process.env.HERDR_SOCKET_PATH;
30
- if (!paneId || !socketPath) return;
31
-
32
- const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
71
+ if (!(paneId && socketPath)) {
72
+ return;
73
+ }
74
+ const endpoint =
75
+ process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
33
76
  const socket = net.createConnection(endpoint);
34
77
  const timeout = setTimeout(() => socket.destroy(), 500);
35
78
  timeout.unref?.();
@@ -41,22 +84,41 @@ const sendToHerdr: Report = (request) => {
41
84
 
42
85
  export class HerdrAdvisorActivity {
43
86
  #activeConsultations = 0;
44
-
45
- constructor(private readonly report: Report = sendToHerdr) {}
87
+ private readonly report: Report;
88
+ private readonly enabled: () => boolean;
89
+
90
+ constructor(
91
+ report: Report = sendToHerdr,
92
+ enabled: () => boolean = () => true
93
+ ) {
94
+ this.report = report;
95
+ this.enabled = enabled;
96
+ }
46
97
 
47
98
  start() {
99
+ if (!this.enabled()) {
100
+ return;
101
+ }
48
102
  this.#activeConsultations += 1;
49
- if (this.#activeConsultations === 1) this.safeReport(false);
103
+ if (this.#activeConsultations === 1) {
104
+ this.safeReport(false);
105
+ }
50
106
  }
51
107
 
52
108
  finish() {
53
- if (this.#activeConsultations === 0) return;
109
+ if (this.#activeConsultations === 0) {
110
+ return;
111
+ }
54
112
  this.#activeConsultations -= 1;
55
- if (this.#activeConsultations === 0) this.safeReport(true);
113
+ if (this.#activeConsultations === 0) {
114
+ this.safeReport(true);
115
+ }
56
116
  }
57
117
 
58
118
  clear() {
59
- if (this.#activeConsultations === 0) return;
119
+ if (this.#activeConsultations === 0) {
120
+ return;
121
+ }
60
122
  this.#activeConsultations = 0;
61
123
  this.safeReport(true);
62
124
  }
@@ -65,20 +127,22 @@ export class HerdrAdvisorActivity {
65
127
  try {
66
128
  this.report(this.request(clear));
67
129
  } catch {
68
- // Herdr is optional; an unavailable integration must never affect advice.
130
+ /* Herdr is optional. */
69
131
  }
70
132
  }
71
133
 
72
- private request(clear: boolean): HerdrRequest {
134
+ private request(clear: boolean): HerdrMetadataRequest {
73
135
  return {
74
136
  id: `${SOURCE}:${nextSequence()}`,
75
137
  method: "pane.report_metadata",
76
138
  params: {
77
- pane_id: process.env.HERDR_PANE_ID ?? "",
78
- source: SOURCE,
79
139
  agent: "pi",
80
140
  applies_to_source: HERDR_PI_SOURCE,
81
- ...(clear ? { clear_state_labels: true } : { state_labels: { working: "seeking advice" } }),
141
+ pane_id: process.env.HERDR_PANE_ID ?? "",
142
+ source: SOURCE,
143
+ ...(clear
144
+ ? { clear_state_labels: true }
145
+ : { state_labels: { working: "seeking advice" } }),
82
146
  seq: nextSequence(),
83
147
  },
84
148
  };
@@ -87,40 +151,91 @@ export class HerdrAdvisorActivity {
87
151
 
88
152
  export class HerdrAdvisorBlock {
89
153
  #blocked = false;
90
-
91
- constructor(private readonly report: Report = sendToHerdr) {}
154
+ private readonly report: Report;
155
+ private readonly enabled: () => boolean;
156
+
157
+ constructor(
158
+ report: Report = sendToHerdr,
159
+ enabled: () => boolean = () => true
160
+ ) {
161
+ this.report = report;
162
+ this.enabled = enabled;
163
+ }
92
164
 
93
165
  set(reason: string) {
166
+ if (!this.enabled()) {
167
+ return;
168
+ }
94
169
  this.#blocked = true;
95
170
  this.safeReport({ blocked: reason });
96
171
  }
97
172
 
98
173
  clear() {
99
- if (!this.#blocked) return;
174
+ if (!this.isBlocked()) {
175
+ return;
176
+ }
100
177
  this.#blocked = false;
178
+ if (!this.enabled()) {
179
+ return;
180
+ }
101
181
  try {
102
182
  this.report({
103
183
  id: `${BLOCK_SOURCE}:${nextSequence()}`,
104
184
  method: "pane.report_metadata",
105
- params: { pane_id: process.env.HERDR_PANE_ID ?? "", source: BLOCK_SOURCE, agent: "pi", applies_to_source: HERDR_PI_SOURCE, clear_state_labels: true, seq: nextSequence() },
185
+ params: {
186
+ agent: "pi",
187
+ applies_to_source: HERDR_PI_SOURCE,
188
+ clear_state_labels: true,
189
+ pane_id: process.env.HERDR_PANE_ID ?? "",
190
+ seq: nextSequence(),
191
+ source: BLOCK_SOURCE,
192
+ },
106
193
  });
107
194
  } catch {
108
- // Herdr remains optional.
195
+ /* Herdr is optional. */
109
196
  }
110
197
  }
111
198
 
199
+ private isBlocked() {
200
+ return this.#blocked;
201
+ }
202
+
112
203
  private safeReport(labels: { blocked: string }) {
113
204
  try {
114
205
  this.report({
115
206
  id: `${BLOCK_SOURCE}:${nextSequence()}`,
116
207
  method: "pane.report_metadata",
117
- params: { pane_id: process.env.HERDR_PANE_ID ?? "", source: BLOCK_SOURCE, agent: "pi", applies_to_source: HERDR_PI_SOURCE, state_labels: labels, seq: nextSequence() },
208
+ params: {
209
+ agent: "pi",
210
+ applies_to_source: HERDR_PI_SOURCE,
211
+ pane_id: process.env.HERDR_PANE_ID ?? "",
212
+ seq: nextSequence(),
213
+ source: BLOCK_SOURCE,
214
+ state_labels: labels,
215
+ },
118
216
  });
119
217
  } catch {
120
- // Herdr remains optional.
218
+ /* Herdr is optional. */
121
219
  }
122
220
  }
123
221
  }
124
222
 
125
- export const herdrAdvisorActivity = new HerdrAdvisorActivity();
126
- export const herdrAdvisorBlock = new HerdrAdvisorBlock();
223
+ export const notifyHerdrAdvisorFailure = (title: string, body: string) => {
224
+ if (!advisorHerdrIntegrationRef) {
225
+ return;
226
+ }
227
+ try {
228
+ sendToHerdr(createHerdrNotificationRequest(title, body));
229
+ } catch {
230
+ /* Herdr transport never changes Advisor safety. */
231
+ }
232
+ };
233
+
234
+ export const herdrAdvisorActivity = new HerdrAdvisorActivity(
235
+ sendToHerdr,
236
+ () => advisorHerdrIntegrationRef
237
+ );
238
+ export const herdrAdvisorBlock = new HerdrAdvisorBlock(
239
+ sendToHerdr,
240
+ () => advisorHerdrIntegrationRef
241
+ );