superwiki 0.1.4 → 0.1.5

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sw",
3
3
  "description": "An LLM-maintained wiki and task tracker in docs/ for coding agents. Obsidian friendly, with a static viewer.",
4
- "version": "0.1.4",
4
+ "version": "0.1.5",
5
5
  "license": "MIT",
6
6
  "keywords": [
7
7
  "wiki",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sw",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "An LLM-maintained wiki and task tracker in docs/ for coding agents. Obsidian friendly, with a static viewer.",
5
5
  "license": "MIT",
6
6
  "skills": "./skills/",
package/README.md CHANGED
@@ -23,13 +23,13 @@ It is built to be cheap for the agent:
23
23
 
24
24
  - **Little to read.** One small index, one file per task, and a script that answers "what is ready?", "what blocks this?" or "is anything broken?" without the agent reading the vault.
25
25
  - **Work in clean contexts.** Planning, implementing and reviewing run in subagents, each on the model you choose for it. The main session only keeps the task's status true, so it stays small.
26
- - **Cost you can see.** `sw-stats` shows what a session used, per agent.
26
+ - **Cost you can see.** `sw-stats` shows what a session used, per agent; `sw-doctor` shows what every session carries before it starts, and what can go.
27
27
 
28
28
  Measured on a real project with 165 tasks, converted from a single markdown index:
29
29
 
30
30
  | | Before | After |
31
31
  | --- | --- | --- |
32
- | Read at the start of every session | 197 KB index | 94-byte catalog + 1.7 KB of rules |
32
+ | Read at the start of every session | 197 KB index | 94-byte catalog + 2.7 KB of rules |
33
33
  | Read to start one task | the index, then the task's section | one file, 2 KB at the median |
34
34
  | Marking a task done | a status cell, plus a ✅ at every reference to it (median 12 places) | one frontmatter line |
35
35
 
@@ -139,7 +139,7 @@ Agents that load `SKILL.md` folders from `~/.agents/skills` pick the skills up f
139
139
 
140
140
  - the skills name Claude Code, Codex and Copilot tools when they dispatch subagents; elsewhere they fall back to doing the work in the main session, and say so;
141
141
  - `sw-config` writes agent files only for `claude`, `codex` and `copilot`, so a per-role model cannot be set;
142
- - `sw-stats` reads the session records of those three tools only.
142
+ - `sw-stats` and `sw-doctor` read the session records of those three tools only.
143
143
 
144
144
  Everything else (the vault, the CLI, the viewer, ingest, lint, explain, triage) depends only on Node and on the agent following the skill text.
145
145
 
@@ -157,7 +157,7 @@ npx superwiki@latest install claude # update: same command, newest release
157
157
  npx superwiki uninstall all
158
158
  ```
159
159
 
160
- After an update, run `sw-init` again in each project: it replaces `docs/.sw/sw.mjs`, the templates and `docs/viewer.html` with the new version and keeps your content.
160
+ After an update, run `sw-init` again in each project: it replaces `docs/.sw/sw.mjs`, the templates and `docs/viewer.html` with the new version and keeps your content. Until then the project keeps the script it was set up with, and a skill that needs a newer command says so.
161
161
 
162
162
  A project's `docs/` folder is plain markdown and keeps working as an Obsidian vault without Superwiki.
163
163
 
@@ -175,6 +175,7 @@ A project's `docs/` folder is plain markdown and keeps working as an Obsidian va
175
175
  | `sw-lint` | structural checks by script, semantic review on request |
176
176
  | `sw-visualize` | open the viewer |
177
177
  | `sw-stats` | what the current session has cost: tokens, context, steps and tool calls, per agent |
178
+ | `sw-doctor` | what a session carries before any work, and what to remove to make every step cheaper |
178
179
  | `sw-config` | the model each tool uses for planning, implementing and reviewing; task areas |
179
180
 
180
181
  ### Examples
@@ -203,6 +204,7 @@ Shown as typed in Claude Code. In Codex, write `$sw-plan` instead of `/sw-plan`.
203
204
  /sw-lint check links, frontmatter and task dependencies
204
205
  /sw-visualize open the task board and the wiki in the browser
205
206
  /sw-stats what this session has cost so far, per agent
207
+ /sw-doctor what fills the context before any work, and what can go
206
208
  ```
207
209
 
208
210
  You do not have to type a command. The rules `sw-init` adds to `AGENTS.md` tell the agent which skill fits, so a plain request should reach the same skill:
@@ -232,6 +234,24 @@ total 149 - - 20.9M 95% 43k
232
234
 
233
235
  `first` and `peak` are the tokens sent with one request. `sent` is that, summed over every step: each step sends the whole context again, which is why a long session in one context is expensive. In Copilot CLI the token columns fill in once the session has closed.
234
236
 
237
+ ### What a session starts with
238
+
239
+ Every agent above began at 59k to 79k tokens before it had read anything, and paid for that on each of its steps. `sw-doctor` shows what that start is made of, from the same session record, and proposes what to switch off for this project. The same session:
240
+
241
+ ```text
242
+ context at session start claude fe4cbd6c-8abf-4db6-9755-469dc7321dc7
243
+ first request: 79k tokens
244
+ part size holds
245
+ rule and memory files 30 KB memory/MEMORY.md 18 KB, my-app/AGENTS.md 11 KB, ...
246
+ skill list 29 KB 213: marketing-skills 41, (none) 37, claude-seo 25, claude-ads 23, +11 more
247
+ tool names (loaded on demand) 17 KB 381: claude_ai_higgsfield 116, claude_ai_meta_ads 98, +9 more
248
+ agent list 14 KB 46: claude-seo 18, (none) 12, claude-ads 10, +4 more
249
+ MCP server instructions 8.3 KB claude.ai higgsfield 2.0 KB, notebooklm 2.0 KB, ...
250
+ session-start hooks 3.3 KB
251
+ ```
252
+
253
+ Here most of the skills, agents and tools came from advertising and SEO plugins that this project never uses. The skill proposes changes and asks before making any; it touches project-local settings only, and never uninstalls a plugin or deletes a memory. Sizes are characters of text: a skill cannot run your agent's own context command (`/context` in Claude Code and Copilot CLI, `/status` in Codex), which shows the same in tokens.
254
+
235
255
  ### The CLI
236
256
 
237
257
  The skills call a small script that answers questions without the agent reading the vault. You can run it yourself, from the project root:
@@ -245,6 +265,7 @@ node docs/.sw/sw.mjs search sync timeout # where something is mentioned
245
265
  node docs/.sw/sw.mjs next-id P # next free id in an area
246
266
  node docs/.sw/sw.mjs lint # broken links, bad frontmatter, dependency errors
247
267
  node docs/.sw/sw.mjs stats # tokens, context and steps of the agent session here
268
+ node docs/.sw/sw.mjs doctor # what that session carried before it read anything
248
269
  node docs/.sw/sw.mjs serve --open # the viewer, reading files live
249
270
  node docs/.sw/sw.mjs snapshot # or: freeze the vault into docs/viewer.html, no server
250
271
  ```
@@ -260,7 +281,9 @@ Edit sources in `src/`:
260
281
  | File | What it is |
261
282
  | --- | --- |
262
283
  | `src/core.js` | the vault model, derived task state, lint and search; shared by the CLI and the viewer |
263
- | `src/stats.js` | reads the agents' session records |
284
+ | `src/sessions.js` | finds the record an agent keeps of a session |
285
+ | `src/stats.js` | reduces a session record to cost per agent |
286
+ | `src/doctor.js` | reduces a session record to what the session started with |
264
287
  | `src/cli.js` | the commands |
265
288
  | `src/viewer.html` | the viewer |
266
289
 
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: Show what fills the context before any work (rules, memory, skills, plugins, tools) and propose what to remove
3
+ ---
4
+
5
+ Use the sw-doctor skill. User arguments: $ARGUMENTS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superwiki",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Agent skills that turn docs/ into an LLM-maintained wiki and task tracker. Obsidian-friendly. Works with Claude Code, Codex and Copilot CLI.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: sw-doctor
3
+ description: Use when the user wants to make agent sessions lighter or cheaper to start, asks what fills the context window before any work (rules, memory, skills, plugins, MCP tools), why a session starts with so many tokens, or invokes sw-doctor or sw:doctor.
4
+ ---
5
+
6
+ # sw-doctor
7
+
8
+ Shows what a session carries before it has read anything, and proposes what to remove. Rule and memory files, the lists of skills, agents and tools, and what plugins and hooks add are sent again with every step of every agent, subagents included, so a smaller start makes every step cheaper.
9
+
10
+ You cannot run the tool's own context command: it is typed by the user. A script reads the same blocks from the record the tool keeps of this session. You read nothing yourself and write no report.
11
+
12
+ Run from the project root:
13
+
14
+ ```bash
15
+ node docs/.sw/sw.mjs doctor
16
+ ```
17
+
18
+ It reports the session it runs in. `--session <id or its first characters>` picks another session of this project, `--tool claude|codex|copilot` looks only at one tool's sessions.
19
+
20
+ ## Steps
21
+
22
+ 1. **Show the table as printed**, in a code block. Sizes are characters of text, not tokens.
23
+ 2. **Propose changes**, largest saving first, at most five. For each: the part and its size from the table, what to change, and where. Use the table under "What to propose". Propose only what the output supports, and say plainly when a part cannot be made smaller.
24
+ 3. **Ask before changing anything**, one proposal at a time. Which plugin or server a project needs is the user's call: say what a group appears to be for and let them decide.
25
+ 4. **Apply what the user approved**, within the limits under "Limits".
26
+ 5. **Have it measured.** A change takes effect in a new session. Ask the user to open one and run the tool's own command there: `/context` in Claude Code and Copilot CLI, `/status` in Codex. Compare its figures with the ones from before; a setting that changed nothing is reported as such and taken out again.
27
+
28
+ If the user pastes the output of that command, use its token figures in place of the character sizes; the parts are the same.
29
+
30
+ ## What to propose
31
+
32
+ | In the table | Propose | What it saves |
33
+ | --- | --- | --- |
34
+ | `rule and memory files`: one file is most of the part | name the file. A rules file (`AGENTS.md`, `CLAUDE.md`): move sections that are needed only for some work into a document the agent opens on demand, and leave a one-line pointer. A memory index: one line per entry holding one lesson; dates, counts and status belong in the entry's own file | its full size |
35
+ | `agent list`: groups that have nothing to do with this project | switch those plugins off for this project (how: "Limits"). The group label is the plugin's name; `(none)` is the user's own and built-in entries | the lines of those agents |
36
+ | `skill list`: groups that have nothing to do with this project | the same switch. Say what to expect: the tool gives the list a fixed budget and shortens descriptions to fit, so fewer skills usually means fuller descriptions for the rest, not a smaller list. Worth doing so the agent picks skills better; not a token saving | little or nothing |
37
+ | `tool names (loaded on demand)`: servers the project does not use | the user disables those MCP servers for this project. Only names are loaded, so the saving is the size shown, not the size of the tool definitions | the names of those tools |
38
+ | `MCP server instructions` | goes away with the server; no separate change | with the server |
39
+ | `session-start hooks` | name the hook and the plugin it belongs to, if the user can say; it goes away with that plugin | its full size |
40
+ | Codex: `skills instructions` is large | skills are listed from `~/.agents/skills` and from plugins; remove or move the ones this user does not use | not measured |
41
+ | Copilot CLI: `custom instruction` is large | the project's instruction files; same advice as for a rules file | its full size |
42
+ | `first request` is far above what the parts add up to | the rest is the tool's own system prompt and built-in tool definitions. Say so; it cannot be changed from here | nothing |
43
+
44
+ Give a saving as the size in the table. Do not turn it into a token or money figure unless the user pasted token numbers.
45
+
46
+ ## Limits
47
+
48
+ - **Project-local settings only.** Never edit the user's global settings, and never uninstall a plugin or remove a server: switching it off for this project is enough and is undone by deleting a line.
49
+ - **Claude Code**:
50
+ - a plugin off: in `.claude/settings.local.json` (personal, normally git-ignored; keep what is already there), `"enabledPlugins": { "<plugin>@<marketplace>": false }`. The full key is in `~/.claude/settings.json` under `enabledPlugins`; read only that key.
51
+ - an MCP server or a claude.ai connector off: the user does it with `/mcp` in the session. Do not edit `~/.claude.json`, and do not set environment variables for it in the project's settings: that was tried and changed nothing.
52
+ - plugins synced from the user's claude.ai account are not in that settings file; they are removed in the account's settings.
53
+ - **Codex and Copilot CLI**: propose, and let the user change their configuration; this skill does not edit it.
54
+ - **Memory and rule files are the user's.** Shorten an index or move a section only when asked, keep a copy of the previous version next to the file, and never delete an entry.
55
+ - If the project's own rules forbid a change, they win.
56
+
57
+ ## If it fails
58
+
59
+ | Output | Do |
60
+ | --- | --- |
61
+ | `unknown command doctor` | the project's `docs/.sw/sw.mjs` is older than this skill; offer to run sw-init, which updates it |
62
+ | `no agent session record found` | say so and ask the user to run the tool's context command and paste the output; work from that |
63
+ | a note that the session has not made a request yet | another session of this project is newer than yours; run again with `--session` and the id of the one you mean |
64
+ | a note that Copilot has not counted its tool definitions | pass it on; the other parts are valid |
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Generated by scripts/build.mjs from src/core.js, src/stats.js, src/cli.js. Do not edit.
2
+ // Generated by scripts/build.mjs from src/core.js, src/sessions.js, src/stats.js, src/doctor.js, src/cli.js. Do not edit.
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { basename, dirname, join, resolve as resolvePath, sep } from 'node:path';
@@ -322,21 +322,18 @@ export function guideFor(vault, area) {
322
322
  return vault.pages.find(p => p.folder === 'wiki' && p.data.type === 'guide' && key(p.data.area ?? '') === key(area)) || null;
323
323
  }
324
324
 
325
- // Session statistics: what the agent session working in this project has cost so far.
326
- // Claude Code, Codex and Copilot CLI each keep a record of every session on disk. This finds the
327
- // record of the project's session and reduces it to one row per agent: the main session and each
328
- // subagent it started.
325
+ // Finds the record an agent tool keeps of the session working in a project.
326
+ // Claude Code, Codex and Copilot CLI each write every session to disk in their own layout; this
327
+ // lists a project's sessions as { tool, id, modified, current, source } and picks one. What a
328
+ // record means is left to the readers in stats.js and doctor.js.
329
329
 
330
- export const STATS_TOOLS = ['claude', 'codex', 'copilot'];
330
+ export const SESSION_TOOLS = ['claude', 'codex', 'copilot'];
331
331
 
332
- const MAIN = 'main';
333
332
  // Codex keeps every project's sessions in one tree; only the most recent files are opened.
334
333
  const CODEX_FILES_SCANNED = 100;
335
334
 
336
- // ---------- Reading records ----------
337
-
338
335
  // A record still being written can end in half a line; lines that do not parse are skipped.
339
- function jsonLines(path) {
336
+ export function jsonLines(path) {
340
337
  const records = [];
341
338
  for (const line of readFileSync(path, 'utf8').split('\n')) {
342
339
  if (!line) continue;
@@ -347,7 +344,7 @@ function jsonLines(path) {
347
344
  return records;
348
345
  }
349
346
 
350
- function jsonFile(path) {
347
+ export function jsonFile(path) {
351
348
  try {
352
349
  return JSON.parse(readFileSync(path, 'utf8'));
353
350
  } catch {
@@ -368,9 +365,111 @@ function rootForms(root) {
368
365
 
369
366
  const isWithin = (roots, dir) => Boolean(dir) && roots.some(root => dir === root || dir.startsWith(root + sep));
370
367
 
368
+ // ---------- Claude Code ----------
369
+ // ~/.claude/projects/<working directory, non-alphanumerics as dashes>/<session>.jsonl, and next to
370
+ // it <session>/subagents/agent-<id>.jsonl with a .meta.json naming the agent type.
371
+ // source: { path, subagentDir }
372
+
373
+ const claudeHome = () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
374
+
375
+ function claudeSessions(root) {
376
+ const sessions = [];
377
+ for (const form of rootForms(root)) {
378
+ const dir = join(claudeHome(), 'projects', form.replace(/[^A-Za-z0-9]/g, '-'));
379
+ if (!existsSync(dir)) continue;
380
+ for (const name of readdirSync(dir)) {
381
+ if (!name.endsWith('.jsonl')) continue;
382
+ const id = name.slice(0, -'.jsonl'.length);
383
+ const path = join(dir, name);
384
+ sessions.push({
385
+ tool: 'claude',
386
+ id,
387
+ modified: modifiedAt(path),
388
+ current: id === process.env.CLAUDE_CODE_SESSION_ID,
389
+ source: { path, subagentDir: join(dir, id, 'subagents') },
390
+ });
391
+ }
392
+ }
393
+ return sessions;
394
+ }
395
+
396
+ // ---------- Codex ----------
397
+ // ~/.codex/sessions/<year>/<month>/<day>/rollout-*.jsonl. The first line is the session's meta:
398
+ // its working directory and, for a subagent, the session that spawned it and its role.
399
+ // source: { files: [{ meta, records }] }, one file per agent
400
+
401
+ const codexHome = () => process.env.CODEX_HOME || join(homedir(), '.codex');
402
+
403
+ function rolloutFiles(dir, found = []) {
404
+ if (!existsSync(dir)) return found;
405
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
406
+ const path = join(dir, entry.name);
407
+ if (entry.isDirectory()) rolloutFiles(path, found);
408
+ else if (entry.name.endsWith('.jsonl')) found.push({ path, modified: modifiedAt(path) });
409
+ }
410
+ return found;
411
+ }
412
+
413
+ function codexSessions(root) {
414
+ const roots = rootForms(root);
415
+ const recent = rolloutFiles(join(codexHome(), 'sessions')).sort((a, b) => b.modified - a.modified).slice(0, CODEX_FILES_SCANNED);
416
+ const byId = new Map();
417
+ for (const { path, modified } of recent) {
418
+ const records = jsonLines(path);
419
+ const meta = records[0]?.type === 'session_meta' ? records[0].payload : null;
420
+ if (!meta || !isWithin(roots, meta.cwd)) continue;
421
+ const id = meta.session_id || meta.id;
422
+ if (!byId.has(id)) byId.set(id, { modified: 0, files: [] });
423
+ const group = byId.get(id);
424
+ group.modified = Math.max(group.modified, modified);
425
+ group.files.push({ meta, records });
426
+ }
427
+ return [...byId].map(([id, { modified, files }]) => ({ tool: 'codex', id, modified, current: false, source: { files } }));
428
+ }
429
+
430
+ // ---------- Copilot CLI ----------
431
+ // ~/.copilot/session-state/<session>/events.jsonl, with the working directory in workspace.yaml.
432
+ // source: { path }
433
+
434
+ const copilotHome = () => join(homedir(), '.copilot');
435
+
436
+ function copilotSessions(root) {
437
+ const roots = rootForms(root);
438
+ const base = join(copilotHome(), 'session-state');
439
+ if (!existsSync(base)) return [];
440
+ const sessions = [];
441
+ for (const id of readdirSync(base)) {
442
+ const path = join(base, id, 'events.jsonl');
443
+ const workspace = join(base, id, 'workspace.yaml');
444
+ if (!existsSync(path) || !existsSync(workspace)) continue;
445
+ const cwd = (readFileSync(workspace, 'utf8').match(/^cwd: (.*)$/m) || [])[1];
446
+ if (!isWithin(roots, cwd)) continue;
447
+ sessions.push({ tool: 'copilot', id, modified: modifiedAt(path), current: false, source: { path } });
448
+ }
449
+ return sessions;
450
+ }
451
+
452
+ // ---------- Choosing ----------
453
+
454
+ const LISTERS = { claude: claudeSessions, codex: codexSessions, copilot: copilotSessions };
455
+
456
+ // The session to report: the one named by `id` (a prefix is enough), else the session this command
457
+ // runs in when the tool says which one that is, else the most recently written one.
458
+ export function findSession(root, { tool, id } = {}) {
459
+ let sessions = (tool ? [tool] : SESSION_TOOLS).flatMap(name => LISTERS[name](root));
460
+ if (id) sessions = sessions.filter(session => session.id.startsWith(id));
461
+ sessions.sort((a, b) => b.modified - a.modified);
462
+ return (!id && sessions.find(session => session.current)) || sessions[0] || null;
463
+ }
464
+
465
+ // Session statistics: what an agent session has cost so far, as one row per agent: the main
466
+ // session and each subagent it started. Reads the session record found by sessions.js.
467
+
468
+ const MAIN = 'main';
469
+
371
470
  // ---------- The summary being built ----------
372
471
 
373
- const newSession = (tool, id) => ({ tool, id, agents: [], tools: {}, note: '' });
472
+ const newStats = session => ({ tool: session.tool, id: session.id, agents: [], tools: {}, note: '' });
374
473
 
375
474
  // Token fields stay null when the record does not hold them, so "unknown" never prints as 0.
376
475
  const newAgent = name => ({
@@ -389,9 +488,9 @@ function addStep(agent, { context, cached, output }) {
389
488
  agent.output = plus(agent.output, output);
390
489
  }
391
490
 
392
- function addToolCall(session, agent, name) {
491
+ function addToolCall(stats, agent, name) {
393
492
  agent.toolCalls++;
394
- session.tools[name] = (session.tools[name] || 0) + 1;
493
+ stats.tools[name] = (stats.tools[name] || 0) + 1;
395
494
  }
396
495
 
397
496
  // Widens the agent's working period to include this record.
@@ -406,33 +505,8 @@ function touch(agent, timestamp) {
406
505
  const byStart = (a, b) => (a.start ?? 0) - (b.start ?? 0);
407
506
 
408
507
  // ---------- Claude Code ----------
409
- // ~/.claude/projects/<working directory, non-alphanumerics as dashes>/<session>.jsonl, and next to
410
- // it <session>/subagents/agent-<id>.jsonl with a .meta.json naming the agent type.
411
-
412
- const claudeHome = () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
413
508
 
414
- function claudeSessions(root) {
415
- const sessions = [];
416
- for (const form of rootForms(root)) {
417
- const dir = join(claudeHome(), 'projects', form.replace(/[^A-Za-z0-9]/g, '-'));
418
- if (!existsSync(dir)) continue;
419
- for (const name of readdirSync(dir)) {
420
- if (!name.endsWith('.jsonl')) continue;
421
- const id = name.slice(0, -'.jsonl'.length);
422
- const path = join(dir, name);
423
- sessions.push({
424
- tool: 'claude',
425
- id,
426
- modified: modifiedAt(path),
427
- current: id === process.env.CLAUDE_CODE_SESSION_ID,
428
- read: () => readClaude(id, path, join(dir, id, 'subagents')),
429
- });
430
- }
431
- }
432
- return sessions;
433
- }
434
-
435
- function readClaudeAgent(session, name, path) {
509
+ function claudeAgent(stats, name, path) {
436
510
  const agent = newAgent(name);
437
511
  // A reply is written as one line per content block. Each line repeats the reply's usage, and
438
512
  // only the last one has the final output count, so the last line of a reply is the one kept.
@@ -442,7 +516,7 @@ function readClaudeAgent(session, name, path) {
442
516
  const message = record.type === 'assistant' && record.message;
443
517
  if (!message) continue;
444
518
  for (const block of message.content || []) {
445
- if (block.type === 'tool_use') addToolCall(session, agent, block.name);
519
+ if (block.type === 'tool_use') addToolCall(stats, agent, block.name);
446
520
  }
447
521
  if (message.usage) replies.set(message.id, message);
448
522
  }
@@ -458,61 +532,31 @@ function readClaudeAgent(session, name, path) {
458
532
  return agent;
459
533
  }
460
534
 
461
- function readClaude(id, path, subagentDir) {
462
- const session = newSession('claude', id);
463
- session.agents.push(readClaudeAgent(session, MAIN, path));
464
- if (!existsSync(subagentDir)) return session;
535
+ function claudeStats(session) {
536
+ const { path, subagentDir } = session.source;
537
+ const stats = newStats(session);
538
+ stats.agents.push(claudeAgent(stats, MAIN, path));
539
+ if (!existsSync(subagentDir)) return stats;
465
540
  const subagents = readdirSync(subagentDir)
466
541
  .filter(name => name.endsWith('.jsonl'))
467
542
  .map(name => {
468
543
  const meta = jsonFile(join(subagentDir, name.replace(/\.jsonl$/, '.meta.json')));
469
- return readClaudeAgent(session, meta.agentType || 'subagent', join(subagentDir, name));
544
+ return claudeAgent(stats, meta.agentType || 'subagent', join(subagentDir, name));
470
545
  });
471
- session.agents.push(...subagents.sort(byStart));
472
- return session;
546
+ stats.agents.push(...subagents.sort(byStart));
547
+ return stats;
473
548
  }
474
549
 
475
550
  // ---------- Codex ----------
476
- // ~/.codex/sessions/<year>/<month>/<day>/rollout-*.jsonl. The first line is the session's meta:
477
- // its working directory and, for a subagent, the session that spawned it and its role.
478
-
479
- const codexHome = () => process.env.CODEX_HOME || join(homedir(), '.codex');
480
551
 
481
- function rolloutFiles(dir, found = []) {
482
- if (!existsSync(dir)) return found;
483
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
484
- const path = join(dir, entry.name);
485
- if (entry.isDirectory()) rolloutFiles(path, found);
486
- else if (entry.name.endsWith('.jsonl')) found.push({ path, modified: modifiedAt(path) });
487
- }
488
- return found;
489
- }
490
-
491
- function codexSessions(root) {
492
- const roots = rootForms(root);
493
- const recent = rolloutFiles(join(codexHome(), 'sessions')).sort((a, b) => b.modified - a.modified).slice(0, CODEX_FILES_SCANNED);
494
- const byId = new Map();
495
- for (const { path, modified } of recent) {
496
- const records = jsonLines(path);
497
- const meta = records[0]?.type === 'session_meta' ? records[0].payload : null;
498
- if (!meta || !isWithin(roots, meta.cwd)) continue;
499
- const id = meta.session_id || meta.id;
500
- if (!byId.has(id)) byId.set(id, { modified: 0, files: [] });
501
- const group = byId.get(id);
502
- group.modified = Math.max(group.modified, modified);
503
- group.files.push({ meta, records });
504
- }
505
- return [...byId].map(([id, { modified, files }]) => ({ tool: 'codex', id, modified, current: false, read: () => readCodex(id, files) }));
506
- }
507
-
508
- function readCodexAgent(session, name, records) {
552
+ function codexAgent(stats, name, records) {
509
553
  const agent = newAgent(name);
510
554
  let lastTotal = null;
511
555
  for (const record of records) {
512
556
  touch(agent, record.timestamp);
513
557
  const payload = record.payload || {};
514
558
  if (record.type === 'turn_context') agent.model = payload.model || agent.model;
515
- if (record.type === 'response_item' && /_call$/.test(payload.type || '')) addToolCall(session, agent, payload.name || payload.type);
559
+ if (record.type === 'response_item' && /_call$/.test(payload.type || '')) addToolCall(stats, agent, payload.name || payload.type);
516
560
  if (record.type !== 'event_msg' || payload.type !== 'token_count' || !payload.info) continue;
517
561
  // The count is also repeated when only the rate limits change; a new request moves the total.
518
562
  const total = payload.info.total_token_usage?.total_tokens;
@@ -524,41 +568,22 @@ function readCodexAgent(session, name, records) {
524
568
  return agent;
525
569
  }
526
570
 
527
- function readCodex(id, files) {
528
- const session = newSession('codex', id);
571
+ function codexStats(session) {
572
+ const stats = newStats(session);
529
573
  const subagents = [];
530
- for (const { meta, records } of files) {
531
- if (meta.thread_source === 'subagent') subagents.push(readCodexAgent(session, meta.agent_role || meta.agent_nickname || 'subagent', records));
532
- else session.agents.push(readCodexAgent(session, MAIN, records));
574
+ for (const { meta, records } of session.source.files) {
575
+ if (meta.thread_source === 'subagent') subagents.push(codexAgent(stats, meta.agent_role || meta.agent_nickname || 'subagent', records));
576
+ else stats.agents.push(codexAgent(stats, MAIN, records));
533
577
  }
534
- session.agents.push(...subagents.sort(byStart));
535
- return session;
578
+ stats.agents.push(...subagents.sort(byStart));
579
+ return stats;
536
580
  }
537
581
 
538
582
  // ---------- Copilot CLI ----------
539
- // ~/.copilot/session-state/<session>/events.jsonl, with the working directory in workspace.yaml.
540
583
  // Events of a subagent carry its agentId. Token counts are written only when the session closes.
541
584
 
542
- const copilotHome = () => join(homedir(), '.copilot');
543
-
544
- function copilotSessions(root) {
545
- const roots = rootForms(root);
546
- const base = join(copilotHome(), 'session-state');
547
- if (!existsSync(base)) return [];
548
- const sessions = [];
549
- for (const id of readdirSync(base)) {
550
- const events = join(base, id, 'events.jsonl');
551
- const workspace = join(base, id, 'workspace.yaml');
552
- if (!existsSync(events) || !existsSync(workspace)) continue;
553
- const cwd = (readFileSync(workspace, 'utf8').match(/^cwd: (.*)$/m) || [])[1];
554
- if (!isWithin(roots, cwd)) continue;
555
- sessions.push({ tool: 'copilot', id, modified: modifiedAt(events), current: false, read: () => readCopilot(id, events) });
556
- }
557
- return sessions;
558
- }
559
-
560
- function readCopilot(id, path) {
561
- const session = newSession('copilot', id);
585
+ function copilotStats(session) {
586
+ const stats = newStats(session);
562
587
  const agents = new Map();
563
588
  const agentOf = key => {
564
589
  if (!agents.has(key)) agents.set(key, newAgent(key));
@@ -566,7 +591,7 @@ function readCopilot(id, path) {
566
591
  };
567
592
  agentOf(MAIN);
568
593
  let closed = false;
569
- for (const event of jsonLines(path)) {
594
+ for (const event of jsonLines(session.source.path)) {
570
595
  const data = event.data || {};
571
596
  const agent = agentOf(event.agentId || MAIN);
572
597
  touch(agent, event.timestamp);
@@ -576,7 +601,7 @@ function readCopilot(id, path) {
576
601
  agent.steps++;
577
602
  agent.model = data.model || agent.model;
578
603
  } else if (event.type === 'tool.execution_start') {
579
- addToolCall(session, agent, data.toolName);
604
+ addToolCall(stats, agent, data.toolName);
580
605
  } else if (event.type === 'session.shutdown' && data.agentMetrics) {
581
606
  // A resumed session closes more than once; each close reports the run that ended with it.
582
607
  closed = true;
@@ -590,24 +615,14 @@ function readCopilot(id, path) {
590
615
  }
591
616
  }
592
617
  }
593
- session.agents = [...agents.values()];
594
- if (!closed) session.note = 'Copilot writes token counts when the session closes; until then /usage shows them.';
595
- return session;
618
+ stats.agents = [...agents.values()];
619
+ if (!closed) stats.note = 'Copilot writes token counts when the session closes; until then /usage shows them.';
620
+ return stats;
596
621
  }
597
622
 
598
- // ---------- Choosing the session ----------
623
+ const STATS_READERS = { claude: claudeStats, codex: codexStats, copilot: copilotStats };
599
624
 
600
- const LISTERS = { claude: claudeSessions, codex: codexSessions, copilot: copilotSessions };
601
-
602
- // The session to report: the one named by `id` (a prefix is enough), else the session this command
603
- // runs in when the tool says which one that is, else the most recently written one.
604
- export function sessionStats(root, { tool, id } = {}) {
605
- let sessions = (tool ? [tool] : STATS_TOOLS).flatMap(name => LISTERS[name](root));
606
- if (id) sessions = sessions.filter(session => session.id.startsWith(id));
607
- sessions.sort((a, b) => b.modified - a.modified);
608
- const chosen = (!id && sessions.find(session => session.current)) || sessions[0];
609
- return chosen ? chosen.read() : null;
610
- }
625
+ export const sessionStats = session => STATS_READERS[session.tool](session);
611
626
 
612
627
  // ---------- Printing ----------
613
628
 
@@ -615,7 +630,7 @@ const COLUMNS = ['agent', 'model', 'steps', 'first', 'peak', 'sent', 'cached', '
615
630
  const TEXT_COLUMNS = 2; // agent and model align left; the numbers after them align right
616
631
  const LEGEND = 'first, peak: tokens sent with one request. sent: that, summed over every step. cached: the share of sent read from the cache.';
617
632
 
618
- function tokens(n) {
633
+ export function tokenCount(n) {
619
634
  if (n == null) return '-';
620
635
  if (n < 1000) return String(n);
621
636
  if (n < 1e6) return `${Math.round(n / 1000)}k`;
@@ -658,33 +673,238 @@ function totalOf(agents) {
658
673
  }
659
674
 
660
675
  const cells = agent => [
661
- agent.name, agent.model, String(agent.steps), tokens(agent.first), tokens(agent.peak), tokens(agent.sent), cachedShare(agent),
662
- tokens(agent.output), String(agent.toolCalls), agent.start == null ? '' : String(minutes(agent.start, agent.end)),
676
+ agent.name, agent.model, String(agent.steps), tokenCount(agent.first), tokenCount(agent.peak), tokenCount(agent.sent), cachedShare(agent),
677
+ tokenCount(agent.output), String(agent.toolCalls), agent.start == null ? '' : String(minutes(agent.start, agent.end)),
663
678
  ];
664
679
 
665
- function table(rows) {
666
- const widths = COLUMNS.map((_, column) => Math.max(...rows.map(row => row[column].length)));
667
- const pad = (cell, column) => (column < TEXT_COLUMNS ? cell.padEnd(widths[column]) : cell.padStart(widths[column]));
680
+ // Rows of text cells as aligned lines: the first `textColumns` align left, the rest right.
681
+ export function alignedRows(rows, textColumns) {
682
+ const widths = rows[0].map((_, column) => Math.max(...rows.map(row => row[column].length)));
683
+ const pad = (cell, column) => (column < textColumns ? cell.padEnd(widths[column]) : cell.padStart(widths[column]));
668
684
  return rows.map(row => row.map(pad).join(' ').trimEnd());
669
685
  }
670
686
 
671
- export function formatStats(session) {
672
- const { agents } = session;
687
+ export function formatStats(stats) {
688
+ const { agents } = stats;
673
689
  const rows = [COLUMNS, ...agents.map(cells)];
674
690
  if (agents.length > 1) rows.push(cells(totalOf(agents)));
675
- const calls = Object.entries(session.tools).sort((a, b) => b[1] - a[1]).map(([name, count]) => `${name} ${count}`);
691
+ const calls = Object.entries(stats.tools).sort((a, b) => b[1] - a[1]).map(([name, count]) => `${name} ${count}`);
676
692
  return [
677
- ['session', session.tool, session.id, sessionPeriod(agents)].filter(Boolean).join(' '),
678
- ...table(rows),
693
+ ['session', stats.tool, stats.id, sessionPeriod(agents)].filter(Boolean).join(' '),
694
+ ...alignedRows(rows, TEXT_COLUMNS),
679
695
  `tool calls ${calls.join(' ') || 'none'}`,
680
696
  LEGEND,
681
- ...(session.note ? [session.note] : []),
697
+ ...(stats.note ? [stats.note] : []),
698
+ ].join('\n');
699
+ }
700
+
701
+ // Start context: what an agent session carries before it has read anything. Rule and memory
702
+ // files, the lists of skills, agents and tools, and what plugins and hooks add are sent again with
703
+ // every step of every agent, so this is the cheapest place to make a session lighter.
704
+ // Reads the session record found by sessions.js and reduces it to parts with a size and a source.
705
+
706
+ // The command that shows the same context in tokens, in each tool.
707
+ const CONTEXT_COMMANDS = { claude: '/context', codex: '/status', copilot: '/context' };
708
+ const SMALL_PART_CHARS = 512; // smaller parts are summed into "other"
709
+ const SOURCES_SHOWN = 6;
710
+ const UNGROUPED = '(none)';
711
+ const NOT_STARTED = 'This session has not made a request yet; its context is recorded with the first one.';
712
+
713
+ // ---------- The report being built ----------
714
+
715
+ const newReport = session => ({ tool: session.tool, id: session.id, firstRequest: null, parts: [], note: '' });
716
+
717
+ // A part's sources are either texts with a size or groups with a count: { label, amount }.
718
+ // `chars` is null for a part the record counts in tokens instead.
719
+ const newPart = (name, measure) => ({ name, chars: 0, tokens: null, count: null, measure, sources: [] });
720
+
721
+ // A part made of texts, each from a named source.
722
+ function sized(name, texts) {
723
+ const part = newPart(name, 'size');
724
+ for (const [label, text] of texts) {
725
+ part.chars += text.length;
726
+ part.sources.push({ label, amount: text.length });
727
+ }
728
+ return part;
729
+ }
730
+
731
+ // A part that is one list of names, grouped by where each name comes from.
732
+ function listed(name, names, text, groupOf) {
733
+ const part = newPart(name, 'count');
734
+ part.chars = text.length;
735
+ part.count = names.length;
736
+ const groups = new Map();
737
+ for (const item of names) groups.set(groupOf(item), (groups.get(groupOf(item)) || 0) + 1);
738
+ part.sources = [...groups].map(([label, amount]) => ({ label, amount }));
739
+ return part;
740
+ }
741
+
742
+ // "plugin:skill" and "plugin:agent" belong to the plugin; "mcp__server__tool" to the server.
743
+ const pluginOf = name => (name.includes(':') ? name.slice(0, name.indexOf(':')) : UNGROUPED);
744
+ const serverOf = name => (name.match(/^mcp__(.+?)__/) || [])[1] || UNGROUPED;
745
+
746
+ // "memory/MEMORY.md": the last folder says which of several same-named files this is.
747
+ const shortPath = path => `${basename(dirname(path))}/${basename(path)}`;
748
+
749
+ // ---------- Claude Code ----------
750
+ // Before the first reply the record holds one "attachment" per block of context the session was
751
+ // given: the instruction files, the skill, agent and tool lists, server instructions, hook output.
752
+
753
+ function claudePart(attachment) {
754
+ switch (attachment.type) {
755
+ case 'instructions':
756
+ return sized('rule and memory files', (attachment.files || []).map(file => [shortPath(file.path), file.content || '']));
757
+ case 'skill_listing':
758
+ return listed('skill list', attachment.names || [], attachment.content || '', pluginOf);
759
+ case 'agent_listing_delta':
760
+ return listed('agent list', attachment.addedTypes || [], (attachment.addedLines || []).join('\n'), pluginOf);
761
+ case 'deferred_tools_delta':
762
+ return listed('tool names (loaded on demand)', attachment.addedNames || [], (attachment.addedLines || []).join('\n'), serverOf);
763
+ case 'mcp_instructions_delta':
764
+ return sized('MCP server instructions', (attachment.addedNames || []).map((name, i) => [name, attachment.addedBlocks?.[i] || '']));
765
+ case 'hook_additional_context':
766
+ return sized('session-start hooks', [[attachment.hookName || 'hook', [attachment.content].flat().join('\n')]]);
767
+ default:
768
+ return null;
769
+ }
770
+ }
771
+
772
+ function claudeStart(session) {
773
+ const report = newReport(session);
774
+ for (const record of jsonLines(session.source.path)) {
775
+ if (record.type === 'attachment') {
776
+ const part = claudePart(record.attachment || {});
777
+ if (part) report.parts.push(part);
778
+ }
779
+ const usage = record.type === 'assistant' && record.message?.usage;
780
+ if (!usage) continue;
781
+ report.firstRequest = (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
782
+ break;
783
+ }
784
+ if (report.firstRequest == null) report.note = NOT_STARTED;
785
+ return report;
786
+ }
787
+
788
+ // ---------- Codex ----------
789
+ // The session meta holds the base instructions. The messages before the first request hold the
790
+ // rest, one tagged block each: <skills_instructions>, <plugins_instructions>, the AGENTS.md text.
791
+
792
+ function codexBlockName(text) {
793
+ if (text.startsWith('# AGENTS.md')) return 'AGENTS.md';
794
+ const tag = (text.match(/^<([a-z_ ]+)>/) || [])[1];
795
+ return tag ? tag.replace(/_/g, ' ') : null; // untagged text is the user's own request
796
+ }
797
+
798
+ function codexStart(session) {
799
+ const report = newReport(session);
800
+ const main = session.source.files.find(file => file.meta.thread_source !== 'subagent') || session.source.files[0];
801
+ report.parts.push(sized('base instructions', [['codex', main.meta.base_instructions?.text || '']]));
802
+ for (const record of main.records) {
803
+ const payload = record.payload || {};
804
+ if (record.type === 'response_item' && payload.type === 'message') {
805
+ for (const block of payload.content || []) {
806
+ const name = codexBlockName(block.text || '');
807
+ if (name) report.parts.push(sized(name, [[payload.role, block.text]]));
808
+ }
809
+ }
810
+ if (record.type !== 'event_msg' || payload.type !== 'token_count' || !payload.info) continue;
811
+ report.firstRequest = payload.info.last_token_usage?.input_tokens ?? null;
812
+ break;
813
+ }
814
+ if (report.firstRequest == null) report.note = NOT_STARTED;
815
+ return report;
816
+ }
817
+
818
+ // ---------- Copilot CLI ----------
819
+ // The first system message holds the context as tagged blocks (<tools>, <custom_instruction>).
820
+ // A usage checkpoint, written later in the session, counts the tool definitions in tokens.
821
+
822
+ function copilotStart(session) {
823
+ const report = newReport(session);
824
+ const events = jsonLines(session.source.path);
825
+ const system = events.find(event => event.type === 'system.message')?.data?.content || '';
826
+ for (const [, tag, body] of system.matchAll(/<([a-z_]+)>(.*?)<\/\1>/gs)) report.parts.push(sized(tag.replace(/_/g, ' '), [['system', body]]));
827
+ const checkpoint = events.find(event => event.type === 'session.usage_checkpoint')?.data?.promptCacheBreakState?.[0]?.models;
828
+ const usage = checkpoint && Object.values(checkpoint)[0];
829
+ if (usage?.tool_tokens == null) {
830
+ report.note = 'Copilot counts its tool definitions at the first usage checkpoint; this session has not reached one.';
831
+ return report;
832
+ }
833
+ report.parts.push({ ...newPart('tool definitions', 'count'), chars: null, tokens: usage.tool_tokens, count: usage.tool_count ?? null });
834
+ return report;
835
+ }
836
+
837
+ const START_READERS = { claude: claudeStart, codex: codexStart, copilot: copilotStart };
838
+
839
+ // ---------- Tidying ----------
840
+
841
+ // Blocks with the same name become one part.
842
+ function mergedByName(parts) {
843
+ const byName = new Map();
844
+ for (const part of parts) {
845
+ const known = byName.get(part.name);
846
+ if (!known) {
847
+ byName.set(part.name, part);
848
+ continue;
849
+ }
850
+ known.chars += part.chars;
851
+ known.sources.push(...part.sources);
852
+ }
853
+ return [...byName.values()];
854
+ }
855
+
856
+ // Largest first; parts too small to matter are summed into "other", which comes last.
857
+ function ranked(parts) {
858
+ const other = newPart('other', 'size');
859
+ const kept = [];
860
+ for (const part of parts) {
861
+ if (part.chars == null || part.chars >= SMALL_PART_CHARS) kept.push(part);
862
+ else other.chars += part.chars;
863
+ }
864
+ kept.sort((a, b) => (b.chars ?? 0) - (a.chars ?? 0));
865
+ return other.chars ? [...kept, other] : kept;
866
+ }
867
+
868
+ export function startContext(session) {
869
+ const report = START_READERS[session.tool](session);
870
+ report.parts = ranked(mergedByName(report.parts));
871
+ for (const part of report.parts) part.sources.sort((a, b) => b.amount - a.amount);
872
+ return report;
873
+ }
874
+
875
+ // ---------- Printing ----------
876
+
877
+ const kilobytes = chars => `${(chars / 1024).toFixed(chars < 10 * 1024 ? 1 : 0)} KB`;
878
+ const sizeOf = part => (part.chars == null ? `${tokenCount(part.tokens)} tokens` : kilobytes(part.chars));
879
+
880
+ // "213: marketing-skills 41, claude-seo 25, +9 more" for a list, "MEMORY.md 18 KB, AGENTS.md 11 KB"
881
+ // for texts. A part that is a single text says nothing its name and size have not said.
882
+ function sourcesOf(part) {
883
+ if (part.measure === 'size' && part.sources.length < 2) return '';
884
+ const amount = source => (part.measure === 'size' ? kilobytes(source.amount) : String(source.amount));
885
+ const shown = part.sources.slice(0, SOURCES_SHOWN).map(source => `${source.label} ${amount(source)}`);
886
+ const hidden = part.sources.length - shown.length;
887
+ const list = shown.join(', ') + (hidden > 0 ? `, +${hidden} more` : '');
888
+ return part.count == null ? list : [String(part.count), list].filter(Boolean).join(': ');
889
+ }
890
+
891
+ export function formatStartContext(report) {
892
+ const rows = [['part', 'size', 'holds'], ...report.parts.map(part => [part.name, sizeOf(part), sourcesOf(part)])];
893
+ const [nameWidth, sizeWidth] = [0, 1].map(column => Math.max(...rows.map(row => row[column].length)));
894
+ return [
895
+ `context at session start ${report.tool} ${report.id}`,
896
+ ...(report.firstRequest == null ? [] : [`first request: ${tokenCount(report.firstRequest)} tokens`]),
897
+ ...rows.map(([name, size, holds]) => `${name.padEnd(nameWidth)} ${size.padStart(sizeWidth)} ${holds}`.trimEnd()),
898
+ `sizes are characters of text; ${CONTEXT_COMMANDS[report.tool]} shows this session's context in tokens`,
899
+ ...(report.note ? [report.note] : []),
682
900
  ].join('\n');
683
901
  }
684
902
 
685
903
  // Superwiki CLI. Lives in a project at docs/.sw/sw.mjs and prints short answers,
686
904
  // so agents do not have to read the vault to get them.
687
905
 
906
+ const SESSION_FLAGS = `[--session <id>] [--tool ${SESSION_TOOLS.join('|')}]`;
907
+
688
908
  const HELP = `sw <command> [--docs <dir>] [--json]
689
909
 
690
910
  status task counts per area and wiki page count
@@ -695,7 +915,8 @@ const HELP = `sw <command> [--docs <dir>] [--json]
695
915
  next-id <AREA> next free task id for an area (numbers are never reused)
696
916
  lint structural checks; exit code 1 on errors
697
917
  stats what the agent session here has cost so far: steps, context and tokens per agent
698
- [--session <id>] another session of this project [--tool ${STATS_TOOLS.join('|')}]
918
+ doctor what that session carried before it read anything: rule files, skill and tool lists
919
+ both take ${SESSION_FLAGS} to look at another session of this project
699
920
  serve [--open] start (or reuse) a local viewer at http://127.0.0.1:<port>/ that reads the files live
700
921
  snapshot write docs/.sw/data.js so docs/viewer.html opens as a file, frozen at this moment`;
701
922
 
@@ -756,7 +977,7 @@ export function vaultData(docs) {
756
977
  // "<" is escaped so page text can never close the script element the viewer loads this with.
757
978
  export const dataScript = data => `window.SW_DATA = ${JSON.stringify(data).replace(/</g, '\\u003c')};\n`;
758
979
 
759
- // ---------- Commands ----------
980
+ // ---------- Vault commands ----------
760
981
  // Each command gets { docs, vault, args, flags } and returns { data, text, code? } or
761
982
  // { error, code? }. `data` is what --json prints; `text` is the default output.
762
983
 
@@ -920,17 +1141,6 @@ function lintCommand({ vault }) {
920
1141
  };
921
1142
  }
922
1143
 
923
- // Agent sessions are recorded by the folder they ran in: the project root, which holds docs/.
924
- function stats({ docs, flags }) {
925
- if (flags.tool && !STATS_TOOLS.includes(flags.tool)) {
926
- return { error: `usage: sw stats [--session <id>] [--tool ${STATS_TOOLS.join('|')}]` };
927
- }
928
- const root = dirname(docs);
929
- const session = sessionStats(root, { tool: flags.tool, id: flags.session });
930
- if (!session) return { error: `no ${flags.tool || 'agent'} session record found for ${root}`, code: 1 };
931
- return { data: session, text: formatStats(session) };
932
- }
933
-
934
1144
  function snapshot({ docs }) {
935
1145
  const data = vaultData(docs);
936
1146
  mkdirSync(join(docs, '.sw'), { recursive: true });
@@ -939,6 +1149,31 @@ function snapshot({ docs }) {
939
1149
  return { data: { files: data.files.length }, text };
940
1150
  }
941
1151
 
1152
+ // ---------- Session commands ----------
1153
+ // These read the record an agent tool keeps of a session, not the vault. Tools file a session
1154
+ // under the folder it ran in: the project root, which holds docs/.
1155
+
1156
+ function sessionArg(command, { docs, flags }) {
1157
+ if (flags.tool && !SESSION_TOOLS.includes(flags.tool)) return { error: `usage: sw ${command} ${SESSION_FLAGS}` };
1158
+ const root = dirname(docs);
1159
+ const session = findSession(root, { tool: flags.tool, id: flags.session });
1160
+ return session || { error: `no ${flags.tool || 'agent'} session record found for ${root}`, code: 1 };
1161
+ }
1162
+
1163
+ function statsCommand(ctx) {
1164
+ const session = sessionArg('stats', ctx);
1165
+ if (session.error) return session;
1166
+ const stats = sessionStats(session);
1167
+ return { data: stats, text: formatStats(stats) };
1168
+ }
1169
+
1170
+ function doctorCommand(ctx) {
1171
+ const session = sessionArg('doctor', ctx);
1172
+ if (session.error) return session;
1173
+ const report = startContext(session);
1174
+ return { data: report, text: formatStartContext(report) };
1175
+ }
1176
+
942
1177
  // ---------- Viewer server ----------
943
1178
  // A file:// page cannot read local files unless the user picks a folder. Served from localhost it
944
1179
  // can: every Refresh asks this process, which reads the files as they are now.
@@ -1038,7 +1273,8 @@ const COMMANDS = {
1038
1273
  search: { run: searchCommand, needsVault: true },
1039
1274
  'next-id': { run: nextIdCommand, needsVault: true },
1040
1275
  lint: { run: lintCommand, needsVault: true },
1041
- stats: { run: stats, needsVault: false },
1276
+ stats: { run: statsCommand, needsVault: false },
1277
+ doctor: { run: doctorCommand, needsVault: false },
1042
1278
  snapshot: { run: snapshot, needsVault: false },
1043
1279
  serve: { run: serve, needsVault: false },
1044
1280
  };
@@ -24,7 +24,7 @@ It reports the session it runs in. For another session of this project, add `--s
24
24
  | --- | --- |
25
25
  | one agent's `sent` is most of the total | which agent, and its share. That is where the session's cost is |
26
26
  | an agent's `peak` is several times its `first` | its context grew during the work; `steps` times a large context is what makes `sent` large |
27
- | `first` is large for `main` | the session starts heavy before it reads anything: rules files, plugins and tool definitions. The tool's own context command shows what fills it |
27
+ | `first` is large, for `main` or for the subagents | every agent starts heavy before it reads anything: rules, memory, and the lists of skills and tools. sw-doctor shows what fills it and what can go |
28
28
  | `cached` is well below the others for one agent | much of what it sent was not served from the cache, which costs more per token. Long pauses do that |
29
29
  | `main` has many `steps` or a `peak` far above its `first` | work is running in the main session that a subagent could do in a clean context |
30
30
 
@@ -36,7 +36,7 @@ So that you can answer a question about them:
36
36
 
37
37
  - `steps`: model requests. `tools`: tool calls. `min`: minutes between the agent's first and last record.
38
38
  - `first`, `peak`: tokens sent with the first request and with the largest one.
39
- - `sent`: tokens sent, summed over every step. A step re-sends the whole context, so this is far larger than `peak`.
39
+ - `sent`: tokens sent, summed over every step. A step sends the whole context again, so this is far larger than `peak`.
40
40
  - `cached`: the share of `sent` that was read from the cache.
41
41
  - `output`: tokens the model wrote.
42
42
  - `-`: the tool's record does not hold that number.