@chrysb/alphaclaw 0.4.4 → 0.4.6-beta.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 +21 -18
- package/lib/public/css/theme.css +29 -0
- package/lib/public/js/app.js +41 -2
- package/lib/public/js/components/badge.js +4 -0
- package/lib/public/js/components/doctor/findings-list.js +191 -0
- package/lib/public/js/components/doctor/fix-card-modal.js +144 -0
- package/lib/public/js/components/doctor/general-warning.js +37 -0
- package/lib/public/js/components/doctor/helpers.js +169 -0
- package/lib/public/js/components/doctor/index.js +536 -0
- package/lib/public/js/components/doctor/summary-cards.js +24 -0
- package/lib/public/js/lib/api.js +79 -0
- package/lib/server/commands.js +8 -4
- package/lib/server/constants.js +22 -26
- package/lib/server/db/doctor/index.js +529 -0
- package/lib/server/db/doctor/schema.js +69 -0
- package/lib/server/doctor/constants.js +43 -0
- package/lib/server/doctor/normalize.js +214 -0
- package/lib/server/doctor/prompt.js +89 -0
- package/lib/server/doctor/service.js +392 -0
- package/lib/server/doctor/workspace-fingerprint.js +126 -0
- package/lib/server/gmail-push.js +102 -6
- package/lib/server/gmail-watch.js +5 -20
- package/lib/server/helpers.js +5 -21
- package/lib/server/routes/doctor.js +123 -0
- package/lib/server/routes/google.js +2 -10
- package/lib/server/routes/system.js +7 -1
- package/lib/server/routes/telegram.js +3 -14
- package/lib/server/routes/usage.js +1 -5
- package/lib/server/routes/webhooks.js +2 -6
- package/lib/server/utils/boolean.js +22 -0
- package/lib/server/utils/json.js +77 -0
- package/lib/server/utils/network.js +5 -0
- package/lib/server/utils/number.js +8 -0
- package/lib/server/utils/shell.js +16 -0
- package/lib/server/webhook-middleware.js +1 -2
- package/lib/server.js +42 -0
- package/package.json +1 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
const {
|
|
2
|
+
parseJsonSafe,
|
|
3
|
+
parseJsonValueFromNoisyOutput,
|
|
4
|
+
} = require("../utils/json");
|
|
5
|
+
const {
|
|
6
|
+
kDoctorCardStatus,
|
|
7
|
+
kDoctorPriority,
|
|
8
|
+
kDoctorMaxCardsPerRun,
|
|
9
|
+
} = require("./constants");
|
|
10
|
+
|
|
11
|
+
const kCandidateArrayKeys = ["cards", "findings", "issues", "recommendations"];
|
|
12
|
+
const kCandidateObjectKeys = [
|
|
13
|
+
"result",
|
|
14
|
+
"data",
|
|
15
|
+
"output",
|
|
16
|
+
"response",
|
|
17
|
+
"message",
|
|
18
|
+
"content",
|
|
19
|
+
"text",
|
|
20
|
+
"payload",
|
|
21
|
+
"payloads",
|
|
22
|
+
"body",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const toTrimmedString = (value) => String(value ?? "").trim();
|
|
26
|
+
|
|
27
|
+
const parseJsonCandidate = (value) => {
|
|
28
|
+
if (value == null) return null;
|
|
29
|
+
if (typeof value === "object") return value;
|
|
30
|
+
if (typeof value !== "string") return null;
|
|
31
|
+
const direct = parseJsonSafe(value, null, { trim: true });
|
|
32
|
+
if (direct) return direct;
|
|
33
|
+
const noisy = parseJsonValueFromNoisyOutput(value);
|
|
34
|
+
if (noisy) return noisy;
|
|
35
|
+
const fencedMatch = value.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
36
|
+
if (!fencedMatch) return null;
|
|
37
|
+
return parseJsonSafe(fencedMatch[1], null, { trim: true });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const collectCandidatePayloads = (rootValue) => {
|
|
41
|
+
const queue = [rootValue];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const candidates = [];
|
|
44
|
+
while (queue.length) {
|
|
45
|
+
const currentValue = queue.shift();
|
|
46
|
+
if (currentValue == null) continue;
|
|
47
|
+
if (typeof currentValue === "string") {
|
|
48
|
+
const parsedValue = parseJsonCandidate(currentValue);
|
|
49
|
+
if (parsedValue && typeof parsedValue === "object") {
|
|
50
|
+
queue.push(parsedValue);
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (typeof currentValue !== "object") continue;
|
|
55
|
+
if (seen.has(currentValue)) continue;
|
|
56
|
+
seen.add(currentValue);
|
|
57
|
+
if (Array.isArray(currentValue)) {
|
|
58
|
+
for (const item of currentValue) {
|
|
59
|
+
if (item != null) queue.push(item);
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
candidates.push(currentValue);
|
|
64
|
+
for (const key of kCandidateObjectKeys) {
|
|
65
|
+
if (currentValue[key] != null) queue.push(currentValue[key]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return candidates;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const normalizePriority = (value) => {
|
|
72
|
+
const normalized = toTrimmedString(value).toUpperCase();
|
|
73
|
+
if (normalized === "P0" || normalized === "CRITICAL" || normalized === "HIGH") {
|
|
74
|
+
return kDoctorPriority.P0;
|
|
75
|
+
}
|
|
76
|
+
if (normalized === "P1" || normalized === "MEDIUM" || normalized === "MODERATE") {
|
|
77
|
+
return kDoctorPriority.P1;
|
|
78
|
+
}
|
|
79
|
+
if (normalized === "P2" || normalized === "LOW" || normalized === "NICE_TO_HAVE") {
|
|
80
|
+
return kDoctorPriority.P2;
|
|
81
|
+
}
|
|
82
|
+
return kDoctorPriority.P2;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const normalizeCardStatus = (value) => {
|
|
86
|
+
const normalized = toTrimmedString(value).toLowerCase();
|
|
87
|
+
if (normalized === kDoctorCardStatus.fixed) return kDoctorCardStatus.fixed;
|
|
88
|
+
if (normalized === kDoctorCardStatus.dismissed) return kDoctorCardStatus.dismissed;
|
|
89
|
+
return kDoctorCardStatus.open;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const normalizeEvidence = (value) => {
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
return value
|
|
95
|
+
.map((item) => {
|
|
96
|
+
if (item == null) return null;
|
|
97
|
+
if (typeof item === "string") {
|
|
98
|
+
const text = toTrimmedString(item);
|
|
99
|
+
return text ? { type: "text", text } : null;
|
|
100
|
+
}
|
|
101
|
+
if (typeof item === "object") return item;
|
|
102
|
+
return { type: "text", text: String(item) };
|
|
103
|
+
})
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
}
|
|
106
|
+
if (typeof value === "string") {
|
|
107
|
+
const text = toTrimmedString(value);
|
|
108
|
+
return text ? [{ type: "text", text }] : [];
|
|
109
|
+
}
|
|
110
|
+
if (value && typeof value === "object") return [value];
|
|
111
|
+
return [];
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const normalizeTargetPaths = (value) => {
|
|
115
|
+
const values = Array.isArray(value) ? value : value == null ? [] : [value];
|
|
116
|
+
return Array.from(
|
|
117
|
+
new Set(
|
|
118
|
+
values
|
|
119
|
+
.map((item) => toTrimmedString(item))
|
|
120
|
+
.filter(Boolean),
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const buildFallbackFixPrompt = ({ title, recommendation, targetPaths }) => {
|
|
126
|
+
const targetLine = targetPaths.length
|
|
127
|
+
? `Focus on these paths if relevant: ${targetPaths.join(", ")}.`
|
|
128
|
+
: "Inspect the relevant workspace files before making changes.";
|
|
129
|
+
return (
|
|
130
|
+
`Please address this Doctor finding safely.\n\n` +
|
|
131
|
+
`Finding: ${title}\n` +
|
|
132
|
+
`Recommendation: ${recommendation}\n` +
|
|
133
|
+
`${targetLine}\n` +
|
|
134
|
+
`Preserve existing behavior unless the change clearly improves workspace guidance organization.`
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const normalizeDoctorCard = (cardValue, index) => {
|
|
139
|
+
const title =
|
|
140
|
+
toTrimmedString(cardValue?.title) ||
|
|
141
|
+
toTrimmedString(cardValue?.headline) ||
|
|
142
|
+
toTrimmedString(cardValue?.name) ||
|
|
143
|
+
`Doctor recommendation ${index + 1}`;
|
|
144
|
+
const summary =
|
|
145
|
+
toTrimmedString(cardValue?.summary) ||
|
|
146
|
+
toTrimmedString(cardValue?.description) ||
|
|
147
|
+
toTrimmedString(cardValue?.detail) ||
|
|
148
|
+
"";
|
|
149
|
+
const recommendation =
|
|
150
|
+
toTrimmedString(cardValue?.recommendation) ||
|
|
151
|
+
toTrimmedString(cardValue?.recommendedAction) ||
|
|
152
|
+
toTrimmedString(cardValue?.action) ||
|
|
153
|
+
summary ||
|
|
154
|
+
title;
|
|
155
|
+
const targetPaths = normalizeTargetPaths(
|
|
156
|
+
cardValue?.targetPaths ?? cardValue?.paths ?? cardValue?.files,
|
|
157
|
+
);
|
|
158
|
+
return {
|
|
159
|
+
priority: normalizePriority(cardValue?.priority ?? cardValue?.severity),
|
|
160
|
+
category: toTrimmedString(cardValue?.category) || "workspace",
|
|
161
|
+
title,
|
|
162
|
+
summary,
|
|
163
|
+
recommendation,
|
|
164
|
+
evidence: normalizeEvidence(cardValue?.evidence),
|
|
165
|
+
targetPaths,
|
|
166
|
+
fixPrompt:
|
|
167
|
+
toTrimmedString(cardValue?.fixPrompt) ||
|
|
168
|
+
toTrimmedString(cardValue?.fix_prompt) ||
|
|
169
|
+
buildFallbackFixPrompt({ title, recommendation, targetPaths }),
|
|
170
|
+
status: normalizeCardStatus(cardValue?.status),
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const extractCardPayload = (payload) => {
|
|
175
|
+
if (!payload || typeof payload !== "object") return null;
|
|
176
|
+
for (const key of kCandidateArrayKeys) {
|
|
177
|
+
if (!Array.isArray(payload[key])) continue;
|
|
178
|
+
return {
|
|
179
|
+
summary:
|
|
180
|
+
toTrimmedString(payload.summary) ||
|
|
181
|
+
toTrimmedString(payload.overview) ||
|
|
182
|
+
toTrimmedString(payload.assessment) ||
|
|
183
|
+
"",
|
|
184
|
+
cards: payload[key],
|
|
185
|
+
rawPayload: payload,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const normalizeDoctorResult = (rawOutput) => {
|
|
192
|
+
const initialPayload = parseJsonCandidate(rawOutput);
|
|
193
|
+
const payloadCandidates = collectCandidatePayloads(initialPayload || rawOutput);
|
|
194
|
+
for (const candidate of payloadCandidates) {
|
|
195
|
+
const extracted = extractCardPayload(candidate);
|
|
196
|
+
if (!extracted) continue;
|
|
197
|
+
const cards = extracted.cards
|
|
198
|
+
.slice(0, kDoctorMaxCardsPerRun)
|
|
199
|
+
.map((cardValue, index) => normalizeDoctorCard(cardValue, index));
|
|
200
|
+
return {
|
|
201
|
+
summary: extracted.summary,
|
|
202
|
+
cards,
|
|
203
|
+
rawPayload: extracted.rawPayload,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
throw new Error("Doctor response did not include a recognizable cards payload");
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
normalizePriority,
|
|
211
|
+
normalizeCardStatus,
|
|
212
|
+
normalizeDoctorResult,
|
|
213
|
+
normalizeDoctorCard,
|
|
214
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const renderList = (items = []) =>
|
|
2
|
+
items.length ? items.map((item) => `- ${item}`).join("\n") : "- (none)";
|
|
3
|
+
|
|
4
|
+
const buildDoctorPrompt = ({
|
|
5
|
+
workspaceRoot = "",
|
|
6
|
+
managedRoot = "",
|
|
7
|
+
protectedPaths = [],
|
|
8
|
+
lockedPaths = [],
|
|
9
|
+
promptVersion = "doctor-v1",
|
|
10
|
+
}) => `
|
|
11
|
+
You are AlphaClaw Doctor. Analyze this OpenClaw workspace for guidance drift, redundancy, misplacement, and cleanup opportunities.
|
|
12
|
+
|
|
13
|
+
Important:
|
|
14
|
+
- Read the workspace and managed files as needed before deciding.
|
|
15
|
+
- This is advisory only. Do not make changes.
|
|
16
|
+
- Focus on organization and correctness of workspace guidance and setup-owned files.
|
|
17
|
+
- Prefer fewer, higher-signal findings.
|
|
18
|
+
- Avoid reporting issues that are already intentionally managed or locked by AlphaClaw.
|
|
19
|
+
- Evaluate files against intended OpenClaw defaults, not against an idealized minimal workspace.
|
|
20
|
+
- A fresh install can be healthy even if it includes broad default guidance.
|
|
21
|
+
- Return ONLY valid JSON. No markdown fences. No extra prose.
|
|
22
|
+
|
|
23
|
+
OpenClaw default context:
|
|
24
|
+
- \`AGENTS.md\` is the workspace home file in the default OpenClaw template. It may intentionally include first-run instructions, session-startup guidance, memory conventions, safety rules, tool pointers, and optional behavioral guidance.
|
|
25
|
+
- Do not treat default-template content as drift just because it is broad or multi-purpose.
|
|
26
|
+
- Only flag \`AGENTS.md\` when there is clear workspace-specific drift, contradiction, substantial unnecessary local accretion, or guidance that no longer fits the file's intended role.
|
|
27
|
+
|
|
28
|
+
AlphaClaw ownership rules:
|
|
29
|
+
- AlphaClaw-managed files and bootstrap files are product-owned constraints.
|
|
30
|
+
- Do not recommend splitting, renaming, relocating, or otherwise restructuring AlphaClaw-managed files solely for cleanliness or purity.
|
|
31
|
+
- Do not propose breaking changes to AlphaClaw's managed file layout, even if another structure might look cleaner.
|
|
32
|
+
- Only flag AlphaClaw-managed content when there is a concrete correctness issue, internal contradiction, broken ownership boundary, or behavior that is actively misleading.
|
|
33
|
+
|
|
34
|
+
Workspace roots:
|
|
35
|
+
- Primary workspace root: ${workspaceRoot || "(unknown)"}
|
|
36
|
+
- Managed OpenClaw root: ${managedRoot || "(unknown)"}
|
|
37
|
+
|
|
38
|
+
AlphaClaw protected paths:
|
|
39
|
+
${renderList(protectedPaths)}
|
|
40
|
+
|
|
41
|
+
AlphaClaw locked/managed paths:
|
|
42
|
+
${renderList(lockedPaths)}
|
|
43
|
+
|
|
44
|
+
Review priorities:
|
|
45
|
+
- Drift between workspace reality and AGENTS.md, TOOLS.md, SKILL.md, README, and setup-owned docs
|
|
46
|
+
- Redundant or scattered instructions that should be centralized
|
|
47
|
+
- Tool-specific guidance placed in the wrong file
|
|
48
|
+
- Workspace cleanup and consolidation opportunities
|
|
49
|
+
- Real contradictions or misleading guidance inside AlphaClaw-managed files
|
|
50
|
+
|
|
51
|
+
Priority rubric:
|
|
52
|
+
- P0: dangerous drift, broken setup ownership, or issues likely to cause incorrect agent behavior
|
|
53
|
+
- P1: meaningful duplication, misplaced guidance, or organizational drift with clear cleanup value
|
|
54
|
+
- P2: nice-to-have consolidation and lower-risk cleanup opportunities
|
|
55
|
+
|
|
56
|
+
Return exactly this JSON shape:
|
|
57
|
+
{
|
|
58
|
+
"summary": "short overall assessment",
|
|
59
|
+
"cards": [
|
|
60
|
+
{
|
|
61
|
+
"priority": "P0 | P1 | P2",
|
|
62
|
+
"category": "short category",
|
|
63
|
+
"title": "short title",
|
|
64
|
+
"summary": "what is wrong and why it matters",
|
|
65
|
+
"recommendation": "clear recommended action",
|
|
66
|
+
"evidence": [
|
|
67
|
+
{ "type": "path", "path": "relative/path" },
|
|
68
|
+
{ "type": "note", "text": "short supporting note" }
|
|
69
|
+
],
|
|
70
|
+
"targetPaths": ["relative/path/one", "relative/path/two"],
|
|
71
|
+
"fixPrompt": "a concise message another agent can use to fix just this finding safely",
|
|
72
|
+
"status": "open"
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
Constraints:
|
|
78
|
+
- Maximum 12 cards
|
|
79
|
+
- Use relative paths in evidence and targetPaths
|
|
80
|
+
- Do not include duplicate cards
|
|
81
|
+
- Do not create cards for healthy default-template behavior
|
|
82
|
+
- Do not create cards whose primary recommendation is to refactor AlphaClaw-managed file structure
|
|
83
|
+
- If there are no meaningful findings, return an empty cards array
|
|
84
|
+
- promptVersion: ${promptVersion}
|
|
85
|
+
`.trim();
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
buildDoctorPrompt,
|
|
89
|
+
};
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
const { buildDoctorPrompt } = require("./prompt");
|
|
2
|
+
const { normalizeDoctorResult } = require("./normalize");
|
|
3
|
+
const { calculateWorkspaceDelta, computeWorkspaceSnapshot } = require("./workspace-fingerprint");
|
|
4
|
+
const {
|
|
5
|
+
kDoctorEngine,
|
|
6
|
+
kDoctorMeaningfulChangeScoreThreshold,
|
|
7
|
+
kDoctorPromptVersion,
|
|
8
|
+
kDoctorRunStatus,
|
|
9
|
+
kDoctorRunTimeoutMs,
|
|
10
|
+
kDoctorStaleThresholdMs,
|
|
11
|
+
} = require("./constants");
|
|
12
|
+
|
|
13
|
+
const shellEscapeArg = (value) => {
|
|
14
|
+
const safeValue = String(value || "");
|
|
15
|
+
return `'${safeValue.replace(/'/g, `'\\''`)}'`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const hasValidIsoTime = (value) => {
|
|
19
|
+
const timestamp = Date.parse(String(value || ""));
|
|
20
|
+
return Number.isFinite(timestamp);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const formatElapsedSince = (isoTime) => {
|
|
24
|
+
if (!hasValidIsoTime(isoTime)) return "the last scan";
|
|
25
|
+
const elapsedMs = Math.max(0, Date.now() - Date.parse(isoTime));
|
|
26
|
+
const elapsedMinutes = Math.max(1, Math.round(elapsedMs / 60000));
|
|
27
|
+
if (elapsedMinutes < 60) {
|
|
28
|
+
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ago`;
|
|
29
|
+
}
|
|
30
|
+
const elapsedHours = Math.round(elapsedMinutes / 60);
|
|
31
|
+
if (elapsedHours < 24) {
|
|
32
|
+
return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ago`;
|
|
33
|
+
}
|
|
34
|
+
const elapsedDays = Math.round(elapsedHours / 24);
|
|
35
|
+
return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ago`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const buildDoctorSessionKey = (runId) => `agent:main:doctor:${Number(runId || 0)}`;
|
|
39
|
+
const buildDoctorSessionId = (runId) => buildDoctorSessionKey(runId);
|
|
40
|
+
const buildDoctorIdempotencyKey = (runId) => `doctor-run-${Number(runId || 0)}`;
|
|
41
|
+
|
|
42
|
+
const createDoctorService = ({
|
|
43
|
+
clawCmd,
|
|
44
|
+
listDoctorRuns,
|
|
45
|
+
listDoctorCards,
|
|
46
|
+
getInitialWorkspaceBaseline,
|
|
47
|
+
setInitialWorkspaceBaseline,
|
|
48
|
+
createDoctorRun,
|
|
49
|
+
completeDoctorRun,
|
|
50
|
+
insertDoctorCards,
|
|
51
|
+
getDoctorRun,
|
|
52
|
+
getDoctorCardsByRunId,
|
|
53
|
+
getDoctorCard,
|
|
54
|
+
updateDoctorCardStatus,
|
|
55
|
+
workspaceRoot,
|
|
56
|
+
managedRoot,
|
|
57
|
+
protectedPaths = [],
|
|
58
|
+
lockedPaths = [],
|
|
59
|
+
}) => {
|
|
60
|
+
const state = {
|
|
61
|
+
activeRunId: 0,
|
|
62
|
+
activeRunPromise: null,
|
|
63
|
+
snapshotCache: null,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const getLatestCompletedRun = () =>
|
|
67
|
+
listDoctorRuns({ limit: 25 }).find((run) => run.status === kDoctorRunStatus.completed) || null;
|
|
68
|
+
|
|
69
|
+
const getCurrentWorkspaceSnapshot = () => {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
if (state.snapshotCache && now - state.snapshotCache.computedAt < 5000) {
|
|
72
|
+
return state.snapshotCache.snapshot;
|
|
73
|
+
}
|
|
74
|
+
const snapshot = computeWorkspaceSnapshot(workspaceRoot);
|
|
75
|
+
state.snapshotCache = {
|
|
76
|
+
computedAt: now,
|
|
77
|
+
snapshot,
|
|
78
|
+
};
|
|
79
|
+
return snapshot;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const getOrCreateInitialBaseline = () => {
|
|
83
|
+
const existingBaseline = getInitialWorkspaceBaseline?.();
|
|
84
|
+
if (existingBaseline?.fingerprint && existingBaseline?.manifest) {
|
|
85
|
+
return existingBaseline;
|
|
86
|
+
}
|
|
87
|
+
const snapshot = getCurrentWorkspaceSnapshot();
|
|
88
|
+
const nextBaseline = {
|
|
89
|
+
fingerprint: snapshot.fingerprint,
|
|
90
|
+
manifest: snapshot.manifest,
|
|
91
|
+
capturedAt: new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
return setInitialWorkspaceBaseline?.(nextBaseline) || nextBaseline;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const cloneRunCards = ({ sourceRunId, targetRunId }) => {
|
|
97
|
+
const sourceCards = getDoctorCardsByRunId(sourceRunId);
|
|
98
|
+
insertDoctorCards({
|
|
99
|
+
runId: targetRunId,
|
|
100
|
+
cards: sourceCards,
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const buildStatus = () => {
|
|
105
|
+
const recentRuns = listDoctorRuns({ limit: 10 });
|
|
106
|
+
const latestRun = recentRuns[0] || null;
|
|
107
|
+
const latestCompletedRun =
|
|
108
|
+
recentRuns.find((run) => run.status === kDoctorRunStatus.completed) || null;
|
|
109
|
+
const lastRunAt =
|
|
110
|
+
latestCompletedRun?.completedAt || latestCompletedRun?.startedAt || null;
|
|
111
|
+
const lastRunAgeMs = hasValidIsoTime(lastRunAt) ? Date.now() - Date.parse(lastRunAt) : null;
|
|
112
|
+
const stale = lastRunAgeMs == null || lastRunAgeMs >= kDoctorStaleThresholdMs;
|
|
113
|
+
const baselineRun = latestCompletedRun;
|
|
114
|
+
const initialBaseline = !baselineRun ? getOrCreateInitialBaseline() : null;
|
|
115
|
+
const currentSnapshot = baselineRun || initialBaseline ? getCurrentWorkspaceSnapshot() : null;
|
|
116
|
+
const baselineManifest =
|
|
117
|
+
baselineRun?.workspaceManifest && typeof baselineRun.workspaceManifest === "object"
|
|
118
|
+
? baselineRun.workspaceManifest
|
|
119
|
+
: initialBaseline?.manifest && typeof initialBaseline.manifest === "object"
|
|
120
|
+
? initialBaseline.manifest
|
|
121
|
+
: null;
|
|
122
|
+
const hasManifestBaseline = !!baselineManifest;
|
|
123
|
+
const delta =
|
|
124
|
+
hasManifestBaseline && currentSnapshot
|
|
125
|
+
? calculateWorkspaceDelta({
|
|
126
|
+
previousManifest: baselineManifest,
|
|
127
|
+
currentManifest: currentSnapshot.manifest,
|
|
128
|
+
})
|
|
129
|
+
: {
|
|
130
|
+
addedFilesCount: 0,
|
|
131
|
+
removedFilesCount: 0,
|
|
132
|
+
modifiedFilesCount: 0,
|
|
133
|
+
changedFilesCount: 0,
|
|
134
|
+
deltaScore: 0,
|
|
135
|
+
changedPaths: [],
|
|
136
|
+
};
|
|
137
|
+
const hasMeaningfulChanges =
|
|
138
|
+
!!latestCompletedRun &&
|
|
139
|
+
delta.deltaScore >= kDoctorMeaningfulChangeScoreThreshold;
|
|
140
|
+
return {
|
|
141
|
+
activeRunId: state.activeRunId || 0,
|
|
142
|
+
runInProgress: !!state.activeRunPromise,
|
|
143
|
+
lastRunAt,
|
|
144
|
+
lastRunAgeMs,
|
|
145
|
+
needsInitialRun: !latestCompletedRun,
|
|
146
|
+
stale,
|
|
147
|
+
changeSummary: {
|
|
148
|
+
...delta,
|
|
149
|
+
hasBaseline: hasManifestBaseline,
|
|
150
|
+
baselineSource: baselineRun ? "last_run" : initialBaseline ? "initial_install" : "none",
|
|
151
|
+
hasMeaningfulChanges,
|
|
152
|
+
},
|
|
153
|
+
latestRun,
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const executeDoctorRun = async (runId) => {
|
|
158
|
+
try {
|
|
159
|
+
const prompt = buildDoctorPrompt({
|
|
160
|
+
workspaceRoot,
|
|
161
|
+
managedRoot,
|
|
162
|
+
protectedPaths,
|
|
163
|
+
lockedPaths,
|
|
164
|
+
promptVersion: kDoctorPromptVersion,
|
|
165
|
+
});
|
|
166
|
+
const gatewayTimeoutMs = kDoctorRunTimeoutMs + 30000;
|
|
167
|
+
const gatewayParams = {
|
|
168
|
+
agentId: "main",
|
|
169
|
+
idempotencyKey: buildDoctorIdempotencyKey(runId),
|
|
170
|
+
message: prompt,
|
|
171
|
+
sessionKey: buildDoctorSessionKey(runId),
|
|
172
|
+
thinking: "medium",
|
|
173
|
+
timeout: Math.round(kDoctorRunTimeoutMs / 1000),
|
|
174
|
+
};
|
|
175
|
+
const result = await clawCmd(
|
|
176
|
+
`gateway call agent --expect-final --json --timeout ${gatewayTimeoutMs} --params ${shellEscapeArg(
|
|
177
|
+
JSON.stringify(gatewayParams),
|
|
178
|
+
)}`,
|
|
179
|
+
{
|
|
180
|
+
quiet: true,
|
|
181
|
+
timeoutMs: gatewayTimeoutMs,
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
if (!result?.ok) {
|
|
185
|
+
throw new Error(result?.stderr || "Doctor analysis command failed");
|
|
186
|
+
}
|
|
187
|
+
const stdoutText = String(result.stdout || "");
|
|
188
|
+
const stderrText = String(result.stderr || "");
|
|
189
|
+
console.log(
|
|
190
|
+
`[doctor] run ${runId} command result ok=${result.ok} code=${result.code ?? 0} stdout_chars=${stdoutText.length} stderr_chars=${stderrText.length}`,
|
|
191
|
+
);
|
|
192
|
+
let normalizedResult = null;
|
|
193
|
+
try {
|
|
194
|
+
normalizedResult = normalizeDoctorResult(stdoutText);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error(
|
|
197
|
+
`[doctor] run ${runId} normalize failed: ${error.message || "Unknown error"}`,
|
|
198
|
+
);
|
|
199
|
+
console.error(`[doctor] run ${runId} stdout begin`);
|
|
200
|
+
console.error(stdoutText || "(empty)");
|
|
201
|
+
console.error(`[doctor] run ${runId} stdout end`);
|
|
202
|
+
console.error(`[doctor] run ${runId} stderr begin`);
|
|
203
|
+
console.error(stderrText || "(empty)");
|
|
204
|
+
console.error(`[doctor] run ${runId} stderr end`);
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
insertDoctorCards({
|
|
208
|
+
runId,
|
|
209
|
+
cards: normalizedResult.cards,
|
|
210
|
+
});
|
|
211
|
+
completeDoctorRun({
|
|
212
|
+
id: runId,
|
|
213
|
+
status: kDoctorRunStatus.completed,
|
|
214
|
+
summary: normalizedResult.summary,
|
|
215
|
+
rawResult: normalizedResult.rawPayload,
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
completeDoctorRun({
|
|
219
|
+
id: runId,
|
|
220
|
+
status: kDoctorRunStatus.failed,
|
|
221
|
+
error: error.message || "Doctor run failed",
|
|
222
|
+
});
|
|
223
|
+
} finally {
|
|
224
|
+
state.activeRunId = 0;
|
|
225
|
+
state.activeRunPromise = null;
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const runDoctor = () => {
|
|
230
|
+
if (state.activeRunPromise) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
alreadyRunning: true,
|
|
234
|
+
runId: state.activeRunId || 0,
|
|
235
|
+
status: buildStatus(),
|
|
236
|
+
error: "Doctor run already in progress",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const workspaceSnapshot = getCurrentWorkspaceSnapshot();
|
|
240
|
+
const workspaceFingerprint = workspaceSnapshot.fingerprint;
|
|
241
|
+
const latestCompletedRun = getLatestCompletedRun();
|
|
242
|
+
if (
|
|
243
|
+
latestCompletedRun &&
|
|
244
|
+
latestCompletedRun.workspaceFingerprint &&
|
|
245
|
+
latestCompletedRun.workspaceFingerprint === workspaceFingerprint
|
|
246
|
+
) {
|
|
247
|
+
const runId = createDoctorRun({
|
|
248
|
+
status: kDoctorRunStatus.completed,
|
|
249
|
+
engine: kDoctorEngine.deterministicReuse,
|
|
250
|
+
workspaceRoot,
|
|
251
|
+
workspaceFingerprint,
|
|
252
|
+
workspaceManifest: workspaceSnapshot.manifest,
|
|
253
|
+
promptVersion: kDoctorPromptVersion,
|
|
254
|
+
reusedFromRunId: latestCompletedRun.id,
|
|
255
|
+
});
|
|
256
|
+
cloneRunCards({
|
|
257
|
+
sourceRunId: latestCompletedRun.id,
|
|
258
|
+
targetRunId: runId,
|
|
259
|
+
});
|
|
260
|
+
const summary = `No workspace changes since last scan (${formatElapsedSince(
|
|
261
|
+
latestCompletedRun.completedAt || latestCompletedRun.startedAt,
|
|
262
|
+
)}). Same findings apply.`;
|
|
263
|
+
completeDoctorRun({
|
|
264
|
+
id: runId,
|
|
265
|
+
status: kDoctorRunStatus.completed,
|
|
266
|
+
summary,
|
|
267
|
+
rawResult: latestCompletedRun.rawResult,
|
|
268
|
+
});
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
runId,
|
|
272
|
+
reusedPreviousRun: true,
|
|
273
|
+
sourceRunId: latestCompletedRun.id,
|
|
274
|
+
status: buildStatus(),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const runId = createDoctorRun({
|
|
278
|
+
status: kDoctorRunStatus.running,
|
|
279
|
+
engine: kDoctorEngine.gatewayAgent,
|
|
280
|
+
workspaceRoot,
|
|
281
|
+
workspaceFingerprint,
|
|
282
|
+
workspaceManifest: workspaceSnapshot.manifest,
|
|
283
|
+
promptVersion: kDoctorPromptVersion,
|
|
284
|
+
});
|
|
285
|
+
state.activeRunId = runId;
|
|
286
|
+
state.activeRunPromise = executeDoctorRun(runId);
|
|
287
|
+
return {
|
|
288
|
+
ok: true,
|
|
289
|
+
runId,
|
|
290
|
+
status: buildStatus(),
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const importDoctorResult = ({
|
|
295
|
+
rawOutput,
|
|
296
|
+
engine = kDoctorEngine.manualImport,
|
|
297
|
+
} = {}) => {
|
|
298
|
+
const normalizedRawOutput = String(rawOutput || "");
|
|
299
|
+
if (!normalizedRawOutput.trim()) {
|
|
300
|
+
throw new Error("Doctor import requires raw output");
|
|
301
|
+
}
|
|
302
|
+
const normalizedResult = normalizeDoctorResult(normalizedRawOutput);
|
|
303
|
+
const workspaceSnapshot = getCurrentWorkspaceSnapshot();
|
|
304
|
+
const runId = createDoctorRun({
|
|
305
|
+
status: kDoctorRunStatus.completed,
|
|
306
|
+
engine,
|
|
307
|
+
workspaceRoot,
|
|
308
|
+
workspaceFingerprint: workspaceSnapshot.fingerprint,
|
|
309
|
+
workspaceManifest: workspaceSnapshot.manifest,
|
|
310
|
+
promptVersion: kDoctorPromptVersion,
|
|
311
|
+
});
|
|
312
|
+
insertDoctorCards({
|
|
313
|
+
runId,
|
|
314
|
+
cards: normalizedResult.cards,
|
|
315
|
+
});
|
|
316
|
+
completeDoctorRun({
|
|
317
|
+
id: runId,
|
|
318
|
+
status: kDoctorRunStatus.completed,
|
|
319
|
+
summary: normalizedResult.summary,
|
|
320
|
+
rawResult: normalizedResult.rawPayload,
|
|
321
|
+
});
|
|
322
|
+
return {
|
|
323
|
+
ok: true,
|
|
324
|
+
runId,
|
|
325
|
+
run: getDoctorRun(runId),
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const requestCardFix = async ({
|
|
330
|
+
cardId,
|
|
331
|
+
sessionId = "",
|
|
332
|
+
replyChannel = "",
|
|
333
|
+
replyTo = "",
|
|
334
|
+
} = {}) => {
|
|
335
|
+
const card = getDoctorCard(cardId);
|
|
336
|
+
if (!card) throw new Error("Doctor card not found");
|
|
337
|
+
const prompt = String(card.fixPrompt || "").trim();
|
|
338
|
+
if (!prompt) throw new Error("Doctor card does not include a fix prompt");
|
|
339
|
+
let command = `agent --agent main --message ${shellEscapeArg(prompt)}`;
|
|
340
|
+
const trimmedSessionId = String(sessionId || "").trim();
|
|
341
|
+
const trimmedReplyChannel = String(replyChannel || "").trim();
|
|
342
|
+
const trimmedReplyTo = String(replyTo || "").trim();
|
|
343
|
+
if (trimmedReplyChannel && trimmedReplyTo) {
|
|
344
|
+
command +=
|
|
345
|
+
` --deliver --reply-channel ${shellEscapeArg(trimmedReplyChannel)}` +
|
|
346
|
+
` --reply-to ${shellEscapeArg(trimmedReplyTo)}`;
|
|
347
|
+
} else if (trimmedSessionId) {
|
|
348
|
+
command += ` --session-id ${shellEscapeArg(trimmedSessionId)}`;
|
|
349
|
+
}
|
|
350
|
+
const result = await clawCmd(command, {
|
|
351
|
+
quiet: true,
|
|
352
|
+
timeoutMs: kDoctorRunTimeoutMs,
|
|
353
|
+
});
|
|
354
|
+
if (!result?.ok) {
|
|
355
|
+
throw new Error(result?.stderr || "Could not send Doctor fix request");
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
ok: true,
|
|
359
|
+
stdout: result.stdout || "",
|
|
360
|
+
card,
|
|
361
|
+
};
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const setCardStatus = ({ cardId, status }) => {
|
|
365
|
+
const updatedCard = updateDoctorCardStatus({
|
|
366
|
+
id: cardId,
|
|
367
|
+
status,
|
|
368
|
+
});
|
|
369
|
+
if (!updatedCard) throw new Error("Doctor card not found");
|
|
370
|
+
return updatedCard;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
buildStatus,
|
|
375
|
+
runDoctor,
|
|
376
|
+
importDoctorResult,
|
|
377
|
+
listDoctorRuns,
|
|
378
|
+
listDoctorCards,
|
|
379
|
+
getDoctorRun,
|
|
380
|
+
getDoctorCardsByRunId,
|
|
381
|
+
requestCardFix,
|
|
382
|
+
setCardStatus,
|
|
383
|
+
getDoctorCard,
|
|
384
|
+
};
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
module.exports = {
|
|
388
|
+
buildDoctorIdempotencyKey,
|
|
389
|
+
buildDoctorSessionKey,
|
|
390
|
+
buildDoctorSessionId,
|
|
391
|
+
createDoctorService,
|
|
392
|
+
};
|