pi-advisor-flow 0.7.0 → 0.8.1-dev.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +27 -18
  2. package/README.md +9 -8
  3. package/dist/index.js +1968 -1081
  4. package/extensions/index.ts +2 -1
  5. package/package.json +48 -35
  6. package/src/attachments.ts +11 -10
  7. package/src/child-session.ts +15 -0
  8. package/src/commands/activation-preparation.ts +1 -0
  9. package/src/commands/activation.ts +12 -3
  10. package/src/commands/lifecycle.ts +7 -1
  11. package/src/commands/manual-command.ts +7 -1
  12. package/src/commands/manual-consultation.ts +102 -92
  13. package/src/commands/manual-progress.ts +7 -5
  14. package/src/commands/model-commands.ts +1 -1
  15. package/src/commands/model-options.ts +14 -3
  16. package/src/commands/model-picker.ts +1 -0
  17. package/src/commands/registration.ts +1 -0
  18. package/src/commands/renderers.ts +67 -55
  19. package/src/commands/runtime.ts +16 -14
  20. package/src/commands/settings-commands.ts +10 -4
  21. package/src/commands/settings-persistence.ts +16 -2
  22. package/src/commands/types.ts +1 -0
  23. package/src/commands.ts +0 -1
  24. package/src/config/args.ts +1 -1
  25. package/src/config/defaults.ts +8 -1
  26. package/src/config/schema.ts +42 -17
  27. package/src/config/state.ts +12 -3
  28. package/src/config/storage.ts +34 -23
  29. package/src/config/types.ts +16 -3
  30. package/src/config/validation.ts +33 -21
  31. package/src/config.ts +3 -0
  32. package/src/content-utils.ts +24 -4
  33. package/src/conversation.ts +16 -15
  34. package/src/git.ts +10 -3
  35. package/src/herdr-block.ts +83 -0
  36. package/src/herdr-shared.ts +108 -0
  37. package/src/herdr.ts +30 -192
  38. package/src/jev/client.ts +75 -85
  39. package/src/jev/failure.ts +19 -0
  40. package/src/jev/key-store.ts +26 -11
  41. package/src/jev/ledger.ts +58 -64
  42. package/src/jev/questions.ts +26 -23
  43. package/src/jev/state.ts +15 -12
  44. package/src/jev/transport.ts +8 -3
  45. package/src/model-stream.ts +35 -37
  46. package/src/outcomes.ts +15 -11
  47. package/src/pi-settings.ts +4 -9
  48. package/src/preferences.ts +4 -2
  49. package/src/redaction.ts +10 -12
  50. package/src/scout-context.ts +59 -60
  51. package/src/scout-curation.ts +4 -13
  52. package/src/scout-groups.ts +178 -175
  53. package/src/scout-protocol.ts +74 -70
  54. package/src/scout-reconstruct.ts +1 -1
  55. package/src/scout.ts +128 -77
  56. package/src/session-state.ts +64 -49
  57. package/src/tool-result-cap.ts +18 -17
  58. package/src/tools/consult-context.ts +7 -15
  59. package/src/tools/consultation.ts +14 -4
  60. package/src/tools/gate-policy.ts +1 -0
  61. package/src/tools/gate-protocol.ts +36 -23
  62. package/src/tools/jev-filter.ts +43 -41
  63. package/src/tools/jev-turn-gate.ts +29 -9
  64. package/src/tools/loop-gate.ts +31 -20
  65. package/src/tools/model-access.ts +46 -0
  66. package/src/tools/prompts.ts +20 -25
  67. package/src/tools/register-ask-advisor.ts +36 -16
  68. package/src/tools/register-lifecycle.ts +22 -0
  69. package/src/tools/register-outcome.ts +2 -0
  70. package/src/tools/register-renderers.ts +146 -128
  71. package/src/tools/registration.ts +12 -14
  72. package/src/tools/render-advisor-result.ts +35 -22
  73. package/src/tools/render-common.ts +16 -19
  74. package/src/tools/scout-status.ts +42 -27
  75. package/src/tools/types.ts +1 -0
  76. package/src/tools.ts +0 -1
  77. package/src/ui/jev-setup-submenu.ts +65 -59
  78. package/src/ui/manual-dialog-render.ts +1 -0
  79. package/src/ui/manual-dialog.ts +39 -43
  80. package/src/ui/masked-input.ts +5 -11
  81. package/src/ui/model-multi-selector.ts +9 -0
  82. package/src/ui/model-selector-adapter.ts +31 -0
  83. package/src/ui/model-selector.ts +14 -161
  84. package/src/ui/searchable-model-list.ts +230 -0
  85. package/src/ui/settings-formatting.ts +24 -14
  86. package/src/ui/settings-items.ts +38 -1
  87. package/src/ui/settings-list-adapter.ts +15 -12
  88. package/src/ui/settings-mutations.ts +157 -70
  89. package/src/ui/settings-selector.ts +18 -9
  90. package/src/ui/text-setting-submenu.ts +3 -6
  91. package/src/ui/types.ts +14 -0
  92. package/src/ui.ts +0 -1
  93. package/src/usage.ts +26 -19
@@ -1,17 +1,18 @@
1
1
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import {
4
- CONFIG_DIR_NAME,
5
- type ExtensionContext,
6
- getAgentDir,
7
- } from "@earendil-works/pi-coding-agent";
3
+
4
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+
7
+ import { isRecord, isString } from "../content-utils.ts";
8
+ import type { RecordValue } from "../content-utils.ts";
8
9
  import { applyConfig, resetDefaults } from "./defaults.ts";
9
10
  import {
10
11
  CONFIG_SCHEMA,
11
12
  configuredModelRef,
12
- type PersistedConfigKey,
13
13
  SAVED_CONFIG_KEYS,
14
14
  } from "./schema.ts";
15
+ import type { PersistedConfigKey } from "./schema.ts";
15
16
  import { setAdvisorOutcomeLoggingRef, setPersistedModelRefs } from "./state.ts";
16
17
  import type { AdvisorConfig } from "./types.ts";
17
18
  import { unknownConfigKeys, validateConfig } from "./validation.ts";
@@ -29,6 +30,7 @@ type ConfigState = {
29
30
  };
30
31
 
31
32
  const currentConfigState = (): ConfigState =>
33
+ // SAFETY: SAVED_CONFIG_KEYS enumerates exactly the ConfigState keys, so fromEntries yields ConfigState.
32
34
  Object.fromEntries(
33
35
  SAVED_CONFIG_KEYS.map((key) => [key, CONFIG_SCHEMA[key].current()])
34
36
  ) as ConfigState;
@@ -41,12 +43,10 @@ const sameConfigValue = <Value>(left: Value, right: Value) =>
41
43
  * preserved like any unknown key, and never trigger the typo warning. */
42
44
  const RESERVED_ADVISOR_JSON_KEYS = new Set(["typesafe_api_key"]);
43
45
 
44
- export const readExistingConfig = (path: string): Record<string, unknown> => {
46
+ export const readExistingConfig = (path: string): RecordValue => {
45
47
  try {
46
- const parsed = JSON.parse(readFileSync(path, "utf8"));
47
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
48
- ? (parsed as Record<string, unknown>)
49
- : {};
48
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
49
+ return isRecord(parsed) ? parsed : {};
50
50
  } catch {
51
51
  return {};
52
52
  }
@@ -61,12 +61,14 @@ const shouldPersistConfigKey = (
61
61
  (key !== "executor" || persistExecutor);
62
62
 
63
63
  const applyChangedConfigValues = (
64
- data: Record<string, unknown>,
64
+ existing: RecordValue,
65
65
  current: ConfigState,
66
66
  changedKeys: readonly SavedConfigKey[],
67
67
  persistAdvisor: boolean,
68
68
  persistExecutor: boolean
69
- ) => {
69
+ ): RecordValue => {
70
+ const dropped = new Set<string>();
71
+ const data: RecordValue = { ...existing };
70
72
  for (const key of changedKeys) {
71
73
  if (!shouldPersistConfigKey(key, persistAdvisor, persistExecutor)) {
72
74
  continue;
@@ -74,11 +76,21 @@ const applyChangedConfigValues = (
74
76
  const value = current[key];
75
77
  const isEmptyModelRef = (key === "advisor" || key === "executor") && !value;
76
78
  if (value === undefined || isEmptyModelRef) {
77
- delete data[key];
79
+ dropped.add(key);
78
80
  } else {
79
81
  data[key] = value;
80
82
  }
81
83
  }
84
+ if (dropped.size === 0) {
85
+ return data;
86
+ }
87
+ const retained: RecordValue = {};
88
+ for (const key of Object.keys(data)) {
89
+ if (!dropped.has(key)) {
90
+ retained[key] = data[key];
91
+ }
92
+ }
93
+ return retained;
82
94
  };
83
95
 
84
96
  // Tracks the last load/save state so a later save merges fresh disk contents instead of overwriting an external edit.
@@ -86,7 +98,7 @@ let loadedConfigState: ConfigState | undefined;
86
98
  let loadedConfigPath: string | undefined;
87
99
 
88
100
  const readConfig = (path: string): AdvisorConfig => {
89
- const config = JSON.parse(readFileSync(path, "utf8"));
101
+ const config = JSON.parse(readFileSync(path, "utf-8"));
90
102
  validateConfig(config, path);
91
103
  return config;
92
104
  };
@@ -142,9 +154,9 @@ export const loadConfig = (_ctx: ExtensionContext) => {
142
154
  );
143
155
  if (globalConfig) {
144
156
  applyConfig(globalConfig);
145
- const unknownKeys = unknownConfigKeys(
146
- globalConfig as Record<string, unknown>
147
- ).filter((key) => !RESERVED_ADVISOR_JSON_KEYS.has(key));
157
+ const unknownKeys = unknownConfigKeys(globalConfig).filter(
158
+ (key) => !RESERVED_ADVISOR_JSON_KEYS.has(key)
159
+ );
148
160
  const warningIdentity = `${global}:${configIdentity(global)}`;
149
161
  if (
150
162
  unknownKeys.length > 0 &&
@@ -196,9 +208,8 @@ export const saveConfig = (
196
208
  if (changedKeys.length === 0) {
197
209
  return path;
198
210
  }
199
- const data = { ...existing };
200
- applyChangedConfigValues(
201
- data,
211
+ const data = applyChangedConfigValues(
212
+ existing,
202
213
  current,
203
214
  changedKeys,
204
215
  persistAdvisor,
@@ -211,14 +222,14 @@ export const saveConfig = (
211
222
  nextLoadedState.advisor = baseline
212
223
  ? baseline.advisor
213
224
  : configuredModelRef(
214
- typeof existing.advisor === "string" ? existing.advisor : undefined
225
+ isString(existing.advisor) ? existing.advisor : undefined
215
226
  );
216
227
  }
217
228
  if (!persistExecutor) {
218
229
  nextLoadedState.executor = baseline
219
230
  ? baseline.executor
220
231
  : configuredModelRef(
221
- typeof existing.executor === "string" ? existing.executor : undefined
232
+ isString(existing.executor) ? existing.executor : undefined
222
233
  );
223
234
  }
224
235
  loadedConfigState = nextLoadedState;
@@ -1,13 +1,23 @@
1
1
  import {
2
+ DEFAULT_MAX_BYTES as PI_DEFAULT_MAX_BYTES,
3
+ DEFAULT_MAX_LINES as PI_DEFAULT_MAX_LINES,
4
+ } from "@earendil-works/pi-coding-agent";
5
+
6
+ import type { GitContextLevel } from "../git.ts";
7
+
8
+ export {
2
9
  DEFAULT_MAX_BYTES,
3
10
  DEFAULT_MAX_LINES,
4
11
  } from "@earendil-works/pi-coding-agent";
5
- import type { GitContextLevel } from "../git.ts";
6
12
 
7
13
  export const DEFAULT_CONTEXT_MAX_CHARS = 15_000;
8
14
  export const MAX_CONTEXT_MAX_CHARS = Number.MAX_SAFE_INTEGER;
9
- export const DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES = DEFAULT_MAX_LINES;
10
- export const DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES = DEFAULT_MAX_BYTES;
15
+ const DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES = PI_DEFAULT_MAX_LINES;
16
+ const DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES = PI_DEFAULT_MAX_BYTES;
17
+ export {
18
+ DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES,
19
+ DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES,
20
+ };
11
21
  export const DEFAULT_ADVISOR_GIT_CONTEXT_MAX_CHARS = 20_000;
12
22
  export const DEFAULT_JEV_MODEL = "jev-latest";
13
23
  // Provisional: the latency benchmark was dropped from scope; revisit if live
@@ -71,6 +81,7 @@ export interface AdvisorConfig {
71
81
  advisorJevTurnGateNoulThreshold?: number;
72
82
  advisorLoopThreshold?: number;
73
83
  advisorMaxCallsPerSession?: number;
84
+ advisorModelWhitelist?: string[];
74
85
  advisorOutcomeLogging?: boolean;
75
86
  advisorPlanGate?: boolean;
76
87
  advisorRedactSecrets?: boolean;
@@ -90,3 +101,5 @@ export interface AdvisorConfig {
90
101
  showUsageFooter?: boolean;
91
102
  simpleMode?: boolean;
92
103
  }
104
+
105
+ export type AdvisorConfigValue = AdvisorConfig[keyof AdvisorConfig];
@@ -1,9 +1,12 @@
1
- import type { ConfigKeySchema } from "./schema.ts";
2
- import { CONFIG_SCHEMA, type ConfigKey, SCHEMA_BY_KEY } from "./schema.ts";
1
+ import { isBoolean, isRecord, isString } from "../content-utils.ts";
2
+ import type { JsonValue } from "../content-utils.ts";
3
+ import type { ConfigKey, ConfigKeySchema } from "./schema.ts";
4
+ import { configKeys, SCHEMA_BY_KEY } from "./schema.ts";
3
5
  import type { AdvisorConfig } from "./types.ts";
4
6
 
5
- // biome-ignore lint/performance/noBarrelFile: re-exports keep the isValid* validators on their historical module for the config facade.
7
+ // Re-exports keep the isValid* validators on their historical module for the config facade.
6
8
  export {
9
+ isValidAdvisorModelWhitelist,
7
10
  isValidAdvisorToolPolicies,
8
11
  isValidContextMaxChars,
9
12
  isValidGateFailureMode,
@@ -13,19 +16,15 @@ export {
13
16
  isValidToolResultMaxLines,
14
17
  } from "./schema.ts";
15
18
 
16
- const CONFIG_KEYS = new Set<ConfigKey>(
17
- Object.keys(CONFIG_SCHEMA) as ConfigKey[]
18
- );
19
+ const CONFIG_KEYS = new Set<string>(configKeys);
19
20
 
20
21
  const keysOfType = (type: ConfigKeySchema["type"]): readonly ConfigKey[] =>
21
- (Object.keys(CONFIG_SCHEMA) as ConfigKey[]).filter(
22
- (key) => SCHEMA_BY_KEY[key].type === type
23
- );
22
+ configKeys.filter((key) => SCHEMA_BY_KEY[key].type === type);
24
23
 
25
24
  const BOOLEAN_CONFIG_KEYS = keysOfType("boolean");
26
25
  const STRING_CONFIG_KEYS = keysOfType("string");
27
26
 
28
- type ConfigRecord = Record<string, unknown>;
27
+ type ConfigRecord = Record<string, JsonValue>;
29
28
 
30
29
  const invalidConfigValue = (
31
30
  path: string,
@@ -37,12 +36,12 @@ const invalidConfigValue = (
37
36
  );
38
37
  };
39
38
 
40
- export const unknownConfigKeys = (config: ConfigRecord) =>
41
- Object.keys(config).filter((key) => !CONFIG_KEYS.has(key as ConfigKey));
39
+ export const unknownConfigKeys = (config: AdvisorConfig) =>
40
+ Object.keys(config).filter((key) => !CONFIG_KEYS.has(key));
42
41
 
43
42
  const validateStringValues = (config: ConfigRecord, path: string) => {
44
43
  for (const key of STRING_CONFIG_KEYS) {
45
- if (config[key] !== undefined && typeof config[key] !== "string") {
44
+ if (config[key] !== undefined && !isString(config[key])) {
46
45
  invalidConfigValue(path, key, SCHEMA_BY_KEY[key].accepted);
47
46
  }
48
47
  }
@@ -50,7 +49,7 @@ const validateStringValues = (config: ConfigRecord, path: string) => {
50
49
 
51
50
  const validateBooleanValues = (config: ConfigRecord, path: string) => {
52
51
  for (const key of BOOLEAN_CONFIG_KEYS) {
53
- if (config[key] !== undefined && typeof config[key] !== "boolean") {
52
+ if (config[key] !== undefined && !isBoolean(config[key])) {
54
53
  invalidConfigValue(path, key, SCHEMA_BY_KEY[key].accepted);
55
54
  }
56
55
  }
@@ -69,6 +68,19 @@ const validateNumericValues = (config: ConfigRecord, path: string) => {
69
68
  }
70
69
  };
71
70
 
71
+ const validateArrayValues = (config: ConfigRecord, path: string) => {
72
+ for (const key of keysOfType("array")) {
73
+ const isValid = SCHEMA_BY_KEY[key].validate;
74
+ if (
75
+ config[key] !== undefined &&
76
+ isValid !== undefined &&
77
+ !isValid(config[key])
78
+ ) {
79
+ invalidConfigValue(path, key, SCHEMA_BY_KEY[key].accepted);
80
+ }
81
+ }
82
+ };
83
+
72
84
  const validateEnumValues = (config: ConfigRecord, path: string) => {
73
85
  for (const key of keysOfType("enum")) {
74
86
  const isValid = SCHEMA_BY_KEY[key].validate;
@@ -99,16 +111,16 @@ export const validateConfig = (
99
111
  value: unknown,
100
112
  path = "advisor.json"
101
113
  ): value is AdvisorConfig => {
102
- if (!value || typeof value !== "object" || Array.isArray(value)) {
114
+ if (!isRecord(value)) {
103
115
  throw new TypeError(
104
116
  `Invalid advisor configuration at ${path}: expected a JSON object.`
105
117
  );
106
118
  }
107
- const config = value as ConfigRecord;
108
- validateStringValues(config, path);
109
- validateBooleanValues(config, path);
110
- validateNumericValues(config, path);
111
- validateObjectValues(config, path);
112
- validateEnumValues(config, path);
119
+ validateStringValues(value, path);
120
+ validateBooleanValues(value, path);
121
+ validateNumericValues(value, path);
122
+ validateObjectValues(value, path);
123
+ validateArrayValues(value, path);
124
+ validateEnumValues(value, path);
113
125
  return true;
114
126
  };
package/src/config.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  advisorHerdrIntegrationRef,
15
15
  advisorLoopThresholdRef,
16
16
  advisorMaxCallsPerSessionRef,
17
+ advisorModelWhitelistRef,
17
18
  advisorOutcomeLoggingRef,
18
19
  advisorPlanGateRef,
19
20
  advisorRedactSecretsRef,
@@ -46,6 +47,7 @@ export {
46
47
  setAdvisorHerdrIntegrationRef,
47
48
  setAdvisorLoopThresholdRef,
48
49
  setAdvisorMaxCallsPerSessionRef,
50
+ setAdvisorModelWhitelistRef,
49
51
  setAdvisorOutcomeLoggingRef,
50
52
  setAdvisorPlanGateRef,
51
53
  setAdvisorRedactSecretsRef,
@@ -93,6 +95,7 @@ export {
93
95
  MAX_CONTEXT_MAX_CHARS,
94
96
  } from "./config/types.ts";
95
97
  export {
98
+ isValidAdvisorModelWhitelist,
96
99
  isValidAdvisorToolPolicies,
97
100
  isValidContextMaxChars,
98
101
  isValidGateFailureMode,
@@ -1,14 +1,34 @@
1
1
  /** Shared untyped-content inspection helpers. */
2
2
 
3
- export type RecordValue = Record<string, unknown>;
3
+ export type JsonValue =
4
+ | null
5
+ | boolean
6
+ | number
7
+ | string
8
+ | JsonValue[]
9
+ | { [key: string]: JsonValue };
10
+
11
+ export type RecordValue = Record<string, JsonValue>;
4
12
 
5
13
  export const isRecord = (value: unknown): value is RecordValue =>
6
14
  Boolean(value) && typeof value === "object" && !Array.isArray(value);
7
15
 
8
- export const byteLength = (value: string) => Buffer.byteLength(value, "utf8");
16
+ export const isRecordOf = <Value>(value: Value): value is Value & RecordValue =>
17
+ Boolean(value) && typeof value === "object" && !Array.isArray(value);
18
+
19
+ export const isString = <Value>(value: Value): value is Value & string =>
20
+ typeof value === "string";
21
+
22
+ export const isNumber = <Value>(value: Value): value is Value & number =>
23
+ typeof value === "number";
24
+
25
+ export const isBoolean = <Value>(value: Value): value is Value & boolean =>
26
+ typeof value === "boolean";
27
+
28
+ export const byteLength = (value: string) => Buffer.byteLength(value, "utf-8");
9
29
 
10
- export const contentParts = (content: unknown): unknown[] => {
11
- if (typeof content === "string") {
30
+ export const contentParts = <Content>(content: Content): unknown[] => {
31
+ if (isString(content)) {
12
32
  return [content];
13
33
  }
14
34
  return Array.isArray(content) ? content : [];
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
2
3
  import {
3
4
  advisorRedactSecretsRef,
4
5
  advisorToolPoliciesRef,
@@ -6,21 +7,22 @@ import {
6
7
  advisorToolResultMaxLinesRef,
7
8
  } from "./config/state.ts";
8
9
  import type { AdvisorToolPolicies } from "./config/types.ts";
9
- import { contentParts, isRecord, type RecordValue } from "./content-utils.ts";
10
+ import { contentParts, isRecordOf, isString } from "./content-utils.ts";
11
+ import type { RecordValue } from "./content-utils.ts";
10
12
  import { redactSecrets } from "./redaction.ts";
11
13
  import { capToolResult } from "./tool-result-cap.ts";
12
14
 
13
- const textFromPart = (part: unknown): string => {
14
- if (typeof part === "string") {
15
+ const textFromPart = <Part>(part: Part): string => {
16
+ if (isString(part)) {
15
17
  return part;
16
18
  }
17
- if (!isRecord(part) || part.type !== "text") {
19
+ if (!isRecordOf(part) || part.type !== "text") {
18
20
  return "";
19
21
  }
20
- return typeof part.text === "string" ? part.text : "";
22
+ return isString(part.text) ? part.text : "";
21
23
  };
22
24
 
23
- export const textFrom = (content: unknown): string =>
25
+ export const textFrom = <Content>(content: Content): string =>
24
26
  contentParts(content).map(textFromPart).join("\n").trim();
25
27
 
26
28
  const assistantEntry = (
@@ -34,10 +36,10 @@ const assistantEntry = (
34
36
  parts.push(redact ? redactSecrets(text) : text);
35
37
  }
36
38
  for (const part of contentParts(message.content)) {
37
- if (!isRecord(part) || part.type !== "toolCall") {
39
+ if (!isRecordOf(part) || part.type !== "toolCall") {
38
40
  continue;
39
41
  }
40
- const toolName = typeof part.name === "string" ? part.name : "unknown";
42
+ const toolName = isString(part.name) ? part.name : "unknown";
41
43
  const policy = policies[toolName] ?? "full";
42
44
  if (policy === "exclude") {
43
45
  parts.push(`[Tool Call: ${toolName}] (excluded by Advisor tool policy)`);
@@ -65,8 +67,7 @@ const toolResultEntry = (
65
67
  redact: boolean
66
68
  ): string => {
67
69
  const status = message.isError ? "error" : "success";
68
- const toolName =
69
- typeof message.toolName === "string" ? message.toolName : "unknown";
70
+ const toolName = isString(message.toolName) ? message.toolName : "unknown";
70
71
  const policy = policies[toolName] ?? "full";
71
72
  const source = textFrom(message.content);
72
73
  if (policy === "exclude") {
@@ -89,20 +90,20 @@ const toolResultEntry = (
89
90
  return `[Tool Result for ${toolName}] (${message.isError ? "Error " : ""}output):\n${capped.content}`;
90
91
  };
91
92
 
92
- export const conversationEntry = (
93
- entry: unknown,
93
+ export const conversationEntry = <Entry>(
94
+ entry: Entry,
94
95
  toolResultMaxLines: number,
95
96
  toolResultMaxBytes: number,
96
97
  policies: AdvisorToolPolicies,
97
98
  redact: boolean
98
99
  ): string | undefined => {
99
- if (!isRecord(entry)) {
100
+ if (!isRecordOf(entry)) {
100
101
  return;
101
102
  }
102
- if (entry.type === "compaction" && typeof entry.summary === "string") {
103
+ if (entry.type === "compaction" && isString(entry.summary)) {
103
104
  return `[System Compaction Summary]: ${redact ? redactSecrets(entry.summary) : entry.summary}`;
104
105
  }
105
- if (entry.type !== "message" || !isRecord(entry.message)) {
106
+ if (entry.type !== "message" || !isRecordOf(entry.message)) {
106
107
  return;
107
108
  }
108
109
  const { message } = entry;
package/src/git.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
 
3
+ import { isString } from "./content-utils.ts";
4
+
3
5
  /** How much repository change context may leave the machine. */
4
6
  export type GitContextLevel = "off" | "summary" | "full";
5
7
 
6
8
  export const GIT_CONTEXT_LEVELS: GitContextLevel[] = ["off", "summary", "full"];
7
9
 
10
+ const GIT_CONTEXT_LEVEL_SET = new Set<string>(GIT_CONTEXT_LEVELS);
11
+
8
12
  export const isValidGitContextLevel = (
9
13
  value: unknown
10
14
  ): value is GitContextLevel =>
11
- GIT_CONTEXT_LEVELS.includes(value as GitContextLevel);
15
+ isString(value) && GIT_CONTEXT_LEVEL_SET.has(value);
12
16
 
13
17
  const LEVEL_RANK: Record<GitContextLevel, number> = {
14
18
  full: 2,
@@ -47,7 +51,10 @@ export interface GitContextResult {
47
51
  * would end the region early and promote the remainder to trusted instructions.
48
52
  */
49
53
  export const escapeRepositoryText = (value: string) =>
50
- value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
54
+ value
55
+ .replaceAll("&", "&amp;")
56
+ .replaceAll("<", "&lt;")
57
+ .replaceAll(">", "&gt;");
51
58
 
52
59
  const TRUNCATION_NOTICE =
53
60
  "\n[Repository context truncated: it exceeded the configured limit.]";
@@ -84,7 +91,7 @@ const deadlineRunner = (): GitRunner => {
84
91
  }
85
92
  return execFileSync("git", args, {
86
93
  cwd,
87
- encoding: "utf8",
94
+ encoding: "utf-8",
88
95
  maxBuffer: GIT_MAX_BUFFER,
89
96
  // Never use a shell: arguments are fixed and must not be re-parsed.
90
97
  shell: false,
@@ -0,0 +1,83 @@
1
+ import type { Report } from "./herdr-shared.ts";
2
+ import {
3
+ BLOCK_SOURCE,
4
+ cleanNotification,
5
+ HERDR_PI_SOURCE,
6
+ nextSequence,
7
+ safeEmitBlocked,
8
+ sendToHerdr,
9
+ } from "./herdr-shared.ts";
10
+
11
+ export class HerdrAdvisorBlock {
12
+ #blocked = false;
13
+ private readonly report: Report;
14
+ private readonly enabled: () => boolean;
15
+
16
+ constructor(
17
+ report: Report = sendToHerdr,
18
+ enabled: () => boolean = () => true
19
+ ) {
20
+ this.report = report;
21
+ this.enabled = enabled;
22
+ }
23
+
24
+ set(reason: string) {
25
+ if (!this.enabled()) {
26
+ return;
27
+ }
28
+ const label = cleanNotification(reason, 200);
29
+ const wasBlocked: boolean = this.#blocked;
30
+ if (!wasBlocked) {
31
+ // The listener refcounts, so only the false → true edge may emit.
32
+ safeEmitBlocked(true, label);
33
+ }
34
+ this.#blocked = true;
35
+ this.safeReport({ blocked: label });
36
+ }
37
+
38
+ clear() {
39
+ const wasBlocked: boolean = this.#blocked;
40
+ this.#blocked = false;
41
+ if (!wasBlocked) {
42
+ return;
43
+ }
44
+ // Clearing previously reported state is a de-escalation and must still be
45
+ // delivered if integration was disabled after the block was reported.
46
+ safeEmitBlocked(false);
47
+ try {
48
+ this.report({
49
+ id: `${BLOCK_SOURCE}:${nextSequence()}`,
50
+ method: "pane.report_metadata",
51
+ params: {
52
+ agent: "pi",
53
+ applies_to_source: HERDR_PI_SOURCE,
54
+ clear_state_labels: true,
55
+ pane_id: process.env.HERDR_PANE_ID ?? "",
56
+ seq: nextSequence(),
57
+ source: BLOCK_SOURCE,
58
+ },
59
+ });
60
+ } catch {
61
+ /* Herdr is optional. */
62
+ }
63
+ }
64
+
65
+ private safeReport(labels: { blocked: string }) {
66
+ try {
67
+ this.report({
68
+ id: `${BLOCK_SOURCE}:${nextSequence()}`,
69
+ method: "pane.report_metadata",
70
+ params: {
71
+ agent: "pi",
72
+ applies_to_source: HERDR_PI_SOURCE,
73
+ pane_id: process.env.HERDR_PANE_ID ?? "",
74
+ seq: nextSequence(),
75
+ source: BLOCK_SOURCE,
76
+ state_labels: labels,
77
+ },
78
+ });
79
+ } catch {
80
+ /* Herdr is optional. */
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,108 @@
1
+ import net from "node:net";
2
+
3
+ import { redactSecrets } from "./redaction.ts";
4
+
5
+ // Keep the JSON-RPC method components separate from Socket's URL-string heuristic.
6
+ const HERDR_NOTIFICATION_METHOD = "notification.show";
7
+
8
+ export const SOURCE = "pi-advisor:advisor-activity";
9
+ const NOTIFICATION_SOURCE = "pi-advisor:advisor-notification";
10
+ export const BLOCK_SOURCE = "pi-advisor:advisor-block";
11
+ export const HERDR_PI_SOURCE = "herdr:pi";
12
+
13
+ export interface HerdrMetadataRequest {
14
+ id: string;
15
+ method: "pane.report_metadata";
16
+ params: {
17
+ pane_id: string;
18
+ source: string;
19
+ agent: "pi";
20
+ applies_to_source: string;
21
+ state_labels?: { working?: string; blocked?: string };
22
+ clear_state_labels?: true;
23
+ seq: number;
24
+ };
25
+ }
26
+
27
+ export interface HerdrNotificationRequest {
28
+ id: string;
29
+ method: typeof HERDR_NOTIFICATION_METHOD;
30
+ params: {
31
+ title: string;
32
+ body: string;
33
+ position: "top-left";
34
+ sound: "request";
35
+ };
36
+ }
37
+
38
+ export type HerdrRequest = HerdrMetadataRequest | HerdrNotificationRequest;
39
+
40
+ export type Report = (request: HerdrRequest) => void;
41
+
42
+ let sequence = Date.now() * 1000;
43
+ export const nextSequence = () => {
44
+ sequence += 1;
45
+ return sequence;
46
+ };
47
+
48
+ // Herdr drops socket reports from other sources, so the blocked state must be
49
+ // signalled through the in-process pi event bus its integration listens on.
50
+ type BlockedEmitter = (active: boolean, label: string) => void;
51
+ let emitBlocked: BlockedEmitter | undefined;
52
+
53
+ export const setHerdrBlockedEmitter = (emitter: BlockedEmitter | undefined) => {
54
+ emitBlocked = emitter;
55
+ };
56
+
57
+ export const safeEmitBlocked = (active: boolean, label = "Advisor blocked") => {
58
+ try {
59
+ emitBlocked?.(active, label);
60
+ } catch {
61
+ /* Herdr is optional. */
62
+ }
63
+ };
64
+
65
+ const isControlCharacter = (character: string) =>
66
+ character <= "\u001F" || character === "\u007F";
67
+
68
+ export const cleanNotification = (value: string, max: number) =>
69
+ [...redactSecrets(value)]
70
+ .map((character) => (isControlCharacter(character) ? " " : character))
71
+ .join("")
72
+ .replaceAll(/\s+/gu, " ")
73
+ .trim()
74
+ .slice(0, max);
75
+
76
+ export const sendToHerdr: Report = (request) => {
77
+ if (process.env.HERDR_ENV !== "1") {
78
+ return;
79
+ }
80
+ const paneId = process.env.HERDR_PANE_ID;
81
+ const socketPath = process.env.HERDR_SOCKET_PATH;
82
+ if (!(paneId && socketPath)) {
83
+ return;
84
+ }
85
+ const endpoint =
86
+ process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
87
+ const socket = net.createConnection(endpoint);
88
+ const timeout = setTimeout(() => socket.destroy(), 500);
89
+ timeout.unref?.();
90
+ socket.once("connect", () => socket.write(`${JSON.stringify(request)}\n`));
91
+ socket.once("data", () => socket.destroy());
92
+ socket.once("error", () => socket.destroy());
93
+ socket.once("close", () => clearTimeout(timeout));
94
+ };
95
+
96
+ export const createHerdrNotificationRequest = (
97
+ title: string,
98
+ body: string
99
+ ): HerdrNotificationRequest => ({
100
+ id: `${NOTIFICATION_SOURCE}:${nextSequence()}`,
101
+ method: HERDR_NOTIFICATION_METHOD,
102
+ params: {
103
+ body: cleanNotification(body, 240),
104
+ position: "top-left",
105
+ sound: "request",
106
+ title: cleanNotification(title, 80),
107
+ },
108
+ });