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
@@ -28,6 +28,7 @@ import {
28
28
  appendImplementLog,
29
29
  saveHarnessDesignSpec,
30
30
  saveHarnessDiscover,
31
+ saveHarnessDocsLayerStaging,
31
32
  saveHarnessResearchTopic,
32
33
  } from "./storage.js";
33
34
  import {
@@ -36,6 +37,8 @@ import {
36
37
  resolve as resolveQueueEntry,
37
38
  markWontfix as markQueueWontfix,
38
39
  } from "./anti_slop/queue.js";
40
+ import { validateLayerDocMarkdown } from "./docs/validator.js";
41
+ import { isSafeLayerId } from "./project-paths.js";
39
42
 
40
43
  // ---------------------------------------------------------------------------
41
44
  // Helpers
@@ -410,6 +413,79 @@ export function registerHarnessPipelineTools(platform: Platform): void {
410
413
  return toolResult({ ok: true, id, details: { state } });
411
414
  },
412
415
  });
416
+
417
+ // -------- harness_docs_record ----------
418
+ platform.registerTool({
419
+ name: "harness_docs_record",
420
+ label: "Harness Docs Record",
421
+ description: "Record one per-layer agent knowledge document for the docs stage.",
422
+ parameters: {
423
+ type: "object",
424
+ properties: {
425
+ sessionId: SESSION_ID_PROP,
426
+ layerId: {
427
+ type: "string",
428
+ description: "Layer id this doc covers (matches the assigned layer rule).",
429
+ },
430
+ markdown: {
431
+ type: "string",
432
+ description: "Full markdown body, including the provenance marker, frontmatter, and required headings.",
433
+ },
434
+ },
435
+ required: ["sessionId", "layerId", "markdown"],
436
+ },
437
+ async execute(_id: string, params: unknown, _signal: AbortSignal, _onUpdate: unknown, toolCtx: unknown) {
438
+ const cwdR = readCwd(toolCtx, "harness_docs_record");
439
+ if (!cwdR.ok) return toolResult(cwdR);
440
+ const sidR = readSessionId(params, "harness_docs_record");
441
+ if (!sidR.ok) return toolResult(sidR);
442
+ if (!isRecord(params)) {
443
+ return toolResult({ ok: false, message: "harness_docs_record requires an object payload" });
444
+ }
445
+ const p = params as Record<string, unknown>;
446
+ const layerId = typeof p.layerId === "string" ? p.layerId : "";
447
+ if (!layerId) {
448
+ return toolResult({ ok: false, message: "harness_docs_record requires a non-empty layerId" });
449
+ }
450
+ if (!isSafeLayerId(layerId)) {
451
+ return toolResult({
452
+ ok: false,
453
+ message: `harness_docs_record: layerId ${JSON.stringify(layerId)} is not a safe filename stem (alnum/_/-/non-leading dots, ≤64 chars, no '..' or path separators)`,
454
+ });
455
+ }
456
+ const markdown = typeof p.markdown === "string" ? p.markdown : "";
457
+ if (markdown.length === 0) {
458
+ return toolResult({ ok: false, message: "harness_docs_record requires a non-empty markdown body" });
459
+ }
460
+
461
+ const expectation = lookupDocsLayerExpectation(sidR.sessionId, layerId);
462
+ if (!expectation) {
463
+ return toolResult({
464
+ ok: false,
465
+ message: `harness_docs_record: no expectation registered for session ${sidR.sessionId} layer ${layerId}. The docs stage is the only valid invocation context.`,
466
+ });
467
+ }
468
+
469
+ const validation = validateLayerDocMarkdown(markdown, {
470
+ expectedLayerId: layerId,
471
+ expectedSourceHash: expectation.expectedSourceHash,
472
+ maxDocLoc: expectation.maxDocLoc,
473
+ maxAgentContextLoc: expectation.maxAgentContextLoc,
474
+ });
475
+ if (!validation.ok) {
476
+ return toolResult({
477
+ ok: false,
478
+ message: `harness_docs_record rejected: ${validation.errors.join("; ")}`,
479
+ details: { errors: validation.errors },
480
+ });
481
+ }
482
+
483
+ const persisted = saveHarnessDocsLayerStaging(platform.paths, cwdR.cwd, sidR.sessionId, layerId, markdown);
484
+ const r = unwrap(persisted, "harness_docs_record");
485
+ if (!r.ok) return toolResult(r);
486
+ return toolResult({ ok: true, path: r.value });
487
+ },
488
+ });
413
489
  }
414
490
 
415
491
  /** Names exposed for hook-bridge correlation. */
@@ -421,6 +497,60 @@ export const HARNESS_PIPELINE_TOOL_NAMES = [
421
497
  "harness_validate_finding",
422
498
  "harness_slop_queue_append",
423
499
  "harness_slop_queue_resolve",
500
+ "harness_docs_record",
424
501
  ] as const;
425
502
 
426
503
  export type HarnessPipelineToolName = (typeof HARNESS_PIPELINE_TOOL_NAMES)[number];
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Docs stage expectations registry.
507
+ //
508
+ // The docs stage registers per-(session, layer) expectations before dispatching each
509
+ // subagent so the `harness_docs_record` handler can validate `layerId`, `sourceHash`,
510
+ // and LOC caps synchronously. Module-scoped Map is intentional: registration and
511
+ // consumption both run in the same process within a single pipeline driver invocation.
512
+ // ---------------------------------------------------------------------------
513
+
514
+ export interface HarnessDocsLayerExpectation {
515
+ expectedSourceHash: string;
516
+ maxDocLoc: number;
517
+ maxAgentContextLoc: number;
518
+ }
519
+
520
+ const docsLayerExpectations = new Map<string, HarnessDocsLayerExpectation>();
521
+
522
+ function docsKey(sessionId: string, layerId: string): string {
523
+ return `${sessionId}::${layerId}`;
524
+ }
525
+
526
+ /** Register the expectations the docs tool will validate on the next record call. */
527
+ export function registerDocsLayerExpectation(
528
+ sessionId: string,
529
+ layerId: string,
530
+ expectation: HarnessDocsLayerExpectation,
531
+ ): void {
532
+ if (!isSafeLayerId(layerId)) {
533
+ throw new Error(
534
+ `registerDocsLayerExpectation: refusing to register unsafe layerId ${JSON.stringify(layerId)}`,
535
+ );
536
+ }
537
+ docsLayerExpectations.set(docsKey(sessionId, layerId), expectation);
538
+ }
539
+
540
+ /** Remove a registered expectation. Idempotent. */
541
+ export function clearDocsLayerExpectation(sessionId: string, layerId: string): void {
542
+ docsLayerExpectations.delete(docsKey(sessionId, layerId));
543
+ }
544
+
545
+ /** Lookup the registered expectation (returns null when none is registered). */
546
+ export function lookupDocsLayerExpectation(
547
+ sessionId: string,
548
+ layerId: string,
549
+ ): HarnessDocsLayerExpectation | null {
550
+ return docsLayerExpectations.get(docsKey(sessionId, layerId)) ?? null;
551
+ }
552
+
553
+ /** Clear every registration (test helper). */
554
+ export function _clearAllDocsLayerExpectationsForTests(): void {
555
+ docsLayerExpectations.clear();
556
+ }
@@ -1,5 +1,8 @@
1
+ import { homedir } from "node:os";
2
+ import * as path from "node:path";
3
+
1
4
  import type { ResolvedMempalaceConfig } from "./config.js";
2
- import type { MempalaceParams } from "./schema.js";
5
+ import { MEMPALACE_ACTIONS, type MempalaceAction, type MempalaceParams } from "./schema.js";
3
6
  import {
4
7
  resolveBridgeScriptPath,
5
8
  resolveManagedVenvPaths,
@@ -24,6 +27,11 @@ export type MempalaceBridgeCallResult =
24
27
  diagnostics: Record<string, unknown>;
25
28
  };
26
29
 
30
+ interface BridgeDispatchResult {
31
+ result: MempalaceBridgeCallResult;
32
+ release: Promise<void>;
33
+ }
34
+
27
35
  export interface MempalaceBridgeRuntimeDeps {
28
36
  resolveBridgeScriptPath?: () => BridgePathResult;
29
37
  runBridgeRequest?: (options: RunBridgeRequestOptions) => Promise<RunBridgeRequestResult>;
@@ -54,6 +62,102 @@ function mergeDiagnostics(...parts: Array<Record<string, unknown> | undefined>):
54
62
  return Object.assign({}, ...parts.filter(Boolean));
55
63
  }
56
64
 
65
+ // Per-action mutation classification. `write` actions are serialized per palace
66
+ // to avoid ChromaDB's sqlite "database is locked" errors; `read` actions run in
67
+ // parallel. The classification is exhaustive (enforced by `satisfies`) so any
68
+ // new action added to MEMPALACE_ACTIONS must be assigned a kind here — no
69
+ // silent default to the read path. `setup` is intercepted before the bridge and
70
+ // never reaches this map; it is still listed for exhaustiveness.
71
+ const ACTION_KIND = {
72
+ // ── reads / queries / listings ──────────────────────────────────────────
73
+ status: "read",
74
+ list_wings: "read",
75
+ list_rooms: "read",
76
+ get_taxonomy: "read",
77
+ search: "read",
78
+ check_duplicate: "read",
79
+ get_aaak_spec: "read",
80
+ get_drawer: "read",
81
+ list_drawers: "read",
82
+ kg_query: "read",
83
+ kg_timeline: "read",
84
+ kg_stats: "read",
85
+ traverse: "read",
86
+ find_tunnels: "read",
87
+ graph_stats: "read",
88
+ list_tunnels: "read",
89
+ follow_tunnels: "read",
90
+ diary_read: "read",
91
+ hook_settings: "read",
92
+ memories_filed_away: "read",
93
+ reconnect: "read",
94
+ version: "read",
95
+ wake_up: "read",
96
+ wake_up_and_search: "read",
97
+ setup: "read", // intercepted upstream; never reaches the bridge mutex
98
+
99
+ // ── writes / mutations ──────────────────────────────────────────────────
100
+ add_drawer: "write",
101
+ update_drawer: "write",
102
+ delete_drawer: "write",
103
+ diary_write: "write",
104
+ kg_add: "write",
105
+ kg_invalidate: "write",
106
+ create_tunnel: "write",
107
+ delete_tunnel: "write",
108
+ // CLI actions that touch the palace on disk: init creates structure,
109
+ // mine writes drawers via the indexer, split rewrites mega-file sources
110
+ // under the palace tree, repair rebuilds the chroma index. All four must
111
+ // serialize against concurrent add_drawer/diary_write writers on the
112
+ // same palace.
113
+ init: "write",
114
+ mine: "write",
115
+ split: "write",
116
+ repair: "write",
117
+ } as const satisfies Record<MempalaceAction, "read" | "write">;
118
+
119
+ // Compile-time check: every MEMPALACE_ACTIONS entry must have a kind.
120
+ // If a new action is added without an ACTION_KIND entry, this errors.
121
+ type _ExhaustiveActionKind = MempalaceAction extends keyof typeof ACTION_KIND ? true : never;
122
+ const _exhaustiveActionKind: _ExhaustiveActionKind = true;
123
+ void _exhaustiveActionKind;
124
+ void MEMPALACE_ACTIONS;
125
+
126
+ function isWriteAction(action: MempalaceAction): boolean {
127
+ return ACTION_KIND[action] === "write";
128
+ }
129
+
130
+ // Subset of write actions where a single retry on transient bridge failures
131
+ // meaningfully improves durability (write-once payloads, low retry cost).
132
+ const RETRY_ON_TRANSIENT = new Set<MempalaceAction>([
133
+ "add_drawer", "diary_write", "kg_add", "kg_invalidate",
134
+ ]);
135
+
136
+ // Error codes that are transient and safe to retry. `bridge_timeout` is
137
+ // deliberately excluded: when the TS-side timeout fires we SIGKILL the python
138
+ // child, but the OS may not have reaped the writer yet — retrying could race a
139
+ // still-live writer on the same sqlite file. `bridge_process_failed` only
140
+ // fires after `child.on('close')`, so the child is definitely gone by then.
141
+ const TRANSIENT_ERROR_CODES = new Set(["bridge_process_failed"]);
142
+
143
+ // Per-palace write serialization queue. Keyed by the canonicalized palace
144
+ // path so that concurrent OMP workspaces (different palaces) do not block each
145
+ // other, and so callers passing `~/x` vs `/Users/me/x` collide on the same
146
+ // lock (they hit the same sqlite file). Entries are deleted in a settlement
147
+ // handler when they still point at the current tail, keeping the map bounded
148
+ // in long-lived OMP processes.
149
+ const palaceMutex = new Map<string, Promise<void>>();
150
+ const RELEASED = Promise.resolve<void>(undefined);
151
+
152
+ function canonicalPalaceKey(palacePath: string): string {
153
+ const trimmed = palacePath.trim();
154
+ if (trimmed === "~") return homedir();
155
+ const expanded = (trimmed.startsWith("~/") || trimmed.startsWith("~\\"))
156
+ ? path.join(homedir(), trimmed.slice(2))
157
+ : trimmed;
158
+ return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(expanded);
159
+ }
160
+
57
161
  function resolveToolTimeoutMs(params: MempalaceParams, bridgeTimeoutMs: number): number {
58
162
  if (typeof params.timeout !== "number") return bridgeTimeoutMs;
59
163
 
@@ -81,55 +185,117 @@ export function createMempalaceBridge(options: CreateMempalaceBridgeOptions): Me
81
185
  const venv = resolveManagedVenvPaths(options.config.managedVenvPath);
82
186
  const timeoutMs = resolveToolTimeoutMs(params, options.config.timeouts.bridgeMs);
83
187
  const palacePath = params.palace ?? options.config.palacePath;
84
- const runResult = await runBridge({
85
- pythonPath: venv.python,
86
- bridgeScriptPath: bridgePath.path,
87
- timeoutMs,
88
- request: {
89
- action: params.action,
90
- params: { ...params },
91
- options: {
92
- cwd: options.cwd,
93
- palacePath,
94
- agentName: options.config.defaultAgentName,
188
+
189
+ const invokeOnce = async (): Promise<BridgeDispatchResult> => {
190
+ const runResult = await runBridge({
191
+ pythonPath: venv.python,
192
+ bridgeScriptPath: bridgePath.path,
193
+ timeoutMs,
194
+ request: {
195
+ action: params.action,
196
+ params: { ...params },
197
+ options: {
198
+ cwd: options.cwd,
199
+ palacePath,
200
+ agentName: options.config.defaultAgentName,
201
+ },
95
202
  },
96
- },
97
- });
203
+ });
204
+
205
+ if (!runResult.ok) {
206
+ return {
207
+ result: {
208
+ ok: false,
209
+ action: params.action,
210
+ error: withSetupRemediation(runResult.error),
211
+ diagnostics: {
212
+ durationMs: runResult.durationMs,
213
+ stdoutPreview: runResult.stdoutPreview,
214
+ stderrTail: runResult.stderrTail,
215
+ },
216
+ },
217
+ release: runResult.completion ?? RELEASED,
218
+ };
219
+ }
220
+
221
+ if (!runResult.response.ok) {
222
+ return {
223
+ result: {
224
+ ok: false,
225
+ action: params.action,
226
+ error: withSetupRemediation(runResult.response.error),
227
+ diagnostics: mergeDiagnostics(runResult.response.diagnostics, {
228
+ durationMs: runResult.durationMs,
229
+ stderr: runResult.stderr,
230
+ }),
231
+ },
232
+ release: RELEASED,
233
+ };
234
+ }
98
235
 
99
- if (!runResult.ok) {
100
236
  return {
101
- ok: false,
102
- action: params.action,
103
- error: withSetupRemediation(runResult.error),
104
- diagnostics: {
105
- durationMs: runResult.durationMs,
106
- stdoutPreview: runResult.stdoutPreview,
107
- stderrTail: runResult.stderrTail,
237
+ result: {
238
+ ok: true,
239
+ action: params.action,
240
+ result: runResult.response.result ?? {},
241
+ diagnostics: mergeDiagnostics(runResult.response.diagnostics, {
242
+ durationMs: runResult.durationMs,
243
+ stderr: runResult.stderr,
244
+ }),
108
245
  },
246
+ release: RELEASED,
109
247
  };
110
- }
248
+ };
111
249
 
112
- if (!runResult.response.ok) {
113
- return {
114
- ok: false,
115
- action: params.action,
116
- error: withSetupRemediation(runResult.response.error),
117
- diagnostics: mergeDiagnostics(runResult.response.diagnostics, {
118
- durationMs: runResult.durationMs,
119
- stderr: runResult.stderr,
120
- }),
250
+ // dispatch: run with one retry on transient errors for eligible actions.
251
+ const dispatch = async (): Promise<BridgeDispatchResult> => {
252
+ const first = await invokeOnce();
253
+ const withRetries = (r: BridgeDispatchResult, retries: number): BridgeDispatchResult => {
254
+ const diag = { ...r.result.diagnostics, retries };
255
+ return {
256
+ result: r.result.ok
257
+ ? { ok: true, action: r.result.action, result: r.result.result, diagnostics: diag }
258
+ : { ok: false, action: r.result.action, error: r.result.error, diagnostics: diag },
259
+ release: r.release,
260
+ };
121
261
  };
262
+ if (
263
+ !first.result.ok &&
264
+ RETRY_ON_TRANSIENT.has(params.action) &&
265
+ TRANSIENT_ERROR_CODES.has(first.result.error.code)
266
+ ) {
267
+ await first.release;
268
+ await new Promise<void>((res) => setTimeout(res, 150));
269
+ return withRetries(await invokeOnce(), 1);
270
+ }
271
+ return withRetries(first, 0);
272
+ };
273
+
274
+ // Serialize write actions per palace to avoid ChromaDB lock contention.
275
+ if (isWriteAction(params.action)) {
276
+ const mutexKey = canonicalPalaceKey(palacePath);
277
+ const tail = palaceMutex.get(mutexKey) ?? RELEASED;
278
+ const mine = tail.then(dispatch, dispatch);
279
+ // Chaining uses the runtime completion signal, not just the call
280
+ // result. A bridge_timeout can be returned to the caller before the
281
+ // killed Python child has fully exited; next writer must wait until
282
+ // that completion settles so sqlite/Chroma locks are released.
283
+ const settled = mine
284
+ .then(({ release }) => release, () => RELEASED)
285
+ .then(() => {}, () => {});
286
+ palaceMutex.set(mutexKey, settled);
287
+ // GC the entry once the release signal settles, if no later writer has
288
+ // stacked on top. Comparing identity preserves the chain when another
289
+ // writer enqueued between `set` above and settlement.
290
+ settled.then(() => {
291
+ if (palaceMutex.get(mutexKey) === settled) {
292
+ palaceMutex.delete(mutexKey);
293
+ }
294
+ });
295
+ return (await mine).result;
122
296
  }
123
297
 
124
- return {
125
- ok: true,
126
- action: params.action,
127
- result: runResult.response.result ?? {},
128
- diagnostics: mergeDiagnostics(runResult.response.diagnostics, {
129
- durationMs: runResult.durationMs,
130
- stderr: runResult.stderr,
131
- }),
132
- };
298
+ return (await dispatch()).result;
133
299
  },
134
300
  };
135
301
  }
@@ -26,13 +26,19 @@ function expandUserPath(input: string, cwd: string): string {
26
26
  }
27
27
 
28
28
  function sanitizedWing(value: string): string {
29
+ // MemPalace's own normalize_wing_name canonicalizes wing slugs with
30
+ // underscores (`hyphens` and spaces are folded to `_`). We mirror that
31
+ // here so a project directory like `sij_mono` and a supipowers-resolved
32
+ // wing `sij-mono` collapse to the same slug; otherwise data ends up
33
+ // split across two wings (`sij_mono` vs `sij-mono`) and search/diary
34
+ // writes diverge between the CLI and the hook bridge.
29
35
  return value
30
36
  .trim()
31
37
  .toLowerCase()
32
- .replace(/[\s/\\]+/g, "-")
33
- .replace(/[^a-z0-9_-]+/g, "-")
34
- .replace(/[-_]+/g, "-")
35
- .replace(/^-+|-+$/g, "");
38
+ .replace(/[\s/\\-]+/g, "_")
39
+ .replace(/[^a-z0-9_]+/g, "_")
40
+ .replace(/_+/g, "_")
41
+ .replace(/^_+|_+$/g, "");
36
42
  }
37
43
 
38
44
  export function normalizeMempalaceWing(value: string): string {
@@ -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 {