supipowers 2.0.2 → 2.2.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 (84) hide show
  1. package/README.md +5 -6
  2. package/package.json +4 -2
  3. package/skills/harness/SKILL.md +1 -0
  4. package/src/bootstrap.ts +8 -133
  5. package/src/commands/optimize-context.ts +153 -16
  6. package/src/commands/runbook.ts +511 -0
  7. package/src/config/defaults.ts +5 -5
  8. package/src/config/loader.ts +1 -0
  9. package/src/config/schema.ts +2 -6
  10. package/src/context/rule-renderer.ts +274 -2
  11. package/src/context/runbook-extension-template.ts +193 -0
  12. package/src/context/startup-check.ts +197 -2
  13. package/src/context/startup-optimizer.ts +133 -10
  14. package/src/context-mode/knowledge/store.ts +381 -43
  15. package/src/context-mode/tools.ts +41 -3
  16. package/src/deps/registry.ts +1 -12
  17. package/src/fix-pr/assessment.ts +1 -0
  18. package/src/fix-pr/prompt-builder.ts +1 -0
  19. package/src/git/commit.ts +76 -18
  20. package/src/harness/command.ts +201 -12
  21. package/src/harness/default-agents/docs.md +39 -0
  22. package/src/harness/docs/config.ts +29 -0
  23. package/src/harness/docs/glob-match.ts +27 -0
  24. package/src/harness/docs/index-renderer.ts +82 -0
  25. package/src/harness/docs/provenance.ts +125 -0
  26. package/src/harness/docs/regen-decision.ts +167 -0
  27. package/src/harness/docs/representative-files.ts +175 -0
  28. package/src/harness/docs/source-hash.ts +106 -0
  29. package/src/harness/docs/validator.ts +233 -0
  30. package/src/harness/git-verification.ts +515 -0
  31. package/src/harness/git-verify-qa.ts +406 -0
  32. package/src/harness/hooks/layer-context-inject.ts +35 -1
  33. package/src/harness/hooks/register.ts +24 -3
  34. package/src/harness/pipeline.ts +37 -13
  35. package/src/harness/pr-comment/baseline.ts +105 -0
  36. package/src/harness/pr-comment/ci-env.ts +120 -0
  37. package/src/harness/pr-comment/gh-poster.ts +227 -0
  38. package/src/harness/pr-comment/handler.ts +198 -0
  39. package/src/harness/pr-comment/render.ts +297 -0
  40. package/src/harness/pr-comment/status.ts +95 -0
  41. package/src/harness/pr-comment/types.ts +73 -0
  42. package/src/harness/pr-comment/workflow-summary.ts +47 -0
  43. package/src/harness/project-paths.ts +95 -0
  44. package/src/harness/stages/design.ts +1 -0
  45. package/src/harness/stages/discover.ts +1 -13
  46. package/src/harness/stages/docs.ts +708 -0
  47. package/src/harness/stages/implement-apply.ts +934 -0
  48. package/src/harness/stages/implement.ts +64 -51
  49. package/src/harness/stages/plan.ts +25 -16
  50. package/src/harness/stages/validate.ts +478 -0
  51. package/src/harness/storage.ts +142 -0
  52. package/src/harness/tools.ts +130 -0
  53. package/src/mempalace/bridge.ts +207 -41
  54. package/src/mempalace/config.ts +10 -4
  55. package/src/mempalace/format.ts +122 -6
  56. package/src/mempalace/hooks.ts +204 -56
  57. package/src/mempalace/installer-helper.ts +18 -4
  58. package/src/mempalace/python/mempalace_bridge.py +128 -3
  59. package/src/mempalace/runtime.ts +53 -16
  60. package/src/mempalace/schema.ts +151 -30
  61. package/src/mempalace/session-summary.ts +5 -0
  62. package/src/mempalace/tool.ts +17 -4
  63. package/src/mempalace/upstream-limits.ts +69 -0
  64. package/src/planning/approval-flow.ts +25 -2
  65. package/src/planning/planning-ask-tool.ts +34 -4
  66. package/src/planning/system-prompt.ts +1 -1
  67. package/src/tool-catalog/active-tool-controller.ts +0 -22
  68. package/src/tool-catalog/active-tool-planner.ts +0 -26
  69. package/src/tool-catalog/tool-groups.ts +1 -9
  70. package/src/types.ts +127 -8
  71. package/src/ui-design/session.ts +114 -8
  72. package/src/utils/executable.ts +10 -1
  73. package/src/workspace/state-paths.ts +1 -1
  74. package/src/commands/mcp.ts +0 -814
  75. package/src/mcp/activation.ts +0 -77
  76. package/src/mcp/config.ts +0 -223
  77. package/src/mcp/docs.ts +0 -154
  78. package/src/mcp/gateway.ts +0 -103
  79. package/src/mcp/lifecycle.ts +0 -79
  80. package/src/mcp/manager-tool.ts +0 -104
  81. package/src/mcp/mcpc.ts +0 -113
  82. package/src/mcp/registry.ts +0 -98
  83. package/src/mcp/triggers.ts +0 -62
  84. package/src/mcp/types.ts +0 -95
@@ -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 {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """One-shot JSON bridge for the native supipowers MemPalace tool.
3
3
 
4
- Action mapping (auditable API surface) — wired against MemPalace 3.3.4:
4
+ Action mapping (auditable API surface):
5
5
  - version: stdlib importlib.metadata only; does not import MemPalace runtime modules.
6
6
  - 29 MCP-equivalent actions dispatch to ``mempalace.mcp_server.tool_<action>``
7
7
  (with the ``traverse → tool_traverse_graph`` rename). The mcp_server
@@ -129,6 +129,8 @@ def _handle_version(params: Dict[str, Any], options: Dict[str, Any]) -> Dict[str
129
129
 
130
130
  def _apply_palace_path(options: Dict[str, Any]) -> None:
131
131
  """Set MEMPALACE_PALACE_PATH so MemPalace's MempalaceConfig picks it up."""
132
+ # NOTE: mutates os.environ — safe only because we run in a fresh process per
133
+ # call. Any future daemon/persistent-process refactor MUST re-evaluate this.
132
134
  palace = options.get("palacePath") or options.get("palace")
133
135
  if palace and isinstance(palace, str) and palace:
134
136
  os.environ["MEMPALACE_PALACE_PATH"] = os.path.expanduser(palace)
@@ -203,6 +205,42 @@ def _rename(mapping: Dict[str, str]) -> Callable[[Dict[str, Any]], Dict[str, Any
203
205
  return extract
204
206
 
205
207
 
208
+
209
+ # ── diary_write: source_file embedding ───────────────────────────────────
210
+ #
211
+ # tool_diary_write(agent_name, entry, topic, wing) does not accept source_file.
212
+ # When a source_file is provided, embed it as a deterministic first-line prefix
213
+ # "[source: <source_file>]" in the entry text so that diary_read content
214
+ # carries the linkage. This is the Option-2 convention documented in
215
+ # session-summary.ts: the caller supplies source_file in params; the bridge
216
+ # embeds it; the round-trip proves it via content inspection.
217
+ DIARY_SOURCE_PREFIX = "[source: "
218
+
219
+ # Upstream sanitize_content default in mempalace.config (3.3.5). Mirrored in
220
+ # src/mempalace/upstream-limits.ts (MEMPALACE_MAX_CONTENT_LENGTH). Bump both
221
+ # when upgrading.
222
+ _MAX_CONTENT_LENGTH = 100_000
223
+
224
+
225
+ def _diary_write_extractor(params: Dict[str, Any]) -> Dict[str, Any]:
226
+ kwargs = _select("agent_name", "topic", "wing")(params)
227
+ entry: str = params.get("entry") or ""
228
+ source_file = params.get("source_file")
229
+ if source_file and isinstance(source_file, str):
230
+ # MemPalace's tool_diary_write calls sanitize_content(entry), which
231
+ # raises ValueError if len(entry) > MAX_CONTENT_LENGTH. Adding the
232
+ # deterministic `[source: ...]\n` prefix can push a TS-valid entry
233
+ # past that limit and turn a previously-valid request into a domain
234
+ # failure. Reserve the prefix budget here by clipping the entry tail
235
+ # so the prefixed payload always fits.
236
+ prefix = f"{DIARY_SOURCE_PREFIX}{source_file}]\n"
237
+ budget = max(0, _MAX_CONTENT_LENGTH - len(prefix))
238
+ if len(entry) > budget:
239
+ entry = entry[:budget]
240
+ entry = f"{prefix}{entry}"
241
+ kwargs["entry"] = entry
242
+ return kwargs
243
+
206
244
  # ── MCP-equivalent action dispatch ───────────────────────────────────────
207
245
  #
208
246
  # Each entry maps our action -> (function name in mempalace.mcp_server,
@@ -224,7 +262,7 @@ MCP_TOOL_DISPATCH: Dict[str, "tuple[str, Callable[[Dict[str, Any]], Dict[str, An
224
262
  "delete_drawer": ("tool_delete_drawer", _select("drawer_id")),
225
263
  # Knowledge graph: MemPalace uses `entity` for the subject in queries.
226
264
  "kg_query": ("tool_kg_query", _rename({"subject": "entity", "as_of": "as_of", "direction": "direction"})),
227
- "kg_add": ("tool_kg_add", _select("subject", "predicate", "object", "valid_from", "source_closet")),
265
+ "kg_add": ("tool_kg_add", _select("subject", "predicate", "object", "valid_from", "valid_to", "source_file", "source_drawer_id")),
228
266
  "kg_invalidate": ("tool_kg_invalidate", _select("subject", "predicate", "object", "ended")),
229
267
  "kg_timeline": ("tool_kg_timeline", _rename({"subject": "entity"})),
230
268
  "kg_stats": ("tool_kg_stats", lambda p: {}),
@@ -239,7 +277,9 @@ MCP_TOOL_DISPATCH: Dict[str, "tuple[str, Callable[[Dict[str, Any]], Dict[str, An
239
277
  "delete_tunnel": ("tool_delete_tunnel", _select("tunnel_id")),
240
278
  # follow_tunnels: MemPalace uses wing/room (not source_wing/source_room).
241
279
  "follow_tunnels": ("tool_follow_tunnels", _rename({"source_wing": "wing", "source_room": "room"})),
242
- "diary_write": ("tool_diary_write", _select("agent_name", "entry", "topic", "wing")),
280
+ # diary_write: source_file is embedded as a prefix in the entry text by
281
+ # _diary_write_extractor — tool_diary_write does not accept source_file natively.
282
+ "diary_write": ("tool_diary_write", _diary_write_extractor),
243
283
  "diary_read": ("tool_diary_read", _select("agent_name", "wing")),
244
284
  "hook_settings": ("tool_hook_settings", lambda p: {}),
245
285
  "memories_filed_away": ("tool_memories_filed_away", lambda p: {}),
@@ -287,6 +327,84 @@ def _handle_wake_up(params: Dict[str, Any], options: Dict[str, Any]) -> Dict[str
287
327
  return _ok({"text": text}, options)
288
328
 
289
329
 
330
+
331
+ # ── wake_up_and_search: composite action (saves one python process spawn) ─
332
+
333
+
334
+ def _handle_wake_up_and_search(params: Dict[str, Any], options: Dict[str, Any]) -> Dict[str, Any]:
335
+ """Batch wake_up + optional search into a single python process.
336
+
337
+ Returns {"wake": {"text": ...}, "search": <tool_search payload or null>}.
338
+ Either half may fail independently; the other half is still returned.
339
+ """
340
+ _apply_palace_path(options)
341
+ palace = options.get("palacePath")
342
+
343
+ # ── Wake half ─────────────────────────────────────────────────────────────
344
+ wake_result: Any = None
345
+ wake_error: "str | None" = None
346
+ try:
347
+ layers = _import_or_raise("mempalace.layers")
348
+ stack = layers.MemoryStack(palace_path=palace) if palace else layers.MemoryStack()
349
+ wake_kwargs: Dict[str, Any] = {}
350
+ if params.get("wing"):
351
+ wake_kwargs["wing"] = params["wing"]
352
+ text = _wrap_runtime_errors("MemoryStack.wake_up", lambda: stack.wake_up(**wake_kwargs))
353
+ wake_result = {"text": text}
354
+ except BridgeDomainError as exc:
355
+ wake_error = f"{exc.code}: {exc.message}"
356
+ except Exception as exc: # pragma: no cover — defensive isolation
357
+ wake_error = str(exc)
358
+
359
+ # ── Search half (skipped when query is absent or empty) ───────────────────
360
+ search_result: Any = None
361
+ search_error: "str | None" = None
362
+ query = params.get("query")
363
+ if query and isinstance(query, str) and query.strip():
364
+ try:
365
+ module = _import_or_raise("mempalace.mcp_server")
366
+ tool_search = getattr(module, "tool_search", None)
367
+ if not callable(tool_search):
368
+ raise BridgeDomainError(
369
+ "mempalace_missing",
370
+ "mempalace.mcp_server.tool_search is not callable.",
371
+ "Upgrade the managed MemPalace runtime via `/supi:memory setup`.",
372
+ )
373
+ search_kwargs: Dict[str, Any] = {"query": query}
374
+ if params.get("wing"):
375
+ search_kwargs["wing"] = params["wing"]
376
+ if params.get("room"):
377
+ search_kwargs["room"] = params["room"]
378
+ if isinstance(params.get("limit"), int):
379
+ search_kwargs["limit"] = params["limit"]
380
+ raw = _wrap_runtime_errors(
381
+ "mempalace.mcp_server.tool_search",
382
+ lambda: tool_search(**search_kwargs),
383
+ )
384
+ normalized = _to_jsonable(raw)
385
+ if isinstance(normalized, dict) and normalized.get("ok") is False and isinstance(normalized.get("error"), dict):
386
+ err = normalized["error"]
387
+ code = err.get("code") if isinstance(err.get("code"), str) else "mempalace_runtime_error"
388
+ message = err.get("message") if isinstance(err.get("message"), str) else json.dumps(err, sort_keys=True)
389
+ search_error = f"{code}: {message}"
390
+ else:
391
+ search_result = normalized
392
+ except BridgeDomainError as exc:
393
+ # Surface as partial error so the caller can distinguish a real
394
+ # search failure from "no query / no hits". Mirrors the wake half.
395
+ search_error = f"{exc.code}: {exc.message}"
396
+ except Exception as exc: # pragma: no cover — defensive isolation
397
+ # Same contract: never let one half kill the other, but the caller
398
+ # gets a string they can render or log.
399
+ search_error = str(exc)
400
+
401
+ payload: Dict[str, Any] = {"wake": wake_result, "search": search_result}
402
+ if wake_error is not None:
403
+ payload["wake_error"] = wake_error
404
+ if search_error is not None:
405
+ payload["search_error"] = search_error
406
+ return _ok(payload, options)
407
+
290
408
  # ── Native CLI args builders ──────────────────────────────────────────────
291
409
 
292
410
 
@@ -307,6 +425,8 @@ def _make_cli_args_mine(params: Dict[str, Any]) -> "list[str]":
307
425
  args.append("--include-ignored")
308
426
  if params.get("no_gitignore"):
309
427
  args.append("--no-gitignore")
428
+ if params.get("dry_run"):
429
+ args.append("--dry-run")
310
430
  if params.get("extract"):
311
431
  args.append("--extract")
312
432
  return args
@@ -331,6 +451,10 @@ def _make_cli_args_repair(params: Dict[str, Any]) -> "list[str]":
331
451
  args.append("--yes")
332
452
  if params.get("mode"):
333
453
  args.extend(["--mode", str(params["mode"])])
454
+ if params.get("source"):
455
+ args.extend(["--source", str(params["source"])])
456
+ if params.get("archive_existing"):
457
+ args.append("--archive-existing")
334
458
  if params.get("dry_run"):
335
459
  args.append("--dry-run")
336
460
  return args
@@ -396,6 +520,7 @@ def _make_cli_handler(action: str) -> Callable[[Dict[str, Any], Dict[str, Any]],
396
520
  DISPATCH: Dict[str, Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]]] = {
397
521
  "version": _handle_version,
398
522
  "wake_up": _handle_wake_up,
523
+ "wake_up_and_search": _handle_wake_up_and_search,
399
524
  }
400
525
  for _action in MCP_TOOL_DISPATCH:
401
526
  DISPATCH[_action] = _make_mcp_handler(_action)