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.
Files changed (34) hide show
  1. package/assets/schema/pal-settings.schema.json +111 -0
  2. package/assets/skills/opinion/SKILL.md +7 -7
  3. package/assets/skills/opinion/tools/opinion.ts +6 -4
  4. package/assets/templates/hooks.copilot.json +4 -0
  5. package/assets/templates/hooks.cursor.json +4 -0
  6. package/assets/templates/pal-settings.json +8 -3
  7. package/assets/templates/settings.claude.json +9 -2
  8. package/package.json +1 -1
  9. package/src/cli/index.ts +16 -1
  10. package/src/hooks/RtkWrap.ts +52 -0
  11. package/src/hooks/UserPromptOrchestrator.ts +3 -3
  12. package/src/hooks/handlers/failure-principle.ts +1 -1
  13. package/src/hooks/handlers/inject-retrieval.ts +18 -6
  14. package/src/hooks/handlers/rating.ts +1 -1
  15. package/src/hooks/handlers/session-intelligence.ts +1 -1
  16. package/src/hooks/handlers/session-name.ts +1 -1
  17. package/src/hooks/handlers/update-check.ts +8 -5
  18. package/src/hooks/lib/inference.ts +2 -41
  19. package/src/hooks/lib/opinions.ts +15 -4
  20. package/src/hooks/lib/settings.ts +5 -0
  21. package/src/hooks/lib/steering.ts +162 -0
  22. package/src/hooks/lib/text-similarity.ts +18 -0
  23. package/src/hooks/lib/which.ts +41 -0
  24. package/src/targets/lib.ts +18 -1
  25. package/src/targets/opencode/plugin.ts +35 -4
  26. package/src/tools/agent/algorithm-reflect.ts +2 -1
  27. package/src/tools/agent/handoff-note.ts +3 -2
  28. package/src/tools/agent/project.ts +42 -2
  29. package/src/tools/agent/relationship-note.ts +6 -11
  30. package/src/tools/agent/thread.ts +6 -9
  31. package/src/tools/agent/wisdom-frame.ts +2 -1
  32. package/src/tools/lib/emit.ts +31 -0
  33. package/src/tools/relationship-reflect.ts +14 -13
  34. 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 (+2% per reflect cycle), while explicit confirmations here jump +10%.
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 50%)
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" # +2%
50
- bun opinion.ts evidence "keywords" --counter "why" # -5%
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) → 50%
63
- ├── supporting evidence → +2% each
64
- ├── counter evidence → -5% each
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(` Added: "${statement}" [${category}] at 50%`);
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 50%)
222
- evidence "keywords" --supporting "why" Supporting evidence (+2%)
223
- evidence "keywords" --counter "why" Counter evidence (-5%)
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
 
@@ -21,6 +21,10 @@
21
21
  {
22
22
  "type": "command",
23
23
  "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
24
+ },
25
+ {
26
+ "type": "command",
27
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
24
28
  }
25
29
  ],
26
30
  "agentStop": [
@@ -21,6 +21,10 @@
21
21
  {
22
22
  "type": "command",
23
23
  "command": "PAL_AGENT=cursor bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
24
+ },
25
+ {
26
+ "type": "command",
27
+ "command": "PAL_AGENT=cursor bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
24
28
  }
25
29
  ],
26
30
  "beforeShellExecution": [
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.61.3",
3
+ "version": "0.62.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
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 { injectRetrieval } from "./handlers/inject-retrieval";
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 retrieval = await injectRetrieval(input.prompt);
34
- logPromptSnapshot(input.prompt, retrieval);
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: 60000,
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 retrieval reminder to stdout in the correct format for the current agent.
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
- * Returns the reminder string that was injected, or null if nothing was injected. */
57
- export async function injectRetrieval(prompt: string): Promise<string | null> {
58
- const reminder = await getRetrievalReminder(prompt);
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
- return reminder;
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
  }
@@ -310,7 +310,7 @@ async function runSentimentInferenceAndStore(
310
310
  system: SENTIMENT_SYSTEM_PROMPT,
311
311
  user: contextBlock,
312
312
  maxTokens: 500,
313
- timeout: 60000,
313
+ timeout: 90000,
314
314
  jsonSchema: SENTIMENT_SCHEMA,
315
315
  caller: "rating",
316
316
  sessionId,
@@ -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: 60000,
181
+ timeout: 90000,
182
182
  jsonSchema: INTELLIGENCE_SCHEMA,
183
183
  caller: "session-intelligence",
184
184
  sessionId,
@@ -70,7 +70,7 @@ async function upgradeWithInference(
70
70
  system: NAME_PROMPT,
71
71
  user: `Generate a 4-word title for: "${promptText}"`,
72
72
  maxTokens: 20,
73
- timeout: 60000,
73
+ timeout: 90000,
74
74
  caller: "session-name",
75
75
  sessionId,
76
76
  });
@@ -72,23 +72,26 @@ async function checkRepo(): Promise<UpdateCache> {
72
72
  });
73
73
  await fetch.exited;
74
74
 
75
- const local = Bun.spawn(["git", "rev-parse", "HEAD"], {
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 localHash = (await new Response(local.stdout).text()).trim();
81
+ const remoteHash = (await new Response(remote.stdout).text()).trim();
82
82
 
83
- const remote = Bun.spawn(["git", "rev-parse", "origin/main"], {
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 remoteHash = (await new Response(remote.stdout).text()).trim();
91
+ const behindCount =
92
+ Number.parseInt((await new Response(behind.stdout).text()).trim(), 10) || 0;
90
93
 
91
- const available = localHash !== remoteHash && remoteHash.length > 0;
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 { accessSync, constants, existsSync } from "node:fs";
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 an existing opinion similar to the given text. Returns the opinion or null. */
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
- if (similarity(text, op.statement) >= threshold) return op;
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 null;
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