pi-advisor-flow 0.6.0 → 0.8.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.
- package/CHANGELOG.md +34 -17
- package/README.md +10 -8
- package/dist/index.js +2852 -868
- package/extensions/index.ts +2 -1
- package/package.json +55 -40
- package/src/attachments.ts +11 -10
- package/src/commands/activation-preparation.ts +1 -0
- package/src/commands/activation.ts +2 -0
- package/src/commands/lifecycle.ts +1 -0
- package/src/commands/manual-command.ts +7 -1
- package/src/commands/manual-consultation.ts +103 -79
- package/src/commands/manual-progress.ts +7 -5
- package/src/commands/model-commands.ts +1 -1
- package/src/commands/model-options.ts +14 -3
- package/src/commands/model-picker.ts +1 -0
- package/src/commands/registration.ts +1 -0
- package/src/commands/renderers.ts +67 -55
- package/src/commands/runtime.ts +16 -14
- package/src/commands/settings-commands.ts +10 -4
- package/src/commands/settings-persistence.ts +64 -2
- package/src/commands/types.ts +2 -0
- package/src/commands.ts +0 -1
- package/src/config/args.ts +1 -1
- package/src/config/defaults.ts +83 -1
- package/src/config/schema.ts +152 -15
- package/src/config/state.ts +80 -2
- package/src/config/storage.ts +38 -22
- package/src/config/types.ts +45 -3
- package/src/config/validation.ts +33 -21
- package/src/config.ts +3 -0
- package/src/content-utils.ts +24 -4
- package/src/conversation.ts +16 -15
- package/src/git.ts +10 -3
- package/src/herdr-block.ts +83 -0
- package/src/herdr-shared.ts +108 -0
- package/src/herdr.ts +30 -192
- package/src/jev/client.ts +338 -0
- package/src/jev/failure.ts +19 -0
- package/src/jev/key-store.ts +267 -0
- package/src/jev/ledger.ts +228 -0
- package/src/jev/questions.ts +122 -0
- package/src/jev/state.ts +50 -0
- package/src/jev/transport.ts +63 -0
- package/src/model-stream.ts +35 -37
- package/src/outcomes.ts +20 -12
- package/src/pi-settings.ts +4 -9
- package/src/preferences.ts +4 -2
- package/src/redaction.ts +10 -12
- package/src/scout-context.ts +59 -60
- package/src/scout-curation.ts +4 -13
- package/src/scout-groups.ts +178 -175
- package/src/scout-protocol.ts +74 -70
- package/src/scout-reconstruct.ts +1 -1
- package/src/scout.ts +115 -73
- package/src/session-state.ts +148 -39
- package/src/tool-result-cap.ts +18 -17
- package/src/tools/consult-context.ts +7 -15
- package/src/tools/consultation.ts +14 -4
- package/src/tools/gate-policy.ts +1 -0
- package/src/tools/gate-protocol.ts +36 -23
- package/src/tools/jev-filter.ts +204 -0
- package/src/tools/jev-turn-gate.ts +203 -0
- package/src/tools/loop-gate.ts +32 -20
- package/src/tools/model-access.ts +46 -0
- package/src/tools/outage-notifier.ts +30 -0
- package/src/tools/prompts.ts +20 -25
- package/src/tools/register-ask-advisor.ts +73 -17
- package/src/tools/register-lifecycle.ts +22 -0
- package/src/tools/register-outcome.ts +2 -0
- package/src/tools/register-renderers.ts +149 -73
- package/src/tools/registration.ts +15 -0
- package/src/tools/render-advisor-result.ts +77 -35
- package/src/tools/render-common.ts +16 -19
- package/src/tools/scout-status.ts +42 -27
- package/src/tools/types.ts +11 -0
- package/src/tools.ts +0 -1
- package/src/ui/jev-setup-submenu.ts +350 -0
- package/src/ui/manual-dialog-render.ts +1 -0
- package/src/ui/manual-dialog.ts +39 -43
- package/src/ui/masked-input.ts +62 -0
- package/src/ui/model-multi-selector.ts +9 -0
- package/src/ui/model-selector-adapter.ts +31 -0
- package/src/ui/model-selector.ts +14 -161
- package/src/ui/searchable-model-list.ts +230 -0
- package/src/ui/settings-formatting.ts +24 -14
- package/src/ui/settings-items.ts +200 -2
- package/src/ui/settings-list-adapter.ts +15 -12
- package/src/ui/settings-mutations.ts +169 -47
- package/src/ui/settings-selector.ts +18 -9
- package/src/ui/text-setting-submenu.ts +3 -6
- package/src/ui/types.ts +25 -0
- package/src/ui.ts +0 -1
- package/src/usage.ts +29 -20
package/src/config/validation.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
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
|
-
//
|
|
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<
|
|
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
|
-
|
|
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,
|
|
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:
|
|
41
|
-
Object.keys(config).filter((key) => !CONFIG_KEYS.has(key
|
|
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 &&
|
|
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 &&
|
|
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 (!
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
validateEnumValues(
|
|
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,
|
package/src/content-utils.ts
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
/** Shared untyped-content inspection helpers. */
|
|
2
2
|
|
|
3
|
-
export type
|
|
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
|
|
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:
|
|
11
|
-
if (
|
|
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 : [];
|
package/src/conversation.ts
CHANGED
|
@@ -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,
|
|
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:
|
|
14
|
-
if (
|
|
15
|
+
const textFromPart = <Part>(part: Part): string => {
|
|
16
|
+
if (isString(part)) {
|
|
15
17
|
return part;
|
|
16
18
|
}
|
|
17
|
-
if (!
|
|
19
|
+
if (!isRecordOf(part) || part.type !== "text") {
|
|
18
20
|
return "";
|
|
19
21
|
}
|
|
20
|
-
return
|
|
22
|
+
return isString(part.text) ? part.text : "";
|
|
21
23
|
};
|
|
22
24
|
|
|
23
|
-
export const textFrom = (content:
|
|
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 (!
|
|
39
|
+
if (!isRecordOf(part) || part.type !== "toolCall") {
|
|
38
40
|
continue;
|
|
39
41
|
}
|
|
40
|
-
const toolName =
|
|
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:
|
|
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 (!
|
|
100
|
+
if (!isRecordOf(entry)) {
|
|
100
101
|
return;
|
|
101
102
|
}
|
|
102
|
-
if (entry.type === "compaction" &&
|
|
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" || !
|
|
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
|
-
|
|
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
|
|
54
|
+
value
|
|
55
|
+
.replaceAll("&", "&")
|
|
56
|
+
.replaceAll("<", "<")
|
|
57
|
+
.replaceAll(">", ">");
|
|
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: "
|
|
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
|
+
});
|