supipowers 2.0.1 → 2.1.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 (80) hide show
  1. package/README.md +10 -6
  2. package/package.json +4 -2
  3. package/skills/harness/SKILL.md +1 -0
  4. package/src/bootstrap.ts +5 -133
  5. package/src/commands/clear.ts +6 -6
  6. package/src/commands/release.ts +3 -1
  7. package/src/commands/update.ts +1 -1
  8. package/src/config/defaults.ts +5 -5
  9. package/src/config/loader.ts +1 -0
  10. package/src/config/schema.ts +2 -6
  11. package/src/context/analyzer.ts +104 -35
  12. package/src/context-mode/knowledge/store.ts +381 -43
  13. package/src/context-mode/tools.ts +41 -3
  14. package/src/deps/registry.ts +1 -12
  15. package/src/fix-pr/assessment.ts +1 -0
  16. package/src/fix-pr/prompt-builder.ts +1 -0
  17. package/src/git/commit.ts +76 -18
  18. package/src/harness/command.ts +103 -6
  19. package/src/harness/default-agents/docs.md +39 -0
  20. package/src/harness/docs/config.ts +29 -0
  21. package/src/harness/docs/glob-match.ts +27 -0
  22. package/src/harness/docs/index-renderer.ts +82 -0
  23. package/src/harness/docs/provenance.ts +125 -0
  24. package/src/harness/docs/regen-decision.ts +167 -0
  25. package/src/harness/docs/representative-files.ts +175 -0
  26. package/src/harness/docs/source-hash.ts +106 -0
  27. package/src/harness/docs/validator.ts +233 -0
  28. package/src/harness/hooks/layer-context-inject.ts +35 -1
  29. package/src/harness/hooks/register.ts +24 -3
  30. package/src/harness/pipeline.ts +20 -5
  31. package/src/harness/pr-comment/baseline.ts +105 -0
  32. package/src/harness/pr-comment/ci-env.ts +120 -0
  33. package/src/harness/pr-comment/gh-poster.ts +227 -0
  34. package/src/harness/pr-comment/handler.ts +198 -0
  35. package/src/harness/pr-comment/render.ts +297 -0
  36. package/src/harness/pr-comment/status.ts +95 -0
  37. package/src/harness/pr-comment/types.ts +73 -0
  38. package/src/harness/pr-comment/workflow-summary.ts +47 -0
  39. package/src/harness/project-paths.ts +95 -0
  40. package/src/harness/stages/design.ts +1 -0
  41. package/src/harness/stages/discover.ts +1 -13
  42. package/src/harness/stages/docs.ts +708 -0
  43. package/src/harness/stages/implement-apply.ts +877 -0
  44. package/src/harness/stages/implement.ts +64 -51
  45. package/src/harness/stages/plan.ts +25 -16
  46. package/src/harness/stages/validate.ts +370 -0
  47. package/src/harness/storage.ts +142 -0
  48. package/src/harness/tools.ts +130 -0
  49. package/src/mempalace/bridge.ts +207 -41
  50. package/src/mempalace/config.ts +10 -4
  51. package/src/mempalace/format.ts +122 -6
  52. package/src/mempalace/hooks.ts +204 -56
  53. package/src/mempalace/installer-helper.ts +18 -4
  54. package/src/mempalace/python/mempalace_bridge.py +128 -3
  55. package/src/mempalace/runtime.ts +55 -18
  56. package/src/mempalace/schema.ts +151 -30
  57. package/src/mempalace/session-summary.ts +5 -0
  58. package/src/mempalace/tool.ts +17 -4
  59. package/src/mempalace/upstream-limits.ts +69 -0
  60. package/src/planning/approval-flow.ts +25 -2
  61. package/src/planning/planning-ask-tool.ts +34 -4
  62. package/src/planning/system-prompt.ts +1 -1
  63. package/src/tool-catalog/active-tool-controller.ts +0 -22
  64. package/src/tool-catalog/active-tool-planner.ts +0 -26
  65. package/src/tool-catalog/tool-groups.ts +1 -9
  66. package/src/types.ts +87 -8
  67. package/src/ui-design/session.ts +114 -10
  68. package/src/utils/executable.ts +10 -1
  69. package/src/workspace/state-paths.ts +1 -1
  70. package/src/commands/mcp.ts +0 -814
  71. package/src/mcp/activation.ts +0 -77
  72. package/src/mcp/config.ts +0 -223
  73. package/src/mcp/docs.ts +0 -154
  74. package/src/mcp/gateway.ts +0 -103
  75. package/src/mcp/lifecycle.ts +0 -79
  76. package/src/mcp/manager-tool.ts +0 -104
  77. package/src/mcp/mcpc.ts +0 -113
  78. package/src/mcp/registry.ts +0 -98
  79. package/src/mcp/triggers.ts +0 -62
  80. package/src/mcp/types.ts +0 -95
@@ -28,6 +28,17 @@ function asArray(value: unknown): RecordValue[] {
28
28
  return Array.isArray(value) ? value.map(asRecord) : [];
29
29
  }
30
30
 
31
+ function asCountMap(value: unknown): Array<[string, number]> {
32
+ if (!value || typeof value !== "object" || Array.isArray(value)) return [];
33
+ const out: Array<[string, number]> = [];
34
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
35
+ const count = typeof raw === "number" && Number.isFinite(raw) ? raw : Number(raw);
36
+ out.push([key, Number.isFinite(count) ? count : 0]);
37
+ }
38
+ out.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
39
+ return out;
40
+ }
41
+
31
42
  function stringValue(value: unknown): string {
32
43
  if (value === null || value === undefined) return "";
33
44
  if (typeof value === "string") return value;
@@ -35,6 +46,10 @@ function stringValue(value: unknown): string {
35
46
  return JSON.stringify(value);
36
47
  }
37
48
 
49
+ function finiteNumber(value: unknown): number | undefined {
50
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
51
+ }
52
+
38
53
  function truncateText(text: string, maxChars: number, guidance: string): string {
39
54
  if (text.length <= maxChars) return text;
40
55
  if (maxChars <= guidance.length + 1) {
@@ -49,11 +64,29 @@ function formatSimilarity(value: unknown): string {
49
64
  }
50
65
 
51
66
  function formatSearch(result: RecordValue, budgets: ResultBudgets): string {
52
- const results = asArray(result.results ?? result.items);
67
+ const results = asArray(result.results);
53
68
  const query = stringValue(result.query) || "(unspecified query)";
54
69
  const count = typeof result.count === "number" ? result.count : results.length;
55
70
  const lines = [`MemPalace search`, `Search results for ${query} (${count})`];
56
71
 
72
+ if (result.index_recovered) {
73
+ lines.push("Index recovered: retried after transient index lookup failure.");
74
+ }
75
+
76
+ // Surface filter context so agents can tell "empty palace" from "over-filtered".
77
+ const filters = asRecord(result.filters);
78
+ const filterParts = (["wing", "room"] as string[]).flatMap(k => {
79
+ const v = stringValue(filters[k]);
80
+ return v ? [`${k}=${v}`] : [];
81
+ });
82
+ if (filterParts.length > 0) {
83
+ lines.push(`Filters applied: ${filterParts.join(", ")}`);
84
+ }
85
+ const totalBeforeFilter = finiteNumber(result.total_before_filter);
86
+ if (totalBeforeFilter !== undefined && totalBeforeFilter > count) {
87
+ lines.push(`Filtered out ${totalBeforeFilter - count} hit(s) by wing/room scope.`);
88
+ }
89
+
57
90
  for (const [index, item] of results.entries()) {
58
91
  const id = stringValue(item.id ?? item.drawer_id) || `#${index + 1}`;
59
92
  const wing = stringValue(item.wing);
@@ -64,12 +97,17 @@ function formatSearch(result: RecordValue, budgets: ResultBudgets): string {
64
97
  if (excerpt) lines.push(` ${excerpt}`);
65
98
  }
66
99
 
100
+
67
101
  return truncateText(lines.join("\n"), budgets.searchResultChars, TRUNCATED_SEARCH_GUIDANCE);
68
102
  }
69
103
 
70
104
  function formatDrawerList(result: RecordValue, budgets: ResultBudgets): string {
71
- const drawers = asArray(result.drawers ?? result.results ?? result.items);
72
- const lines = [`Drawers (${drawers.length})`];
105
+ const drawers = asArray(result.drawers ?? result.results);
106
+ const total = finiteNumber(result.total);
107
+ const header = total === undefined
108
+ ? `Drawers (${drawers.length})`
109
+ : `Drawers (${drawers.length} shown, ${total} total)`;
110
+ const lines = [header];
73
111
 
74
112
  for (const drawer of drawers) {
75
113
  const id = stringValue(drawer.id ?? drawer.drawer_id) || "unknown";
@@ -81,6 +119,29 @@ function formatDrawerList(result: RecordValue, budgets: ResultBudgets): string {
81
119
  return truncateText(lines.join("\n"), budgets.listResultChars, TRUNCATED_LIST_GUIDANCE);
82
120
  }
83
121
 
122
+ function formatWingList(result: RecordValue, budgets: ResultBudgets): string {
123
+ const wings = asCountMap(result.wings);
124
+ const total = wings.reduce((acc, [, n]) => acc + n, 0);
125
+ const lines = [`Wings (${wings.length}${total ? `, ${total} drawers`: ""})`];
126
+ for (const [name, count] of wings) {
127
+ lines.push(`- ${name} (${count})`);
128
+ }
129
+ if (result.partial) lines.push("(partial result — palace returned an error mid-scan)");
130
+ return truncateText(lines.join("\n"), budgets.listResultChars, TRUNCATED_LIST_GUIDANCE);
131
+ }
132
+
133
+ function formatRoomList(result: RecordValue, budgets: ResultBudgets): string {
134
+ const wing = stringValue(result.wing) || "all";
135
+ const rooms = asCountMap(result.rooms);
136
+ const total = rooms.reduce((acc, [, n]) => acc + n, 0);
137
+ const lines = [`Rooms in ${wing} (${rooms.length}${total ? `, ${total} drawers` : ""})`];
138
+ for (const [name, count] of rooms) {
139
+ lines.push(`- ${name} (${count})`);
140
+ }
141
+ if (result.partial) lines.push("(partial result — palace returned an error mid-scan)");
142
+ return truncateText(lines.join("\n"), budgets.listResultChars, TRUNCATED_LIST_GUIDANCE);
143
+ }
144
+
84
145
  function formatDiary(result: RecordValue, budgets: ResultBudgets): string {
85
146
  const entries = asArray(result.entries ?? result.results ?? result.items);
86
147
  const lines = [`Diary entries (${entries.length})`];
@@ -96,13 +157,23 @@ function formatDiary(result: RecordValue, budgets: ResultBudgets): string {
96
157
  }
97
158
 
98
159
  function formatStatus(result: RecordValue): string {
99
- const wings = Array.isArray(result.wings) ? result.wings.length : result.wingCount ?? result.wings_count ?? "unknown";
100
160
  const lines = ["MemPalace status"];
101
161
  const palacePath = stringValue(result.palacePath ?? result.palace_path ?? result.palace);
102
162
  if (palacePath) lines.push(`palace: ${palacePath}`);
103
163
  if ("ready" in result) lines.push(`ready: ${String(result.ready)}`);
104
164
  if ("version" in result) lines.push(`version: ${stringValue(result.version)}`);
105
- lines.push(`wings: ${String(wings)}`);
165
+
166
+ const wingsCount = Array.isArray(result.wings)
167
+ ? result.wings.length
168
+ : result.wings && typeof result.wings === "object"
169
+ ? Object.keys(result.wings as Record<string, unknown>).length
170
+ : (typeof result.wingCount === "number" ? result.wingCount : undefined)
171
+ ?? (typeof result.wings_count === "number" ? result.wings_count : undefined);
172
+ lines.push(`wings: ${wingsCount === undefined ? "unknown" : String(wingsCount)}`);
173
+
174
+ if (typeof result.total_drawers === "number") lines.push(`drawers: ${result.total_drawers}`);
175
+ else if (typeof result.totalDrawers === "number") lines.push(`drawers: ${result.totalDrawers}`);
176
+
106
177
  return lines.join("\n");
107
178
  }
108
179
 
@@ -112,6 +183,45 @@ function formatGeneric(action: MempalaceAction, result: RecordValue, budgets: Re
112
183
  return truncateText(`MemPalace ${action} result\n${JSON.stringify(result, null, 2)}`, budgets.listResultChars, TRUNCATED_LIST_GUIDANCE);
113
184
  }
114
185
 
186
+ function formatWake(wake: RecordValue, budgets: ResultBudgets): string {
187
+ const text = stringValue(wake.text);
188
+ if (!text) return "MemPalace wake: (no text returned)";
189
+ // Wake payloads are roughly the size of a search result block (L0/L1
190
+ // condensation), so reuse the search budget rather than introducing a new
191
+ // dimension. truncateText preserves trailing guidance so the agent can ask
192
+ // for more if it was clipped.
193
+ return truncateText(`MemPalace wake\n${text}`, budgets.searchResultChars, TRUNCATED_SEARCH_GUIDANCE);
194
+ }
195
+
196
+ function formatWakeUpAndSearch(result: RecordValue, budgets: ResultBudgets): string {
197
+ const wake = result.wake;
198
+ const search = result.search;
199
+ const parts: string[] = [];
200
+
201
+ // Wake half: null means wake failed — emit a one-line notice so the operator
202
+ // can see something went wrong without poisoning the turn with empty output.
203
+ // Wake payloads from python are `{ text: <L0+L1 markdown> }` — render that
204
+ // text directly. Passing through formatSearch() would drop it entirely
205
+ // because formatSearch only reads `query`/`results`.
206
+ if (wake === null || wake === undefined) {
207
+ const notice = typeof result.wake_error === "string" ? result.wake_error : "wake_up failed";
208
+ parts.push(`MemPalace wake: ${notice}`);
209
+ } else {
210
+ parts.push(formatWake(asRecord(wake), budgets));
211
+ }
212
+
213
+ // Search half: omit when null AND no error reported. If python attached a
214
+ // search_error (composite call failed mid-way), surface it as a one-liner so
215
+ // the caller can distinguish "no query / no hits" from "search blew up".
216
+ if (search !== null && search !== undefined) {
217
+ parts.push(formatSearch(asRecord(search), budgets));
218
+ } else if (typeof result.search_error === "string") {
219
+ parts.push(`MemPalace search: ${result.search_error}`);
220
+ }
221
+
222
+ return truncateText(parts.join("\n\n"), budgets.searchResultChars, TRUNCATED_SEARCH_GUIDANCE);
223
+ }
224
+
115
225
  export function formatMempalaceResult(
116
226
  action: MempalaceAction,
117
227
  result: unknown,
@@ -124,8 +234,14 @@ export function formatMempalaceResult(
124
234
  text = formatStatus(record);
125
235
  } else if (action === "search" || action === "wake_up") {
126
236
  text = formatSearch(record, budgets);
127
- } else if (action === "list_drawers" || action === "list_wings" || action === "list_rooms") {
237
+ } else if (action === "wake_up_and_search") {
238
+ text = formatWakeUpAndSearch(record, budgets);
239
+ } else if (action === "list_drawers") {
128
240
  text = formatDrawerList(record, budgets);
241
+ } else if (action === "list_wings") {
242
+ text = formatWingList(record, budgets);
243
+ } else if (action === "list_rooms") {
244
+ text = formatRoomList(record, budgets);
129
245
  } else if (action === "diary_read") {
130
246
  text = formatDiary(record, budgets);
131
247
  } else {
@@ -3,17 +3,73 @@ import { normalizeSystemPromptBlocks } from "../platform/system-prompt.js";
3
3
  import type { SupipowersConfig } from "../types.js";
4
4
  import { createMempalaceBridge, type MempalaceBridgeFacade } from "./bridge.js";
5
5
  import { resolveDefaultWing, resolveMempalaceConfig, type ResolvedMempalaceConfig } from "./config.js";
6
+ import { resolveInstalledBridgeScriptPath } from "./runtime.js";
6
7
  import { getEventStore as getContextEventStore, getSessionId as getContextSessionId } from "../context-mode/hooks.js";
7
8
  import { buildCompactionCheckpoint, buildShutdownDiary } from "./session-summary.js";
9
+ import { snapshotMempalaceInstall } from "./installer-helper.js";
8
10
 
9
11
  export interface MempalaceHooksDeps {
10
12
  createBridge?: (config: ResolvedMempalaceConfig, cwd: string) => MempalaceBridgeFacade;
11
13
  getEventStore?: () => Parameters<typeof buildCompactionCheckpoint>[0]["eventStore"];
12
14
  getSessionId?: () => string;
13
15
  now?: () => string;
16
+ snapshotInstall?: (paths: Platform["paths"], cwd: string, config: SupipowersConfig) => { ready: boolean };
14
17
  }
15
18
 
16
- const wakeUpCache = new Map<string, string>();
19
+ /** Maximum number of (sessionId × wing × palace) entries to keep in memory. */
20
+ const HOOK_CACHE_LRU_CAP = 64;
21
+
22
+ /** Insertion-ordered bounded LRU. Drops the least-recently-used entry on overflow. */
23
+ class BoundedLRU<K, V> {
24
+ private readonly inner = new Map<K, V>();
25
+
26
+ constructor(private readonly cap: number) {}
27
+
28
+ get(key: K): V | undefined {
29
+ if (!this.inner.has(key)) return undefined;
30
+ const value = this.inner.get(key) as V;
31
+ this.inner.delete(key);
32
+ this.inner.set(key, value);
33
+ return value;
34
+ }
35
+
36
+ set(key: K, value: V): void {
37
+ if (this.inner.has(key)) {
38
+ this.inner.delete(key);
39
+ } else if (this.inner.size >= this.cap) {
40
+ const oldest = this.inner.keys().next().value;
41
+ if (oldest !== undefined) this.inner.delete(oldest);
42
+ }
43
+ this.inner.set(key, value);
44
+ }
45
+
46
+ delete(key: K): boolean {
47
+ return this.inner.delete(key);
48
+ }
49
+
50
+ clear(): void {
51
+ this.inner.clear();
52
+ }
53
+
54
+ keys(): IterableIterator<K> {
55
+ return this.inner.keys();
56
+ }
57
+ }
58
+
59
+ function warnHookStateFallback(platform: Platform, message: string): void {
60
+ const logger = (platform as { logger?: { warn?: (message: string) => void } }).logger;
61
+ if (typeof logger?.warn === "function") {
62
+ logger.warn(message);
63
+ return;
64
+ }
65
+ console.warn(message);
66
+ }
67
+
68
+ function hookTimeoutSeconds(timeoutMs: number): number {
69
+ return Math.max(1, Math.floor(timeoutMs / 1000));
70
+ }
71
+
72
+ const wakeUpCache = new BoundedLRU<string, string>(HOOK_CACHE_LRU_CAP);
17
73
 
18
74
  /**
19
75
  * Per-session turn counter for wake-up cadence gating. The full wake-up block
@@ -21,7 +77,7 @@ const wakeUpCache = new Map<string, string>();
21
77
  * `mempalace.budgets.wakeUpInjectionEvery`); other turns get a one-line
22
78
  * refresher. Cleared on session_start / session_switch.
23
79
  */
24
- const turnCounters = new Map<string, number>();
80
+ const turnCounters = new BoundedLRU<string, number>(HOOK_CACHE_LRU_CAP);
25
81
 
26
82
  /** Test-only: reset cadence state between cases. */
27
83
  export function _resetMempalaceHookState(): void {
@@ -91,6 +147,15 @@ function setupGuidanceBlock(resolved: ResolvedMempalaceConfig, wing: string): st
91
147
  ].join("\n");
92
148
  }
93
149
 
150
+ function wakeFailureBlock(resolved: ResolvedMempalaceConfig, wing: string, error: string): string {
151
+ return [
152
+ "# MemPalace memory",
153
+ `- palace: ${resolved.palacePath}`,
154
+ `- default wing: ${wing}`,
155
+ `- Wake-up failed: ${error}`,
156
+ ].join("\n");
157
+ }
158
+
94
159
  function wakeUpBlock(resolved: ResolvedMempalaceConfig, wing: string, text: string): string {
95
160
  const excerpt = truncateByTokenBudget(text, resolved.budgets.wakeUpTokens);
96
161
  const lines = [
@@ -139,19 +204,33 @@ function extractUserPrompt(event: unknown): string {
139
204
  /** Minimum prompt length below which we skip auto-search (saves a bridge call). */
140
205
  const AUTO_SEARCH_MIN_PROMPT_CHARS = 15;
141
206
 
142
- /** Cap on the search query length. Long prompts are truncated to the first N chars. */
143
- const AUTO_SEARCH_QUERY_MAX_CHARS = 500;
144
-
145
207
  /**
146
- * Returns `true` when `prompt` is a trivial acknowledgement that does not warrant
147
- * a memory search (saves the bridge round-trip for "yes", "ok", "thanks", etc.).
208
+ * Returns `true` when `prompt` warrants a MemPalace auto-search.
209
+ * Rules applied in order:
210
+ * 1. Too-short or trivial filler word → skip (saves the bridge round-trip).
211
+ * 2. Contains "?" or starts with a question word → search.
212
+ * 3. Contains a memory/recall signal → search.
213
+ * 4. Starts with a clearly imperative verb and has no search signal → skip.
214
+ * 5. Ambiguous → search (preserves recall on uncertain prompts).
148
215
  */
149
- function isTrivialPrompt(prompt: string): boolean {
216
+ function shouldAutoSearchPrompt(prompt: string): boolean {
150
217
  const normalized = prompt.toLowerCase().replace(/[^\p{L}\p{N}\s]+/gu, " ").replace(/\s+/g, " ").trim();
151
- if (normalized.length < AUTO_SEARCH_MIN_PROMPT_CHARS) return true;
152
- // Conservative wordlist: only strip obvious filler.
218
+ // Rule 1: trivial length or obvious filler.
219
+ if (normalized.length < AUTO_SEARCH_MIN_PROMPT_CHARS) return false;
153
220
  const TRIVIAL = new Set(["yes", "no", "ok", "okay", "thanks", "thank you", "great", "cool", "go", "continue", "proceed"]);
154
- return TRIVIAL.has(normalized);
221
+ if (TRIVIAL.has(normalized)) return false;
222
+ // Rule 2: explicit question signals → search.
223
+ if (prompt.includes("?")) return true;
224
+ const QUESTION_PREFIXES = ["what", "why", "when", "who", "where", "how", "which", "do", "does", "is", "are", "can", "should"];
225
+ if (QUESTION_PREFIXES.some(p => normalized.startsWith(p + " ") || normalized === p)) return true;
226
+ // Rule 3: memory/recall signal words → search.
227
+ const RECALL_SIGNALS = ["remember", "recall", "decided", "decision", "chose", "last time", "previously", "earlier", "before"];
228
+ if (RECALL_SIGNALS.some(s => normalized.includes(s))) return true;
229
+ // Rule 4: clearly imperative verb at start, no search signal above → skip.
230
+ const IMPERATIVE_PREFIXES = ["fix", "add", "remove", "delete", "run", "update", "refactor", "rename", "move", "write", "create", "make", "implement", "build"];
231
+ if (IMPERATIVE_PREFIXES.some(p => normalized.startsWith(p + " ") || normalized === p)) return false;
232
+ // Rule 5: ambiguous → search.
233
+ return true;
155
234
  }
156
235
 
157
236
  interface SearchHit {
@@ -169,13 +248,13 @@ function pickHits(result: unknown): SearchHit[] {
169
248
  }
170
249
 
171
250
  /** Score-gated relevance check so we don't inject low-quality matches as noise. */
172
- function isRelevantHit(hit: SearchHit): boolean {
251
+ function isRelevantHit(hit: SearchHit, similarityFloor: number, bm25Floor: number): boolean {
173
252
  const sim = typeof hit.similarity === "number" ? hit.similarity : null;
174
253
  const bm25 = typeof hit.bm25_score === "number" ? hit.bm25_score : null;
175
- // Either signal must clear a low bar. Mempalace's `similarity` is ~1.0 for
254
+ // Either signal must clear the configured floor. similarity is ~1.0 for
176
255
  // perfect, ~0.5 for "kinda related"; bm25 is unbounded but >0.3 is meaningful.
177
- if (sim !== null && sim >= 0.55) return true;
178
- if (bm25 !== null && bm25 >= 0.3) return true;
256
+ if (sim !== null && sim >= similarityFloor) return true;
257
+ if (bm25 !== null && bm25 >= bm25Floor) return true;
179
258
  return false;
180
259
  }
181
260
 
@@ -210,12 +289,38 @@ export function registerMempalaceHooks(
210
289
  ): void {
211
290
  if (!config.mempalace.enabled) return;
212
291
 
213
- const clearAll = () => {
214
- wakeUpCache.clear();
215
- turnCounters.clear();
292
+ const snapshotInstall = deps.snapshotInstall ?? snapshotMempalaceInstall;
293
+ const isInstallReady = (cwd: string): boolean => snapshotInstall(platform.paths, cwd, config).ready;
294
+
295
+ const bridgeRuntime = {
296
+ resolveBridgeScriptPath: () => resolveInstalledBridgeScriptPath(platform.paths),
297
+ };
298
+
299
+ const clearSessionState = (sessionId: string): void => {
300
+ for (const key of [...wakeUpCache.keys()]) {
301
+ if (key.startsWith(`${sessionId}|`)) wakeUpCache.delete(key);
302
+ }
303
+ for (const key of [...turnCounters.keys()]) {
304
+ if (key.startsWith(`${sessionId}|`)) turnCounters.delete(key);
305
+ }
306
+ };
307
+
308
+ const clearForSession = (event: unknown): void => {
309
+ const source = typeof event === "object" && event !== null ? event as { sessionId?: unknown; previousSessionId?: unknown } : null;
310
+ const sessionId = typeof source?.sessionId === "string" && source.sessionId.length > 0 ? source.sessionId : null;
311
+ const previousSessionId =
312
+ typeof source?.previousSessionId === "string" && source.previousSessionId.length > 0 ? source.previousSessionId : null;
313
+ if (sessionId === null && previousSessionId === null) {
314
+ warnHookStateFallback(platform, "[mempalace hooks] session event missing sessionId — clearing all hook state");
315
+ wakeUpCache.clear();
316
+ turnCounters.clear();
317
+ return;
318
+ }
319
+ if (sessionId !== null) clearSessionState(sessionId);
320
+ if (previousSessionId !== null) clearSessionState(previousSessionId);
216
321
  };
217
- platform.on("session_start", clearAll);
218
- platform.on("session_switch", clearAll);
322
+ platform.on("session_start", clearForSession);
323
+ platform.on("session_switch", clearForSession);
219
324
 
220
325
  platform.on("before_agent_start", async (event: unknown, ctx: unknown) => {
221
326
  const wakeUpEnabled = config.mempalace.hooks.wakeUp;
@@ -234,11 +339,14 @@ export function registerMempalaceHooks(
234
339
  const sessionId = sessionIdFrom(event, ctx);
235
340
  const cacheKey = `${sessionId}|${wing}|${resolved.palacePath}`;
236
341
  const basePrompt = currentSystemPromptBlocks(event, ctx);
342
+ if (!isInstallReady(cwd)) {
343
+ return appendPrompt(basePrompt, setupGuidanceBlock(resolved, wing));
344
+ }
237
345
  const userPrompt = extractUserPrompt(event);
238
346
 
239
347
  const bridge = deps.createBridge
240
348
  ? deps.createBridge(resolved, cwd)
241
- : createMempalaceBridge({ cwd, config: resolved });
349
+ : createMempalaceBridge({ cwd, config: resolved, runtime: bridgeRuntime });
242
350
 
243
351
  // Cadence gating: wake-up dump on turn 1 and every Nth turn; refresher
244
352
  // otherwise. Saves ~750 tokens/turn average for a default cadence of 10.
@@ -248,42 +356,78 @@ export function registerMempalaceHooks(
248
356
  turnCounters.set(turnKey, turnCount);
249
357
  const isFullInjectionTurn = turnCount === 1 || turnCount % cadence === 0;
250
358
 
251
- // Run the cached generic wake_up and the per-prompt search in parallel.
252
- // wake_up is cached for the session; auto-search is fresh every turn.
253
- const wakePromise = (async (): Promise<string> => {
254
- if (!isFullInjectionTurn) return wakeUpRefresher(resolved, wing);
359
+ // On cadence-gated turns: one bridge.execute call handles both wake and search
360
+ // (wake_up_and_search). On non-injection turns: refresher string + optional
361
+ // separate search call. Either path issues at most one bridge call per turn.
362
+ const timeoutSeconds = hookTimeoutSeconds(resolved.timeouts.hookMs); /* seconds; bridge multiplies by 1000 */
363
+ const wantsSearch = autoSearchEnabled && shouldAutoSearchPrompt(userPrompt);
364
+ // Let upstream MemPalace extract the salient question/tail from long prompts.
365
+ const query = wantsSearch ? userPrompt : undefined;
366
+
367
+ let wakeBlock: string;
368
+ let searchBlock: string | null = null;
369
+
370
+ if (!isFullInjectionTurn) {
371
+ // Non-injection turns: lightweight refresher, no wake bridge call needed.
372
+ wakeBlock = wakeUpRefresher(resolved, wing);
373
+ if (wantsSearch) {
374
+ try {
375
+ const result = await bridge.execute({ action: "search", query: query!, wing, limit: 3, timeout: timeoutSeconds });
376
+ if (result.ok) {
377
+ const { autoSearchSimilarityFloor, autoSearchBm25Floor } = resolved.budgets;
378
+ const hits = pickHits(result.result).filter(hit => isRelevantHit(hit, autoSearchSimilarityFloor, autoSearchBm25Floor));
379
+ searchBlock = autoSearchBlock(hits, resolved.budgets.autoSearchTokens);
380
+ }
381
+ } catch {
382
+ // Auto-search is best-effort. A failure here must never block the turn.
383
+ }
384
+ }
385
+ } else {
255
386
  const cached = wakeUpCache.get(cacheKey);
256
- if (cached) return cached;
257
- const wake = await bridge.execute({ action: "wake_up", wing, timeout: resolved.timeouts.hookMs });
258
- const block = wake.ok
259
- ? wakeUpBlock(resolved, wing, wakeText(wake.result))
260
- : setupGuidanceBlock(resolved, wing);
261
- wakeUpCache.set(cacheKey, block);
262
- return block;
263
- })();
264
-
265
- const searchPromise = (async (): Promise<string | null> => {
266
- if (!autoSearchEnabled) return null;
267
- if (!userPrompt || isTrivialPrompt(userPrompt)) return null;
268
- const query = userPrompt.slice(0, AUTO_SEARCH_QUERY_MAX_CHARS);
269
- try {
270
- const result = await bridge.execute({
271
- action: "search",
272
- query,
387
+ if (cached) {
388
+ // Wake block is already cached only issue a search call if warranted.
389
+ wakeBlock = cached;
390
+ if (wantsSearch) {
391
+ try {
392
+ const result = await bridge.execute({ action: "search", query: query!, wing, limit: 3, timeout: timeoutSeconds });
393
+ if (result.ok) {
394
+ const { autoSearchSimilarityFloor, autoSearchBm25Floor } = resolved.budgets;
395
+ const hits = pickHits(result.result).filter(hit => isRelevantHit(hit, autoSearchSimilarityFloor, autoSearchBm25Floor));
396
+ searchBlock = autoSearchBlock(hits, resolved.budgets.autoSearchTokens);
397
+ }
398
+ } catch {
399
+ // Auto-search is best-effort. A failure here must never block the turn.
400
+ }
401
+ }
402
+ } else {
403
+ // Cache miss: batch wake + search into one bridge call.
404
+ const batchResult = await bridge.execute({
405
+ action: "wake_up_and_search",
273
406
  wing,
274
- limit: 3,
275
- timeout: resolved.timeouts.hookMs,
407
+ timeout: timeoutSeconds,
408
+ ...(query !== undefined ? { query, limit: 3 } : {}),
276
409
  });
277
- if (!result.ok) return null;
278
- const hits = pickHits(result.result).filter(isRelevantHit);
279
- return autoSearchBlock(hits, resolved.budgets.autoSearchTokens);
280
- } catch {
281
- // Auto-search is best-effort. A failure here must never block the turn.
282
- return null;
410
+ const composite = batchResult.ok ? (batchResult.result as Record<string, unknown>) : null;
411
+ const compositeWake = composite !== null ? (composite.wake as Record<string, unknown> | null | undefined) : undefined;
412
+ const wakeError = composite !== null && typeof composite.wake_error === "string" ? composite.wake_error : "wake_up failed";
413
+ const block = compositeWake != null
414
+ ? wakeUpBlock(resolved, wing, wakeText(compositeWake))
415
+ : batchResult.ok
416
+ ? wakeFailureBlock(resolved, wing, wakeError)
417
+ : setupGuidanceBlock(resolved, wing);
418
+ wakeUpCache.set(cacheKey, block);
419
+ wakeBlock = block;
420
+
421
+ // Extract search hits from the composite result (only if search was requested).
422
+ const compositeSearch = composite !== null ? composite.search : undefined;
423
+ if (compositeSearch != null && autoSearchEnabled) {
424
+ const { autoSearchSimilarityFloor, autoSearchBm25Floor } = resolved.budgets;
425
+ const hits = pickHits(compositeSearch).filter(hit => isRelevantHit(hit, autoSearchSimilarityFloor, autoSearchBm25Floor));
426
+ searchBlock = autoSearchBlock(hits, resolved.budgets.autoSearchTokens);
427
+ }
283
428
  }
284
- })();
429
+ }
285
430
 
286
- const [wakeBlock, searchBlock] = await Promise.all([wakePromise, searchPromise]);
287
431
  const combined = searchBlock ? `${wakeBlock}\n${searchBlock}` : wakeBlock;
288
432
  return appendPrompt(basePrompt, combined);
289
433
  });
@@ -292,6 +436,7 @@ export function registerMempalaceHooks(
292
436
  platform.on("session_before_compact", async (_event: unknown, ctx: unknown) => {
293
437
  try {
294
438
  const cwd = contextCwd(ctx);
439
+ if (!isInstallReady(cwd)) return undefined;
295
440
  const resolved = resolveMempalaceConfig(config, cwd, platform.paths);
296
441
  let wing: string;
297
442
  try {
@@ -311,7 +456,7 @@ export function registerMempalaceHooks(
311
456
  });
312
457
  const bridge = deps.createBridge
313
458
  ? deps.createBridge(resolved, cwd)
314
- : createMempalaceBridge({ cwd, config: resolved });
459
+ : createMempalaceBridge({ cwd, config: resolved, runtime: bridgeRuntime });
315
460
  await bridge.execute({
316
461
  action: "add_drawer",
317
462
  wing: checkpoint.metadata.wing,
@@ -319,7 +464,7 @@ export function registerMempalaceHooks(
319
464
  content: checkpoint.content,
320
465
  added_by: checkpoint.metadata.added_by,
321
466
  source_file: checkpoint.metadata.source_file,
322
- timeout: resolved.timeouts.hookMs,
467
+ timeout: hookTimeoutSeconds(resolved.timeouts.hookMs), /* seconds; bridge multiplies by 1000 */
323
468
  });
324
469
  } catch {
325
470
  // Compaction must never be cancelled by MemPalace checkpoint failures.
@@ -332,6 +477,7 @@ export function registerMempalaceHooks(
332
477
  platform.on("session_shutdown", async (_event: unknown, ctx: unknown) => {
333
478
  try {
334
479
  const cwd = contextCwd(ctx);
480
+ if (!isInstallReady(cwd)) return undefined;
335
481
  const resolved = resolveMempalaceConfig(config, cwd, platform.paths);
336
482
  let wing: string;
337
483
  try {
@@ -351,7 +497,7 @@ export function registerMempalaceHooks(
351
497
  });
352
498
  const bridge = deps.createBridge
353
499
  ? deps.createBridge(resolved, cwd)
354
- : createMempalaceBridge({ cwd, config: resolved });
500
+ : createMempalaceBridge({ cwd, config: resolved, runtime: bridgeRuntime });
355
501
  await bridge.execute({
356
502
  action: "diary_write",
357
503
  agent_name: diary.metadata.agent_name,
@@ -359,7 +505,7 @@ export function registerMempalaceHooks(
359
505
  topic: diary.metadata.topic,
360
506
  entry: diary.entry,
361
507
  source_file: diary.metadata.source_file,
362
- timeout: resolved.timeouts.hookMs,
508
+ timeout: hookTimeoutSeconds(resolved.timeouts.hookMs), /* seconds; bridge multiplies by 1000 */
363
509
  });
364
510
  } catch {
365
511
  // Shutdown must never be delayed or failed by MemPalace diary writes.
@@ -367,4 +513,6 @@ export function registerMempalaceHooks(
367
513
  return undefined;
368
514
  });
369
515
  }
516
+
517
+ platform.on("session_shutdown", clearForSession);
370
518
  }
@@ -6,7 +6,7 @@ import type { SupipowersConfig } from "../types.js";
6
6
  import { createMempalaceBridge, type MempalaceBridgeFacade } from "./bridge.js";
7
7
  import { resolveDefaultWing, resolveMempalaceConfig } from "./config.js";
8
8
  import {
9
- resolveBridgeScriptPath,
9
+ resolveInstalledBridgeScriptPath,
10
10
  resolveManagedVenvPaths,
11
11
  setupMempalaceRuntime,
12
12
  type ProcessRunner,
@@ -44,7 +44,7 @@ export function snapshotMempalaceInstall(
44
44
  const managedBinDir = paths.global("bin");
45
45
  const uvBinary = process.platform === "win32" ? "uv.exe" : "uv";
46
46
  const uvPath = path.join(managedBinDir, uvBinary);
47
- const bridge = resolveBridgeScriptPath();
47
+ const bridge = resolveInstalledBridgeScriptPath(paths);
48
48
 
49
49
  const uvInstalled = existsSync(uvPath);
50
50
  const venvInstalled = existsSync(venv.python);
@@ -85,7 +85,7 @@ export async function runMempalaceSetup(
85
85
  ): Promise<SetupMempalaceRuntimeResult> {
86
86
  const config = options.config ?? DEFAULT_CONFIG;
87
87
  const resolved = resolveMempalaceConfig(config, options.cwd, options.paths);
88
- const bridge = resolveBridgeScriptPath();
88
+ const bridge = resolveInstalledBridgeScriptPath(options.paths);
89
89
  if (!bridge.ok) {
90
90
  return { ok: false, error: bridge.error };
91
91
  }
@@ -109,6 +109,16 @@ export interface MempalaceInitState {
109
109
  function isWingPresent(result: unknown, wing: string): boolean {
110
110
  if (!result || typeof result !== "object") return false;
111
111
  const record = result as Record<string, unknown>;
112
+
113
+ // tool_list_wings returns `{ wings: { <name>: <count>, ... } }`. The
114
+ // dict shape is the canonical one from mempalace.mcp_server. Older
115
+ // / partial responses may carry array shapes (items/results), so we
116
+ // accept both rather than coupling tightly.
117
+ const wings = record.wings;
118
+ if (wings && typeof wings === "object" && !Array.isArray(wings)) {
119
+ if (Object.prototype.hasOwnProperty.call(wings, wing)) return true;
120
+ }
121
+
112
122
  const candidates = [record.wings, record.items, record.results];
113
123
  for (const list of candidates) {
114
124
  if (!Array.isArray(list)) continue;
@@ -145,7 +155,11 @@ export async function checkMempalaceProjectInitialized(options: {
145
155
  } catch {
146
156
  wing = "project";
147
157
  }
148
- const bridge = options.bridge ?? createMempalaceBridge({ cwd: options.cwd, config: resolved });
158
+ const bridge = options.bridge ?? createMempalaceBridge({
159
+ cwd: options.cwd,
160
+ config: resolved,
161
+ runtime: { resolveBridgeScriptPath: () => resolveInstalledBridgeScriptPath(options.paths) },
162
+ });
149
163
  const result = await bridge.execute({ action: "list_wings" });
150
164
  if (!result.ok) {
151
165
  return {