portable-agent-layer 0.61.3 → 0.62.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/assets/schema/pal-settings.schema.json +111 -0
- package/assets/skills/opinion/SKILL.md +7 -7
- package/assets/skills/opinion/tools/opinion.ts +6 -4
- package/assets/templates/hooks.copilot.json +4 -0
- package/assets/templates/hooks.cursor.json +4 -0
- package/assets/templates/pal-settings.json +8 -3
- package/assets/templates/settings.claude.json +9 -2
- package/package.json +1 -1
- package/src/cli/index.ts +16 -1
- package/src/hooks/RtkWrap.ts +52 -0
- package/src/hooks/UserPromptOrchestrator.ts +3 -3
- package/src/hooks/handlers/failure-principle.ts +1 -1
- package/src/hooks/handlers/inject-retrieval.ts +18 -6
- package/src/hooks/handlers/rating.ts +1 -1
- package/src/hooks/handlers/session-intelligence.ts +1 -1
- package/src/hooks/handlers/session-name.ts +1 -1
- package/src/hooks/handlers/update-check.ts +8 -5
- package/src/hooks/lib/inference.ts +2 -41
- package/src/hooks/lib/opinions.ts +15 -4
- package/src/hooks/lib/settings.ts +5 -0
- package/src/hooks/lib/steering.ts +162 -0
- package/src/hooks/lib/text-similarity.ts +18 -0
- package/src/hooks/lib/which.ts +41 -0
- package/src/targets/lib.ts +18 -1
- package/src/targets/opencode/plugin.ts +35 -4
- package/src/tools/agent/algorithm-reflect.ts +2 -1
- package/src/tools/agent/handoff-note.ts +3 -2
- package/src/tools/agent/project.ts +42 -2
- package/src/tools/agent/relationship-note.ts +6 -11
- package/src/tools/agent/thread.ts +6 -9
- package/src/tools/agent/wisdom-frame.ts +2 -1
- package/src/tools/lib/emit.ts +31 -0
- package/src/tools/relationship-reflect.ts +14 -13
- package/src/tools/token-cost.ts +49 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/kovrichard/portable-agent-layer/main/assets/schema/pal-settings.schema.json",
|
|
4
|
+
"title": "PAL settings",
|
|
5
|
+
"description": "Configuration for the Portable Agent Layer. Lives at ~/.pal/memory/pal-settings.json. All sections are optional; PAL falls back to defaults for anything absent.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": true,
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "JSON Schema reference — enables editor autocomplete, validation, and hover docs."
|
|
12
|
+
},
|
|
13
|
+
"identity": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"description": "Who the assistant is and who it serves.",
|
|
16
|
+
"additionalProperties": false,
|
|
17
|
+
"properties": {
|
|
18
|
+
"ai": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"description": "The assistant's persona.",
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"properties": {
|
|
23
|
+
"name": { "type": "string", "description": "Short name (e.g. the name you call the assistant)." },
|
|
24
|
+
"fullName": { "type": "string", "description": "Full/formal name." },
|
|
25
|
+
"displayName": { "type": "string", "description": "Uppercase label used in headers." },
|
|
26
|
+
"catchphrase": { "type": "string", "description": "Greeting line. '{name}' is substituted with the ai.name value." }
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"principal": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"description": "The human the assistant works for.",
|
|
32
|
+
"additionalProperties": false,
|
|
33
|
+
"properties": {
|
|
34
|
+
"name": { "type": "string", "description": "The principal's name." },
|
|
35
|
+
"timezone": { "type": "string", "description": "IANA timezone (e.g. Europe/Budapest)." }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"loadAtStartup": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"description": "Files force-loaded into session context at startup. Injected as <system-reminder> blocks.",
|
|
43
|
+
"additionalProperties": false,
|
|
44
|
+
"properties": {
|
|
45
|
+
"files": {
|
|
46
|
+
"type": "array",
|
|
47
|
+
"description": "Absolute or PAL-relative file paths to inject at session start.",
|
|
48
|
+
"items": { "type": "string" }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"dynamicContext": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"description": "Dynamic context sections injected at session start. Set any key to false to disable it; absent keys default to enabled.",
|
|
55
|
+
"additionalProperties": { "type": "boolean" },
|
|
56
|
+
"properties": {
|
|
57
|
+
"wisdom": { "type": "boolean", "description": "Crystallized wisdom principles." },
|
|
58
|
+
"opinions": { "type": "boolean", "description": "Tracked user opinions." },
|
|
59
|
+
"relationship": { "type": "boolean", "description": "Recent relationship notes." },
|
|
60
|
+
"learningDigest": { "type": "boolean", "description": "Lessons-from-failures digest." },
|
|
61
|
+
"failurePatterns": { "type": "boolean", "description": "Recurring failure patterns." },
|
|
62
|
+
"projectHistory": { "type": "boolean", "description": "Per-project session history." },
|
|
63
|
+
"projects": { "type": "boolean", "description": "Active projects list." },
|
|
64
|
+
"sessionIntelligence": { "type": "boolean", "description": "Rating trends and session signals." },
|
|
65
|
+
"handoff": { "type": "boolean", "description": "Handoff note from the previous session." },
|
|
66
|
+
"selfModel": { "type": "boolean", "description": "The assistant's self-model synthesis." },
|
|
67
|
+
"contextualSteering": { "type": "boolean", "description": "Prompt-time steering self-checks (see the steering section)." },
|
|
68
|
+
"steeringTestReport": { "type": "boolean", "description": "When true, the assistant notes to the user which steering self-check fired. For the dual-live test period; ships false." }
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"attribution": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"description": "Git co-author attribution opt-in.",
|
|
74
|
+
"additionalProperties": false,
|
|
75
|
+
"properties": {
|
|
76
|
+
"enabled": { "type": "boolean", "description": "Add the PAL co-author trailer to commits." },
|
|
77
|
+
"decided": { "type": "boolean", "description": "Gates the one-time attribution prompt." }
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"steering": {
|
|
81
|
+
"type": "object",
|
|
82
|
+
"description": "Contextual steering: PAL injects a short self-check into your prompt when it matches a task type. Toggle the whole feature via dynamicContext.contextualSteering.",
|
|
83
|
+
"additionalProperties": false,
|
|
84
|
+
"properties": {
|
|
85
|
+
"disable": {
|
|
86
|
+
"type": "array",
|
|
87
|
+
"description": "Shipped steering rules to suppress, by tag.",
|
|
88
|
+
"items": {
|
|
89
|
+
"type": "string",
|
|
90
|
+
"enum": ["debugging", "destructive", "refactor", "planning", "testing", "committing", "secrets"]
|
|
91
|
+
},
|
|
92
|
+
"uniqueItems": true
|
|
93
|
+
},
|
|
94
|
+
"rules": {
|
|
95
|
+
"type": "array",
|
|
96
|
+
"description": "Personal steering rules appended to the shipped set. A malformed entry (missing field or bad regex) is skipped, never fatal.",
|
|
97
|
+
"items": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"additionalProperties": false,
|
|
100
|
+
"required": ["tag", "pattern", "snippet"],
|
|
101
|
+
"properties": {
|
|
102
|
+
"tag": { "type": "string", "description": "Short identifier for this rule (also used to dedupe matches)." },
|
|
103
|
+
"pattern": { "type": "string", "description": "JavaScript regex source, matched case-insensitively against the prompt." },
|
|
104
|
+
"snippet": { "type": "string", "description": "The self-check text injected when the pattern matches." }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -7,7 +7,7 @@ description: "Opinion tracker for relationship notes. PROACTIVE: When the user c
|
|
|
7
7
|
|
|
8
8
|
Tool: `tools/opinion.ts`
|
|
9
9
|
|
|
10
|
-
Manage confidence-scored opinions about the user. Opinions are promoted from recurring relationship notes (the W/O/B observations captured at session end). This tool is the fast path to grow opinion confidence — relationship notes feed the slow path (+
|
|
10
|
+
Manage confidence-scored opinions about the user. Opinions are promoted from recurring relationship notes (the W/O/B observations captured at session end). This tool is the fast path to grow opinion confidence — relationship notes feed the slow path (+5% per reflect cycle), while explicit confirmations here jump +10%.
|
|
11
11
|
|
|
12
12
|
Opinions at ≥85% confidence are automatically injected into every session context.
|
|
13
13
|
|
|
@@ -42,12 +42,12 @@ bun opinion.ts list
|
|
|
42
42
|
# Show details for a specific opinion (fuzzy-matched)
|
|
43
43
|
bun opinion.ts show "keywords"
|
|
44
44
|
|
|
45
|
-
# Add a new opinion (starts at
|
|
45
|
+
# Add a new opinion (starts at 60%)
|
|
46
46
|
bun opinion.ts add "statement" [--category workflow]
|
|
47
47
|
|
|
48
48
|
# Add evidence to an existing opinion
|
|
49
|
-
bun opinion.ts evidence "keywords" --supporting "why" # +
|
|
50
|
-
bun opinion.ts evidence "keywords" --counter "why" # -
|
|
49
|
+
bun opinion.ts evidence "keywords" --supporting "why" # +5%
|
|
50
|
+
bun opinion.ts evidence "keywords" --counter "why" # -10%
|
|
51
51
|
bun opinion.ts evidence "keywords" --confirmation "why" # +10%
|
|
52
52
|
bun opinion.ts evidence "keywords" --contradiction "why" # -20%
|
|
53
53
|
```
|
|
@@ -59,9 +59,9 @@ bun opinion.ts evidence "keywords" --contradiction "why" # -20%
|
|
|
59
59
|
## Confidence Lifecycle
|
|
60
60
|
|
|
61
61
|
```
|
|
62
|
-
New opinion (add) →
|
|
63
|
-
├── supporting evidence → +
|
|
64
|
-
├── counter evidence → -
|
|
62
|
+
New opinion (add) → 60%
|
|
63
|
+
├── supporting evidence → +5% each
|
|
64
|
+
├── counter evidence → -10% each
|
|
65
65
|
├── explicit confirmation → +10% each
|
|
66
66
|
├── explicit contradiction → -20% each
|
|
67
67
|
└── at ≥85% → auto-injected into session context
|
|
@@ -147,7 +147,9 @@ switch (command) {
|
|
|
147
147
|
const opinion = createOpinion(statement, "manual add");
|
|
148
148
|
opinion.category = category;
|
|
149
149
|
saveOpinion(opinion);
|
|
150
|
-
console.log(
|
|
150
|
+
console.log(
|
|
151
|
+
` Added: "${statement}" [${category}] at ${Math.round(opinion.confidence * 100)}%`
|
|
152
|
+
);
|
|
151
153
|
break;
|
|
152
154
|
}
|
|
153
155
|
|
|
@@ -218,9 +220,9 @@ switch (command) {
|
|
|
218
220
|
Commands:
|
|
219
221
|
list List all opinions with confidence bars
|
|
220
222
|
show "keywords" Show opinion details + full evidence history
|
|
221
|
-
add "statement" [--category X] Create new opinion (starts at
|
|
222
|
-
evidence "keywords" --supporting "why" Supporting evidence (+
|
|
223
|
-
evidence "keywords" --counter "why" Counter evidence (-
|
|
223
|
+
add "statement" [--category X] Create new opinion (starts at 60%)
|
|
224
|
+
evidence "keywords" --supporting "why" Supporting evidence (+5%)
|
|
225
|
+
evidence "keywords" --counter "why" Counter evidence (-10%)
|
|
224
226
|
evidence "keywords" --confirmation "why" User explicitly confirmed (+10%)
|
|
225
227
|
evidence "keywords" --contradiction "why" User explicitly contradicted (-20%)
|
|
226
228
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/kovrichard/portable-agent-layer/main/assets/schema/pal-settings.schema.json",
|
|
2
3
|
"identity": {
|
|
3
4
|
"ai": {
|
|
4
5
|
"name": "",
|
|
@@ -12,11 +13,9 @@
|
|
|
12
13
|
}
|
|
13
14
|
},
|
|
14
15
|
"loadAtStartup": {
|
|
15
|
-
"_docs": "Files force-loaded into session context at startup. Injected as <system-reminder> blocks.",
|
|
16
16
|
"files": []
|
|
17
17
|
},
|
|
18
18
|
"dynamicContext": {
|
|
19
|
-
"_docs": "Dynamic context sections injected at session start. Set to false to disable.",
|
|
20
19
|
"wisdom": true,
|
|
21
20
|
"opinions": true,
|
|
22
21
|
"relationship": true,
|
|
@@ -26,6 +25,12 @@
|
|
|
26
25
|
"projects": true,
|
|
27
26
|
"sessionIntelligence": true,
|
|
28
27
|
"handoff": true,
|
|
29
|
-
"selfModel": true
|
|
28
|
+
"selfModel": true,
|
|
29
|
+
"contextualSteering": true,
|
|
30
|
+
"steeringTestReport": false
|
|
31
|
+
},
|
|
32
|
+
"steering": {
|
|
33
|
+
"disable": [],
|
|
34
|
+
"rules": []
|
|
30
35
|
}
|
|
31
36
|
}
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
"permissions": {
|
|
3
3
|
"allow": [
|
|
4
4
|
"Read(//*)",
|
|
5
|
-
"Grep(//*)",
|
|
6
|
-
"Glob(//*)",
|
|
7
5
|
"WebFetch",
|
|
8
6
|
"WebSearch",
|
|
9
7
|
"Bash(cat //*)",
|
|
@@ -110,6 +108,15 @@
|
|
|
110
108
|
"command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
|
|
111
109
|
}
|
|
112
110
|
]
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"matcher": "Bash",
|
|
114
|
+
"hooks": [
|
|
115
|
+
{
|
|
116
|
+
"type": "command",
|
|
117
|
+
"command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
|
|
118
|
+
}
|
|
119
|
+
]
|
|
113
120
|
}
|
|
114
121
|
],
|
|
115
122
|
"Stop": [
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -622,6 +622,13 @@ function nodeInstallHint(): string {
|
|
|
622
622
|
return "install Node ≥ 22.6 (see https://nodejs.org or your package manager)";
|
|
623
623
|
}
|
|
624
624
|
|
|
625
|
+
function rtkInstallHint(): string {
|
|
626
|
+
if (process.platform === "win32")
|
|
627
|
+
return "download rtk.exe from https://github.com/rtk-ai/rtk/releases and add it to PATH";
|
|
628
|
+
if (process.platform === "darwin") return "`brew install rtk`";
|
|
629
|
+
return "`curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh`";
|
|
630
|
+
}
|
|
631
|
+
|
|
625
632
|
function checkHookHealth(home: string): HookHealth {
|
|
626
633
|
const stateDir = resolve(home, "memory", "state");
|
|
627
634
|
// Read current + all rotated logs (`.1`..`.5`) + legacy `.prev` so a recent
|
|
@@ -671,6 +678,7 @@ interface DoctorResult {
|
|
|
671
678
|
cursor: ToolCheck;
|
|
672
679
|
copilot: ToolCheck;
|
|
673
680
|
codex: ToolCheck;
|
|
681
|
+
rtk: ToolCheck;
|
|
674
682
|
hasAgent: boolean;
|
|
675
683
|
}
|
|
676
684
|
|
|
@@ -684,6 +692,7 @@ function doctor(silent = false): DoctorResult {
|
|
|
684
692
|
cursor: { name: "cursor", available: true },
|
|
685
693
|
copilot: { name: "copilot", available: true },
|
|
686
694
|
codex: { name: "codex", available: true },
|
|
695
|
+
rtk: { name: "rtk", available: true },
|
|
687
696
|
hasAgent: true,
|
|
688
697
|
};
|
|
689
698
|
}
|
|
@@ -694,6 +703,7 @@ function doctor(silent = false): DoctorResult {
|
|
|
694
703
|
const cursor = checkTool("cursor");
|
|
695
704
|
const copilot = checkTool("copilot", ["version"]);
|
|
696
705
|
const codex = checkTool("codex");
|
|
706
|
+
const rtk = checkTool("rtk");
|
|
697
707
|
const hasAgent =
|
|
698
708
|
claude.available ||
|
|
699
709
|
opencode.available ||
|
|
@@ -751,6 +761,11 @@ function doctor(silent = false): DoctorResult {
|
|
|
751
761
|
: fail(
|
|
752
762
|
"Playwright Chromium — not found (run 'pal cli install' or 'bunx playwright install chromium')"
|
|
753
763
|
);
|
|
764
|
+
rtk.available
|
|
765
|
+
? ok(rtk.version || "rtk")
|
|
766
|
+
: info(
|
|
767
|
+
`rtk — not installed (optional; enables Bash output compression — ${rtkInstallHint()})`
|
|
768
|
+
);
|
|
754
769
|
|
|
755
770
|
console.log("");
|
|
756
771
|
log.info("PAL state");
|
|
@@ -1023,7 +1038,7 @@ function doctor(silent = false): DoctorResult {
|
|
|
1023
1038
|
console.log("");
|
|
1024
1039
|
}
|
|
1025
1040
|
|
|
1026
|
-
return { bun, claude, opencode, cursor, copilot, codex, hasAgent };
|
|
1041
|
+
return { bun, claude, opencode, cursor, copilot, codex, rtk, hasAgent };
|
|
1027
1042
|
}
|
|
1028
1043
|
|
|
1029
1044
|
// ── Commands ──
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook: PreToolUse — transparently wraps Bash commands in `rtk` to compress
|
|
3
|
+
* tool output before it reaches the model's context.
|
|
4
|
+
*
|
|
5
|
+
* PAL owns the wiring + presence-gating; rtk owns the rewrite. We do NOT
|
|
6
|
+
* reimplement rtk's selectivity or per-agent JSON shape — we forward the hook's
|
|
7
|
+
* stdin to `rtk hook <agent>` and pass its stdout straight back. rtk emits the
|
|
8
|
+
* correct rewrite object for each agent (Claude/Cursor/Copilot all differ), or
|
|
9
|
+
* nothing when a command isn't worth rewriting.
|
|
10
|
+
*
|
|
11
|
+
* Fail-open by construction: if rtk isn't installed, the agent can't rewrite,
|
|
12
|
+
* or anything throws, we emit nothing and exit 0 — the original command runs
|
|
13
|
+
* unchanged. rtk absent must never block or alter a Bash call.
|
|
14
|
+
*
|
|
15
|
+
* Codex (allow/deny only) and opencode (plugin path) have no `rtk hook`
|
|
16
|
+
* subcommand and are handled elsewhere — this hook no-ops for them.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { getActiveAgent } from "./lib/agent";
|
|
20
|
+
import { findBinaryOnPath } from "./lib/which";
|
|
21
|
+
|
|
22
|
+
const RTK_HOOK_SUBCOMMAND: Partial<Record<ReturnType<typeof getActiveAgent>, string>> = {
|
|
23
|
+
claude: "claude",
|
|
24
|
+
cursor: "cursor",
|
|
25
|
+
copilot: "copilot",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
async function run(): Promise<void> {
|
|
29
|
+
const subcommand = RTK_HOOK_SUBCOMMAND[getActiveAgent()];
|
|
30
|
+
if (!subcommand) return; // codex/opencode — no rtk hook processor
|
|
31
|
+
|
|
32
|
+
const rtk = findBinaryOnPath("rtk");
|
|
33
|
+
if (!rtk) return; // fail-open: rtk not installed → command runs unchanged
|
|
34
|
+
|
|
35
|
+
const stdin = await Bun.stdin.text();
|
|
36
|
+
const proc = Bun.spawn([rtk, "hook", subcommand], {
|
|
37
|
+
stdin: Buffer.from(stdin),
|
|
38
|
+
stdout: "pipe",
|
|
39
|
+
stderr: "ignore", // drop rtk's "no hook installed" stderr nag
|
|
40
|
+
});
|
|
41
|
+
const out = await new Response(proc.stdout).text();
|
|
42
|
+
await proc.exited;
|
|
43
|
+
if (proc.exitCode === 0 && out) {
|
|
44
|
+
process.stdout.write(out);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await run();
|
|
50
|
+
} catch {
|
|
51
|
+
// Fail open — emit nothing, allow the original command.
|
|
52
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - session-name: generate 4-word session headline on first prompt
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { injectPromptContext } from "./handlers/inject-retrieval";
|
|
11
11
|
import { captureRating } from "./handlers/rating";
|
|
12
12
|
import { captureSessionName } from "./handlers/session-name";
|
|
13
13
|
import { logDebug, logError, logPromptSnapshot } from "./lib/log";
|
|
@@ -30,8 +30,8 @@ logDebug("UserPromptOrchestrator", `Input: ${JSON.stringify(input).slice(0, 200)
|
|
|
30
30
|
if (!input?.prompt) process.exit(0);
|
|
31
31
|
|
|
32
32
|
const sessionId = input.session_id ?? input.sessionId ?? input.conversation_id;
|
|
33
|
-
const
|
|
34
|
-
logPromptSnapshot(input.prompt,
|
|
33
|
+
const injected = await injectPromptContext(input.prompt);
|
|
34
|
+
logPromptSnapshot(input.prompt, injected);
|
|
35
35
|
|
|
36
36
|
const results = await Promise.allSettled([
|
|
37
37
|
captureRating(input.prompt, sessionId),
|
|
@@ -60,7 +60,7 @@ async function processFailurePrinciple(
|
|
|
60
60
|
system: `Analyze this failed AI interaction (rated ${pending.rating}/10). Return JSON: {"principle": "<verb-first actionable rule, 10-20 words — write a full sentence, not a fragment>", "detailed_context": "<root cause and what to do differently, 50-150 words>"}.`,
|
|
61
61
|
user: `User feedback: ${pending.context}\n\nConversation:\n${recent}`,
|
|
62
62
|
maxTokens: 400,
|
|
63
|
-
timeout:
|
|
63
|
+
timeout: 90000,
|
|
64
64
|
jsonSchema: {
|
|
65
65
|
type: "object" as const,
|
|
66
66
|
properties: {
|
|
@@ -12,6 +12,7 @@ import { logDebug, logError } from "../lib/log";
|
|
|
12
12
|
import { runRetrieval } from "../lib/retrieval";
|
|
13
13
|
import { ensureIndex } from "../lib/retrieval-index";
|
|
14
14
|
import { isEnabled } from "../lib/settings";
|
|
15
|
+
import { getSteeringReminder } from "../lib/steering";
|
|
15
16
|
|
|
16
17
|
const TIMEOUT_MS = 250;
|
|
17
18
|
|
|
@@ -51,12 +52,11 @@ export async function getRetrievalReminder(prompt: string): Promise<string | nul
|
|
|
51
52
|
return result.reminder;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
/** Write
|
|
55
|
+
/** Write a reminder to stdout in the correct format for the current agent.
|
|
55
56
|
* Claude Code: plain text. Cursor: { additional_context }. Codex: hookSpecificOutput JSON.
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (!reminder) return null;
|
|
57
|
+
* MUST be called at most once per hook run — Cursor/Codex expect a single JSON
|
|
58
|
+
* object on stdout, so all prompt-time context is merged before this call. */
|
|
59
|
+
function writeForAgent(reminder: string): void {
|
|
60
60
|
if (isCursor()) {
|
|
61
61
|
process.stdout.write(JSON.stringify({ additional_context: reminder }));
|
|
62
62
|
} else if (isCodex()) {
|
|
@@ -71,5 +71,17 @@ export async function injectRetrieval(prompt: string): Promise<string | null> {
|
|
|
71
71
|
} else {
|
|
72
72
|
process.stdout.write(`${reminder}\n`);
|
|
73
73
|
}
|
|
74
|
-
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Gather all prompt-time context — prior-lesson retrieval + contextual steering —
|
|
77
|
+
* merge into a single payload, and do the one per-agent write. Returns the combined
|
|
78
|
+
* reminder that was injected, or null if there was nothing to inject. */
|
|
79
|
+
export async function injectPromptContext(prompt: string): Promise<string | null> {
|
|
80
|
+
const retrieval = await getRetrievalReminder(prompt);
|
|
81
|
+
const steering = getSteeringReminder(prompt);
|
|
82
|
+
const parts = [steering, retrieval].filter((p): p is string => Boolean(p));
|
|
83
|
+
if (parts.length === 0) return null;
|
|
84
|
+
const combined = parts.join("\n\n");
|
|
85
|
+
writeForAgent(combined);
|
|
86
|
+
return combined;
|
|
75
87
|
}
|
|
@@ -178,7 +178,7 @@ async function captureSessionIntelligence(
|
|
|
178
178
|
].join("\n"),
|
|
179
179
|
user: `User messages:\n${numberedMessages}\n\nLast AI response:\n${assistantWindow}`,
|
|
180
180
|
maxTokens: 350,
|
|
181
|
-
timeout:
|
|
181
|
+
timeout: 90000,
|
|
182
182
|
jsonSchema: INTELLIGENCE_SCHEMA,
|
|
183
183
|
caller: "session-intelligence",
|
|
184
184
|
sessionId,
|
|
@@ -72,23 +72,26 @@ async function checkRepo(): Promise<UpdateCache> {
|
|
|
72
72
|
});
|
|
73
73
|
await fetch.exited;
|
|
74
74
|
|
|
75
|
-
const
|
|
75
|
+
const remote = Bun.spawn(["git", "rev-parse", "origin/main"], {
|
|
76
76
|
cwd: repoDir,
|
|
77
77
|
stdout: "pipe",
|
|
78
78
|
stderr: "ignore",
|
|
79
79
|
windowsHide: true,
|
|
80
80
|
});
|
|
81
|
-
const
|
|
81
|
+
const remoteHash = (await new Response(remote.stdout).text()).trim();
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// "Ahead" (local unpushed commits) is not an update — only remote commits we
|
|
84
|
+
// lack are. Count HEAD..origin/main so being ahead or diverged never nags.
|
|
85
|
+
const behind = Bun.spawn(["git", "rev-list", "--count", "HEAD..origin/main"], {
|
|
84
86
|
cwd: repoDir,
|
|
85
87
|
stdout: "pipe",
|
|
86
88
|
stderr: "ignore",
|
|
87
89
|
windowsHide: true,
|
|
88
90
|
});
|
|
89
|
-
const
|
|
91
|
+
const behindCount =
|
|
92
|
+
Number.parseInt((await new Response(behind.stdout).text()).trim(), 10) || 0;
|
|
90
93
|
|
|
91
|
-
const available =
|
|
94
|
+
const available = behindCount > 0 && remoteHash.length > 0;
|
|
92
95
|
|
|
93
96
|
// Get remote version from package.json on origin/main
|
|
94
97
|
let latest = current;
|
|
@@ -20,8 +20,7 @@
|
|
|
20
20
|
* yet wired and currently fall through to the API path.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
24
|
-
import { basename, delimiter, resolve as resolvePath } from "node:path";
|
|
23
|
+
import { basename } from "node:path";
|
|
25
24
|
import {
|
|
26
25
|
getActiveAgent,
|
|
27
26
|
isClaude,
|
|
@@ -33,6 +32,7 @@ import {
|
|
|
33
32
|
import { logDebug } from "./log";
|
|
34
33
|
import { HAIKU_MODEL } from "./models";
|
|
35
34
|
import { buildSpawnGuardEnv, getInferenceDepth, SPAWN_GUARD_ENV } from "./spawn-guard";
|
|
35
|
+
import { findBinaryOnPath } from "./which";
|
|
36
36
|
|
|
37
37
|
export function hasApiKey(): boolean {
|
|
38
38
|
return !!process.env.PAL_ANTHROPIC_API_KEY;
|
|
@@ -213,45 +213,6 @@ let opencodeBinaryCache: string | null | undefined;
|
|
|
213
213
|
let copilotBinaryCache: string | null | undefined;
|
|
214
214
|
let cursorBinaryCache: string | null | undefined;
|
|
215
215
|
|
|
216
|
-
/**
|
|
217
|
-
* Resolve a binary on PATH to its full absolute path.
|
|
218
|
-
*
|
|
219
|
-
* Manual PATH walk (instead of Bun.which / `which` subprocess) because:
|
|
220
|
-
* 1. Ubuntu 24.04 dropped the `which` binary entirely.
|
|
221
|
-
* 2. Windows has no `which` at all.
|
|
222
|
-
* 3. Bun.which snapshots PATH at startup and ignores mid-test mutations.
|
|
223
|
-
* 4. Bun.spawn on Windows is inconsistent at resolving PATHEXT for bare
|
|
224
|
-
* names — passing the full `.cmd`/`.exe` path bypasses that fragility.
|
|
225
|
-
*
|
|
226
|
-
* Returns the resolved absolute path or null.
|
|
227
|
-
*/
|
|
228
|
-
function findBinaryOnPath(name: string): string | null {
|
|
229
|
-
const PATH = process.env.PATH;
|
|
230
|
-
if (!PATH) return null;
|
|
231
|
-
const exts =
|
|
232
|
-
process.platform === "win32"
|
|
233
|
-
? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")
|
|
234
|
-
: [""];
|
|
235
|
-
for (const dir of PATH.split(delimiter)) {
|
|
236
|
-
if (!dir) continue;
|
|
237
|
-
for (const ext of exts) {
|
|
238
|
-
const candidate = resolvePath(dir, name + ext);
|
|
239
|
-
try {
|
|
240
|
-
if (process.platform === "win32") {
|
|
241
|
-
// Windows has no executable bit — existence in PATHEXT is enough.
|
|
242
|
-
if (existsSync(candidate)) return candidate;
|
|
243
|
-
} else {
|
|
244
|
-
accessSync(candidate, constants.X_OK);
|
|
245
|
-
return candidate;
|
|
246
|
-
}
|
|
247
|
-
} catch {
|
|
248
|
-
/* not here — try next */
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
216
|
function getClaudeBinary(): string | null {
|
|
256
217
|
if (claudeBinaryCache !== undefined) return claudeBinaryCache;
|
|
257
218
|
claudeBinaryCache = findBinaryOnPath("claude");
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
13
|
import { paths } from "./paths";
|
|
14
|
-
import { similarity } from "./text-similarity";
|
|
14
|
+
import { containment, similarity } from "./text-similarity";
|
|
15
15
|
|
|
16
16
|
// ── Types ──
|
|
17
17
|
|
|
@@ -118,16 +118,27 @@ function slugify(text: string): string {
|
|
|
118
118
|
.join("-");
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
/** Find
|
|
121
|
+
/** Find the best-matching opinion for the given text. Returns the opinion or null. */
|
|
122
122
|
export function findSimilarOpinion(
|
|
123
123
|
text: string,
|
|
124
124
|
opinions: Opinion[],
|
|
125
125
|
threshold = 0.3
|
|
126
126
|
): Opinion | null {
|
|
127
|
+
let best: Opinion | null = null;
|
|
128
|
+
let bestScore = 0;
|
|
127
129
|
for (const op of opinions) {
|
|
128
|
-
|
|
130
|
+
// Dice handles full-statement comparisons (dedup, reflect promotion);
|
|
131
|
+
// containment lets a short keyword query reach a long statement.
|
|
132
|
+
const score = Math.max(
|
|
133
|
+
similarity(text, op.statement),
|
|
134
|
+
containment(text, op.statement)
|
|
135
|
+
);
|
|
136
|
+
if (score >= threshold && score > bestScore) {
|
|
137
|
+
bestScore = score;
|
|
138
|
+
best = op;
|
|
139
|
+
}
|
|
129
140
|
}
|
|
130
|
-
return
|
|
141
|
+
return best;
|
|
131
142
|
}
|
|
132
143
|
|
|
133
144
|
/** Create a new opinion from a recurring note. Starts at confidence 0.60. */
|
|
@@ -25,6 +25,11 @@ export interface PalSettingsData {
|
|
|
25
25
|
dynamicContext?: Record<string, boolean>;
|
|
26
26
|
/** Git co-author attribution opt-in. `decided` gates the one-time prompt. */
|
|
27
27
|
attribution?: { enabled?: boolean; decided?: boolean };
|
|
28
|
+
/** Contextual-steering user extension: personal rules + shipped rules to suppress by tag. */
|
|
29
|
+
steering?: {
|
|
30
|
+
disable?: string[];
|
|
31
|
+
rules?: Array<{ tag: string; pattern: string; snippet: string }>;
|
|
32
|
+
};
|
|
28
33
|
[key: string]: unknown;
|
|
29
34
|
}
|
|
30
35
|
|