claude-memory-admin 1.5.2 → 1.6.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 CHANGED
@@ -21,23 +21,23 @@ itself, and worth saying out loud.
21
21
 
22
22
  ---
23
23
 
24
- ### Prune: see what MEMORY.md actually costs you
24
+ ### Cleanup: what MEMORY.md costs you and what is broken, in one list
25
25
 
26
- ![The Prune tab, showing how much of the MEMORY.md load limit a project uses and a sortable list of memories by age, size and inbound links](assets/prune.webp)
26
+ ![The Cleanup tab: the MEMORY.md load meter, then one worst-first list beneath it - a broken wikilink with Remove link, two over-long index hooks and two that only restate the description, each with its own Edit hook button, an empty section, and an orphan offered the MEMORY.md bullet it is missing](assets/cleanup.webp)
27
27
 
28
- ### Health: every problem found, worst first
28
+ ### Memory: the list, the index and the graph
29
29
 
30
- ![The Health tab in the light theme, listing checks ranked by severity: a broken wikilink, a memory no index entry points at, index hooks over 200 characters, hooks that only restate the file's own description, and an empty section](assets/health.webp)
30
+ ![The Memory tab, List segment: memories grouped by their MEMORY.md section with age, size and inbound link count on each, a sort control and a Select toggle above them, and the opened memory beside the list with its metadata, its swept origin transcript struck through, and its wikilinks](assets/memory.webp)
31
31
 
32
32
  ### Graph: see how memories link to each other
33
33
 
34
- ![The Graph tab, showing memories as nodes coloured by type with wikilinks as edges, orphans below a dashed line and a dashed outline where a link points at nothing](assets/graph.webp)
34
+ ![The Graph segment of the Memory tab, showing memories as nodes coloured by type with wikilinks as edges, orphans below a dashed line and a dashed outline where a link points at nothing](assets/graph.webp)
35
35
 
36
- ### Context: what every session loads before a project is even chosen
36
+ ### Environment: what every session loads before a project is even chosen
37
37
 
38
- ![The Global entry's Context tab, showing the estimated token cost of the user-scope instruction files, a file reached twice through both an import and the rules directory, and the order the files load in](assets/context.webp)
38
+ ![The Global entry, whose only tab is Environment, showing the estimated token cost of the user-scope instruction files, a rule file reached twice through both an import and the rules directory so its tokens are paid for twice, and the order the files load in](assets/context.webp)
39
39
 
40
- There is a light theme and a dark one, toggled with `t`; the Health shot above is
40
+ There is a light theme and a dark one, toggled with `t`; the Memory shot above is
41
41
  the light one and the rest are dark.
42
42
 
43
43
  ---
@@ -77,6 +77,17 @@ project is to the cliff, and makes it quick to get back under it.
77
77
 
78
78
  ## What it does
79
79
 
80
+ Everything lives under three tabs, each answering one question:
81
+
82
+ | Tab | The question | Holds |
83
+ | --- | --- | --- |
84
+ | **Memory** | what is in here? | the memory list, `MEMORY.md`, the graph |
85
+ | **Cleanup** | what should I fix? | the load meter and one worst-first list of fixable things |
86
+ | **Environment** | what else does Claude load? | instructions, settings, sessions - all read-only |
87
+
88
+ Undo is a button in the project header rather than a fourth tab, because it is a
89
+ safety net and not a place you browse.
90
+
80
91
  - **Projects by real path.** The directories on disk are slugified cwds
81
92
  (`-Users-me-repos-Blog`) and the slugification is lossy. The true path is
82
93
  recovered from the `cwd` field in the session transcripts stored next to each
@@ -104,16 +115,16 @@ project is to the cliff, and makes it quick to get back under it.
104
115
  with snippets and match highlighting. Press `/` to jump to it.
105
116
  - **Read each memory** with its frontmatter as structured metadata and
106
117
  `[[wikilinks]]` as clickable links. Dead links are struck through in red.
107
- - **Prune** (see below).
108
- - **Graph** the wikilinks between memories. Hovering dims everything that is not
109
- a neighbour, which is the only practical way to read a dense cluster.
118
+ - **Cleanup** (see below).
119
+ - **Graph** the wikilinks between memories, under Memory. Hovering dims everything
120
+ that is not a neighbour, which is the only practical way to read a dense cluster.
110
121
  - **Where each memory came from.** Claude Code stamps `originSessionId` into a
111
122
  memory's frontmatter, and the transcript it names sits next to the store until
112
123
  the sweep takes it. The memory reads *written in "Release process notes", on
113
124
  `main`* while that transcript is there, and says so plainly once it is gone:
114
125
  a swept id is struck through in red, like a dead wikilink, because why the
115
126
  memory exists can no longer be traced from anything on disk.
116
- - **Sessions**: the transcripts beside a store, with the retention window drawn
127
+ - **Sessions** (under Environment): the transcripts beside a store, with the retention window drawn
117
128
  the way MEMORY.md's cutoff is - each session a tick, the sweep line where it
118
129
  falls. Under it, a tile per day shades how much work that day held, and
119
130
  clicking one narrows the list to it; the grid spans the retention window
@@ -123,8 +134,8 @@ project is to the cliff, and makes it quick to get back under it.
123
134
  slug and then the opening prompt; a session that names itself nowhere in the
124
135
  part read is shown by id rather than given an invented name. Only the head of
125
136
  each file is ever read, so a 200MB store of transcripts costs a quarter of a
126
- second, and nothing on this tab deletes one.
127
- - **Health**: orphans, dangling pointers, broken wikilinks, files linked only
137
+ second, and nothing here deletes one.
138
+ - **What Cleanup checks**: orphans, dangling pointers, broken wikilinks, files linked only
128
139
  mid-sentence, `name` fields that disagree with the filename, two files claiming
129
140
  one `name`, a file bulleted twice, a blank `description`, a `type` outside the
130
141
  four documented ones, a memory that is frontmatter and little else, a hook that
@@ -133,19 +144,20 @@ project is to the cliff, and makes it quick to get back under it.
133
144
  been swept, a project whose last proof of its own path is about to be, and
134
145
  sessions that produced no memory at all while auto memory was on. An orphan
135
146
  can be given the `MEMORY.md` bullet it is missing without leaving the page.
136
- - **Every count is visible before you click.** Each tab carries a badge, coloured
137
- amber for something to tidy and red for something actively broken, and the
138
- sidebar gives each store a dot of its worst severity - memory, instructions and
139
- settings together. Which project is in trouble is the first thing the app tells
140
- you, not something you find by opening eight tabs each.
147
+ - **Every count is visible before you click.** Each of the three tabs carries a
148
+ badge, coloured amber for something to tidy and red for something actively
149
+ broken, and the sidebar gives each store a dot of its worst severity - memory,
150
+ instructions and settings together. Which project is in trouble is the first
151
+ thing the app tells you, not something you find by opening every tab in turn.
141
152
  - **Dates you can trust.** Claude Code stamps `modified` into frontmatter, but
142
153
  only on files that already have some, and never adds frontmatter to a file
143
154
  without it. Anything falling back to the file's mtime is labelled, because
144
155
  mtime is reset by any copy or restore.
145
- - **Context**: what a session starting in this project would load as
146
- *instructions*, which is the other half of the startup budget (see below).
147
- - **Settings**: every layer Claude Code would read, side by side, with the value
148
- that wins and the ones it shadows (see below).
156
+ - **Instructions** (under Environment): what a session starting in this project
157
+ would load as *instructions*, which is the other half of the startup budget
158
+ (see below).
159
+ - **Settings** (under Environment): every layer Claude Code would read, side by
160
+ side, with the value that wins and the ones it shadows (see below).
149
161
  - **Delete with cascade**, always reversible.
150
162
 
151
163
  Everything above works the same on both kinds of store. The load meter, graph,
@@ -155,17 +167,20 @@ memory directory is a `MEMORY.md` index plus topic files under the same 200-line
155
167
 
156
168
  ## Keeping MEMORY.md small
157
169
 
158
- The **Prune** tab exists because a bloated index costs tokens on every single
159
- session and, past the limit, silently stops loading.
170
+ The **Cleanup** tab exists because a bloated index costs tokens on every single
171
+ session and, past the limit, silently stops loading. It is one list: the load
172
+ meter, then everything worth doing something about, worst first, each row
173
+ carrying its own fix. A finding and its fix are never on different tabs.
160
174
 
161
175
  - **Load meter**. How much of the 200-line / 25KB budget the index uses, and
162
176
  which of the two is binding. Frontmatter and HTML comments are excluded,
163
177
  because Claude Code strips those before loading.
164
- - **The cutoff, drawn where it falls**. Past the limit, the MEMORY.md tab rules a
165
- line across the file and dims everything below it, and Prune names the memories
166
- that stopped being loaded. Because the stripping shifts every line, the cutoff
178
+ - **The cutoff, drawn where it falls**. Past the limit, the MEMORY.md segment rules
179
+ a line across the file and dims everything below it, and Cleanup lists the
180
+ memories that stopped being loaded at the top of the worklist, each with
181
+ **Move up**. Because the stripping shifts every line, the cutoff
167
182
  is mapped back to real line numbers rather than counted in the loaded text. The
168
- tab reads the index either **rendered** — headings, lists and clickable entries,
183
+ segment reads the index either **rendered** — headings, lists and clickable entries,
169
184
  with pointers to files that do not exist struck through — or as **source**, with
170
185
  line numbers. Either way the cutoff is drawn in the same place. The choice is
171
186
  remembered.
@@ -178,16 +193,18 @@ session and, past the limit, silently stops loading.
178
193
  - **Move above the cutoff**. Past the limit, an entry is on disk but invisible.
179
194
  **Move up** relocates its bullet, and the indented lines under it, to the end
180
195
  of any section that starts above the cutoff. It moves one entry rather than
181
- making room, so whatever is now last drops below the line instead - the tab
196
+ making room, so whatever is now last drops below the line instead - the app
182
197
  says so before you do it.
183
198
  - **Possible overlap**. Pairs of memories ranked by shared *rare* vocabulary, to
184
199
  surface the same lesson saved three times from three sessions. It is a hint,
185
- not a verdict. **Merge** folds one into the other: the body moves under a
186
- heading you name, every `[[wikilink]]` that pointed at the source is repointed
187
- rather than broken, a link the survivor had to the source becomes plain text
188
- instead of a self-link, and the two index bullets collapse to one.
189
- - **Bulk prune**. Sort by age, size, or inbound links, tick several, delete them
190
- as one restore point.
200
+ not a verdict, so it sorts last. **Merge** folds one into the other: the body
201
+ moves under a heading you name, every `[[wikilink]]` that pointed at the source
202
+ is repointed rather than broken, a link the survivor had to the source becomes
203
+ plain text instead of a self-link, and the two index bullets collapse to one.
204
+
205
+ Bulk pruning is not here, because it is browsing rather than fixing: the **Memory**
206
+ list sorts by age, size or inbound links, and **Select** turns on the checkboxes so
207
+ several can go as one restore point. One list of memories, not two.
191
208
 
192
209
  Anthropic's own guidance for the index: one line per entry, detail in the topic
193
210
  files, merge or drop stale entries.
@@ -195,13 +212,13 @@ files, merge or drop stale entries.
195
212
  ## The other half of the budget
196
213
 
197
214
  `MEMORY.md` is not the only thing loaded at the start of every session. The
198
- **Context** tab resolves what else is, in load order: managed policy, your
215
+ **Environment** tab's **Instructions** segment resolves what else is, in load order: managed policy, your
199
216
  `~/.claude/CLAUDE.md` and `~/.claude/rules/`, every `CLAUDE.md` and
200
217
  `CLAUDE.local.md` from the filesystem root down to the project, `.claude/CLAUDE.md`,
201
218
  and `.claude/rules/`, with `@path` imports expanded and `claudeMdExcludes` applied.
202
219
 
203
220
  Unlike `MEMORY.md`, none of it is truncated: `CLAUDE.md` files load in full
204
- however long they are. So the tab reports cost rather than a cliff, and separates
221
+ however long they are. So it reports cost rather than a cliff, and separates
205
222
  what every session pays for from the path-scoped rules that only load on a match.
206
223
 
207
224
  It also finds the failures that leave a file silently doing nothing:
@@ -227,7 +244,7 @@ Backticks are respected, so a `` `@README` `` in your prose is not reported as a
227
244
  import, and neither is an email address.
228
245
 
229
246
  Every file listed opens in place, so you can read what actually loads without
230
- leaving the tab. Nothing here is editable: the app never rewrites a `CLAUDE.md`.
247
+ leaving the page. Nothing here is editable: the app never rewrites a `CLAUDE.md`.
231
248
 
232
249
  ### The user scope on its own
233
250
 
@@ -241,7 +258,7 @@ It holds instructions rather than memory, so it is read-only and has no MEMORY.m
241
258
  graph or trash. Search does not reach into it.
242
259
 
243
260
  This is re-derived from the documented resolution rules rather than reported by
244
- Claude Code, and the tab says so. Run `/context` in a session for the ground
261
+ Claude Code, and the app says so. Run `/context` in a session for the ground
245
262
  truth, or the `InstructionsLoaded` hook to log exactly what loaded and why.
246
263
 
247
264
  ## The one check that reads your code
@@ -252,7 +269,7 @@ it takes the paths a memory names in a code span and asks whether anything in th
252
269
  repository still matches them. A memory whose `Foo.cs` was renamed two months ago
253
270
  still reads as authoritative, and nothing else on this page can tell.
254
271
 
255
- Turn it on from the Health tab, per store; the choice is remembered in the browser
272
+ Turn it on from the bottom of the Cleanup tab, per store; the choice is remembered in the browser
256
273
  and nothing is written to disk. It walks the project once, skipping `.git`,
257
274
  `node_modules`, build output and the like, and matches by suffix - a memory that
258
275
  says `Infrastructure/Reporting/Foo.cs` for a file that really lives under
@@ -261,13 +278,14 @@ alone reported seven real paths in ten as missing. Only a token with a real sour
261
278
  extension is treated as a claim about a file, because memories are also full of
262
279
  HTTP routes and type names that no file was ever going to match.
263
280
 
264
- It is a pointer, not a verdict, and the tab says so: a file that moved reads the
281
+ It is a pointer, not a verdict, and the app says so: a file that moved reads the
265
282
  same as one the memory never got right.
266
283
 
267
284
  ## Which settings are actually in force
268
285
 
269
286
  Five files can set the keys this tool cares about, and the one everybody edits,
270
- `~/.claude/settings.json`, is the weakest of them. The **Settings** tab reads all
287
+ `~/.claude/settings.json`, is the weakest of them. The **Environment** tab's
288
+ **Settings** segment reads all
271
289
  five and shows, per key, the value that wins and the ones it shadows, struck
272
290
  through, each labelled with the file it came from:
273
291
 
@@ -282,9 +300,10 @@ It also names the failures that are otherwise silent:
282
300
  - A file that parses but is not an object, or cannot be read at all.
283
301
  - An `autoMemoryDirectory` that is neither absolute nor `~/`-prefixed.
284
302
  - A value Claude Code accepts the key of but not the number, like a
285
- `cleanupPeriodDays` below 1: the tab shows what is written *and* what applies.
303
+ `cleanupPeriodDays` below 1: it shows what is written *and* what applies.
286
304
 
287
- The tab is read-only. It reports what is configured; it never writes a setting.
305
+ Every segment of Environment is read-only. It reports what is configured; it never
306
+ writes a setting.
288
307
 
289
308
  ## Everything it writes is reversible
290
309
 
@@ -297,12 +316,14 @@ operation, and they are trashed and restored as a single step.
297
316
  one restore point. Session transcripts (`*.jsonl`) and the project folder are
298
317
  never touched, only the contents of `memory/`.
299
318
 
300
- **Remove link** in the Health tab clears a `[[wikilink]]` whose target no longer
319
+ **Remove link** in the Cleanup tab clears a `[[wikilink]]` whose target no longer
301
320
  exists. The markup goes and the words stay, so `see [[gone]] for details` becomes
302
321
  `see gone for details`.
303
322
 
304
323
  Everything lands in `memory/.trash/` with a restore record and comes back from the
305
- Trash tab, one undo per operation however many files it touched.
324
+ **Undo** control in the project header, one undo per operation however many files
325
+ it touched. Undo is not a place you browse, so it is a button with a count rather
326
+ than a tab of its own.
306
327
 
307
328
  The app never writes a memory file of its own. It edits `MEMORY.md` a line at a
308
329
  time - a hook rewritten, a bullet added or moved - and each of those keeps every
@@ -378,87 +399,6 @@ No bundler and no build step to run it: the backend is `node:http` plus `node:fs
378
399
  and the frontend is plain ES modules the browser loads directly. Two runtime
379
400
  dependencies, `marked` and `dompurify`, both only for rendering memory bodies safely.
380
401
 
381
- Styling is the one thing that is compiled. `styles/app.css` is the Tailwind v4
382
- source and `public/styles.css` is its committed output, so an installed copy is
383
- ready to serve and never builds anything. After editing the source, run
384
- `npm run build:css` and commit the result — CI rebuilds it and fails on drift.
385
-
386
- | Path | Purpose |
387
- | --- | --- |
388
- | `bin/claude-memory-admin.mjs` | CLI entry point and argument parsing |
389
- | `server.mjs` | HTTP server: static files + JSON API |
390
- | `src/projects.mjs` | Project discovery, slug → real path resolution |
391
- | `src/settings.mjs` | Layered reads of Claude Code's settings files, and the report behind the Settings tab |
392
- | `src/pathcache.mjs` | The opt-in record of confirmed project paths |
393
- | `src/sessions.mjs` | Session transcripts: bounded head reads, retention, provenance |
394
- | `src/stores.mjs` | Store discovery: the user scope, auto memory and the three agent scopes |
395
- | `src/instructions.mjs` | CLAUDE.md chain, `@` imports and rules resolution |
396
- | `src/parse.mjs` | `MEMORY.md` and frontmatter parsers, wikilinks |
397
- | `src/checks.mjs` | The consistency checks, as pure functions over parsed data |
398
- | `src/pathcheck.mjs` | The opt-in check of paths a memory names, against the repo |
399
- | `src/model.mjs` | Joins index, files, graph and health into one model |
400
- | `src/stats.mjs` | Load-limit accounting and overlap detection |
401
- | `src/search.mjs` | Full-text search across every store |
402
- | `src/mutate.mjs` | Delete / edit / merge / restore, the only code that writes |
403
- | `styles/app.css` | Tailwind source, compiled to `public/styles.css` |
404
- | `public/` | Frontend |
405
-
406
- Tests run against committed fixtures under `test/fixtures/`: a projects store
407
- encoding the awkward shapes real memory directories contain, including one index
408
- deliberately past the load limit, an `agents/` tree covering all three subagent
409
- scopes, and an `instructions/` tree covering the import and glob edge cases. Your own
410
- `~/.claude/projects` is additionally checked when it exists, always on a
411
- throwaway copy.
412
-
413
- ### Releasing
414
-
415
- Publishing runs in CI. You do not bump anything by hand.
416
-
417
- Go to **Actions, Release, Run workflow**, pick `patch`, `minor` or `major`, and
418
- run it. The workflow tests, bumps `package.json` and `package-lock.json`, commits
419
- and tags the bump, pushes both, then publishes to npm.
420
-
421
- If you would rather cut the version locally, that still works:
422
-
423
- ```bash
424
- npm version patch
425
- git push --follow-tags
426
- ```
427
-
428
- Pushing a `v*` tag publishes whatever is in `package.json`, after checking the
429
- two agree. Either route refuses to publish a version that is already on npm,
430
- because npm never allows one to be replaced.
431
-
432
- It needs one repository secret:
433
-
434
- | Secret | Value |
435
- | --- | --- |
436
- | `NPM_TOKEN` | an npm **Automation** access token with publish rights |
437
-
438
- Add it under *Settings, Secrets and variables, Actions, New repository secret*.
439
- An Automation token is the right kind because it bypasses 2FA, which an
440
- unattended workflow cannot satisfy.
441
-
442
- Releases are published with [npm provenance](https://docs.npmjs.com/generating-provenance-statements),
443
- so every version carries a signed, verifiable record of the workflow run and
444
- commit that built it. That needs the `id-token: write` permission the workflow
445
- already requests, a public repository, and the `repository` field in
446
- `package.json` pointing at this repo.
447
-
448
- Two things that will bite if they apply to you: the workflow pushes the bump
449
- commit to the default branch, so a branch protection rule that blocks pushes
450
- will stop it; and the tag it pushes uses `GITHUB_TOKEN`, which by design does
451
- not trigger other workflows, so there is no double publish.
452
-
453
- ### Screenshots and demo data
454
-
455
- The screenshots come from an invented store, never a real one:
456
-
457
- ```bash
458
- node scripts/demo-store.mjs /tmp/demo-store
459
- npm start -- --root /tmp/demo-store
460
- ```
461
-
462
402
  ## License
463
403
 
464
404
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory-admin",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Browse, audit and prune the auto memory Claude Code keeps under ~/.claude/projects",
5
5
  "keywords": [
6
6
  "claude",
package/public/app.mjs CHANGED
@@ -1,33 +1,23 @@
1
- import { renderGraph } from '/graph.mjs';
2
1
  import { isDialogOpen } from '/dialog.mjs';
3
2
  import { el, node } from '/dom.mjs';
4
3
  import * as ui from '/ui.mjs';
5
4
  import { state } from '/state.mjs';
6
5
  import { toast } from '/api.mjs';
7
6
  import { register, paint } from '/bus.mjs';
8
- import { openStore, reloadStores, selectMemory } from '/store.mjs';
7
+ import { openStore, reloadStores } from '/store.mjs';
9
8
  import { renderStores } from '/views/stores.mjs';
10
9
  import { renderTabs, renderStoreHeader } from '/views/header.mjs';
11
- import { renderMemories } from '/views/memories.mjs';
12
- import { renderIndex } from '/views/index.mjs';
13
- import { renderContext } from '/views/context.mjs';
14
- import { renderHealth } from '/views/health.mjs';
15
- import { renderSessions } from '/views/sessions.mjs';
16
- import { renderSettings } from '/views/settings.mjs';
17
- import { renderTrash } from '/views/trash.mjs';
18
- import { renderPrune } from '/views/prune.mjs';
10
+ import { renderMemory } from '/views/memory.mjs';
11
+ import { renderCleanup } from '/views/cleanup.mjs';
12
+ import { renderEnvironment } from '/views/environment.mjs';
19
13
  import { renderSearch, scheduleSearch, clearSearch } from '/views/search.mjs';
20
14
  import { openStoreDeleteDialog } from '/dialogs/store-delete.mjs';
15
+ import { openTrashDialog } from '/dialogs/trash.mjs';
21
16
 
22
17
  const TAB_VIEWS = {
23
- memories: renderMemories,
24
- prune: renderPrune,
25
- context: renderContext,
26
- sessions: renderSessions,
27
- index: renderIndex,
28
- health: renderHealth,
29
- settings: renderSettings,
30
- trash: renderTrash,
18
+ memory: renderMemory,
19
+ cleanup: renderCleanup,
20
+ environment: renderEnvironment,
31
21
  };
32
22
 
33
23
  function renderView() {
@@ -42,21 +32,7 @@ function renderTab() {
42
32
  const container = el('tab-content');
43
33
  container.textContent = '';
44
34
  const view = TAB_VIEWS[state.tab];
45
- if (view) return view(container);
46
- if (state.tab !== 'graph') return;
47
-
48
- const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
49
- container.append(wrap);
50
- renderGraph(wrap, state.store.graph, {
51
- selected: state.selected,
52
- spread: state.spread,
53
- onSelect: (file) => selectMemory(file),
54
- onSpreadChange: (value) => {
55
- state.spread = value;
56
- localStorage.setItem('graphSpread', String(value));
57
- paint('tab');
58
- },
59
- });
35
+ if (view) view(container);
60
36
  }
61
37
 
62
38
  function applyCollapsed() {
@@ -89,7 +65,9 @@ function toggleTheme() {
89
65
  function setCollapsed(value) {
90
66
  state.collapsed = value;
91
67
  applyCollapsed();
92
- if (state.tab === 'graph' && state.store) requestAnimationFrame(() => paint('tab'));
68
+ if (state.tab === 'memory' && state.segment.memory === 'graph' && state.store) {
69
+ requestAnimationFrame(() => paint('tab'));
70
+ }
93
71
  }
94
72
 
95
73
  function applyStyles() {
@@ -119,6 +97,7 @@ async function init() {
119
97
  el('collapse').addEventListener('click', () => setCollapsed(true));
120
98
  el('expand').addEventListener('click', () => setCollapsed(false));
121
99
  el('delete-project').addEventListener('click', openStoreDeleteDialog);
100
+ el('undo').addEventListener('click', openTrashDialog);
122
101
  el('theme-toggle').addEventListener('click', toggleTheme);
123
102
  const scheme = matchMedia('(prefers-color-scheme: dark)');
124
103
  scheme.addEventListener('change', (event) => {
package/public/dialog.mjs CHANGED
@@ -7,6 +7,13 @@ const TEXT_ENTRY = /^(text|search|url|email|tel|number|password)$/;
7
7
 
8
8
  export const isDialogOpen = () => Boolean(root().firstElementChild);
9
9
 
10
+ export function closeDialog() {
11
+ const dialog = root().firstElementChild;
12
+ if (!dialog) return;
13
+ dialog.close();
14
+ dialog.remove();
15
+ }
16
+
10
17
  export function openDialog({ title, subtitle, body, actions, focus }) {
11
18
  const previous = root().firstElementChild;
12
19
  if (previous) {
@@ -46,7 +46,8 @@ export async function openBulkDeleteDialog(files) {
46
46
  method: 'POST',
47
47
  body: JSON.stringify({ files, label: `${memories.length} pruned memories` }),
48
48
  });
49
- state.pruneSelection.clear();
49
+ state.listSelection.clear();
50
+ state.selecting = false;
50
51
  if (doomed.has(state.selected)) state.selected = null;
51
52
  await openStore(state.storeId, { keepTab: true });
52
53
  toast(`Deleted ${memories.length} memories`, {
@@ -0,0 +1,53 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { openDialog } from '/dialog.mjs';
4
+ import { state } from '/state.mjs';
5
+ import { restoreFromTrash } from '/store.mjs';
6
+
7
+ const INDEX_EDIT_LABELS = {
8
+ hook: 'MEMORY.md hook shortened',
9
+ move: 'MEMORY.md entry moved',
10
+ add: 'MEMORY.md entry added',
11
+ };
12
+
13
+ function describe(record) {
14
+ const when = String(record.deletedAt).replace('T', ' ').replace(/\..*$/, '');
15
+ if (record.kind === 'wikilink') return `unlinked in ${record.sourceFile} · ${when}`;
16
+ if (record.kind === 'index-edit') return `${INDEX_EDIT_LABELS[record.op] || 'MEMORY.md edited'} · ${when}`;
17
+ if (record.kind === 'merge') return `merged into ${record.into} · ${when} · ${record.backups?.length || 0} file(s) rewritten`;
18
+ return `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
19
+ }
20
+
21
+ export function openTrashDialog() {
22
+ const trash = state.store.trash;
23
+ const body = [];
24
+
25
+ if (!trash.length) {
26
+ body.push(node('p', { class: ui.note, text: 'Nothing to undo. Deleted memories and index edits land here and can be restored.' }));
27
+ } else {
28
+ body.push(node('p', { class: ui.noteTight, text: 'One undo per operation, however many files it touched. Everything lives in memory/.trash/ until you restore it.' }));
29
+ for (const record of trash) {
30
+ const detail = describe(record);
31
+ body.push(node('div', { class: ui.issue(!record.present) }, [
32
+ node('div', { class: ui.issueBody }, [
33
+ node('div', { class: ui.issueTitle, text: record.label || record.id }),
34
+ node('div', { class: ui.issueDetail, text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
35
+ ]),
36
+ record.present
37
+ ? node('button', {
38
+ class: ui.buttonPrimarySmall,
39
+ text: 'Restore',
40
+ onclick: () => restoreFromTrash(record.id, { close: true }),
41
+ })
42
+ : null,
43
+ ]));
44
+ }
45
+ }
46
+
47
+ return openDialog({
48
+ title: 'Undo',
49
+ subtitle: trash.length ? `${trash.length} restorable operation${trash.length === 1 ? '' : 's'}` : null,
50
+ body,
51
+ actions: [{ label: 'Close' }],
52
+ });
53
+ }
package/public/index.html CHANGED
@@ -56,6 +56,7 @@
56
56
  <header id="project-head" data-ui="projectHead">
57
57
  <div data-ui="projectHeadRow">
58
58
  <h2 id="project-title" data-ui="projectTitle"></h2>
59
+ <button id="undo" data-ui="buttonSmall" title="Restore something you deleted" hidden></button>
59
60
  <button id="delete-project" data-ui="buttonDangerSmall">Delete all memory</button>
60
61
  </div>
61
62
  <p id="project-sub" data-ui="projectSub"></p>
package/public/parts.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as ui from '/ui.mjs';
2
2
  import { node } from '/dom.mjs';
3
- import { state } from '/state.mjs';
3
+ import { state, SEGMENT_STORAGE } from '/state.mjs';
4
4
  import { paint } from '/bus.mjs';
5
5
 
6
6
  export function issue(title, detail, { bad = false, action, secondary } = {}) {
@@ -14,11 +14,18 @@ export function issue(title, detail, { bad = false, action, secondary } = {}) {
14
14
  ]);
15
15
  }
16
16
 
17
- export function goToTab(id) {
18
- state.tab = id;
17
+ export function goTo(tab, segment) {
18
+ state.tab = tab;
19
+ if (segment && SEGMENT_STORAGE[tab]) {
20
+ state.segment[tab] = segment;
21
+ localStorage.setItem(SEGMENT_STORAGE[tab], segment);
22
+ }
19
23
  paint('tabs', 'tab');
20
24
  }
21
25
 
26
+ export const isAt = (tab, segment) => state.tab === tab
27
+ && (!segment || state.segment[tab] === segment);
28
+
22
29
  export function cutMarker(cutoff) {
23
30
  return node('div', { class: ui.cutLine }, [
24
31
  node('span', { class: ui.cutLabel, text: `not loaded past here — ${cutoff.droppedLines} lines dropped on ${cutoff.by}` }),
package/public/state.mjs CHANGED
@@ -1,8 +1,33 @@
1
+ export const SEGMENT_STORAGE = {
2
+ memory: 'navMemorySegment',
3
+ environment: 'navEnvironmentSegment',
4
+ };
5
+
6
+ const SEGMENTS = {
7
+ memory: ['list', 'index', 'graph'],
8
+ environment: ['instructions', 'settings', 'sessions'],
9
+ };
10
+
11
+ const storedSegment = (tab) => {
12
+ const value = localStorage.getItem(SEGMENT_STORAGE[tab]);
13
+ return SEGMENTS[tab].includes(value) ? value : SEGMENTS[tab][0];
14
+ };
15
+
16
+ const LIST_SORTS = ['section', 'oldest', 'largest', 'unlinked', 'name'];
17
+ const storedListSort = () => {
18
+ const value = localStorage.getItem('memoryListSort');
19
+ return LIST_SORTS.includes(value) ? value : 'section';
20
+ };
21
+
1
22
  export const state = {
2
23
  stores: [],
3
24
  storeId: null,
4
25
  store: null,
5
- tab: 'memories',
26
+ tab: 'memory',
27
+ segment: {
28
+ memory: storedSegment('memory'),
29
+ environment: storedSegment('environment'),
30
+ },
6
31
  selected: null,
7
32
  showAll: false,
8
33
  collapsed: localStorage.getItem('sidebarCollapsed') === '1',
@@ -10,8 +35,9 @@ export const state = {
10
35
  spread: Number(localStorage.getItem('graphSpread')) || 1.6,
11
36
  query: '',
12
37
  search: null,
13
- pruneSort: 'oldest',
14
- pruneSelection: new Set(),
38
+ listSort: storedListSort(),
39
+ listSelection: new Set(),
40
+ selecting: false,
15
41
  indexView: localStorage.getItem('memoryIndexView') === 'source' ? 'source' : 'rendered',
16
42
  aux: { instructions: null, settings: null, sessions: null, sessionDay: null },
17
43
  pathCheck: null,
package/public/store.mjs CHANGED
@@ -1,15 +1,20 @@
1
1
  import { api, toast } from '/api.mjs';
2
2
  import { state, worst, worstSeverity } from '/state.mjs';
3
3
  import { paint } from '/bus.mjs';
4
+ import { closeDialog } from '/dialog.mjs';
5
+ import { goTo } from '/parts.mjs';
4
6
 
5
7
  export async function openStore(id, { keepTab = false } = {}) {
6
- if (id !== state.storeId) state.pruneSelection.clear();
8
+ if (id !== state.storeId) {
9
+ state.listSelection.clear();
10
+ state.selecting = false;
11
+ }
7
12
  state.storeId = id;
8
13
  state.aux = { instructions: null, settings: null, sessions: null, sessionDay: null };
9
14
  state.pathCheck = null;
10
15
  if (!keepTab) {
11
16
  const opening = state.stores.find((store) => store.id === id);
12
- state.tab = opening && opening.kind === 'global' ? 'context' : 'memories';
17
+ state.tab = opening && opening.kind === 'global' ? 'environment' : 'memory';
13
18
  state.selected = null;
14
19
  }
15
20
  paint('stores');
@@ -78,9 +83,8 @@ export async function sweepIssues() {
78
83
 
79
84
  export function selectMemory(file) {
80
85
  state.selected = file;
81
- if (state.tab !== 'memories') {
82
- state.tab = 'memories';
83
- paint('tabs', 'tab');
86
+ if (state.tab !== 'memory' || state.segment.memory !== 'list') {
87
+ goTo('memory', 'list');
84
88
  } else {
85
89
  paint('tab');
86
90
  }
@@ -100,7 +104,8 @@ export async function loadSessionsForProvenance() {
100
104
  }
101
105
  }
102
106
 
103
- export async function restoreFromTrash(id) {
107
+ export async function restoreFromTrash(id, { close = false } = {}) {
108
+ if (close) closeDialog();
104
109
  try {
105
110
  const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/restore`, {
106
111
  method: 'POST',