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,46 @@
1
+ import type { BudgetObservation, Inventory, Kind } from '../core/types.js';
2
+ import { type ListingEntry, type Suggestion } from './suggest.js';
3
+ /** The listing for the scan's scope: its own project's newest, else the newest anywhere. */
4
+ export declare function newestBudget(inv: Inventory): BudgetObservation | undefined;
5
+ export interface ListingProjection {
6
+ chars: number;
7
+ described: number;
8
+ dropped: number;
9
+ }
10
+ export interface FixPlan {
11
+ /** Archive targets, plugin-level (CEO O3). */
12
+ ids: string[];
13
+ /** Targets by kind, and everything they turn off counting plugin parts. */
14
+ byKind: Partial<Record<Kind, number>>;
15
+ turnsOff: number;
16
+ listing: {
17
+ before: ListingProjection;
18
+ after: ListingProjection;
19
+ observedAt: string | null;
20
+ } | null;
21
+ /** Skills that get their description back in the listing. */
22
+ descriptionsBack: number;
23
+ /** Fewer characters in every session: the listing, agent descriptions, MCP tool lists and session-start hook text. */
24
+ sessionCharsSaved: number;
25
+ /** Unused items packlight may not remove; the totals count them on top of the archive (cumulative). */
26
+ byHand: {
27
+ items: ByHand[];
28
+ descriptionsBack: number;
29
+ sessionCharsSaved: number;
30
+ } | null;
31
+ }
32
+ /** The skill listing as entries, in the order Claude Code logged it, without the items in `gone`. */
33
+ export declare function listingEntries(inv: Inventory, b: BudgetObservation, gone?: Set<string>): ListingEntry[];
34
+ export interface ByHand {
35
+ id: string;
36
+ name: string;
37
+ kind: Kind;
38
+ where: string;
39
+ turnsOff: number;
40
+ }
41
+ /**
42
+ * One-click fix: archive every suggested item (plugin-level), and say what it buys. Suggestions already exclude
43
+ * anything used, kept, too new or ambiguous. Items only removable by hand (claude.ai synced skills, the Claude
44
+ * app's own plugins) that pass the same rules are listed separately with what removing them too would buy.
45
+ */
46
+ export declare function fixPlan(inv: Inventory, sug: Map<string, Suggestion>, keptIds?: Set<string>): FixPlan;
@@ -0,0 +1,116 @@
1
+ import { MIN_DAYS, MIN_SESSIONS, simulateListing } from './suggest.js';
2
+ /** The listing for the scan's scope: its own project's newest, else the newest anywhere. */
3
+ export function newestBudget(inv) {
4
+ const own = inv.projectScope !== 'global' ? inv.budget[inv.projectScope] : undefined;
5
+ return own ?? Object.values(inv.budget).sort((a, b) => (a.timestamp ?? '').localeCompare(b.timestamp ?? '')).at(-1);
6
+ }
7
+ /** The skill listing as entries, in the order Claude Code logged it, without the items in `gone`. */
8
+ export function listingEntries(inv, b, gone = new Set()) {
9
+ const byKey = new Map();
10
+ for (const i of inv.items)
11
+ for (const k of i.logKeys)
12
+ if (!byKey.has(k))
13
+ byKey.set(k, i);
14
+ const priority = new Set(inv.listingPriority ?? []);
15
+ const lines = b.entries ?? [...b.withDescription.map(name => ({ name, chars: 0, described: true })), ...b.dropped.map(name => ({ name, chars: 0, described: false }))];
16
+ const out = [];
17
+ for (const l of lines) {
18
+ const it = byKey.get(l.name);
19
+ if (it && gone.has(it.id))
20
+ continue;
21
+ const nameChars = l.name.length + 3;
22
+ const full = l.described && l.chars ? l.chars : it?.standingChars ? it.standingChars : l.chars || nameChars;
23
+ // Skills Claude Code counts as used, and its own bundled skills (no file packlight knows), go first.
24
+ out.push({ name: l.name, nameChars, fullChars: Math.max(full, nameChars), priority: priority.has(l.name) || !it });
25
+ }
26
+ return out;
27
+ }
28
+ /**
29
+ * One-click fix: archive every suggested item (plugin-level), and say what it buys. Suggestions already exclude
30
+ * anything used, kept, too new or ambiguous. Items only removable by hand (claude.ai synced skills, the Claude
31
+ * app's own plugins) that pass the same rules are listed separately with what removing them too would buy.
32
+ */
33
+ export function fixPlan(inv, sug, keptIds = new Set()) {
34
+ const plugins = new Map(inv.items.filter(i => i.kind === 'plugin').map(p => [p.plugin, p]));
35
+ const pluginOf = (i) => (i.plugin && i.kind !== 'plugin' ? plugins.get(i.plugin) ?? i : i);
36
+ const partsOf = (p) => inv.items.filter(m => m.plugin === p.plugin && m.kind !== 'plugin');
37
+ const inScope = (i) => inv.projectScope === 'global' || i.projectRoot === null || i.projectRoot === inv.projectScope;
38
+ const targets = new Map();
39
+ for (const i of inv.items) {
40
+ if (i.kind === 'instructions' || !inScope(i) || !sug.get(i.id)?.suggested)
41
+ continue;
42
+ const t = pluginOf(i);
43
+ if (sug.get(t.id)?.suggested)
44
+ targets.set(t.id, t);
45
+ }
46
+ // By hand: the suggestion rules minus the removal method. A plugin qualifies only when no part was used or kept.
47
+ const qualifiesByHand = (i) => {
48
+ const s = sug.get(i.id);
49
+ return !!s && s.reason === 'manual' && !i.removal.suite && i.enabled && !keptIds.has(i.id) && !i.usage.total && !i.usage.ambiguous && !i.usage.uncertain
50
+ && s.windowSessions >= MIN_SESSIONS && s.windowDays >= MIN_DAYS && !s.unreadableLines;
51
+ };
52
+ const hand = new Map();
53
+ for (const i of inv.items) {
54
+ if (i.kind === 'instructions' || !inScope(i) || targets.has(i.id))
55
+ continue;
56
+ const t = pluginOf(i);
57
+ if (hand.has(t.id) || targets.has(t.id))
58
+ continue;
59
+ if (t.kind === 'plugin' ? qualifiesByHand(t) && partsOf(t).every(m => !m.usage.total && !keptIds.has(m.id)) : t === i && qualifiesByHand(i))
60
+ hand.set(t.id, t);
61
+ }
62
+ const expand = (set) => {
63
+ const gone = new Set();
64
+ for (const t of set) {
65
+ gone.add(t.id);
66
+ if (t.kind === 'plugin')
67
+ for (const m of partsOf(t))
68
+ gone.add(m.id);
69
+ }
70
+ return gone;
71
+ };
72
+ const gone = expand(targets.values());
73
+ const goneWithHand = expand([...targets.values(), ...hand.values()]);
74
+ const b = newestBudget(inv);
75
+ const cap = b && b.dropped.length ? b.listingChars : Number.MAX_SAFE_INTEGER;
76
+ const before = b ? simulateListing(listingEntries(inv, b), cap) : null;
77
+ const outcome = (g) => {
78
+ let listing = null;
79
+ let descriptionsBack = 0;
80
+ if (b && before) {
81
+ const staying = listingEntries(inv, b, g);
82
+ const { describedNames, ...after } = simulateListing(staying, cap);
83
+ void describedNames;
84
+ // Archived skills that were name-only also leave the dropped count; only skills that stay can "come back".
85
+ const had = new Set(before.describedNames);
86
+ descriptionsBack = Math.max(0, staying.filter(e => !had.has(e.name)).length - after.dropped);
87
+ listing = after;
88
+ }
89
+ const goneItems = inv.items.filter(i => g.has(i.id));
90
+ // Agent descriptions and MCP tool names and instructions load every session in full; skills go through the listing above.
91
+ // Without a capped listing (Codex), every skill line goes straight out of the session.
92
+ const agentChars = goneItems.filter(i => i.kind === 'agent' || i.kind === 'mcp' || (inv.agent === 'codex' && (i.kind === 'skill' || i.kind === 'command'))).reduce((n, i) => n + i.standingChars, 0);
93
+ const hookChars = goneItems.filter(i => i.kind === 'hook' && i.hook?.event === 'SessionStart' && i.usage.hook?.firings)
94
+ .reduce((n, i) => n + i.usage.hook.injectedChars / i.usage.hook.firings, 0);
95
+ return { listing, descriptionsBack, sessionCharsSaved: Math.round((listing && before ? before.chars - listing.chars : 0) + agentChars + hookChars) };
96
+ };
97
+ const auto = outcome(gone);
98
+ const withHand = hand.size ? outcome(goneWithHand) : null;
99
+ const byKind = {};
100
+ for (const t of targets.values())
101
+ byKind[t.kind] = (byKind[t.kind] ?? 0) + 1;
102
+ const strip = (p) => ({ chars: p.chars, described: p.described, dropped: p.dropped });
103
+ return {
104
+ ids: [...targets.keys()],
105
+ byKind,
106
+ turnsOff: gone.size,
107
+ listing: before && auto.listing ? { before: strip(before), after: auto.listing, observedAt: b.timestamp } : null,
108
+ descriptionsBack: auto.descriptionsBack,
109
+ sessionCharsSaved: auto.sessionCharsSaved,
110
+ byHand: withHand ? {
111
+ items: [...hand.values()].map(t => ({ id: t.id, name: t.source === 'claude.ai connector' && t.description ? `${t.name.slice(0, 8)}… (${t.description.replace(/^Tools: /, '').split(', ').slice(0, 2).join(', ')})` : t.name, kind: t.kind, where: t.removal.where ?? '', turnsOff: t.kind === 'plugin' ? partsOf(t).length : 1 })),
112
+ descriptionsBack: withHand.descriptionsBack,
113
+ sessionCharsSaved: withHand.sessionCharsSaved,
114
+ } : null,
115
+ };
116
+ }
@@ -0,0 +1,7 @@
1
+ export declare const BRAND_GREEN = "#3fae6e";
2
+ /** The mark as standalone SVG markup. `tile` puts it on a rounded square (app icon, favicon). */
3
+ export declare function logoSvg(opts: {
4
+ ink: string;
5
+ tile?: string;
6
+ id?: string;
7
+ }): string;
@@ -0,0 +1,23 @@
1
+ // packlight's mark: a solid front-view backpack with a small handle, low side pockets and a green front pocket.
2
+ // The flap, clasp and pocket outline are cut out with a mask, so they show whatever sits behind the mark.
3
+ // client.ts draws the same shapes with DOM calls (it cannot import); keep the two in step.
4
+ export const BRAND_GREEN = '#3fae6e';
5
+ /** The mark as standalone SVG markup. `tile` puts it on a rounded square (app icon, favicon). */
6
+ export function logoSvg(opts) {
7
+ const id = opts.id ?? 'pl-cut';
8
+ const glyph = `<defs><mask id="${id}" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64">`
9
+ + '<rect width="64" height="64" fill="#fff"/>'
10
+ + '<path d="M15.5 34v19M48.5 34v19" stroke="#000" stroke-width="2.5"/>'
11
+ + '<path d="M22 15.5v7.5a5 5 0 0 0 5 5h10a5 5 0 0 0 5-5v-7.5" fill="none" stroke="#000" stroke-width="3"/>'
12
+ + '<rect x="29" y="23" width="6" height="8" rx="2" fill="#000"/>'
13
+ + '<rect x="22" y="39" width="20" height="20" rx="4" fill="none" stroke="#000" stroke-width="3"/>'
14
+ + `</mask></defs><g mask="url(#${id})" fill="${opts.ink}">`
15
+ + `<path d="M28 13v-2a4 4 0 0 1 8 0v2" fill="none" stroke="${opts.ink}" stroke-width="4"/>`
16
+ + '<rect x="10" y="35" width="10" height="17" rx="3.5"/><rect x="44" y="35" width="10" height="17" rx="3.5"/>'
17
+ + '<rect x="16" y="12" width="32" height="47" rx="7"/></g>'
18
+ + `<rect x="24.5" y="41.5" width="15" height="17.5" rx="2" fill="${BRAND_GREEN}"/>`;
19
+ const body = opts.tile
20
+ ? `<rect width="64" height="64" rx="14" fill="${opts.tile}"/><g transform="translate(5 4) scale(.84)">${glyph}</g>`
21
+ : glyph;
22
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${body}</svg>`;
23
+ }
@@ -0,0 +1,103 @@
1
+ import { type PacklightPaths } from '../archive/paths.js';
2
+ import { MESSAGES } from '../core/messages.js';
3
+ import type { Inventory, Item } from '../core/types.js';
4
+ import { type FixPlan } from './fix.js';
5
+ import { type Suggestion } from './suggest.js';
6
+ export interface ReportItem extends Omit<Item, 'declarations' | 'descHash' | 'targetFingerprint' | 'logKeys'> {
7
+ logKeys: string[];
8
+ suggestion: Suggestion;
9
+ keptAt?: string;
10
+ /** Kept, but its files changed since (design-delta D2 correction). */
11
+ keptUpdatedSince?: boolean;
12
+ /** Archived by packlight and installed again since (doctor check 8). */
13
+ cameBack?: string;
14
+ }
15
+ export interface Finding {
16
+ level: 'high' | 'medium' | 'good';
17
+ title: string;
18
+ body: string;
19
+ }
20
+ export interface ReportData {
21
+ version: 1;
22
+ scanId: string;
23
+ createdAt: string;
24
+ agent: string;
25
+ scope: string;
26
+ home: string;
27
+ window: Inventory['window'];
28
+ sessions: number;
29
+ linesUnreadable: number;
30
+ coverage: Inventory['coverage'];
31
+ hasLogs: boolean;
32
+ projects: {
33
+ root: string;
34
+ name: string;
35
+ sessions: number;
36
+ }[];
37
+ items: ReportItem[];
38
+ budget: Inventory['budget'];
39
+ listingPriority: string[];
40
+ /** What the scope's newest session started with, by source (characters). */
41
+ sessionLoad: SessionLoad | null;
42
+ /** The one-click fix: every suggested item, and what archiving them buys. */
43
+ fix: FixPlan;
44
+ thresholds: {
45
+ sessions: number;
46
+ days: number;
47
+ };
48
+ previous?: {
49
+ scanId: string;
50
+ createdAt: string;
51
+ hookStartChars: number;
52
+ dropped: number | null;
53
+ budgetTimestamp: string | null;
54
+ };
55
+ current: {
56
+ hookStartChars: number;
57
+ };
58
+ archived: {
59
+ opId: string;
60
+ itemId: string;
61
+ itemName: string;
62
+ kind: string;
63
+ method: string;
64
+ status: string;
65
+ archivedAt: string;
66
+ where: string;
67
+ }[];
68
+ /** Marks from the newest picks file saved for the previous scan (design-delta DE4 reopened). */
69
+ savedPicks?: {
70
+ scanId: string;
71
+ file: string;
72
+ marks: Record<string, 'archive' | 'keep'>;
73
+ };
74
+ findings: Finding[];
75
+ messages: typeof MESSAGES & {
76
+ applyCommand: string;
77
+ reportCommand: string;
78
+ pasteCommand: string;
79
+ fixCommand: string;
80
+ invoke: string;
81
+ };
82
+ }
83
+ /** Characters hooks add at each session start: the sum of each start hook's average injection. */
84
+ export declare function hookStartChars(inv: Inventory): number;
85
+ export interface SessionLoad {
86
+ skillListing: number;
87
+ mcpTools: number;
88
+ mcpToolCount: number;
89
+ mcpInstructions: number;
90
+ hookStart: number;
91
+ agents: number;
92
+ observedAt: string | null;
93
+ agentsMd?: number;
94
+ pluginInstructions?: number;
95
+ measuredTokens?: number;
96
+ measuredSessions?: number;
97
+ }
98
+ /** Rough tokens for a number of characters: about 4 per token for English text (labelled "about" wherever shown). */
99
+ export declare const CHARS_PER_TOKEN = 4;
100
+ /** Characters of setup a session starts with; for Codex the measured first request, converted back to characters. */
101
+ export declare const loadTotal: (l: SessionLoad) => number;
102
+ export declare function sessionLoad(inv: Inventory): SessionLoad | null;
103
+ export declare function buildReport(inv: Inventory, paths: PacklightPaths): ReportData;
@@ -0,0 +1,194 @@
1
+ import { lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { basename, join, sep } from 'node:path';
3
+ import { readKeeps, findPicksFile, parsePicks } from '../archive/apply.js';
4
+ import { listManifests } from '../archive/journal.js';
5
+ import { downloadsDir, readScan } from '../archive/paths.js';
6
+ import { MESSAGES, APPLY_COMMAND, FIX_COMMAND, INVOKE, PASTE_COMMAND, REPORT_COMMAND } from '../core/messages.js';
7
+ import { fixPlan, newestBudget } from './fix.js';
8
+ import { MIN_DAYS, MIN_SESSIONS, suggestions } from './suggest.js';
9
+ /** Characters hooks add at each session start: the sum of each start hook's average injection. */
10
+ export function hookStartChars(inv) {
11
+ return Math.round(inv.items
12
+ .filter(i => i.kind === 'hook' && i.enabled && i.hook?.event === 'SessionStart' && i.usage.hook && i.usage.hook.firings > 0)
13
+ .reduce((n, i) => n + i.usage.hook.injectedChars / i.usage.hook.firings, 0));
14
+ }
15
+ /** Rough tokens for a number of characters: about 4 per token for English text (labelled "about" wherever shown). */
16
+ export const CHARS_PER_TOKEN = 4;
17
+ /** Characters of setup a session starts with; for Codex the measured first request, converted back to characters. */
18
+ export const loadTotal = (l) => (l.measuredTokens ? l.measuredTokens * CHARS_PER_TOKEN : l.skillListing + l.mcpTools + l.mcpInstructions + l.hookStart + l.agents + (l.agentsMd ?? 0) + (l.pluginInstructions ?? 0));
19
+ export function sessionLoad(inv) {
20
+ if (inv.agent === 'codex') {
21
+ const s = inv.codexStart;
22
+ if (!s)
23
+ return null;
24
+ const t = [...s.firstRequestTokens].sort((a, b) => a - b);
25
+ return {
26
+ skillListing: s.skillListingChars, mcpTools: 0, mcpToolCount: 0, mcpInstructions: 0, hookStart: 0, agents: 0, observedAt: s.timestamp,
27
+ agentsMd: s.agentsMdChars, pluginInstructions: s.pluginInstructionChars + s.appInstructionChars,
28
+ ...(t.length ? { measuredTokens: t[Math.floor(t.length / 2)], measuredSessions: t.length } : {}),
29
+ };
30
+ }
31
+ const tl = inv.toolListing?.[inv.projectScope] ?? Object.values(inv.toolListing ?? {}).sort((a, b) => (a.timestamp ?? '').localeCompare(b.timestamp ?? '')).at(-1);
32
+ const b = newestBudget(inv);
33
+ if (!tl && !b)
34
+ return null;
35
+ const servers = Object.values(tl?.servers ?? {});
36
+ return {
37
+ skillListing: b?.listingChars ?? 0,
38
+ mcpTools: servers.reduce((n, s) => n + s.chars, 0),
39
+ mcpToolCount: servers.reduce((n, s) => n + s.tools, 0),
40
+ mcpInstructions: servers.reduce((n, s) => n + s.instructionChars, 0),
41
+ hookStart: hookStartChars(inv),
42
+ // Agent descriptions load every session too: global ones and the scanned project's own.
43
+ agents: Math.round(inv.items.filter(i => i.kind === 'agent' && i.enabled && (i.projectRoot === null || i.projectRoot === inv.projectScope)).reduce((n, i) => n + i.standingChars, 0)),
44
+ observedAt: tl?.timestamp ?? b?.timestamp ?? null,
45
+ };
46
+ }
47
+ function previousScan(paths, inv) {
48
+ let ids;
49
+ try {
50
+ ids = readdirSync(paths.scans).filter(id => id < inv.scanId).sort().reverse();
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ for (const id of ids) {
56
+ const s = readScan(paths, id);
57
+ if (s && s.agent === inv.agent && s.projectScope === inv.projectScope)
58
+ return s;
59
+ }
60
+ return undefined;
61
+ }
62
+ function findings(inv, items) {
63
+ const out = [];
64
+ const settings = (() => { try {
65
+ return JSON.parse(readFileSync(join(inv.home, '.claude', 'settings.json'), 'utf8'));
66
+ }
67
+ catch {
68
+ return {};
69
+ } })();
70
+ if (typeof settings.model === 'string' && /sonnet|haiku/i.test(settings.model)) {
71
+ out.push({ level: 'high', title: `Your default model is set to "${settings.model}"`, body: 'Every new session starts on a smaller model unless you pick another one. Remove "model" from ~/.claude/settings.json, or set it to the model you mean.' });
72
+ }
73
+ const perSession = (i) => (inv.sessionsInWindow ? i.usage.total / inv.sessionsInWindow : 0);
74
+ // Heavy servers barely used: their tool list loads every session whether or not a tool is called.
75
+ const heavy = items.filter(i => i.kind === 'mcp' && i.standingChars >= 2000 && i.usage.total > 0 && i.usage.total <= Math.max(2, inv.sessionsInWindow / 50))
76
+ .sort((a, b) => b.standingChars - a.standingChars);
77
+ if (heavy.length && inv.sessionsInWindow) {
78
+ const name = (i) => (i.source === 'claude.ai connector' && i.description ? `${i.name.slice(0, 8)}… (${i.description})` : i.name);
79
+ out.push({ level: 'medium', title: `${heavy.length} MCP server${heavy.length === 1 ? ' loads' : 's load'} a lot every session and ${heavy.length === 1 ? 'is' : 'are'} rarely called`,
80
+ body: heavy.map(i => `${name(i)}: ${(i.standingChars / 1000).toFixed(1)}k chars every session, called ${i.usage.total === 1 ? 'once' : `${i.usage.total} times`} in ${inv.sessionsInWindow} sessions.`).join(' ') + ' Turn one off where you added it and back on when you need it.' });
81
+ }
82
+ for (const h of items.filter(i => i.kind === 'hook' && i.usage.hook && i.usage.hook.firings > 0)) {
83
+ const u = h.usage.hook;
84
+ const per = u.injectedChars / u.firings;
85
+ if (h.hook?.event === 'SessionStart' && per > 5000)
86
+ out.push({ level: 'high', title: `${h.name} adds ${Math.round(per / 100) / 10}k characters at every session start`, body: `It fired ${u.firings} times in this scan. That text sits in the context of every session.` });
87
+ if (perSession(h) > 50)
88
+ out.push({ level: 'medium', title: `${h.name} fires about ${Math.round(perSession(h))} times per session`, body: `Each firing adds about ${Math.round(per)} characters and takes ${u.avgDurationMs} ms on average.` });
89
+ if (u.p95DurationMs > 1000)
90
+ out.push({ level: 'medium', title: `${h.name} is slow`, body: `One in twenty runs takes ${(u.p95DurationMs / 1000).toFixed(1)} s or longer.` });
91
+ }
92
+ const off = items.filter(i => i.kind === 'plugin' && !i.enabled && i.path);
93
+ if (off.length)
94
+ out.push({ level: 'medium', title: `${off.length} plugin${off.length === 1 ? ' is' : 's are'} turned off but still installed`, body: `${off.map(p => p.name).join(', ')}. They load nothing, but stay in the plugin cache.` });
95
+ const broken = [];
96
+ const skillsDir = join(inv.home, '.claude', 'skills');
97
+ try {
98
+ for (const name of readdirSync(skillsDir)) {
99
+ const p = join(skillsDir, name);
100
+ if (lstatSync(p).isSymbolicLink()) {
101
+ try {
102
+ statSync(p);
103
+ }
104
+ catch {
105
+ broken.push(name);
106
+ }
107
+ }
108
+ }
109
+ }
110
+ catch { /* no skills folder */ }
111
+ if (broken.length)
112
+ out.push({ level: 'medium', title: `${broken.length} skill link${broken.length === 1 ? ' points' : 's point'} nowhere`, body: `${broken.join(', ')} in ~/.claude/skills ${broken.length === 1 ? 'is a link' : 'are links'} to a folder that no longer exists.` });
113
+ const shared = items.filter(i => i.usage.ambiguous);
114
+ if (shared.length)
115
+ out.push({ level: 'medium', title: `${shared.length} items share a name or command with another item`, body: 'Their usage cannot be told apart, so packlight never suggests them. Compare them in the Items tab.' });
116
+ const mcpNames = new Map();
117
+ for (const m of items.filter(i => i.kind === 'mcp' && !i.plugin))
118
+ mcpNames.set(m.name, (mcpNames.get(m.name) ?? 0) + 1);
119
+ const twice = [...mcpNames].filter(([, n]) => n > 1).map(([n]) => n);
120
+ if (twice.length)
121
+ out.push({ level: 'medium', title: `${twice.length} MCP server${twice.length === 1 ? ' is' : 's are'} configured more than once`, body: twice.join(', ') });
122
+ const back = items.filter(i => i.cameBack);
123
+ if (back.length)
124
+ out.push({ level: 'medium', title: `${back.length} archived item${back.length === 1 ? ' came' : 's came'} back`, body: `${back.map(i => i.name).join(', ')}: archived by packlight and installed again since, probably by an updater.` });
125
+ if (inv.coverage.uncertain)
126
+ out.push({ level: 'high', title: 'Usage may be incomplete', body: `${inv.coverage.reasons.join('; ')}. Nothing is suggested from usage until packlight can read these logs.` });
127
+ return out;
128
+ }
129
+ export function buildReport(inv, paths) {
130
+ const keeps = readKeeps(paths).keeps;
131
+ const sug = suggestions(inv, new Set(Object.keys(keeps)));
132
+ // Only this agent's archives: a manifest belongs to Codex when the item came from a .codex folder.
133
+ const fromCodex = (where) => where.includes(`${sep}.codex${sep}`);
134
+ const manifests = listManifests(paths).filter(m => fromCodex(m.originalPath ?? m.settingsFile ?? '') === (inv.agent === 'codex'));
135
+ const openArchives = new Map(manifests.filter(m => m.status === 'archived').map(m => [m.itemId, m]));
136
+ const items = inv.items.map(({ declarations, descHash, targetFingerprint, ...rest }) => {
137
+ const k = keeps[rest.id];
138
+ const m = openArchives.get(rest.id);
139
+ return {
140
+ ...rest,
141
+ suggestion: sug.get(rest.id),
142
+ ...(k ? { keptAt: k.keptAt, keptUpdatedSince: k.fingerprintAtKeep !== targetFingerprint } : {}),
143
+ ...(m && m.method !== 'manual' ? { cameBack: m.archivedAt } : {}),
144
+ };
145
+ });
146
+ const prev = previousScan(paths, inv);
147
+ const prevBudget = prev ? newestBudget(prev) : undefined;
148
+ let savedPicks;
149
+ if (prev) {
150
+ const file = findPicksFile(downloadsDir(inv.home));
151
+ if (file) {
152
+ try {
153
+ const p = parsePicks(readFileSync(file, 'utf8'));
154
+ if (p.scanId === prev.scanId) {
155
+ const marks = {};
156
+ for (const x of p.picks)
157
+ if (x.action === 'archive' || x.action === 'keep')
158
+ marks[x.id] = x.action;
159
+ savedPicks = { scanId: p.scanId, file: basename(file), marks };
160
+ }
161
+ }
162
+ catch { /* not a picks file packlight can read */ }
163
+ }
164
+ }
165
+ return {
166
+ version: 1,
167
+ scanId: inv.scanId,
168
+ createdAt: inv.createdAt,
169
+ agent: inv.agent,
170
+ scope: inv.projectScope,
171
+ home: inv.home,
172
+ window: inv.window,
173
+ sessions: inv.sessionsInWindow,
174
+ linesUnreadable: inv.linesUnreadable,
175
+ coverage: inv.coverage,
176
+ hasLogs: inv.sessions.length > 0,
177
+ projects: inv.projects.filter(p => p.exists).map(p => ({ root: p.root, name: basename(p.root), sessions: p.sessions })),
178
+ items,
179
+ budget: inv.budget,
180
+ listingPriority: inv.listingPriority ?? [],
181
+ fix: fixPlan(inv, sug, new Set(Object.keys(keeps))),
182
+ sessionLoad: sessionLoad(inv),
183
+ thresholds: { sessions: MIN_SESSIONS, days: MIN_DAYS },
184
+ ...(prev ? { previous: { scanId: prev.scanId, createdAt: prev.createdAt, hookStartChars: hookStartChars(prev), dropped: prevBudget?.dropped.length ?? null, budgetTimestamp: prevBudget?.timestamp ?? null } } : {}),
185
+ current: { hookStartChars: hookStartChars(inv) },
186
+ archived: manifests.map(m => ({
187
+ opId: m.opId, itemId: m.itemId, itemName: m.itemName, kind: m.kind, method: m.method, status: m.status, archivedAt: m.archivedAt,
188
+ where: m.originalPath ?? (m.settingsFile ? `${m.settingsFile} ${m.jsonPointer}` : m.manualSteps ?? ''),
189
+ })),
190
+ ...(savedPicks ? { savedPicks } : {}),
191
+ findings: findings(inv, items),
192
+ messages: { ...MESSAGES, applyCommand: APPLY_COMMAND, reportCommand: REPORT_COMMAND, pasteCommand: PASTE_COMMAND, fixCommand: FIX_COMMAND, invoke: INVOKE },
193
+ };
194
+ }
@@ -0,0 +1,9 @@
1
+ import type { ReportData } from './model.js';
2
+ /** No network at all (acceptance criterion 5): inline style and script only, images only as data: URIs. */
3
+ export declare const CSP = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:";
4
+ /**
5
+ * JSON that is safe inside a <script> element: "<", ">" and "&" are escaped so no description can close the
6
+ * element or open a comment, and the line separators JavaScript treats as newlines are escaped too (CEO F4).
7
+ */
8
+ export declare function scriptSafeJson(value: unknown): string;
9
+ export declare function renderReport(data: ReportData): string;
@@ -0,0 +1,45 @@
1
+ import { clientMain } from './client.js';
2
+ import { logoSvg } from './logo.js';
3
+ import { STYLES } from './styles.js';
4
+ import { simulateListing } from './suggest.js';
5
+ /** No network at all (acceptance criterion 5): inline style and script only, images only as data: URIs. */
6
+ export const CSP = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:";
7
+ // A tile, so the tab icon reads on light and dark browser chrome alike.
8
+ const ICON = 'data:image/svg+xml,' + encodeURIComponent(logoSvg({ ink: '#f4efe0', tile: '#18181b' }));
9
+ /**
10
+ * JSON that is safe inside a <script> element: "<", ">" and "&" are escaped so no description can close the
11
+ * element or open a comment, and the line separators JavaScript treats as newlines are escaped too (CEO F4).
12
+ */
13
+ export function scriptSafeJson(value) {
14
+ return JSON.stringify(value)
15
+ .replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026')
16
+ .replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
17
+ }
18
+ const escapeText = (s) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
19
+ export function renderReport(data) {
20
+ const scope = data.scope === 'global' ? 'all projects' : data.scope.split(/[\\/]/).pop() ?? data.scope;
21
+ const title = `packlight: ${scope}, ${new Date(data.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}`;
22
+ return `<!doctype html>
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="utf-8">
26
+ <meta http-equiv="Content-Security-Policy" content="${CSP}">
27
+ <meta name="viewport" content="width=device-width, initial-scale=1">
28
+ <meta name="referrer" content="no-referrer">
29
+ <title>${escapeText(title)}</title>
30
+ <link rel="icon" href="${ICON}">
31
+ <style>${STYLES}</style>
32
+ </head>
33
+ <body>
34
+ <a class="sr" href="#main">Skip to the report</a>
35
+ <div id="app"><noscript>This report needs JavaScript, which runs only inside this file and makes no network requests.</noscript></div>
36
+ <div id="live" class="sr" aria-live="polite"></div>
37
+ <script id="packlight-data" type="application/json">${scriptSafeJson(data)}</script>
38
+ <script>
39
+ const simulateListing = ${simulateListing.toString()};
40
+ (${clientMain.toString()})();
41
+ </script>
42
+ </body>
43
+ </html>
44
+ `;
45
+ }
@@ -0,0 +1 @@
1
+ export declare const STYLES: string;