@recallkit/web 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/next-env.d.ts +6 -0
  2. package/next.config.ts +13 -0
  3. package/package.json +40 -0
  4. package/public/logo.png +0 -0
  5. package/public/textures/bg-scene.png +0 -0
  6. package/src/app/api/_lib/guards.ts +35 -0
  7. package/src/app/api/_lib/limits.ts +6 -0
  8. package/src/app/api/_lib/responses.ts +9 -0
  9. package/src/app/api/commit/complete/route.ts +112 -0
  10. package/src/app/api/commit/preview/route.ts +71 -0
  11. package/src/app/api/commit/route.ts +16 -0
  12. package/src/app/api/memory-cache/route.ts +50 -0
  13. package/src/app/api/pending/[id]/delete/route.ts +21 -0
  14. package/src/app/api/pending/[id]/route.ts +47 -0
  15. package/src/app/api/pending/route.ts +41 -0
  16. package/src/app/api/security.ts +25 -0
  17. package/src/app/api/status/route.ts +35 -0
  18. package/src/app/dashboard/page.tsx +57 -0
  19. package/src/app/drafts/page.tsx +5 -0
  20. package/src/app/globals.css +10 -0
  21. package/src/app/icon.png +0 -0
  22. package/src/app/layout.tsx +43 -0
  23. package/src/app/page.tsx +132 -0
  24. package/src/app/settings/page.tsx +76 -0
  25. package/src/components/ArrowRightIcon.tsx +25 -0
  26. package/src/components/CommitPreview.tsx +156 -0
  27. package/src/components/CopyValue.tsx +49 -0
  28. package/src/components/MemoryInbox.tsx +74 -0
  29. package/src/components/RetrievedMemories.tsx +36 -0
  30. package/src/components/TopNav.tsx +39 -0
  31. package/src/components/WalletConnectButton.tsx +68 -0
  32. package/src/components/inbox/EmptyInbox.tsx +20 -0
  33. package/src/components/inbox/InboxStats.tsx +41 -0
  34. package/src/components/inbox/MemoryCandidateList.tsx +90 -0
  35. package/src/components/inbox/MemoryCandidateRow.tsx +195 -0
  36. package/src/components/memory-cache/CachedMemoryList.tsx +47 -0
  37. package/src/components/memory-cache/EmptyCache.tsx +13 -0
  38. package/src/hooks/useCommitFlow.ts +55 -0
  39. package/src/hooks/useMemoryCache.ts +44 -0
  40. package/src/hooks/usePendingMemories.ts +137 -0
  41. package/src/hooks/useWallet.ts +69 -0
  42. package/src/lib/api.ts +71 -0
  43. package/src/lib/wallet.ts +88 -0
  44. package/src/services/commitMemories.ts +153 -0
  45. package/src/services/signerApi.ts +67 -0
  46. package/src/services/types.ts +22 -0
  47. package/src/stores/appStore.ts +18 -0
  48. package/src/stores/createStore.ts +41 -0
  49. package/src/stores/slices/memoryCacheSlice.ts +29 -0
  50. package/src/stores/slices/pendingMemorySlice.ts +21 -0
  51. package/src/stores/slices/walletSlice.ts +24 -0
  52. package/src/styles/base.css +61 -0
  53. package/src/styles/buttons.css +53 -0
  54. package/src/styles/data-display.css +485 -0
  55. package/src/styles/forms.css +86 -0
  56. package/src/styles/landing.css +75 -0
  57. package/src/styles/layout.css +111 -0
  58. package/src/styles/navigation.css +121 -0
  59. package/src/styles/overlays.css +65 -0
  60. package/src/styles/tokens.css +26 -0
  61. package/src/styles/utilities.css +358 -0
  62. package/src/utils/errors.ts +5 -0
  63. package/src/utils/format.ts +37 -0
  64. package/tsconfig.json +44 -0
@@ -0,0 +1,20 @@
1
+ export function EmptyInbox() {
2
+ return (
3
+ <div className="tile">
4
+ <div style={{ color: "var(--ink)", fontSize: 14.5, fontWeight: 500 }}>
5
+ No memory drafts yet.
6
+ </div>
7
+ <div className="body-dim" style={{ marginTop: 6 }}>
8
+ Memory drafts show up here when an agent runs the{" "}
9
+ <code style={{ color: "var(--accent)", fontFamily: "var(--font-mono), monospace" }}>
10
+ suggest-memory
11
+ </code>{" "}
12
+ skill script. Try this in your agent session:
13
+ </div>
14
+ <code className="snippet">
15
+ <span className="snippet__prompt">›</span>
16
+ Remember that I prefer wallet-native Arkiv writes and batch memory commits.
17
+ </code>
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,41 @@
1
+ import type { PendingMemoryCandidate } from "@recallkit/core/schemas";
2
+
3
+ type Props = {
4
+ pending: PendingMemoryCandidate[];
5
+ selectedCount: number;
6
+ };
7
+
8
+ export function InboxStats({ pending, selectedCount }: Props) {
9
+ const highest = pending.length
10
+ ? Math.max(...pending.map((c) => c.importance)).toFixed(2)
11
+ : "none";
12
+
13
+ return (
14
+ <div className="stat-grid">
15
+ <div className="stat-grid__cell">
16
+ <div className="stat-grid__label">Drafts</div>
17
+ <div className="stat-grid__value">{pending.length}</div>
18
+ </div>
19
+ <div className="stat-grid__cell">
20
+ <div className="stat-grid__label">Selected</div>
21
+ <div className="stat-grid__value stat-grid__value--accent">{selectedCount}</div>
22
+ </div>
23
+ <div className="stat-grid__cell">
24
+ <div className="stat-grid__label">Kinds</div>
25
+ <div className="stat-grid__value stat-grid__value--mono">
26
+ {kindSummary(pending) || "none"}
27
+ </div>
28
+ </div>
29
+ <div className="stat-grid__cell">
30
+ <div className="stat-grid__label">Top importance</div>
31
+ <div className="stat-grid__value">{highest}</div>
32
+ </div>
33
+ </div>
34
+ );
35
+ }
36
+
37
+ function kindSummary(pending: PendingMemoryCandidate[]): string {
38
+ if (pending.length === 0) return "";
39
+ const kinds = new Set(pending.map((c) => c.kind));
40
+ return [...kinds].slice(0, 3).join(", ");
41
+ }
@@ -0,0 +1,90 @@
1
+ import { normalizeMemoryScope } from "@recallkit/core/memory";
2
+ import type { PendingMemoryCandidate } from "@recallkit/core/schemas";
3
+ import { MemoryCandidateRow } from "./MemoryCandidateRow";
4
+
5
+ type Props = {
6
+ candidates: PendingMemoryCandidate[];
7
+ selectedIds: Set<string>;
8
+ onToggle: (id: string) => void;
9
+ onChange: (candidate: PendingMemoryCandidate) => void;
10
+ onSave: (candidate: PendingMemoryCandidate) => void;
11
+ onApprove: (candidate: PendingMemoryCandidate) => void;
12
+ onReject: (id: string) => void;
13
+ };
14
+
15
+ export function MemoryCandidateList({
16
+ candidates,
17
+ selectedIds,
18
+ onToggle,
19
+ onChange,
20
+ onSave,
21
+ onApprove,
22
+ onReject,
23
+ }: Props) {
24
+ const selectableCandidates = candidates.filter(isSelectableCandidate);
25
+ const total = candidates.length;
26
+ const selectableTotal = selectableCandidates.length;
27
+ const selectedSelectableCount = selectableCandidates.filter((candidate) =>
28
+ selectedIds.has(candidate.id),
29
+ ).length;
30
+ const allSelected = selectableTotal > 0 && selectedSelectableCount === selectableTotal;
31
+ const someSelected = selectedSelectableCount > 0 && !allSelected;
32
+
33
+ const toggleAll = () => {
34
+ if (allSelected) {
35
+ selectableCandidates.forEach((candidate) => {
36
+ if (selectedIds.has(candidate.id)) onToggle(candidate.id);
37
+ });
38
+ return;
39
+ }
40
+
41
+ selectableCandidates.forEach((candidate) => {
42
+ if (!selectedIds.has(candidate.id)) onToggle(candidate.id);
43
+ });
44
+ };
45
+
46
+ return (
47
+ <div className="candidate-list">
48
+ <div className="candidate-list__toolbar">
49
+ <label className="candidate-list__select-all">
50
+ <input
51
+ type="checkbox"
52
+ className="check"
53
+ checked={allSelected}
54
+ ref={(el) => {
55
+ if (el) el.indeterminate = someSelected;
56
+ }}
57
+ onChange={toggleAll}
58
+ aria-label={allSelected ? "Deselect all" : "Select all"}
59
+ />
60
+ <span>
61
+ {selectedSelectableCount > 0
62
+ ? `${selectedSelectableCount} of ${selectableTotal} selected`
63
+ : `${total} candidate${total === 1 ? "" : "s"}`}
64
+ </span>
65
+ </label>
66
+ <span className="candidate-list__sort">Sorted by importance ↓</span>
67
+ </div>
68
+
69
+ <div className="candidate-list__items">
70
+ {candidates.map((candidate) => (
71
+ <MemoryCandidateRow
72
+ key={candidate.id}
73
+ candidate={candidate}
74
+ selected={selectedIds.has(candidate.id)}
75
+ selectable={isSelectableCandidate(candidate)}
76
+ onToggle={() => onToggle(candidate.id)}
77
+ onChange={onChange}
78
+ onSave={() => onSave(candidate)}
79
+ onApprove={() => onApprove(candidate)}
80
+ onReject={() => onReject(candidate.id)}
81
+ />
82
+ ))}
83
+ </div>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ function isSelectableCandidate(candidate: PendingMemoryCandidate): boolean {
89
+ return candidate.status === "approved" && normalizeMemoryScope(candidate.scope).level !== "session";
90
+ }
@@ -0,0 +1,195 @@
1
+ import { normalizeMemoryScope, reasonForScope } from "@recallkit/core/memory";
2
+ import type { MemoryScopeLevel, PendingMemoryCandidate } from "@recallkit/core/schemas";
3
+ import { formatScopeLabel, formatTimeAgo, percent } from "@/utils/format";
4
+
5
+ type Props = {
6
+ candidate: PendingMemoryCandidate;
7
+ selected: boolean;
8
+ selectable: boolean;
9
+ onToggle: () => void;
10
+ onChange: (candidate: PendingMemoryCandidate) => void;
11
+ onSave: () => void;
12
+ onApprove: () => void;
13
+ onReject: () => void;
14
+ };
15
+
16
+ const SCOPE_OPTIONS: Array<{ level: MemoryScopeLevel; label: string }> = [
17
+ { level: "global", label: "Global" },
18
+ { level: "project", label: "Current Project" },
19
+ { level: "session", label: "Session" },
20
+ ];
21
+
22
+ export function MemoryCandidateRow({
23
+ candidate,
24
+ selected,
25
+ selectable,
26
+ onToggle,
27
+ onChange,
28
+ onSave,
29
+ onApprove,
30
+ onReject,
31
+ }: Props) {
32
+ const importancePct = percent(candidate.importance);
33
+ const confidencePct = percent(candidate.confidence);
34
+ const scope = normalizeMemoryScope(candidate.scope);
35
+
36
+ function updateScopeLevel(level: MemoryScopeLevel) {
37
+ const nextScope = normalizeMemoryScope({
38
+ ...scope,
39
+ level,
40
+ ...(level === "project" && scope.projectId ? { projectId: scope.projectId } : {}),
41
+ });
42
+ onChange({ ...candidate, scope: nextScope, scopeReason: reasonForScope(nextScope) });
43
+ }
44
+
45
+ function updateProjectId(projectId: string) {
46
+ const nextScope = normalizeMemoryScope({ ...scope, level: "project", projectId });
47
+ onChange({ ...candidate, scope: nextScope, scopeReason: reasonForScope(nextScope) });
48
+ }
49
+
50
+ return (
51
+ <article className={`candidate${selected ? " is-selected" : ""}`}>
52
+ <div className="candidate__gutter">
53
+ <input
54
+ type="checkbox"
55
+ className="check"
56
+ checked={selected}
57
+ disabled={!selectable}
58
+ onChange={onToggle}
59
+ aria-label={selectable ? "Select memory" : "Session memory stays local"}
60
+ />
61
+ </div>
62
+
63
+ <div className="candidate__body">
64
+ <header className="candidate__head">
65
+ <div className="candidate__tags">
66
+ <span className={`kind-pill kind-pill--${candidate.kind}`}>
67
+ <span className="kind-pill__dot" aria-hidden="true" />
68
+ {candidate.kind}
69
+ </span>
70
+ <span className="scope-chip">{formatScopeLabel(scope)}</span>
71
+ <span className="tag-chip">{candidate.status}</span>
72
+ {candidate.tags.slice(0, 4).map((tag) => (
73
+ <span key={tag} className="tag-chip">
74
+ #{tag}
75
+ </span>
76
+ ))}
77
+ {candidate.tags.length > 4 ? (
78
+ <span className="tag-chip tag-chip--muted">+{candidate.tags.length - 4}</span>
79
+ ) : null}
80
+ </div>
81
+ <div className="candidate__attribution">
82
+ {candidate.createdByAgent ? (
83
+ <span className="candidate__agent">{candidate.createdByAgent}</span>
84
+ ) : null}
85
+ <time
86
+ className="candidate__time"
87
+ dateTime={candidate.createdAt}
88
+ title={new Date(candidate.createdAt).toLocaleString()}
89
+ >
90
+ {formatTimeAgo(candidate.createdAt)}
91
+ </time>
92
+ </div>
93
+ </header>
94
+
95
+ <textarea
96
+ className="candidate-text"
97
+ value={candidate.text}
98
+ onChange={(event) => onChange({ ...candidate, text: event.target.value })}
99
+ />
100
+
101
+ <div className="candidate-scope">
102
+ <div className="candidate-scope__head">
103
+ <span className="candidate__reason-label">Scope</span>
104
+ <span className="candidate-scope__value">{formatScopeLabel(scope)}</span>
105
+ </div>
106
+ <div className="scope-segment" role="group" aria-label="Memory scope">
107
+ {SCOPE_OPTIONS.map((option) => (
108
+ <button
109
+ key={option.level}
110
+ type="button"
111
+ className={scope.level === option.level ? "scope-segment__button is-active" : "scope-segment__button"}
112
+ onClick={() => updateScopeLevel(option.level)}
113
+ >
114
+ {option.label}
115
+ </button>
116
+ ))}
117
+ </div>
118
+ {scope.level === "project" ? (
119
+ <label className="candidate-scope__project">
120
+ <span>Project</span>
121
+ <input
122
+ value={scope.projectId ?? ""}
123
+ onChange={(event) => updateProjectId(event.target.value)}
124
+ placeholder="recallkit"
125
+ />
126
+ </label>
127
+ ) : null}
128
+ {candidate.scopeReason ? <p className="candidate-scope__reason">{candidate.scopeReason}</p> : null}
129
+ {scope.level === "session" ? (
130
+ <p className="candidate-scope__reason">Session memories stay local and are skipped by Arkiv commits.</p>
131
+ ) : null}
132
+ </div>
133
+
134
+ {candidate.reason ? (
135
+ <div className="candidate__reason">
136
+ <span className="candidate__reason-label">Reason</span>
137
+ <p>{candidate.reason}</p>
138
+ </div>
139
+ ) : null}
140
+
141
+ <footer className="candidate__foot">
142
+ <div className="candidate__scores">
143
+ <ScoreRow label="Importance" value={candidate.importance} pct={importancePct} />
144
+ <ScoreRow
145
+ label="Confidence"
146
+ value={candidate.confidence}
147
+ pct={confidencePct}
148
+ variant="dim"
149
+ />
150
+ </div>
151
+
152
+ <div className="candidate__actions">
153
+ <button type="button" className="row-action" onClick={onSave}>
154
+ Save edits
155
+ </button>
156
+ {candidate.status !== "approved" && scope.level !== "session" ? (
157
+ <button type="button" className="row-action" onClick={onApprove}>
158
+ Approve
159
+ </button>
160
+ ) : null}
161
+ <button
162
+ type="button"
163
+ className="row-action row-action--danger"
164
+ onClick={onReject}
165
+ >
166
+ Reject
167
+ </button>
168
+ </div>
169
+ </footer>
170
+ </div>
171
+ </article>
172
+ );
173
+ }
174
+
175
+ function ScoreRow({
176
+ label,
177
+ value,
178
+ pct,
179
+ variant,
180
+ }: {
181
+ label: string;
182
+ value: number;
183
+ pct: number;
184
+ variant?: "dim";
185
+ }) {
186
+ return (
187
+ <div className="score">
188
+ <span className="score__label">{label}</span>
189
+ <span className={variant === "dim" ? "bar bar--dim" : "bar"}>
190
+ <span className="bar__fill" style={{ width: `${pct}%` }} />
191
+ </span>
192
+ <span className="score__value">{value.toFixed(2)}</span>
193
+ </div>
194
+ );
195
+ }
@@ -0,0 +1,47 @@
1
+ import type { CachedMemory } from "@/stores/slices/memoryCacheSlice";
2
+ import { percent } from "@/utils/format";
3
+
4
+ type Props = {
5
+ memories: CachedMemory[];
6
+ };
7
+
8
+ export function CachedMemoryList({ memories }: Props) {
9
+ return (
10
+ <div className="list">
11
+ <div className="list__head">
12
+ <span>{memories.length} cached memories</span>
13
+ <span>top by retrieval score</span>
14
+ </div>
15
+ {memories.map((memory) => (
16
+ <div key={memory.id} className="list__row list__row--simple">
17
+ <div style={{ flex: 1, minWidth: 0 }}>
18
+ <div style={{ color: "var(--ink)", fontSize: 14.5, fontWeight: 500 }}>
19
+ {memory.summary}
20
+ </div>
21
+ <div className="meta">
22
+ <span>{memory.kind}</span>
23
+ <span className="meta__sep">·</span>
24
+ <span>
25
+ importance <ScoreBar value={memory.retrieval.importance} />{" "}
26
+ {memory.retrieval.importance.toFixed(2)}
27
+ </span>
28
+ <span className="meta__sep">·</span>
29
+ <span>
30
+ confidence <ScoreBar value={memory.evidence.confidence} dim />{" "}
31
+ {memory.evidence.confidence.toFixed(2)}
32
+ </span>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ ))}
37
+ </div>
38
+ );
39
+ }
40
+
41
+ function ScoreBar({ value, dim = false }: { value: number; dim?: boolean }) {
42
+ return (
43
+ <span className={dim ? "bar bar--dim" : "bar"}>
44
+ <span className="bar__fill" style={{ width: `${percent(value)}%` }} />
45
+ </span>
46
+ );
47
+ }
@@ -0,0 +1,13 @@
1
+ type Props = {
2
+ loaded: boolean;
3
+ };
4
+
5
+ export function EmptyCache({ loaded }: Props) {
6
+ return (
7
+ <div className="tile">
8
+ <div className="body-dim">
9
+ {loaded ? "Cache is empty." : "No local cache loaded."}
10
+ </div>
11
+ </div>
12
+ );
13
+ }
@@ -0,0 +1,55 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+ import { commitSelectedMemories } from "@/services/commitMemories";
5
+ import { buildCommitPreview } from "@/services/signerApi";
6
+ import type { CommitEntityKeys, CommitPreviewData } from "@/services/types";
7
+ import { readError } from "@/utils/errors";
8
+
9
+ export function useCommitFlow(selectedIds: string[]) {
10
+ const [preview, setPreview] = useState<CommitPreviewData | undefined>();
11
+ const [passphrase, setPassphrase] = useState("");
12
+ const [busy, setBusy] = useState(false);
13
+ const [error, setError] = useState<string | undefined>();
14
+ const [entityKeys, setEntityKeys] = useState<CommitEntityKeys | undefined>();
15
+
16
+ const loadPreview = useCallback(async () => {
17
+ setBusy(true);
18
+ setError(undefined);
19
+ try {
20
+ setPreview(await buildCommitPreview(selectedIds));
21
+ } catch (err) {
22
+ setError(readError(err, "Unable to build commit preview."));
23
+ } finally {
24
+ setBusy(false);
25
+ }
26
+ }, [selectedIds]);
27
+
28
+ const commit = useCallback(async () => {
29
+ if (!preview) return false;
30
+ setBusy(true);
31
+ setError(undefined);
32
+ try {
33
+ const keys = await commitSelectedMemories({ preview, passphrase, selectedIds });
34
+ setEntityKeys(keys);
35
+ return true;
36
+ } catch (err) {
37
+ setError(readError(err, "Commit failed."));
38
+ return false;
39
+ } finally {
40
+ setBusy(false);
41
+ }
42
+ }, [passphrase, preview, selectedIds]);
43
+
44
+ return {
45
+ preview,
46
+ passphrase,
47
+ busy,
48
+ error,
49
+ entityKeys,
50
+ canCommit: Boolean(preview && preview.memories.length > 0) && passphrase.length >= 8 && !busy,
51
+ setPassphrase,
52
+ loadPreview,
53
+ commit,
54
+ };
55
+ }
@@ -0,0 +1,44 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import { getMemoryCache } from "@/services/signerApi";
5
+ import { appStore, useAppStore } from "@/stores/appStore";
6
+ import { readError } from "@/utils/errors";
7
+
8
+ export function useMemoryCache() {
9
+ const memoryCache = useAppStore((state) => state.memoryCache);
10
+
11
+ const load = useCallback(async () => {
12
+ const current = appStore.getState().memoryCache;
13
+ if (current.loading) return;
14
+
15
+ appStore.setState((state) => ({
16
+ memoryCache: { ...state.memoryCache, loading: true, error: undefined },
17
+ }));
18
+
19
+ try {
20
+ const packs = await getMemoryCache();
21
+ appStore.setState((state) => ({
22
+ memoryCache: {
23
+ ...state.memoryCache,
24
+ memories: packs.flatMap((pack) => pack.memories).slice(0, 8),
25
+ loaded: true,
26
+ loading: false,
27
+ },
28
+ }));
29
+ } catch (error) {
30
+ appStore.setState((state) => ({
31
+ memoryCache: {
32
+ ...state.memoryCache,
33
+ loading: false,
34
+ error: readError(error, "Unable to load cache."),
35
+ },
36
+ }));
37
+ }
38
+ }, []);
39
+
40
+ return {
41
+ ...memoryCache,
42
+ load,
43
+ };
44
+ }
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo } from "react";
4
+ import { normalizeMemoryScope } from "@recallkit/core/memory";
5
+ import type { PendingMemoryCandidate } from "@recallkit/core/schemas";
6
+ import {
7
+ deletePendingMemory,
8
+ getPendingMemories,
9
+ updatePendingMemory,
10
+ } from "@/services/signerApi";
11
+ import { appStore, useAppStore } from "@/stores/appStore";
12
+ import { readError } from "@/utils/errors";
13
+
14
+ export function usePendingMemories() {
15
+ const pendingMemory = useAppStore((state) => state.pendingMemory);
16
+ const selectedSet = useMemo(() => new Set(pendingMemory.selectedIds), [pendingMemory.selectedIds]);
17
+ const committableSelectedCount = useMemo(
18
+ () =>
19
+ pendingMemory.items.filter((item) =>
20
+ pendingMemory.selectedIds.includes(item.id) && isCommittableDraft(item),
21
+ ).length,
22
+ [pendingMemory.items, pendingMemory.selectedIds],
23
+ );
24
+
25
+ const refresh = useCallback(async (options: { force?: boolean } = {}) => {
26
+ if (appStore.getState().pendingMemory.refreshing) return;
27
+
28
+ appStore.setState((state) => ({
29
+ pendingMemory: { ...state.pendingMemory, refreshing: true, error: undefined },
30
+ }));
31
+
32
+ try {
33
+ const items = await getPendingMemories(options.force ? { force: true } : {});
34
+ const validIds = new Set(items.map((item) => item.id));
35
+ appStore.setState((state) => ({
36
+ pendingMemory: {
37
+ ...state.pendingMemory,
38
+ items,
39
+ selectedIds: state.pendingMemory.selectedIds.filter((id) => validIds.has(id)),
40
+ refreshing: false,
41
+ loaded: true,
42
+ },
43
+ }));
44
+ } catch (error) {
45
+ appStore.setState((state) => ({
46
+ pendingMemory: {
47
+ ...state.pendingMemory,
48
+ refreshing: false,
49
+ error: readError(error, "Unable to load pending memories."),
50
+ },
51
+ }));
52
+ }
53
+ }, []);
54
+
55
+ useEffect(() => {
56
+ if (pendingMemory.loaded || pendingMemory.refreshing) return;
57
+ void refresh();
58
+ }, [pendingMemory.loaded, pendingMemory.refreshing, refresh]);
59
+
60
+ const toggle = useCallback((id: string) => {
61
+ appStore.setState((state) => {
62
+ const candidate = state.pendingMemory.items.find((item) => item.id === id);
63
+ if (candidate && !isCommittableDraft(candidate)) return state;
64
+ const selectedIds = state.pendingMemory.selectedIds.includes(id)
65
+ ? state.pendingMemory.selectedIds.filter((selectedId) => selectedId !== id)
66
+ : [...state.pendingMemory.selectedIds, id];
67
+
68
+ return {
69
+ pendingMemory: { ...state.pendingMemory, selectedIds },
70
+ };
71
+ });
72
+ }, []);
73
+
74
+ const patchCandidate = useCallback((candidate: PendingMemoryCandidate) => {
75
+ appStore.setState((state) => ({
76
+ pendingMemory: {
77
+ ...state.pendingMemory,
78
+ items: state.pendingMemory.items.map((item) =>
79
+ item.id === candidate.id ? candidate : item,
80
+ ),
81
+ },
82
+ }));
83
+ }, []);
84
+
85
+ const save = useCallback(
86
+ async (candidate: PendingMemoryCandidate) => {
87
+ await updatePendingMemory(candidate);
88
+ await refresh({ force: true });
89
+ },
90
+ [refresh],
91
+ );
92
+
93
+ const approve = useCallback(
94
+ async (candidate: PendingMemoryCandidate) => {
95
+ await updatePendingMemory({ ...candidate, status: "approved" });
96
+ await refresh({ force: true });
97
+ },
98
+ [refresh],
99
+ );
100
+
101
+ const reject = useCallback(
102
+ async (id: string) => {
103
+ await deletePendingMemory(id);
104
+ await refresh({ force: true });
105
+ },
106
+ [refresh],
107
+ );
108
+
109
+ const clearSelection = useCallback(() => {
110
+ appStore.setState((state) => ({
111
+ pendingMemory: { ...state.pendingMemory, selectedIds: [] },
112
+ }));
113
+ }, []);
114
+
115
+ const sortedItems = useMemo(
116
+ () => [...pendingMemory.items].sort((a, b) => b.importance - a.importance),
117
+ [pendingMemory.items],
118
+ );
119
+
120
+ return {
121
+ ...pendingMemory,
122
+ selectedSet,
123
+ sortedItems,
124
+ committableSelectedCount,
125
+ refresh,
126
+ toggle,
127
+ patchCandidate,
128
+ save,
129
+ approve,
130
+ reject,
131
+ clearSelection,
132
+ };
133
+ }
134
+
135
+ function isCommittableDraft(candidate: PendingMemoryCandidate): boolean {
136
+ return candidate.status === "approved" && normalizeMemoryScope(candidate.scope).level !== "session";
137
+ }