supipowers 2.0.2 → 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.
- package/README.md +5 -6
- package/package.json +4 -2
- package/skills/harness/SKILL.md +1 -0
- package/src/bootstrap.ts +5 -133
- package/src/config/defaults.ts +5 -5
- package/src/config/loader.ts +1 -0
- package/src/config/schema.ts +2 -6
- package/src/context-mode/knowledge/store.ts +381 -43
- package/src/context-mode/tools.ts +41 -3
- package/src/deps/registry.ts +1 -12
- package/src/fix-pr/assessment.ts +1 -0
- package/src/fix-pr/prompt-builder.ts +1 -0
- package/src/git/commit.ts +76 -18
- package/src/harness/command.ts +103 -6
- package/src/harness/default-agents/docs.md +39 -0
- package/src/harness/docs/config.ts +29 -0
- package/src/harness/docs/glob-match.ts +27 -0
- package/src/harness/docs/index-renderer.ts +82 -0
- package/src/harness/docs/provenance.ts +125 -0
- package/src/harness/docs/regen-decision.ts +167 -0
- package/src/harness/docs/representative-files.ts +175 -0
- package/src/harness/docs/source-hash.ts +106 -0
- package/src/harness/docs/validator.ts +233 -0
- package/src/harness/hooks/layer-context-inject.ts +35 -1
- package/src/harness/hooks/register.ts +24 -3
- package/src/harness/pipeline.ts +20 -5
- package/src/harness/pr-comment/baseline.ts +105 -0
- package/src/harness/pr-comment/ci-env.ts +120 -0
- package/src/harness/pr-comment/gh-poster.ts +227 -0
- package/src/harness/pr-comment/handler.ts +198 -0
- package/src/harness/pr-comment/render.ts +297 -0
- package/src/harness/pr-comment/status.ts +95 -0
- package/src/harness/pr-comment/types.ts +73 -0
- package/src/harness/pr-comment/workflow-summary.ts +47 -0
- package/src/harness/project-paths.ts +95 -0
- package/src/harness/stages/design.ts +1 -0
- package/src/harness/stages/discover.ts +1 -13
- package/src/harness/stages/docs.ts +708 -0
- package/src/harness/stages/implement-apply.ts +877 -0
- package/src/harness/stages/implement.ts +64 -51
- package/src/harness/stages/plan.ts +25 -16
- package/src/harness/stages/validate.ts +370 -0
- package/src/harness/storage.ts +142 -0
- package/src/harness/tools.ts +130 -0
- package/src/mempalace/bridge.ts +207 -41
- package/src/mempalace/config.ts +10 -4
- package/src/mempalace/format.ts +122 -6
- package/src/mempalace/hooks.ts +204 -56
- package/src/mempalace/installer-helper.ts +18 -4
- package/src/mempalace/python/mempalace_bridge.py +128 -3
- package/src/mempalace/runtime.ts +53 -16
- package/src/mempalace/schema.ts +151 -30
- package/src/mempalace/session-summary.ts +5 -0
- package/src/mempalace/tool.ts +17 -4
- package/src/mempalace/upstream-limits.ts +69 -0
- package/src/planning/approval-flow.ts +25 -2
- package/src/planning/planning-ask-tool.ts +34 -4
- package/src/planning/system-prompt.ts +1 -1
- package/src/tool-catalog/active-tool-controller.ts +0 -22
- package/src/tool-catalog/active-tool-planner.ts +0 -26
- package/src/tool-catalog/tool-groups.ts +1 -9
- package/src/types.ts +87 -8
- package/src/ui-design/session.ts +114 -8
- package/src/utils/executable.ts +10 -1
- package/src/workspace/state-paths.ts +1 -1
- package/src/commands/mcp.ts +0 -814
- package/src/mcp/activation.ts +0 -77
- package/src/mcp/config.ts +0 -223
- package/src/mcp/docs.ts +0 -154
- package/src/mcp/gateway.ts +0 -103
- package/src/mcp/lifecycle.ts +0 -79
- package/src/mcp/manager-tool.ts +0 -104
- package/src/mcp/mcpc.ts +0 -113
- package/src/mcp/registry.ts +0 -98
- package/src/mcp/triggers.ts +0 -62
- package/src/mcp/types.ts +0 -95
package/src/harness/storage.ts
CHANGED
|
@@ -29,15 +29,22 @@ import {
|
|
|
29
29
|
getHarnessDecisionsPath,
|
|
30
30
|
getHarnessDesignSpecJsonPath,
|
|
31
31
|
getHarnessDiscoverPath,
|
|
32
|
+
getHarnessDocsStagingDir,
|
|
33
|
+
getHarnessDocsStagingLayerPath,
|
|
34
|
+
getHarnessDocsStagingReadmePath,
|
|
32
35
|
getHarnessImplementLogPath,
|
|
33
36
|
getHarnessManifestPath,
|
|
34
37
|
getHarnessPipelineLogPath,
|
|
35
38
|
getHarnessQueuePath,
|
|
39
|
+
getHarnessRepoDocsLayerPath,
|
|
40
|
+
getHarnessRepoDocsLayersDir,
|
|
41
|
+
getHarnessRepoDocsReadmePath,
|
|
36
42
|
getHarnessRepoScorePath,
|
|
37
43
|
getHarnessResearchTopicPath,
|
|
38
44
|
getHarnessScoreHistoryPath,
|
|
39
45
|
getHarnessSessionDir,
|
|
40
46
|
getHarnessValidateReportPath,
|
|
47
|
+
HARNESS_DOCS_LAYERS_DIRNAME,
|
|
41
48
|
} from "./project-paths.js";
|
|
42
49
|
|
|
43
50
|
// ---------------------------------------------------------------------------
|
|
@@ -418,6 +425,44 @@ export function appendImplementLog(
|
|
|
418
425
|
return appendJsonl(getHarnessImplementLogPath(paths, cwd, sessionId), record);
|
|
419
426
|
}
|
|
420
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Return true if the implement log records a successful programmatic apply for this
|
|
430
|
+
* session: the most recent record has `kind: "applied"` and an empty `errors` array.
|
|
431
|
+
* Used by `HarnessImplementStage.isComplete` to fast-skip reruns.
|
|
432
|
+
*/
|
|
433
|
+
export function hasSuccessfulImplementApply(
|
|
434
|
+
paths: PlatformPaths,
|
|
435
|
+
cwd: string,
|
|
436
|
+
sessionId: string,
|
|
437
|
+
): boolean {
|
|
438
|
+
const logPath = getHarnessImplementLogPath(paths, cwd, sessionId);
|
|
439
|
+
if (!fs.existsSync(logPath)) return false;
|
|
440
|
+
let raw: string;
|
|
441
|
+
try {
|
|
442
|
+
raw = fs.readFileSync(logPath, "utf8");
|
|
443
|
+
} catch {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
// Scan from the end so a later failed re-apply correctly overrides an earlier success.
|
|
447
|
+
const lines = raw.split("\n");
|
|
448
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
449
|
+
const line = lines[i].trim();
|
|
450
|
+
if (line.length === 0) continue;
|
|
451
|
+
let record: unknown;
|
|
452
|
+
try {
|
|
453
|
+
record = JSON.parse(line);
|
|
454
|
+
} catch {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) continue;
|
|
458
|
+
const r = record as { kind?: unknown; errors?: unknown };
|
|
459
|
+
if (r.kind !== "applied") continue;
|
|
460
|
+
const errCount = Array.isArray(r.errors) ? r.errors.length : 0;
|
|
461
|
+
return errCount === 0;
|
|
462
|
+
}
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
|
|
421
466
|
// ---------------------------------------------------------------------------
|
|
422
467
|
// Project-scoped queue + score (shared across worktrees)
|
|
423
468
|
// ---------------------------------------------------------------------------
|
|
@@ -465,3 +510,100 @@ export function appendScoreHistory(
|
|
|
465
510
|
): UltraPlanStorageResult<string> {
|
|
466
511
|
return appendJsonl(getHarnessScoreHistoryPath(paths, cwd), record);
|
|
467
512
|
}
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// Docs stage — staging + repo promotion.
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
/** Save a single layer doc into the session's staging area. Atomic write. */
|
|
519
|
+
export function saveHarnessDocsLayerStaging(
|
|
520
|
+
paths: PlatformPaths,
|
|
521
|
+
cwd: string,
|
|
522
|
+
sessionId: string,
|
|
523
|
+
layerId: string,
|
|
524
|
+
markdown: string,
|
|
525
|
+
): UltraPlanStorageResult<string> {
|
|
526
|
+
return writeTextAtomic(
|
|
527
|
+
getHarnessDocsStagingLayerPath(paths, cwd, sessionId, layerId),
|
|
528
|
+
markdown,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Read a single staged layer doc. */
|
|
533
|
+
export function loadHarnessDocsLayerStaging(
|
|
534
|
+
paths: PlatformPaths,
|
|
535
|
+
cwd: string,
|
|
536
|
+
sessionId: string,
|
|
537
|
+
layerId: string,
|
|
538
|
+
): UltraPlanStorageResult<string> {
|
|
539
|
+
return readTextFile(getHarnessDocsStagingLayerPath(paths, cwd, sessionId, layerId));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** List staged layer ids (file basenames without `.md`). Returns [] when dir is absent. */
|
|
543
|
+
export function listHarnessDocsLayerStaging(
|
|
544
|
+
paths: PlatformPaths,
|
|
545
|
+
cwd: string,
|
|
546
|
+
sessionId: string,
|
|
547
|
+
): string[] {
|
|
548
|
+
const dir = path.join(
|
|
549
|
+
getHarnessDocsStagingDir(paths, cwd, sessionId),
|
|
550
|
+
HARNESS_DOCS_LAYERS_DIRNAME,
|
|
551
|
+
);
|
|
552
|
+
if (!fs.existsSync(dir)) return [];
|
|
553
|
+
try {
|
|
554
|
+
return fs
|
|
555
|
+
.readdirSync(dir)
|
|
556
|
+
.filter((name) => name.endsWith(".md"))
|
|
557
|
+
.map((name) => name.slice(0, -3))
|
|
558
|
+
.sort();
|
|
559
|
+
} catch {
|
|
560
|
+
return [];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Save the staged docs index. Atomic write. */
|
|
565
|
+
export function saveHarnessDocsIndexStaging(
|
|
566
|
+
paths: PlatformPaths,
|
|
567
|
+
cwd: string,
|
|
568
|
+
sessionId: string,
|
|
569
|
+
markdown: string,
|
|
570
|
+
): UltraPlanStorageResult<string> {
|
|
571
|
+
return writeTextAtomic(getHarnessDocsStagingReadmePath(paths, cwd, sessionId), markdown);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Promote staged docs to the repo-local docs/ tree.
|
|
576
|
+
*
|
|
577
|
+
* Atomicity contract: layer docs are written first (each via temp → rename); the index
|
|
578
|
+
* is written last so an observer reading mid-promotion never sees an index pointing at
|
|
579
|
+
* a yet-to-land layer doc. A failure midway leaves the previous repo state in place for
|
|
580
|
+
* already-rewritten files only when their layer was earlier in the list — callers must
|
|
581
|
+
* therefore treat partial failures as a "blocked" outcome and rely on the next run to
|
|
582
|
+
* re-promote from staging.
|
|
583
|
+
*/
|
|
584
|
+
export function promoteHarnessDocsToRepo(
|
|
585
|
+
paths: PlatformPaths,
|
|
586
|
+
cwd: string,
|
|
587
|
+
sessionId: string,
|
|
588
|
+
layerIds: readonly string[],
|
|
589
|
+
): UltraPlanStorageResult<{ layerPaths: string[]; indexPath: string }> {
|
|
590
|
+
fs.mkdirSync(getHarnessRepoDocsLayersDir(paths, cwd), { recursive: true });
|
|
591
|
+
|
|
592
|
+
const layerPaths: string[] = [];
|
|
593
|
+
for (const layerId of layerIds) {
|
|
594
|
+
const staged = loadHarnessDocsLayerStaging(paths, cwd, sessionId, layerId);
|
|
595
|
+
if (!staged.ok) return staged;
|
|
596
|
+
const repoPath = getHarnessRepoDocsLayerPath(paths, cwd, layerId);
|
|
597
|
+
const wrote = writeTextAtomic(repoPath, staged.value);
|
|
598
|
+
if (!wrote.ok) return wrote;
|
|
599
|
+
layerPaths.push(wrote.value);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const indexStaged = readTextFile(getHarnessDocsStagingReadmePath(paths, cwd, sessionId));
|
|
603
|
+
if (!indexStaged.ok) return indexStaged;
|
|
604
|
+
const indexRepo = getHarnessRepoDocsReadmePath(paths, cwd);
|
|
605
|
+
const wroteIndex = writeTextAtomic(indexRepo, indexStaged.value);
|
|
606
|
+
if (!wroteIndex.ok) return wroteIndex;
|
|
607
|
+
|
|
608
|
+
return success({ layerPaths, indexPath: wroteIndex.value });
|
|
609
|
+
}
|
package/src/harness/tools.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/mempalace/bridge.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
}
|
package/src/mempalace/config.ts
CHANGED
|
@@ -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
|
|
33
|
-
.replace(/[^a-z0-9_
|
|
34
|
-
.replace(/
|
|
35
|
-
.replace(
|
|
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 {
|