@chrysb/alphaclaw 0.4.6-beta.0 → 0.4.6-beta.2

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.
@@ -35,11 +35,15 @@ export const DoctorFindingsList = ({
35
35
  <div class="space-y-2">
36
36
  <div class="flex flex-wrap items-start justify-between gap-3">
37
37
  <div class="space-y-2 min-w-0">
38
- <h3 class="text-sm font-semibold text-gray-100">${card.title}</h3>
39
38
  <div class="flex flex-wrap items-center gap-2">
40
39
  <${Badge} tone=${getDoctorPriorityTone(card.priority)}>
41
40
  ${card.priority}
42
41
  </${Badge}>
42
+ <h3 class="text-sm font-semibold text-gray-100">
43
+ ${card.title}
44
+ </h3>
45
+ </div>
46
+ <div class="flex flex-wrap items-center gap-2">
43
47
  <${Badge} tone=${getDoctorCategoryTone(card.category)}>
44
48
  ${formatDoctorCategory(card.category)}
45
49
  </${Badge}>
@@ -1,10 +1,16 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
2
+ import { useEffect, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { ModalShell } from "../modal-shell.js";
5
5
  import { ActionButton } from "../action-button.js";
6
- import { fetchAgentSessions, sendDoctorCardFix } from "../../lib/api.js";
6
+ import { Badge } from "../badge.js";
7
+ import {
8
+ sendDoctorCardFix,
9
+ updateDoctorCardStatus,
10
+ } from "../../lib/api.js";
7
11
  import { showToast } from "../toast.js";
12
+ import { getDoctorPriorityTone } from "./helpers.js";
13
+ import { useAgentSessions } from "../../hooks/useAgentSessions.js";
8
14
 
9
15
  const html = htm.bind(h);
10
16
 
@@ -14,54 +20,22 @@ export const DoctorFixCardModal = ({
14
20
  onClose = () => {},
15
21
  onComplete = () => {},
16
22
  }) => {
17
- const [sessions, setSessions] = useState([]);
18
- const [selectedSessionKey, setSelectedSessionKey] = useState("");
19
- const [loadingSessions, setLoadingSessions] = useState(false);
23
+ const {
24
+ sessions,
25
+ selectedSessionKey,
26
+ setSelectedSessionKey,
27
+ selectedSession,
28
+ loading: loadingSessions,
29
+ error: loadError,
30
+ } = useAgentSessions({ enabled: visible });
31
+
20
32
  const [sending, setSending] = useState(false);
21
- const [loadError, setLoadError] = useState("");
33
+ const [promptText, setPromptText] = useState("");
22
34
 
23
35
  useEffect(() => {
24
36
  if (!visible) return;
25
- let active = true;
26
- const loadSessions = async () => {
27
- try {
28
- setLoadingSessions(true);
29
- setLoadError("");
30
- const data = await fetchAgentSessions();
31
- if (!active) return;
32
- const nextSessions = Array.isArray(data?.sessions) ? data.sessions : [];
33
- setSessions(nextSessions);
34
- const preferredSession =
35
- nextSessions.find((sessionRow) => {
36
- const key = String(sessionRow?.key || "").toLowerCase();
37
- return key === "agent:main:main";
38
- }) ||
39
- nextSessions.find((sessionRow) => {
40
- const key = String(sessionRow?.key || "").toLowerCase();
41
- return key.includes(":direct:") || key.includes(":group:");
42
- }) ||
43
- nextSessions[0] ||
44
- null;
45
- setSelectedSessionKey(String(preferredSession?.key || ""));
46
- } catch (error) {
47
- if (!active) return;
48
- setSessions([]);
49
- setSelectedSessionKey("");
50
- setLoadError(error.message || "Could not load agent sessions");
51
- } finally {
52
- if (active) setLoadingSessions(false);
53
- }
54
- };
55
- loadSessions();
56
- return () => {
57
- active = false;
58
- };
59
- }, [visible, card?.id]);
60
-
61
- const selectedSession = useMemo(
62
- () => sessions.find((sessionRow) => String(sessionRow?.key || "") === selectedSessionKey) || null,
63
- [sessions, selectedSessionKey],
64
- );
37
+ setPromptText(String(card?.fixPrompt || ""));
38
+ }, [visible, card?.fixPrompt, card?.id]);
65
39
 
66
40
  const handleSend = async () => {
67
41
  if (!card?.id || sending) return;
@@ -72,9 +46,18 @@ export const DoctorFixCardModal = ({
72
46
  sessionId: selectedSession?.sessionId || "",
73
47
  replyChannel: selectedSession?.replyChannel || "",
74
48
  replyTo: selectedSession?.replyTo || "",
49
+ prompt: promptText,
75
50
  });
76
- showToast("Doctor fix request sent to your agent", "success");
77
- onComplete();
51
+ try {
52
+ await updateDoctorCardStatus({ cardId: card.id, status: "fixed" });
53
+ showToast("Doctor fix request sent and finding marked fixed", "success");
54
+ } catch (statusError) {
55
+ showToast(
56
+ statusError.message || "Doctor fix request sent, but could not mark the finding fixed",
57
+ "warning",
58
+ );
59
+ }
60
+ await onComplete();
78
61
  onClose();
79
62
  } catch (error) {
80
63
  showToast(error.message || "Could not send Doctor fix request", "error");
@@ -96,8 +79,14 @@ export const DoctorFixCardModal = ({
96
79
  </p>
97
80
  </div>
98
81
  <div class="ac-surface-inset p-3 space-y-1">
99
- <div class="text-xs text-gray-500 uppercase tracking-wide">${card?.priority || "P2"}</div>
100
- <div class="text-sm text-gray-200">${card?.title || "Doctor finding"}</div>
82
+ <div class="flex items-center gap-2 min-w-0">
83
+ <${Badge} tone=${getDoctorPriorityTone(card?.priority || "P2")}>
84
+ ${card?.priority || "P2"}
85
+ </${Badge}>
86
+ <div class="text-sm font-semibold text-gray-200 leading-5 min-w-0">
87
+ ${card?.title || "Doctor finding"}
88
+ </div>
89
+ </div>
101
90
  <div class="text-xs text-gray-400">${card?.recommendation || ""}</div>
102
91
  </div>
103
92
  <div class="space-y-2">
@@ -116,11 +105,23 @@ export const DoctorFixCardModal = ({
116
105
  `,
117
106
  )}
118
107
  </select>
119
- ${loadingSessions
120
- ? html`<div class="text-xs text-gray-500">Loading sessions...</div>`
121
- : null}
108
+ ${
109
+ loadingSessions
110
+ ? html`<div class="text-xs text-gray-500">Loading sessions...</div>`
111
+ : null
112
+ }
122
113
  ${loadError ? html`<div class="text-xs text-red-400">${loadError}</div>` : null}
123
114
  </div>
115
+ <div class="space-y-2">
116
+ <label class="text-xs text-gray-500">Instructions</label>
117
+ <textarea
118
+ value=${promptText}
119
+ onInput=${(event) => setPromptText(String(event.currentTarget?.value || ""))}
120
+ disabled=${sending}
121
+ rows="8"
122
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 focus:border-gray-500 font-mono leading-5"
123
+ ></textarea>
124
+ </div>
124
125
  <div class="flex items-center justify-end gap-2">
125
126
  <${ActionButton}
126
127
  onClick=${onClose}
@@ -131,7 +132,7 @@ export const DoctorFixCardModal = ({
131
132
  />
132
133
  <${ActionButton}
133
134
  onClick=${handleSend}
134
- disabled=${!selectedSession || loadingSessions || !!loadError}
135
+ disabled=${!selectedSession || loadingSessions || !!loadError || !String(promptText || "").trim()}
135
136
  loading=${sending}
136
137
  tone="primary"
137
138
  size="md"
@@ -81,41 +81,10 @@ export const getDoctorWarningMessage = (doctorStatus = null) => {
81
81
  return "Doctor has not been run in the last week.";
82
82
  };
83
83
 
84
- export const getDoctorDriftRisk = (changeSummary = null) => {
85
- const deltaScore = Number(changeSummary?.deltaScore || 0);
84
+ export const getDoctorChangeLabel = (changeSummary = null) => {
86
85
  const changedFilesCount = Number(changeSummary?.changedFilesCount || 0);
87
- const hasBaseline = !!changeSummary?.hasBaseline;
88
- const baselineSource = String(changeSummary?.baselineSource || "none");
89
- if (!hasBaseline) {
90
- return {
91
- label: "Unknown",
92
- tone: "neutral",
93
- detail: "No prior baseline yet",
94
- };
95
- }
96
- if (deltaScore >= 8 || changedFilesCount >= 8) {
97
- return {
98
- label: "High",
99
- tone: "danger",
100
- detail: `${changedFilesCount} changed file${changedFilesCount === 1 ? "" : "s"}`,
101
- };
102
- }
103
- if (deltaScore >= 4 || changedFilesCount >= 4) {
104
- return {
105
- label: "Moderate",
106
- tone: "warning",
107
- detail: `${changedFilesCount} changed file${changedFilesCount === 1 ? "" : "s"}`,
108
- };
109
- }
110
- return {
111
- label: "Low",
112
- tone: "info",
113
- detail: changedFilesCount
114
- ? `${changedFilesCount} changed file${changedFilesCount === 1 ? "" : "s"}`
115
- : baselineSource === "initial_install"
116
- ? "Compared to initial install"
117
- : "No detected changes",
118
- };
86
+ if (changedFilesCount === 0) return "No changes since last run";
87
+ return `${changedFilesCount} change${changedFilesCount === 1 ? "" : "s"} since last run`;
119
88
  };
120
89
 
121
90
  export const getDoctorRunPillDetail = (run = null) => {
@@ -11,7 +11,6 @@ import {
11
11
  } from "../../lib/api.js";
12
12
  import { formatLocaleDateTime } from "../../lib/format.js";
13
13
  import { ActionButton } from "../action-button.js";
14
- import { Badge } from "../badge.js";
15
14
  import { LoadingSpinner } from "../loading-spinner.js";
16
15
  import { PageHeader } from "../page-header.js";
17
16
  import { showToast } from "../toast.js";
@@ -21,7 +20,7 @@ import { DoctorFixCardModal } from "./fix-card-modal.js";
21
20
  import {
22
21
  buildDoctorRunMarkers,
23
22
  buildDoctorStatusFilterOptions,
24
- getDoctorDriftRisk,
23
+ getDoctorChangeLabel,
25
24
  getDoctorRunPillDetail,
26
25
  shouldShowDoctorWarning,
27
26
  } from "./helpers.js";
@@ -167,8 +166,8 @@ export const DoctorTab = ({ isActive = false }) => {
167
166
  () => buildDoctorStatusFilterOptions(),
168
167
  [],
169
168
  );
170
- const driftRisk = useMemo(
171
- () => getDoctorDriftRisk(doctorStatus?.changeSummary || null),
169
+ const changeLabel = useMemo(
170
+ () => getDoctorChangeLabel(doctorStatus?.changeSummary || null),
172
171
  [doctorStatus],
173
172
  );
174
173
  const canRunDoctor = useMemo(() => {
@@ -183,6 +182,7 @@ export const DoctorTab = ({ isActive = false }) => {
183
182
  () => shouldShowDoctorWarning(doctorStatus, 0),
184
183
  [doctorStatus],
185
184
  );
185
+ const hasCompletedDoctorRun = !!doctorStatus?.lastRunAt;
186
186
  const hasRuns = runs.length > 0;
187
187
  const hasLoadedRuns = runsPoll.data !== null || runsPoll.error !== null;
188
188
  const hasLoadedCards = cardsPoll.data !== null || cardsPoll.error !== null;
@@ -314,25 +314,25 @@ export const DoctorTab = ({ isActive = false }) => {
314
314
  ? html`
315
315
  <${DoctorSummaryCards} cards=${openCards} />
316
316
  <div class="space-y-2">
317
- <div
318
- class="bg-surface border border-border rounded-xl p-4 flex flex-wrap items-center justify-between gap-3"
319
- >
320
- <div class="flex flex-wrap items-center gap-2 text-xs text-gray-500">
321
- <${Badge} tone=${driftRisk.tone}
322
- >${driftRisk.label} drift risk${
323
- driftRisk.detail ? ` · ${driftRisk.detail}` : ""
324
- }</${Badge}
325
- >
326
- </div>
327
- <div class="flex items-center gap-3 text-xs text-gray-500">
328
- <span>Last run</span>
329
- <span class="text-gray-300">
330
- ${formatLocaleDateTime(doctorStatus?.lastRunAt, {
331
- fallback: "Never",
332
- })}
333
- </span>
334
- </div>
335
- </div>
317
+ ${hasCompletedDoctorRun
318
+ ? html`
319
+ <div
320
+ class="bg-surface border border-border rounded-xl p-4 flex flex-wrap items-center justify-between gap-3"
321
+ >
322
+ <span class="text-xs text-gray-500">
323
+ Last run ·${" "}
324
+ <span class="text-gray-300">
325
+ ${formatLocaleDateTime(doctorStatus?.lastRunAt, {
326
+ fallback: "Never",
327
+ })}
328
+ </span>
329
+ </span>
330
+ <span class="text-xs text-gray-500">
331
+ ${changeLabel}
332
+ </span>
333
+ </div>
334
+ `
335
+ : null}
336
336
  ${
337
337
  showDoctorStaleBanner
338
338
  ? html`
@@ -376,7 +376,7 @@ export const DoctorTab = ({ isActive = false }) => {
376
376
  setSelectedRunFilter(String(run.id || ""))}
377
377
  >
378
378
  <span class="font-medium">Run #${run.id}</span>
379
- <span class="inline-flex items-center gap-1.5">
379
+ <span class="inline-flex items-center gap-1">
380
380
  ${markers.map(
381
381
  (marker) => html`
382
382
  <span
@@ -52,7 +52,7 @@ const kTreeIndentPx = 9;
52
52
  const kFolderBasePaddingPx = 10;
53
53
  const kFileBasePaddingPx = 14;
54
54
  const kTreeRefreshIntervalMs = 5000;
55
- const kExpandedFoldersStorageKey = "alphaclaw.browse.expandedFolders";
55
+ import { kExpandedFoldersStorageKey } from "../lib/storage-keys.js";
56
56
 
57
57
  const readStoredExpandedPaths = () => {
58
58
  try {
@@ -1,6 +1,7 @@
1
- export const kFileViewerModeStorageKey = "alphaclaw.browse.fileViewerMode";
2
- export const kLegacyFileViewerModeStorageKey = "alphaclawBrowseFileViewerMode";
3
- export const kEditorSelectionStorageKey = "alphaclaw.browse.editorSelectionByPath";
1
+ export {
2
+ kFileViewerModeStorageKey,
3
+ kEditorSelectionStorageKey,
4
+ } from "../../lib/storage-keys.js";
4
5
  export const kLoadingIndicatorDelayMs = 1000;
5
6
  export const kFileRefreshIntervalMs = 5000;
6
7
  export const kSqlitePageSize = 50;
@@ -1,15 +1,12 @@
1
1
  import {
2
2
  kEditorSelectionStorageKey,
3
3
  kFileViewerModeStorageKey,
4
- kLegacyFileViewerModeStorageKey,
5
4
  } from "./constants.js";
6
5
 
7
6
  export const readStoredFileViewerMode = () => {
8
7
  try {
9
8
  const storedMode = String(
10
- window.localStorage.getItem(kFileViewerModeStorageKey) ||
11
- window.localStorage.getItem(kLegacyFileViewerModeStorageKey) ||
12
- "",
9
+ window.localStorage.getItem(kFileViewerModeStorageKey) || "",
13
10
  ).trim();
14
11
  return storedMode === "preview" ? "preview" : "edit";
15
12
  } catch {
@@ -1,12 +1,13 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
2
+ import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { ModalShell } from "../modal-shell.js";
5
5
  import { PageHeader } from "../page-header.js";
6
6
  import { CloseIcon } from "../icons.js";
7
7
  import { ActionButton } from "../action-button.js";
8
- import { fetchAgentSessions, sendAgentMessage } from "../../lib/api.js";
8
+ import { sendAgentMessage } from "../../lib/api.js";
9
9
  import { showToast } from "../toast.js";
10
+ import { useAgentSessions } from "../../hooks/useAgentSessions.js";
10
11
 
11
12
  const html = htm.bind(h);
12
13
 
@@ -45,6 +46,11 @@ const kStepTitles = [
45
46
  const kTotalSteps = kStepTitles.length;
46
47
  const kNoSessionSelectedValue = "__none__";
47
48
 
49
+ const kDirectOrGroupFilter = (sessionRow) => {
50
+ const key = String(sessionRow?.key || "").toLowerCase();
51
+ return key.includes(":direct:") || key.includes(":group:");
52
+ };
53
+
48
54
  const renderCommandBlock = (command = "", onCopy = () => {}) => html`
49
55
  <div class="rounded-lg border border-border bg-black/30 p-3">
50
56
  <pre
@@ -80,10 +86,14 @@ export const GmailSetupWizard = ({
80
86
  const [watchEnabled, setWatchEnabled] = useState(false);
81
87
  const [sendingToAgent, setSendingToAgent] = useState(false);
82
88
  const [agentMessageSent, setAgentMessageSent] = useState(false);
83
- const [agentSessions, setAgentSessions] = useState([]);
84
- const [selectedSessionKey, setSelectedSessionKey] = useState("");
85
- const [loadingAgentSessions, setLoadingAgentSessions] = useState(false);
86
- const [agentSessionsError, setAgentSessionsError] = useState("");
89
+
90
+ const {
91
+ sessions: selectableAgentSessions,
92
+ selectedSessionKey,
93
+ setSelectedSessionKey,
94
+ loading: loadingAgentSessions,
95
+ error: agentSessionsError,
96
+ } = useAgentSessions({ enabled: visible, filter: kDirectOrGroupFilter });
87
97
 
88
98
  useEffect(() => {
89
99
  if (!visible) return;
@@ -94,41 +104,6 @@ export const GmailSetupWizard = ({
94
104
  setWatchEnabled(false);
95
105
  setSendingToAgent(false);
96
106
  setAgentMessageSent(false);
97
- setAgentSessions([]);
98
- setSelectedSessionKey("");
99
- setLoadingAgentSessions(false);
100
- setAgentSessionsError("");
101
- }, [visible, account?.id]);
102
-
103
- useEffect(() => {
104
- if (!visible) return;
105
- let active = true;
106
- const loadAgentSessions = async () => {
107
- try {
108
- setLoadingAgentSessions(true);
109
- setAgentSessionsError("");
110
- const data = await fetchAgentSessions();
111
- if (!active) return;
112
- const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
113
- setAgentSessions(sessions);
114
- const defaultSession = sessions.find((sessionRow) => {
115
- const key = String(sessionRow?.key || "").toLowerCase();
116
- return key.includes(":direct:") || key.includes(":group:");
117
- });
118
- setSelectedSessionKey((currentKey) => currentKey || String(defaultSession?.key || ""));
119
- } catch (err) {
120
- if (!active) return;
121
- setAgentSessions([]);
122
- setSelectedSessionKey("");
123
- setAgentSessionsError(err.message || "Could not load sessions");
124
- } finally {
125
- if (active) setLoadingAgentSessions(false);
126
- }
127
- };
128
- loadAgentSessions();
129
- return () => {
130
- active = false;
131
- };
132
107
  }, [visible, account?.id]);
133
108
 
134
109
  const commands = clientConfig?.commands || null;
@@ -150,23 +125,15 @@ export const GmailSetupWizard = ({
150
125
  }
151
126
  return true;
152
127
  }, [needsProjectId, projectIdInput]);
153
- const selectableAgentSessions = useMemo(
154
- () =>
155
- agentSessions.filter((sessionRow) => {
156
- const key = String(sessionRow?.key || "").toLowerCase();
157
- return key.includes(":direct:") || key.includes(":group:");
158
- }),
159
- [agentSessions],
160
- );
161
128
 
162
- const handleCopy = async (value) => {
129
+ const handleCopy = useCallback(async (value) => {
163
130
  const ok = await copyText(value);
164
131
  if (ok) {
165
132
  showToast("Copied to clipboard", "success");
166
133
  return;
167
134
  }
168
135
  showToast("Could not copy text", "error");
169
- };
136
+ }, []);
170
137
 
171
138
  const handleFinish = async () => {
172
139
  try {
@@ -7,7 +7,10 @@ import { InfoTooltip } from "../info-tooltip.js";
7
7
  const html = htm.bind(h);
8
8
 
9
9
  const resolveWatchState = ({ watchStatus, busy = false }) => {
10
- if (busy) return { label: "Starting", tone: "warning" };
10
+ if (busy) {
11
+ const label = watchStatus?.enabled ? "Stopping" : "Starting";
12
+ return { label, tone: "warning" };
13
+ }
11
14
  if (!watchStatus?.enabled) return { label: "Stopped", tone: "neutral" };
12
15
  if (watchStatus.enabled && !watchStatus.running)
13
16
  return { label: "Error", tone: "danger" };
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from "https://esm.sh/preact/hooks";
2
2
 
3
- export const kOnboardingStorageKey = "openclaw_setup";
3
+ import { kOnboardingStorageKey } from "../../lib/storage-keys.js";
4
+ export { kOnboardingStorageKey };
4
5
  export const kOnboardingStepKey = "_step";
5
6
  export const kPairingChannelKey = "_pairingChannel";
6
7
  export const kOnboardingSetupErrorKey = "_lastSetupError";
@@ -23,8 +23,11 @@ const kSteps = [
23
23
  { id: "summary", label: "Summary" },
24
24
  ];
25
25
 
26
- const kTelegramWorkspaceStorageKey = "telegram-workspace-state-v1";
27
- const kTelegramWorkspaceCacheKey = "telegram-workspace-cache-v1";
26
+ import {
27
+ kTelegramWorkspaceStorageKey,
28
+ kTelegramWorkspaceCacheKey,
29
+ } from "../../lib/storage-keys.js";
30
+
28
31
  const loadTelegramWorkspaceState = () => {
29
32
  try {
30
33
  const raw = window.localStorage.getItem(kTelegramWorkspaceStorageKey);
@@ -0,0 +1,128 @@
1
+ import { useState, useEffect, useMemo, useCallback } from "https://esm.sh/preact/hooks";
2
+ import { fetchAgentSessions } from "../lib/api.js";
3
+ import {
4
+ kAgentSessionsCacheKey,
5
+ kAgentLastSessionKey,
6
+ } from "../lib/storage-keys.js";
7
+
8
+ const readCachedSessions = () => {
9
+ try {
10
+ const raw = localStorage.getItem(kAgentSessionsCacheKey);
11
+ if (!raw) return [];
12
+ const parsed = JSON.parse(raw);
13
+ return Array.isArray(parsed) ? parsed : [];
14
+ } catch {
15
+ return [];
16
+ }
17
+ };
18
+
19
+ const writeCachedSessions = (sessions) => {
20
+ try {
21
+ localStorage.setItem(kAgentSessionsCacheKey, JSON.stringify(sessions));
22
+ } catch {}
23
+ };
24
+
25
+ const readLastSessionKey = () => {
26
+ try {
27
+ return localStorage.getItem(kAgentLastSessionKey) || "";
28
+ } catch {
29
+ return "";
30
+ }
31
+ };
32
+
33
+ const writeLastSessionKey = (key) => {
34
+ try {
35
+ localStorage.setItem(kAgentLastSessionKey, String(key || ""));
36
+ } catch {}
37
+ };
38
+
39
+ const pickPreferredSession = (sessions, lastKey) => {
40
+ if (lastKey) {
41
+ const lastMatch = sessions.find((row) => String(row?.key || "") === lastKey);
42
+ if (lastMatch) return lastMatch;
43
+ }
44
+ return (
45
+ sessions.find((row) => String(row?.key || "").toLowerCase() === "agent:main:main") ||
46
+ sessions.find((row) => {
47
+ const key = String(row?.key || "").toLowerCase();
48
+ return key.includes(":direct:") || key.includes(":group:");
49
+ }) ||
50
+ sessions[0] ||
51
+ null
52
+ );
53
+ };
54
+
55
+ /**
56
+ * Shared hook for agent session selection with localStorage caching.
57
+ *
58
+ * @param {object} options
59
+ * @param {boolean} options.enabled - Whether to load sessions (tie to modal visibility, etc.)
60
+ * @param {(sessions: Array) => Array} [options.filter] - Optional filter applied to the session list before exposing it.
61
+ * @returns {{ sessions, selectedSessionKey, setSelectedSessionKey, selectedSession, loading, error }}
62
+ */
63
+ export const useAgentSessions = ({ enabled = false, filter } = {}) => {
64
+ const [allSessions, setAllSessions] = useState([]);
65
+ const [selectedSessionKey, setSelectedSessionKeyState] = useState("");
66
+ const [loading, setLoading] = useState(false);
67
+ const [error, setError] = useState("");
68
+
69
+ const setSelectedSessionKey = useCallback((key) => {
70
+ const normalized = String(key || "");
71
+ setSelectedSessionKeyState(normalized);
72
+ writeLastSessionKey(normalized);
73
+ }, []);
74
+
75
+ useEffect(() => {
76
+ if (!enabled) return;
77
+ let active = true;
78
+
79
+ const cached = readCachedSessions();
80
+ const lastKey = readLastSessionKey();
81
+ if (cached.length > 0) {
82
+ setAllSessions(cached);
83
+ const preferred = pickPreferredSession(cached, lastKey);
84
+ setSelectedSessionKeyState(String(preferred?.key || ""));
85
+ }
86
+
87
+ const load = async () => {
88
+ try {
89
+ if (cached.length === 0) setLoading(true);
90
+ setError("");
91
+ const data = await fetchAgentSessions();
92
+ if (!active) return;
93
+ const nextSessions = Array.isArray(data?.sessions) ? data.sessions : [];
94
+ setAllSessions(nextSessions);
95
+ writeCachedSessions(nextSessions);
96
+ if (cached.length === 0 || !lastKey) {
97
+ const preferred = pickPreferredSession(nextSessions, lastKey);
98
+ setSelectedSessionKeyState(String(preferred?.key || ""));
99
+ }
100
+ } catch (err) {
101
+ if (!active) return;
102
+ if (cached.length === 0) {
103
+ setAllSessions([]);
104
+ setSelectedSessionKeyState("");
105
+ setError(err.message || "Could not load agent sessions");
106
+ }
107
+ } finally {
108
+ if (active) setLoading(false);
109
+ }
110
+ };
111
+ load();
112
+ return () => {
113
+ active = false;
114
+ };
115
+ }, [enabled]);
116
+
117
+ const sessions = useMemo(
118
+ () => (filter ? allSessions.filter(filter) : allSessions),
119
+ [allSessions, filter],
120
+ );
121
+
122
+ const selectedSession = useMemo(
123
+ () => sessions.find((row) => String(row?.key || "") === selectedSessionKey) || null,
124
+ [sessions, selectedSessionKey],
125
+ );
126
+
127
+ return { sessions, selectedSessionKey, setSelectedSessionKey, selectedSession, loading, error };
128
+ };
@@ -261,6 +261,7 @@ export const sendDoctorCardFix = async ({
261
261
  sessionId = "",
262
262
  replyChannel = "",
263
263
  replyTo = "",
264
+ prompt = "",
264
265
  } = {}) => {
265
266
  const res = await authFetch(
266
267
  `/api/doctor/findings/${encodeURIComponent(String(cardId || ""))}/fix`,
@@ -271,6 +272,7 @@ export const sendDoctorCardFix = async ({
271
272
  sessionId: String(sessionId || ""),
272
273
  replyChannel: String(replyChannel || ""),
273
274
  replyTo: String(replyTo || ""),
275
+ prompt: String(prompt || ""),
274
276
  }),
275
277
  },
276
278
  );
@@ -1,20 +1,21 @@
1
- export const kFileDraftStorageKeyPrefix = "alphaclaw.browse.draft.";
2
- export const kLegacyFileDraftStorageKeyPrefix = "alphaclawBrowseDraft:";
3
- export const kDraftIndexStorageKey = "alphaclaw.draftIndex";
1
+ import {
2
+ kFileDraftStorageKeyPrefix,
3
+ kDraftIndexStorageKey,
4
+ } from "./storage-keys.js";
5
+
6
+ export { kFileDraftStorageKeyPrefix, kDraftIndexStorageKey };
4
7
  export const kDraftIndexChangedEventName = "alphaclaw:browse-draft-index-changed";
5
8
 
6
9
  const getStorage = (storage) => storage || window.localStorage;
7
10
 
8
- export const getFileDraftStorageKey = (filePath, useLegacyPrefix = false) =>
9
- `${useLegacyPrefix ? kLegacyFileDraftStorageKeyPrefix : kFileDraftStorageKeyPrefix}${String(filePath || "").trim()}`;
11
+ export const getFileDraftStorageKey = (filePath) =>
12
+ `${kFileDraftStorageKeyPrefix}${String(filePath || "").trim()}`;
10
13
 
11
14
  export const readStoredFileDraft = (filePath, storage) => {
12
15
  try {
13
16
  if (!filePath) return "";
14
17
  const localStorage = getStorage(storage);
15
- const draft =
16
- localStorage.getItem(getFileDraftStorageKey(filePath)) ||
17
- localStorage.getItem(getFileDraftStorageKey(filePath, true));
18
+ const draft = localStorage.getItem(getFileDraftStorageKey(filePath));
18
19
  return typeof draft === "string" ? draft : "";
19
20
  } catch {
20
21
  return "";
@@ -34,7 +35,6 @@ export const clearStoredFileDraft = (filePath, storage) => {
34
35
  if (!filePath) return;
35
36
  const localStorage = getStorage(storage);
36
37
  localStorage.removeItem(getFileDraftStorageKey(filePath));
37
- localStorage.removeItem(getFileDraftStorageKey(filePath, true));
38
38
  } catch {}
39
39
  };
40
40
 
@@ -94,10 +94,6 @@ export const readStoredDraftPaths = (storage) => {
94
94
  const path = key.slice(kFileDraftStorageKeyPrefix.length).trim();
95
95
  if (path) nextDraftPaths.add(path);
96
96
  }
97
- if (key.startsWith(kLegacyFileDraftStorageKeyPrefix)) {
98
- const path = key.slice(kLegacyFileDraftStorageKeyPrefix.length).trim();
99
- if (path) nextDraftPaths.add(path);
100
- }
101
97
  }
102
98
  if (nextDraftPaths.size > 0) {
103
99
  writeDraftIndex(nextDraftPaths, { storage: localStorage });
@@ -0,0 +1,28 @@
1
+ // Centralized localStorage key registry.
2
+ // All standalone localStorage keys used by the Setup UI should be defined here
3
+ // so they stay discoverable, consistently prefixed, and free of collisions.
4
+ //
5
+ // Naming convention: "alphaclaw.<area>.<purpose>"
6
+
7
+ // --- UI settings (single JSON blob containing sub-keys) ---
8
+ export const kUiSettingsStorageKey = "alphaclaw.ui.settings";
9
+
10
+ // --- Browse / file viewer ---
11
+ export const kFileViewerModeStorageKey = "alphaclaw.browse.viewerMode";
12
+ export const kEditorSelectionStorageKey = "alphaclaw.browse.editorSelection";
13
+ export const kExpandedFoldersStorageKey = "alphaclaw.browse.expandedFolders";
14
+
15
+ // --- Browse / drafts ---
16
+ export const kFileDraftStorageKeyPrefix = "alphaclaw.browse.draft.";
17
+ export const kDraftIndexStorageKey = "alphaclaw.browse.draftIndex";
18
+
19
+ // --- Onboarding ---
20
+ export const kOnboardingStorageKey = "alphaclaw.onboarding.state";
21
+
22
+ // --- Telegram workspace ---
23
+ export const kTelegramWorkspaceStorageKey = "alphaclaw.telegram.workspaceState";
24
+ export const kTelegramWorkspaceCacheKey = "alphaclaw.telegram.workspaceCache";
25
+
26
+ // --- Agent sessions (shared across session pickers) ---
27
+ export const kAgentSessionsCacheKey = "alphaclaw.agent.sessionsCache";
28
+ export const kAgentLastSessionKey = "alphaclaw.agent.lastSessionKey";
@@ -1,4 +1,6 @@
1
- export const kUiSettingsStorageKey = "alphaclaw.uiSettings";
1
+ import { kUiSettingsStorageKey } from "./storage-keys.js";
2
+
3
+ export { kUiSettingsStorageKey };
2
4
 
3
5
  const parseSettings = (rawValue) => {
4
6
  if (!rawValue) return {};
@@ -1,11 +1,26 @@
1
1
  const renderList = (items = []) =>
2
2
  items.length ? items.map((item) => `- ${item}`).join("\n") : "- (none)";
3
3
 
4
+ const renderResolvedCards = (cards = []) => {
5
+ if (!cards.length) return "";
6
+ const lines = cards.map(
7
+ (card) =>
8
+ `- [${card.status}] ${card.title}` +
9
+ (card.category ? ` (${card.category})` : ""),
10
+ );
11
+ return `
12
+
13
+ Previously resolved findings (do not re-suggest these):
14
+ ${lines.join("\n")}
15
+ `;
16
+ };
17
+
4
18
  const buildDoctorPrompt = ({
5
19
  workspaceRoot = "",
6
20
  managedRoot = "",
7
21
  protectedPaths = [],
8
22
  lockedPaths = [],
23
+ resolvedCards = [],
9
24
  promptVersion = "doctor-v1",
10
25
  }) => `
11
26
  You are AlphaClaw Doctor. Analyze this OpenClaw workspace for guidance drift, redundancy, misplacement, and cleanup opportunities.
@@ -74,10 +89,11 @@ Return exactly this JSON shape:
74
89
  ]
75
90
  }
76
91
 
77
- Constraints:
92
+ ${renderResolvedCards(resolvedCards)}Constraints:
78
93
  - Maximum 12 cards
79
94
  - Use relative paths in evidence and targetPaths
80
95
  - Do not include duplicate cards
96
+ - Do not re-suggest findings that appear in the "Previously resolved" list above
81
97
  - Do not create cards for healthy default-template behavior
82
98
  - Do not create cards whose primary recommendation is to refactor AlphaClaw-managed file structure
83
99
  - If there are no meaningful findings, return an empty cards array
@@ -156,11 +156,20 @@ const createDoctorService = ({
156
156
 
157
157
  const executeDoctorRun = async (runId) => {
158
158
  try {
159
+ const allCards = listDoctorCards();
160
+ const resolvedCards = allCards
161
+ .filter((card) => card.status === "dismissed" || card.status === "fixed")
162
+ .map((card) => ({
163
+ status: card.status,
164
+ title: card.title || "",
165
+ category: card.category || "",
166
+ }));
159
167
  const prompt = buildDoctorPrompt({
160
168
  workspaceRoot,
161
169
  managedRoot,
162
170
  protectedPaths,
163
171
  lockedPaths,
172
+ resolvedCards,
164
173
  promptVersion: kDoctorPromptVersion,
165
174
  });
166
175
  const gatewayTimeoutMs = kDoctorRunTimeoutMs + 30000;
@@ -331,12 +340,13 @@ const createDoctorService = ({
331
340
  sessionId = "",
332
341
  replyChannel = "",
333
342
  replyTo = "",
343
+ prompt = "",
334
344
  } = {}) => {
335
345
  const card = getDoctorCard(cardId);
336
346
  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)}`;
347
+ const resolvedPrompt = String(prompt || card.fixPrompt || "").trim();
348
+ if (!resolvedPrompt) throw new Error("Doctor card does not include a fix prompt");
349
+ let command = `agent --agent main --message ${shellEscapeArg(resolvedPrompt)}`;
340
350
  const trimmedSessionId = String(sessionId || "").trim();
341
351
  const trimmedReplyChannel = String(replyChannel || "").trim();
342
352
  const trimmedReplyTo = String(replyTo || "").trim();
@@ -4,6 +4,17 @@ const crypto = require("crypto");
4
4
 
5
5
  const kIgnoredDirectoryNames = new Set([".git", "node_modules"]);
6
6
 
7
+ const kContentFileExtensions = new Set([
8
+ ".md", ".json", ".js", ".ts", ".jsx", ".tsx", ".yaml", ".yml",
9
+ ".txt", ".sh", ".css", ".html", ".xml", ".toml", ".ini", ".cfg",
10
+ ".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h",
11
+ ]);
12
+
13
+ const isContentFile = (relativePath = "") => {
14
+ const ext = path.extname(String(relativePath || "")).toLowerCase();
15
+ return kContentFileExtensions.has(ext);
16
+ };
17
+
7
18
  const hashFile = (filePath) => {
8
19
  const buffer = fs.readFileSync(filePath);
9
20
  return crypto.createHash("sha256").update(buffer).digest("hex");
@@ -34,11 +45,21 @@ const buildWorkspaceManifest = (rootDir) => {
34
45
  const normalizedRootDir = path.resolve(String(rootDir || ""));
35
46
  const files = walkFiles(normalizedRootDir);
36
47
  return files.reduce((manifest, filePath) => {
37
- manifest[normalizeRelativePath(normalizedRootDir, filePath)] = hashFile(filePath);
48
+ const stat = fs.statSync(filePath);
49
+ manifest[normalizeRelativePath(normalizedRootDir, filePath)] = {
50
+ hash: hashFile(filePath),
51
+ size: stat.size,
52
+ };
38
53
  return manifest;
39
54
  }, {});
40
55
  };
41
56
 
57
+ const getManifestEntryHash = (entry) =>
58
+ typeof entry === "object" && entry !== null ? String(entry.hash || "") : String(entry || "");
59
+
60
+ const getManifestEntrySize = (entry) =>
61
+ typeof entry === "object" && entry !== null ? Number(entry.size || 0) : 0;
62
+
42
63
  const computeWorkspaceFingerprintFromManifest = (manifest = {}) => {
43
64
  const hash = crypto.createHash("sha256");
44
65
  const entries = Object.entries(manifest).sort(([leftPath], [rightPath]) =>
@@ -46,10 +67,10 @@ const computeWorkspaceFingerprintFromManifest = (manifest = {}) => {
46
67
  );
47
68
 
48
69
  hash.update("workspace-fingerprint-v1");
49
- for (const [relativePath, fileHash] of entries) {
70
+ for (const [relativePath, entry] of entries) {
50
71
  hash.update(relativePath);
51
72
  hash.update("\0");
52
- hash.update(fileHash);
73
+ hash.update(getManifestEntryHash(entry));
53
74
  hash.update("\0");
54
75
  }
55
76
 
@@ -84,6 +105,20 @@ const getPathChangeWeight = (relativePath = "") => {
84
105
  return 1;
85
106
  };
86
107
 
108
+ const kByteDeltaSmallThreshold = 100;
109
+ const kByteDeltaSignificantThreshold = 500;
110
+
111
+ const getModifiedFileScore = (relativePath, previousEntry, currentEntry) => {
112
+ if (!isContentFile(relativePath)) return 1;
113
+ const previousSize = getManifestEntrySize(previousEntry);
114
+ const currentSize = getManifestEntrySize(currentEntry);
115
+ if (!previousSize && !currentSize) return getPathChangeWeight(relativePath);
116
+ const byteDelta = Math.abs(currentSize - previousSize);
117
+ if (byteDelta < kByteDeltaSmallThreshold) return 1;
118
+ if (byteDelta < kByteDeltaSignificantThreshold) return 2;
119
+ return getPathChangeWeight(relativePath);
120
+ };
121
+
87
122
  const calculateWorkspaceDelta = ({ previousManifest = {}, currentManifest = {} } = {}) => {
88
123
  const previousPaths = Object.keys(previousManifest);
89
124
  const currentPaths = Object.keys(currentManifest);
@@ -100,19 +135,23 @@ const calculateWorkspaceDelta = ({ previousManifest = {}, currentManifest = {} }
100
135
  };
101
136
 
102
137
  for (const relativePath of allPaths) {
103
- const previousHash = previousManifest[relativePath] || "";
104
- const currentHash = currentManifest[relativePath] || "";
138
+ const previousEntry = previousManifest[relativePath];
139
+ const currentEntry = currentManifest[relativePath];
140
+ const previousHash = getManifestEntryHash(previousEntry);
141
+ const currentHash = getManifestEntryHash(currentEntry);
105
142
  if (!previousHash && currentHash) {
106
143
  changeSummary.addedFilesCount += 1;
144
+ changeSummary.deltaScore += getPathChangeWeight(relativePath);
107
145
  } else if (previousHash && !currentHash) {
108
146
  changeSummary.removedFilesCount += 1;
147
+ changeSummary.deltaScore += getPathChangeWeight(relativePath);
109
148
  } else if (previousHash !== currentHash) {
110
149
  changeSummary.modifiedFilesCount += 1;
150
+ changeSummary.deltaScore += getModifiedFileScore(relativePath, previousEntry, currentEntry);
111
151
  } else {
112
152
  continue;
113
153
  }
114
154
  changeSummary.changedFilesCount += 1;
115
- changeSummary.deltaScore += getPathChangeWeight(relativePath);
116
155
  changeSummary.changedPaths.push(relativePath);
117
156
  }
118
157
 
@@ -123,4 +162,5 @@ module.exports = {
123
162
  calculateWorkspaceDelta,
124
163
  computeWorkspaceFingerprintFromManifest,
125
164
  computeWorkspaceSnapshot,
165
+ isContentFile,
126
166
  };
@@ -109,6 +109,7 @@ const registerDoctorRoutes = ({ app, requireAuth, doctorService }) => {
109
109
  sessionId: req.body?.sessionId,
110
110
  replyChannel: req.body?.replyChannel,
111
111
  replyTo: req.body?.replyTo,
112
+ prompt: req.body?.prompt,
112
113
  });
113
114
  return res.json(result);
114
115
  } catch (error) {
@@ -20,6 +20,7 @@ const registerSystemRoutes = ({
20
20
  onExpectedGatewayRestart,
21
21
  OPENCLAW_DIR,
22
22
  restartRequiredState,
23
+ topicRegistry,
23
24
  }) => {
24
25
  let envRestartPending = false;
25
26
  const kEnvVarsReservedForUserInput = new Set([
@@ -73,6 +74,19 @@ const registerSystemRoutes = ({
73
74
  if (telegramMatch) {
74
75
  return `Telegram ${telegramMatch[1]}`;
75
76
  }
77
+ const telegramTopicMatch = key.match(/:telegram:group:([^:]+):topic:([^:]+)$/);
78
+ if (telegramTopicMatch) {
79
+ const [, groupId, topicId] = telegramTopicMatch;
80
+ let groupEntry = null;
81
+ try {
82
+ groupEntry = topicRegistry?.getGroup?.(groupId) || null;
83
+ } catch {}
84
+ const groupName = String(groupEntry?.name || "").trim();
85
+ const topicName = String(groupEntry?.topics?.[topicId]?.name || "").trim();
86
+ if (groupName && topicName) return `Telegram ${groupName} · ${topicName}`;
87
+ if (topicName) return `Telegram Topic ${topicName}`;
88
+ return `Telegram Topic ${topicId}`;
89
+ }
76
90
  const directMatch = key.match(/:direct:([^:]+)$/);
77
91
  if (directMatch) {
78
92
  return `Direct ${directMatch[1]}`;
package/lib/server.js CHANGED
@@ -43,6 +43,7 @@ const {
43
43
  getSessionDetail,
44
44
  getSessionTimeSeries,
45
45
  } = require("./server/db/usage");
46
+ const topicRegistry = require("./server/topic-registry");
46
47
  const {
47
48
  initDoctorDb,
48
49
  listDoctorRuns,
@@ -239,6 +240,7 @@ registerSystemRoutes({
239
240
  onExpectedGatewayRestart: () => watchdog.onExpectedRestart(),
240
241
  OPENCLAW_DIR: constants.OPENCLAW_DIR,
241
242
  restartRequiredState,
243
+ topicRegistry,
242
244
  });
243
245
  registerBrowseRoutes({
244
246
  app,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.4.6-beta.0",
3
+ "version": "0.4.6-beta.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },