packlight-cli 0.0.0-stage → 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 (60) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -2
  4. package/dist/adapters/claude-code/inventory.d.ts +48 -0
  5. package/dist/adapters/claude-code/inventory.js +449 -0
  6. package/dist/adapters/claude-code/logs.d.ts +54 -0
  7. package/dist/adapters/claude-code/logs.js +359 -0
  8. package/dist/adapters/codex/inventory.d.ts +17 -0
  9. package/dist/adapters/codex/inventory.js +130 -0
  10. package/dist/adapters/codex/logs.d.ts +22 -0
  11. package/dist/adapters/codex/logs.js +151 -0
  12. package/dist/adapters/codex/toml.d.ts +10 -0
  13. package/dist/adapters/codex/toml.js +76 -0
  14. package/dist/archive/apply.d.ts +62 -0
  15. package/dist/archive/apply.js +284 -0
  16. package/dist/archive/fsops.d.ts +24 -0
  17. package/dist/archive/fsops.js +95 -0
  18. package/dist/archive/journal.d.ts +41 -0
  19. package/dist/archive/journal.js +95 -0
  20. package/dist/archive/lock.d.ts +2 -0
  21. package/dist/archive/lock.js +32 -0
  22. package/dist/archive/manifest.d.ts +37 -0
  23. package/dist/archive/manifest.js +9 -0
  24. package/dist/archive/methods.d.ts +46 -0
  25. package/dist/archive/methods.js +239 -0
  26. package/dist/archive/paths.d.ts +18 -0
  27. package/dist/archive/paths.js +61 -0
  28. package/dist/archive/restore.d.ts +30 -0
  29. package/dist/archive/restore.js +84 -0
  30. package/dist/cli.d.ts +2 -0
  31. package/dist/cli.js +329 -0
  32. package/dist/core/buckets.d.ts +3 -0
  33. package/dist/core/buckets.js +6 -0
  34. package/dist/core/frontmatter.d.ts +6 -0
  35. package/dist/core/frontmatter.js +31 -0
  36. package/dist/core/hash.d.ts +19 -0
  37. package/dist/core/hash.js +42 -0
  38. package/dist/core/messages.d.ts +16 -0
  39. package/dist/core/messages.js +17 -0
  40. package/dist/core/scan.d.ts +16 -0
  41. package/dist/core/scan.js +345 -0
  42. package/dist/core/summary.d.ts +4 -0
  43. package/dist/core/summary.js +63 -0
  44. package/dist/core/types.d.ts +185 -0
  45. package/dist/core/types.js +3 -0
  46. package/dist/report/client.d.ts +1 -0
  47. package/dist/report/client.js +676 -0
  48. package/dist/report/fix.d.ts +46 -0
  49. package/dist/report/fix.js +116 -0
  50. package/dist/report/logo.d.ts +7 -0
  51. package/dist/report/logo.js +23 -0
  52. package/dist/report/model.d.ts +103 -0
  53. package/dist/report/model.js +194 -0
  54. package/dist/report/render.d.ts +9 -0
  55. package/dist/report/render.js +45 -0
  56. package/dist/report/styles.d.ts +1 -0
  57. package/dist/report/styles.js +202 -0
  58. package/dist/report/suggest.d.ts +39 -0
  59. package/dist/report/suggest.js +88 -0
  60. package/package.json +56 -4
@@ -0,0 +1,676 @@
1
+ export function clientMain() {
2
+ const data = JSON.parse(document.getElementById('packlight-data').textContent);
3
+ const KINDS = [['skill', 'Skills'], ['command', 'Commands'], ['agent', 'Agents'], ['hook', 'Hooks'], ['plugin', 'Plugins'], ['mcp', 'MCP servers'], ['instructions', 'Instructions']];
4
+ const AGENT = data.agent === 'codex' ? 'Codex' : 'Claude Code';
5
+ const KIND_TEXT = data.agent === 'codex' ? {
6
+ skill: 'Each skill\'s name, description and path sit in a listing Codex sees every session. Codex opens a skill\'s SKILL.md when it uses it.',
7
+ command: 'Custom prompts you type as slash commands.',
8
+ agent: 'Subagents Codex can hand work to.',
9
+ hook: 'Programs Codex runs on events.',
10
+ plugin: 'Bundles of skills and tools. Switch one off in Codex\'s plugin settings, or with enabled = false in ~/.codex/config.toml.',
11
+ mcp: 'Tool servers Codex can call. Switch one off with enabled = false under its [mcp_servers] table in ~/.codex/config.toml.',
12
+ instructions: 'AGENTS.md files, loaded in full in their project. packlight never changes these.',
13
+ } : {
14
+ skill: 'Each skill\'s name and description sit in a listing Claude sees every session. When the listing runs out of room, descriptions are dropped.',
15
+ command: 'Slash commands you type. Their descriptions share the skill listing.',
16
+ agent: 'Subagents Claude can hand work to. Their descriptions load every session.',
17
+ hook: 'Scripts that run on events. Some add text to the session each time they fire.',
18
+ plugin: 'Bundles of the other kinds. Archiving a plugin turns off every part of it.',
19
+ mcp: 'Tool servers Claude can call. Claude Code lists every tool\'s name at the start of each session, and some servers add instructions too, so an unused server still costs its load.',
20
+ instructions: 'CLAUDE.md and similar files, loaded in full in their project. packlight never changes these.',
21
+ };
22
+ const ONE = { skill: 'skill', command: 'command', agent: 'agent', hook: 'hook', plugin: 'plugin', mcp: 'MCP server' };
23
+ const METHOD_TEXT = {
24
+ move: 'Moved into packlight\'s archive', 'hook-extract': 'Cut out of their settings files', 'mcp-extract': 'Cut out of their config files',
25
+ 'plugin-disable': 'Plugins turned off', manual: 'Removed by hand (packlight records them)',
26
+ };
27
+ function h(tag, props, ...kids) {
28
+ const el = document.createElement(tag);
29
+ for (const [k, v] of Object.entries(props ?? {})) {
30
+ if (v === undefined || v === null || v === false)
31
+ continue;
32
+ if (k === 'class')
33
+ el.className = String(v);
34
+ else if (k.startsWith('on') && typeof v === 'function')
35
+ el.addEventListener(k.slice(2), v);
36
+ else if (v === true)
37
+ el.setAttribute(k, '');
38
+ else
39
+ el.setAttribute(k, String(v));
40
+ }
41
+ const add = (c) => {
42
+ if (c === null || c === undefined || c === false)
43
+ return;
44
+ if (Array.isArray(c)) {
45
+ c.forEach(add);
46
+ return;
47
+ }
48
+ el.append(typeof c === 'string' || typeof c === 'number' ? document.createTextNode(String(c)) : c);
49
+ };
50
+ kids.forEach(add);
51
+ return el;
52
+ }
53
+ const svg = (path) => {
54
+ const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
55
+ s.setAttribute('viewBox', '0 0 12 12');
56
+ s.setAttribute('aria-hidden', 'true');
57
+ const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
58
+ p.setAttribute('d', path);
59
+ p.setAttribute('fill', 'none');
60
+ p.setAttribute('stroke', 'currentColor');
61
+ p.setAttribute('stroke-width', '1.6');
62
+ s.append(p);
63
+ return s;
64
+ };
65
+ const chars = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1).replace(/\.0$/, '')}M` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(Math.round(n)));
66
+ const date = (s) => (s ? new Date(s).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: new Date(s).getFullYear() === new Date().getFullYear() ? undefined : 'numeric' }) : '—');
67
+ // Tokens are estimated from characters (logs record characters): about 4 per token, the same as CHARS_PER_TOKEN in model.ts.
68
+ const tokens = (c) => chars(c / 4);
69
+ const setupTotal = () => { const l = data.sessionLoad; return !l ? 0 : l.measuredTokens ? l.measuredTokens * 4 : l.skillListing + l.mcpTools + l.mcpInstructions + l.hookStart + l.agents + (l.agentsMd ?? 0) + (l.pluginInstructions ?? 0); };
70
+ const share = (part, whole) => { const p = Math.min(100, (part / whole) * 100); return p > 0 && p < 1 ? 'under 1%' : `${Math.round(p)}%`; };
71
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
72
+ const tildify = (p) => (p.startsWith(data.home) ? `~${p.slice(data.home.length)}` : p);
73
+ const live = document.getElementById('live');
74
+ const say = (text) => { live.textContent = ''; window.setTimeout(() => { live.textContent = text; }, 30); };
75
+ // ------------------------------------------------------------------ state
76
+ const byId = new Map(data.items.map(i => [i.id, i]));
77
+ const pluginItem = (key) => data.items.find(i => i.kind === 'plugin' && i.plugin === key);
78
+ const projectName = (root) => data.projects.find(p => p.root === root)?.name ?? root.split(/[\\/]/).pop() ?? root;
79
+ const fromHash = new URLSearchParams(location.hash.slice(1));
80
+ const state = {
81
+ tab: fromHash.get('tab') ?? 'items',
82
+ view: fromHash.get('project') ?? (data.scope !== 'global' ? data.scope : 'global'),
83
+ kind: 'skill',
84
+ suggestedOnly: false,
85
+ pushedOutOnly: false,
86
+ q: '',
87
+ sort: 'cost',
88
+ open: new Set(),
89
+ confirmPlugin: null,
90
+ fixOpen: false,
91
+ marks: {},
92
+ saved: false,
93
+ downloadFailed: null,
94
+ storage: 'ok',
95
+ carry: null,
96
+ };
97
+ if (state.view !== 'global' && !data.projects.some(p => p.root === state.view))
98
+ state.view = 'global';
99
+ // Marks are stored per project scope and scan id; a newer report offers to bring them over (design DR5, DE4).
100
+ const storeKey = `packlight:marks:${data.agent}:${data.scope}`;
101
+ try {
102
+ const raw = window.localStorage.getItem(storeKey);
103
+ const stored = raw ? JSON.parse(raw) : null;
104
+ if (stored && stored.scanId === data.scanId)
105
+ state.marks = stored.marks;
106
+ else if (stored && stored.scanId < data.scanId && Object.keys(stored.marks).length)
107
+ state.carry = { source: 'storage', scanId: stored.scanId, marks: stored.marks };
108
+ }
109
+ catch {
110
+ state.storage = 'blocked';
111
+ }
112
+ if (!state.carry && !Object.keys(state.marks).length && data.savedPicks && Object.keys(data.savedPicks.marks).length) {
113
+ state.carry = { source: 'saved', scanId: data.savedPicks.scanId, marks: data.savedPicks.marks };
114
+ }
115
+ const persist = () => {
116
+ if (state.storage === 'blocked')
117
+ return;
118
+ try {
119
+ window.localStorage.setItem(storeKey, JSON.stringify({ scanId: data.scanId, marks: state.marks }));
120
+ }
121
+ catch {
122
+ state.storage = 'blocked';
123
+ }
124
+ };
125
+ // ------------------------------------------------------------------ derived values
126
+ const inView = (i) => state.view === 'global' || i.projectRoot === null || i.projectRoot === state.view;
127
+ const budget = () => (state.view === 'global' ? undefined : data.budget[state.view]);
128
+ const listingStatus = (i) => {
129
+ const b = budget();
130
+ if (!b || (i.kind !== 'skill' && i.kind !== 'command'))
131
+ return null;
132
+ if (i.logKeys.some(k => b.dropped.includes(k)))
133
+ return 'pushed out';
134
+ if (i.logKeys.some(k => b.withDescription.includes(k)))
135
+ return 'listed';
136
+ return null;
137
+ };
138
+ /** The item a mark lands on: plugin children are marked through their plugin (CEO O3, DR7). */
139
+ const markTarget = (i) => (i.plugin && i.kind !== 'plugin' ? pluginItem(i.plugin) ?? i : i);
140
+ const markOf = (i) => state.marks[markTarget(i).id];
141
+ // Suites and built-in skills are never archived (their own uninstaller or the app owns them).
142
+ const markable = (i) => i.kind !== 'instructions' && !i.removal.suite;
143
+ const archived = () => Object.entries(state.marks).filter(([, m]) => m === 'archive').map(([id]) => byId.get(id)).filter((x) => !!x);
144
+ const usageCell = (i) => {
145
+ if (!data.hasLogs)
146
+ return 'Unavailable';
147
+ if (i.usage.ambiguous)
148
+ return `ambiguous: ${i.usage.total} shared`;
149
+ return i.usage.total ? String(i.usage.total) : 'not observed';
150
+ };
151
+ const reasonText = (i) => {
152
+ const s = i.suggestion;
153
+ if (s.suggested)
154
+ return `Suggested: not observed in any of ${s.windowSessions} sessions over ${s.windowDays} days.`;
155
+ switch (s.reason) {
156
+ case 'used': return `Used ${plural(i.usage.total, 'time')}.`;
157
+ case 'too-new': return `Installed ${plural(s.windowDays, 'day')} ago: too new to judge.`;
158
+ case 'too-few-sessions': return `Only ${s.windowSessions} of the ${data.thresholds.sessions} sessions needed to judge it.`;
159
+ case 'ambiguous': return 'Shares a name or command with another item, so its usage cannot be told apart.';
160
+ case 'uncertain': return 'Usage may be incomplete: packlight could not read some log lines.';
161
+ case 'parse-gaps': return `Usage uncertain: ${plural(s.unreadableLines, 'unreadable line')} in the sessions since it was installed.`;
162
+ case 'manual': return `Only removable by hand: ${i.removal.where ?? ''}`;
163
+ case 'kept': return `You kept it on ${date(i.keptAt)}.`;
164
+ case 'off': return 'Turned off already.';
165
+ case 'plugin-in-use': return 'Another part of its plugin is in use.';
166
+ default: return '';
167
+ }
168
+ };
169
+ const loadOf = (i) => {
170
+ if (i.standingChars)
171
+ return { value: i.standingChars, unit: i.kind === 'instructions' ? 'in its project' : 'every session' };
172
+ if (i.usage.hook && i.usage.hook.firings)
173
+ return { value: i.usage.hook.injectedChars / i.usage.hook.firings, unit: 'per firing' };
174
+ if (i.kind === 'plugin') {
175
+ const sum = data.items.filter(c => c.plugin === i.plugin && c.kind !== 'plugin').reduce((n, c) => n + c.standingChars, 0);
176
+ return sum ? { value: sum, unit: 'every session' } : null;
177
+ }
178
+ return null;
179
+ };
180
+ const visibleItems = () => data.items.filter(inView);
181
+ const suggestedInView = () => visibleItems().filter(i => i.suggestion.suggested);
182
+ /** Projected listing after archiving `gone` (a projection, always labelled so). */
183
+ function projected(gone) {
184
+ const b = budget();
185
+ if (!b)
186
+ return null;
187
+ // Same model as the CLI's fix plan (report/fix.ts listingEntries): logged order, Claude Code's used skills first.
188
+ const byKey = new Map();
189
+ for (const i of data.items)
190
+ for (const k of i.logKeys)
191
+ if (!byKey.has(k))
192
+ byKey.set(k, i);
193
+ const priority = new Set(data.listingPriority);
194
+ const lines = b.entries ?? [...b.withDescription.map(name => ({ name, chars: 0, described: true })), ...b.dropped.map(name => ({ name, chars: 0, described: false }))];
195
+ const entries = lines.map(l => {
196
+ const it = byKey.get(l.name);
197
+ const nameChars = l.name.length + 3;
198
+ const full = l.described && l.chars ? l.chars : it?.standingChars ? it.standingChars : l.chars || nameChars;
199
+ return { name: l.name, it, nameChars, fullChars: Math.max(full, nameChars), priority: priority.has(l.name) || !it };
200
+ }).filter(e => !(e.it && (gone.has(e.it.id) || (e.it.plugin && gone.has(markTarget(e.it).id)))));
201
+ return simulateListing(entries, b.dropped.length ? b.listingChars : Number.MAX_SAFE_INTEGER).dropped;
202
+ }
203
+ // ------------------------------------------------------------------ rendering
204
+ const root = document.getElementById('app');
205
+ const keyOf = (el) => (el instanceof HTMLElement ? el.dataset.key ?? null : null);
206
+ function render() {
207
+ const focusKey = keyOf(document.activeElement);
208
+ location.replace(`#${new URLSearchParams({ tab: state.tab, ...(state.view !== data.scope ? { project: state.view } : {}) }).toString()}`);
209
+ root.replaceChildren(masthead(), tabs(), h('main', { id: 'main' }, h('div', { class: 'wrap' }, panel())), footer(), picksBar());
210
+ const again = focusKey ? root.querySelector(`[data-key="${CSS.escape(focusKey)}"]`) : null;
211
+ if (again) {
212
+ again.focus();
213
+ if (again instanceof HTMLInputElement)
214
+ again.setSelectionRange(again.value.length, again.value.length);
215
+ }
216
+ }
217
+ function footer() {
218
+ return h('footer', { class: 'foot' }, h('div', { class: 'wrap' }, h('span', null, 'packlight, built by Cooper Kao'), h('a', { href: 'https://github.com/cooperism-lab/packlight', rel: 'noreferrer', target: '_blank' }, 'github.com/cooperism-lab/packlight'), h('span', null, 'This page runs only on your computer and sends nothing.')));
219
+ }
220
+ // The mark from report/logo.ts, drawn with DOM calls (no markup strings in the page, CEO F4).
221
+ function logo() {
222
+ const NS = 'http://www.w3.org/2000/svg';
223
+ const el = (tag, attrs, ...kids) => {
224
+ const e = document.createElementNS(NS, tag);
225
+ for (const [k, v] of Object.entries(attrs))
226
+ e.setAttribute(k, v);
227
+ e.append(...kids);
228
+ return e;
229
+ };
230
+ const cut = { stroke: '#000' };
231
+ return el('svg', { viewBox: '0 0 64 64', 'aria-hidden': 'true' }, el('defs', {}, el('mask', { id: 'pl-cut', maskUnits: 'userSpaceOnUse', x: '0', y: '0', width: '64', height: '64' }, el('rect', { width: '64', height: '64', fill: '#fff' }), el('path', { d: 'M15.5 34v19M48.5 34v19', ...cut, 'stroke-width': '2.5' }), el('path', { d: 'M22 15.5v7.5a5 5 0 0 0 5 5h10a5 5 0 0 0 5-5v-7.5', fill: 'none', ...cut, 'stroke-width': '3' }), el('rect', { x: '29', y: '23', width: '6', height: '8', rx: '2', fill: '#000' }), el('rect', { x: '22', y: '39', width: '20', height: '20', rx: '4', fill: 'none', ...cut, 'stroke-width': '3' }))), el('g', { mask: 'url(#pl-cut)', fill: 'currentColor' }, el('path', { d: 'M28 13v-2a4 4 0 0 1 8 0v2', fill: 'none', stroke: 'currentColor', 'stroke-width': '4' }), el('rect', { x: '10', y: '35', width: '10', height: '17', rx: '3.5' }), el('rect', { x: '44', y: '35', width: '10', height: '17', rx: '3.5' }), el('rect', { x: '16', y: '12', width: '32', height: '47', rx: '7' })), el('rect', { x: '24.5', y: '41.5', width: '15', height: '17.5', rx: '2', fill: '#3fae6e' }));
232
+ }
233
+ function masthead() {
234
+ const select = h('select', { 'aria-label': 'Project', 'data-key': 'project', onchange: (e) => { state.view = e.target.value; state.pushedOutOnly = false; render(); say(`Showing ${state.view === 'global' ? 'all projects' : projectName(state.view)}`); } }, h('option', { value: 'global', selected: state.view === 'global' }, 'All projects'), data.projects.map(p => h('option', { value: p.root, selected: state.view === p.root }, `${p.name} (${plural(p.sessions, 'session')})`)));
235
+ return h('header', { class: 'mast' }, h('div', { class: 'wrap' }, h('span', { class: 'brand' }, logo(), 'packlight'), h('span', { class: 'meta' }, select, h('span', null, AGENT), h('span', null, `scanned ${date(data.createdAt)}`))));
236
+ }
237
+ function tabs() {
238
+ const picks = Object.keys(state.marks).length;
239
+ const open = data.archived.filter(a => a.status !== 'restored').length;
240
+ const list = [['items', 'Items'], ['findings', `Findings (${data.findings.length})`], ['picks', `Picks (${picks})`], ['archived', `Archived (${open})`]];
241
+ const nav = h('nav', { class: 'tabs', 'aria-label': 'Sections' }, h('div', { class: 'wrap', role: 'tablist' }, list.map(([id, label]) => h('button', {
242
+ role: 'tab', id: `tab-${id}`, 'aria-selected': String(state.tab === id), 'aria-controls': 'main', tabindex: state.tab === id ? 0 : -1, 'data-key': `tab-${id}`,
243
+ onclick: () => { state.tab = id; render(); },
244
+ onkeydown: (e) => {
245
+ if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft')
246
+ return;
247
+ const i = list.findIndex(([x]) => x === state.tab);
248
+ state.tab = list[(i + (e.key === 'ArrowRight' ? 1 : list.length - 1)) % list.length][0];
249
+ render();
250
+ document.getElementById(`tab-${state.tab}`)?.focus();
251
+ },
252
+ }, label))));
253
+ return nav;
254
+ }
255
+ function panel() {
256
+ const body = state.tab === 'findings' ? findingsPanel() : state.tab === 'picks' ? picksPanel() : state.tab === 'archived' ? archivedPanel() : itemsPanel();
257
+ return h('section', { role: 'tabpanel', 'aria-labelledby': `tab-${state.tab}` }, body);
258
+ }
259
+ // ------------------------------------------------------------------ Items
260
+ function banners() {
261
+ const out = [];
262
+ if (!data.hasLogs)
263
+ out.push(h('p', { class: 'banner warn' }, `No session logs at ${tildify(data.home)}/.claude/projects. Usage is unknown, not zero, so nothing is suggested for archive.`));
264
+ if (state.carry) {
265
+ const still = Object.keys(state.carry.marks).filter(id => byId.has(id));
266
+ out.push(h('div', { class: 'banner' }, h('span', null, state.carry.source === 'saved'
267
+ ? `Your saved picks from the ${date(state.carry.scanId.slice(0, 10))} scan hold ${plural(Object.keys(state.carry.marks).length, 'mark')}; ${still.length} still match this scan. Only marks you saved with Save my picks carry over in this browser.`
268
+ : `You marked ${plural(Object.keys(state.carry.marks).length, 'item')} in the ${date(state.carry.scanId.slice(0, 10))} scan. ${still.length} still match this scan.`), h('button', { class: 'btn small', 'data-key': 'carry-yes', onclick: () => { for (const id of still)
269
+ state.marks[id] = state.carry.marks[id]; state.carry = null; persist(); render(); say(`Brought over ${plural(still.length, 'mark')}`); } }, 'Bring them over'), h('button', { class: 'btn small', 'data-key': 'carry-no', onclick: () => { state.carry = null; persist(); render(); } }, 'Discard')));
270
+ }
271
+ if (state.storage === 'blocked')
272
+ out.push(h('p', { class: 'banner' }, 'Marks last until you close this page (this browser blocks saved marks).'));
273
+ return out;
274
+ }
275
+ // The top of the report: one headline, one bar, one action. Details live in each kind's section.
276
+ function hero() {
277
+ const context = h('p', { class: 'context' }, h('span', null, state.view === 'global' ? 'All projects' : projectName(state.view)), h('span', null, AGENT), h('span', null, `${date(data.window.from)} to ${date(data.window.to)}`), h('span', null, plural(data.sessions, 'session')), data.linesUnreadable ? h('span', null, `parse gaps: ${plural(data.linesUnreadable, 'line')}`) : null);
278
+ const go = (kind, pushed = false) => () => { state.kind = kind; state.sort = 'cost'; state.suggestedOnly = false; state.pushedOutOnly = pushed; render(); document.querySelector('.kindhead')?.scrollIntoView({ block: 'start' }); };
279
+ // Codex sessions mostly run outside a project, so its all-projects view keeps the measured headline.
280
+ if (state.view === 'global' && !(data.agent === 'codex' && data.sessionLoad)) {
281
+ const top = [...data.projects].sort((a, b) => b.sessions - a.sessions).slice(0, 5);
282
+ return h('div', { class: 'hero' }, context, h('h1', null, 'Pick a project to see what its sessions start with'), h('p', { class: 'hero-line' }, top.map(p => h('button', { class: 'btn', 'data-key': `pick-${p.root}`, onclick: () => { state.view = p.root; render(); } }, p.name))));
283
+ }
284
+ // What each session starts with, by source. Measured for the scanned project only.
285
+ const L = state.view === data.scope ? data.sessionLoad : null;
286
+ const b = budget();
287
+ const parts = L ? [
288
+ { key: 'mcp', label: 'MCP tool names', value: L.mcpTools, kind: 'mcp' },
289
+ { key: 'skills', label: 'skill listing', value: L.skillListing, kind: 'skill' },
290
+ { key: 'hooks', label: 'hook text', value: L.hookStart, kind: 'hook' },
291
+ { key: 'instr', label: 'MCP instructions', value: L.mcpInstructions, kind: 'mcp' },
292
+ { key: 'agents', label: 'agent descriptions', value: L.agents, kind: 'agent' },
293
+ { key: 'agentsmd', label: 'AGENTS.md', value: L.agentsMd ?? 0, kind: 'instructions' },
294
+ { key: 'plugins', label: 'plugin and app instructions', value: L.pluginInstructions ?? 0, kind: 'plugin' },
295
+ ].filter(p => p.value > 0).sort((x, y) => y.value - x.value) : [];
296
+ // Codex measures the whole first request; what packlight cannot attribute is Codex's own prompt and tools.
297
+ if (L?.measuredTokens) {
298
+ const rest = L.measuredTokens * 4 - parts.reduce((n, p) => n + p.value, 0);
299
+ if (rest > 0)
300
+ parts.push({ key: 'base', label: `${AGENT} itself (its prompt and tools)`, value: rest, kind: 'skill', fixed: true });
301
+ }
302
+ const total = parts.reduce((n, p) => n + p.value, 0);
303
+ if (!total) {
304
+ if (!b)
305
+ return h('div', { class: 'hero' }, context, h('h1', null, `Nothing measured for ${projectName(state.view)} yet`), h('p', { class: 'lede' }, `${AGENT} records what a session starts with when it begins. None of the scanned sessions in this project did, so packlight cannot measure it.`));
306
+ const n = b.dropped.length;
307
+ return h('div', { class: 'hero' }, context, h('h1', null, n ? `${n} skills pushed out of ${projectName(state.view)}'s skill listing` : `All ${n + b.withDescription.length} skills fit in ${projectName(state.view)}'s listing`), n ? h('p', { class: 'hero-line' }, h('button', { class: 'btn', 'data-key': 'review-pushed', onclick: go('skill', true) }, `Show the ${n}`)) : null);
308
+ }
309
+ // What the fix could take off, drawn over the end of the bar (projected).
310
+ const f = data.fix;
311
+ const saved = f.ids.length ? f.sessionCharsSaved : f.byHand?.sessionCharsSaved ?? 0;
312
+ const bar = h('div', { class: 'loadbar', role: 'img', 'aria-label': `Each session starts with about ${tokens(total)} tokens (${chars(total)} characters): ${parts.map(p => `${tokens(p.value)} tokens of ${p.label}`).join(', ')}${saved > 0 ? `. About ${tokens(saved)} tokens could go` : ''}` }, parts.map(p => h('i', { class: `seg seg-${p.key}`, style: `width:${(p.value / total) * 100}%` })), saved > 0 ? h('i', { class: 'save', style: `width:${Math.min(100, (saved / total) * 100)}%` }) : null);
313
+ const legend = h('div', { class: 'legend' }, parts.map(p => (p.fixed
314
+ ? h('span', { class: 'legend-item static' }, h('i', { class: `sw seg-${p.key}`, 'aria-hidden': 'true' }), h('b', { class: 'num' }, tokens(p.value)), ` ${p.label}`)
315
+ : h('button', { class: 'legend-item', 'data-key': `load-${p.key}`, title: `${chars(p.value)} characters`, onclick: go(p.kind, false) }, h('i', { class: `sw seg-${p.key}`, 'aria-hidden': 'true' }), h('b', { class: 'num' }, tokens(p.value)), ` ${p.label}`))), saved > 0 ? h('span', { class: 'legend-item static' }, h('i', { class: 'sw save', 'aria-hidden': 'true' }), h('b', { class: 'num' }, tokens(saved)), ' could go') : null);
316
+ return h('div', { class: 'hero' }, context, h('h1', null, `${state.view === 'global' ? `Each ${AGENT} session` : `Each session in ${projectName(state.view)}`} starts with about ${tokens(total)} tokens of setup`), bar, legend, L?.measuredTokens ? h('p', { class: 'measured' }, `Measured by ${AGENT}: the median first request across ${plural(L.measuredSessions ?? 0, 'session')}, including your first message. The parts are estimated at about 4 characters per token.`) : null, fixRow(parts.filter(p => !p.fixed)[0]));
317
+ }
318
+ // The one-click fix (report/fix.ts) as one line; everything else opens below it on demand.
319
+ // Always one clear next step: fix, see what to remove by hand, or look at the biggest cost.
320
+ function fixRow(top) {
321
+ const f = data.fix;
322
+ if (state.view !== data.scope)
323
+ return null;
324
+ if (!f.ids.length && !f.byHand?.items.length) {
325
+ const target = top ?? { label: 'your skills', kind: 'skill' };
326
+ return h('section', { class: 'fix', 'aria-label': 'Fix' }, h('div', { class: 'fix-row' }, h('p', null, h('b', null, 'Nothing unused to remove.'), ` Your biggest cost is ${target.label}.`), h('button', { class: 'btn primary', 'data-key': 'fix', onclick: () => {
327
+ state.kind = target.kind;
328
+ state.sort = 'cost';
329
+ state.suggestedOnly = false;
330
+ state.pushedOutOnly = false;
331
+ render();
332
+ document.querySelector('.kindhead')?.scrollIntoView({ block: 'start' });
333
+ } }, 'See the biggest cost')));
334
+ }
335
+ const hand = f.byHand;
336
+ const total = setupTotal();
337
+ const gains = (back, saved) => [
338
+ saved > 0 ? `saves about ${tokens(saved)} tokens per session${total ? ` (${share(saved, total)} of your setup)` : ''}` : null,
339
+ back ? `${plural(back, 'skill description')} back` : null,
340
+ ].filter((x) => !!x);
341
+ const exact = (saved) => (saved > 0 ? h('p', { class: 'muted' }, `Projected: ${chars(saved)} fewer characters per session, about ${tokens(saved)} tokens at roughly 4 characters per token.`) : null);
342
+ const headline = f.ids.length
343
+ ? [h('b', null, `Archive ${plural(f.ids.length, 'unused item')}`), gains(f.descriptionsBack, f.sessionCharsSaved).map(g => ` · ${g}`)]
344
+ : [h('b', null, `${plural(hand.items.length, 'unused item')} only you can remove`), gains(hand.descriptionsBack, hand.sessionCharsSaved).map(g => ` · ${g}`)];
345
+ const kinds = KINDS.filter(([k]) => f.byKind[k]).map(([k, label]) => (f.byKind[k] === 1 ? `1 ${ONE[k]}` : `${f.byKind[k]} ${k === 'mcp' ? label : label.toLowerCase()}`)).join(', ');
346
+ const groups = new Map();
347
+ for (const i of hand?.items ?? [])
348
+ groups.set(i.where, [...(groups.get(i.where) ?? []), i]);
349
+ return h('section', { class: 'fix', 'aria-label': 'Fix' }, h('div', { class: 'fix-row' }, h('p', null, ...headline, (f.ids.length ? gains(f.descriptionsBack, f.sessionCharsSaved) : gains(hand.descriptionsBack, hand.sessionCharsSaved)).length
350
+ ? h('span', { class: 'muted' }, ' (projected)')
351
+ : h('span', { class: 'muted' }, ' · they load nothing in these sessions, so removing them only tidies up')), h('button', { class: 'btn primary', 'data-key': 'fix', 'aria-expanded': String(state.fixOpen), onclick: () => { state.fixOpen = !state.fixOpen; render(); } }, f.ids.length ? 'Fix it' : state.fixOpen ? 'Hide the list' : 'Show me what to remove')), state.fixOpen ? h('div', { class: 'fix-run', role: 'region', 'aria-label': 'Run the fix' }, f.ids.length ? [
352
+ h('p', { class: 'muted' }, `${kinds}. None was used since it was installed, over at least ${data.thresholds.sessions} sessions and ${data.thresholds.days} days. Everything can be restored.`),
353
+ exact(f.sessionCharsSaved),
354
+ h('p', null, 'Run this in your terminal. It scans again, shows the plan and asks once before changing anything.'),
355
+ h('div', { class: 'codebox' }, h('code', null, data.messages.fixCommand), h('button', { class: 'btn small', 'data-key': 'copy-fix', onclick: copy(data.messages.fixCommand, 'the fix command') }, 'Copy')),
356
+ h('button', { class: 'linkish', 'data-key': 'fix-review', onclick: () => {
357
+ for (const id of f.ids)
358
+ state.marks[id] = 'archive';
359
+ persist();
360
+ state.tab = 'picks';
361
+ render();
362
+ say(`Marked ${plural(f.ids.length, 'item')} for archive`);
363
+ } }, 'Review them one by one instead'),
364
+ ] : null, hand?.items.length ? h('div', { class: 'byhand' }, h('p', null, h('b', null, f.ids.length ? `Also unused, but only you can remove: ${plural(hand.items.length, 'item')}` : 'Remove these where they were added'), f.ids.length && gains(hand.descriptionsBack, hand.sessionCharsSaved).length ? h('span', { class: 'muted' }, ` (with them: ${gains(hand.descriptionsBack, hand.sessionCharsSaved).join(', ')})`) : null), f.ids.length ? null : exact(hand.sessionCharsSaved), [...groups].map(([where, list]) => h('p', null, h('b', null, where), ` ${list.map(i => i.kind === 'plugin' ? `${i.name} (${plural(i.turnsOff, 'part')})` : i.name).join(', ')}`))) : null) : null);
365
+ }
366
+ function sinceStrip() {
367
+ const p = data.previous;
368
+ if (!p)
369
+ return null;
370
+ const archivedSince = data.archived.filter(a => a.archivedAt > p.createdAt && a.status !== 'restored').length;
371
+ const b = budget();
372
+ const predicted = b && p.budgetTimestamp && b.timestamp && b.timestamp <= p.createdAt;
373
+ if (!archivedSince && p.hookStartChars === data.current.hookStartChars && (!b || p.dropped === b.dropped.length))
374
+ return null;
375
+ return h('p', { class: 'strip', 'aria-label': 'Since the last scan' }, h('span', null, `Since the ${date(p.createdAt)} scan:`), h('span', null, 'hook chars per session start ', h('b', null, `${chars(p.hookStartChars)} → ${chars(data.current.hookStartChars)}`)), b && p.dropped !== null ? h('span', null, 'pushed out ', h('b', null, `${p.dropped} → ${b.dropped.length}`), predicted ? ' predicted' : '') : null, archivedSince ? h('span', null, h('b', null, String(archivedSince)), ' items archived') : null);
376
+ }
377
+ function itemsPanel() {
378
+ return [...banners(), hero(), sinceStrip(), h('div', { class: 'browse' }, kindNav(), h('div', { class: 'kindpane' }, kindHeader(), filters(), table()))];
379
+ }
380
+ // A sidebar of kinds on wide screens; the same buttons wrap into a row on narrow ones.
381
+ function kindNav() {
382
+ const items = visibleItems();
383
+ return h('nav', { class: 'kinds', 'aria-label': 'Kinds' }, KINDS.map(([k, label]) => {
384
+ const of = items.filter(i => i.kind === k);
385
+ if (!of.length)
386
+ return null;
387
+ const sug = of.filter(i => i.suggestion.suggested).length;
388
+ return h('button', { class: 'kind', 'aria-pressed': String(state.kind === k), 'data-key': `kind-${k}`, onclick: () => { state.kind = k; state.pushedOutOnly = false; render(); } }, h('span', { class: 'kl' }, label), h('span', { class: 'kn num' }, String(of.length)), sug ? h('span', { class: 'ks' }, `${sug} suggested`) : null);
389
+ }));
390
+ }
391
+ function kindHeader() {
392
+ const of = visibleItems().filter(i => i.kind === state.kind);
393
+ const label = KINDS.find(([k]) => k === state.kind)?.[1] ?? '';
394
+ const standing = state.kind === 'plugin' ? 0 : of.reduce((n, i) => n + (i.standingChars || 0), 0);
395
+ const unseen = of.filter(i => !i.usage.total).length;
396
+ const sug = of.filter(i => i.suggestion.suggested).length;
397
+ const pushed = state.kind === 'skill' || state.kind === 'command' ? of.filter(i => listingStatus(i) === 'pushed out').length : 0;
398
+ const toolHooks = state.kind === 'hook' ? of.filter(i => i.enabled && /ToolUse$/.test(i.hook?.event ?? '') && i.usage.hook?.firings) : [];
399
+ const toolHookChars = toolHooks.reduce((n, i) => n + i.usage.hook.injectedChars / i.usage.hook.firings, 0);
400
+ const stat = (n, text) => h('span', null, h('b', { class: 'num' }, String(n)), ` ${text}`);
401
+ return h('div', { class: 'kindhead' }, h('h2', null, label, h('span', { class: 'kc num' }, String(of.length))), h('p', { class: 'kinddesc' }, KIND_TEXT[state.kind]), h('p', { class: 'kindstats' }, state.kind === 'skill' && data.agent === 'codex' && data.sessionLoad
402
+ ? stat(`${chars(data.sessionLoad.skillListing)} chars`, 'in the skill listing every session')
403
+ : state.kind === 'skill' && budget()
404
+ ? stat(`${chars(budget().listingChars)} chars`, `in the listing every session (full text would be ${chars(standing)})`)
405
+ : standing ? stat(`${chars(standing)} chars`, state.kind === 'instructions' ? 'loaded in their projects' : 'loaded every session') : null, state.kind !== 'instructions' && data.hasLogs ? stat(unseen, 'not observed') : null, pushed ? h('span', null, h('b', { class: 'num' }, String(pushed)), ' listed by name only (the listing is full) ', h('button', { class: 'linkish', 'data-key': 'review-pushed', 'aria-pressed': String(state.pushedOutOnly), onclick: () => { state.pushedOutOnly = !state.pushedOutOnly; state.suggestedOnly = false; render(); say(state.pushedOutOnly ? `Showing ${plural(pushed, 'skill')} listed by name only` : 'Showing all skills'); } }, state.pushedOutOnly ? 'Show all' : `Show the ${pushed}`)) : null, toolHookChars ? stat(`${chars(toolHookChars)} chars`, 'added per tool call') : null, sug ? stat(sug, 'suggested') : null));
406
+ }
407
+ function filters() {
408
+ const items = visibleItems();
409
+ const suggested = items.filter(i => i.kind === state.kind && i.suggestion.suggested);
410
+ const sugChip = h('button', { class: 'chip', 'aria-pressed': String(state.suggestedOnly), 'data-key': 'suggested', onclick: () => { state.suggestedOnly = !state.suggestedOnly; render(); } }, `Suggested (${suggested.length})`);
411
+ const pushedChip = state.pushedOutOnly ? h('button', { class: 'chip', 'aria-pressed': 'true', 'data-key': 'pushed', onclick: () => { state.pushedOutOnly = false; render(); } }, 'Pushed out') : null;
412
+ const search = h('input', { class: 'search', type: 'search', placeholder: 'Search names, descriptions', 'aria-label': 'Search names and descriptions', value: state.q, 'data-key': 'search',
413
+ oninput: (e) => { state.q = e.target.value; render(); } });
414
+ const sort = h('select', { class: 'sortsel', 'aria-label': 'Sort', 'data-key': 'sort', onchange: (e) => { state.sort = e.target.value; render(); } }, [['cost', 'Load, highest first'], ['uses', 'Uses, most first'], ['least', 'Uses, fewest first'], ['name', 'Name, A to Z']].map(([v, l]) => h('option', { value: v, selected: state.sort === v }, l)));
415
+ const shown = rows();
416
+ const total = items.filter(i => i.kind === state.kind).length;
417
+ const active = [state.suggestedOnly, state.pushedOutOnly, state.q !== ''].filter(Boolean).length;
418
+ const tooFew = data.sessions < data.thresholds.sessions;
419
+ return h('div', { class: 'filters' }, search, h('button', { class: 'btn filters-toggle', 'aria-expanded': String(document.body.classList.contains('filters-open')), 'data-key': 'filters-toggle', onclick: () => { document.body.classList.toggle('filters-open'); render(); } }, `Filters (${active})`), h('div', { class: 'more chips' }, sugChip, pushedChip, sort), h('p', { class: 'countline' }, h('span', { role: 'status' }, `Showing ${shown.length} of ${total}`), active ? h('button', { class: 'linkish', 'data-key': 'clear', onclick: () => { state.suggestedOnly = false; state.pushedOutOnly = false; state.q = ''; render(); } }, 'Clear filters') : null), state.suggestedOnly ? h('p', { class: 'countline' }, h('span', null, tooFew
420
+ ? `Too few sessions to suggest (${data.sessions} of ${data.thresholds.sessions} needed).`
421
+ : `Suggested: not observed in any session since it was installed, over at least ${data.thresholds.sessions} sessions and ${data.thresholds.days} days. Excludes items with shared usage, items only removable by hand, and items you kept.`), suggested.length ? h('button', { class: 'btn small', 'data-key': 'mark-all', onclick: () => {
422
+ for (const i of shown.filter(x => x.suggestion.suggested))
423
+ state.marks[markTarget(i).id] = 'archive';
424
+ persist();
425
+ render();
426
+ say(`Marked ${plural(shown.length, 'item')} for archive`);
427
+ } }, `Mark all ${shown.length} shown`) : null) : null);
428
+ }
429
+ function rows() {
430
+ const q = state.q.toLowerCase();
431
+ const list = visibleItems().filter(i => i.kind === state.kind
432
+ && (!state.suggestedOnly || i.suggestion.suggested)
433
+ && (!state.pushedOutOnly || listingStatus(i) === 'pushed out')
434
+ && (!q || `${i.name} ${i.description} ${i.source}`.toLowerCase().includes(q)));
435
+ const load = (i) => loadOf(i)?.value ?? 0;
436
+ const sorters = {
437
+ cost: (a, b) => load(b) - load(a) || a.name.localeCompare(b.name),
438
+ uses: (a, b) => b.usage.total - a.usage.total || a.name.localeCompare(b.name),
439
+ least: (a, b) => a.usage.total - b.usage.total || load(b) - load(a),
440
+ name: (a, b) => a.name.localeCompare(b.name),
441
+ };
442
+ return list.sort(sorters[state.sort] ?? sorters.cost);
443
+ }
444
+ function markControl(i) {
445
+ if (!markable(i))
446
+ return null;
447
+ if (i.plugin && i.kind !== 'plugin') {
448
+ const p = pluginItem(i.plugin);
449
+ if (!p)
450
+ return null;
451
+ if (state.marks[p.id] === 'archive')
452
+ return h('button', { class: 'btn small', 'data-key': `unmark-${i.id}`, onclick: () => { delete state.marks[p.id]; persist(); render(); say(`Unmarked ${p.name}`); } }, 'Unmark plugin');
453
+ return h('button', { class: 'btn small', 'aria-expanded': String(state.confirmPlugin === i.id), 'data-key': `markplugin-${i.id}`, onclick: () => { state.confirmPlugin = state.confirmPlugin === i.id ? null : i.id; render(); } }, 'Mark plugin…');
454
+ }
455
+ if (i.keptAt && state.marks[i.id] !== 'unkeep') {
456
+ return h('button', { class: 'btn small', 'data-key': `unkeep-${i.id}`, onclick: () => { state.marks[i.id] = 'unkeep'; persist(); render(); say(`Clear keep of ${i.name} saved with your picks`); } }, 'Clear keep');
457
+ }
458
+ const m = state.marks[i.id];
459
+ const set = (mark) => () => {
460
+ if (m === mark)
461
+ delete state.marks[i.id];
462
+ else
463
+ state.marks[i.id] = mark;
464
+ persist();
465
+ render();
466
+ say(m === mark ? `Unmarked ${i.name}` : mark === 'archive' ? `Marked ${i.name} for archive${i.kind === 'plugin' ? `, ${plural(memberCount(i), 'item')}` : ''}` : `Kept ${i.name}`);
467
+ };
468
+ return h('span', { class: 'seg', role: 'group', 'aria-label': `Mark ${i.name}` }, h('button', { class: 'a', 'aria-pressed': String(m === 'archive'), 'data-key': `a-${i.id}`, onclick: set('archive') }, 'Archive'), h('button', { class: 'k', 'aria-pressed': String(m === 'keep'), 'data-key': `k-${i.id}`, onclick: set('keep') }, 'Keep'));
469
+ }
470
+ const memberCount = (p) => Object.values(p.members ?? {}).reduce((n, x) => n + (x ?? 0), 0);
471
+ const impactText = (p) => {
472
+ const ONE = { skill: 'skill', command: 'command', agent: 'agent', hook: 'hook', plugin: 'plugin', mcp: 'MCP server', instructions: 'instruction file' };
473
+ const MANY = { skill: 'skills', command: 'commands', agent: 'agents', hook: 'hooks', plugin: 'plugins', mcp: 'MCP servers', instructions: 'instruction files' };
474
+ const parts = Object.entries(p.members ?? {}).map(([k, n]) => `${n} ${n === 1 ? ONE[k] ?? k : MANY[k] ?? k}`);
475
+ return `Archiving ${p.name} turns off ${plural(memberCount(p), 'item')}: ${parts.join(', ')}.`;
476
+ };
477
+ function table() {
478
+ const list = rows();
479
+ if (!list.length) {
480
+ return h('p', { class: 'banner' }, state.q ? `No items match "${state.q}" in ${KINDS.find(k => k[0] === state.kind)?.[1]}.` : 'Nothing to show with these filters.', h('button', { class: 'btn small', 'data-key': 'clear-empty', onclick: () => { state.suggestedOnly = false; state.pushedOutOnly = false; state.q = ''; render(); } }, 'Clear filters'));
481
+ }
482
+ const cols = state.kind === 'hook'
483
+ ? ['Firings', 'Chars per firing', 'p95']
484
+ : state.kind === 'plugin' ? ['Items', 'Last observed', 'Load'] : state.kind === 'mcp' ? ['Last observed', 'Tool calls', 'Load'] : state.kind === 'instructions' ? ['Load'] : ['Last observed', 'Uses', 'Listing', 'Load'];
485
+ const head = h('thead', null, h('tr', null, h('th', null, h('span', { class: 'sr' }, 'Details')), h('th', null, 'Mark'), h('th', { scope: 'col' }, 'Name'), h('th', { scope: 'col', class: 'col-2nd col-source' }, 'Source'), cols.map(c => h('th', { scope: 'col', class: `r col-2nd${c === 'Last observed' && cols.length === 4 ? ' col-last' : ''}` }, c))));
486
+ const cells = (i) => {
487
+ const ld = loadOf(i);
488
+ const loadCell = h('td', { class: 'r num col-2nd' }, ld ? [`${chars(ld.value)} chars`, h('span', { class: 'unit' }, ld.unit)] : '—');
489
+ const uses = h('td', { class: 'r num col-2nd' }, usageCell(i), i.usage.uncertain && data.hasLogs ? h('span', { class: 'unit' }, 'may be low') : null);
490
+ if (state.kind === 'hook') {
491
+ const u = i.usage.hook;
492
+ return [h('td', { class: 'r num col-2nd' }, u ? String(u.firings) : usageCell(i)), h('td', { class: 'r num col-2nd' }, u && u.firings ? `${chars(u.injectedChars / u.firings)} chars` : '—', u?.injectionShared ? h('span', { class: 'unit' }, 'shared') : null), h('td', { class: 'r num col-2nd' }, u && u.firings ? `${u.p95DurationMs} ms` : '—')];
493
+ }
494
+ if (state.kind === 'plugin')
495
+ return [h('td', { class: 'r num col-2nd' }, String(memberCount(i))), h('td', { class: 'r col-2nd' }, date(i.usage.lastUsed)), loadCell];
496
+ if (state.kind === 'mcp')
497
+ return [h('td', { class: 'r col-2nd' }, date(i.usage.lastUsed)), uses, loadCell];
498
+ if (state.kind === 'instructions')
499
+ return [loadCell];
500
+ const ls = listingStatus(i);
501
+ return [h('td', { class: 'r col-2nd col-last' }, date(i.usage.lastUsed)), uses, h('td', { class: 'r col-2nd' }, ls ? h('span', { class: `tag ${ls === 'pushed out' ? 'red' : ''}` }, ls) : '—'), loadCell];
502
+ };
503
+ const body = h('tbody', null);
504
+ const rowFor = (i) => {
505
+ const open = state.open.has(i.id);
506
+ const m = markOf(i);
507
+ const ld = loadOf(i);
508
+ const tags = [];
509
+ if (i.plugin && i.kind !== 'plugin')
510
+ tags.push(h('span', { class: 'tag' }, m === 'archive' ? `archived with ${pluginItem(i.plugin)?.name ?? 'its plugin'}` : `part of ${pluginItem(i.plugin)?.name ?? 'a plugin'}`));
511
+ if (i.suggestion.suggested)
512
+ tags.push(h('span', { class: 'tag amber' }, 'suggested'));
513
+ if (i.keptAt)
514
+ tags.push(h('span', { class: 'tag green' }, `kept ${date(i.keptAt)}${i.keptUpdatedSince ? ' · updated since' : ''}`));
515
+ if (i.cameBack)
516
+ tags.push(h('span', { class: 'tag red' }, 'came back'));
517
+ if (!i.enabled)
518
+ tags.push(h('span', { class: 'tag' }, 'off'));
519
+ const tr = h('tr', { class: m === 'archive' ? 'marked-archive' : '' }, h('td', null, h('button', { class: 'disclose', 'aria-expanded': String(open), 'aria-controls': `d-${i.id}`, 'aria-label': `Details for ${i.name}`, 'data-key': `open-${i.id}`, onclick: () => { open ? state.open.delete(i.id) : state.open.add(i.id); render(); } }, svg('M4 2l4 4-4 4'))), h('td', null, markControl(i)), h('td', { class: 'name' }, h('div', { class: 'nm' }, i.name, tags), h('div', { class: 'sub' }, i.hook ? `${i.hook.event}${i.hook.matcher ? ` (${i.hook.matcher})` : ''} · ${i.source}` : i.source === 'claude.ai connector' && i.description ? `${i.source} · ${i.description}` : i.source), h('div', { class: 'narrow-meta' }, `${ld ? `${chars(ld.value)} chars ${ld.unit}` : usageCell(i)} · ${i.usage.lastUsed ? `last observed ${date(i.usage.lastUsed)}` : data.hasLogs ? 'not observed' : 'usage unavailable'}`), state.confirmPlugin === i.id && i.plugin ? impactBox(pluginItem(i.plugin)) : null), h('td', { class: 'col-2nd col-source muted' }, i.source), cells(i));
520
+ const out = [tr];
521
+ if (open) {
522
+ const facts = [
523
+ ['Path', i.path ? h('code', null, tildify(i.path)) : 'Only in the app it came from'],
524
+ ['Description', i.description || 'No description.'],
525
+ ...(i.hook ? [['Command', h('code', null, i.hook.command)]] : []),
526
+ ['Why', reasonText(i) || '—'],
527
+ ['Last observed', date(i.usage.lastUsed)],
528
+ ['Installed', i.firstSeen ? date(i.firstSeen) : 'Unknown'],
529
+ ['Removal', i.removal.method === 'manual' ? `By hand: ${i.removal.where ?? ''}` : i.removal.method],
530
+ ];
531
+ out.push(h('tr', { class: 'detail', id: `d-${i.id}` }, h('td', { colspan: 9 }, h('dl', null, facts.map(([k, v]) => [h('dt', null, k), h('dd', null, v)])))));
532
+ }
533
+ return out;
534
+ };
535
+ // First paint: 50 rows now, the rest in idle time (design DR5).
536
+ list.slice(0, 50).forEach(i => body.append(...rowFor(i)));
537
+ const rest = list.slice(50);
538
+ const tableEl = h('table', { class: 'items' }, head, body);
539
+ if (rest.length) {
540
+ const status = h('p', { class: 'countline', role: 'status' }, `Showing 50 of ${list.length}, loading the rest`);
541
+ const idle = (cb) => { const w = window; if (w.requestIdleCallback)
542
+ w.requestIdleCallback(cb);
543
+ else
544
+ setTimeout(cb, 16); };
545
+ const step = () => {
546
+ rest.splice(0, 100).forEach(i => body.append(...rowFor(i)));
547
+ if (rest.length)
548
+ idle(step);
549
+ else
550
+ status.remove();
551
+ };
552
+ idle(step);
553
+ return [tableEl, status];
554
+ }
555
+ return tableEl;
556
+ }
557
+ function impactBox(p) {
558
+ if (!p)
559
+ return null;
560
+ return h('div', { class: 'impact', role: 'group', 'aria-label': `Archive ${p.name}` }, h('span', null, impactText(p)), h('button', { class: 'btn small primary', 'data-key': `confirm-plugin-${p.id}`, onclick: () => { state.marks[p.id] = 'archive'; state.confirmPlugin = null; persist(); render(); say(`Marked ${p.name} for archive, ${plural(memberCount(p), 'item')}`); } }, 'Archive plugin'), h('button', { class: 'btn small', 'data-key': `cancel-plugin-${p.id}`, onclick: () => { state.confirmPlugin = null; render(); } }, 'Cancel'));
561
+ }
562
+ // ------------------------------------------------------------------ Picks
563
+ function picksJson() {
564
+ return JSON.stringify({ scanId: data.scanId, createdAt: new Date().toISOString(), picks: Object.entries(state.marks).map(([id, action]) => ({ id, action, note: '' })) }, null, 1);
565
+ }
566
+ function savePicks() {
567
+ const json = picksJson();
568
+ try {
569
+ const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
570
+ const a = h('a', { href: url, download: `packlight-picks-${data.scanId}.json` });
571
+ document.body.append(a);
572
+ a.click();
573
+ a.remove();
574
+ window.setTimeout(() => URL.revokeObjectURL(url), 10_000);
575
+ state.saved = true;
576
+ state.downloadFailed = null;
577
+ say(`Picks saved, not applied yet`);
578
+ }
579
+ catch {
580
+ state.downloadFailed = json;
581
+ }
582
+ render();
583
+ }
584
+ const copy = (text, label) => () => {
585
+ void navigator.clipboard?.writeText(text).then(() => say(`Copied ${label}`), () => say('Copy failed: select the text and copy it yourself'));
586
+ };
587
+ const codebox = (cmd, key) => h('div', { class: 'codebox' }, h('code', null, cmd), h('button', { class: 'btn small', 'data-key': key, onclick: copy(cmd, 'command') }, 'Copy'));
588
+ function picksPanel() {
589
+ const marks = Object.entries(state.marks);
590
+ const toArchive = archived();
591
+ const keeps = marks.filter(([, m]) => m === 'keep').length;
592
+ const unkeeps = marks.filter(([, m]) => m === 'unkeep').length;
593
+ const groups = new Map();
594
+ for (const i of toArchive)
595
+ groups.set(i.removal.method, [...(groups.get(i.removal.method) ?? []), i]);
596
+ const out = [h('h1', { class: 'panel-title' }, 'Picks: not applied yet'), ...banners()];
597
+ if (!marks.length) {
598
+ out.push(h('p', { class: 'lede' }, 'Nothing marked yet. In Items, mark the rows you want to archive; they collect here.'), h('button', { class: 'btn primary', disabled: true }, 'Save my picks'), h('p', { class: 'muted' }, 'Mark at least one item to save your picks.'));
599
+ return out;
600
+ }
601
+ const b = budget();
602
+ const after = projected(new Set(toArchive.map(i => i.id)));
603
+ const startNow = visibleItems().filter(i => i.kind === 'hook' && i.enabled && i.hook?.event === 'SessionStart' && i.usage.hook?.firings);
604
+ const startAfter = startNow.filter(i => !toArchive.some(t => t.id === markTarget(i).id));
605
+ const per = (l) => l.reduce((n, i) => n + i.usage.hook.injectedChars / i.usage.hook.firings, 0);
606
+ out.push(h('p', { class: 'strip' }, h('span', null, 'Projected after apply:'), b && after !== null ? h('span', null, h('b', null, `${b.dropped.length} → ${after}`), ' skills pushed out') : null, h('span', null, h('b', null, `${chars(per(startNow))} → ${chars(per(startAfter))}`), ' chars per session start')));
607
+ for (const [method, list] of groups) {
608
+ out.push(h('div', { class: 'group' }, h('h2', null, `${METHOD_TEXT[method] ?? method} (${list.length})`), h('ul', null, list.map(i => h('li', null, i.name, i.kind === 'plugin' ? h('span', { class: 'muted' }, ` (${impactText(i)})`) : null, i.usage.ambiguous ? h('span', { class: 'muted' }, ` · ${usageCell(i)}`) : null)))));
609
+ }
610
+ if (keeps || unkeeps)
611
+ out.push(h('div', { class: 'group' }, h('h2', null, 'Keep (no change to your setup)'), h('p', { class: 'muted' }, `${plural(keeps, 'item')} to keep${unkeeps ? `, ${plural(unkeeps, 'keep')} to clear` : ''}.`)));
612
+ // A download can be blocked without any error reaching the page, so copying is always offered too.
613
+ out.push(h('p', { class: 'hero-line' }, h('button', { class: 'btn primary', 'data-key': 'save', onclick: savePicks }, 'Save my picks'), h('button', { class: 'btn', 'data-key': 'copy-picks', onclick: () => { copy(picksJson(), 'picks')(); state.saved = true; render(); } }, 'Copy picks')));
614
+ if (state.downloadFailed) {
615
+ out.push(h('div', { class: 'next' }, h('p', null, `Save this as packlight-picks-${data.scanId}.json, then run the command below.`), h('textarea', { class: 'picksjson', readonly: true, 'aria-label': 'Picks file contents' }, state.downloadFailed), h('button', { class: 'btn small', 'data-key': 'copy-json', onclick: copy(state.downloadFailed, 'picks') }, 'Copy')));
616
+ }
617
+ if (state.saved || state.downloadFailed) {
618
+ out.push(h('div', { class: 'next', role: 'region', 'aria-label': 'Next step' }, h('p', null, data.messages.picksSaved), h('ol', null, h('li', null, data.messages.stepApply, codebox(data.messages.applyCommand, 'copy-apply')), h('li', null, data.messages.stepReport, codebox(data.messages.reportCommand, 'copy-report'))), h('p', { class: 'muted' }, data.messages.noFile)));
619
+ }
620
+ return out;
621
+ }
622
+ // ------------------------------------------------------------------ Archived and Findings
623
+ function archivedPanel() {
624
+ const open = data.archived.filter(a => a.status === 'archived');
625
+ const waiting = data.archived.filter(a => a.status === 'waiting');
626
+ const out = [h('h1', { class: 'panel-title' }, 'Archived by packlight')];
627
+ if (!open.length && !waiting.length) {
628
+ out.push(h('p', { class: 'lede' }, `Nothing archived yet. Items you apply with \`${data.messages.applyCommand}\` appear here, each with its restore command.`));
629
+ return out;
630
+ }
631
+ if (open.length)
632
+ out.push(codebox(`${data.messages.invoke} restore --all`, 'copy-restore-all'));
633
+ for (const a of [...open].reverse()) {
634
+ const back = data.items.find(i => i.id === a.itemId);
635
+ out.push(h('div', { class: 'group' }, h('h2', null, a.itemName, back ? h('span', { class: 'tag red' }, 'came back') : null), h('p', { class: 'muted' }, `${METHOD_TEXT[a.method] ?? a.method} · ${date(a.archivedAt)} · ${tildify(a.where)}`), back ? h('p', { class: 'muted' }, `Archived ${date(a.archivedAt)}, reinstalled since (probably by an updater).`) : null, codebox(`${data.messages.invoke} restore ${a.opId}`, `copy-${a.opId}`)));
636
+ }
637
+ if (waiting.length) {
638
+ out.push(h('div', { class: 'group' }, h('h2', null, 'Waiting for you'), h('ul', null, waiting.map(w => h('li', null, w.itemName, h('span', { class: 'muted' }, ` · ${w.where} ${data.items.some(i => i.id === w.itemId) ? '(still installed)' : '(done)'}`))))));
639
+ }
640
+ return out;
641
+ }
642
+ function findingsPanel() {
643
+ const out = [h('h1', { class: 'panel-title' }, 'Setup checks')];
644
+ if (!data.findings.length)
645
+ return [...out, h('p', { class: 'lede' }, 'No setup problems found in this scan.')];
646
+ return [...out, data.findings.map(f => h('div', { class: `finding ${f.level}` }, h('i', { 'aria-hidden': 'true' }), h('h2', null, f.title), h('p', null, f.body)))];
647
+ }
648
+ function picksBar() {
649
+ const marks = Object.values(state.marks);
650
+ const a = marks.filter(m => m === 'archive').length;
651
+ const k = marks.filter(m => m === 'keep').length;
652
+ return h('div', { class: 'picksbar', hidden: !marks.length || state.tab === 'picks', role: 'region', 'aria-label': 'Picks' }, h('span', { class: 'count num' }, `${a} to archive${k ? ` · ${k} to keep` : ''}`), state.saved ? h('span', { class: 'muted' }, 'Saved, not applied') : null, h('button', { class: 'btn primary', 'data-key': 'review-picks', onclick: () => { state.tab = 'picks'; render(); window.scrollTo(0, 0); } }, 'Review picks'));
653
+ }
654
+ document.addEventListener('keydown', e => {
655
+ if (e.key === '/' && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) {
656
+ const s = document.querySelector('.search');
657
+ if (s) {
658
+ e.preventDefault();
659
+ s.focus();
660
+ }
661
+ }
662
+ if (e.key === 'Escape') {
663
+ if (state.confirmPlugin) {
664
+ const k = `markplugin-${state.confirmPlugin}`;
665
+ state.confirmPlugin = null;
666
+ render();
667
+ root.querySelector(`[data-key="${CSS.escape(k)}"]`)?.focus();
668
+ }
669
+ else if (document.body.classList.contains('filters-open')) {
670
+ document.body.classList.remove('filters-open');
671
+ render();
672
+ }
673
+ }
674
+ });
675
+ render();
676
+ }