@worca/app 1.2.0 → 1.3.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 +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
package/src/core/artifacts.mjs
CHANGED
|
@@ -11,13 +11,15 @@
|
|
|
11
11
|
import { mkdir, writeFile, readFile, copyFile, readdir } from 'node:fs/promises';
|
|
12
12
|
import { join, basename, resolve, isAbsolute } from 'node:path';
|
|
13
13
|
import { randomBytes } from 'node:crypto';
|
|
14
|
-
import { realpathSync, existsSync } from 'node:fs';
|
|
14
|
+
import { realpathSync, existsSync, statSync } from 'node:fs';
|
|
15
15
|
import { hostname } from 'node:os';
|
|
16
16
|
import { projectKey, projectStorePath, canonicalProjectRoot, workspaceStorePath } from './store.mjs';
|
|
17
17
|
import { listProjects } from './projects.mjs';
|
|
18
18
|
import { branchExists, diffShortstat, hasGh, findPrForBranch } from './git-info.mjs';
|
|
19
19
|
import { getDb, tx } from './db.mjs';
|
|
20
20
|
import { RUN_LOG_FILE } from './run-log.mjs';
|
|
21
|
+
import { readRunLedger } from './metrics/ledger.mjs';
|
|
22
|
+
import { memoryTotals } from './memory-sync.mjs';
|
|
21
23
|
|
|
22
24
|
// ── DB row <-> state object mapping (Phase 3) ──────────────────────────────────
|
|
23
25
|
// JSON columns are TEXT; (de)serialize at THIS boundary only. Reads are fail-safe:
|
|
@@ -82,17 +84,27 @@ export function deleteStoreMeta(key) {
|
|
|
82
84
|
* in plans//reviews/, siblings of pipelines/). Idempotent (INSERT OR IGNORE on the
|
|
83
85
|
* (pipeline_id, kind, rel_path) PK), best-effort: a logging failure never breaks a
|
|
84
86
|
* run. A null/empty path is a no-op. The pipelines row must already exist (FK).
|
|
87
|
+
* Optional per-step attribution (step_key/node_id/cycle/created_at) is stamped on
|
|
88
|
+
* the FIRST insert only — the conflict behavior stays byte-for-byte INSERT OR
|
|
89
|
+
* IGNORE (first write wins), so a re-record never clobbers an existing row's
|
|
90
|
+
* attribution. The 3-arg form still works (attr defaults to {}, columns NULL).
|
|
85
91
|
* @param {string} pipelineId
|
|
86
92
|
* @param {string} kind
|
|
87
93
|
* @param {string} relPath
|
|
94
|
+
* @param {{stepKey?:string, nodeId?:string, cycle?:number}} [attr]
|
|
88
95
|
*/
|
|
89
|
-
export function recordArtifact(pipelineId, kind, relPath) {
|
|
96
|
+
export function recordArtifact(pipelineId, kind, relPath, attr = {}) {
|
|
90
97
|
if (!pipelineId || !kind || !relPath) return;
|
|
98
|
+
const stepKey = attr.stepKey ?? null;
|
|
99
|
+
const nodeId = attr.nodeId ?? null;
|
|
100
|
+
const cycle = attr.cycle ?? null;
|
|
101
|
+
const createdAt = new Date().toISOString();
|
|
91
102
|
try {
|
|
92
103
|
tx(() => {
|
|
93
104
|
getDb().prepare(
|
|
94
|
-
'INSERT OR IGNORE INTO artifacts (pipeline_id, kind, rel_path
|
|
95
|
-
|
|
105
|
+
'INSERT OR IGNORE INTO artifacts (pipeline_id, kind, rel_path, step_key, node_id, cycle, created_at) '
|
|
106
|
+
+ 'VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
107
|
+
).run(pipelineId, kind, relPath, stepKey, nodeId, cycle, createdAt);
|
|
96
108
|
});
|
|
97
109
|
} catch { /* artifact indexing is best-effort; never break a run on it */ }
|
|
98
110
|
}
|
|
@@ -111,6 +123,53 @@ export async function listArtifacts(pipelineId) {
|
|
|
111
123
|
.all(pipelineId).map((r) => ({ kind: r.kind, relPath: r.rel_path }));
|
|
112
124
|
}
|
|
113
125
|
|
|
126
|
+
/**
|
|
127
|
+
* List a run's artifacts with step attribution and on-disk byte size, ordered
|
|
128
|
+
* created_at (NULLs first, so legacy rows bucket ahead) then rel_path. `bytes` is
|
|
129
|
+
* stat-ed run dir first, store root second (mirroring resolveIndexedArtifactForRow's
|
|
130
|
+
* base order); a missing file reports bytes: 0. Optional { stepKey, kind } filter.
|
|
131
|
+
* An optional `limit` caps the SQL result so the per-row statSync only runs on
|
|
132
|
+
* rows the caller keeps (pass limit+1 to detect truncation); omit it to size
|
|
133
|
+
* every row.
|
|
134
|
+
* @param {string} pipelineId
|
|
135
|
+
* @param {{stepKey?:string, kind?:string, limit?:number}} [filter]
|
|
136
|
+
* @returns {Promise<Array<{kind:string, stepKey:string|null, nodeId:string|null, cycle:number|null, relPath:string, bytes:number, createdAt:string|null}>>}
|
|
137
|
+
*/
|
|
138
|
+
export async function listRunArtifacts(pipelineId, filter = {}) {
|
|
139
|
+
const row = findPipelineRowById(pipelineId);
|
|
140
|
+
if (!row) return [];
|
|
141
|
+
// Query the RESOLVED id: findPipelineRowById accepts a run-dir basename/suffix
|
|
142
|
+
// (DIR_ID_RE), so `pipelineId` may not equal the stored `pipeline_id`.
|
|
143
|
+
const clauses = ['pipeline_id = ?'];
|
|
144
|
+
const args = [row.id];
|
|
145
|
+
if (filter.stepKey) { clauses.push('step_key = ?'); args.push(filter.stepKey); }
|
|
146
|
+
if (filter.kind) { clauses.push('kind = ?'); args.push(filter.kind); }
|
|
147
|
+
const hasLimit = Number.isInteger(filter.limit) && filter.limit > 0;
|
|
148
|
+
const raw = getDb().prepare(
|
|
149
|
+
`SELECT kind, rel_path, step_key, node_id, cycle, created_at FROM artifacts
|
|
150
|
+
WHERE ${clauses.join(' AND ')}
|
|
151
|
+
ORDER BY (created_at IS NULL) DESC, created_at ASC, rel_path ASC${hasLimit ? ' LIMIT ?' : ''}`,
|
|
152
|
+
).all(...args, ...(hasLimit ? [filter.limit] : []));
|
|
153
|
+
const isWs = row.target === 'workspace' || !!row.workspace_key;
|
|
154
|
+
const storeRoot = isWs ? workspaceStorePath(row.workspace_key) : projectStorePath(row.project_key);
|
|
155
|
+
const runDir = await runDirForRow(row);
|
|
156
|
+
const sizeOf = (rel) => {
|
|
157
|
+
for (const base of [runDir, storeRoot]) {
|
|
158
|
+
try { return statSync(join(base, rel)).size; } catch { /* try next base */ }
|
|
159
|
+
}
|
|
160
|
+
return 0;
|
|
161
|
+
};
|
|
162
|
+
return raw.map((r) => ({
|
|
163
|
+
kind: r.kind,
|
|
164
|
+
stepKey: r.step_key ?? null,
|
|
165
|
+
nodeId: r.node_id ?? null,
|
|
166
|
+
cycle: r.cycle ?? null,
|
|
167
|
+
relPath: r.rel_path,
|
|
168
|
+
bytes: sizeOf(r.rel_path),
|
|
169
|
+
createdAt: r.created_at ?? null,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
114
173
|
/**
|
|
115
174
|
* Upsert the clarify row for a pipeline. Pass { questions } and/or { answers }. A
|
|
116
175
|
* partial call updates only the provided column, preserving the other. JSON-encoded
|
|
@@ -538,6 +597,32 @@ export function updatePhaseStatus(pipelineId, ordinal, status, ts) {
|
|
|
538
597
|
} catch { /* best-effort */ }
|
|
539
598
|
}
|
|
540
599
|
|
|
600
|
+
/**
|
|
601
|
+
* Aggregate live run progress from the existing readers. Free-text fields
|
|
602
|
+
* (titles, review summaries, question/answer text) are redacted by the caller,
|
|
603
|
+
* not here — this is a pure data assembler. Returns null for an unknown run.
|
|
604
|
+
* @param {string} pipelineId
|
|
605
|
+
* @returns {Promise<null | {runId:string, phase:string|null, status:string|null, phases:Array, tasks:Array, clarify:object, reviews:Array, stepQuestions:Array}>}
|
|
606
|
+
*/
|
|
607
|
+
export async function readRunProgress(pipelineId) {
|
|
608
|
+
const row = findPipelineRowById(pipelineId);
|
|
609
|
+
if (!row) return null;
|
|
610
|
+
// Read against the RESOLVED id — `pipelineId` may be a run-dir basename/suffix
|
|
611
|
+
// (findPipelineRowById's DIR_ID_RE) that no downstream table keys on.
|
|
612
|
+
const id = row.id;
|
|
613
|
+
const extras = readPipelineExtras(id);
|
|
614
|
+
return {
|
|
615
|
+
runId: row.id,
|
|
616
|
+
phase: row.phase ?? null,
|
|
617
|
+
status: row.status ?? null,
|
|
618
|
+
phases: listPhases(id),
|
|
619
|
+
tasks: listTasks(id),
|
|
620
|
+
clarify: extras.clarify,
|
|
621
|
+
reviews: extras.reviews,
|
|
622
|
+
stepQuestions: extras.stepQuestions,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
541
626
|
/**
|
|
542
627
|
* Convert an arbitrary string to a safe kebab-case slug.
|
|
543
628
|
* - Lowercases, replaces non-alphanumerics with hyphens, collapses repeats,
|
|
@@ -1138,6 +1223,22 @@ export function persistPrState(pipelineId, pr) {
|
|
|
1138
1223
|
} catch { /* best-effort */ }
|
|
1139
1224
|
}
|
|
1140
1225
|
|
|
1226
|
+
/**
|
|
1227
|
+
* Last persisted PR facts for a pipeline (spec §6.8), or null when none were
|
|
1228
|
+
* ever observed. Read by the later PR lookups so a cross-repo PR (which lives in
|
|
1229
|
+
* the base repo, not the cwd's default) is resolved by URL instead of by branch.
|
|
1230
|
+
* The resolved `state` (rowToState) deliberately omits pr_* — use this instead.
|
|
1231
|
+
* @param {string} pipelineId
|
|
1232
|
+
* @returns {{ url:string, number:(number|null), state:string }|null}
|
|
1233
|
+
*/
|
|
1234
|
+
export function readPrState(pipelineId) {
|
|
1235
|
+
if (!pipelineId) return null;
|
|
1236
|
+
try {
|
|
1237
|
+
const row = getDb().prepare('SELECT pr_url, pr_number, pr_state FROM pipelines WHERE id = ?').get(pipelineId);
|
|
1238
|
+
return row && row.pr_url ? { url: row.pr_url, number: row.pr_number ?? null, state: row.pr_state ?? 'OPEN' } : null;
|
|
1239
|
+
} catch { return null; }
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1141
1242
|
/**
|
|
1142
1243
|
* The status a stale (crashed/killed) run is reconciled to. Distinct from a user
|
|
1143
1244
|
* 'stopped' and a real 'error': the owning process died before Orchestrator.run()'s
|
|
@@ -1532,7 +1633,10 @@ async function rowToHistoryEntry(row, repoDir = null, opts = {}) {
|
|
|
1532
1633
|
// unavailable we still set pr:null (the field is present whenever requested), so
|
|
1533
1634
|
// callers can distinguish "looked, none" from "did not look".
|
|
1534
1635
|
if (opts.withPr && repoDir && feature) {
|
|
1535
|
-
|
|
1636
|
+
// pr_url first (repo-agnostic view); the branch search only for rows with no PR yet.
|
|
1637
|
+
entry.pr = (await hasGh())
|
|
1638
|
+
? await findPrForBranch({ projectDir: repoDir, head: feature, prUrl: row.pr_url || null })
|
|
1639
|
+
: null;
|
|
1536
1640
|
}
|
|
1537
1641
|
return entry;
|
|
1538
1642
|
}
|
|
@@ -1574,7 +1678,7 @@ export async function listPipelines(projectDir, opts = {}, workspaceKey) {
|
|
|
1574
1678
|
const dirById = await runDirIndex(pipelinesDir);
|
|
1575
1679
|
const rows = getDb().prepare(`
|
|
1576
1680
|
SELECT id, project_key, target, title, status, started_at, updated_at, total_cost_usd, total_active_ms,
|
|
1577
|
-
branch, workspace_meta, guardrails_id,
|
|
1681
|
+
branch, workspace_meta, guardrails_id, pr_url,
|
|
1578
1682
|
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
|
|
1579
1683
|
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
|
|
1580
1684
|
FROM pipelines
|
|
@@ -1603,7 +1707,7 @@ export async function listPipelines(projectDir, opts = {}, workspaceKey) {
|
|
|
1603
1707
|
export async function listAllPipelines(opts = {}, { batchSize = 16 } = {}) {
|
|
1604
1708
|
const rows = getDb().prepare(`
|
|
1605
1709
|
SELECT id, project_key, workspace_key, target, title, status, started_at, updated_at,
|
|
1606
|
-
total_cost_usd, total_active_ms, branch, workspace_meta, guardrails_id,
|
|
1710
|
+
total_cost_usd, total_active_ms, branch, workspace_meta, guardrails_id, pr_url,
|
|
1607
1711
|
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
|
|
1608
1712
|
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
|
|
1609
1713
|
FROM pipelines
|
|
@@ -1700,7 +1804,8 @@ export async function enrichPipelinesPr(onBatch, { batchSize = 16 } = {}) {
|
|
|
1700
1804
|
for (let i = 0; i < targets.length; i += batchSize) {
|
|
1701
1805
|
const slice = targets.slice(i, i + batchSize);
|
|
1702
1806
|
const items = await Promise.all(slice.map(async (r) => {
|
|
1703
|
-
const
|
|
1807
|
+
const prUrl = readPrState(r.id)?.url || null;
|
|
1808
|
+
const pr = (await findPrForBranch({ projectDir: r.projectDir, head: r.branch, prUrl })) || null;
|
|
1704
1809
|
if (pr) persistPrState(r.id, pr); // positive observations only (null never clears)
|
|
1705
1810
|
return { projectKey: r.projectKey, id: r.id, pr };
|
|
1706
1811
|
}));
|
|
@@ -1889,6 +1994,13 @@ export function findPipelineRowById(id) {
|
|
|
1889
1994
|
return row || null;
|
|
1890
1995
|
}
|
|
1891
1996
|
|
|
1997
|
+
/** The detail `state` of ONE pipeline by id alone — any store key, archived included (the Ask Worca progress
|
|
1998
|
+
* card's hydration read). The keyed History routes keep their full payload; this is the bare rowToState. */
|
|
1999
|
+
export function readPipelineStateById(id) {
|
|
2000
|
+
const row = findPipelineRowById(id);
|
|
2001
|
+
return row ? rowToState(row) : null;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1892
2004
|
/**
|
|
1893
2005
|
* The DB-backed lookups `sweepRunRoots` (worktree.mjs) needs, in ONE place shared by
|
|
1894
2006
|
* both callers — `ui/server.mjs`'s boot sweep and the `worca doctor` subcommand.
|
|
@@ -2048,10 +2160,22 @@ export async function readPipelineByKey(key, id) {
|
|
|
2048
2160
|
artifacts: await listArtifacts(row.id), // [{kind, relPath}] — drives the Live-logs dropdown (project + workspace)
|
|
2049
2161
|
results,
|
|
2050
2162
|
overview,
|
|
2163
|
+
teamMetrics: readRunLedger(row.id),
|
|
2164
|
+
memory: await readMemoryLedger(dir),
|
|
2051
2165
|
...readPipelineExtras(row.id),
|
|
2052
2166
|
};
|
|
2053
2167
|
}
|
|
2054
2168
|
|
|
2169
|
+
/** A run's memory ledger (<runDir>/memory.json, agent-memory P1 amendment A2) as
|
|
2170
|
+
* { mount, changes, totals }, or null when the run wrote none. The ONE reader: the History
|
|
2171
|
+
* detail above serves it whole, ask/tool-deps.mjs' readRunMemory serves get_run the
|
|
2172
|
+
* changes + totals (never the mount path). Read-only, null on any failure. */
|
|
2173
|
+
export async function readMemoryLedger(dir) {
|
|
2174
|
+
const ledger = await readJsonFile(join(dir, 'memory.json'));
|
|
2175
|
+
if (!ledger || !Array.isArray(ledger.changes)) return null;
|
|
2176
|
+
return { mount: ledger.mount || null, changes: ledger.changes, totals: memoryTotals(ledger.changes) };
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2055
2179
|
/** Local helper: read + JSON-parse a file, null on any failure. */
|
|
2056
2180
|
async function readJsonFile(p) {
|
|
2057
2181
|
try { return JSON.parse(await readFile(p, 'utf8')); } catch { return null; }
|
package/src/core/ask/catalog.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Readers are injected so unit tests run without a DB.
|
|
7
7
|
import { listProjects as realListProjects } from '../projects.mjs';
|
|
8
8
|
import { listWorkspaces as realListWorkspaces } from '../workspaces.mjs';
|
|
9
|
-
import { listWorkflows as realListWorkflows, GRAPH_DEFAULT_WORKFLOW } from '../workflows.mjs';
|
|
9
|
+
import { listWorkflows as realListWorkflows, GRAPH_DEFAULT_WORKFLOW, GRAPH_MEMORY_DEFRAG_WORKFLOW } from '../workflows.mjs';
|
|
10
10
|
import { classifyLoops } from '../../shared/graph/loops.mjs';
|
|
11
11
|
import { rankNodes } from '../../shared/graph/layout.mjs';
|
|
12
12
|
import { registryPortsFn } from '../graph/registry-ports.mjs';
|
|
@@ -27,6 +27,27 @@ function graphSteps(tpl, portsFn) {
|
|
|
27
27
|
return [...byRank.keys()].sort((a, b) => a - b).map((r) => byRank.get(r));
|
|
28
28
|
}
|
|
29
29
|
import { loadAgentRegistry as realLoadAgentRegistry } from '../agent-registry.mjs';
|
|
30
|
+
import { agentVocabulary } from '../auto/classify.mjs';
|
|
31
|
+
|
|
32
|
+
/** The agents a hand-authored shape may place (spec §8.5): the classifier's vocabulary, one compact record each.
|
|
33
|
+
* selfLoop mirrors the assembler's BAD_SELF_LOOP rule EXACTLY (assemble.mjs:349,:363-367): the agent's FIRST
|
|
34
|
+
* `when:'blocking'` output exists and one of its `loop` inputs accepts that type (equal, or the input is `any`).
|
|
35
|
+
* Read from the registry's port objects, not the card's summary strings. */
|
|
36
|
+
export function shapeAgents(registry) {
|
|
37
|
+
const loops = (m) => {
|
|
38
|
+
const outs = Array.isArray(m?.outputs) ? m.outputs : [];
|
|
39
|
+
const blocking = outs.find((o) => o && o.when === 'blocking') || null;
|
|
40
|
+
if (!blocking) return false;
|
|
41
|
+
const ins = Array.isArray(m?.inputs) ? m.inputs.filter((p) => p && p.loop) : [];
|
|
42
|
+
return ins.some((i) => i.type === 'any' || i.type === blocking.type);
|
|
43
|
+
};
|
|
44
|
+
return agentVocabulary(registry, { domain: 'coding' })
|
|
45
|
+
.map((c) => ({
|
|
46
|
+
key: c.key, displayName: c.displayName, purpose: c.purpose, inputs: c.inputs, outputs: c.outputs,
|
|
47
|
+
verifier: c.verifier, clarifier: c.clarifier, selfLoop: loops(registry[c.key]), fanOut: c.fanOut, asksQuestions: c.asksQuestions,
|
|
48
|
+
}))
|
|
49
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
50
|
+
}
|
|
30
51
|
|
|
31
52
|
/**
|
|
32
53
|
* Pure: a stored workflow template → the catalog shape. `tpl.steps` is already
|
|
@@ -78,30 +99,34 @@ export function shapeWorkflow(tpl, registry = {}) {
|
|
|
78
99
|
}
|
|
79
100
|
|
|
80
101
|
/**
|
|
81
|
-
* @param {{listProjects?:Function, listWorkspaces?:Function, listWorkflows?:Function, defaultWorkflow?:object, loadAgentRegistry?:Function}} [deps]
|
|
102
|
+
* @param {{listProjects?:Function, listWorkspaces?:Function, listWorkflows?:Function, defaultWorkflow?:object, builtinWorkflows?:object[], loadAgentRegistry?:Function}} [deps]
|
|
82
103
|
*/
|
|
83
104
|
export function createCatalog({
|
|
84
105
|
listProjects = realListProjects,
|
|
85
106
|
listWorkspaces = realListWorkspaces,
|
|
86
107
|
listWorkflows = realListWorkflows,
|
|
87
108
|
defaultWorkflow = GRAPH_DEFAULT_WORKFLOW,
|
|
109
|
+
builtinWorkflows = [GRAPH_MEMORY_DEFRAG_WORKFLOW],
|
|
88
110
|
loadAgentRegistry = realLoadAgentRegistry,
|
|
89
111
|
} = {}) {
|
|
90
112
|
async function buildCatalog() {
|
|
91
113
|
const [projects, workspaces, workflows] = await Promise.all([listProjects(), listWorkspaces(), listWorkflows()]);
|
|
92
114
|
let registry = {};
|
|
93
115
|
try { registry = loadAgentRegistry() || {}; } catch { registry = {}; }
|
|
94
|
-
// Same order as GET /api/workflows: the graph default,
|
|
95
|
-
//
|
|
96
|
-
//
|
|
116
|
+
// Same order as GET /api/workflows: the graph default, the other built-ins (Memory
|
|
117
|
+
// defragment — spec §7.2 wants the assistant to see it), then saved rows. wf_auto
|
|
118
|
+
// stays out: it has no graph. shapeWorkflow already derives `steps` + `feedbacks`.
|
|
119
|
+
const builtins = [defaultWorkflow, ...builtinWorkflows];
|
|
120
|
+
const builtinIds = new Set(builtins.map((t) => t.id));
|
|
97
121
|
const templates = [
|
|
98
|
-
|
|
99
|
-
...workflows.filter((t) => t && t.id
|
|
122
|
+
...builtins,
|
|
123
|
+
...workflows.filter((t) => t && !builtinIds.has(t.id)),
|
|
100
124
|
];
|
|
101
125
|
return {
|
|
102
126
|
projects: projects.map((p) => ({ key: p.key, name: p.name, path: p.path })),
|
|
103
127
|
workspaces: workspaces.map((w) => ({ id: w.id, name: w.name, projectKeys: [...(w.projectKeys || [])] })),
|
|
104
128
|
workflows: templates.map((t) => shapeWorkflow(t, registry)),
|
|
129
|
+
agents: shapeAgents(registry),
|
|
105
130
|
};
|
|
106
131
|
}
|
|
107
132
|
return { buildCatalog };
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// imports at all); tools.mjs owns the protected-path filter and the redaction, so
|
|
13
13
|
// the fail-closed read rules live next to get_run_diff's.
|
|
14
14
|
import {
|
|
15
|
-
addDiffComment, listDiffComments, getDiffComment, setDiffCommentResolved, deleteDiffComment,
|
|
15
|
+
addDiffComment, addDiffCommentReply, listDiffComments, getDiffComment, setDiffCommentResolved, deleteDiffComment,
|
|
16
16
|
} from '../diff-comments.mjs';
|
|
17
17
|
import { hunkContext } from '../diff-anchor.mjs';
|
|
18
18
|
|
|
@@ -40,13 +40,16 @@ export function defaultCommentDeps() {
|
|
|
40
40
|
list: (storeKey, pipelineId, { status = 'all', path = null, patchText = null, keep = null } = {}) => {
|
|
41
41
|
const rows = listDiffComments(storeKey, pipelineId, { status, path });
|
|
42
42
|
const kept = typeof keep === 'function' ? rows.filter(keep) : rows;
|
|
43
|
-
|
|
43
|
+
// Context for ROOTS only: a reply shares its root's anchor, and parsing the
|
|
44
|
+
// whole patch once more per reply would buy the model nothing (D7).
|
|
45
|
+
return kept.map((c) => ((patchText == null || c.parentId) ? c : {
|
|
44
46
|
...c,
|
|
45
47
|
context: hunkContext(patchText, { project: c.projectKey, path: c.path, side: c.side, line: c.line },
|
|
46
48
|
COMMENT_CONTEXT_RADIUS),
|
|
47
49
|
}));
|
|
48
50
|
},
|
|
49
51
|
add: (input) => addDiffComment({ ...input, author: 'ask' }),
|
|
52
|
+
reply: (input) => addDiffCommentReply({ ...input, author: 'ask' }),
|
|
50
53
|
get: (id) => getDiffComment(id),
|
|
51
54
|
setResolved: (id, resolved) => setDiffCommentResolved(id, resolved),
|
|
52
55
|
remove: (id) => deleteDiffComment(id),
|
package/src/core/ask/events.mjs
CHANGED
|
@@ -32,11 +32,12 @@ const weight = (u) => u.input + 1.25 * u.cacheCreation + 0.1 * u.cacheRead + 5 *
|
|
|
32
32
|
const clone = (v) => JSON.parse(JSON.stringify(v));
|
|
33
33
|
const short = (name) => String(name ?? '').replace(/^mcp__worca__/, '');
|
|
34
34
|
const isAgentTool = (name) => name === 'Task' || name === 'Agent';
|
|
35
|
-
// The
|
|
35
|
+
// The four write tools whose success must reach the browser. The reducer runs in
|
|
36
36
|
// the PARENT process, so this is the only place a child-process write becomes a
|
|
37
37
|
// broadcast (the MCP server cannot call broadcast()).
|
|
38
38
|
const COMMENT_WRITE_TOOLS = new Set([
|
|
39
|
-
'mcp__worca__add_diff_comment', '
|
|
39
|
+
'mcp__worca__add_diff_comment', 'mcp__worca__reply_to_diff_comment',
|
|
40
|
+
'mcp__worca__resolve_diff_comment', 'mcp__worca__delete_diff_comment',
|
|
40
41
|
]);
|
|
41
42
|
// The worktree-mutating tools (P4): the MCP child opens/removes checkouts and
|
|
42
43
|
// moves HEAD (checkout/switch/fetch → tools.mjs noteNav) — invisible to this
|
|
@@ -45,6 +46,10 @@ const COMMENT_WRITE_TOOLS = new Set([
|
|
|
45
46
|
// only when its subcommand is one noteNav acts on; a `log`/`status` never pokes.
|
|
46
47
|
const WORKTREE_TOOLS = new Set(['mcp__worca__open_worktree', 'mcp__worca__remove_worktree', 'mcp__worca__git']);
|
|
47
48
|
const GIT_NAV_SUBCOMMANDS = new Set(['checkout', 'switch', 'fetch']);
|
|
49
|
+
// The memory writers (agent-memory-design.md §9.1): a successful remember/forget in the CHILD
|
|
50
|
+
// becomes the same `memory-changed` broadcast the REST routes emit (ui/server.mjs emitMemoryChanged).
|
|
51
|
+
// The scope key rides the tool RESULT (`scopeKey`), like pokeCommentWrite reads `comment.runId`.
|
|
52
|
+
const MEMORY_WRITE_TOOLS = new Set(['mcp__worca__remember', 'mcp__worca__forget']);
|
|
48
53
|
/** True when a SUCCESSFUL call of `name` with `input` changed this thread's worktree rows. */
|
|
49
54
|
export function worktreeMutatingCall(name, input) {
|
|
50
55
|
if (!WORKTREE_TOOLS.has(name)) return false;
|
|
@@ -118,11 +123,22 @@ export function labelForTool(name, input = {}, attachmentNames = {}) {
|
|
|
118
123
|
case 'list_workflows': return 'Looking at workflows';
|
|
119
124
|
case 'list_projects': return 'Looking at projects';
|
|
120
125
|
case 'propose_run': return 'Preparing a run';
|
|
126
|
+
case 'propose_workflow': return 'Building a workflow';
|
|
127
|
+
case 'propose_metrics_change': return 'Proposing a metrics change';
|
|
128
|
+
case 'get_team_metrics': return 'Reading team metrics';
|
|
129
|
+
case 'list_team_metrics_runs': return 'Listing team runs';
|
|
130
|
+
case 'push_team_metrics': return 'Pushing team metrics';
|
|
131
|
+
case 'track_run': return 'Tracking a run';
|
|
121
132
|
case 'read_attachment': return `Reading ${(attachmentNames && attachmentNames[id]) || 'attachment'}`;
|
|
122
133
|
case 'list_diff_comments': return id ? `Reading comments on ${id.slice(0, 12)}` : 'Reading diff comments';
|
|
123
134
|
case 'add_diff_comment': return 'Writing a diff comment';
|
|
135
|
+
case 'reply_to_diff_comment': return 'Replying to a diff comment';
|
|
124
136
|
case 'resolve_diff_comment': return 'Updating a diff comment';
|
|
125
137
|
case 'delete_diff_comment': return 'Deleting a diff comment';
|
|
138
|
+
case 'list_memory': return 'Reading memory';
|
|
139
|
+
case 'read_memory': return input?.name ? `Reading memory: ${input.name}` : 'Reading memory';
|
|
140
|
+
case 'remember': return input?.name ? `Saving memory: ${input.name}` : 'Saving memory';
|
|
141
|
+
case 'forget': return input?.name ? `Removing memory: ${input.name}` : 'Removing memory';
|
|
126
142
|
default: return `Using ${n}`;
|
|
127
143
|
}
|
|
128
144
|
}
|
|
@@ -159,8 +175,13 @@ export function createTurnReducer({
|
|
|
159
175
|
setTimeout: setT = globalThis.setTimeout,
|
|
160
176
|
clearTimeout: clearT = globalThis.clearTimeout,
|
|
161
177
|
onProposal = null,
|
|
178
|
+
onWorkflowStart = null,
|
|
179
|
+
onWorkflowResult = null,
|
|
180
|
+
onMetricsProposal = null, // propose_metrics_change RESULT (team metrics card; the parent re-validates the input)
|
|
181
|
+
onTrackRun = null,
|
|
162
182
|
onCommentMutation = null,
|
|
163
183
|
onWorktreeMutation = null,
|
|
184
|
+
onMemoryMutation = null,
|
|
164
185
|
estimateLiveCost = null,
|
|
165
186
|
attachmentNames = {},
|
|
166
187
|
resolveCost = null,
|
|
@@ -348,6 +369,11 @@ export function createTurnReducer({
|
|
|
348
369
|
fullInputs.set(c.id, input);
|
|
349
370
|
label(labelForTool(c.name, input, attachmentNames));
|
|
350
371
|
upsertBlock({ kind: 'tool', id: c.id, name: c.name, input: clipJson(input, limits.blockIoMaxChars), status: 'running', durationMs: null });
|
|
372
|
+
// P3: the workflow card exists from the tool_use on (state 'building' — the four-step trace), so the
|
|
373
|
+
// START is a hook too. Sync: the block must precede any frame the tool result produces.
|
|
374
|
+
if (c.name === 'mcp__worca__propose_workflow' && typeof onWorkflowStart === 'function') {
|
|
375
|
+
try { onWorkflowStart({ toolUseId: c.id, input }); } catch { reducerErrors += 1; }
|
|
376
|
+
}
|
|
351
377
|
}
|
|
352
378
|
} else {
|
|
353
379
|
const agent = byId.get(ptu);
|
|
@@ -387,6 +413,18 @@ export function createTurnReducer({
|
|
|
387
413
|
try { onWorktreeMutation({ tool: short(name) }); } catch { /* a broken sink never breaks the stream */ }
|
|
388
414
|
}
|
|
389
415
|
|
|
416
|
+
// And for memory: a remember/forget succeeded in the CHILD, so the parent broadcasts the same
|
|
417
|
+
// `memory-changed` frame the REST writes emit. Note the asymmetry with the worktree poke — that
|
|
418
|
+
// one reads the call INPUT, this one the result TEXT, because the scope key rides the result.
|
|
419
|
+
function pokeMemoryWrite(name, text, isError) {
|
|
420
|
+
if (isError || !MEMORY_WRITE_TOOLS.has(name) || typeof onMemoryMutation !== 'function') return;
|
|
421
|
+
try {
|
|
422
|
+
const parsed = JSON.parse(text);
|
|
423
|
+
const scope = typeof parsed?.scopeKey === 'string' ? parsed.scopeKey : null;
|
|
424
|
+
if (scope) onMemoryMutation({ scope, tool: short(name) });
|
|
425
|
+
} catch { /* unparseable result — no poke; the next open refetches anyway */ }
|
|
426
|
+
}
|
|
427
|
+
|
|
390
428
|
function onUser(raw, ptu, isMain) {
|
|
391
429
|
const content = Array.isArray(raw.message?.content) ? raw.message.content : [];
|
|
392
430
|
for (const c of content) {
|
|
@@ -400,6 +438,7 @@ export function createTurnReducer({
|
|
|
400
438
|
if (agent) appendLog(agent, c.is_error ? `← error: ${clipStr(text, 120)}` : `← ok ${((now() - ct.t0) / 1000).toFixed(1)}s`);
|
|
401
439
|
pokeCommentWrite(ct.name, text, c.is_error);
|
|
402
440
|
pokeWorktreeMutation(ct.name, ct.input, c.is_error);
|
|
441
|
+
pokeMemoryWrite(ct.name, text, c.is_error);
|
|
403
442
|
continue;
|
|
404
443
|
}
|
|
405
444
|
const b = byId.get(c.tool_use_id);
|
|
@@ -435,8 +474,32 @@ export function createTurnReducer({
|
|
|
435
474
|
if (ret && typeof ret.then === 'function') pendingHooks.push(ret.then(() => {}, () => { reducerErrors += 1; }));
|
|
436
475
|
} catch { reducerErrors += 1; }
|
|
437
476
|
}
|
|
477
|
+
if (b.name === 'mcp__worca__propose_workflow' && typeof onWorkflowResult === 'function') {
|
|
478
|
+
// The RAW result text: the parent re-validates from the returned shape (spec §8.2, PD1); an isError result
|
|
479
|
+
// carries "error: <message>" and flips the card to failed.
|
|
480
|
+
try {
|
|
481
|
+
const ret = onWorkflowResult({ toolUseId: b.id, input: fullInputs.get(b.id) ?? {}, text, isError: !!c.is_error });
|
|
482
|
+
if (ret && typeof ret.then === 'function') pendingHooks.push(ret.then(() => {}, () => { reducerErrors += 1; }));
|
|
483
|
+
} catch { reducerErrors += 1; }
|
|
484
|
+
}
|
|
485
|
+
if (b.name === 'mcp__worca__propose_metrics_change' && typeof onMetricsProposal === 'function') {
|
|
486
|
+
// The parent re-validates from the tool INPUT (metrics-proposal.mjs is pure over the real readers);
|
|
487
|
+
// the raw result text only says whether the child accepted it.
|
|
488
|
+
try {
|
|
489
|
+
const ret = onMetricsProposal({ toolUseId: b.id, input: fullInputs.get(b.id) ?? {}, text, isError: !!c.is_error });
|
|
490
|
+
if (ret && typeof ret.then === 'function') pendingHooks.push(ret.then(() => {}, () => { reducerErrors += 1; }));
|
|
491
|
+
} catch { reducerErrors += 1; }
|
|
492
|
+
}
|
|
493
|
+
if (b.name === 'mcp__worca__track_run' && typeof onTrackRun === 'function') {
|
|
494
|
+
// The parent owns the runs Map, the link rows and the followers: it re-resolves the id itself (D4).
|
|
495
|
+
try {
|
|
496
|
+
const ret = onTrackRun({ toolUseId: b.id, input: fullInputs.get(b.id) ?? {}, text, isError: !!c.is_error });
|
|
497
|
+
if (ret && typeof ret.then === 'function') pendingHooks.push(ret.then(() => {}, () => { reducerErrors += 1; }));
|
|
498
|
+
} catch { reducerErrors += 1; }
|
|
499
|
+
}
|
|
438
500
|
pokeCommentWrite(b.name, text, c.is_error);
|
|
439
501
|
pokeWorktreeMutation(b.name, fullInputs.get(b.id), c.is_error);
|
|
502
|
+
pokeMemoryWrite(b.name, text, c.is_error);
|
|
440
503
|
}
|
|
441
504
|
}
|
|
442
505
|
|
package/src/core/ask/limits.mjs
CHANGED
|
@@ -35,7 +35,16 @@ export const ASK_LIMITS = Object.freeze({
|
|
|
35
35
|
worktreesGlobal: 15, // P4 D9
|
|
36
36
|
attachmentReadDefaultBytes: 32_000,
|
|
37
37
|
attachmentReadMaxBytes: 200_000,
|
|
38
|
+
artifactsListMaxLimit: 200,
|
|
39
|
+
artifactReadDefaultBytes: 60_000,
|
|
40
|
+
artifactReadMaxBytes: 200_000,
|
|
38
41
|
briefMaxChars: 8000,
|
|
42
|
+
metricsRunsDefaultLimit: 20, // list_team_metrics_runs page (= listRunsDefaultLimit)
|
|
43
|
+
metricsRunsMaxLimit: 100, // list_team_metrics_runs page cap (= listRunsMaxLimit)
|
|
44
|
+
metricsBreakdownMaxRows: 20, // get_team_metrics rows per breakdown dimension
|
|
45
|
+
workflowTaskMaxChars: 32_000, // propose_workflow task text (= classify.mjs TASK_TEXT_CAP)
|
|
46
|
+
workflowNoteMaxChars: 200, // propose_workflow note shown on the card
|
|
47
|
+
proposalNoteMaxChars: 200, // propose_run note ("why this shape") shown on the run card
|
|
39
48
|
commentBodyMaxChars: 4000, // diff_comments.body cap (pinned equal to COMMENT_BODY_MAX)
|
|
40
49
|
titleMaxChars: 120,
|
|
41
50
|
headerRuns: 5,
|
|
@@ -24,7 +24,10 @@ import { pathToFileURL } from 'node:url';
|
|
|
24
24
|
import { createAskTools, AskToolError } from './tools.mjs';
|
|
25
25
|
import { defaultToolDeps } from './tool-deps.mjs';
|
|
26
26
|
import { defaultWorktreeDeps } from './worktree-deps.mjs';
|
|
27
|
+
import { defaultMemoryDeps } from './memory-deps.mjs';
|
|
27
28
|
import { defaultCommentDeps } from './comment-deps.mjs';
|
|
29
|
+
import { defaultWorkflowDeps } from './workflow-deps.mjs';
|
|
30
|
+
import { defaultMetricsDeps } from './metrics-deps.mjs';
|
|
28
31
|
|
|
29
32
|
const SUPPORTED_PROTOCOLS = Object.freeze(['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']);
|
|
30
33
|
const DEFAULT_PROTOCOL = '2025-06-18';
|
|
@@ -112,17 +115,24 @@ export async function main({ argv = process.argv.slice(2), env = process.env, st
|
|
|
112
115
|
const { home, thread } = parseArgv(argv);
|
|
113
116
|
if (home) env.WORCA_HOME = home; // argv wins; worcaHome() reads the env at call time
|
|
114
117
|
const threadId = thread || env.WORCA_ASK_THREAD_ID || null;
|
|
118
|
+
// P3 (v7): stdin closing == the chat turn ended or was stopped — abort whatever propose_workflow is still classifying
|
|
119
|
+
// (its result could never be delivered), so the drain below returns promptly instead of after the classifier's timeout.
|
|
120
|
+
const life = new AbortController();
|
|
115
121
|
const server = createRpcServer({
|
|
116
122
|
tools: createAskTools({
|
|
117
123
|
...defaultToolDeps({ threadId }),
|
|
118
124
|
...defaultWorktreeDeps({ threadId }),
|
|
125
|
+
...defaultMemoryDeps({ threadId }),
|
|
119
126
|
...defaultCommentDeps(),
|
|
127
|
+
...defaultWorkflowDeps({ threadId, signal: life.signal }),
|
|
128
|
+
...defaultMetricsDeps({ threadId }),
|
|
120
129
|
}),
|
|
121
130
|
write: (s) => stdout.write(s),
|
|
122
131
|
});
|
|
123
132
|
const rl = createInterface({ input: stdin });
|
|
124
133
|
rl.on('line', (line) => { server.feed(line); });
|
|
125
134
|
await new Promise((resolve) => rl.on('close', resolve));
|
|
135
|
+
life.abort();
|
|
126
136
|
await server.idle();
|
|
127
137
|
await new Promise((resolve) => stdout.write('', resolve)); // macOS pipes are async: drain before exit
|
|
128
138
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/core/ask/memory-deps.mjs
|
|
2
|
+
// The dep bundle of the memory tools (agent-memory-design.md §9.1; P2 amendment B3) — reads AND
|
|
3
|
+
// writes under ONE namespaced sub-object, exactly like worktree-deps.mjs / comment-deps.mjs.
|
|
4
|
+
// Deliberately separate from tool-deps.mjs, whose source is pinned read-only. Everything goes
|
|
5
|
+
// through src/core/memory-store.mjs (validation, frontmatter repair, caps, snapshots, counters),
|
|
6
|
+
// so the MCP path and the REST path share one writer. Also the chat's --add-dir memory mount.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
memoryRoot, listMemory, readMemory, writeMemory, removeMemory, renderMemoryFile, MEMORY_NAME_HELP,
|
|
10
|
+
} from '../memory-store.mjs';
|
|
11
|
+
import { mountDirs, withStoreLock, refreshMount } from '../memory-sync.mjs';
|
|
12
|
+
import { memoryCaps } from '../settings.mjs';
|
|
13
|
+
import { listProjects, worcaHome } from '../projects.mjs';
|
|
14
|
+
import { PROJECT_KEY_RE } from '../store.mjs';
|
|
15
|
+
import { getThread } from './store.mjs';
|
|
16
|
+
|
|
17
|
+
/** {key, name, path} for a registered project key, or null. */
|
|
18
|
+
async function projectByKey(key) {
|
|
19
|
+
if (typeof key !== 'string' || !key) return null;
|
|
20
|
+
const p = (await listProjects()).find((x) => x && x.key === key);
|
|
21
|
+
return p ? { key: p.key, name: p.name || '', path: p.path } : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function defaultMemoryDeps({ threadId }) {
|
|
25
|
+
const source = `ask:${threadId || 'unknown'}`;
|
|
26
|
+
return {
|
|
27
|
+
memory: {
|
|
28
|
+
projectByKey,
|
|
29
|
+
/** The page-following (unpinned) project of this thread, or null — tool-deps' pinnedScope
|
|
30
|
+
* covers the pinned case. B30: the context the panel sends carries `projectDir` on the New,
|
|
31
|
+
* Running and Projects pages and `projectKey` only on History detail, so this mirrors
|
|
32
|
+
* resolveAskContext (ui/server.mjs) and resolves either through the registry. */
|
|
33
|
+
contextProjectKey: async () => {
|
|
34
|
+
if (!threadId) return null;
|
|
35
|
+
let c = null;
|
|
36
|
+
try { c = getThread(threadId)?.context ?? null; } catch { return null; }
|
|
37
|
+
if (!c || c.pinned === true) return null;
|
|
38
|
+
if (typeof c.projectKey === 'string' && c.projectKey) return c.projectKey;
|
|
39
|
+
if (typeof c.projectDir === 'string' && c.projectDir) {
|
|
40
|
+
try {
|
|
41
|
+
const p = (await listProjects()).find((x) => x && x.path === c.projectDir); // the match resolveAskContext uses
|
|
42
|
+
return p ? p.key : null;
|
|
43
|
+
} catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
},
|
|
47
|
+
list: (scope) => listMemory(memoryRoot(), scope),
|
|
48
|
+
read: (scope, name) => readMemory(memoryRoot(), scope, name),
|
|
49
|
+
/** Compose + write one file. Omitted description/paths keep the existing file's values (an
|
|
50
|
+
* omission never erases); `append` joins the bodies with one blank line (replace on a new
|
|
51
|
+
* file). Read-compose-write under the store lock (I2-#13) so two turns of THIS process
|
|
52
|
+
* cannot lose an append; a concurrent MCP child or a run's sync-back is not covered — that
|
|
53
|
+
* race is a Non-goal, and the pre-write snapshot keeps the losing version. */
|
|
54
|
+
remember: (scope, { name, body, description, paths, mode = 'replace' }) => withStoreLock(memoryRoot(), async () => {
|
|
55
|
+
const existing = await readMemory(memoryRoot(), scope, name);
|
|
56
|
+
const meta = {
|
|
57
|
+
name,
|
|
58
|
+
description: description == null ? (existing?.meta.description || '') : String(description),
|
|
59
|
+
paths: paths == null ? (existing?.meta.paths || []) : paths,
|
|
60
|
+
extra: existing?.meta.extra || {},
|
|
61
|
+
};
|
|
62
|
+
const nextBody = mode === 'append' && existing ? `${existing.body.trimEnd()}\n\n${String(body).trim()}\n` : String(body);
|
|
63
|
+
const r = await writeMemory(memoryRoot(), scope, name, renderMemoryFile(meta, nextBody), { source, caps: memoryCaps() });
|
|
64
|
+
return { created: r.created, bytes: r.bytes };
|
|
65
|
+
}),
|
|
66
|
+
forget: (scope, name) => removeMemory(memoryRoot(), scope, name, { source }),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `<home>/ask/memory/<projectKey|global>` — the --add-dir base of one scope set. Never the cwd (one
|
|
72
|
+
* Claude Code project slug for every thread) and never under tmp/ (Read-denied there). `global` is a
|
|
73
|
+
* safe sentinel: a registry key is `<slug>-<8 hex>` (PROJECT_KEY_RE), so it can never be that word. */
|
|
74
|
+
export function askMemoryMountBase(projectKey) { return join(worcaHome(), 'ask', 'memory', projectKey || 'global'); }
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Refresh the chat's rules mount for one scope set (native-rules revision, D16): global + the
|
|
78
|
+
* given project, written under `<base>/.claude/rules/worca/{global,project}/` — the layout the
|
|
79
|
+
* CLI loads through `--add-dir <base>` + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 (probes J/J2).
|
|
80
|
+
* The writing is memory-sync's refreshMount (NON-destructive: files written atomically by name,
|
|
81
|
+
* stale ones unlinked, dirs never removed — a turn already spawning on the same mount must not
|
|
82
|
+
* read an empty dir; a file that cannot be written keeps its previous copy and is reported).
|
|
83
|
+
* Serialised under the store lock so it never interleaves with an in-process sync or remember
|
|
84
|
+
* (the MCP child writes from another process; whole-file atomic writes make that harmless).
|
|
85
|
+
* `async` on purpose: the projectKey guard REJECTS rather than throwing at the call site.
|
|
86
|
+
* @returns {Promise<string|null>} the base to pass as --add-dir, or null when the scope set holds
|
|
87
|
+
* no file (B33: nothing to load ⇒ the spawn stays byte-identical); the mount is emptied then.
|
|
88
|
+
*/
|
|
89
|
+
export async function refreshAskMemoryMount({ projectKey = null, projectName = null } = {}) {
|
|
90
|
+
// A path segment: refuse anything but a registry-shaped key before a single mkdir (defence in
|
|
91
|
+
// depth — today's only caller passes a resolved key; the shape store.mjs#projectKey produces and
|
|
92
|
+
// the server's memory routes test). The rejection lands in the turn's catch.
|
|
93
|
+
if (projectKey != null && !PROJECT_KEY_RE.test(projectKey)) throw new Error(`refreshAskMemoryMount: invalid projectKey ${JSON.stringify(projectKey)}`);
|
|
94
|
+
const base = askMemoryMountBase(projectKey);
|
|
95
|
+
const dirs = mountDirs({ members: projectKey ? [{ projectKey, projectName }] : [], isWorkspace: false });
|
|
96
|
+
return withStoreLock(memoryRoot(), async () => {
|
|
97
|
+
// Two different failures reach ONE callback: a junk name / unreadable file in the STORE (the
|
|
98
|
+
// listing, `listMemoryDir` → ENAME) and a mount target that could not be written (the rename).
|
|
99
|
+
// Only the second has a "previous copy"; reporting a store name that way would promise a copy
|
|
100
|
+
// that never existed and name a path the user cannot fix by retrying.
|
|
101
|
+
const onError = (p, err) => console.warn(err?.code === 'ENAME'
|
|
102
|
+
? `[worca-ask] memory: ignoring ${JSON.stringify(p)} — ${MEMORY_NAME_HELP}`
|
|
103
|
+
: `[worca-ask] memory mount: ${p} could not be refreshed (${err?.message || err}); the previous copy (if any) is served this turn`);
|
|
104
|
+
const { files } = await refreshMount({ root: memoryRoot(), mount: join(base, '.claude', 'rules', 'worca'), dirs, onError });
|
|
105
|
+
return files ? base : null;
|
|
106
|
+
});
|
|
107
|
+
}
|