pi-advisor-flow 0.1.9 → 0.2.1

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,223 @@
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}`);
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 markerBytes = byteLength(marker);
73
+ if (maxBytes < markerBytes || maxLines === 1) {
74
+ const content = [...marker].reduce(
75
+ (result, character) =>
76
+ byteLength(result + character) <= maxBytes
77
+ ? result + character
78
+ : result,
79
+ ""
80
+ );
81
+ return {
82
+ content,
83
+ omittedLines: totalLines,
84
+ totalBytes,
85
+ totalLines,
86
+ truncated: true,
87
+ };
88
+ }
89
+ const headCount = Math.floor((maxLines - 1) / 2);
90
+ const tailCount = maxLines - 1 - headCount;
91
+ const collect = (
92
+ candidates: string[],
93
+ maxEntries: number,
94
+ maxContentBytes: number
95
+ ) => {
96
+ const selected: string[] = [];
97
+ let used = 0;
98
+ for (const line of candidates.slice(0, maxEntries)) {
99
+ const next = used + byteLength(line) + (selected.length ? 1 : 0);
100
+ if (next > maxContentBytes) {
101
+ break;
46
102
  }
47
- } else if (entry.type === "compaction") {
48
- entries.push(`[System Compaction Summary]: ${entry.summary}`);
103
+ selected.push(line);
104
+ used = next;
105
+ }
106
+ return selected;
107
+ };
108
+ const availableBytes = maxBytes - markerBytes - 2;
109
+ const head = collect(lines, headCount, Math.floor(availableBytes / 2));
110
+ const tail = collect(
111
+ lines.slice(Math.max(head.length, lines.length - tailCount)),
112
+ tailCount,
113
+ availableBytes - byteLength(head.join("\n"))
114
+ );
115
+ const content = [...head, marker, ...tail].join("\n");
116
+ return {
117
+ content,
118
+ omittedLines: Math.max(0, totalLines - head.length - tail.length),
119
+ totalBytes,
120
+ totalLines,
121
+ truncated: true,
122
+ };
123
+ };
124
+
125
+ const assistantEntry = (message: RecordValue): string | undefined => {
126
+ const parts: string[] = [];
127
+ const text = textFrom(message.content);
128
+ if (text) {
129
+ parts.push(text);
130
+ }
131
+ for (const part of contentParts(message.content)) {
132
+ if (!isRecord(part) || part.type !== "toolCall") {
133
+ continue;
134
+ }
135
+ parts.push(
136
+ `[Tool Call: ${String(part.name)}(${JSON.stringify(part.arguments)})]`
137
+ );
138
+ }
139
+ return parts.length > 0 ? `Executor: ${parts.join("\n")}` : undefined;
140
+ };
141
+
142
+ const toolResultEntry = (
143
+ message: RecordValue,
144
+ toolResultMaxLines: number,
145
+ toolResultMaxBytes: number
146
+ ): string => {
147
+ const status = message.isError ? "Error " : "";
148
+ const capped = capToolResult(
149
+ textFrom(message.content),
150
+ toolResultMaxLines,
151
+ toolResultMaxBytes
152
+ );
153
+ const toolName =
154
+ typeof message.toolName === "string" ? message.toolName : "unknown";
155
+ return `[Tool Result for ${toolName}] (${status}output):\n${capped.content}`;
156
+ };
157
+
158
+ const conversationEntry = (
159
+ entry: unknown,
160
+ toolResultMaxLines: number,
161
+ toolResultMaxBytes: number
162
+ ): string | undefined => {
163
+ if (!isRecord(entry)) {
164
+ return;
165
+ }
166
+ if (entry.type === "compaction" && typeof entry.summary === "string") {
167
+ return `[System Compaction Summary]: ${entry.summary}`;
168
+ }
169
+ if (entry.type !== "message" || !isRecord(entry.message)) {
170
+ return;
171
+ }
172
+ const { message } = entry;
173
+ if (message.role === "user") {
174
+ const text = textFrom(message.content);
175
+ return text ? `User: ${text}` : undefined;
176
+ }
177
+ if (message.role === "assistant") {
178
+ return assistantEntry(message);
179
+ }
180
+ if (message.role === "toolResult" || message.role === "tool") {
181
+ return toolResultEntry(message, toolResultMaxLines, toolResultMaxBytes);
182
+ }
183
+ };
184
+
185
+ const selectRecentEntries = (entries: string[], maxChars: number): string => {
186
+ const separator = "\n\n";
187
+ const joined = entries.join(separator);
188
+ if (joined.length <= maxChars || maxChars === Number.MAX_SAFE_INTEGER) {
189
+ return joined;
190
+ }
191
+ const selected: string[] = [];
192
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
193
+ const candidate = [entries[index], ...selected].join(separator);
194
+ if (candidate.length <= maxChars || selected.length === 0) {
195
+ selected.unshift(entries[index]);
196
+ } else {
197
+ break;
49
198
  }
50
199
  }
51
- const joined = entries.join("\n\n");
52
- return joined.length > maxChars ? joined.slice(-maxChars) : joined;
200
+ const omitted = entries.length - selected.length;
201
+ const suffix = selected.length
202
+ ? `${separator}${selected.join(separator)}`
203
+ : "";
204
+ return `[Older context omitted: ${omitted} complete entr${omitted === 1 ? "y" : "ies"}]${suffix}`;
205
+ };
206
+
207
+ export const recentConversation = (
208
+ ctx: ExtensionContext,
209
+ maxChars = 15_000,
210
+ toolResultMaxLines = advisorToolResultMaxLinesRef,
211
+ toolResultMaxBytes = advisorToolResultMaxBytesRef
212
+ ): string => {
213
+ if (maxChars === 0) {
214
+ return "";
215
+ }
216
+ const entries = ctx.sessionManager
217
+ .getBranch()
218
+ .map((entry) =>
219
+ conversationEntry(entry, toolResultMaxLines, toolResultMaxBytes)
220
+ )
221
+ .filter((entry): entry is string => entry !== undefined);
222
+ return selectRecentEntries(entries, maxChars);
53
223
  };
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,25 +151,49 @@ 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
+ const wasBlocked: boolean = this.#blocked;
100
175
  this.#blocked = false;
176
+ if (!wasBlocked) {
177
+ return;
178
+ }
179
+ if (!this.enabled()) {
180
+ return;
181
+ }
101
182
  try {
102
183
  this.report({
103
184
  id: `${BLOCK_SOURCE}:${nextSequence()}`,
104
185
  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() },
186
+ params: {
187
+ agent: "pi",
188
+ applies_to_source: HERDR_PI_SOURCE,
189
+ clear_state_labels: true,
190
+ pane_id: process.env.HERDR_PANE_ID ?? "",
191
+ seq: nextSequence(),
192
+ source: BLOCK_SOURCE,
193
+ },
106
194
  });
107
195
  } catch {
108
- // Herdr remains optional.
196
+ /* Herdr is optional. */
109
197
  }
110
198
  }
111
199
 
@@ -114,13 +202,37 @@ export class HerdrAdvisorBlock {
114
202
  this.report({
115
203
  id: `${BLOCK_SOURCE}:${nextSequence()}`,
116
204
  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() },
205
+ params: {
206
+ agent: "pi",
207
+ applies_to_source: HERDR_PI_SOURCE,
208
+ pane_id: process.env.HERDR_PANE_ID ?? "",
209
+ seq: nextSequence(),
210
+ source: BLOCK_SOURCE,
211
+ state_labels: labels,
212
+ },
118
213
  });
119
214
  } catch {
120
- // Herdr remains optional.
215
+ /* Herdr is optional. */
121
216
  }
122
217
  }
123
218
  }
124
219
 
125
- export const herdrAdvisorActivity = new HerdrAdvisorActivity();
126
- export const herdrAdvisorBlock = new HerdrAdvisorBlock();
220
+ export const notifyHerdrAdvisorFailure = (title: string, body: string) => {
221
+ if (!advisorHerdrIntegrationRef) {
222
+ return;
223
+ }
224
+ try {
225
+ sendToHerdr(createHerdrNotificationRequest(title, body));
226
+ } catch {
227
+ /* Herdr transport never changes Advisor safety. */
228
+ }
229
+ };
230
+
231
+ export const herdrAdvisorActivity = new HerdrAdvisorActivity(
232
+ sendToHerdr,
233
+ () => advisorHerdrIntegrationRef
234
+ );
235
+ export const herdrAdvisorBlock = new HerdrAdvisorBlock(
236
+ sendToHerdr,
237
+ () => advisorHerdrIntegrationRef
238
+ );