@kolisachint/hoocode-agent 0.5.15 → 0.5.17

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 (35) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/core/learn/extract.d.ts +48 -1
  3. package/dist/core/learn/extract.d.ts.map +1 -1
  4. package/dist/core/learn/extract.js +120 -23
  5. package/dist/core/learn/extract.js.map +1 -1
  6. package/dist/core/learn/state.d.ts +6 -3
  7. package/dist/core/learn/state.d.ts.map +1 -1
  8. package/dist/core/learn/state.js +6 -3
  9. package/dist/core/learn/state.js.map +1 -1
  10. package/dist/core/session-manager.d.ts +8 -0
  11. package/dist/core/session-manager.d.ts.map +1 -1
  12. package/dist/core/session-manager.js +12 -2
  13. package/dist/core/session-manager.js.map +1 -1
  14. package/dist/core/settings-manager.d.ts +11 -0
  15. package/dist/core/settings-manager.d.ts.map +1 -1
  16. package/dist/core/settings-manager.js +14 -0
  17. package/dist/core/settings-manager.js.map +1 -1
  18. package/dist/extensions/core/learn.d.ts.map +1 -1
  19. package/dist/extensions/core/learn.js +149 -15
  20. package/dist/extensions/core/learn.js.map +1 -1
  21. package/dist/modes/interactive/components/settings-selector.d.ts +3 -1
  22. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  23. package/dist/modes/interactive/components/settings-selector.js +72 -0
  24. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  25. package/dist/modes/interactive/interactive-mode.d.ts +21 -3
  26. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  27. package/dist/modes/interactive/interactive-mode.js +54 -15
  28. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  29. package/docs/settings.md +4 -0
  30. package/docs/usage.md +16 -1
  31. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  32. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  33. package/examples/extensions/sandbox/package.json +1 -1
  34. package/examples/extensions/with-deps/package.json +1 -1
  35. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.17] - 2026-08-14
4
+
5
+ ### Fixed
6
+
7
+ - The `Working...` line no longer strobes, the hardware cursor no longer
8
+ flickers at the end of it, and a long working message is truncated instead of
9
+ wrapping onto a second line. See the tui changelog for details — the same
10
+ fixes cover the compaction, retry, branch-summary and bash-command loaders.
11
+
12
+ ## [0.5.16] - 2026-08-13
13
+
14
+ ### Fixed
15
+
16
+ - `/learn` no longer reports "no recent sessions in this directory" while a full
17
+ history sits in `~/.hoocode/sessions/`. It searched only the directory the
18
+ live session manager pointed at, which is the right one in the ordinary case
19
+ and wrong in three: an in-memory session (`--no-session`) reports no directory
20
+ at all and was taken literally, `--session <path>` points at wherever that
21
+ file lives, and a custom `sessionDir` setting points at one directory shared
22
+ by every project. The per-cwd session directory is now always searched
23
+ alongside it, and a session reachable from both is counted once.
24
+ - `/learn` no longer discards sessions whose recorded working directory is the
25
+ same directory spelled differently — reached through a symlink, in different
26
+ case on the case-insensitive filesystems macOS and Windows ship by default, or
27
+ with a trailing separator. Any of these silently dropped the entire history the
28
+ command exists to read.
29
+ - `/learn`'s per-directory memory is keyed on the working directory rather than
30
+ on the live session's directory, so a session with no directory of its own no
31
+ longer lands every project on one shared state file, and projects configured
32
+ to share a `sessionDir` no longer share one bookmark.
33
+ - Warnings and errors in the TUI are rendered as one filled block instead of two
34
+ different shapes. A warning previously had neither the blank line nor the left
35
+ padding an error had, so it collided with whatever preceded it and hung off the
36
+ margin, and neither had a background to separate it from ordinary output.
37
+
38
+ ### Added
39
+
40
+ - `/learn` explains an empty result instead of asserting there is no history. It
41
+ names the directories it searched, how many transcripts they hold, and why each
42
+ was passed over — outside the age window, beyond the session cap, or recorded
43
+ under a different working directory. Those have different fixes, and the single
44
+ sentence it printed before could not tell them apart.
45
+ - `/learn settings` — the five thresholds with the values in force, the user and
46
+ project `settings.json` paths to set them in, the session directory being read,
47
+ and the state file. The settings existed but had no discoverable home; every
48
+ message that now reports a disappointing result points at them.
49
+ - A **Learning** category in the `/settings` pane holding the same five
50
+ thresholds. Top level rather than folded into Advanced, since these are what
51
+ decide whether `/learn` finds anything and burying them is what made them
52
+ undiscoverable. A value set by hand in `settings.json` that is not one of the
53
+ presets stays in the cycle, so a keypress cannot silently snap the window
54
+ down to the first preset.
55
+
3
56
  ## [0.5.15] - 2026-08-13
4
57
 
5
58
  ### Fixed
@@ -84,9 +84,35 @@ export interface WorkflowCandidate extends Proposable {
84
84
  count: number;
85
85
  sessions: number;
86
86
  }
87
+ /**
88
+ * Why a session file on disk did not make it into the digest.
89
+ *
90
+ * "No recent sessions" is the one outcome a user cannot act on without this:
91
+ * an empty session directory, a directory full of month-old sessions, and a
92
+ * directory full of sessions belonging to another checkout all produce the same
93
+ * sentence, and the fix differs in each case.
94
+ */
95
+ export interface SessionScanReport {
96
+ /** Directories actually searched, in order. */
97
+ dirs: string[];
98
+ /** Directories that do not exist on disk. */
99
+ missingDirs: string[];
100
+ /** `.jsonl` files found across all searched directories. */
101
+ files: number;
102
+ /** Skipped for being older than the age window. */
103
+ tooOld: number;
104
+ /** Skipped because the session header records a different working directory. */
105
+ otherCwd: number;
106
+ /** Skipped for being beyond `maxSessions`. */
107
+ overLimit: number;
108
+ /** Skipped for being unreadable, unparseable, or empty. */
109
+ unreadable: number;
110
+ }
87
111
  export interface LearnDigest {
88
112
  scannedSessions: number;
89
113
  skippedSessions: number;
114
+ /** Where the sessions came from, and what was passed over. */
115
+ scan: SessionScanReport;
90
116
  oldestSession?: string;
91
117
  newestSession?: string;
92
118
  agentsFilePath?: string;
@@ -106,7 +132,11 @@ export interface LearnDigest {
106
132
  export interface ExtractOptions {
107
133
  cwd: string;
108
134
  agentDir: string;
109
- /** Override the directory scanned. Defaults to the per-cwd session dir. */
135
+ /**
136
+ * An extra directory to scan, normally the live session manager's. The
137
+ * per-cwd default directory is always scanned as well, so a session manager
138
+ * pointing somewhere unusual cannot hide this directory's history.
139
+ */
110
140
  sessionDir?: string;
111
141
  maxSessions?: number;
112
142
  maxAgeDays?: number;
@@ -134,6 +164,23 @@ export interface ExtractOptions {
134
164
  /** Injectable clock, for tests. */
135
165
  now?: Date;
136
166
  }
167
+ /**
168
+ * Every directory this cwd's sessions could be sitting in.
169
+ *
170
+ * The caller passes the live session manager's directory, which is the right
171
+ * answer almost always — but not quite always, and each exception silently
172
+ * emptied the digest. An in-memory session (`--no-session`) reports `""`; an
173
+ * explicit `--session <path>` reports wherever that file lives; a custom
174
+ * `sessionDir` setting points at one shared directory. In every one of those
175
+ * cases the per-cwd default directory still holds the history worth mining, so
176
+ * search both and let the header check sort out what belongs to this cwd.
177
+ */
178
+ export declare function candidateSessionDirs(options: Pick<ExtractOptions, "cwd" | "agentDir" | "sessionDir">): string[];
179
+ /**
180
+ * Where this cwd's sessions were found and what was passed over, without
181
+ * ranking anything. `/learn stats` reports on the window without re-mining it.
182
+ */
183
+ export declare function scanSessions(options: ExtractOptions): SessionScanReport;
137
184
  /**
138
185
  * Everything a proposal could already have been written into.
139
186
  *
@@ -1 +1 @@
1
- {"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../../src/core/learn/extract.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Session mining for `/learn`.\n *\n * Reads session `.jsonl` files straight off disk rather than the live context.\n * That is the whole point: the on-disk transcript is complete even when the\n * in-context one has been compacted away, and it spans every past session\n * instead of only this one. Cross-session repetition is the signal that decides\n * whether something is a durable rule or a one-off, and it is the one thing a\n * prompt reading its own context cannot see.\n *\n * The split of labour is deliberate. This module is entirely deterministic: it\n * parses, filters, normalizes, counts and ranks. Judgement — is this a rule, how\n * should it be phrased, which scope owns it — belongs to the model reading the\n * digest, which is why the output carries evidence (counts, sessions, dates)\n * rather than conclusions.\n */\n\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { dirname, join, resolve } from \"node:path\";\nimport type { AgentMessage } from \"@kolisachint/hoocode-agent-core\";\nimport type { TextContent, ToolCall } from \"@kolisachint/hoocode-ai\";\nimport { getUserAgentsDir } from \"../../config.js\";\nimport { getDefaultSessionDir } from \"../session-manager.js\";\nimport { loadSkills } from \"../skills.js\";\nimport {\n\tcommandHead,\n\tcontentWords,\n\textractErrorRegion,\n\tisBenignFailure,\n\tisRuleShapedDirective,\n\tisUninformativeFailure,\n\tnormalizeCommand,\n\tnormalizeDirective,\n\tnormalizeErrorSignature,\n\twordOverlap,\n} from \"./normalize.js\";\nimport { judge, type LearnState } from \"./state.js\";\n\n/**\n * Prefix on the message `/learn` injects. The digest is persisted like any user\n * turn, so without this marker the next `/learn` would mine its own output and\n * every proposal would compound its own count.\n */\nexport const LEARN_DIGEST_MARKER = \"[learn-digest]\";\n\n/** Sessions considered, newest first. */\nconst DEFAULT_MAX_SESSIONS = 20;\n/** Sessions older than this are ignored — a pattern that stopped is not a rule. */\nconst DEFAULT_MAX_AGE_DAYS = 30;\n/** Entries parsed per session file, as a guard against pathological transcripts. */\nconst MAX_ENTRIES_PER_SESSION = 8000;\n/** Tool calls per session fed to the workflow detector. */\nconst MAX_TOOL_CALLS_PER_SESSION = 400;\n/** How far forward the fix extractor looks for the same command succeeding. */\nconst FIX_LOOKAHEAD = 40;\n/** Word overlap against an existing rule above which a directive counts as covered. */\nconst COVERED_OVERLAP = 0.6;\n/**\n * The same bar for skills, set higher on purpose.\n *\n * A rule is one line, so overlap against it is a sharp signal. A skill is a name\n * plus a description written to attract matches, which is a far larger haystack\n * — a short directive's words turn up in it by chance much more readily. The\n * higher bar and the truncation below keep \"you already have a skill for this\"\n * from being said on a coincidence.\n */\nconst SKILL_COVERED_OVERLAP = 0.75;\n/** Description characters considered. The opening says what a skill does; the rest is trigger bait. */\nconst SKILL_DESCRIPTION_CHARS = 300;\n/** Directives must reach this many occurrences to be reported at all. */\nconst DEFAULT_MIN_DIRECTIVE_COUNT = 2;\n/** Tool sequence lengths considered as workflow candidates. */\nconst WORKFLOW_MIN_LEN = 3;\nconst WORKFLOW_MAX_LEN = 5;\n/** Repeats before a tool sequence is worth proposing as a skill. */\nconst DEFAULT_MIN_WORKFLOW_COUNT = 3;\n/** Cap on each list in the digest, so the model's budget goes to the top signals. */\nconst DEFAULT_MAX_PER_CATEGORY = 8;\n\n/**\n * Where a repeated directive already lives, if anywhere.\n *\n * A directive covered by a rule and said only once is simply dropped — the rule\n * exists and is working. What survives is one of three cases, and they want\n * different responses:\n *\n * - `new` — not written down anywhere. Propose it.\n * - `restated` — a context-file rule covers it and you said it anyway, so the\n * rule is not working. Rewrite it; do not add a second one.\n * - `has-skill` — a *skill* covers it and you asked by hand anyway, which\n * usually means the skill's `description` is not triggering. Sharpen the\n * description rather than writing a rule that duplicates the skill.\n */\nexport type DirectiveStatus = \"new\" | \"restated\" | \"has-skill\";\n\n/** Fields every proposable item shares, so suppression can be applied uniformly. */\ninterface Proposable {\n\t/** Stable identity across runs — what the state file remembers. */\n\tkey: string;\n\t/** Newest occurrence in the window, ISO. */\n\tlastSeen: string;\n}\n\nexport interface DirectiveCluster extends Proposable {\n\t/** Representative raw text, the longest seen in the cluster. */\n\ttext: string;\n\tnormalized: string;\n\t/** Total times said. */\n\tcount: number;\n\t/** Distinct sessions it was said in — the stronger of the two counts. */\n\tsessions: number;\n\tstatus: DirectiveStatus;\n\t/** The existing rule line matched, when status is `restated`. */\n\texistingRule?: string;\n\t/** The skill that already covers this, when status is `has-skill`. */\n\texistingSkill?: string;\n\t/**\n\t * Shown before and still not written down anywhere — neither as a rule nor as\n\t * a skill — so you saw this proposal and passed on it. Only meaningful for\n\t * directives, which are the only items with a real coverage signal.\n\t */\n\tpreviouslyDeclined: boolean;\n}\n\nexport interface FixCandidate extends Proposable {\n\t/** Normalized failing command. */\n\tcommand: string;\n\t/** Normalized error signature, the dedupe key. */\n\tsignature: string;\n\t/** Short raw excerpt, so the model sees the real error text. */\n\terrorExcerpt: string;\n\t/** Commands run between the failure and the pass. */\n\tinterveningCommands: string[];\n\t/** Files edited between the failure and the pass. */\n\teditedFiles: string[];\n\t/** Times this signature failed and was resolved across the window. */\n\tcount: number;\n\tsessions: number;\n}\n\nexport interface WorkflowCandidate extends Proposable {\n\t/** Tool-call signatures in order. */\n\tsteps: string[];\n\tcount: number;\n\tsessions: number;\n}\n\nexport interface LearnDigest {\n\tscannedSessions: number;\n\tskippedSessions: number;\n\toldestSession?: string;\n\tnewestSession?: string;\n\tagentsFilePath?: string;\n\tagentsFileTokens?: number;\n\tdirectives: DirectiveCluster[];\n\tfixes: FixCandidate[];\n\tworkflows: WorkflowCandidate[];\n\t/** Items held back because nothing new has happened since they were last shown. */\n\tsuppressed: number;\n\t/** Everything this run put on screen, for the caller to persist. */\n\tsurfaced: Array<{ key: string; lastSeen: string; covered: boolean }>;\n}\n\nexport interface ExtractOptions {\n\tcwd: string;\n\tagentDir: string;\n\t/** Override the directory scanned. Defaults to the per-cwd session dir. */\n\tsessionDir?: string;\n\tmaxSessions?: number;\n\tmaxAgeDays?: number;\n\t/** Occurrences a directive needs before it is proposed. The signal/noise dial. */\n\tminRepeats?: number;\n\t/** Non-overlapping repeats a tool sequence needs before it is proposed as a skill. */\n\tminWorkflowRepeats?: number;\n\t/** Cap on each list in the digest. */\n\tmaxProposals?: number;\n\t/**\n\t * What previous runs already showed. Items with no new occurrences since are\n\t * held back. Omit (or pass `ignoreState`) to propose everything in the window.\n\t */\n\tstate?: LearnState;\n\t/** Re-propose everything, ignoring what previous runs surfaced (`/learn all`). */\n\tignoreState?: boolean;\n\t/**\n\t * Skills a directive can already be covered by. Defaults to the ones loaded\n\t * from disk; injectable so tests do not read the developer's real skills.\n\t */\n\tskills?: Array<{ name: string; description: string }>;\n\t/** Injectable clock, for tests. */\n\tnow?: Date;\n}\n\ninterface SessionHeaderLike {\n\ttype: \"session\";\n\tid?: string;\n\ttimestamp?: string;\n\tcwd?: string;\n}\n\ninterface EntryLike {\n\ttype: string;\n\tid?: string;\n\tparentId?: string | null;\n\ttimestamp?: string;\n\tmessage?: AgentMessage;\n}\n\n/** One session, reduced to the branch that was actually taken. */\ninterface ParsedSession {\n\tfile: string;\n\tid: string;\n\ttimestamp: string;\n\tentries: EntryLike[];\n}\n\nfunction textOf(content: unknown): string {\n\tif (typeof content === \"string\") return content;\n\tif (!Array.isArray(content)) return \"\";\n\treturn content\n\t\t.map((block) =>\n\t\t\tblock && typeof block === \"object\" && (block as TextContent).type === \"text\"\n\t\t\t\t? ((block as TextContent).text ?? \"\")\n\t\t\t\t: \"\",\n\t\t)\n\t\t.join(\"\\n\")\n\t\t.trim();\n}\n\nfunction isToolCall(block: unknown): block is ToolCall {\n\treturn !!block && typeof block === \"object\" && (block as ToolCall).type === \"toolCall\";\n}\n\n/**\n * Reduce a session's raw entries to the branch that was actually taken.\n *\n * Session files are trees — forks and clones append entries that were never\n * part of the same conversation. Walking parent links back from the last entry\n * keeps the extractor from stitching a \"fix\" out of two turns that never\n * happened in sequence. Sessions written before entry ids existed are flat, and\n * for those file order *is* the branch.\n */\nfunction activeBranch(entries: EntryLike[]): EntryLike[] {\n\tconst withIds = entries.filter((e) => typeof e.id === \"string\");\n\tif (withIds.length === 0) return entries;\n\n\tconst byId = new Map<string, EntryLike>();\n\tfor (const entry of withIds) byId.set(entry.id as string, entry);\n\n\tconst branch: EntryLike[] = [];\n\tconst seen = new Set<string>();\n\tlet cursor: EntryLike | undefined = withIds[withIds.length - 1];\n\twhile (cursor?.id && !seen.has(cursor.id)) {\n\t\tseen.add(cursor.id);\n\t\tbranch.push(cursor);\n\t\tcursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;\n\t}\n\treturn branch.reverse();\n}\n\nfunction parseSessionFile(file: string, cwd: string): ParsedSession | undefined {\n\tlet raw: string;\n\ttry {\n\t\traw = readFileSync(file, \"utf-8\");\n\t} catch {\n\t\treturn undefined;\n\t}\n\n\tconst lines = raw.split(\"\\n\");\n\tlet header: SessionHeaderLike | undefined;\n\tconst entries: EntryLike[] = [];\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\tif (entries.length >= MAX_ENTRIES_PER_SESSION) break;\n\t\tlet parsed: EntryLike | SessionHeaderLike;\n\t\ttry {\n\t\t\tparsed = JSON.parse(line);\n\t\t} catch {\n\t\t\t// A partially-flushed final line is normal for a live session.\n\t\t\tcontinue;\n\t\t}\n\t\tif (parsed.type === \"session\") {\n\t\t\theader ??= parsed as SessionHeaderLike;\n\t\t\tcontinue;\n\t\t}\n\t\tentries.push(parsed as EntryLike);\n\t}\n\n\t// An explicit `--session` path can put a session for another directory in\n\t// this directory, so trust the header over the file's location.\n\tif (header?.cwd && resolve(header.cwd) !== resolve(cwd)) return undefined;\n\tif (entries.length === 0) return undefined;\n\n\treturn {\n\t\tfile,\n\t\tid: header?.id ?? file,\n\t\ttimestamp: header?.timestamp ?? statSync(file).mtime.toISOString(),\n\t\tentries: activeBranch(entries),\n\t};\n}\n\nfunction listSessions(options: ExtractOptions): { sessions: ParsedSession[]; skipped: number } {\n\tconst dir = options.sessionDir ?? getDefaultSessionDir(options.cwd, options.agentDir);\n\tif (!existsSync(dir)) return { sessions: [], skipped: 0 };\n\n\tconst maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;\n\tconst maxAgeDays = options.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;\n\tconst now = options.now ?? new Date();\n\tconst cutoff = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000;\n\n\tlet files: string[];\n\ttry {\n\t\tfiles = readdirSync(dir)\n\t\t\t.filter((f) => f.endsWith(\".jsonl\"))\n\t\t\t.map((f) => join(dir, f));\n\t} catch {\n\t\treturn { sessions: [], skipped: 0 };\n\t}\n\n\tconst dated = files\n\t\t.map((file) => {\n\t\t\ttry {\n\t\t\t\treturn { file, mtime: statSync(file).mtime.getTime() };\n\t\t\t} catch {\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t})\n\t\t.filter((f): f is { file: string; mtime: number } => !!f)\n\t\t.sort((a, b) => b.mtime - a.mtime);\n\n\tconst sessions: ParsedSession[] = [];\n\tlet skipped = 0;\n\tfor (const { file, mtime } of dated) {\n\t\tif (sessions.length >= maxSessions) {\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (mtime < cutoff) {\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tconst parsed = parseSessionFile(file, options.cwd);\n\t\tif (parsed) sessions.push(parsed);\n\t\telse skipped++;\n\t}\n\treturn { sessions, skipped };\n}\n\n/**\n * Hold back items already shown that have not recurred since, then cap the rest.\n *\n * Order matters: suppression runs *before* the cap, or an item you already\n * decided on would occupy one of the few slots the digest has and push a live\n * signal off the list.\n */\nfunction applySuppression<T extends Proposable>(\n\titems: T[],\n\tstate: LearnState | undefined,\n\tmaxProposals: number,\n\tcovered: (item: T) => boolean,\n\tonDeclined?: (item: T) => void,\n): { kept: T[]; suppressed: number } {\n\tif (!state) return { kept: items.slice(0, maxProposals), suppressed: 0 };\n\n\tconst kept: T[] = [];\n\tlet suppressed = 0;\n\tfor (const item of items) {\n\t\tconst verdict = judge(state, { key: item.key, lastSeen: item.lastSeen, covered: covered(item) });\n\t\tif (verdict.suppressed) {\n\t\t\tsuppressed++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (verdict.previouslyDeclined) onDeclined?.(item);\n\t\tkept.push(item);\n\t}\n\treturn { kept: kept.slice(0, maxProposals), suppressed };\n}\n\n/** Nearest AGENTS.md walking up from cwd, so proposals can be checked against it. */\nfunction findAgentsFile(cwd: string): string | undefined {\n\tlet dir = resolve(cwd);\n\twhile (true) {\n\t\tfor (const name of [\"AGENTS.md\", \"AGENTS.MD\", \"CLAUDE.md\", \"CLAUDE.MD\"]) {\n\t\t\tconst candidate = join(dir, name);\n\t\t\tif (existsSync(candidate)) return candidate;\n\t\t}\n\t\tconst parent = dirname(dir);\n\t\tif (parent === dir) return undefined;\n\t\tdir = parent;\n\t}\n}\n\ninterface ToolEvent {\n\tname: string;\n\targs: Record<string, any>;\n\t/** Set once the matching result is seen. */\n\tisError?: boolean;\n\toutput?: string;\n}\n\n/** Pair tool calls with their results along one branch, in call order. */\nfunction toolEvents(entries: EntryLike[]): ToolEvent[] {\n\tconst byCallId = new Map<string, ToolEvent>();\n\tconst ordered: ToolEvent[] = [];\n\n\tfor (const entry of entries) {\n\t\tconst message = entry.type === \"message\" ? entry.message : undefined;\n\t\tif (!message) continue;\n\t\tif (message.role === \"assistant\") {\n\t\t\tfor (const block of (message.content ?? []) as unknown[]) {\n\t\t\t\tif (!isToolCall(block)) continue;\n\t\t\t\tconst event: ToolEvent = { name: block.name, args: block.arguments ?? {} };\n\t\t\t\tbyCallId.set(block.id, event);\n\t\t\t\tordered.push(event);\n\t\t\t}\n\t\t} else if (message.role === \"toolResult\") {\n\t\t\tconst event = byCallId.get(message.toolCallId);\n\t\t\tif (!event) continue;\n\t\t\tevent.isError = message.isError;\n\t\t\tevent.output = textOf(message.content);\n\t\t}\n\t}\n\treturn ordered;\n}\n\n/** User turns worth mining, in order, with the digest's own output excluded. */\nfunction userDirectives(entries: EntryLike[]): string[] {\n\tconst out: string[] = [];\n\tfor (const entry of entries) {\n\t\tconst message = entry.type === \"message\" ? entry.message : undefined;\n\t\tif (!message || message.role !== \"user\") continue;\n\t\tconst text = textOf(message.content);\n\t\tif (!text || text.startsWith(LEARN_DIGEST_MARKER)) continue;\n\t\tif (!isRuleShapedDirective(text)) continue;\n\t\tout.push(text.trim());\n\t}\n\treturn out;\n}\n\nfunction clusterDirectives(\n\tperSession: Array<{ session: ParsedSession; directives: string[] }>,\n\tcoverage: CoverageIndex,\n\tminRepeats: number,\n): DirectiveCluster[] {\n\tinterface Acc {\n\t\ttext: string;\n\t\tnormalized: string;\n\t\tcount: number;\n\t\tsessions: Set<string>;\n\t\tlastSeen: string;\n\t}\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const { session, directives } of perSession) {\n\t\tfor (const text of directives) {\n\t\t\tconst normalized = normalizeDirective(text);\n\t\t\tif (!normalized) continue;\n\t\t\tconst existing = acc.get(normalized);\n\t\t\tif (existing) {\n\t\t\t\texisting.count++;\n\t\t\t\texisting.sessions.add(session.id);\n\t\t\t\tif (session.timestamp > existing.lastSeen) existing.lastSeen = session.timestamp;\n\t\t\t\tif (text.length > existing.text.length) existing.text = text;\n\t\t\t} else {\n\t\t\t\tacc.set(normalized, {\n\t\t\t\t\ttext,\n\t\t\t\t\tnormalized,\n\t\t\t\t\tcount: 1,\n\t\t\t\t\tsessions: new Set([session.id]),\n\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tconst clusters: DirectiveCluster[] = [];\n\tfor (const entry of acc.values()) {\n\t\tif (entry.count < minRepeats) continue;\n\n\t\t// Everything reaching here cleared the repeat threshold. Suppression handles\n\t\t// the case that used to make these labels lie — a proposal accepted from a\n\t\t// previous run coming back as \"not working\" when nothing had happened\n\t\t// since. By the time an item survives that filter, a match genuinely means\n\t\t// you repeated yourself after the rule or skill already existed.\n\t\tconst match = matchCoverage(entry.text, coverage);\n\t\tclusters.push({\n\t\t\tkey: `directive:${entry.normalized}`,\n\t\t\ttext: entry.text,\n\t\t\tnormalized: entry.normalized,\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t\tstatus: match.rule ? \"restated\" : match.skill ? \"has-skill\" : \"new\",\n\t\t\texistingRule: match.rule,\n\t\t\texistingSkill: match.skill,\n\t\t\tpreviouslyDeclined: false,\n\t\t});\n\t}\n\n\treturn clusters.sort((a, b) => b.sessions - a.sessions || b.count - a.count || a.text.localeCompare(b.text));\n}\n\n/** Files a mutating tool touched, for the resolution summary. */\nfunction editedFile(event: ToolEvent): string | undefined {\n\tif (![\"edit\", \"write\", \"multi_edit\", \"apply_patch\"].includes(event.name)) return undefined;\n\tconst path = event.args?.path ?? event.args?.file_path ?? event.args?.filePath;\n\treturn typeof path === \"string\" ? path : undefined;\n}\n\nfunction extractFixes(perSession: Array<{ session: ParsedSession; events: ToolEvent[] }>): FixCandidate[] {\n\tinterface Acc {\n\t\tcandidate: FixCandidate;\n\t\tsessions: Set<string>;\n\t}\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const { session, events } of perSession) {\n\t\tfor (let i = 0; i < events.length; i++) {\n\t\t\tconst failure = events[i]!;\n\t\t\tif (failure.name !== \"bash\" || !failure.isError) continue;\n\t\t\tconst command = typeof failure.args?.command === \"string\" ? failure.args.command : \"\";\n\t\t\tif (!command || isBenignFailure(command)) continue;\n\n\t\t\tconst normalized = normalizeCommand(command);\n\t\t\tconst interveningCommands: string[] = [];\n\t\t\tconst editedFiles: string[] = [];\n\t\t\tlet resolved = false;\n\n\t\t\tfor (let j = i + 1; j < Math.min(events.length, i + 1 + FIX_LOOKAHEAD); j++) {\n\t\t\t\tconst next = events[j]!;\n\t\t\t\tconst file = editedFile(next);\n\t\t\t\tif (file) editedFiles.push(file);\n\n\t\t\t\tif (next.name !== \"bash\") continue;\n\t\t\t\tconst nextCommand = typeof next.args?.command === \"string\" ? next.args.command : \"\";\n\t\t\t\tif (!nextCommand) continue;\n\n\t\t\t\t// The same command later succeeding is the only evidence that the\n\t\t\t\t// problem was actually fixed. A *different* command passing says\n\t\t\t\t// nothing, and neither does the model moving on.\n\t\t\t\tif (normalizeCommand(nextCommand) === normalized && !next.isError) {\n\t\t\t\t\tresolved = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tinterveningCommands.push(nextCommand.trim());\n\t\t\t}\n\n\t\t\tif (!resolved) continue;\n\n\t\t\tconst output = failure.output ?? \"\";\n\t\t\t// An abort is the user changing their mind, not a problem that was\n\t\t\t// solved, and empty output carries nothing to sign or show.\n\t\t\tif (isUninformativeFailure(output)) continue;\n\n\t\t\t// Sign the error region, not the whole output: build tools lead with an\n\t\t\t// identical banner, so signing everything makes unrelated failures of\n\t\t\t// the same command collide on their shared preamble.\n\t\t\tconst errorRegion = extractErrorRegion(output);\n\t\t\tconst signature = normalizeErrorSignature(errorRegion);\n\t\t\tif (!signature) continue;\n\n\t\t\tconst key = `${normalized}\u0000${signature}`;\n\t\t\tconst existing = acc.get(key);\n\t\t\tif (existing) {\n\t\t\t\texisting.candidate.count++;\n\t\t\t\texisting.sessions.add(session.id);\n\t\t\t\tif (session.timestamp > existing.candidate.lastSeen) existing.candidate.lastSeen = session.timestamp;\n\t\t\t} else {\n\t\t\t\tacc.set(key, {\n\t\t\t\t\tsessions: new Set([session.id]),\n\t\t\t\t\tcandidate: {\n\t\t\t\t\t\tkey: `fix:${key}`,\n\t\t\t\t\t\tcommand: normalized,\n\t\t\t\t\t\tsignature,\n\t\t\t\t\t\terrorExcerpt: errorRegion.replace(/\\s+/g, \" \").trim().slice(0, 240),\n\t\t\t\t\t\tinterveningCommands: [...new Set(interveningCommands)].slice(0, 5),\n\t\t\t\t\t\teditedFiles: [...new Set(editedFiles)].slice(0, 5),\n\t\t\t\t\t\tcount: 1,\n\t\t\t\t\t\tsessions: 1,\n\t\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tconst out: FixCandidate[] = [];\n\tfor (const { candidate, sessions } of acc.values()) {\n\t\tcandidate.sessions = sessions.size;\n\t\tout.push(candidate);\n\t}\n\treturn out.sort((a, b) => b.count - a.count || b.sessions - a.sessions || a.signature.localeCompare(b.signature));\n}\n\n/** A tool call reduced to a comparable step: the tool, plus what a bash call runs. */\nfunction stepSignature(event: ToolEvent): string {\n\tif (event.name === \"bash\") {\n\t\tconst command = typeof event.args?.command === \"string\" ? event.args.command : \"\";\n\t\tconst head = commandHead(command);\n\t\treturn head ? `bash:${head}` : \"bash\";\n\t}\n\treturn event.name;\n}\n\n/**\n * Commands that are how an agent looks around rather than what the user was\n * doing. A sequence built only from these plus file edits describes \"coding\",\n * not a workflow, and no useful skill has ever come out of one.\n */\nconst PLUMBING_COMMANDS = new Set([\n\t\"cd\",\n\t\"ls\",\n\t\"pwd\",\n\t\"cat\",\n\t\"head\",\n\t\"tail\",\n\t\"wc\",\n\t\"echo\",\n\t\"which\",\n\t\"find\",\n\t\"fd\",\n\t\"grep\",\n\t\"rg\",\n\t\"sed\",\n\t\"awk\",\n\t\"git status\",\n\t\"git diff\",\n\t\"git log\",\n\t\"git show\",\n]);\n\n/**\n * Whether a sequence is a procedure rather than the rhythm of editing code.\n *\n * Two distinct doing-commands is the bar, and it was set by looking at real\n * transcripts. One command is not enough: the edit/test loop\n * (`edit → edit → bash:npm run`) satisfies it, and because a sliding window\n * over a long alternating run produces every rotation of that cycle, it alone\n * filled all eight slots with `edit → npm run → edit`, `npm run → edit → edit`\n * and so on — one habit described eight ways.\n *\n * A procedure worth a skill chains *different* actions: test then commit then\n * push, build then tag then publish. Requiring two distinct ones keeps those and\n * drops the rhythm. The cost is real — a genuine one-command routine with setup\n * is missed — and that is the intended trade, since a missed skill costs nothing\n * while a digest full of noise costs the reader's attention every run.\n */\nfunction isProcedure(steps: string[]): boolean {\n\tconst commands = new Set<string>();\n\tfor (const step of steps) {\n\t\tif (!step.startsWith(\"bash:\")) continue;\n\t\tconst head = step.slice(\"bash:\".length);\n\t\tif (PLUMBING_COMMANDS.has(head) || PLUMBING_COMMANDS.has(head.split(\" \")[0] ?? \"\")) continue;\n\t\tcommands.add(head);\n\t}\n\treturn commands.size >= 2;\n}\n\n/** True when `needle` appears as a contiguous run inside `haystack`. */\nfunction containsSequence(haystack: string[], needle: string[]): boolean {\n\tif (needle.length > haystack.length) return false;\n\tfor (let i = 0; i + needle.length <= haystack.length; i++) {\n\t\tif (needle.every((step, offset) => haystack[i + offset] === step)) return true;\n\t}\n\treturn false;\n}\n\nfunction extractWorkflows(\n\tperSession: Array<{ session: ParsedSession; events: ToolEvent[] }>,\n\tminRepeats: number,\n): WorkflowCandidate[] {\n\tinterface Acc {\n\t\tsteps: string[];\n\t\tcount: number;\n\t\tsessions: Set<string>;\n\t\tlastSeen: string;\n\t}\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const { session, events } of perSession) {\n\t\tconst steps = events.slice(0, MAX_TOOL_CALLS_PER_SESSION).map(stepSignature);\n\n\t\tfor (let len = WORKFLOW_MIN_LEN; len <= WORKFLOW_MAX_LEN; len++) {\n\t\t\t// Collect every position first, then count greedily without overlap.\n\t\t\t// Counting each sliding position separately treats one long stretch of\n\t\t\t// edit/read churn as dozens of repeats: an `edit > read > edit` run of\n\t\t\t// length 12 scores 10 occurrences when it is really one stretch of work.\n\t\t\tconst positions = new Map<string, number[]>();\n\t\t\tfor (let i = 0; i + len <= steps.length; i++) {\n\t\t\t\tconst window = steps.slice(i, i + len);\n\t\t\t\t// A run of one repeated tool is a loop, not a workflow.\n\t\t\t\tif (new Set(window).size < 2) continue;\n\t\t\t\tif (!isProcedure(window)) continue;\n\t\t\t\tconst key = window.join(\" > \");\n\t\t\t\tconst list = positions.get(key);\n\t\t\t\tif (list) list.push(i);\n\t\t\t\telse positions.set(key, [i]);\n\t\t\t}\n\n\t\t\tfor (const [key, occurrences] of positions) {\n\t\t\t\tlet count = 0;\n\t\t\t\tlet nextFree = -1;\n\t\t\t\tfor (const start of occurrences) {\n\t\t\t\t\tif (start < nextFree) continue;\n\t\t\t\t\tcount++;\n\t\t\t\t\tnextFree = start + len;\n\t\t\t\t}\n\n\t\t\t\tconst existing = acc.get(key);\n\t\t\t\tif (existing) {\n\t\t\t\t\texisting.count += count;\n\t\t\t\t\texisting.sessions.add(session.id);\n\t\t\t\t\tif (session.timestamp > existing.lastSeen) existing.lastSeen = session.timestamp;\n\t\t\t\t} else {\n\t\t\t\t\tacc.set(key, {\n\t\t\t\t\t\tsteps: key.split(\" > \"),\n\t\t\t\t\t\tcount,\n\t\t\t\t\t\tsessions: new Set([session.id]),\n\t\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tconst ranked = [...acc.values()]\n\t\t.filter((entry) => entry.count >= minRepeats)\n\t\t.map((entry) => ({\n\t\t\tkey: `workflow:${entry.steps.join(\" > \")}`,\n\t\t\tsteps: entry.steps,\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t}))\n\t\t// Sessions first, matching directives: a sequence seen in three sessions is\n\t\t// a workflow, while one repeated ten times in a single session is usually\n\t\t// just the shape of that one task.\n\t\t.sort(\n\t\t\t(a, b) =>\n\t\t\t\tb.sessions - a.sessions ||\n\t\t\t\tb.count - a.count ||\n\t\t\t\tb.steps.length - a.steps.length ||\n\t\t\t\ta.steps.join().localeCompare(b.steps.join()),\n\t\t);\n\n\t// Every n-gram overlaps its own extensions and prefixes, so without this the\n\t// list is one workflow described five slightly different ways. The test runs\n\t// both directions on purpose: a shorter sequence always outranks the longer\n\t// one containing it (it occurs at least as often), so checking only\n\t// shorter-inside-kept would never fire. Keep the best-ranked member of each\n\t// family and drop the rest.\n\tconst distinct: typeof ranked = [];\n\tfor (const candidate of ranked) {\n\t\tconst overlapsKept = distinct.some(\n\t\t\t(kept) => containsSequence(kept.steps, candidate.steps) || containsSequence(candidate.steps, kept.steps),\n\t\t);\n\t\tif (overlapsKept) continue;\n\t\tdistinct.push(candidate);\n\t}\n\treturn distinct;\n}\n\n/**\n * Everything a proposal could already have been written into.\n *\n * Built once and shared, because the same question — is this already written\n * down? — is asked while ranking a run *and* afterwards by `/learn stats`,\n * which reconstructs adoption by comparing coverage now against coverage when\n * the item was shown.\n */\nexport interface CoverageIndex {\n\t/** Candidate rule lines from the repo context file and both user scopes. */\n\truleLines: string[];\n\tskills: Array<{ name: string; description: string }>;\n}\n\nexport interface CoverageMatch {\n\t/** The context-file line that covers this, if any. */\n\trule?: string;\n\t/** The skill that covers this, if any. Only set when no rule matched. */\n\tskill?: string;\n}\n\n/**\n * Where a piece of text is already written down, if anywhere.\n *\n * A rule wins over a skill when both match: it is the more specific answer, and\n * \"rewrite this line\" is more actionable than \"sharpen a description\".\n */\nexport function matchCoverage(text: string, index: CoverageIndex): CoverageMatch {\n\tconst words = contentWords(text);\n\n\tlet bestLine: string | undefined;\n\tlet bestOverlap = 0;\n\tfor (const line of index.ruleLines) {\n\t\tconst overlap = wordOverlap(words, line);\n\t\tif (overlap > bestOverlap) {\n\t\t\tbestOverlap = overlap;\n\t\t\tbestLine = line;\n\t\t}\n\t}\n\tif (bestOverlap >= COVERED_OVERLAP) return { rule: bestLine };\n\n\tlet bestSkill: string | undefined;\n\tlet bestSkillOverlap = 0;\n\tfor (const skill of index.skills) {\n\t\tconst haystack = `${skill.name} ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`;\n\t\tconst overlap = wordOverlap(words, haystack);\n\t\tif (overlap > bestSkillOverlap) {\n\t\t\tbestSkillOverlap = overlap;\n\t\t\tbestSkill = skill.name;\n\t\t}\n\t}\n\tif (bestSkillOverlap >= SKILL_COVERED_OVERLAP) return { skill: bestSkill };\n\n\treturn {};\n}\n\n/** Assemble the coverage index for a directory. */\nexport function buildCoverageIndex(options: {\n\tcwd: string;\n\tagentDir: string;\n\tskills?: Array<{ name: string; description: string }>;\n}): CoverageIndex {\n\tconst corpus = coverageCorpus(options.agentDir, findAgentsFile(options.cwd));\n\treturn {\n\t\truleLines: corpus\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => line.trim())\n\t\t\t.filter((line) => line.length > 0 && !line.startsWith(\"#\")),\n\t\tskills: options.skills ?? loadSkillIndex(options.cwd, options.agentDir),\n\t};\n}\n\n/**\n * Text a proposal is checked against to decide whether it is already written\n * down — the nearest repo context file plus both user scopes.\n *\n * All three matter for suppression, because `/learn` can route a rule to the\n * user scope. Checking only the repo file would report a rule you accepted into\n * `~/.agents/AGENTS.md` as declined.\n */\nfunction coverageCorpus(agentDir: string, repoFile: string | undefined): string {\n\tconst parts: string[] = [];\n\tfor (const candidate of [repoFile, join(getUserAgentsDir(), \"AGENTS.md\"), join(agentDir, \"AGENTS.md\")]) {\n\t\tif (!candidate || !existsSync(candidate)) continue;\n\t\ttry {\n\t\t\tparts.push(readFileSync(candidate, \"utf-8\"));\n\t\t} catch {\n\t\t\t// Unreadable context file: treat as absent rather than failing the run.\n\t\t}\n\t}\n\treturn parts.join(\"\\n\");\n}\n\n/**\n * Skills a proposal could already have become.\n *\n * `/learn` routes long or conditional guidance to a skill rather than a rule, so\n * without this a proposal you adopted *as a skill* would read as declined —\n * looking only at context files sees an unchanged `AGENTS.md` and concludes you\n * passed. Reuses the real loader rather than a second SKILL.md scanner so the\n * set of locations cannot drift from what the session actually loads.\n */\nfunction loadSkillIndex(cwd: string, agentDir: string): Array<{ name: string; description: string }> {\n\ttry {\n\t\treturn loadSkills({ cwd, agentDir, skillPaths: [], includeDefaults: true }).skills.map((skill) => ({\n\t\t\tname: skill.name,\n\t\t\tdescription: skill.description ?? \"\",\n\t\t}));\n\t} catch {\n\t\t// Skills are an enrichment here, not the point of the command.\n\t\treturn [];\n\t}\n}\n\n/** Mine the recent sessions for this cwd and return the ranked digest. */\nexport function extractLearnDigest(options: ExtractOptions): LearnDigest {\n\tconst { sessions, skipped } = listSessions(options);\n\n\tconst agentsFilePath = findAgentsFile(options.cwd);\n\tlet agentsContent: string | undefined;\n\tif (agentsFilePath) {\n\t\ttry {\n\t\t\tagentsContent = readFileSync(agentsFilePath, \"utf-8\");\n\t\t} catch {\n\t\t\tagentsContent = undefined;\n\t\t}\n\t}\n\tconst coverage = buildCoverageIndex({ cwd: options.cwd, agentDir: options.agentDir, skills: options.skills });\n\n\tconst withDirectives = sessions.map((session) => ({ session, directives: userDirectives(session.entries) }));\n\tconst withEvents = sessions.map((session) => ({ session, events: toolEvents(session.entries) }));\n\n\tconst timestamps = sessions.map((s) => s.timestamp).sort();\n\tconst state = options.ignoreState ? undefined : options.state;\n\n\t// Directives carry a real coverage signal — is this written down as a rule or\n\t// a skill right now? — which is what separates an adopted proposal from a\n\t// declined one. Fixes and workflows do not: a fix may have become a rule, a\n\t// skill, or a habit, and which one is not recoverable here, so they get\n\t// suppression only and are never labelled declined.\n\tconst maxProposals = options.maxProposals ?? DEFAULT_MAX_PER_CATEGORY;\n\tconst directives = applySuppression(\n\t\tclusterDirectives(withDirectives, coverage, options.minRepeats ?? DEFAULT_MIN_DIRECTIVE_COUNT),\n\t\tstate,\n\t\tmaxProposals,\n\t\t(item) => item.status !== \"new\",\n\t\t(item) => {\n\t\t\titem.previouslyDeclined = true;\n\t\t},\n\t);\n\tconst fixes = applySuppression(extractFixes(withEvents), state, maxProposals, () => false);\n\tconst workflows = applySuppression(\n\t\textractWorkflows(withEvents, options.minWorkflowRepeats ?? DEFAULT_MIN_WORKFLOW_COUNT),\n\t\tstate,\n\t\tmaxProposals,\n\t\t() => false,\n\t);\n\n\tconst surfaced = [\n\t\t...directives.kept.map((d) => ({ key: d.key, lastSeen: d.lastSeen, covered: d.status !== \"new\" })),\n\t\t...fixes.kept.map((f) => ({ key: f.key, lastSeen: f.lastSeen, covered: false })),\n\t\t...workflows.kept.map((w) => ({ key: w.key, lastSeen: w.lastSeen, covered: false })),\n\t];\n\n\treturn {\n\t\tscannedSessions: sessions.length,\n\t\tskippedSessions: skipped,\n\t\toldestSession: timestamps[0],\n\t\tnewestSession: timestamps[timestamps.length - 1],\n\t\tagentsFilePath,\n\t\tagentsFileTokens:\n\t\t\tagentsContent === undefined ? undefined : Math.round(Buffer.byteLength(agentsContent, \"utf-8\") / 4),\n\t\tdirectives: directives.kept,\n\t\tfixes: fixes.kept,\n\t\tworkflows: workflows.kept,\n\t\tsuppressed: directives.suppressed + fixes.suppressed + workflows.suppressed,\n\t\tsurfaced,\n\t};\n}\n"]}
1
+ {"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../../src/core/learn/extract.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Session mining for `/learn`.\n *\n * Reads session `.jsonl` files straight off disk rather than the live context.\n * That is the whole point: the on-disk transcript is complete even when the\n * in-context one has been compacted away, and it spans every past session\n * instead of only this one. Cross-session repetition is the signal that decides\n * whether something is a durable rule or a one-off, and it is the one thing a\n * prompt reading its own context cannot see.\n *\n * The split of labour is deliberate. This module is entirely deterministic: it\n * parses, filters, normalizes, counts and ranks. Judgement — is this a rule, how\n * should it be phrased, which scope owns it — belongs to the model reading the\n * digest, which is why the output carries evidence (counts, sessions, dates)\n * rather than conclusions.\n */\n\nimport { existsSync, readdirSync, readFileSync, realpathSync, statSync } from \"node:fs\";\nimport { dirname, join, resolve, sep } from \"node:path\";\nimport type { AgentMessage } from \"@kolisachint/hoocode-agent-core\";\nimport type { TextContent, ToolCall } from \"@kolisachint/hoocode-ai\";\nimport { getUserAgentsDir } from \"../../config.js\";\nimport { getSessionDirPath } from \"../session-manager.js\";\nimport { loadSkills } from \"../skills.js\";\nimport {\n\tcommandHead,\n\tcontentWords,\n\textractErrorRegion,\n\tisBenignFailure,\n\tisRuleShapedDirective,\n\tisUninformativeFailure,\n\tnormalizeCommand,\n\tnormalizeDirective,\n\tnormalizeErrorSignature,\n\twordOverlap,\n} from \"./normalize.js\";\nimport { judge, type LearnState } from \"./state.js\";\n\n/**\n * Prefix on the message `/learn` injects. The digest is persisted like any user\n * turn, so without this marker the next `/learn` would mine its own output and\n * every proposal would compound its own count.\n */\nexport const LEARN_DIGEST_MARKER = \"[learn-digest]\";\n\n/** Sessions considered, newest first. */\nconst DEFAULT_MAX_SESSIONS = 20;\n/** Sessions older than this are ignored — a pattern that stopped is not a rule. */\nconst DEFAULT_MAX_AGE_DAYS = 30;\n/** Entries parsed per session file, as a guard against pathological transcripts. */\nconst MAX_ENTRIES_PER_SESSION = 8000;\n/** Tool calls per session fed to the workflow detector. */\nconst MAX_TOOL_CALLS_PER_SESSION = 400;\n/** How far forward the fix extractor looks for the same command succeeding. */\nconst FIX_LOOKAHEAD = 40;\n/** Word overlap against an existing rule above which a directive counts as covered. */\nconst COVERED_OVERLAP = 0.6;\n/**\n * The same bar for skills, set higher on purpose.\n *\n * A rule is one line, so overlap against it is a sharp signal. A skill is a name\n * plus a description written to attract matches, which is a far larger haystack\n * — a short directive's words turn up in it by chance much more readily. The\n * higher bar and the truncation below keep \"you already have a skill for this\"\n * from being said on a coincidence.\n */\nconst SKILL_COVERED_OVERLAP = 0.75;\n/** Description characters considered. The opening says what a skill does; the rest is trigger bait. */\nconst SKILL_DESCRIPTION_CHARS = 300;\n/** Directives must reach this many occurrences to be reported at all. */\nconst DEFAULT_MIN_DIRECTIVE_COUNT = 2;\n/** Tool sequence lengths considered as workflow candidates. */\nconst WORKFLOW_MIN_LEN = 3;\nconst WORKFLOW_MAX_LEN = 5;\n/** Repeats before a tool sequence is worth proposing as a skill. */\nconst DEFAULT_MIN_WORKFLOW_COUNT = 3;\n/** Cap on each list in the digest, so the model's budget goes to the top signals. */\nconst DEFAULT_MAX_PER_CATEGORY = 8;\n\n/**\n * Where a repeated directive already lives, if anywhere.\n *\n * A directive covered by a rule and said only once is simply dropped — the rule\n * exists and is working. What survives is one of three cases, and they want\n * different responses:\n *\n * - `new` — not written down anywhere. Propose it.\n * - `restated` — a context-file rule covers it and you said it anyway, so the\n * rule is not working. Rewrite it; do not add a second one.\n * - `has-skill` — a *skill* covers it and you asked by hand anyway, which\n * usually means the skill's `description` is not triggering. Sharpen the\n * description rather than writing a rule that duplicates the skill.\n */\nexport type DirectiveStatus = \"new\" | \"restated\" | \"has-skill\";\n\n/** Fields every proposable item shares, so suppression can be applied uniformly. */\ninterface Proposable {\n\t/** Stable identity across runs — what the state file remembers. */\n\tkey: string;\n\t/** Newest occurrence in the window, ISO. */\n\tlastSeen: string;\n}\n\nexport interface DirectiveCluster extends Proposable {\n\t/** Representative raw text, the longest seen in the cluster. */\n\ttext: string;\n\tnormalized: string;\n\t/** Total times said. */\n\tcount: number;\n\t/** Distinct sessions it was said in — the stronger of the two counts. */\n\tsessions: number;\n\tstatus: DirectiveStatus;\n\t/** The existing rule line matched, when status is `restated`. */\n\texistingRule?: string;\n\t/** The skill that already covers this, when status is `has-skill`. */\n\texistingSkill?: string;\n\t/**\n\t * Shown before and still not written down anywhere — neither as a rule nor as\n\t * a skill — so you saw this proposal and passed on it. Only meaningful for\n\t * directives, which are the only items with a real coverage signal.\n\t */\n\tpreviouslyDeclined: boolean;\n}\n\nexport interface FixCandidate extends Proposable {\n\t/** Normalized failing command. */\n\tcommand: string;\n\t/** Normalized error signature, the dedupe key. */\n\tsignature: string;\n\t/** Short raw excerpt, so the model sees the real error text. */\n\terrorExcerpt: string;\n\t/** Commands run between the failure and the pass. */\n\tinterveningCommands: string[];\n\t/** Files edited between the failure and the pass. */\n\teditedFiles: string[];\n\t/** Times this signature failed and was resolved across the window. */\n\tcount: number;\n\tsessions: number;\n}\n\nexport interface WorkflowCandidate extends Proposable {\n\t/** Tool-call signatures in order. */\n\tsteps: string[];\n\tcount: number;\n\tsessions: number;\n}\n\n/**\n * Why a session file on disk did not make it into the digest.\n *\n * \"No recent sessions\" is the one outcome a user cannot act on without this:\n * an empty session directory, a directory full of month-old sessions, and a\n * directory full of sessions belonging to another checkout all produce the same\n * sentence, and the fix differs in each case.\n */\nexport interface SessionScanReport {\n\t/** Directories actually searched, in order. */\n\tdirs: string[];\n\t/** Directories that do not exist on disk. */\n\tmissingDirs: string[];\n\t/** `.jsonl` files found across all searched directories. */\n\tfiles: number;\n\t/** Skipped for being older than the age window. */\n\ttooOld: number;\n\t/** Skipped because the session header records a different working directory. */\n\totherCwd: number;\n\t/** Skipped for being beyond `maxSessions`. */\n\toverLimit: number;\n\t/** Skipped for being unreadable, unparseable, or empty. */\n\tunreadable: number;\n}\n\nexport interface LearnDigest {\n\tscannedSessions: number;\n\tskippedSessions: number;\n\t/** Where the sessions came from, and what was passed over. */\n\tscan: SessionScanReport;\n\toldestSession?: string;\n\tnewestSession?: string;\n\tagentsFilePath?: string;\n\tagentsFileTokens?: number;\n\tdirectives: DirectiveCluster[];\n\tfixes: FixCandidate[];\n\tworkflows: WorkflowCandidate[];\n\t/** Items held back because nothing new has happened since they were last shown. */\n\tsuppressed: number;\n\t/** Everything this run put on screen, for the caller to persist. */\n\tsurfaced: Array<{ key: string; lastSeen: string; covered: boolean }>;\n}\n\nexport interface ExtractOptions {\n\tcwd: string;\n\tagentDir: string;\n\t/**\n\t * An extra directory to scan, normally the live session manager's. The\n\t * per-cwd default directory is always scanned as well, so a session manager\n\t * pointing somewhere unusual cannot hide this directory's history.\n\t */\n\tsessionDir?: string;\n\tmaxSessions?: number;\n\tmaxAgeDays?: number;\n\t/** Occurrences a directive needs before it is proposed. The signal/noise dial. */\n\tminRepeats?: number;\n\t/** Non-overlapping repeats a tool sequence needs before it is proposed as a skill. */\n\tminWorkflowRepeats?: number;\n\t/** Cap on each list in the digest. */\n\tmaxProposals?: number;\n\t/**\n\t * What previous runs already showed. Items with no new occurrences since are\n\t * held back. Omit (or pass `ignoreState`) to propose everything in the window.\n\t */\n\tstate?: LearnState;\n\t/** Re-propose everything, ignoring what previous runs surfaced (`/learn all`). */\n\tignoreState?: boolean;\n\t/**\n\t * Skills a directive can already be covered by. Defaults to the ones loaded\n\t * from disk; injectable so tests do not read the developer's real skills.\n\t */\n\tskills?: Array<{ name: string; description: string }>;\n\t/** Injectable clock, for tests. */\n\tnow?: Date;\n}\n\ninterface SessionHeaderLike {\n\ttype: \"session\";\n\tid?: string;\n\ttimestamp?: string;\n\tcwd?: string;\n}\n\ninterface EntryLike {\n\ttype: string;\n\tid?: string;\n\tparentId?: string | null;\n\ttimestamp?: string;\n\tmessage?: AgentMessage;\n}\n\n/** One session, reduced to the branch that was actually taken. */\ninterface ParsedSession {\n\tfile: string;\n\tid: string;\n\ttimestamp: string;\n\tentries: EntryLike[];\n}\n\nfunction textOf(content: unknown): string {\n\tif (typeof content === \"string\") return content;\n\tif (!Array.isArray(content)) return \"\";\n\treturn content\n\t\t.map((block) =>\n\t\t\tblock && typeof block === \"object\" && (block as TextContent).type === \"text\"\n\t\t\t\t? ((block as TextContent).text ?? \"\")\n\t\t\t\t: \"\",\n\t\t)\n\t\t.join(\"\\n\")\n\t\t.trim();\n}\n\nfunction isToolCall(block: unknown): block is ToolCall {\n\treturn !!block && typeof block === \"object\" && (block as ToolCall).type === \"toolCall\";\n}\n\n/**\n * Reduce a session's raw entries to the branch that was actually taken.\n *\n * Session files are trees — forks and clones append entries that were never\n * part of the same conversation. Walking parent links back from the last entry\n * keeps the extractor from stitching a \"fix\" out of two turns that never\n * happened in sequence. Sessions written before entry ids existed are flat, and\n * for those file order *is* the branch.\n */\nfunction activeBranch(entries: EntryLike[]): EntryLike[] {\n\tconst withIds = entries.filter((e) => typeof e.id === \"string\");\n\tif (withIds.length === 0) return entries;\n\n\tconst byId = new Map<string, EntryLike>();\n\tfor (const entry of withIds) byId.set(entry.id as string, entry);\n\n\tconst branch: EntryLike[] = [];\n\tconst seen = new Set<string>();\n\tlet cursor: EntryLike | undefined = withIds[withIds.length - 1];\n\twhile (cursor?.id && !seen.has(cursor.id)) {\n\t\tseen.add(cursor.id);\n\t\tbranch.push(cursor);\n\t\tcursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;\n\t}\n\treturn branch.reverse();\n}\n\n/**\n * Compare two directory paths the way the filesystem does.\n *\n * A session header stores the cwd as it was typed, and the same directory can\n * be spelled several ways: through a symlink (`/tmp` is `/private/tmp` on\n * macOS), with a trailing separator, or in different case on the\n * case-insensitive filesystems that macOS and Windows ship by default. String\n * equality on `resolve()` alone rejects every one of those, and rejecting them\n * here means silently discarding the whole history the command exists to read.\n */\nfunction normalizeDirPath(path: string): string {\n\tlet resolved = resolve(path);\n\ttry {\n\t\tresolved = realpathSync.native(resolved);\n\t} catch {\n\t\t// Deleted or never-created directory: the textual form is all we have.\n\t}\n\t// `resolve` already drops a trailing separator except at a filesystem root,\n\t// where dropping it would turn \"/\" into \"\".\n\tif (resolved.length > 1 && resolved.endsWith(sep)) resolved = resolved.slice(0, -1);\n\treturn process.platform === \"win32\" || process.platform === \"darwin\" ? resolved.toLowerCase() : resolved;\n}\n\nfunction sameDirectory(a: string, b: string): boolean {\n\treturn normalizeDirPath(a) === normalizeDirPath(b);\n}\n\n/** Reason a candidate file produced no session, for the scan report. */\ntype SkipReason = \"otherCwd\" | \"unreadable\";\n\nfunction parseSessionFile(file: string, cwd: string, onSkip: (reason: SkipReason) => void): ParsedSession | undefined {\n\tlet raw: string;\n\ttry {\n\t\traw = readFileSync(file, \"utf-8\");\n\t} catch {\n\t\tonSkip(\"unreadable\");\n\t\treturn undefined;\n\t}\n\n\tconst lines = raw.split(\"\\n\");\n\tlet header: SessionHeaderLike | undefined;\n\tconst entries: EntryLike[] = [];\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\tif (entries.length >= MAX_ENTRIES_PER_SESSION) break;\n\t\tlet parsed: EntryLike | SessionHeaderLike;\n\t\ttry {\n\t\t\tparsed = JSON.parse(line);\n\t\t} catch {\n\t\t\t// A partially-flushed final line is normal for a live session.\n\t\t\tcontinue;\n\t\t}\n\t\tif (parsed.type === \"session\") {\n\t\t\theader ??= parsed as SessionHeaderLike;\n\t\t\tcontinue;\n\t\t}\n\t\tentries.push(parsed as EntryLike);\n\t}\n\n\t// An explicit `--session` path can put a session for another directory in\n\t// this directory, so trust the header over the file's location.\n\tif (header?.cwd && !sameDirectory(header.cwd, cwd)) {\n\t\tonSkip(\"otherCwd\");\n\t\treturn undefined;\n\t}\n\tif (entries.length === 0) {\n\t\tonSkip(\"unreadable\");\n\t\treturn undefined;\n\t}\n\n\treturn {\n\t\tfile,\n\t\tid: header?.id ?? file,\n\t\ttimestamp: header?.timestamp ?? statSync(file).mtime.toISOString(),\n\t\tentries: activeBranch(entries),\n\t};\n}\n\n/**\n * Every directory this cwd's sessions could be sitting in.\n *\n * The caller passes the live session manager's directory, which is the right\n * answer almost always — but not quite always, and each exception silently\n * emptied the digest. An in-memory session (`--no-session`) reports `\"\"`; an\n * explicit `--session <path>` reports wherever that file lives; a custom\n * `sessionDir` setting points at one shared directory. In every one of those\n * cases the per-cwd default directory still holds the history worth mining, so\n * search both and let the header check sort out what belongs to this cwd.\n */\nexport function candidateSessionDirs(options: Pick<ExtractOptions, \"cwd\" | \"agentDir\" | \"sessionDir\">): string[] {\n\tconst dirs: string[] = [];\n\tconst seen = new Set<string>();\n\tfor (const dir of [options.sessionDir, getSessionDirPath(options.cwd, options.agentDir)]) {\n\t\tif (!dir) continue;\n\t\tconst key = normalizeDirPath(dir);\n\t\tif (seen.has(key)) continue;\n\t\tseen.add(key);\n\t\tdirs.push(dir);\n\t}\n\treturn dirs;\n}\n\nfunction listSessions(options: ExtractOptions): {\n\tsessions: ParsedSession[];\n\tskipped: number;\n\tscan: SessionScanReport;\n} {\n\tconst dirs = candidateSessionDirs(options);\n\tconst scan: SessionScanReport = {\n\t\tdirs,\n\t\tmissingDirs: [],\n\t\tfiles: 0,\n\t\ttooOld: 0,\n\t\totherCwd: 0,\n\t\toverLimit: 0,\n\t\tunreadable: 0,\n\t};\n\n\tconst maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;\n\tconst maxAgeDays = options.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;\n\tconst now = options.now ?? new Date();\n\tconst cutoff = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000;\n\n\tconst files: string[] = [];\n\tfor (const dir of dirs) {\n\t\tif (!existsSync(dir)) {\n\t\t\tscan.missingDirs.push(dir);\n\t\t\tcontinue;\n\t\t}\n\t\ttry {\n\t\t\tfor (const name of readdirSync(dir)) {\n\t\t\t\tif (name.endsWith(\".jsonl\")) files.push(join(dir, name));\n\t\t\t}\n\t\t} catch {\n\t\t\tscan.missingDirs.push(dir);\n\t\t}\n\t}\n\tscan.files = files.length;\n\n\t// Newest first across all directories, so `maxSessions` keeps the most recent\n\t// history rather than whichever directory happened to be searched first.\n\tconst dated = files\n\t\t.map((file) => {\n\t\t\ttry {\n\t\t\t\treturn { file, mtime: statSync(file).mtime.getTime() };\n\t\t\t} catch {\n\t\t\t\tscan.unreadable++;\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t})\n\t\t.filter((f): f is { file: string; mtime: number } => !!f)\n\t\t.sort((a, b) => b.mtime - a.mtime);\n\n\tconst sessions: ParsedSession[] = [];\n\tconst seenIds = new Set<string>();\n\tlet skipped = 0;\n\tfor (const { file, mtime } of dated) {\n\t\tif (sessions.length >= maxSessions) {\n\t\t\tscan.overLimit++;\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (mtime < cutoff) {\n\t\t\tscan.tooOld++;\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tconst parsed = parseSessionFile(file, options.cwd, (reason) => {\n\t\t\tscan[reason]++;\n\t\t});\n\t\tif (!parsed) {\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\t// Searching two directories can turn up the same session twice (an explicit\n\t\t// `--session` path inside the default directory). Counting it twice would\n\t\t// inflate the cross-session repetition that decides what gets proposed.\n\t\tif (seenIds.has(parsed.id)) {\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tseenIds.add(parsed.id);\n\t\tsessions.push(parsed);\n\t}\n\treturn { sessions, skipped, scan };\n}\n\n/**\n * Hold back items already shown that have not recurred since, then cap the rest.\n *\n * Order matters: suppression runs *before* the cap, or an item you already\n * decided on would occupy one of the few slots the digest has and push a live\n * signal off the list.\n */\nfunction applySuppression<T extends Proposable>(\n\titems: T[],\n\tstate: LearnState | undefined,\n\tmaxProposals: number,\n\tcovered: (item: T) => boolean,\n\tonDeclined?: (item: T) => void,\n): { kept: T[]; suppressed: number } {\n\tif (!state) return { kept: items.slice(0, maxProposals), suppressed: 0 };\n\n\tconst kept: T[] = [];\n\tlet suppressed = 0;\n\tfor (const item of items) {\n\t\tconst verdict = judge(state, { key: item.key, lastSeen: item.lastSeen, covered: covered(item) });\n\t\tif (verdict.suppressed) {\n\t\t\tsuppressed++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (verdict.previouslyDeclined) onDeclined?.(item);\n\t\tkept.push(item);\n\t}\n\treturn { kept: kept.slice(0, maxProposals), suppressed };\n}\n\n/**\n * Where this cwd's sessions were found and what was passed over, without\n * ranking anything. `/learn stats` reports on the window without re-mining it.\n */\nexport function scanSessions(options: ExtractOptions): SessionScanReport {\n\treturn listSessions(options).scan;\n}\n\n/** Nearest AGENTS.md walking up from cwd, so proposals can be checked against it. */\nfunction findAgentsFile(cwd: string): string | undefined {\n\tlet dir = resolve(cwd);\n\twhile (true) {\n\t\tfor (const name of [\"AGENTS.md\", \"AGENTS.MD\", \"CLAUDE.md\", \"CLAUDE.MD\"]) {\n\t\t\tconst candidate = join(dir, name);\n\t\t\tif (existsSync(candidate)) return candidate;\n\t\t}\n\t\tconst parent = dirname(dir);\n\t\tif (parent === dir) return undefined;\n\t\tdir = parent;\n\t}\n}\n\ninterface ToolEvent {\n\tname: string;\n\targs: Record<string, any>;\n\t/** Set once the matching result is seen. */\n\tisError?: boolean;\n\toutput?: string;\n}\n\n/** Pair tool calls with their results along one branch, in call order. */\nfunction toolEvents(entries: EntryLike[]): ToolEvent[] {\n\tconst byCallId = new Map<string, ToolEvent>();\n\tconst ordered: ToolEvent[] = [];\n\n\tfor (const entry of entries) {\n\t\tconst message = entry.type === \"message\" ? entry.message : undefined;\n\t\tif (!message) continue;\n\t\tif (message.role === \"assistant\") {\n\t\t\tfor (const block of (message.content ?? []) as unknown[]) {\n\t\t\t\tif (!isToolCall(block)) continue;\n\t\t\t\tconst event: ToolEvent = { name: block.name, args: block.arguments ?? {} };\n\t\t\t\tbyCallId.set(block.id, event);\n\t\t\t\tordered.push(event);\n\t\t\t}\n\t\t} else if (message.role === \"toolResult\") {\n\t\t\tconst event = byCallId.get(message.toolCallId);\n\t\t\tif (!event) continue;\n\t\t\tevent.isError = message.isError;\n\t\t\tevent.output = textOf(message.content);\n\t\t}\n\t}\n\treturn ordered;\n}\n\n/** User turns worth mining, in order, with the digest's own output excluded. */\nfunction userDirectives(entries: EntryLike[]): string[] {\n\tconst out: string[] = [];\n\tfor (const entry of entries) {\n\t\tconst message = entry.type === \"message\" ? entry.message : undefined;\n\t\tif (!message || message.role !== \"user\") continue;\n\t\tconst text = textOf(message.content);\n\t\tif (!text || text.startsWith(LEARN_DIGEST_MARKER)) continue;\n\t\tif (!isRuleShapedDirective(text)) continue;\n\t\tout.push(text.trim());\n\t}\n\treturn out;\n}\n\nfunction clusterDirectives(\n\tperSession: Array<{ session: ParsedSession; directives: string[] }>,\n\tcoverage: CoverageIndex,\n\tminRepeats: number,\n): DirectiveCluster[] {\n\tinterface Acc {\n\t\ttext: string;\n\t\tnormalized: string;\n\t\tcount: number;\n\t\tsessions: Set<string>;\n\t\tlastSeen: string;\n\t}\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const { session, directives } of perSession) {\n\t\tfor (const text of directives) {\n\t\t\tconst normalized = normalizeDirective(text);\n\t\t\tif (!normalized) continue;\n\t\t\tconst existing = acc.get(normalized);\n\t\t\tif (existing) {\n\t\t\t\texisting.count++;\n\t\t\t\texisting.sessions.add(session.id);\n\t\t\t\tif (session.timestamp > existing.lastSeen) existing.lastSeen = session.timestamp;\n\t\t\t\tif (text.length > existing.text.length) existing.text = text;\n\t\t\t} else {\n\t\t\t\tacc.set(normalized, {\n\t\t\t\t\ttext,\n\t\t\t\t\tnormalized,\n\t\t\t\t\tcount: 1,\n\t\t\t\t\tsessions: new Set([session.id]),\n\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tconst clusters: DirectiveCluster[] = [];\n\tfor (const entry of acc.values()) {\n\t\tif (entry.count < minRepeats) continue;\n\n\t\t// Everything reaching here cleared the repeat threshold. Suppression handles\n\t\t// the case that used to make these labels lie — a proposal accepted from a\n\t\t// previous run coming back as \"not working\" when nothing had happened\n\t\t// since. By the time an item survives that filter, a match genuinely means\n\t\t// you repeated yourself after the rule or skill already existed.\n\t\tconst match = matchCoverage(entry.text, coverage);\n\t\tclusters.push({\n\t\t\tkey: `directive:${entry.normalized}`,\n\t\t\ttext: entry.text,\n\t\t\tnormalized: entry.normalized,\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t\tstatus: match.rule ? \"restated\" : match.skill ? \"has-skill\" : \"new\",\n\t\t\texistingRule: match.rule,\n\t\t\texistingSkill: match.skill,\n\t\t\tpreviouslyDeclined: false,\n\t\t});\n\t}\n\n\treturn clusters.sort((a, b) => b.sessions - a.sessions || b.count - a.count || a.text.localeCompare(b.text));\n}\n\n/** Files a mutating tool touched, for the resolution summary. */\nfunction editedFile(event: ToolEvent): string | undefined {\n\tif (![\"edit\", \"write\", \"multi_edit\", \"apply_patch\"].includes(event.name)) return undefined;\n\tconst path = event.args?.path ?? event.args?.file_path ?? event.args?.filePath;\n\treturn typeof path === \"string\" ? path : undefined;\n}\n\nfunction extractFixes(perSession: Array<{ session: ParsedSession; events: ToolEvent[] }>): FixCandidate[] {\n\tinterface Acc {\n\t\tcandidate: FixCandidate;\n\t\tsessions: Set<string>;\n\t}\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const { session, events } of perSession) {\n\t\tfor (let i = 0; i < events.length; i++) {\n\t\t\tconst failure = events[i]!;\n\t\t\tif (failure.name !== \"bash\" || !failure.isError) continue;\n\t\t\tconst command = typeof failure.args?.command === \"string\" ? failure.args.command : \"\";\n\t\t\tif (!command || isBenignFailure(command)) continue;\n\n\t\t\tconst normalized = normalizeCommand(command);\n\t\t\tconst interveningCommands: string[] = [];\n\t\t\tconst editedFiles: string[] = [];\n\t\t\tlet resolved = false;\n\n\t\t\tfor (let j = i + 1; j < Math.min(events.length, i + 1 + FIX_LOOKAHEAD); j++) {\n\t\t\t\tconst next = events[j]!;\n\t\t\t\tconst file = editedFile(next);\n\t\t\t\tif (file) editedFiles.push(file);\n\n\t\t\t\tif (next.name !== \"bash\") continue;\n\t\t\t\tconst nextCommand = typeof next.args?.command === \"string\" ? next.args.command : \"\";\n\t\t\t\tif (!nextCommand) continue;\n\n\t\t\t\t// The same command later succeeding is the only evidence that the\n\t\t\t\t// problem was actually fixed. A *different* command passing says\n\t\t\t\t// nothing, and neither does the model moving on.\n\t\t\t\tif (normalizeCommand(nextCommand) === normalized && !next.isError) {\n\t\t\t\t\tresolved = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tinterveningCommands.push(nextCommand.trim());\n\t\t\t}\n\n\t\t\tif (!resolved) continue;\n\n\t\t\tconst output = failure.output ?? \"\";\n\t\t\t// An abort is the user changing their mind, not a problem that was\n\t\t\t// solved, and empty output carries nothing to sign or show.\n\t\t\tif (isUninformativeFailure(output)) continue;\n\n\t\t\t// Sign the error region, not the whole output: build tools lead with an\n\t\t\t// identical banner, so signing everything makes unrelated failures of\n\t\t\t// the same command collide on their shared preamble.\n\t\t\tconst errorRegion = extractErrorRegion(output);\n\t\t\tconst signature = normalizeErrorSignature(errorRegion);\n\t\t\tif (!signature) continue;\n\n\t\t\tconst key = `${normalized}\u0000${signature}`;\n\t\t\tconst existing = acc.get(key);\n\t\t\tif (existing) {\n\t\t\t\texisting.candidate.count++;\n\t\t\t\texisting.sessions.add(session.id);\n\t\t\t\tif (session.timestamp > existing.candidate.lastSeen) existing.candidate.lastSeen = session.timestamp;\n\t\t\t} else {\n\t\t\t\tacc.set(key, {\n\t\t\t\t\tsessions: new Set([session.id]),\n\t\t\t\t\tcandidate: {\n\t\t\t\t\t\tkey: `fix:${key}`,\n\t\t\t\t\t\tcommand: normalized,\n\t\t\t\t\t\tsignature,\n\t\t\t\t\t\terrorExcerpt: errorRegion.replace(/\\s+/g, \" \").trim().slice(0, 240),\n\t\t\t\t\t\tinterveningCommands: [...new Set(interveningCommands)].slice(0, 5),\n\t\t\t\t\t\teditedFiles: [...new Set(editedFiles)].slice(0, 5),\n\t\t\t\t\t\tcount: 1,\n\t\t\t\t\t\tsessions: 1,\n\t\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tconst out: FixCandidate[] = [];\n\tfor (const { candidate, sessions } of acc.values()) {\n\t\tcandidate.sessions = sessions.size;\n\t\tout.push(candidate);\n\t}\n\treturn out.sort((a, b) => b.count - a.count || b.sessions - a.sessions || a.signature.localeCompare(b.signature));\n}\n\n/** A tool call reduced to a comparable step: the tool, plus what a bash call runs. */\nfunction stepSignature(event: ToolEvent): string {\n\tif (event.name === \"bash\") {\n\t\tconst command = typeof event.args?.command === \"string\" ? event.args.command : \"\";\n\t\tconst head = commandHead(command);\n\t\treturn head ? `bash:${head}` : \"bash\";\n\t}\n\treturn event.name;\n}\n\n/**\n * Commands that are how an agent looks around rather than what the user was\n * doing. A sequence built only from these plus file edits describes \"coding\",\n * not a workflow, and no useful skill has ever come out of one.\n */\nconst PLUMBING_COMMANDS = new Set([\n\t\"cd\",\n\t\"ls\",\n\t\"pwd\",\n\t\"cat\",\n\t\"head\",\n\t\"tail\",\n\t\"wc\",\n\t\"echo\",\n\t\"which\",\n\t\"find\",\n\t\"fd\",\n\t\"grep\",\n\t\"rg\",\n\t\"sed\",\n\t\"awk\",\n\t\"git status\",\n\t\"git diff\",\n\t\"git log\",\n\t\"git show\",\n]);\n\n/**\n * Whether a sequence is a procedure rather than the rhythm of editing code.\n *\n * Two distinct doing-commands is the bar, and it was set by looking at real\n * transcripts. One command is not enough: the edit/test loop\n * (`edit → edit → bash:npm run`) satisfies it, and because a sliding window\n * over a long alternating run produces every rotation of that cycle, it alone\n * filled all eight slots with `edit → npm run → edit`, `npm run → edit → edit`\n * and so on — one habit described eight ways.\n *\n * A procedure worth a skill chains *different* actions: test then commit then\n * push, build then tag then publish. Requiring two distinct ones keeps those and\n * drops the rhythm. The cost is real — a genuine one-command routine with setup\n * is missed — and that is the intended trade, since a missed skill costs nothing\n * while a digest full of noise costs the reader's attention every run.\n */\nfunction isProcedure(steps: string[]): boolean {\n\tconst commands = new Set<string>();\n\tfor (const step of steps) {\n\t\tif (!step.startsWith(\"bash:\")) continue;\n\t\tconst head = step.slice(\"bash:\".length);\n\t\tif (PLUMBING_COMMANDS.has(head) || PLUMBING_COMMANDS.has(head.split(\" \")[0] ?? \"\")) continue;\n\t\tcommands.add(head);\n\t}\n\treturn commands.size >= 2;\n}\n\n/** True when `needle` appears as a contiguous run inside `haystack`. */\nfunction containsSequence(haystack: string[], needle: string[]): boolean {\n\tif (needle.length > haystack.length) return false;\n\tfor (let i = 0; i + needle.length <= haystack.length; i++) {\n\t\tif (needle.every((step, offset) => haystack[i + offset] === step)) return true;\n\t}\n\treturn false;\n}\n\nfunction extractWorkflows(\n\tperSession: Array<{ session: ParsedSession; events: ToolEvent[] }>,\n\tminRepeats: number,\n): WorkflowCandidate[] {\n\tinterface Acc {\n\t\tsteps: string[];\n\t\tcount: number;\n\t\tsessions: Set<string>;\n\t\tlastSeen: string;\n\t}\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const { session, events } of perSession) {\n\t\tconst steps = events.slice(0, MAX_TOOL_CALLS_PER_SESSION).map(stepSignature);\n\n\t\tfor (let len = WORKFLOW_MIN_LEN; len <= WORKFLOW_MAX_LEN; len++) {\n\t\t\t// Collect every position first, then count greedily without overlap.\n\t\t\t// Counting each sliding position separately treats one long stretch of\n\t\t\t// edit/read churn as dozens of repeats: an `edit > read > edit` run of\n\t\t\t// length 12 scores 10 occurrences when it is really one stretch of work.\n\t\t\tconst positions = new Map<string, number[]>();\n\t\t\tfor (let i = 0; i + len <= steps.length; i++) {\n\t\t\t\tconst window = steps.slice(i, i + len);\n\t\t\t\t// A run of one repeated tool is a loop, not a workflow.\n\t\t\t\tif (new Set(window).size < 2) continue;\n\t\t\t\tif (!isProcedure(window)) continue;\n\t\t\t\tconst key = window.join(\" > \");\n\t\t\t\tconst list = positions.get(key);\n\t\t\t\tif (list) list.push(i);\n\t\t\t\telse positions.set(key, [i]);\n\t\t\t}\n\n\t\t\tfor (const [key, occurrences] of positions) {\n\t\t\t\tlet count = 0;\n\t\t\t\tlet nextFree = -1;\n\t\t\t\tfor (const start of occurrences) {\n\t\t\t\t\tif (start < nextFree) continue;\n\t\t\t\t\tcount++;\n\t\t\t\t\tnextFree = start + len;\n\t\t\t\t}\n\n\t\t\t\tconst existing = acc.get(key);\n\t\t\t\tif (existing) {\n\t\t\t\t\texisting.count += count;\n\t\t\t\t\texisting.sessions.add(session.id);\n\t\t\t\t\tif (session.timestamp > existing.lastSeen) existing.lastSeen = session.timestamp;\n\t\t\t\t} else {\n\t\t\t\t\tacc.set(key, {\n\t\t\t\t\t\tsteps: key.split(\" > \"),\n\t\t\t\t\t\tcount,\n\t\t\t\t\t\tsessions: new Set([session.id]),\n\t\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tconst ranked = [...acc.values()]\n\t\t.filter((entry) => entry.count >= minRepeats)\n\t\t.map((entry) => ({\n\t\t\tkey: `workflow:${entry.steps.join(\" > \")}`,\n\t\t\tsteps: entry.steps,\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t}))\n\t\t// Sessions first, matching directives: a sequence seen in three sessions is\n\t\t// a workflow, while one repeated ten times in a single session is usually\n\t\t// just the shape of that one task.\n\t\t.sort(\n\t\t\t(a, b) =>\n\t\t\t\tb.sessions - a.sessions ||\n\t\t\t\tb.count - a.count ||\n\t\t\t\tb.steps.length - a.steps.length ||\n\t\t\t\ta.steps.join().localeCompare(b.steps.join()),\n\t\t);\n\n\t// Every n-gram overlaps its own extensions and prefixes, so without this the\n\t// list is one workflow described five slightly different ways. The test runs\n\t// both directions on purpose: a shorter sequence always outranks the longer\n\t// one containing it (it occurs at least as often), so checking only\n\t// shorter-inside-kept would never fire. Keep the best-ranked member of each\n\t// family and drop the rest.\n\tconst distinct: typeof ranked = [];\n\tfor (const candidate of ranked) {\n\t\tconst overlapsKept = distinct.some(\n\t\t\t(kept) => containsSequence(kept.steps, candidate.steps) || containsSequence(candidate.steps, kept.steps),\n\t\t);\n\t\tif (overlapsKept) continue;\n\t\tdistinct.push(candidate);\n\t}\n\treturn distinct;\n}\n\n/**\n * Everything a proposal could already have been written into.\n *\n * Built once and shared, because the same question — is this already written\n * down? — is asked while ranking a run *and* afterwards by `/learn stats`,\n * which reconstructs adoption by comparing coverage now against coverage when\n * the item was shown.\n */\nexport interface CoverageIndex {\n\t/** Candidate rule lines from the repo context file and both user scopes. */\n\truleLines: string[];\n\tskills: Array<{ name: string; description: string }>;\n}\n\nexport interface CoverageMatch {\n\t/** The context-file line that covers this, if any. */\n\trule?: string;\n\t/** The skill that covers this, if any. Only set when no rule matched. */\n\tskill?: string;\n}\n\n/**\n * Where a piece of text is already written down, if anywhere.\n *\n * A rule wins over a skill when both match: it is the more specific answer, and\n * \"rewrite this line\" is more actionable than \"sharpen a description\".\n */\nexport function matchCoverage(text: string, index: CoverageIndex): CoverageMatch {\n\tconst words = contentWords(text);\n\n\tlet bestLine: string | undefined;\n\tlet bestOverlap = 0;\n\tfor (const line of index.ruleLines) {\n\t\tconst overlap = wordOverlap(words, line);\n\t\tif (overlap > bestOverlap) {\n\t\t\tbestOverlap = overlap;\n\t\t\tbestLine = line;\n\t\t}\n\t}\n\tif (bestOverlap >= COVERED_OVERLAP) return { rule: bestLine };\n\n\tlet bestSkill: string | undefined;\n\tlet bestSkillOverlap = 0;\n\tfor (const skill of index.skills) {\n\t\tconst haystack = `${skill.name} ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`;\n\t\tconst overlap = wordOverlap(words, haystack);\n\t\tif (overlap > bestSkillOverlap) {\n\t\t\tbestSkillOverlap = overlap;\n\t\t\tbestSkill = skill.name;\n\t\t}\n\t}\n\tif (bestSkillOverlap >= SKILL_COVERED_OVERLAP) return { skill: bestSkill };\n\n\treturn {};\n}\n\n/** Assemble the coverage index for a directory. */\nexport function buildCoverageIndex(options: {\n\tcwd: string;\n\tagentDir: string;\n\tskills?: Array<{ name: string; description: string }>;\n}): CoverageIndex {\n\tconst corpus = coverageCorpus(options.agentDir, findAgentsFile(options.cwd));\n\treturn {\n\t\truleLines: corpus\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => line.trim())\n\t\t\t.filter((line) => line.length > 0 && !line.startsWith(\"#\")),\n\t\tskills: options.skills ?? loadSkillIndex(options.cwd, options.agentDir),\n\t};\n}\n\n/**\n * Text a proposal is checked against to decide whether it is already written\n * down — the nearest repo context file plus both user scopes.\n *\n * All three matter for suppression, because `/learn` can route a rule to the\n * user scope. Checking only the repo file would report a rule you accepted into\n * `~/.agents/AGENTS.md` as declined.\n */\nfunction coverageCorpus(agentDir: string, repoFile: string | undefined): string {\n\tconst parts: string[] = [];\n\tfor (const candidate of [repoFile, join(getUserAgentsDir(), \"AGENTS.md\"), join(agentDir, \"AGENTS.md\")]) {\n\t\tif (!candidate || !existsSync(candidate)) continue;\n\t\ttry {\n\t\t\tparts.push(readFileSync(candidate, \"utf-8\"));\n\t\t} catch {\n\t\t\t// Unreadable context file: treat as absent rather than failing the run.\n\t\t}\n\t}\n\treturn parts.join(\"\\n\");\n}\n\n/**\n * Skills a proposal could already have become.\n *\n * `/learn` routes long or conditional guidance to a skill rather than a rule, so\n * without this a proposal you adopted *as a skill* would read as declined —\n * looking only at context files sees an unchanged `AGENTS.md` and concludes you\n * passed. Reuses the real loader rather than a second SKILL.md scanner so the\n * set of locations cannot drift from what the session actually loads.\n */\nfunction loadSkillIndex(cwd: string, agentDir: string): Array<{ name: string; description: string }> {\n\ttry {\n\t\treturn loadSkills({ cwd, agentDir, skillPaths: [], includeDefaults: true }).skills.map((skill) => ({\n\t\t\tname: skill.name,\n\t\t\tdescription: skill.description ?? \"\",\n\t\t}));\n\t} catch {\n\t\t// Skills are an enrichment here, not the point of the command.\n\t\treturn [];\n\t}\n}\n\n/** Mine the recent sessions for this cwd and return the ranked digest. */\nexport function extractLearnDigest(options: ExtractOptions): LearnDigest {\n\tconst { sessions, skipped, scan } = listSessions(options);\n\n\tconst agentsFilePath = findAgentsFile(options.cwd);\n\tlet agentsContent: string | undefined;\n\tif (agentsFilePath) {\n\t\ttry {\n\t\t\tagentsContent = readFileSync(agentsFilePath, \"utf-8\");\n\t\t} catch {\n\t\t\tagentsContent = undefined;\n\t\t}\n\t}\n\tconst coverage = buildCoverageIndex({ cwd: options.cwd, agentDir: options.agentDir, skills: options.skills });\n\n\tconst withDirectives = sessions.map((session) => ({ session, directives: userDirectives(session.entries) }));\n\tconst withEvents = sessions.map((session) => ({ session, events: toolEvents(session.entries) }));\n\n\tconst timestamps = sessions.map((s) => s.timestamp).sort();\n\tconst state = options.ignoreState ? undefined : options.state;\n\n\t// Directives carry a real coverage signal — is this written down as a rule or\n\t// a skill right now? — which is what separates an adopted proposal from a\n\t// declined one. Fixes and workflows do not: a fix may have become a rule, a\n\t// skill, or a habit, and which one is not recoverable here, so they get\n\t// suppression only and are never labelled declined.\n\tconst maxProposals = options.maxProposals ?? DEFAULT_MAX_PER_CATEGORY;\n\tconst directives = applySuppression(\n\t\tclusterDirectives(withDirectives, coverage, options.minRepeats ?? DEFAULT_MIN_DIRECTIVE_COUNT),\n\t\tstate,\n\t\tmaxProposals,\n\t\t(item) => item.status !== \"new\",\n\t\t(item) => {\n\t\t\titem.previouslyDeclined = true;\n\t\t},\n\t);\n\tconst fixes = applySuppression(extractFixes(withEvents), state, maxProposals, () => false);\n\tconst workflows = applySuppression(\n\t\textractWorkflows(withEvents, options.minWorkflowRepeats ?? DEFAULT_MIN_WORKFLOW_COUNT),\n\t\tstate,\n\t\tmaxProposals,\n\t\t() => false,\n\t);\n\n\tconst surfaced = [\n\t\t...directives.kept.map((d) => ({ key: d.key, lastSeen: d.lastSeen, covered: d.status !== \"new\" })),\n\t\t...fixes.kept.map((f) => ({ key: f.key, lastSeen: f.lastSeen, covered: false })),\n\t\t...workflows.kept.map((w) => ({ key: w.key, lastSeen: w.lastSeen, covered: false })),\n\t];\n\n\treturn {\n\t\tscannedSessions: sessions.length,\n\t\tskippedSessions: skipped,\n\t\tscan,\n\t\toldestSession: timestamps[0],\n\t\tnewestSession: timestamps[timestamps.length - 1],\n\t\tagentsFilePath,\n\t\tagentsFileTokens:\n\t\t\tagentsContent === undefined ? undefined : Math.round(Buffer.byteLength(agentsContent, \"utf-8\") / 4),\n\t\tdirectives: directives.kept,\n\t\tfixes: fixes.kept,\n\t\tworkflows: workflows.kept,\n\t\tsuppressed: directives.suppressed + fixes.suppressed + workflows.suppressed,\n\t\tsurfaced,\n\t};\n}\n"]}
@@ -14,10 +14,10 @@
14
14
  * digest, which is why the output carries evidence (counts, sessions, dates)
15
15
  * rather than conclusions.
16
16
  */
17
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
18
- import { dirname, join, resolve } from "node:path";
17
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
18
+ import { dirname, join, resolve, sep } from "node:path";
19
19
  import { getUserAgentsDir } from "../../config.js";
20
- import { getDefaultSessionDir } from "../session-manager.js";
20
+ import { getSessionDirPath } from "../session-manager.js";
21
21
  import { loadSkills } from "../skills.js";
22
22
  import { commandHead, contentWords, extractErrorRegion, isBenignFailure, isRuleShapedDirective, isUninformativeFailure, normalizeCommand, normalizeDirective, normalizeErrorSignature, wordOverlap, } from "./normalize.js";
23
23
  import { judge } from "./state.js";
@@ -101,12 +101,40 @@ function activeBranch(entries) {
101
101
  }
102
102
  return branch.reverse();
103
103
  }
104
- function parseSessionFile(file, cwd) {
104
+ /**
105
+ * Compare two directory paths the way the filesystem does.
106
+ *
107
+ * A session header stores the cwd as it was typed, and the same directory can
108
+ * be spelled several ways: through a symlink (`/tmp` is `/private/tmp` on
109
+ * macOS), with a trailing separator, or in different case on the
110
+ * case-insensitive filesystems that macOS and Windows ship by default. String
111
+ * equality on `resolve()` alone rejects every one of those, and rejecting them
112
+ * here means silently discarding the whole history the command exists to read.
113
+ */
114
+ function normalizeDirPath(path) {
115
+ let resolved = resolve(path);
116
+ try {
117
+ resolved = realpathSync.native(resolved);
118
+ }
119
+ catch {
120
+ // Deleted or never-created directory: the textual form is all we have.
121
+ }
122
+ // `resolve` already drops a trailing separator except at a filesystem root,
123
+ // where dropping it would turn "/" into "".
124
+ if (resolved.length > 1 && resolved.endsWith(sep))
125
+ resolved = resolved.slice(0, -1);
126
+ return process.platform === "win32" || process.platform === "darwin" ? resolved.toLowerCase() : resolved;
127
+ }
128
+ function sameDirectory(a, b) {
129
+ return normalizeDirPath(a) === normalizeDirPath(b);
130
+ }
131
+ function parseSessionFile(file, cwd, onSkip) {
105
132
  let raw;
106
133
  try {
107
134
  raw = readFileSync(file, "utf-8");
108
135
  }
109
136
  catch {
137
+ onSkip("unreadable");
110
138
  return undefined;
111
139
  }
112
140
  const lines = raw.split("\n");
@@ -133,10 +161,14 @@ function parseSessionFile(file, cwd) {
133
161
  }
134
162
  // An explicit `--session` path can put a session for another directory in
135
163
  // this directory, so trust the header over the file's location.
136
- if (header?.cwd && resolve(header.cwd) !== resolve(cwd))
164
+ if (header?.cwd && !sameDirectory(header.cwd, cwd)) {
165
+ onSkip("otherCwd");
137
166
  return undefined;
138
- if (entries.length === 0)
167
+ }
168
+ if (entries.length === 0) {
169
+ onSkip("unreadable");
139
170
  return undefined;
171
+ }
140
172
  return {
141
173
  file,
142
174
  id: header?.id ?? file,
@@ -144,52 +176,109 @@ function parseSessionFile(file, cwd) {
144
176
  entries: activeBranch(entries),
145
177
  };
146
178
  }
179
+ /**
180
+ * Every directory this cwd's sessions could be sitting in.
181
+ *
182
+ * The caller passes the live session manager's directory, which is the right
183
+ * answer almost always — but not quite always, and each exception silently
184
+ * emptied the digest. An in-memory session (`--no-session`) reports `""`; an
185
+ * explicit `--session <path>` reports wherever that file lives; a custom
186
+ * `sessionDir` setting points at one shared directory. In every one of those
187
+ * cases the per-cwd default directory still holds the history worth mining, so
188
+ * search both and let the header check sort out what belongs to this cwd.
189
+ */
190
+ export function candidateSessionDirs(options) {
191
+ const dirs = [];
192
+ const seen = new Set();
193
+ for (const dir of [options.sessionDir, getSessionDirPath(options.cwd, options.agentDir)]) {
194
+ if (!dir)
195
+ continue;
196
+ const key = normalizeDirPath(dir);
197
+ if (seen.has(key))
198
+ continue;
199
+ seen.add(key);
200
+ dirs.push(dir);
201
+ }
202
+ return dirs;
203
+ }
147
204
  function listSessions(options) {
148
- const dir = options.sessionDir ?? getDefaultSessionDir(options.cwd, options.agentDir);
149
- if (!existsSync(dir))
150
- return { sessions: [], skipped: 0 };
205
+ const dirs = candidateSessionDirs(options);
206
+ const scan = {
207
+ dirs,
208
+ missingDirs: [],
209
+ files: 0,
210
+ tooOld: 0,
211
+ otherCwd: 0,
212
+ overLimit: 0,
213
+ unreadable: 0,
214
+ };
151
215
  const maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
152
216
  const maxAgeDays = options.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;
153
217
  const now = options.now ?? new Date();
154
218
  const cutoff = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000;
155
- let files;
156
- try {
157
- files = readdirSync(dir)
158
- .filter((f) => f.endsWith(".jsonl"))
159
- .map((f) => join(dir, f));
160
- }
161
- catch {
162
- return { sessions: [], skipped: 0 };
219
+ const files = [];
220
+ for (const dir of dirs) {
221
+ if (!existsSync(dir)) {
222
+ scan.missingDirs.push(dir);
223
+ continue;
224
+ }
225
+ try {
226
+ for (const name of readdirSync(dir)) {
227
+ if (name.endsWith(".jsonl"))
228
+ files.push(join(dir, name));
229
+ }
230
+ }
231
+ catch {
232
+ scan.missingDirs.push(dir);
233
+ }
163
234
  }
235
+ scan.files = files.length;
236
+ // Newest first across all directories, so `maxSessions` keeps the most recent
237
+ // history rather than whichever directory happened to be searched first.
164
238
  const dated = files
165
239
  .map((file) => {
166
240
  try {
167
241
  return { file, mtime: statSync(file).mtime.getTime() };
168
242
  }
169
243
  catch {
244
+ scan.unreadable++;
170
245
  return undefined;
171
246
  }
172
247
  })
173
248
  .filter((f) => !!f)
174
249
  .sort((a, b) => b.mtime - a.mtime);
175
250
  const sessions = [];
251
+ const seenIds = new Set();
176
252
  let skipped = 0;
177
253
  for (const { file, mtime } of dated) {
178
254
  if (sessions.length >= maxSessions) {
255
+ scan.overLimit++;
179
256
  skipped++;
180
257
  continue;
181
258
  }
182
259
  if (mtime < cutoff) {
260
+ scan.tooOld++;
183
261
  skipped++;
184
262
  continue;
185
263
  }
186
- const parsed = parseSessionFile(file, options.cwd);
187
- if (parsed)
188
- sessions.push(parsed);
189
- else
264
+ const parsed = parseSessionFile(file, options.cwd, (reason) => {
265
+ scan[reason]++;
266
+ });
267
+ if (!parsed) {
190
268
  skipped++;
269
+ continue;
270
+ }
271
+ // Searching two directories can turn up the same session twice (an explicit
272
+ // `--session` path inside the default directory). Counting it twice would
273
+ // inflate the cross-session repetition that decides what gets proposed.
274
+ if (seenIds.has(parsed.id)) {
275
+ skipped++;
276
+ continue;
277
+ }
278
+ seenIds.add(parsed.id);
279
+ sessions.push(parsed);
191
280
  }
192
- return { sessions, skipped };
281
+ return { sessions, skipped, scan };
193
282
  }
194
283
  /**
195
284
  * Hold back items already shown that have not recurred since, then cap the rest.
@@ -215,6 +304,13 @@ function applySuppression(items, state, maxProposals, covered, onDeclined) {
215
304
  }
216
305
  return { kept: kept.slice(0, maxProposals), suppressed };
217
306
  }
307
+ /**
308
+ * Where this cwd's sessions were found and what was passed over, without
309
+ * ranking anything. `/learn stats` reports on the window without re-mining it.
310
+ */
311
+ export function scanSessions(options) {
312
+ return listSessions(options).scan;
313
+ }
218
314
  /** Nearest AGENTS.md walking up from cwd, so proposals can be checked against it. */
219
315
  function findAgentsFile(cwd) {
220
316
  let dir = resolve(cwd);
@@ -656,7 +752,7 @@ function loadSkillIndex(cwd, agentDir) {
656
752
  }
657
753
  /** Mine the recent sessions for this cwd and return the ranked digest. */
658
754
  export function extractLearnDigest(options) {
659
- const { sessions, skipped } = listSessions(options);
755
+ const { sessions, skipped, scan } = listSessions(options);
660
756
  const agentsFilePath = findAgentsFile(options.cwd);
661
757
  let agentsContent;
662
758
  if (agentsFilePath) {
@@ -691,6 +787,7 @@ export function extractLearnDigest(options) {
691
787
  return {
692
788
  scannedSessions: sessions.length,
693
789
  skippedSessions: skipped,
790
+ scan,
694
791
  oldestSession: timestamps[0],
695
792
  newestSession: timestamps[timestamps.length - 1],
696
793
  agentsFilePath,