autonomous-flow-daemon 1.0.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.ko.md +142 -125
  3. package/README.md +119 -134
  4. package/package.json +11 -5
  5. package/src/adapters/index.ts +247 -35
  6. package/src/cli.ts +79 -1
  7. package/src/commands/benchmark.ts +187 -0
  8. package/src/commands/diagnose.ts +56 -14
  9. package/src/commands/doctor.ts +243 -0
  10. package/src/commands/evolution.ts +107 -0
  11. package/src/commands/fix.ts +22 -2
  12. package/src/commands/hooks.ts +136 -0
  13. package/src/commands/lang.ts +41 -0
  14. package/src/commands/mcp.ts +129 -0
  15. package/src/commands/restart.ts +14 -0
  16. package/src/commands/score.ts +192 -64
  17. package/src/commands/start.ts +137 -37
  18. package/src/commands/stats.ts +103 -0
  19. package/src/commands/status.ts +157 -0
  20. package/src/commands/stop.ts +42 -9
  21. package/src/commands/sync.ts +253 -20
  22. package/src/commands/vaccine.ts +177 -0
  23. package/src/constants.ts +26 -1
  24. package/src/core/boast.ts +280 -0
  25. package/src/core/config.ts +49 -0
  26. package/src/core/db.ts +74 -3
  27. package/src/core/discovery.ts +65 -0
  28. package/src/core/evolution.ts +215 -0
  29. package/src/core/hologram/engine.ts +71 -0
  30. package/src/core/hologram/fallback.ts +11 -0
  31. package/src/core/hologram/incremental.ts +227 -0
  32. package/src/core/hologram/py-extractor.ts +132 -0
  33. package/src/core/hologram/ts-extractor.ts +320 -0
  34. package/src/core/hologram/types.ts +25 -0
  35. package/src/core/hologram.ts +64 -236
  36. package/src/core/hook-manager.ts +259 -0
  37. package/src/core/i18n/messages.ts +309 -0
  38. package/src/core/immune.ts +8 -123
  39. package/src/core/locale.ts +88 -0
  40. package/src/core/log-rotate.ts +33 -0
  41. package/src/core/log-utils.ts +38 -0
  42. package/src/core/lru-map.ts +61 -0
  43. package/src/core/notify.ts +53 -14
  44. package/src/core/rule-engine.ts +287 -0
  45. package/src/core/semantic-diff.ts +432 -0
  46. package/src/core/telemetry.ts +94 -0
  47. package/src/core/vaccine-registry.ts +212 -0
  48. package/src/core/workspace.ts +28 -0
  49. package/src/core/yaml-minimal.ts +176 -0
  50. package/src/daemon/client.ts +34 -6
  51. package/src/daemon/event-batcher.ts +108 -0
  52. package/src/daemon/guards.ts +13 -0
  53. package/src/daemon/http-routes.ts +293 -0
  54. package/src/daemon/mcp-handler.ts +270 -0
  55. package/src/daemon/server.ts +492 -273
  56. package/src/daemon/types.ts +100 -0
  57. package/src/daemon/workspace-map.ts +92 -0
  58. package/src/platform.ts +60 -0
  59. package/src/version.ts +15 -0
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Self-Evolution Engine
3
+ *
4
+ * Analyzes quarantined (corrupted) files against their restored originals,
5
+ * generates failure lessons, and writes them to afd-lessons.md so AI agents
6
+ * learn from their own mistakes.
7
+ */
8
+
9
+ import { readdirSync, readFileSync, writeFileSync, renameSync, existsSync } from "fs";
10
+ import { resolve, basename } from "path";
11
+ import { QUARANTINE_DIR } from "../constants";
12
+ import { lineDiff } from "./log-utils";
13
+
14
+ const LESSONS_FILE = "afd-lessons.md";
15
+ const LEARNED_SUFFIX = ".learned";
16
+
17
+ export interface QuarantineEntry {
18
+ /** Full path inside .afd/quarantine/ */
19
+ quarantinePath: string;
20
+ /** Original file path (reconstructed from quarantine filename) */
21
+ originalPath: string;
22
+ /** Timestamp string extracted from filename */
23
+ timestamp: string;
24
+ /** Whether this entry has already been learned */
25
+ learned: boolean;
26
+ }
27
+
28
+ export interface EvolutionLesson {
29
+ entry: QuarantineEntry;
30
+ diff: string[];
31
+ corruptedContent: string;
32
+ restoredContent: string | null;
33
+ failureType: "corruption" | "deletion";
34
+ suggestion: string;
35
+ }
36
+
37
+ export interface EvolutionStats {
38
+ totalQuarantined: number;
39
+ totalLearned: number;
40
+ pending: number;
41
+ lessons: EvolutionLesson[];
42
+ }
43
+
44
+ /** Parse quarantine filename: YYYYMMDD_HHMMSS_originalname → { timestamp, originalPath } */
45
+ function parseQuarantineName(filename: string): { timestamp: string; originalPath: string } | null {
46
+ // e.g. 20260401_021028_.claude_hooks.json or 20260401_020741_.claudeignore
47
+ const match = filename.replace(LEARNED_SUFFIX, "").match(/^(\d{8}_\d{6})_(.+)$/);
48
+ if (!match) return null;
49
+ const ts = match[1];
50
+ // Reconstruct path: underscores that were separators become path separators
51
+ // Heuristic: first underscore-delimited segment starting with "." is likely a directory
52
+ const rawName = match[2];
53
+ // Reverse the flatten: `.claude_hooks.json` → `.claude/hooks.json`
54
+ const originalPath = rawName.replace(/^\.([^.]+)_/, ".$1/");
55
+ return { timestamp: ts, originalPath };
56
+ }
57
+
58
+ /** List all quarantined entries */
59
+ export function listQuarantine(): QuarantineEntry[] {
60
+ if (!existsSync(QUARANTINE_DIR)) return [];
61
+ const files = readdirSync(QUARANTINE_DIR);
62
+ const entries: QuarantineEntry[] = [];
63
+
64
+ for (const file of files) {
65
+ const isLearned = file.endsWith(LEARNED_SUFFIX);
66
+ const cleanName = file.replace(LEARNED_SUFFIX, "");
67
+ const parsed = parseQuarantineName(cleanName);
68
+ if (!parsed) continue;
69
+ entries.push({
70
+ quarantinePath: resolve(QUARANTINE_DIR, file),
71
+ originalPath: parsed.originalPath,
72
+ timestamp: parsed.timestamp,
73
+ learned: isLearned,
74
+ });
75
+ }
76
+
77
+ return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
78
+ }
79
+
80
+ /** Detect what type of corruption occurred */
81
+ function detectFailureType(content: string): "corruption" | "deletion" {
82
+ return content.trim() === "DELETED" ? "deletion" : "corruption";
83
+ }
84
+
85
+ /** Generate a human-readable suggestion based on the failure */
86
+ function generateSuggestion(entry: QuarantineEntry, failureType: string, corruptedContent: string, restoredContent: string | null): string {
87
+ const file = entry.originalPath;
88
+
89
+ if (failureType === "deletion") {
90
+ return `Do NOT delete \`${file}\`. This is an immune-critical file protected by afd. ` +
91
+ `If you need to modify it, edit the content instead of removing the file.`;
92
+ }
93
+
94
+ // Corruption analysis
95
+ if (file.endsWith(".json")) {
96
+ try {
97
+ JSON.parse(corruptedContent);
98
+ } catch (e) {
99
+ const msg = e instanceof Error ? e.message : String(e);
100
+ return `When editing \`${file}\`, ensure valid JSON syntax. ` +
101
+ `Common mistake: ${msg}. Always validate JSON structure after editing.`;
102
+ }
103
+ }
104
+
105
+ if (corruptedContent.trim().length === 0) {
106
+ return `Do NOT empty \`${file}\`. The file was overwritten with blank content. ` +
107
+ `Preserve existing content when making changes.`;
108
+ }
109
+
110
+ if (restoredContent && corruptedContent.length < restoredContent.length * 0.1) {
111
+ return `Do NOT truncate \`${file}\`. Content was reduced by >90%. ` +
112
+ `When editing, preserve the existing structure and only modify specific sections.`;
113
+ }
114
+
115
+ return `When editing \`${file}\`, be careful not to corrupt its structure. ` +
116
+ `Always verify the file is still valid after changes.`;
117
+ }
118
+
119
+ /** Analyze pending (unlearned) quarantine entries and produce lessons */
120
+ export function analyzeQuarantine(): EvolutionStats {
121
+ const entries = listQuarantine();
122
+ const totalQuarantined = entries.length;
123
+ const totalLearned = entries.filter(e => e.learned).length;
124
+ const pending = entries.filter(e => !e.learned);
125
+
126
+ const lessons: EvolutionLesson[] = [];
127
+
128
+ for (const entry of pending) {
129
+ const corruptedContent = readFileSync(entry.quarantinePath, "utf-8");
130
+ const failureType = detectFailureType(corruptedContent);
131
+
132
+ let restoredContent: string | null = null;
133
+ let diff: string[] = [];
134
+
135
+ if (existsSync(entry.originalPath)) {
136
+ restoredContent = readFileSync(entry.originalPath, "utf-8");
137
+ if (failureType === "corruption") {
138
+ diff = lineDiff(corruptedContent, restoredContent, 20);
139
+ } else {
140
+ diff = [` (file was deleted — restored from antibody snapshot)`];
141
+ }
142
+ } else {
143
+ diff = [` (original file not found — may still be deleted)`];
144
+ }
145
+
146
+ const suggestion = generateSuggestion(entry, failureType, corruptedContent, restoredContent);
147
+
148
+ lessons.push({ entry, diff, corruptedContent, restoredContent, failureType, suggestion });
149
+ }
150
+
151
+ return { totalQuarantined, totalLearned, pending: pending.length, lessons };
152
+ }
153
+
154
+ /** Mark a quarantine entry as learned by renaming with .learned suffix */
155
+ export function markLearned(entry: QuarantineEntry): void {
156
+ if (entry.learned) return;
157
+ const newPath = entry.quarantinePath + LEARNED_SUFFIX;
158
+ renameSync(entry.quarantinePath, newPath);
159
+ }
160
+
161
+ /** Build the lesson block for afd-lessons.md */
162
+ function buildLessonBlock(lesson: EvolutionLesson): string {
163
+ const ts = lesson.entry.timestamp.replace("_", "T");
164
+ const formattedTs = `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)} ${ts.slice(9, 11)}:${ts.slice(11, 13)}:${ts.slice(13, 15)}`;
165
+ const lines: string[] = [
166
+ `### ${lesson.entry.originalPath} (${formattedTs})`,
167
+ `- **Type**: ${lesson.failureType === "deletion" ? "Unauthorized Deletion" : "Content Corruption"}`,
168
+ `- **Rule**: ${lesson.suggestion}`,
169
+ ];
170
+ if (lesson.diff.length > 0) {
171
+ lines.push(`- **Diff**:`);
172
+ lines.push("```");
173
+ lines.push(...lesson.diff);
174
+ lines.push("```");
175
+ }
176
+ return lines.join("\n");
177
+ }
178
+
179
+ /**
180
+ * Write lessons to afd-lessons.md and mark entries as learned.
181
+ * Returns the number of new lessons written.
182
+ */
183
+ export function evolve(): { lessonsWritten: number; totalLessons: number } {
184
+ const stats = analyzeQuarantine();
185
+ if (stats.lessons.length === 0) {
186
+ return { lessonsWritten: 0, totalLessons: stats.totalLearned };
187
+ }
188
+
189
+ // Build new lesson blocks
190
+ const newBlocks = stats.lessons.map(buildLessonBlock);
191
+
192
+ // Read or initialize afd-lessons.md
193
+ let existing = "";
194
+ if (existsSync(LESSONS_FILE)) {
195
+ existing = readFileSync(LESSONS_FILE, "utf-8");
196
+ }
197
+
198
+ if (!existing.includes("# Failure Lessons")) {
199
+ existing = `# Failure Lessons\n\n` +
200
+ `> Auto-generated by afd Self-Evolution engine.\n` +
201
+ `> AI agents: read these rules to avoid repeating past mistakes.\n\n` +
202
+ existing;
203
+ }
204
+
205
+ // Append new lessons
206
+ const updated = existing.trimEnd() + "\n\n" + newBlocks.join("\n\n") + "\n";
207
+ writeFileSync(LESSONS_FILE, updated, "utf-8");
208
+
209
+ // Mark all analyzed entries as learned
210
+ for (const lesson of stats.lessons) {
211
+ markLearned(lesson.entry);
212
+ }
213
+
214
+ return { lessonsWritten: stats.lessons.length, totalLessons: stats.totalLearned + stats.lessons.length };
215
+ }
@@ -0,0 +1,71 @@
1
+ import { Parser, Language, type Tree } from "web-tree-sitter";
2
+ import { resolve, dirname } from "path";
3
+
4
+ /**
5
+ * Singleton Tree-sitter engine with grammar caching.
6
+ * Parser.init() runs once; grammar WASMs are lazy-loaded and cached in-memory.
7
+ */
8
+ export class TreeSitterEngine {
9
+ private static instance: TreeSitterEngine | null = null;
10
+ private parser: Parser | null = null;
11
+ private grammarCache = new Map<string, Language>();
12
+ private initPromise: Promise<void> | null = null;
13
+
14
+ static async getInstance(): Promise<TreeSitterEngine> {
15
+ if (!this.instance) {
16
+ this.instance = new TreeSitterEngine();
17
+ await this.instance.init();
18
+ }
19
+ return this.instance;
20
+ }
21
+
22
+ /** Reset singleton — for testing only */
23
+ static resetForTest(): void {
24
+ if (this.instance?.parser) {
25
+ this.instance.parser.delete();
26
+ }
27
+ this.instance = null;
28
+ }
29
+
30
+ private async init(): Promise<void> {
31
+ if (this.initPromise) return this.initPromise;
32
+ this.initPromise = (async () => {
33
+ await Parser.init();
34
+ this.parser = new Parser();
35
+ })();
36
+ return this.initPromise;
37
+ }
38
+
39
+ async parse(source: string, grammarName: string): Promise<Tree> {
40
+ const grammar = await this.loadGrammar(grammarName);
41
+ this.parser!.setLanguage(grammar);
42
+ const tree = this.parser!.parse(source);
43
+ if (!tree) throw new Error(`Failed to parse with grammar: ${grammarName}`);
44
+ return tree;
45
+ }
46
+
47
+ private async loadGrammar(grammarName: string): Promise<Language> {
48
+ const cached = this.grammarCache.get(grammarName);
49
+ if (cached) return cached;
50
+
51
+ const wasmPath = resolveGrammarWasm(grammarName);
52
+ const lang = await Language.load(wasmPath);
53
+ this.grammarCache.set(grammarName, lang);
54
+ return lang;
55
+ }
56
+ }
57
+
58
+ /** Resolve WASM file path from installed npm grammar package */
59
+ function resolveGrammarWasm(grammarName: string): string {
60
+ // Grammar packages: tree-sitter-typescript, tree-sitter-python, etc.
61
+ // WASM file is at package root: node_modules/tree-sitter-{name}/tree-sitter-{name}.wasm
62
+ const packageName = `tree-sitter-${grammarName}`;
63
+ try {
64
+ // require.resolve returns bindings/node/index.js — walk up to package root
65
+ const pkgJson = require.resolve(`${packageName}/package.json`);
66
+ return resolve(dirname(pkgJson), `${packageName}.wasm`);
67
+ } catch {
68
+ // Fallback: try direct node_modules path
69
+ return resolve(process.cwd(), "node_modules", packageName, `${packageName}.wasm`);
70
+ }
71
+ }
@@ -0,0 +1,11 @@
1
+ import type { HologramResult } from "./types";
2
+
3
+ /** L0 fallback: return full source when tree-sitter cannot parse */
4
+ export function fallbackL0(filePath: string, source: string): HologramResult {
5
+ return {
6
+ hologram: source,
7
+ originalLength: source.length,
8
+ hologramLength: source.length,
9
+ savings: 0,
10
+ };
11
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Incremental Hologram — diff-only mode.
3
+ *
4
+ * Compares previous and current hologram extractions,
5
+ * returns only changed nodes with surrounding context in unified-diff style.
6
+ */
7
+
8
+ import type { Tree } from "web-tree-sitter";
9
+ import type { HologramResult, LanguageExtractor, HologramOptions } from "./types";
10
+ import { TreeSitterEngine } from "./engine";
11
+
12
+ /** In-memory cache for previous hologram lines (per file path) */
13
+ const hologramCache = new Map<string, string[]>();
14
+
15
+ /** Maximum cache entries */
16
+ const MAX_CACHE_SIZE = 200;
17
+
18
+ export function clearHologramCache(): void {
19
+ hologramCache.clear();
20
+ }
21
+
22
+ export function setCachedHologram(filePath: string, lines: string[]): void {
23
+ // True LRU: delete existing entry first so re-insert moves it to end
24
+ hologramCache.delete(filePath);
25
+ if (hologramCache.size >= MAX_CACHE_SIZE) {
26
+ const oldestKey = hologramCache.keys().next().value;
27
+ if (oldestKey) hologramCache.delete(oldestKey);
28
+ }
29
+ hologramCache.set(filePath, lines);
30
+ }
31
+
32
+ export function getCachedHologram(filePath: string): string[] | undefined {
33
+ const value = hologramCache.get(filePath);
34
+ if (value !== undefined) {
35
+ // Promote to most-recently-used position
36
+ hologramCache.delete(filePath);
37
+ hologramCache.set(filePath, value);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ /**
43
+ * Generate an incremental (diff-only) hologram.
44
+ * Compares current extraction with cached previous result.
45
+ * Returns unified-diff style output showing only changed nodes.
46
+ */
47
+ export async function generateIncrementalHologram(
48
+ filePath: string,
49
+ source: string,
50
+ extractor: LanguageExtractor,
51
+ options?: HologramOptions,
52
+ ): Promise<HologramResult> {
53
+ const engine = await TreeSitterEngine.getInstance();
54
+ const tree = await engine.parse(source, extractor.grammarName);
55
+ const currentLines = extractor.extract(tree, source, options);
56
+ tree.delete();
57
+
58
+ const previousLines = getCachedHologram(filePath);
59
+
60
+ // Cache current result for next diff
61
+ setCachedHologram(filePath, currentLines);
62
+
63
+ // No previous → return full hologram with diff header
64
+ if (!previousLines) {
65
+ const hologram = currentLines.join("\n");
66
+ return {
67
+ hologram,
68
+ originalLength: source.length,
69
+ hologramLength: hologram.length,
70
+ savings: source.length > 0
71
+ ? Math.round((source.length - hologram.length) / source.length * 1000) / 10
72
+ : 0,
73
+ language: extractor.grammarName,
74
+ isDiff: false,
75
+ changedNodes: currentLines.length,
76
+ };
77
+ }
78
+
79
+ // Diff previous vs current lines
80
+ const diffOutput = buildUnifiedDiff(filePath, previousLines, currentLines);
81
+
82
+ return {
83
+ hologram: diffOutput.text,
84
+ originalLength: source.length,
85
+ hologramLength: diffOutput.text.length,
86
+ savings: source.length > 0
87
+ ? Math.round((source.length - diffOutput.text.length) / source.length * 1000) / 10
88
+ : 0,
89
+ language: extractor.grammarName,
90
+ isDiff: true,
91
+ changedNodes: diffOutput.changedCount,
92
+ };
93
+ }
94
+
95
+ interface DiffOutput {
96
+ text: string;
97
+ changedCount: number;
98
+ }
99
+
100
+ /**
101
+ * Build a unified-diff style output comparing old and new hologram lines.
102
+ * Groups unchanged lines into summary markers, shows changed lines with +/- prefixes.
103
+ */
104
+ function buildUnifiedDiff(filePath: string, oldLines: string[], newLines: string[]): DiffOutput {
105
+ const header = `--- a/${filePath} (previous)\n+++ b/${filePath} (current)\n`;
106
+
107
+ // Simple line-by-line diff using LCS approach
108
+ const hunks = computeHunks(oldLines, newLines);
109
+
110
+ if (hunks.length === 0) {
111
+ return {
112
+ text: header + "@@ no changes @@",
113
+ changedCount: 0,
114
+ };
115
+ }
116
+
117
+ const parts: string[] = [header];
118
+ let changedCount = 0;
119
+ let oldIdx = 0;
120
+
121
+ for (const hunk of hunks) {
122
+ // Show unchanged lines before this hunk as a summary
123
+ const unchangedBefore = hunk.oldStart - oldIdx;
124
+ if (unchangedBefore > 0) {
125
+ const summaryLines = oldLines.slice(oldIdx, hunk.oldStart);
126
+ const firstLine = summaryLines[0]?.split("{")[0]?.trim() ?? "...";
127
+ parts.push(`@@ unchanged: ${firstLine} (${unchangedBefore} ${unchangedBefore === 1 ? "declaration" : "declarations"}) @@`);
128
+ }
129
+
130
+ // Show removed lines
131
+ for (let i = hunk.oldStart; i < hunk.oldStart + hunk.oldCount; i++) {
132
+ parts.push(`- ${oldLines[i]}`);
133
+ changedCount++;
134
+ }
135
+
136
+ // Show added lines
137
+ for (let i = hunk.newStart; i < hunk.newStart + hunk.newCount; i++) {
138
+ parts.push(`+ ${newLines[i]}`);
139
+ }
140
+
141
+ oldIdx = hunk.oldStart + hunk.oldCount;
142
+ }
143
+
144
+ // Trailing unchanged
145
+ const trailingCount = oldLines.length - oldIdx;
146
+ if (trailingCount > 0) {
147
+ parts.push(`@@ unchanged: ${trailingCount} more ${trailingCount === 1 ? "declaration" : "declarations"} @@`);
148
+ }
149
+
150
+ return { text: parts.join("\n"), changedCount };
151
+ }
152
+
153
+ interface Hunk {
154
+ oldStart: number;
155
+ oldCount: number;
156
+ newStart: number;
157
+ newCount: number;
158
+ }
159
+
160
+ /**
161
+ * Compute diff hunks between old and new line arrays.
162
+ * Uses a simple O(n*m) LCS-based diff suitable for small arrays (hologram lines are typically < 100).
163
+ */
164
+ function computeHunks(oldLines: string[], newLines: string[]): Hunk[] {
165
+ const n = oldLines.length;
166
+ const m = newLines.length;
167
+
168
+ // Guard: for very large inputs, fall back to full diff to stay within SEAM budget
169
+ if (n * m > 50_000) {
170
+ return [{ oldStart: 0, oldCount: n, newStart: 0, newCount: m }];
171
+ }
172
+
173
+ // Build LCS table
174
+ const dp: number[][] = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0));
175
+ for (let i = 1; i <= n; i++) {
176
+ for (let j = 1; j <= m; j++) {
177
+ if (oldLines[i - 1] === newLines[j - 1]) {
178
+ dp[i][j] = dp[i - 1][j - 1] + 1;
179
+ } else {
180
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
181
+ }
182
+ }
183
+ }
184
+
185
+ // Backtrack to find matching lines
186
+ const matches: Array<[number, number]> = [];
187
+ let i = n, j = m;
188
+ while (i > 0 && j > 0) {
189
+ if (oldLines[i - 1] === newLines[j - 1]) {
190
+ matches.unshift([i - 1, j - 1]);
191
+ i--; j--;
192
+ } else if (dp[i - 1][j] > dp[i][j - 1]) {
193
+ i--;
194
+ } else {
195
+ j--;
196
+ }
197
+ }
198
+
199
+ // Convert matches to hunks (gaps between matches)
200
+ const hunks: Hunk[] = [];
201
+ let oi = 0, ni = 0;
202
+
203
+ for (const [mi, mj] of matches) {
204
+ if (oi < mi || ni < mj) {
205
+ hunks.push({
206
+ oldStart: oi,
207
+ oldCount: mi - oi,
208
+ newStart: ni,
209
+ newCount: mj - ni,
210
+ });
211
+ }
212
+ oi = mi + 1;
213
+ ni = mj + 1;
214
+ }
215
+
216
+ // Trailing diff
217
+ if (oi < n || ni < m) {
218
+ hunks.push({
219
+ oldStart: oi,
220
+ oldCount: n - oi,
221
+ newStart: ni,
222
+ newCount: m - ni,
223
+ });
224
+ }
225
+
226
+ return hunks;
227
+ }
@@ -0,0 +1,132 @@
1
+ import type { Node, Tree } from "web-tree-sitter";
2
+ import type { LanguageExtractor, HologramOptions } from "./types";
3
+
4
+ /** Stub a Python function/method body — keep signature + "..." */
5
+ function stubPythonBody(node: Node, source: string): string {
6
+ const body = node.childForFieldName("body");
7
+ if (!body) return node.text;
8
+ return source.slice(node.startIndex, body.startIndex).trimEnd() + " ...";
9
+ }
10
+
11
+ /** Extract a Python function definition */
12
+ function extractFunction(node: Node, source: string): string {
13
+ // Decorators
14
+ const decorators = collectDecorators(node, source);
15
+ const sig = stubPythonBody(node, source);
16
+ return decorators + sig;
17
+ }
18
+
19
+ /** Extract a Python class with method signatures */
20
+ function extractClass(node: Node, source: string): string {
21
+ const decorators = collectDecorators(node, source);
22
+ const nameNode = node.childForFieldName("name");
23
+ const name = nameNode?.text ?? "?";
24
+ const superclasses = node.childForFieldName("superclasses");
25
+ const sup = superclasses ? superclasses.text : "";
26
+ const body = node.childForFieldName("body");
27
+
28
+ const header = `class ${name}${sup ? `(${sup.replace(/^\(|\)$/g, "")})` : ""}:`;
29
+
30
+ if (!body) return decorators + header;
31
+
32
+ const members: string[] = [];
33
+ for (const child of body.namedChildren) {
34
+ switch (child.type) {
35
+ case "function_definition": {
36
+ const memberDecorators = collectDecorators(child, source);
37
+ members.push(indent(memberDecorators + stubPythonBody(child, source)));
38
+ break;
39
+ }
40
+ case "expression_statement": {
41
+ // Type-annotated assignments: x: int = 42
42
+ const expr = child.namedChildren[0];
43
+ if (expr?.type === "assignment" || expr?.type === "type") {
44
+ members.push(indent(child.text.split("\n")[0]));
45
+ }
46
+ break;
47
+ }
48
+ case "class_definition": {
49
+ // Nested class — just show header
50
+ const nestedName = child.childForFieldName("name")?.text ?? "?";
51
+ members.push(indent(`class ${nestedName}: ...`));
52
+ break;
53
+ }
54
+ }
55
+ }
56
+
57
+ if (members.length === 0) {
58
+ return decorators + header + "\n ...";
59
+ }
60
+
61
+ return decorators + header + "\n" + members.join("\n");
62
+ }
63
+
64
+ /** Collect decorator lines above a node */
65
+ function collectDecorators(node: Node, source: string): string {
66
+ const decorators: string[] = [];
67
+ // In tree-sitter-python, decorators are children of the decorated_definition
68
+ // or are 'decorator' type children of the function/class
69
+ const parent = node.parent;
70
+ if (parent?.type === "decorated_definition") {
71
+ for (const child of parent.namedChildren) {
72
+ if (child.type === "decorator") {
73
+ decorators.push(child.text);
74
+ }
75
+ }
76
+ }
77
+ return decorators.length > 0 ? decorators.join("\n") + "\n" : "";
78
+ }
79
+
80
+ function indent(s: string): string {
81
+ return s.split("\n").map(line => " " + line).join("\n");
82
+ }
83
+
84
+ /** Process a single top-level statement */
85
+ function extractTopLevel(node: Node, source: string): string | null {
86
+ switch (node.type) {
87
+ case "import_statement":
88
+ case "import_from_statement":
89
+ return node.text;
90
+ case "function_definition":
91
+ return extractFunction(node, source);
92
+ case "class_definition":
93
+ return extractClass(node, source);
94
+ case "decorated_definition": {
95
+ // Unwrap to get the inner function/class
96
+ const inner = node.namedChildren.find(c =>
97
+ c.type === "function_definition" || c.type === "class_definition");
98
+ if (inner) return extractTopLevel(inner, source);
99
+ return null;
100
+ }
101
+ case "expression_statement": {
102
+ // Module-level type annotations or assignments
103
+ const expr = node.namedChildren[0];
104
+ if (expr?.type === "assignment" || expr?.type === "type") {
105
+ return node.text.split("\n")[0];
106
+ }
107
+ // __all__ = [...]
108
+ if (node.text.startsWith("__all__")) {
109
+ return node.text;
110
+ }
111
+ return null;
112
+ }
113
+ default:
114
+ return null;
115
+ }
116
+ }
117
+
118
+ export const pyExtractor: LanguageExtractor = {
119
+ extensions: ["py", "pyi"],
120
+ grammarName: "python",
121
+
122
+ extract(tree: Tree, source: string, _options?: HologramOptions): string[] {
123
+ const lines: string[] = [];
124
+
125
+ for (const stmt of tree.rootNode.namedChildren) {
126
+ const line = extractTopLevel(stmt, source);
127
+ if (line) lines.push(line);
128
+ }
129
+
130
+ return lines;
131
+ },
132
+ };