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,76 +1,225 @@
1
- export type AdvisorVerdict = "proceed" | "revise" | "blocked" | "insufficient-evidence";
2
- export type ConsultationOrigin = "executor" | "automatic" | "manual";
1
+ export type GateDecision = "proceed" | "revise" | "blocked";
2
+ export type ConsultationTrigger = "manual" | "executor-requested";
3
+ export type GateTrigger =
4
+ | "repeated-tool-call"
5
+ | "completion-review"
6
+ | "custom-rule";
7
+ export type AdvisorTrigger = ConsultationTrigger | GateTrigger;
8
+ export type ExecutionEffect = "continued" | "tool-blocked" | "session-blocked";
3
9
 
4
- type Consultation = { origin: ConsultationOrigin; verdict?: AdvisorVerdict };
10
+ export interface AdvisorInvocationRecord {
11
+ cost?: number;
12
+ decision?: GateDecision;
13
+ executionEffect: ExecutionEffect;
14
+ failure?: string;
15
+ kind: "markdown" | "gate";
16
+ model: string;
17
+ trigger: AdvisorTrigger;
18
+ usage?: unknown;
19
+ }
20
+
21
+ const WHITESPACE = /\s/;
22
+ const TIMESTAMP_KEYS = new Set([
23
+ "createdat",
24
+ "date",
25
+ "datetime",
26
+ "time",
27
+ "timestamp",
28
+ "updatedat",
29
+ ]);
30
+ const REQUEST_ID_KEYS = new Set(["correlationid", "requestid", "traceid"]);
31
+ const normalizedKey = (key: string) => key.replace(/[-_]/g, "").toLowerCase();
32
+ const isVolatileKey = (key: string, keys: Set<string>) =>
33
+ keys.has(normalizedKey(key));
5
34
 
6
- const stableJson = (value: unknown): string => {
7
- if (value === null || typeof value !== "object") return JSON.stringify(value);
8
- if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
9
- const record = value as Record<string, unknown>;
10
- return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
35
+ const normalizeShellWhitespace = (command: string) => {
36
+ let result = "";
37
+ let quote: "'" | '"' | "`" | undefined;
38
+ let pendingSpace = false;
39
+ for (const char of command.trim()) {
40
+ if (quote) {
41
+ result += char;
42
+ if (char === quote) {
43
+ quote = undefined;
44
+ }
45
+ continue;
46
+ }
47
+ if (char === "'" || char === '"' || char === "`") {
48
+ if (pendingSpace && result) {
49
+ result += " ";
50
+ }
51
+ pendingSpace = false;
52
+ quote = char;
53
+ result += char;
54
+ } else if (WHITESPACE.test(char)) {
55
+ pendingSpace = true;
56
+ } else {
57
+ if (pendingSpace && result) {
58
+ result += " ";
59
+ }
60
+ pendingSpace = false;
61
+ result += char;
62
+ }
63
+ }
64
+ return result;
11
65
  };
12
66
 
67
+ const normalizeString = (value: string) =>
68
+ value
69
+ .replace(/\/(?:private\/)?tmp\/[^\s/]+/g, "/tmp/<temporary>")
70
+ .replace(/\/var\/folders\/[^\s/]+/g, "/var/folders/<temporary>");
71
+
72
+ export const normalizeToolInput = (
73
+ toolName: string,
74
+ input: unknown
75
+ ): unknown => {
76
+ const visit = (value: unknown, key?: string): unknown => {
77
+ if (typeof value === "string") {
78
+ if (key && isVolatileKey(key, TIMESTAMP_KEYS)) {
79
+ return "<timestamp>";
80
+ }
81
+ if (key && isVolatileKey(key, REQUEST_ID_KEYS)) {
82
+ return "<request-id>";
83
+ }
84
+ const normalized = normalizeString(value);
85
+ return toolName === "bash" && key === "command"
86
+ ? normalizeShellWhitespace(normalized)
87
+ : normalized;
88
+ }
89
+ if (Array.isArray(value)) {
90
+ return value.map((item) => visit(item));
91
+ }
92
+ if (value && typeof value === "object") {
93
+ const record = value as Record<string, unknown>;
94
+ return Object.fromEntries(
95
+ Object.keys(record)
96
+ .sort()
97
+ .map((childKey) => [childKey, visit(record[childKey], childKey)])
98
+ );
99
+ }
100
+ return value;
101
+ };
102
+ return visit(input);
103
+ };
104
+
105
+ export const normalizedToolSignature = (toolName: string, input: unknown) =>
106
+ `${toolName}:${JSON.stringify(normalizeToolInput(toolName, input))}`;
107
+
13
108
  export class AdvisorSessionState {
14
109
  #previousSignature?: string;
15
110
  #repetitions = 0;
16
111
  #blockedReason?: string;
17
- #consultations: Consultation[] = [];
112
+ #invocations: AdvisorInvocationRecord[] = [];
18
113
  #loopInterventions = 0;
19
- #automaticCalls = 0;
114
+ #consumedCalls = 0;
20
115
 
21
116
  resetTask() {
22
117
  this.#previousSignature = undefined;
23
118
  this.#repetitions = 0;
24
119
  this.#blockedReason = undefined;
25
- this.#consultations = [];
120
+ this.#invocations = [];
26
121
  this.#loopInterventions = 0;
27
- this.#automaticCalls = 0;
122
+ this.#consumedCalls = 0;
28
123
  }
29
124
 
30
- clearBlocked() { this.#blockedReason = undefined; }
31
- resetRepetition() { this.#previousSignature = undefined; this.#repetitions = 0; }
32
- get blocked() { return this.#blockedReason !== undefined; }
33
- get blockedReason() { return this.#blockedReason; }
34
- block(reason: string) { this.#blockedReason ??= reason; }
125
+ clearBlocked() {
126
+ this.#blockedReason = undefined;
127
+ }
128
+ resetRepetition() {
129
+ this.#previousSignature = undefined;
130
+ this.#repetitions = 0;
131
+ }
132
+ get blocked() {
133
+ return this.#blockedReason !== undefined;
134
+ }
135
+ get blockedReason() {
136
+ return this.#blockedReason;
137
+ }
138
+ block(reason: string) {
139
+ this.#blockedReason ??= reason;
140
+ }
35
141
 
36
142
  recordToolCall(toolName: string, input: unknown, threshold: number) {
37
- if (toolName === "ask_advisor") return false;
38
- const signature = `${toolName}:${stableJson(input)}`;
39
- this.#repetitions = signature === this.#previousSignature ? this.#repetitions + 1 : 1;
143
+ if (toolName === "ask_advisor") {
144
+ return false;
145
+ }
146
+ const signature = normalizedToolSignature(toolName, input);
147
+ this.#repetitions =
148
+ signature === this.#previousSignature ? this.#repetitions + 1 : 1;
40
149
  this.#previousSignature = signature;
41
- if (this.#repetitions < threshold) return false;
150
+ if (this.#repetitions < threshold) {
151
+ return false;
152
+ }
42
153
  this.#loopInterventions += 1;
43
154
  return true;
44
155
  }
45
156
 
46
- canUseAutomaticCall(limit: number | undefined) {
47
- return limit === undefined || this.#automaticCalls < limit;
157
+ canConsult(limit: number | undefined) {
158
+ return limit === undefined || this.#consumedCalls < limit;
48
159
  }
49
-
50
- consumeAutomaticCall() { this.#automaticCalls += 1; }
51
- remainingAutomaticCalls(limit: number | undefined) {
52
- return limit === undefined ? undefined : Math.max(0, limit - this.#automaticCalls);
160
+ consumeCall() {
161
+ this.#consumedCalls += 1;
162
+ }
163
+ remainingCalls(limit: number | undefined) {
164
+ return limit === undefined
165
+ ? undefined
166
+ : Math.max(0, limit - this.#consumedCalls);
167
+ }
168
+ get consumedCalls() {
169
+ return this.#consumedCalls;
53
170
  }
54
171
 
55
- recordConsultation(origin: ConsultationOrigin, verdict?: AdvisorVerdict) {
56
- this.#consultations.push({ origin, verdict });
172
+ recordInvocation(record: AdvisorInvocationRecord) {
173
+ this.#invocations.push(record);
57
174
  }
58
175
 
59
176
  summary(limit: number | undefined) {
60
- if (this.#consultations.length === 0 && this.#loopInterventions === 0) return undefined;
61
- const count = (origin: ConsultationOrigin) => this.#consultations.filter((item) => item.origin === origin).length;
62
- const verdicts = (["proceed", "revise", "insufficient-evidence", "blocked"] as AdvisorVerdict[])
63
- .map((verdict) => [verdict, this.#consultations.filter((item) => item.verdict === verdict).length] as const)
64
- .filter(([, count]) => count > 0)
65
- .map(([verdict, count]) => `${count} ${verdict}`)
66
- .join(", ") || "none recorded";
67
- const budget = limit === undefined ? "unlimited" : `${this.#automaticCalls} / ${limit} used`;
177
+ if (this.#invocations.length === 0 && this.#loopInterventions === 0) {
178
+ return;
179
+ }
180
+ const markdown = this.#invocations.filter(
181
+ (item) => item.kind === "markdown"
182
+ );
183
+ const gates = this.#invocations.filter((item) => item.kind === "gate");
184
+ const countTrigger = (trigger: AdvisorTrigger) =>
185
+ this.#invocations.filter((item) => item.trigger === trigger).length;
186
+ const decisions =
187
+ (["proceed", "revise", "blocked"] as GateDecision[])
188
+ .map(
189
+ (decision) =>
190
+ [
191
+ decision,
192
+ gates.filter((item) => item.decision === decision).length,
193
+ ] as const
194
+ )
195
+ .filter(([, count]) => count > 0)
196
+ .map(([decision, count]) => `${count} ${decision}`)
197
+ .join(", ") || "none";
198
+ const effects = (effect: ExecutionEffect) =>
199
+ this.#invocations.filter((item) => item.executionEffect === effect)
200
+ .length;
201
+ const failures = this.#invocations
202
+ .filter((item) => item.failure)
203
+ .map((item) => item.failure);
204
+ const models =
205
+ [
206
+ ...new Set(this.#invocations.map((item) => item.model).filter(Boolean)),
207
+ ].join(", ") || "unknown";
208
+ const budget =
209
+ limit === undefined
210
+ ? `${this.#consumedCalls} used; unlimited remaining`
211
+ : `${this.#consumedCalls} / ${limit} used; ${Math.max(0, limit - this.#consumedCalls)} remaining`;
68
212
  return [
69
213
  "[Session Advisor Summary]",
70
- `Consultations: ${this.#consultations.length} ${count("executor")} Executor-requested, ${count("automatic")} automatic, ${count("manual")} manual`,
71
- `Verdicts: ${verdicts}`,
72
- `Loop interventions: ${this.#loopInterventions}`,
73
- `Automatic budget: ${budget}`,
214
+ `Consultations: ${markdown.length} Markdown (${countTrigger("manual")} manual, ${countTrigger("executor-requested")} executor-requested), automatic gates: ${gates.length}`,
215
+ `Triggers: ${["manual", "executor-requested", "repeated-tool-call", "completion-review", "custom-rule"].filter((trigger) => countTrigger(trigger as AdvisorTrigger) > 0).join(", ") || "none"}`,
216
+ `Models: ${models}`,
217
+ `Budget: ${budget}`,
218
+ `Markdown advice: ${markdown.length} responses`,
219
+ `Gate decisions: ${decisions}`,
220
+ `Loop matching: normalized tool signatures; ${this.#loopInterventions} gate intervention${this.#loopInterventions === 1 ? "" : "s"}`,
221
+ `Execution effects: ${effects("tool-blocked")} tool blocked, ${effects("session-blocked")} sessions blocked, ${effects("continued")} continued`,
222
+ `Failures: ${failures.length ? failures.join(", ") : "none"}`,
74
223
  ].join("\n");
75
224
  }
76
225
  }