@agimon-ai/doompi-file-edit 0.0.1-alpha.45 → 0.0.1-alpha.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
- const e=require("../../_virtual/_rolldown/runtime.cjs"),t=require("../../services/lineDiff.cjs");let n=require("node:path");n=e.__toESM(n,1);const r=`bash`;function i(e,t){if(!e||typeof e!=`object`)return;let n=e[t];return typeof n==`string`?n:void 0}var a=class{timeline;snapshots;manifests;pending=new Map;bracketed=new Set;manifest;excluded=[];contents=new Map;now;git;constructor(e,t,n,r={}){this.timeline=e,this.snapshots=t,this.manifests=n,this.now=r.now??Date.now,this.git=r.git}reset(e={}){this.pending.clear(),this.bracketed.clear(),this.contents.clear(),this.manifest=void 0,this.excluded=e.exclude??[]}async start(e,t,a,o){if(t===`edit`||t===`write`){let r=i(a,`path`);if(!r)return;let s=n.default.resolve(o,r);this.pending.set(e,{tool:t,filePath:s,before:await this.snapshots.capture(s)});return}t===r&&(this.bracketed.add(e),this.manifest??=await this.manifests.take(o,this.excluded))}async end(e,t,n){let r=this.pending.get(e);this.pending.delete(e);let i=this.bracketed.delete(e);if(!t){if(r){await this.recordTool(r);return}i&&await this.recordScan(n)}}async recordTool(e){let t=await this.snapshots.capture(e.filePath),n=await this.countChanges(e.before,t);t!==void 0&&this.contents.set(e.filePath,t),await this.append({version:2,path:e.filePath,tool:e.tool,at:this.now(),origin:`tool`,...e.before===void 0?{}:{before:e.before},...t===void 0?{}:{after:t},...n}),await this.remember(e.filePath)}async recordScan(e){let t=this.manifest;if(t===void 0)return;let n=await this.manifests.take(e,this.excluded);this.manifest=n;let i=this.manifests.changed(t,n);if(i.length===0)return;let a=await this.unchangedInGit(e,i);for(let e of i){let t=await this.snapshots.capture(e),n=this.contents.get(e);t!==void 0&&this.contents.set(e,t),(t===void 0||t!==n)&&(n===void 0&&a.has(e)||await this.append({version:2,path:e,tool:r,at:this.now(),origin:`scan`,verified:!0,...t===void 0?{}:{after:t}}))}}async unchangedInGit(e,t){let n=t.filter(e=>!this.contents.has(e));return this.git===void 0||n.length===0?new Set:this.git.unchanged(e,n)}async countChanges(e,n){if(e===void 0||n===void 0)return{};if(e===n)return{additions:0,removals:0};let[r,i]=await Promise.all([this.snapshots.read(e),this.snapshots.read(n)]);if(r===void 0||i===void 0)return{};let a=t.lineDiff(r,i);return{additions:a.additions,removals:a.removals}}async append(e){(e.before===void 0||e.before!==e.after)&&await this.timeline.append(e)}async remember(e){if(this.manifest===void 0)return;let t=new Map(this.manifest.entries),n=await this.manifests.fingerprint(e);n===void 0?t.delete(e):t.set(e,n),this.manifest={entries:t,truncated:this.manifest.truncated}}};exports.EditTracker=a;
1
+ const e=require("../../_virtual/_rolldown/runtime.cjs"),t=require("../../services/lineDiff.cjs");let n=require("node:path");n=e.__toESM(n,1);const r=`bash`;function i(e,t){if(!e||typeof e!=`object`)return;let n=e[t];return typeof n==`string`?n:void 0}var a=class{timeline;snapshots;manifests;pending=new Map;bracketed=new Map;manifest;excluded=[];contents=new Map;now;git;constructor(e,t,n,r={}){this.timeline=e,this.snapshots=t,this.manifests=n,this.now=r.now??Date.now,this.git=r.git}reset(e={}){this.pending.clear(),this.bracketed.clear(),this.contents.clear(),this.manifest=void 0,this.excluded=e.exclude??[]}async start(e,t,a,o){if(t===`edit`||t===`write`){let r=i(a,`path`);if(!r)return;let s=n.default.resolve(o,r);this.pending.set(e,{tool:t,filePath:s,before:await this.snapshots.capture(s)});return}t===r&&(this.bracketed.set(e,this.now()),this.manifest??=await this.manifests.take(o,this.excluded))}async end(e,t,n){let r=this.pending.get(e);this.pending.delete(e);let i=this.bracketed.get(e);if(this.bracketed.delete(e),!t){if(r){await this.recordTool(r);return}i!==void 0&&await this.recordScan(n,i)}}async recordTool(e){let t=await this.snapshots.capture(e.filePath),n=await this.countChanges(e.before,t);t!==void 0&&this.contents.set(e.filePath,t),await this.append({version:2,path:e.filePath,tool:e.tool,at:this.now(),origin:`tool`,...e.before===void 0?{}:{before:e.before},...t===void 0?{}:{after:t},...n}),await this.remember(e.filePath)}async recordScan(e,t){let n=this.manifest;if(n===void 0)return;let i=await this.manifests.take(e,this.excluded);this.manifest=i;let a=await this.writtenDuring(this.manifests.changed(n,i),t);if(a.length===0)return;let o=await this.unchangedInGit(e,a);for(let e of a){let t=await this.snapshots.capture(e),n=this.contents.get(e);t!==void 0&&this.contents.set(e,t),(t===void 0||t!==n)&&(n===void 0&&o.has(e)||await this.append({version:2,path:e,tool:r,at:this.now(),origin:`scan`,verified:!0,...t===void 0?{}:{after:t}}))}}async writtenDuring(e,t){let n=t-1e3,r=[];for(let t of e){let e=await this.manifests.modifiedAt(t);(e===void 0||e>=n)&&r.push(t)}return r}async unchangedInGit(e,t){let n=t.filter(e=>!this.contents.has(e));return this.git===void 0||n.length===0?new Set:this.git.unchanged(e,n)}async countChanges(e,n){if(e===void 0||n===void 0)return{};if(e===n)return{additions:0,removals:0};let[r,i]=await Promise.all([this.snapshots.read(e),this.snapshots.read(n)]);if(r===void 0||i===void 0)return{};let a=t.lineDiff(r,i);return{additions:a.additions,removals:a.removals}}async append(e){(e.before===void 0||e.before!==e.after)&&await this.timeline.append(e)}async remember(e){if(this.manifest===void 0)return;let t=new Map(this.manifest.entries),n=await this.manifests.fingerprint(e);n===void 0?t.delete(e):t.set(e,n),this.manifest={entries:t,truncated:this.manifest.truncated}}};exports.EditTracker=a;
2
2
  //# sourceMappingURL=EditTracker.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"EditTracker.cjs","names":["path","lineDiff"],"sources":["../../../src/adapters/EditTracker/EditTracker.ts"],"sourcesContent":["import path from 'node:path';\nimport { lineDiff } from '../../services/lineDiff.ts';\nimport type { FileEditTool, TimelineEvent } from '../../types/domain';\nimport type { IEditTracker } from '../../types/editTracker';\nimport type { GitStatusPort } from '../../types/gitStatus.ts';\nimport type { SnapshotStorePort } from '../../types/snapshotStore.ts';\nimport type { ITimelineStore } from '../../types/timelineStore';\nimport type { TreeManifest, TreeManifestPort } from '../../types/treeManifest.ts';\n\nconst EDIT_TOOL = 'edit';\nconst WRITE_TOOL = 'write';\nconst BASH_TOOL = 'bash';\n\n/**\n * Records what the session changed, and captures enough of it to be diffed.\n *\n * Two mechanisms, because one is not enough. `edit` and `write` name their file\n * in the call arguments, so the content is read before the tool runs and both\n * sides of the change are known exactly. `bash` names nothing reliable: the\n * agent can write a script and run it, and reading path-shaped tokens out of\n * the command would miss every file that script touches. So a bash call is\n * bracketed by tree manifests instead, and whatever moved between them is\n * recorded, however it was written.\n *\n * A manifest-found path is recorded without a baseline, because it was only\n * identified after it had already changed. That is a real limit and the wire\n * carries it as `origin: 'scan'` rather than pretending a diff exists.\n *\n * A manifest compares size and modification time, which a checkout, an install,\n * or a formatter rewriting identical bytes moves without changing a single\n * byte of content. So a candidate is confirmed before it is recorded: its\n * content hash is compared against the last one this session saw, and a path\n * this session has never captured is put to git. Neither answer is available\n * for every file, an ignored temporary file is unknown to git and a first\n * sighting has no earlier hash, so an unconfirmable candidate is still\n * recorded; only a proven non-change is dropped.\n */\ninterface PendingEdit {\n tool: 'edit' | 'write';\n filePath: string;\n before: string | undefined;\n}\n\nfunction objectValue(value: unknown, key: string): string | undefined {\n if (!value || typeof value !== 'object') return undefined;\n const found = (value as Record<string, unknown>)[key];\n return typeof found === 'string' ? found : undefined;\n}\n\nexport interface EditTrackerOptions {\n /** Injectable so a test can pin the recorded timestamps. */\n now?: () => number;\n /**\n * Decides whether a first-seen scan candidate actually changed. Optional:\n * without it the tracker keeps recording every candidate it cannot disprove.\n */\n git?: GitStatusPort;\n}\n\nexport class EditTracker implements IEditTracker {\n private readonly pending = new Map<string, PendingEdit>();\n /** Set while a bash call is in flight, so its end knows a manifest is owed. */\n private readonly bracketed = new Set<string>();\n /**\n * The tree as this tracker last saw it. A bash call compares against it and\n * then replaces it, so each call costs one walk rather than two, and a change\n * made between calls is still noticed on the next one.\n */\n private manifest: TreeManifest | undefined;\n /**\n * This package's own storage, which the walk must never report. The timeline\n * and its snapshots can land inside the tree being watched, and recording\n * them would make every change cause another one.\n */\n private excluded: readonly string[] = [];\n /**\n * The last content hash this session saw per path, from either mechanism.\n * A candidate whose hash has not moved was touched, not edited.\n */\n private readonly contents = new Map<string, string>();\n private readonly now: () => number;\n private readonly git: GitStatusPort | undefined;\n\n constructor(\n private readonly timeline: ITimelineStore,\n private readonly snapshots: SnapshotStorePort,\n private readonly manifests: TreeManifestPort,\n options: EditTrackerOptions = {},\n ) {\n this.now = options.now ?? Date.now;\n this.git = options.git;\n }\n\n /**\n * Forgets the previous session's tree so a new one does not inherit its\n * baseline, and takes the paths this session's own bookkeeping occupies.\n */\n reset(options: { exclude?: readonly string[] } = {}): void {\n this.pending.clear();\n this.bracketed.clear();\n this.contents.clear();\n this.manifest = undefined;\n this.excluded = options.exclude ?? [];\n }\n\n async start(id: string, tool: string, args: unknown, cwd: string): Promise<void> {\n if (tool === EDIT_TOOL || tool === WRITE_TOOL) {\n const supplied = objectValue(args, 'path');\n if (!supplied) return;\n const filePath = path.resolve(cwd, supplied);\n this.pending.set(id, { tool, filePath, before: await this.snapshots.capture(filePath) });\n return;\n }\n if (tool !== BASH_TOOL) return;\n this.bracketed.add(id);\n // The first bash call of a session has nothing to compare against, so it\n // pays for the baseline walk; every later call reuses the previous end.\n this.manifest ??= await this.manifests.take(cwd, this.excluded);\n }\n\n async end(id: string, isError: boolean, cwd: string): Promise<void> {\n const pending = this.pending.get(id);\n this.pending.delete(id);\n const bracketed = this.bracketed.delete(id);\n if (isError) return;\n if (pending) {\n await this.recordTool(pending);\n return;\n }\n if (bracketed) await this.recordScan(cwd);\n }\n\n /** An `edit` or `write` whose file was read on both sides of the call. */\n private async recordTool(pending: PendingEdit): Promise<void> {\n const after = await this.snapshots.capture(pending.filePath);\n const counts = await this.countChanges(pending.before, after);\n if (after !== undefined) this.contents.set(pending.filePath, after);\n await this.append({\n version: 2,\n path: pending.filePath,\n tool: pending.tool,\n at: this.now(),\n origin: 'tool',\n ...(pending.before === undefined ? {} : { before: pending.before }),\n ...(after === undefined ? {} : { after }),\n ...counts,\n });\n // The tool already accounted for this path, so the next bash comparison\n // must not report it a second time under its own name.\n await this.remember(pending.filePath);\n }\n\n /** Whatever a bash call actually edited, however the command wrote it. */\n private async recordScan(cwd: string): Promise<void> {\n const before = this.manifest;\n if (before === undefined) return;\n const after = await this.manifests.take(cwd, this.excluded);\n this.manifest = after;\n const candidates = this.manifests.changed(before, after);\n if (candidates.length === 0) return;\n const unchanged = await this.unchangedInGit(cwd, candidates);\n for (const filePath of candidates) {\n const captured = await this.snapshots.capture(filePath);\n const known = this.contents.get(filePath);\n if (captured !== undefined) this.contents.set(filePath, captured);\n // The bytes are the ones this session already recorded: the call moved the\n // modification time and nothing else.\n if (captured !== undefined && captured === known) continue;\n // Never captured here, but git still holds the file and says it matches.\n if (known === undefined && unchanged.has(filePath)) continue;\n await this.append({\n version: 2,\n path: filePath,\n tool: BASH_TOOL,\n at: this.now(),\n origin: 'scan',\n verified: true,\n ...(captured === undefined ? {} : { after: captured }),\n });\n }\n }\n\n /** Which candidates git tracks and reports as untouched; empty when it cannot say. */\n private async unchangedInGit(cwd: string, candidates: readonly string[]): Promise<ReadonlySet<string>> {\n const unseen = candidates.filter((filePath) => !this.contents.has(filePath));\n if (this.git === undefined || unseen.length === 0) return new Set();\n return this.git.unchanged(cwd, unseen);\n }\n\n /** How many lines moved, when both sides were captured. */\n private async countChanges(\n before: string | undefined,\n after: string | undefined,\n ): Promise<{ additions?: number; removals?: number }> {\n if (before === undefined || after === undefined) return {};\n if (before === after) return { additions: 0, removals: 0 };\n const [beforeText, afterText] = await Promise.all([this.snapshots.read(before), this.snapshots.read(after)]);\n if (beforeText === undefined || afterText === undefined) return {};\n const diff = lineDiff(beforeText, afterText);\n return { additions: diff.additions, removals: diff.removals };\n }\n\n /** Records the change unless it turned out to be no change at all. */\n private async append(event: TimelineEvent): Promise<void> {\n if (event.before !== undefined && event.before === event.after) return;\n await this.timeline.append(event);\n }\n\n /**\n * Folds a path the tool layer just handled into the tracked manifest, so the\n * next bash comparison sees it as already accounted for.\n *\n * The fingerprint has to be the one a walk would read, not a stand-in, or the\n * next comparison reports the file again under bash and every tool edit is\n * recorded twice.\n */\n private async remember(filePath: string): Promise<void> {\n if (this.manifest === undefined) return;\n const entries = new Map(this.manifest.entries);\n const fingerprint = await this.manifests.fingerprint(filePath);\n if (fingerprint === undefined) entries.delete(filePath);\n else entries.set(filePath, fingerprint);\n this.manifest = { entries, truncated: this.manifest.truncated };\n }\n}\n\n/** The tools this tracker knows how to attribute a change to. */\nexport const TRACKED_TOOLS: readonly FileEditTool[] = [EDIT_TOOL, WRITE_TOOL, BASH_TOOL];\n"],"mappings":"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"}
1
+ {"version":3,"file":"EditTracker.cjs","names":["path","lineDiff"],"sources":["../../../src/adapters/EditTracker/EditTracker.ts"],"sourcesContent":["import path from 'node:path';\nimport { lineDiff } from '../../services/lineDiff.ts';\nimport type { FileEditTool, TimelineEvent } from '../../types/domain';\nimport type { IEditTracker } from '../../types/editTracker';\nimport type { GitStatusPort } from '../../types/gitStatus.ts';\nimport type { SnapshotStorePort } from '../../types/snapshotStore.ts';\nimport type { ITimelineStore } from '../../types/timelineStore';\nimport type { TreeManifest, TreeManifestPort } from '../../types/treeManifest.ts';\n\nconst EDIT_TOOL = 'edit';\nconst WRITE_TOOL = 'write';\nconst BASH_TOOL = 'bash';\n\n/**\n * Records what the session changed, and captures enough of it to be diffed.\n *\n * Two mechanisms, because one is not enough. `edit` and `write` name their file\n * in the call arguments, so the content is read before the tool runs and both\n * sides of the change are known exactly. `bash` names nothing reliable: the\n * agent can write a script and run it, and reading path-shaped tokens out of\n * the command would miss every file that script touches. So a bash call is\n * bracketed by tree manifests instead, and whatever moved between them is\n * recorded, however it was written.\n *\n * A manifest-found path is recorded without a baseline, because it was only\n * identified after it had already changed. That is a real limit and the wire\n * carries it as `origin: 'scan'` rather than pretending a diff exists.\n *\n * A manifest compares size and modification time, and it compares the tree as\n * this tracker last walked it against the tree now, so it answers whether a\n * file differs, never when it moved. A path a previous session left dirty, or\n * one a checkout or an install rewrote, differs exactly like a path the command\n * just wrote. So a candidate is confirmed three ways before it is recorded: its\n * modification time has to fall inside the call that is being closed, its\n * content hash has to differ from the last one this session saw, and a path\n * this session has never captured is put to git. Not every answer is available\n * for every file, an ignored temporary file is unknown to git and a first\n * sighting has no earlier hash, so an unconfirmable candidate is still\n * recorded; only a proven non-change is dropped.\n */\ninterface PendingEdit {\n tool: 'edit' | 'write';\n filePath: string;\n before: string | undefined;\n}\n\nfunction objectValue(value: unknown, key: string): string | undefined {\n if (!value || typeof value !== 'object') return undefined;\n const found = (value as Record<string, unknown>)[key];\n return typeof found === 'string' ? found : undefined;\n}\n\nexport interface EditTrackerOptions {\n /** Injectable so a test can pin the recorded timestamps. */\n now?: () => number;\n /**\n * Decides whether a first-seen scan candidate actually changed. Optional:\n * without it the tracker keeps recording every candidate it cannot disprove.\n */\n git?: GitStatusPort;\n}\n\n/**\n * How far before a call started a write may claim to have happened and still be\n * attributed to it. A working tree can sit on a filesystem that stores whole\n * seconds, which floors a write made just after the call began to a stamp just\n * before it, and the cost of being generous here is one second of the staleness\n * this check exists to remove.\n */\nconst MODIFIED_TOLERANCE_MS = 1000;\n\nexport class EditTracker implements IEditTracker {\n private readonly pending = new Map<string, PendingEdit>();\n /** Start time per in-flight bash call, so its end knows what it may claim. */\n private readonly bracketed = new Map<string, number>();\n /**\n * The tree as this tracker last saw it. A bash call compares against it and\n * then replaces it, so each call costs one walk rather than two, and a change\n * made between calls is still noticed on the next one.\n */\n private manifest: TreeManifest | undefined;\n /**\n * This package's own storage, which the walk must never report. The timeline\n * and its snapshots can land inside the tree being watched, and recording\n * them would make every change cause another one.\n */\n private excluded: readonly string[] = [];\n /**\n * The last content hash this session saw per path, from either mechanism.\n * A candidate whose hash has not moved was touched, not edited.\n */\n private readonly contents = new Map<string, string>();\n private readonly now: () => number;\n private readonly git: GitStatusPort | undefined;\n\n constructor(\n private readonly timeline: ITimelineStore,\n private readonly snapshots: SnapshotStorePort,\n private readonly manifests: TreeManifestPort,\n options: EditTrackerOptions = {},\n ) {\n this.now = options.now ?? Date.now;\n this.git = options.git;\n }\n\n /**\n * Forgets the previous session's tree so a new one does not inherit its\n * baseline, and takes the paths this session's own bookkeeping occupies.\n */\n reset(options: { exclude?: readonly string[] } = {}): void {\n this.pending.clear();\n this.bracketed.clear();\n this.contents.clear();\n this.manifest = undefined;\n this.excluded = options.exclude ?? [];\n }\n\n async start(id: string, tool: string, args: unknown, cwd: string): Promise<void> {\n if (tool === EDIT_TOOL || tool === WRITE_TOOL) {\n const supplied = objectValue(args, 'path');\n if (!supplied) return;\n const filePath = path.resolve(cwd, supplied);\n this.pending.set(id, { tool, filePath, before: await this.snapshots.capture(filePath) });\n return;\n }\n if (tool !== BASH_TOOL) return;\n // Read before the baseline walk, which is the one thing here that can take\n // long enough to matter: a write that races it belongs to this call.\n this.bracketed.set(id, this.now());\n // The first bash call of a session has nothing to compare against, so it\n // pays for the baseline walk; every later call reuses the previous end.\n this.manifest ??= await this.manifests.take(cwd, this.excluded);\n }\n\n async end(id: string, isError: boolean, cwd: string): Promise<void> {\n const pending = this.pending.get(id);\n this.pending.delete(id);\n const startedAt = this.bracketed.get(id);\n this.bracketed.delete(id);\n if (isError) return;\n if (pending) {\n await this.recordTool(pending);\n return;\n }\n if (startedAt !== undefined) await this.recordScan(cwd, startedAt);\n }\n\n /** An `edit` or `write` whose file was read on both sides of the call. */\n private async recordTool(pending: PendingEdit): Promise<void> {\n const after = await this.snapshots.capture(pending.filePath);\n const counts = await this.countChanges(pending.before, after);\n if (after !== undefined) this.contents.set(pending.filePath, after);\n await this.append({\n version: 2,\n path: pending.filePath,\n tool: pending.tool,\n at: this.now(),\n origin: 'tool',\n ...(pending.before === undefined ? {} : { before: pending.before }),\n ...(after === undefined ? {} : { after }),\n ...counts,\n });\n // The tool already accounted for this path, so the next bash comparison\n // must not report it a second time under its own name.\n await this.remember(pending.filePath);\n }\n\n /** Whatever a bash call actually edited, however the command wrote it. */\n private async recordScan(cwd: string, startedAt: number): Promise<void> {\n const before = this.manifest;\n if (before === undefined) return;\n const after = await this.manifests.take(cwd, this.excluded);\n this.manifest = after;\n const candidates = await this.writtenDuring(this.manifests.changed(before, after), startedAt);\n if (candidates.length === 0) return;\n const unchanged = await this.unchangedInGit(cwd, candidates);\n for (const filePath of candidates) {\n const captured = await this.snapshots.capture(filePath);\n const known = this.contents.get(filePath);\n if (captured !== undefined) this.contents.set(filePath, captured);\n // The bytes are the ones this session already recorded: the call moved the\n // modification time and nothing else.\n if (captured !== undefined && captured === known) continue;\n // Never captured here, but git still holds the file and says it matches.\n if (known === undefined && unchanged.has(filePath)) continue;\n await this.append({\n version: 2,\n path: filePath,\n tool: BASH_TOOL,\n at: this.now(),\n origin: 'scan',\n verified: true,\n ...(captured === undefined ? {} : { after: captured }),\n });\n }\n }\n\n /**\n * Narrows candidates to the ones that could have been written by the call\n * being closed.\n *\n * This is what keeps a working tree that was already dirty out of the list. A\n * file whose bytes were last written before the call began was differed by\n * something else, whatever moved its fingerprint. A file that is no longer\n * there has no time to read and a delete is a change the call may well have\n * made, so it stays a candidate.\n */\n private async writtenDuring(candidates: readonly string[], startedAt: number): Promise<string[]> {\n const floor = startedAt - MODIFIED_TOLERANCE_MS;\n const written: string[] = [];\n for (const filePath of candidates) {\n const modifiedAt = await this.manifests.modifiedAt(filePath);\n if (modifiedAt === undefined || modifiedAt >= floor) written.push(filePath);\n }\n return written;\n }\n\n /** Which candidates git tracks and reports as untouched; empty when it cannot say. */\n private async unchangedInGit(cwd: string, candidates: readonly string[]): Promise<ReadonlySet<string>> {\n const unseen = candidates.filter((filePath) => !this.contents.has(filePath));\n if (this.git === undefined || unseen.length === 0) return new Set();\n return this.git.unchanged(cwd, unseen);\n }\n\n /** How many lines moved, when both sides were captured. */\n private async countChanges(\n before: string | undefined,\n after: string | undefined,\n ): Promise<{ additions?: number; removals?: number }> {\n if (before === undefined || after === undefined) return {};\n if (before === after) return { additions: 0, removals: 0 };\n const [beforeText, afterText] = await Promise.all([this.snapshots.read(before), this.snapshots.read(after)]);\n if (beforeText === undefined || afterText === undefined) return {};\n const diff = lineDiff(beforeText, afterText);\n return { additions: diff.additions, removals: diff.removals };\n }\n\n /** Records the change unless it turned out to be no change at all. */\n private async append(event: TimelineEvent): Promise<void> {\n if (event.before !== undefined && event.before === event.after) return;\n await this.timeline.append(event);\n }\n\n /**\n * Folds a path the tool layer just handled into the tracked manifest, so the\n * next bash comparison sees it as already accounted for.\n *\n * The fingerprint has to be the one a walk would read, not a stand-in, or the\n * next comparison reports the file again under bash and every tool edit is\n * recorded twice.\n */\n private async remember(filePath: string): Promise<void> {\n if (this.manifest === undefined) return;\n const entries = new Map(this.manifest.entries);\n const fingerprint = await this.manifests.fingerprint(filePath);\n if (fingerprint === undefined) entries.delete(filePath);\n else entries.set(filePath, fingerprint);\n this.manifest = { entries, truncated: this.manifest.truncated };\n }\n}\n\n/** The tools this tracker knows how to attribute a change to. */\nexport const TRACKED_TOOLS: readonly FileEditTool[] = [EDIT_TOOL, WRITE_TOOL, BASH_TOOL];\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import{lineDiff as e}from"../../services/lineDiff.mjs";import t from"node:path";const n=`bash`;function r(e,t){if(!e||typeof e!=`object`)return;let n=e[t];return typeof n==`string`?n:void 0}var i=class{timeline;snapshots;manifests;pending=new Map;bracketed=new Set;manifest;excluded=[];contents=new Map;now;git;constructor(e,t,n,r={}){this.timeline=e,this.snapshots=t,this.manifests=n,this.now=r.now??Date.now,this.git=r.git}reset(e={}){this.pending.clear(),this.bracketed.clear(),this.contents.clear(),this.manifest=void 0,this.excluded=e.exclude??[]}async start(e,i,a,o){if(i===`edit`||i===`write`){let n=r(a,`path`);if(!n)return;let s=t.resolve(o,n);this.pending.set(e,{tool:i,filePath:s,before:await this.snapshots.capture(s)});return}i===n&&(this.bracketed.add(e),this.manifest??=await this.manifests.take(o,this.excluded))}async end(e,t,n){let r=this.pending.get(e);this.pending.delete(e);let i=this.bracketed.delete(e);if(!t){if(r){await this.recordTool(r);return}i&&await this.recordScan(n)}}async recordTool(e){let t=await this.snapshots.capture(e.filePath),n=await this.countChanges(e.before,t);t!==void 0&&this.contents.set(e.filePath,t),await this.append({version:2,path:e.filePath,tool:e.tool,at:this.now(),origin:`tool`,...e.before===void 0?{}:{before:e.before},...t===void 0?{}:{after:t},...n}),await this.remember(e.filePath)}async recordScan(e){let t=this.manifest;if(t===void 0)return;let r=await this.manifests.take(e,this.excluded);this.manifest=r;let i=this.manifests.changed(t,r);if(i.length===0)return;let a=await this.unchangedInGit(e,i);for(let e of i){let t=await this.snapshots.capture(e),r=this.contents.get(e);t!==void 0&&this.contents.set(e,t),(t===void 0||t!==r)&&(r===void 0&&a.has(e)||await this.append({version:2,path:e,tool:n,at:this.now(),origin:`scan`,verified:!0,...t===void 0?{}:{after:t}}))}}async unchangedInGit(e,t){let n=t.filter(e=>!this.contents.has(e));return this.git===void 0||n.length===0?new Set:this.git.unchanged(e,n)}async countChanges(t,n){if(t===void 0||n===void 0)return{};if(t===n)return{additions:0,removals:0};let[r,i]=await Promise.all([this.snapshots.read(t),this.snapshots.read(n)]);if(r===void 0||i===void 0)return{};let a=e(r,i);return{additions:a.additions,removals:a.removals}}async append(e){(e.before===void 0||e.before!==e.after)&&await this.timeline.append(e)}async remember(e){if(this.manifest===void 0)return;let t=new Map(this.manifest.entries),n=await this.manifests.fingerprint(e);n===void 0?t.delete(e):t.set(e,n),this.manifest={entries:t,truncated:this.manifest.truncated}}};export{i as EditTracker};
1
+ import{lineDiff as e}from"../../services/lineDiff.mjs";import t from"node:path";const n=`bash`;function r(e,t){if(!e||typeof e!=`object`)return;let n=e[t];return typeof n==`string`?n:void 0}var i=class{timeline;snapshots;manifests;pending=new Map;bracketed=new Map;manifest;excluded=[];contents=new Map;now;git;constructor(e,t,n,r={}){this.timeline=e,this.snapshots=t,this.manifests=n,this.now=r.now??Date.now,this.git=r.git}reset(e={}){this.pending.clear(),this.bracketed.clear(),this.contents.clear(),this.manifest=void 0,this.excluded=e.exclude??[]}async start(e,i,a,o){if(i===`edit`||i===`write`){let n=r(a,`path`);if(!n)return;let s=t.resolve(o,n);this.pending.set(e,{tool:i,filePath:s,before:await this.snapshots.capture(s)});return}i===n&&(this.bracketed.set(e,this.now()),this.manifest??=await this.manifests.take(o,this.excluded))}async end(e,t,n){let r=this.pending.get(e);this.pending.delete(e);let i=this.bracketed.get(e);if(this.bracketed.delete(e),!t){if(r){await this.recordTool(r);return}i!==void 0&&await this.recordScan(n,i)}}async recordTool(e){let t=await this.snapshots.capture(e.filePath),n=await this.countChanges(e.before,t);t!==void 0&&this.contents.set(e.filePath,t),await this.append({version:2,path:e.filePath,tool:e.tool,at:this.now(),origin:`tool`,...e.before===void 0?{}:{before:e.before},...t===void 0?{}:{after:t},...n}),await this.remember(e.filePath)}async recordScan(e,t){let r=this.manifest;if(r===void 0)return;let i=await this.manifests.take(e,this.excluded);this.manifest=i;let a=await this.writtenDuring(this.manifests.changed(r,i),t);if(a.length===0)return;let o=await this.unchangedInGit(e,a);for(let e of a){let t=await this.snapshots.capture(e),r=this.contents.get(e);t!==void 0&&this.contents.set(e,t),(t===void 0||t!==r)&&(r===void 0&&o.has(e)||await this.append({version:2,path:e,tool:n,at:this.now(),origin:`scan`,verified:!0,...t===void 0?{}:{after:t}}))}}async writtenDuring(e,t){let n=t-1e3,r=[];for(let t of e){let e=await this.manifests.modifiedAt(t);(e===void 0||e>=n)&&r.push(t)}return r}async unchangedInGit(e,t){let n=t.filter(e=>!this.contents.has(e));return this.git===void 0||n.length===0?new Set:this.git.unchanged(e,n)}async countChanges(t,n){if(t===void 0||n===void 0)return{};if(t===n)return{additions:0,removals:0};let[r,i]=await Promise.all([this.snapshots.read(t),this.snapshots.read(n)]);if(r===void 0||i===void 0)return{};let a=e(r,i);return{additions:a.additions,removals:a.removals}}async append(e){(e.before===void 0||e.before!==e.after)&&await this.timeline.append(e)}async remember(e){if(this.manifest===void 0)return;let t=new Map(this.manifest.entries),n=await this.manifests.fingerprint(e);n===void 0?t.delete(e):t.set(e,n),this.manifest={entries:t,truncated:this.manifest.truncated}}};export{i as EditTracker};
2
2
  //# sourceMappingURL=EditTracker.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"EditTracker.mjs","names":[],"sources":["../../../src/adapters/EditTracker/EditTracker.ts"],"sourcesContent":["import path from 'node:path';\nimport { lineDiff } from '../../services/lineDiff.ts';\nimport type { FileEditTool, TimelineEvent } from '../../types/domain';\nimport type { IEditTracker } from '../../types/editTracker';\nimport type { GitStatusPort } from '../../types/gitStatus.ts';\nimport type { SnapshotStorePort } from '../../types/snapshotStore.ts';\nimport type { ITimelineStore } from '../../types/timelineStore';\nimport type { TreeManifest, TreeManifestPort } from '../../types/treeManifest.ts';\n\nconst EDIT_TOOL = 'edit';\nconst WRITE_TOOL = 'write';\nconst BASH_TOOL = 'bash';\n\n/**\n * Records what the session changed, and captures enough of it to be diffed.\n *\n * Two mechanisms, because one is not enough. `edit` and `write` name their file\n * in the call arguments, so the content is read before the tool runs and both\n * sides of the change are known exactly. `bash` names nothing reliable: the\n * agent can write a script and run it, and reading path-shaped tokens out of\n * the command would miss every file that script touches. So a bash call is\n * bracketed by tree manifests instead, and whatever moved between them is\n * recorded, however it was written.\n *\n * A manifest-found path is recorded without a baseline, because it was only\n * identified after it had already changed. That is a real limit and the wire\n * carries it as `origin: 'scan'` rather than pretending a diff exists.\n *\n * A manifest compares size and modification time, which a checkout, an install,\n * or a formatter rewriting identical bytes moves without changing a single\n * byte of content. So a candidate is confirmed before it is recorded: its\n * content hash is compared against the last one this session saw, and a path\n * this session has never captured is put to git. Neither answer is available\n * for every file, an ignored temporary file is unknown to git and a first\n * sighting has no earlier hash, so an unconfirmable candidate is still\n * recorded; only a proven non-change is dropped.\n */\ninterface PendingEdit {\n tool: 'edit' | 'write';\n filePath: string;\n before: string | undefined;\n}\n\nfunction objectValue(value: unknown, key: string): string | undefined {\n if (!value || typeof value !== 'object') return undefined;\n const found = (value as Record<string, unknown>)[key];\n return typeof found === 'string' ? found : undefined;\n}\n\nexport interface EditTrackerOptions {\n /** Injectable so a test can pin the recorded timestamps. */\n now?: () => number;\n /**\n * Decides whether a first-seen scan candidate actually changed. Optional:\n * without it the tracker keeps recording every candidate it cannot disprove.\n */\n git?: GitStatusPort;\n}\n\nexport class EditTracker implements IEditTracker {\n private readonly pending = new Map<string, PendingEdit>();\n /** Set while a bash call is in flight, so its end knows a manifest is owed. */\n private readonly bracketed = new Set<string>();\n /**\n * The tree as this tracker last saw it. A bash call compares against it and\n * then replaces it, so each call costs one walk rather than two, and a change\n * made between calls is still noticed on the next one.\n */\n private manifest: TreeManifest | undefined;\n /**\n * This package's own storage, which the walk must never report. The timeline\n * and its snapshots can land inside the tree being watched, and recording\n * them would make every change cause another one.\n */\n private excluded: readonly string[] = [];\n /**\n * The last content hash this session saw per path, from either mechanism.\n * A candidate whose hash has not moved was touched, not edited.\n */\n private readonly contents = new Map<string, string>();\n private readonly now: () => number;\n private readonly git: GitStatusPort | undefined;\n\n constructor(\n private readonly timeline: ITimelineStore,\n private readonly snapshots: SnapshotStorePort,\n private readonly manifests: TreeManifestPort,\n options: EditTrackerOptions = {},\n ) {\n this.now = options.now ?? Date.now;\n this.git = options.git;\n }\n\n /**\n * Forgets the previous session's tree so a new one does not inherit its\n * baseline, and takes the paths this session's own bookkeeping occupies.\n */\n reset(options: { exclude?: readonly string[] } = {}): void {\n this.pending.clear();\n this.bracketed.clear();\n this.contents.clear();\n this.manifest = undefined;\n this.excluded = options.exclude ?? [];\n }\n\n async start(id: string, tool: string, args: unknown, cwd: string): Promise<void> {\n if (tool === EDIT_TOOL || tool === WRITE_TOOL) {\n const supplied = objectValue(args, 'path');\n if (!supplied) return;\n const filePath = path.resolve(cwd, supplied);\n this.pending.set(id, { tool, filePath, before: await this.snapshots.capture(filePath) });\n return;\n }\n if (tool !== BASH_TOOL) return;\n this.bracketed.add(id);\n // The first bash call of a session has nothing to compare against, so it\n // pays for the baseline walk; every later call reuses the previous end.\n this.manifest ??= await this.manifests.take(cwd, this.excluded);\n }\n\n async end(id: string, isError: boolean, cwd: string): Promise<void> {\n const pending = this.pending.get(id);\n this.pending.delete(id);\n const bracketed = this.bracketed.delete(id);\n if (isError) return;\n if (pending) {\n await this.recordTool(pending);\n return;\n }\n if (bracketed) await this.recordScan(cwd);\n }\n\n /** An `edit` or `write` whose file was read on both sides of the call. */\n private async recordTool(pending: PendingEdit): Promise<void> {\n const after = await this.snapshots.capture(pending.filePath);\n const counts = await this.countChanges(pending.before, after);\n if (after !== undefined) this.contents.set(pending.filePath, after);\n await this.append({\n version: 2,\n path: pending.filePath,\n tool: pending.tool,\n at: this.now(),\n origin: 'tool',\n ...(pending.before === undefined ? {} : { before: pending.before }),\n ...(after === undefined ? {} : { after }),\n ...counts,\n });\n // The tool already accounted for this path, so the next bash comparison\n // must not report it a second time under its own name.\n await this.remember(pending.filePath);\n }\n\n /** Whatever a bash call actually edited, however the command wrote it. */\n private async recordScan(cwd: string): Promise<void> {\n const before = this.manifest;\n if (before === undefined) return;\n const after = await this.manifests.take(cwd, this.excluded);\n this.manifest = after;\n const candidates = this.manifests.changed(before, after);\n if (candidates.length === 0) return;\n const unchanged = await this.unchangedInGit(cwd, candidates);\n for (const filePath of candidates) {\n const captured = await this.snapshots.capture(filePath);\n const known = this.contents.get(filePath);\n if (captured !== undefined) this.contents.set(filePath, captured);\n // The bytes are the ones this session already recorded: the call moved the\n // modification time and nothing else.\n if (captured !== undefined && captured === known) continue;\n // Never captured here, but git still holds the file and says it matches.\n if (known === undefined && unchanged.has(filePath)) continue;\n await this.append({\n version: 2,\n path: filePath,\n tool: BASH_TOOL,\n at: this.now(),\n origin: 'scan',\n verified: true,\n ...(captured === undefined ? {} : { after: captured }),\n });\n }\n }\n\n /** Which candidates git tracks and reports as untouched; empty when it cannot say. */\n private async unchangedInGit(cwd: string, candidates: readonly string[]): Promise<ReadonlySet<string>> {\n const unseen = candidates.filter((filePath) => !this.contents.has(filePath));\n if (this.git === undefined || unseen.length === 0) return new Set();\n return this.git.unchanged(cwd, unseen);\n }\n\n /** How many lines moved, when both sides were captured. */\n private async countChanges(\n before: string | undefined,\n after: string | undefined,\n ): Promise<{ additions?: number; removals?: number }> {\n if (before === undefined || after === undefined) return {};\n if (before === after) return { additions: 0, removals: 0 };\n const [beforeText, afterText] = await Promise.all([this.snapshots.read(before), this.snapshots.read(after)]);\n if (beforeText === undefined || afterText === undefined) return {};\n const diff = lineDiff(beforeText, afterText);\n return { additions: diff.additions, removals: diff.removals };\n }\n\n /** Records the change unless it turned out to be no change at all. */\n private async append(event: TimelineEvent): Promise<void> {\n if (event.before !== undefined && event.before === event.after) return;\n await this.timeline.append(event);\n }\n\n /**\n * Folds a path the tool layer just handled into the tracked manifest, so the\n * next bash comparison sees it as already accounted for.\n *\n * The fingerprint has to be the one a walk would read, not a stand-in, or the\n * next comparison reports the file again under bash and every tool edit is\n * recorded twice.\n */\n private async remember(filePath: string): Promise<void> {\n if (this.manifest === undefined) return;\n const entries = new Map(this.manifest.entries);\n const fingerprint = await this.manifests.fingerprint(filePath);\n if (fingerprint === undefined) entries.delete(filePath);\n else entries.set(filePath, fingerprint);\n this.manifest = { entries, truncated: this.manifest.truncated };\n }\n}\n\n/** The tools this tracker knows how to attribute a change to. */\nexport const TRACKED_TOOLS: readonly FileEditTool[] = [EDIT_TOOL, WRITE_TOOL, BASH_TOOL];\n"],"mappings":"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"}
1
+ {"version":3,"file":"EditTracker.mjs","names":[],"sources":["../../../src/adapters/EditTracker/EditTracker.ts"],"sourcesContent":["import path from 'node:path';\nimport { lineDiff } from '../../services/lineDiff.ts';\nimport type { FileEditTool, TimelineEvent } from '../../types/domain';\nimport type { IEditTracker } from '../../types/editTracker';\nimport type { GitStatusPort } from '../../types/gitStatus.ts';\nimport type { SnapshotStorePort } from '../../types/snapshotStore.ts';\nimport type { ITimelineStore } from '../../types/timelineStore';\nimport type { TreeManifest, TreeManifestPort } from '../../types/treeManifest.ts';\n\nconst EDIT_TOOL = 'edit';\nconst WRITE_TOOL = 'write';\nconst BASH_TOOL = 'bash';\n\n/**\n * Records what the session changed, and captures enough of it to be diffed.\n *\n * Two mechanisms, because one is not enough. `edit` and `write` name their file\n * in the call arguments, so the content is read before the tool runs and both\n * sides of the change are known exactly. `bash` names nothing reliable: the\n * agent can write a script and run it, and reading path-shaped tokens out of\n * the command would miss every file that script touches. So a bash call is\n * bracketed by tree manifests instead, and whatever moved between them is\n * recorded, however it was written.\n *\n * A manifest-found path is recorded without a baseline, because it was only\n * identified after it had already changed. That is a real limit and the wire\n * carries it as `origin: 'scan'` rather than pretending a diff exists.\n *\n * A manifest compares size and modification time, and it compares the tree as\n * this tracker last walked it against the tree now, so it answers whether a\n * file differs, never when it moved. A path a previous session left dirty, or\n * one a checkout or an install rewrote, differs exactly like a path the command\n * just wrote. So a candidate is confirmed three ways before it is recorded: its\n * modification time has to fall inside the call that is being closed, its\n * content hash has to differ from the last one this session saw, and a path\n * this session has never captured is put to git. Not every answer is available\n * for every file, an ignored temporary file is unknown to git and a first\n * sighting has no earlier hash, so an unconfirmable candidate is still\n * recorded; only a proven non-change is dropped.\n */\ninterface PendingEdit {\n tool: 'edit' | 'write';\n filePath: string;\n before: string | undefined;\n}\n\nfunction objectValue(value: unknown, key: string): string | undefined {\n if (!value || typeof value !== 'object') return undefined;\n const found = (value as Record<string, unknown>)[key];\n return typeof found === 'string' ? found : undefined;\n}\n\nexport interface EditTrackerOptions {\n /** Injectable so a test can pin the recorded timestamps. */\n now?: () => number;\n /**\n * Decides whether a first-seen scan candidate actually changed. Optional:\n * without it the tracker keeps recording every candidate it cannot disprove.\n */\n git?: GitStatusPort;\n}\n\n/**\n * How far before a call started a write may claim to have happened and still be\n * attributed to it. A working tree can sit on a filesystem that stores whole\n * seconds, which floors a write made just after the call began to a stamp just\n * before it, and the cost of being generous here is one second of the staleness\n * this check exists to remove.\n */\nconst MODIFIED_TOLERANCE_MS = 1000;\n\nexport class EditTracker implements IEditTracker {\n private readonly pending = new Map<string, PendingEdit>();\n /** Start time per in-flight bash call, so its end knows what it may claim. */\n private readonly bracketed = new Map<string, number>();\n /**\n * The tree as this tracker last saw it. A bash call compares against it and\n * then replaces it, so each call costs one walk rather than two, and a change\n * made between calls is still noticed on the next one.\n */\n private manifest: TreeManifest | undefined;\n /**\n * This package's own storage, which the walk must never report. The timeline\n * and its snapshots can land inside the tree being watched, and recording\n * them would make every change cause another one.\n */\n private excluded: readonly string[] = [];\n /**\n * The last content hash this session saw per path, from either mechanism.\n * A candidate whose hash has not moved was touched, not edited.\n */\n private readonly contents = new Map<string, string>();\n private readonly now: () => number;\n private readonly git: GitStatusPort | undefined;\n\n constructor(\n private readonly timeline: ITimelineStore,\n private readonly snapshots: SnapshotStorePort,\n private readonly manifests: TreeManifestPort,\n options: EditTrackerOptions = {},\n ) {\n this.now = options.now ?? Date.now;\n this.git = options.git;\n }\n\n /**\n * Forgets the previous session's tree so a new one does not inherit its\n * baseline, and takes the paths this session's own bookkeeping occupies.\n */\n reset(options: { exclude?: readonly string[] } = {}): void {\n this.pending.clear();\n this.bracketed.clear();\n this.contents.clear();\n this.manifest = undefined;\n this.excluded = options.exclude ?? [];\n }\n\n async start(id: string, tool: string, args: unknown, cwd: string): Promise<void> {\n if (tool === EDIT_TOOL || tool === WRITE_TOOL) {\n const supplied = objectValue(args, 'path');\n if (!supplied) return;\n const filePath = path.resolve(cwd, supplied);\n this.pending.set(id, { tool, filePath, before: await this.snapshots.capture(filePath) });\n return;\n }\n if (tool !== BASH_TOOL) return;\n // Read before the baseline walk, which is the one thing here that can take\n // long enough to matter: a write that races it belongs to this call.\n this.bracketed.set(id, this.now());\n // The first bash call of a session has nothing to compare against, so it\n // pays for the baseline walk; every later call reuses the previous end.\n this.manifest ??= await this.manifests.take(cwd, this.excluded);\n }\n\n async end(id: string, isError: boolean, cwd: string): Promise<void> {\n const pending = this.pending.get(id);\n this.pending.delete(id);\n const startedAt = this.bracketed.get(id);\n this.bracketed.delete(id);\n if (isError) return;\n if (pending) {\n await this.recordTool(pending);\n return;\n }\n if (startedAt !== undefined) await this.recordScan(cwd, startedAt);\n }\n\n /** An `edit` or `write` whose file was read on both sides of the call. */\n private async recordTool(pending: PendingEdit): Promise<void> {\n const after = await this.snapshots.capture(pending.filePath);\n const counts = await this.countChanges(pending.before, after);\n if (after !== undefined) this.contents.set(pending.filePath, after);\n await this.append({\n version: 2,\n path: pending.filePath,\n tool: pending.tool,\n at: this.now(),\n origin: 'tool',\n ...(pending.before === undefined ? {} : { before: pending.before }),\n ...(after === undefined ? {} : { after }),\n ...counts,\n });\n // The tool already accounted for this path, so the next bash comparison\n // must not report it a second time under its own name.\n await this.remember(pending.filePath);\n }\n\n /** Whatever a bash call actually edited, however the command wrote it. */\n private async recordScan(cwd: string, startedAt: number): Promise<void> {\n const before = this.manifest;\n if (before === undefined) return;\n const after = await this.manifests.take(cwd, this.excluded);\n this.manifest = after;\n const candidates = await this.writtenDuring(this.manifests.changed(before, after), startedAt);\n if (candidates.length === 0) return;\n const unchanged = await this.unchangedInGit(cwd, candidates);\n for (const filePath of candidates) {\n const captured = await this.snapshots.capture(filePath);\n const known = this.contents.get(filePath);\n if (captured !== undefined) this.contents.set(filePath, captured);\n // The bytes are the ones this session already recorded: the call moved the\n // modification time and nothing else.\n if (captured !== undefined && captured === known) continue;\n // Never captured here, but git still holds the file and says it matches.\n if (known === undefined && unchanged.has(filePath)) continue;\n await this.append({\n version: 2,\n path: filePath,\n tool: BASH_TOOL,\n at: this.now(),\n origin: 'scan',\n verified: true,\n ...(captured === undefined ? {} : { after: captured }),\n });\n }\n }\n\n /**\n * Narrows candidates to the ones that could have been written by the call\n * being closed.\n *\n * This is what keeps a working tree that was already dirty out of the list. A\n * file whose bytes were last written before the call began was differed by\n * something else, whatever moved its fingerprint. A file that is no longer\n * there has no time to read and a delete is a change the call may well have\n * made, so it stays a candidate.\n */\n private async writtenDuring(candidates: readonly string[], startedAt: number): Promise<string[]> {\n const floor = startedAt - MODIFIED_TOLERANCE_MS;\n const written: string[] = [];\n for (const filePath of candidates) {\n const modifiedAt = await this.manifests.modifiedAt(filePath);\n if (modifiedAt === undefined || modifiedAt >= floor) written.push(filePath);\n }\n return written;\n }\n\n /** Which candidates git tracks and reports as untouched; empty when it cannot say. */\n private async unchangedInGit(cwd: string, candidates: readonly string[]): Promise<ReadonlySet<string>> {\n const unseen = candidates.filter((filePath) => !this.contents.has(filePath));\n if (this.git === undefined || unseen.length === 0) return new Set();\n return this.git.unchanged(cwd, unseen);\n }\n\n /** How many lines moved, when both sides were captured. */\n private async countChanges(\n before: string | undefined,\n after: string | undefined,\n ): Promise<{ additions?: number; removals?: number }> {\n if (before === undefined || after === undefined) return {};\n if (before === after) return { additions: 0, removals: 0 };\n const [beforeText, afterText] = await Promise.all([this.snapshots.read(before), this.snapshots.read(after)]);\n if (beforeText === undefined || afterText === undefined) return {};\n const diff = lineDiff(beforeText, afterText);\n return { additions: diff.additions, removals: diff.removals };\n }\n\n /** Records the change unless it turned out to be no change at all. */\n private async append(event: TimelineEvent): Promise<void> {\n if (event.before !== undefined && event.before === event.after) return;\n await this.timeline.append(event);\n }\n\n /**\n * Folds a path the tool layer just handled into the tracked manifest, so the\n * next bash comparison sees it as already accounted for.\n *\n * The fingerprint has to be the one a walk would read, not a stand-in, or the\n * next comparison reports the file again under bash and every tool edit is\n * recorded twice.\n */\n private async remember(filePath: string): Promise<void> {\n if (this.manifest === undefined) return;\n const entries = new Map(this.manifest.entries);\n const fingerprint = await this.manifests.fingerprint(filePath);\n if (fingerprint === undefined) entries.delete(filePath);\n else entries.set(filePath, fingerprint);\n this.manifest = { entries, truncated: this.manifest.truncated };\n }\n}\n\n/** The tools this tracker knows how to attribute a change to. */\nexport const TRACKED_TOOLS: readonly FileEditTool[] = [EDIT_TOOL, WRITE_TOOL, BASH_TOOL];\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- const e=require("../../_virtual/_rolldown/runtime.cjs");let t=require("node:fs/promises");t=e.__toESM(t,1);let n=require("node:path");n=e.__toESM(n,1);const r=new Set([`.git`,`.hg`,`.svn`,`.cache`,`.gradle`,`.next`,`.nuxt`,`.nx`,`.pnpm-store`,`.turbo`,`.venv`,`__pycache__`,`build`,`coverage`,`dist`,`node_modules`,`out`,`target`,`vendor`,`venv`]);function i(e,t){return`${e}:${t}`}var a=class{maxEntries;maxDepth;ignored;constructor(e={}){this.maxEntries=e.maxEntries??2e4,this.maxDepth=e.maxDepth??12,this.ignored=e.ignoredDirectories??r}async take(e,t=[]){let r=new Map,i=new Set(t.map(e=>n.default.resolve(e)));return{entries:r,truncated:await this.walk(e,0,r,i)}}async fingerprint(e){try{let n=await t.default.stat(e);return n.isFile()?i(n.size,n.mtimeMs):void 0}catch{return}}changed(e,t){let n=new Set;for(let[r,i]of t.entries)e.entries.get(r)!==i&&n.add(r);for(let r of e.entries.keys())t.entries.has(r)||n.add(r);return[...n].sort()}async walk(e,r,a,o){if(r>this.maxDepth)return!0;let s;try{s=await t.default.readdir(e,{withFileTypes:!0})}catch{return!1}let c=!1;for(let l of s){if(a.size>=this.maxEntries)return!0;let s=n.default.join(e,l.name);if(!o.has(s)&&!l.isSymbolicLink()){if(l.isDirectory()){if(this.ignored.has(l.name))continue;c=await this.walk(s,r+1,a,o)||c;continue}if(l.isFile())try{let e=await t.default.stat(s);a.set(s,i(e.size,e.mtimeMs))}catch{}}}return c}};exports.IGNORED_DIRECTORIES=r,exports.MAX_ENTRIES=2e4,exports.NodeTreeManifestAdapter=a;
1
+ const e=require("../../_virtual/_rolldown/runtime.cjs");let t=require("node:fs/promises");t=e.__toESM(t,1);let n=require("node:path");n=e.__toESM(n,1);const r=new Set([`.git`,`.hg`,`.svn`,`.cache`,`.gradle`,`.next`,`.nuxt`,`.nx`,`.pnpm-store`,`.turbo`,`.venv`,`__pycache__`,`build`,`coverage`,`dist`,`node_modules`,`out`,`target`,`vendor`,`venv`]);function i(e,t){return`${e}:${t}`}var a=class{maxEntries;maxDepth;ignored;constructor(e={}){this.maxEntries=e.maxEntries??2e4,this.maxDepth=e.maxDepth??12,this.ignored=e.ignoredDirectories??r}async take(e,t=[]){let r=new Map,i=new Set(t.map(e=>n.default.resolve(e)));return{entries:r,truncated:await this.walk(e,0,r,i)}}async fingerprint(e){try{let n=await t.default.stat(e);return n.isFile()?i(n.size,n.mtimeMs):void 0}catch{return}}async modifiedAt(e){try{let n=await t.default.stat(e);return n.isFile()?n.mtimeMs:void 0}catch{return}}changed(e,t){let n=new Set;for(let[r,i]of t.entries)e.entries.get(r)!==i&&n.add(r);for(let r of e.entries.keys())t.entries.has(r)||n.add(r);return[...n].sort()}async walk(e,r,a,o){if(r>this.maxDepth)return!0;let s;try{s=await t.default.readdir(e,{withFileTypes:!0})}catch{return!1}let c=!1;for(let l of s){if(a.size>=this.maxEntries)return!0;let s=n.default.join(e,l.name);if(!o.has(s)&&!l.isSymbolicLink()){if(l.isDirectory()){if(this.ignored.has(l.name))continue;c=await this.walk(s,r+1,a,o)||c;continue}if(l.isFile())try{let e=await t.default.stat(s);a.set(s,i(e.size,e.mtimeMs))}catch{}}}return c}};exports.IGNORED_DIRECTORIES=r,exports.MAX_ENTRIES=2e4,exports.NodeTreeManifestAdapter=a;
2
2
  //# sourceMappingURL=treeManifest.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"treeManifest.cjs","names":["path","fs"],"sources":["../../../src/adapters/node/treeManifest.ts"],"sourcesContent":["import type { Dirent } from 'node:fs';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { TreeManifest, TreeManifestPort } from '../../types/treeManifest.ts';\n\n/**\n * Take a bounded manifest of a working tree and report which files moved\n * between two of them.\n *\n * One technology per adapter. This is where node enters the package; the\n * services that depend on the capability keep importing the port.\n *\n * The caps are the whole design. An unbounded walk of a working directory is\n * dominated by dependency and build trees, which change constantly and tell a\n * reader nothing, so those are skipped by name; what remains is capped on both\n * entries and depth so the walk stays a fixed cost rather than a function of\n * how large the checkout grew. Hitting a cap is reported, never hidden.\n */\n\n/** Directories a working tree fills with output nobody wants listed as an edit. */\nexport const IGNORED_DIRECTORIES: ReadonlySet<string> = new Set([\n '.git',\n '.hg',\n '.svn',\n '.cache',\n '.gradle',\n '.next',\n '.nuxt',\n '.nx',\n '.pnpm-store',\n '.turbo',\n '.venv',\n '__pycache__',\n 'build',\n 'coverage',\n 'dist',\n 'node_modules',\n 'out',\n 'target',\n 'vendor',\n 'venv',\n]);\n\nexport const MAX_ENTRIES = 20_000;\nexport const MAX_DEPTH = 12;\n\n/**\n * How a file is summarised. Size and modification time are what a walk can read\n * cheaply for thousands of files; hashing every one of them would turn a scan\n * into a read of the whole tree.\n */\nfunction fingerprintOf(size: number, modifiedMs: number): string {\n return `${size}:${modifiedMs}`;\n}\n\nexport interface NodeTreeManifestOptions {\n maxEntries?: number;\n maxDepth?: number;\n ignoredDirectories?: ReadonlySet<string>;\n}\n\nexport class NodeTreeManifestAdapter implements TreeManifestPort {\n private readonly maxEntries: number;\n private readonly maxDepth: number;\n private readonly ignored: ReadonlySet<string>;\n\n constructor(options: NodeTreeManifestOptions = {}) {\n this.maxEntries = options.maxEntries ?? MAX_ENTRIES;\n this.maxDepth = options.maxDepth ?? MAX_DEPTH;\n this.ignored = options.ignoredDirectories ?? IGNORED_DIRECTORIES;\n }\n\n async take(root: string, exclude: readonly string[] = []): Promise<TreeManifest> {\n const entries = new Map<string, string>();\n const skipped = new Set(exclude.map((entry) => path.resolve(entry)));\n const truncated = await this.walk(root, 0, entries, skipped);\n return { entries, truncated };\n }\n\n async fingerprint(filePath: string): Promise<string | undefined> {\n try {\n const stat = await fs.stat(filePath);\n return stat.isFile() ? fingerprintOf(stat.size, stat.mtimeMs) : undefined;\n } catch {\n return undefined;\n }\n }\n\n changed(before: TreeManifest, after: TreeManifest): string[] {\n const moved = new Set<string>();\n for (const [filePath, fingerprint] of after.entries) {\n if (before.entries.get(filePath) !== fingerprint) moved.add(filePath);\n }\n for (const filePath of before.entries.keys()) {\n if (!after.entries.has(filePath)) moved.add(filePath);\n }\n return [...moved].sort();\n }\n\n /** Fills `entries` depth-first and answers whether a cap cut the walk short. */\n private async walk(\n directory: string,\n depth: number,\n entries: Map<string, string>,\n skipped: ReadonlySet<string>,\n ): Promise<boolean> {\n if (depth > this.maxDepth) return true;\n let listing: Dirent[];\n try {\n listing = await fs.readdir(directory, { withFileTypes: true });\n } catch {\n // A directory that vanished or refused a read is not the walk's problem;\n // the rest of the tree is still worth reporting.\n return false;\n }\n let truncated = false;\n for (const entry of listing) {\n if (entries.size >= this.maxEntries) return true;\n const entryPath = path.join(directory, entry.name);\n if (skipped.has(entryPath)) continue;\n // Symlinks are never followed: a link into a parent turns the walk into a\n // cycle, and a link out of the tree reports a file this session does not own.\n if (entry.isSymbolicLink()) continue;\n if (entry.isDirectory()) {\n if (this.ignored.has(entry.name)) continue;\n truncated = (await this.walk(entryPath, depth + 1, entries, skipped)) || truncated;\n continue;\n }\n if (!entry.isFile()) continue;\n try {\n const stat = await fs.stat(entryPath);\n entries.set(entryPath, fingerprintOf(stat.size, stat.mtimeMs));\n } catch {\n // Raced with a delete between readdir and stat; it is simply not there.\n }\n }\n return truncated;\n }\n}\n"],"mappings":"uJAoBA,MAAa,EAA2C,IAAI,IAAI,CAC9D,OACA,MACA,OACA,SACA,UACA,QACA,QACA,MACA,cACA,SACA,QACA,cACA,QACA,WACA,OACA,eACA,MACA,SACA,SACA,MACF,CAAC,EAUD,SAAS,EAAc,EAAc,EAA4B,CAC/D,MAAO,GAAG,EAAK,GAAG,GACpB,CAQA,IAAa,EAAb,KAAiE,CAC/D,WACA,SACA,QAEA,YAAY,EAAmC,CAAC,EAAG,CACjD,KAAK,WAAa,EAAQ,YAAA,IAC1B,KAAK,SAAW,EAAQ,UAAA,GACxB,KAAK,QAAU,EAAQ,oBAAsB,CAC/C,CAEA,MAAM,KAAK,EAAc,EAA6B,CAAC,EAA0B,CAC/E,IAAM,EAAU,IAAI,IACd,EAAU,IAAI,IAAI,EAAQ,IAAK,GAAUA,EAAAA,QAAK,QAAQ,CAAK,CAAC,CAAC,EAEnE,MAAO,CAAE,UAAS,UAAA,MADM,KAAK,KAAK,EAAM,EAAG,EAAS,CAAO,CAC/B,CAC9B,CAEA,MAAM,YAAY,EAA+C,CAC/D,GAAI,CACF,IAAM,EAAO,MAAMC,EAAAA,QAAG,KAAK,CAAQ,EACnC,OAAO,EAAK,OAAO,EAAI,EAAc,EAAK,KAAM,EAAK,OAAO,EAAI,IAAA,EAClE,MAAQ,CACN,MACF,CACF,CAEA,QAAQ,EAAsB,EAA+B,CAC3D,IAAM,EAAQ,IAAI,IAClB,IAAK,GAAM,CAAC,EAAU,KAAgB,EAAM,QACtC,EAAO,QAAQ,IAAI,CAAQ,IAAM,GAAa,EAAM,IAAI,CAAQ,EAEtE,IAAK,IAAM,KAAY,EAAO,QAAQ,KAAK,EACpC,EAAM,QAAQ,IAAI,CAAQ,GAAG,EAAM,IAAI,CAAQ,EAEtD,MAAO,CAAC,GAAG,CAAK,CAAC,CAAC,KAAK,CACzB,CAGA,MAAc,KACZ,EACA,EACA,EACA,EACkB,CAClB,GAAI,EAAQ,KAAK,SAAU,MAAO,GAClC,IAAI,EACJ,GAAI,CACF,EAAU,MAAMA,EAAAA,QAAG,QAAQ,EAAW,CAAE,cAAe,EAAK,CAAC,CAC/D,MAAQ,CAGN,MAAO,EACT,CACA,IAAI,EAAY,GAChB,IAAK,IAAM,KAAS,EAAS,CAC3B,GAAI,EAAQ,MAAQ,KAAK,WAAY,MAAO,GAC5C,IAAM,EAAYD,EAAAA,QAAK,KAAK,EAAW,EAAM,IAAI,EAC7C,MAAQ,IAAI,CAAS,GAGrB,GAAM,eAAe,EACzB,IAAI,EAAM,YAAY,EAAG,CACvB,GAAI,KAAK,QAAQ,IAAI,EAAM,IAAI,EAAG,SAClC,EAAa,MAAM,KAAK,KAAK,EAAW,EAAQ,EAAG,EAAS,CAAO,GAAM,EACzE,QACF,CACK,KAAM,OAAO,EAClB,GAAI,CACF,IAAM,EAAO,MAAMC,EAAAA,QAAG,KAAK,CAAS,EACpC,EAAQ,IAAI,EAAW,EAAc,EAAK,KAAM,EAAK,OAAO,CAAC,CAC/D,MAAQ,CAER,CAPA,CAQF,CACA,OAAO,CACT,CACF"}
1
+ {"version":3,"file":"treeManifest.cjs","names":["path","fs"],"sources":["../../../src/adapters/node/treeManifest.ts"],"sourcesContent":["import type { Dirent } from 'node:fs';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { TreeManifest, TreeManifestPort } from '../../types/treeManifest.ts';\n\n/**\n * Take a bounded manifest of a working tree and report which files moved\n * between two of them.\n *\n * One technology per adapter. This is where node enters the package; the\n * services that depend on the capability keep importing the port.\n *\n * The caps are the whole design. An unbounded walk of a working directory is\n * dominated by dependency and build trees, which change constantly and tell a\n * reader nothing, so those are skipped by name; what remains is capped on both\n * entries and depth so the walk stays a fixed cost rather than a function of\n * how large the checkout grew. Hitting a cap is reported, never hidden.\n */\n\n/** Directories a working tree fills with output nobody wants listed as an edit. */\nexport const IGNORED_DIRECTORIES: ReadonlySet<string> = new Set([\n '.git',\n '.hg',\n '.svn',\n '.cache',\n '.gradle',\n '.next',\n '.nuxt',\n '.nx',\n '.pnpm-store',\n '.turbo',\n '.venv',\n '__pycache__',\n 'build',\n 'coverage',\n 'dist',\n 'node_modules',\n 'out',\n 'target',\n 'vendor',\n 'venv',\n]);\n\nexport const MAX_ENTRIES = 20_000;\nexport const MAX_DEPTH = 12;\n\n/**\n * How a file is summarised. Size and modification time are what a walk can read\n * cheaply for thousands of files; hashing every one of them would turn a scan\n * into a read of the whole tree.\n */\nfunction fingerprintOf(size: number, modifiedMs: number): string {\n return `${size}:${modifiedMs}`;\n}\n\nexport interface NodeTreeManifestOptions {\n maxEntries?: number;\n maxDepth?: number;\n ignoredDirectories?: ReadonlySet<string>;\n}\n\nexport class NodeTreeManifestAdapter implements TreeManifestPort {\n private readonly maxEntries: number;\n private readonly maxDepth: number;\n private readonly ignored: ReadonlySet<string>;\n\n constructor(options: NodeTreeManifestOptions = {}) {\n this.maxEntries = options.maxEntries ?? MAX_ENTRIES;\n this.maxDepth = options.maxDepth ?? MAX_DEPTH;\n this.ignored = options.ignoredDirectories ?? IGNORED_DIRECTORIES;\n }\n\n async take(root: string, exclude: readonly string[] = []): Promise<TreeManifest> {\n const entries = new Map<string, string>();\n const skipped = new Set(exclude.map((entry) => path.resolve(entry)));\n const truncated = await this.walk(root, 0, entries, skipped);\n return { entries, truncated };\n }\n\n async fingerprint(filePath: string): Promise<string | undefined> {\n try {\n const stat = await fs.stat(filePath);\n return stat.isFile() ? fingerprintOf(stat.size, stat.mtimeMs) : undefined;\n } catch {\n return undefined;\n }\n }\n\n async modifiedAt(filePath: string): Promise<number | undefined> {\n try {\n const stat = await fs.stat(filePath);\n return stat.isFile() ? stat.mtimeMs : undefined;\n } catch {\n return undefined;\n }\n }\n\n changed(before: TreeManifest, after: TreeManifest): string[] {\n const moved = new Set<string>();\n for (const [filePath, fingerprint] of after.entries) {\n if (before.entries.get(filePath) !== fingerprint) moved.add(filePath);\n }\n for (const filePath of before.entries.keys()) {\n if (!after.entries.has(filePath)) moved.add(filePath);\n }\n return [...moved].sort();\n }\n\n /** Fills `entries` depth-first and answers whether a cap cut the walk short. */\n private async walk(\n directory: string,\n depth: number,\n entries: Map<string, string>,\n skipped: ReadonlySet<string>,\n ): Promise<boolean> {\n if (depth > this.maxDepth) return true;\n let listing: Dirent[];\n try {\n listing = await fs.readdir(directory, { withFileTypes: true });\n } catch {\n // A directory that vanished or refused a read is not the walk's problem;\n // the rest of the tree is still worth reporting.\n return false;\n }\n let truncated = false;\n for (const entry of listing) {\n if (entries.size >= this.maxEntries) return true;\n const entryPath = path.join(directory, entry.name);\n if (skipped.has(entryPath)) continue;\n // Symlinks are never followed: a link into a parent turns the walk into a\n // cycle, and a link out of the tree reports a file this session does not own.\n if (entry.isSymbolicLink()) continue;\n if (entry.isDirectory()) {\n if (this.ignored.has(entry.name)) continue;\n truncated = (await this.walk(entryPath, depth + 1, entries, skipped)) || truncated;\n continue;\n }\n if (!entry.isFile()) continue;\n try {\n const stat = await fs.stat(entryPath);\n entries.set(entryPath, fingerprintOf(stat.size, stat.mtimeMs));\n } catch {\n // Raced with a delete between readdir and stat; it is simply not there.\n }\n }\n return truncated;\n }\n}\n"],"mappings":"uJAoBA,MAAa,EAA2C,IAAI,IAAI,CAC9D,OACA,MACA,OACA,SACA,UACA,QACA,QACA,MACA,cACA,SACA,QACA,cACA,QACA,WACA,OACA,eACA,MACA,SACA,SACA,MACF,CAAC,EAUD,SAAS,EAAc,EAAc,EAA4B,CAC/D,MAAO,GAAG,EAAK,GAAG,GACpB,CAQA,IAAa,EAAb,KAAiE,CAC/D,WACA,SACA,QAEA,YAAY,EAAmC,CAAC,EAAG,CACjD,KAAK,WAAa,EAAQ,YAAA,IAC1B,KAAK,SAAW,EAAQ,UAAA,GACxB,KAAK,QAAU,EAAQ,oBAAsB,CAC/C,CAEA,MAAM,KAAK,EAAc,EAA6B,CAAC,EAA0B,CAC/E,IAAM,EAAU,IAAI,IACd,EAAU,IAAI,IAAI,EAAQ,IAAK,GAAUA,EAAAA,QAAK,QAAQ,CAAK,CAAC,CAAC,EAEnE,MAAO,CAAE,UAAS,UAAA,MADM,KAAK,KAAK,EAAM,EAAG,EAAS,CAAO,CAC/B,CAC9B,CAEA,MAAM,YAAY,EAA+C,CAC/D,GAAI,CACF,IAAM,EAAO,MAAMC,EAAAA,QAAG,KAAK,CAAQ,EACnC,OAAO,EAAK,OAAO,EAAI,EAAc,EAAK,KAAM,EAAK,OAAO,EAAI,IAAA,EAClE,MAAQ,CACN,MACF,CACF,CAEA,MAAM,WAAW,EAA+C,CAC9D,GAAI,CACF,IAAM,EAAO,MAAMA,EAAAA,QAAG,KAAK,CAAQ,EACnC,OAAO,EAAK,OAAO,EAAI,EAAK,QAAU,IAAA,EACxC,MAAQ,CACN,MACF,CACF,CAEA,QAAQ,EAAsB,EAA+B,CAC3D,IAAM,EAAQ,IAAI,IAClB,IAAK,GAAM,CAAC,EAAU,KAAgB,EAAM,QACtC,EAAO,QAAQ,IAAI,CAAQ,IAAM,GAAa,EAAM,IAAI,CAAQ,EAEtE,IAAK,IAAM,KAAY,EAAO,QAAQ,KAAK,EACpC,EAAM,QAAQ,IAAI,CAAQ,GAAG,EAAM,IAAI,CAAQ,EAEtD,MAAO,CAAC,GAAG,CAAK,CAAC,CAAC,KAAK,CACzB,CAGA,MAAc,KACZ,EACA,EACA,EACA,EACkB,CAClB,GAAI,EAAQ,KAAK,SAAU,MAAO,GAClC,IAAI,EACJ,GAAI,CACF,EAAU,MAAMA,EAAAA,QAAG,QAAQ,EAAW,CAAE,cAAe,EAAK,CAAC,CAC/D,MAAQ,CAGN,MAAO,EACT,CACA,IAAI,EAAY,GAChB,IAAK,IAAM,KAAS,EAAS,CAC3B,GAAI,EAAQ,MAAQ,KAAK,WAAY,MAAO,GAC5C,IAAM,EAAYD,EAAAA,QAAK,KAAK,EAAW,EAAM,IAAI,EAC7C,MAAQ,IAAI,CAAS,GAGrB,GAAM,eAAe,EACzB,IAAI,EAAM,YAAY,EAAG,CACvB,GAAI,KAAK,QAAQ,IAAI,EAAM,IAAI,EAAG,SAClC,EAAa,MAAM,KAAK,KAAK,EAAW,EAAQ,EAAG,EAAS,CAAO,GAAM,EACzE,QACF,CACK,KAAM,OAAO,EAClB,GAAI,CACF,IAAM,EAAO,MAAMC,EAAAA,QAAG,KAAK,CAAS,EACpC,EAAQ,IAAI,EAAW,EAAc,EAAK,KAAM,EAAK,OAAO,CAAC,CAC/D,MAAQ,CAER,CAPA,CAQF,CACA,OAAO,CACT,CACF"}
@@ -1,2 +1,2 @@
1
- import e from"node:fs/promises";import t from"node:path";const n=new Set([`.git`,`.hg`,`.svn`,`.cache`,`.gradle`,`.next`,`.nuxt`,`.nx`,`.pnpm-store`,`.turbo`,`.venv`,`__pycache__`,`build`,`coverage`,`dist`,`node_modules`,`out`,`target`,`vendor`,`venv`]),r=2e4;function i(e,t){return`${e}:${t}`}var a=class{maxEntries;maxDepth;ignored;constructor(e={}){this.maxEntries=e.maxEntries??2e4,this.maxDepth=e.maxDepth??12,this.ignored=e.ignoredDirectories??n}async take(e,n=[]){let r=new Map,i=new Set(n.map(e=>t.resolve(e)));return{entries:r,truncated:await this.walk(e,0,r,i)}}async fingerprint(t){try{let n=await e.stat(t);return n.isFile()?i(n.size,n.mtimeMs):void 0}catch{return}}changed(e,t){let n=new Set;for(let[r,i]of t.entries)e.entries.get(r)!==i&&n.add(r);for(let r of e.entries.keys())t.entries.has(r)||n.add(r);return[...n].sort()}async walk(n,r,a,o){if(r>this.maxDepth)return!0;let s;try{s=await e.readdir(n,{withFileTypes:!0})}catch{return!1}let c=!1;for(let l of s){if(a.size>=this.maxEntries)return!0;let s=t.join(n,l.name);if(!o.has(s)&&!l.isSymbolicLink()){if(l.isDirectory()){if(this.ignored.has(l.name))continue;c=await this.walk(s,r+1,a,o)||c;continue}if(l.isFile())try{let t=await e.stat(s);a.set(s,i(t.size,t.mtimeMs))}catch{}}}return c}};export{n as IGNORED_DIRECTORIES,r as MAX_ENTRIES,a as NodeTreeManifestAdapter};
1
+ import e from"node:fs/promises";import t from"node:path";const n=new Set([`.git`,`.hg`,`.svn`,`.cache`,`.gradle`,`.next`,`.nuxt`,`.nx`,`.pnpm-store`,`.turbo`,`.venv`,`__pycache__`,`build`,`coverage`,`dist`,`node_modules`,`out`,`target`,`vendor`,`venv`]),r=2e4;function i(e,t){return`${e}:${t}`}var a=class{maxEntries;maxDepth;ignored;constructor(e={}){this.maxEntries=e.maxEntries??2e4,this.maxDepth=e.maxDepth??12,this.ignored=e.ignoredDirectories??n}async take(e,n=[]){let r=new Map,i=new Set(n.map(e=>t.resolve(e)));return{entries:r,truncated:await this.walk(e,0,r,i)}}async fingerprint(t){try{let n=await e.stat(t);return n.isFile()?i(n.size,n.mtimeMs):void 0}catch{return}}async modifiedAt(t){try{let n=await e.stat(t);return n.isFile()?n.mtimeMs:void 0}catch{return}}changed(e,t){let n=new Set;for(let[r,i]of t.entries)e.entries.get(r)!==i&&n.add(r);for(let r of e.entries.keys())t.entries.has(r)||n.add(r);return[...n].sort()}async walk(n,r,a,o){if(r>this.maxDepth)return!0;let s;try{s=await e.readdir(n,{withFileTypes:!0})}catch{return!1}let c=!1;for(let l of s){if(a.size>=this.maxEntries)return!0;let s=t.join(n,l.name);if(!o.has(s)&&!l.isSymbolicLink()){if(l.isDirectory()){if(this.ignored.has(l.name))continue;c=await this.walk(s,r+1,a,o)||c;continue}if(l.isFile())try{let t=await e.stat(s);a.set(s,i(t.size,t.mtimeMs))}catch{}}}return c}};export{n as IGNORED_DIRECTORIES,r as MAX_ENTRIES,a as NodeTreeManifestAdapter};
2
2
  //# sourceMappingURL=treeManifest.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"treeManifest.mjs","names":[],"sources":["../../../src/adapters/node/treeManifest.ts"],"sourcesContent":["import type { Dirent } from 'node:fs';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { TreeManifest, TreeManifestPort } from '../../types/treeManifest.ts';\n\n/**\n * Take a bounded manifest of a working tree and report which files moved\n * between two of them.\n *\n * One technology per adapter. This is where node enters the package; the\n * services that depend on the capability keep importing the port.\n *\n * The caps are the whole design. An unbounded walk of a working directory is\n * dominated by dependency and build trees, which change constantly and tell a\n * reader nothing, so those are skipped by name; what remains is capped on both\n * entries and depth so the walk stays a fixed cost rather than a function of\n * how large the checkout grew. Hitting a cap is reported, never hidden.\n */\n\n/** Directories a working tree fills with output nobody wants listed as an edit. */\nexport const IGNORED_DIRECTORIES: ReadonlySet<string> = new Set([\n '.git',\n '.hg',\n '.svn',\n '.cache',\n '.gradle',\n '.next',\n '.nuxt',\n '.nx',\n '.pnpm-store',\n '.turbo',\n '.venv',\n '__pycache__',\n 'build',\n 'coverage',\n 'dist',\n 'node_modules',\n 'out',\n 'target',\n 'vendor',\n 'venv',\n]);\n\nexport const MAX_ENTRIES = 20_000;\nexport const MAX_DEPTH = 12;\n\n/**\n * How a file is summarised. Size and modification time are what a walk can read\n * cheaply for thousands of files; hashing every one of them would turn a scan\n * into a read of the whole tree.\n */\nfunction fingerprintOf(size: number, modifiedMs: number): string {\n return `${size}:${modifiedMs}`;\n}\n\nexport interface NodeTreeManifestOptions {\n maxEntries?: number;\n maxDepth?: number;\n ignoredDirectories?: ReadonlySet<string>;\n}\n\nexport class NodeTreeManifestAdapter implements TreeManifestPort {\n private readonly maxEntries: number;\n private readonly maxDepth: number;\n private readonly ignored: ReadonlySet<string>;\n\n constructor(options: NodeTreeManifestOptions = {}) {\n this.maxEntries = options.maxEntries ?? MAX_ENTRIES;\n this.maxDepth = options.maxDepth ?? MAX_DEPTH;\n this.ignored = options.ignoredDirectories ?? IGNORED_DIRECTORIES;\n }\n\n async take(root: string, exclude: readonly string[] = []): Promise<TreeManifest> {\n const entries = new Map<string, string>();\n const skipped = new Set(exclude.map((entry) => path.resolve(entry)));\n const truncated = await this.walk(root, 0, entries, skipped);\n return { entries, truncated };\n }\n\n async fingerprint(filePath: string): Promise<string | undefined> {\n try {\n const stat = await fs.stat(filePath);\n return stat.isFile() ? fingerprintOf(stat.size, stat.mtimeMs) : undefined;\n } catch {\n return undefined;\n }\n }\n\n changed(before: TreeManifest, after: TreeManifest): string[] {\n const moved = new Set<string>();\n for (const [filePath, fingerprint] of after.entries) {\n if (before.entries.get(filePath) !== fingerprint) moved.add(filePath);\n }\n for (const filePath of before.entries.keys()) {\n if (!after.entries.has(filePath)) moved.add(filePath);\n }\n return [...moved].sort();\n }\n\n /** Fills `entries` depth-first and answers whether a cap cut the walk short. */\n private async walk(\n directory: string,\n depth: number,\n entries: Map<string, string>,\n skipped: ReadonlySet<string>,\n ): Promise<boolean> {\n if (depth > this.maxDepth) return true;\n let listing: Dirent[];\n try {\n listing = await fs.readdir(directory, { withFileTypes: true });\n } catch {\n // A directory that vanished or refused a read is not the walk's problem;\n // the rest of the tree is still worth reporting.\n return false;\n }\n let truncated = false;\n for (const entry of listing) {\n if (entries.size >= this.maxEntries) return true;\n const entryPath = path.join(directory, entry.name);\n if (skipped.has(entryPath)) continue;\n // Symlinks are never followed: a link into a parent turns the walk into a\n // cycle, and a link out of the tree reports a file this session does not own.\n if (entry.isSymbolicLink()) continue;\n if (entry.isDirectory()) {\n if (this.ignored.has(entry.name)) continue;\n truncated = (await this.walk(entryPath, depth + 1, entries, skipped)) || truncated;\n continue;\n }\n if (!entry.isFile()) continue;\n try {\n const stat = await fs.stat(entryPath);\n entries.set(entryPath, fingerprintOf(stat.size, stat.mtimeMs));\n } catch {\n // Raced with a delete between readdir and stat; it is simply not there.\n }\n }\n return truncated;\n }\n}\n"],"mappings":"yDAoBA,MAAa,EAA2C,IAAI,IAAI,CAC9D,OACA,MACA,OACA,SACA,UACA,QACA,QACA,MACA,cACA,SACA,QACA,cACA,QACA,WACA,OACA,eACA,MACA,SACA,SACA,MACF,CAAC,EAEY,EAAc,IAQ3B,SAAS,EAAc,EAAc,EAA4B,CAC/D,MAAO,GAAG,EAAK,GAAG,GACpB,CAQA,IAAa,EAAb,KAAiE,CAC/D,WACA,SACA,QAEA,YAAY,EAAmC,CAAC,EAAG,CACjD,KAAK,WAAa,EAAQ,YAAA,IAC1B,KAAK,SAAW,EAAQ,UAAA,GACxB,KAAK,QAAU,EAAQ,oBAAsB,CAC/C,CAEA,MAAM,KAAK,EAAc,EAA6B,CAAC,EAA0B,CAC/E,IAAM,EAAU,IAAI,IACd,EAAU,IAAI,IAAI,EAAQ,IAAK,GAAU,EAAK,QAAQ,CAAK,CAAC,CAAC,EAEnE,MAAO,CAAE,UAAS,UAAA,MADM,KAAK,KAAK,EAAM,EAAG,EAAS,CAAO,CAC/B,CAC9B,CAEA,MAAM,YAAY,EAA+C,CAC/D,GAAI,CACF,IAAM,EAAO,MAAM,EAAG,KAAK,CAAQ,EACnC,OAAO,EAAK,OAAO,EAAI,EAAc,EAAK,KAAM,EAAK,OAAO,EAAI,IAAA,EAClE,MAAQ,CACN,MACF,CACF,CAEA,QAAQ,EAAsB,EAA+B,CAC3D,IAAM,EAAQ,IAAI,IAClB,IAAK,GAAM,CAAC,EAAU,KAAgB,EAAM,QACtC,EAAO,QAAQ,IAAI,CAAQ,IAAM,GAAa,EAAM,IAAI,CAAQ,EAEtE,IAAK,IAAM,KAAY,EAAO,QAAQ,KAAK,EACpC,EAAM,QAAQ,IAAI,CAAQ,GAAG,EAAM,IAAI,CAAQ,EAEtD,MAAO,CAAC,GAAG,CAAK,CAAC,CAAC,KAAK,CACzB,CAGA,MAAc,KACZ,EACA,EACA,EACA,EACkB,CAClB,GAAI,EAAQ,KAAK,SAAU,MAAO,GAClC,IAAI,EACJ,GAAI,CACF,EAAU,MAAM,EAAG,QAAQ,EAAW,CAAE,cAAe,EAAK,CAAC,CAC/D,MAAQ,CAGN,MAAO,EACT,CACA,IAAI,EAAY,GAChB,IAAK,IAAM,KAAS,EAAS,CAC3B,GAAI,EAAQ,MAAQ,KAAK,WAAY,MAAO,GAC5C,IAAM,EAAY,EAAK,KAAK,EAAW,EAAM,IAAI,EAC7C,MAAQ,IAAI,CAAS,GAGrB,GAAM,eAAe,EACzB,IAAI,EAAM,YAAY,EAAG,CACvB,GAAI,KAAK,QAAQ,IAAI,EAAM,IAAI,EAAG,SAClC,EAAa,MAAM,KAAK,KAAK,EAAW,EAAQ,EAAG,EAAS,CAAO,GAAM,EACzE,QACF,CACK,KAAM,OAAO,EAClB,GAAI,CACF,IAAM,EAAO,MAAM,EAAG,KAAK,CAAS,EACpC,EAAQ,IAAI,EAAW,EAAc,EAAK,KAAM,EAAK,OAAO,CAAC,CAC/D,MAAQ,CAER,CAPA,CAQF,CACA,OAAO,CACT,CACF"}
1
+ {"version":3,"file":"treeManifest.mjs","names":[],"sources":["../../../src/adapters/node/treeManifest.ts"],"sourcesContent":["import type { Dirent } from 'node:fs';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { TreeManifest, TreeManifestPort } from '../../types/treeManifest.ts';\n\n/**\n * Take a bounded manifest of a working tree and report which files moved\n * between two of them.\n *\n * One technology per adapter. This is where node enters the package; the\n * services that depend on the capability keep importing the port.\n *\n * The caps are the whole design. An unbounded walk of a working directory is\n * dominated by dependency and build trees, which change constantly and tell a\n * reader nothing, so those are skipped by name; what remains is capped on both\n * entries and depth so the walk stays a fixed cost rather than a function of\n * how large the checkout grew. Hitting a cap is reported, never hidden.\n */\n\n/** Directories a working tree fills with output nobody wants listed as an edit. */\nexport const IGNORED_DIRECTORIES: ReadonlySet<string> = new Set([\n '.git',\n '.hg',\n '.svn',\n '.cache',\n '.gradle',\n '.next',\n '.nuxt',\n '.nx',\n '.pnpm-store',\n '.turbo',\n '.venv',\n '__pycache__',\n 'build',\n 'coverage',\n 'dist',\n 'node_modules',\n 'out',\n 'target',\n 'vendor',\n 'venv',\n]);\n\nexport const MAX_ENTRIES = 20_000;\nexport const MAX_DEPTH = 12;\n\n/**\n * How a file is summarised. Size and modification time are what a walk can read\n * cheaply for thousands of files; hashing every one of them would turn a scan\n * into a read of the whole tree.\n */\nfunction fingerprintOf(size: number, modifiedMs: number): string {\n return `${size}:${modifiedMs}`;\n}\n\nexport interface NodeTreeManifestOptions {\n maxEntries?: number;\n maxDepth?: number;\n ignoredDirectories?: ReadonlySet<string>;\n}\n\nexport class NodeTreeManifestAdapter implements TreeManifestPort {\n private readonly maxEntries: number;\n private readonly maxDepth: number;\n private readonly ignored: ReadonlySet<string>;\n\n constructor(options: NodeTreeManifestOptions = {}) {\n this.maxEntries = options.maxEntries ?? MAX_ENTRIES;\n this.maxDepth = options.maxDepth ?? MAX_DEPTH;\n this.ignored = options.ignoredDirectories ?? IGNORED_DIRECTORIES;\n }\n\n async take(root: string, exclude: readonly string[] = []): Promise<TreeManifest> {\n const entries = new Map<string, string>();\n const skipped = new Set(exclude.map((entry) => path.resolve(entry)));\n const truncated = await this.walk(root, 0, entries, skipped);\n return { entries, truncated };\n }\n\n async fingerprint(filePath: string): Promise<string | undefined> {\n try {\n const stat = await fs.stat(filePath);\n return stat.isFile() ? fingerprintOf(stat.size, stat.mtimeMs) : undefined;\n } catch {\n return undefined;\n }\n }\n\n async modifiedAt(filePath: string): Promise<number | undefined> {\n try {\n const stat = await fs.stat(filePath);\n return stat.isFile() ? stat.mtimeMs : undefined;\n } catch {\n return undefined;\n }\n }\n\n changed(before: TreeManifest, after: TreeManifest): string[] {\n const moved = new Set<string>();\n for (const [filePath, fingerprint] of after.entries) {\n if (before.entries.get(filePath) !== fingerprint) moved.add(filePath);\n }\n for (const filePath of before.entries.keys()) {\n if (!after.entries.has(filePath)) moved.add(filePath);\n }\n return [...moved].sort();\n }\n\n /** Fills `entries` depth-first and answers whether a cap cut the walk short. */\n private async walk(\n directory: string,\n depth: number,\n entries: Map<string, string>,\n skipped: ReadonlySet<string>,\n ): Promise<boolean> {\n if (depth > this.maxDepth) return true;\n let listing: Dirent[];\n try {\n listing = await fs.readdir(directory, { withFileTypes: true });\n } catch {\n // A directory that vanished or refused a read is not the walk's problem;\n // the rest of the tree is still worth reporting.\n return false;\n }\n let truncated = false;\n for (const entry of listing) {\n if (entries.size >= this.maxEntries) return true;\n const entryPath = path.join(directory, entry.name);\n if (skipped.has(entryPath)) continue;\n // Symlinks are never followed: a link into a parent turns the walk into a\n // cycle, and a link out of the tree reports a file this session does not own.\n if (entry.isSymbolicLink()) continue;\n if (entry.isDirectory()) {\n if (this.ignored.has(entry.name)) continue;\n truncated = (await this.walk(entryPath, depth + 1, entries, skipped)) || truncated;\n continue;\n }\n if (!entry.isFile()) continue;\n try {\n const stat = await fs.stat(entryPath);\n entries.set(entryPath, fingerprintOf(stat.size, stat.mtimeMs));\n } catch {\n // Raced with a delete between readdir and stat; it is simply not there.\n }\n }\n return truncated;\n }\n}\n"],"mappings":"yDAoBA,MAAa,EAA2C,IAAI,IAAI,CAC9D,OACA,MACA,OACA,SACA,UACA,QACA,QACA,MACA,cACA,SACA,QACA,cACA,QACA,WACA,OACA,eACA,MACA,SACA,SACA,MACF,CAAC,EAEY,EAAc,IAQ3B,SAAS,EAAc,EAAc,EAA4B,CAC/D,MAAO,GAAG,EAAK,GAAG,GACpB,CAQA,IAAa,EAAb,KAAiE,CAC/D,WACA,SACA,QAEA,YAAY,EAAmC,CAAC,EAAG,CACjD,KAAK,WAAa,EAAQ,YAAA,IAC1B,KAAK,SAAW,EAAQ,UAAA,GACxB,KAAK,QAAU,EAAQ,oBAAsB,CAC/C,CAEA,MAAM,KAAK,EAAc,EAA6B,CAAC,EAA0B,CAC/E,IAAM,EAAU,IAAI,IACd,EAAU,IAAI,IAAI,EAAQ,IAAK,GAAU,EAAK,QAAQ,CAAK,CAAC,CAAC,EAEnE,MAAO,CAAE,UAAS,UAAA,MADM,KAAK,KAAK,EAAM,EAAG,EAAS,CAAO,CAC/B,CAC9B,CAEA,MAAM,YAAY,EAA+C,CAC/D,GAAI,CACF,IAAM,EAAO,MAAM,EAAG,KAAK,CAAQ,EACnC,OAAO,EAAK,OAAO,EAAI,EAAc,EAAK,KAAM,EAAK,OAAO,EAAI,IAAA,EAClE,MAAQ,CACN,MACF,CACF,CAEA,MAAM,WAAW,EAA+C,CAC9D,GAAI,CACF,IAAM,EAAO,MAAM,EAAG,KAAK,CAAQ,EACnC,OAAO,EAAK,OAAO,EAAI,EAAK,QAAU,IAAA,EACxC,MAAQ,CACN,MACF,CACF,CAEA,QAAQ,EAAsB,EAA+B,CAC3D,IAAM,EAAQ,IAAI,IAClB,IAAK,GAAM,CAAC,EAAU,KAAgB,EAAM,QACtC,EAAO,QAAQ,IAAI,CAAQ,IAAM,GAAa,EAAM,IAAI,CAAQ,EAEtE,IAAK,IAAM,KAAY,EAAO,QAAQ,KAAK,EACpC,EAAM,QAAQ,IAAI,CAAQ,GAAG,EAAM,IAAI,CAAQ,EAEtD,MAAO,CAAC,GAAG,CAAK,CAAC,CAAC,KAAK,CACzB,CAGA,MAAc,KACZ,EACA,EACA,EACA,EACkB,CAClB,GAAI,EAAQ,KAAK,SAAU,MAAO,GAClC,IAAI,EACJ,GAAI,CACF,EAAU,MAAM,EAAG,QAAQ,EAAW,CAAE,cAAe,EAAK,CAAC,CAC/D,MAAQ,CAGN,MAAO,EACT,CACA,IAAI,EAAY,GAChB,IAAK,IAAM,KAAS,EAAS,CAC3B,GAAI,EAAQ,MAAQ,KAAK,WAAY,MAAO,GAC5C,IAAM,EAAY,EAAK,KAAK,EAAW,EAAM,IAAI,EAC7C,MAAQ,IAAI,CAAS,GAGrB,GAAM,eAAe,EACzB,IAAI,EAAM,YAAY,EAAG,CACvB,GAAI,KAAK,QAAQ,IAAI,EAAM,IAAI,EAAG,SAClC,EAAa,MAAM,KAAK,KAAK,EAAW,EAAQ,EAAG,EAAS,CAAO,GAAM,EACzE,QACF,CACK,KAAM,OAAO,EAClB,GAAI,CACF,IAAM,EAAO,MAAM,EAAG,KAAK,CAAS,EACpC,EAAQ,IAAI,EAAW,EAAc,EAAK,KAAM,EAAK,OAAO,CAAC,CAC/D,MAAQ,CAER,CAPA,CAQF,CACA,OAAO,CACT,CACF"}
@@ -40,6 +40,15 @@ interface TreeManifestPort {
40
40
  * its manifest so the next walk does not report the same change twice.
41
41
  */
42
42
  fingerprint(filePath: string): Promise<string | undefined>;
43
+ /**
44
+ * When the file was last written, in epoch milliseconds, or undefined when it
45
+ * is not there.
46
+ *
47
+ * A comparison of two manifests only says a file differs between them, not
48
+ * when it moved, so a path left dirty by earlier work looks the same as one a
49
+ * command just wrote. Reading the modification time back tells the two apart.
50
+ */
51
+ modifiedAt(filePath: string): Promise<number | undefined>;
43
52
  /** Every path added, removed, or modified between two manifests, sorted. */
44
53
  changed(before: TreeManifest, after: TreeManifest): string[];
45
54
  }
@@ -1 +1 @@
1
- {"version":3,"file":"treeManifest.d.cts","names":[],"sources":["../../src/types/treeManifest.ts"],"mappings":";;;;;;;;;;;;;;;;;;;UAkBiB;;WAEN,SAAS;;WAET;;UAGM;;;;;;;;;;EAUf,KAAK,cAAc,8BAA8B,QAAQ;;;;;;EAMzD,YAAY,mBAAmB;;EAE/B,QAAQ,QAAQ,cAAc,OAAO"}
1
+ {"version":3,"file":"treeManifest.d.cts","names":[],"sources":["../../src/types/treeManifest.ts"],"mappings":";;;;;;;;;;;;;;;;;;;UAkBiB;;WAEN,SAAS;;WAET;;UAGM;;;;;;;;;;EAUf,KAAK,cAAc,8BAA8B,QAAQ;;;;;;EAMzD,YAAY,mBAAmB;;;;;;;;;EAS/B,WAAW,mBAAmB;;EAE9B,QAAQ,QAAQ,cAAc,OAAO"}
@@ -40,6 +40,15 @@ interface TreeManifestPort {
40
40
  * its manifest so the next walk does not report the same change twice.
41
41
  */
42
42
  fingerprint(filePath: string): Promise<string | undefined>;
43
+ /**
44
+ * When the file was last written, in epoch milliseconds, or undefined when it
45
+ * is not there.
46
+ *
47
+ * A comparison of two manifests only says a file differs between them, not
48
+ * when it moved, so a path left dirty by earlier work looks the same as one a
49
+ * command just wrote. Reading the modification time back tells the two apart.
50
+ */
51
+ modifiedAt(filePath: string): Promise<number | undefined>;
43
52
  /** Every path added, removed, or modified between two manifests, sorted. */
44
53
  changed(before: TreeManifest, after: TreeManifest): string[];
45
54
  }
@@ -1 +1 @@
1
- {"version":3,"file":"treeManifest.d.mts","names":[],"sources":["../../src/types/treeManifest.ts"],"mappings":";;;;;;;;;;;;;;;;;;;UAkBiB;;WAEN,SAAS;;WAET;;UAGM;;;;;;;;;;EAUf,KAAK,cAAc,8BAA8B,QAAQ;;;;;;EAMzD,YAAY,mBAAmB;;EAE/B,QAAQ,QAAQ,cAAc,OAAO"}
1
+ {"version":3,"file":"treeManifest.d.mts","names":[],"sources":["../../src/types/treeManifest.ts"],"mappings":";;;;;;;;;;;;;;;;;;;UAkBiB;;WAEN,SAAS;;WAET;;UAGM;;;;;;;;;;EAUf,KAAK,cAAc,8BAA8B,QAAQ;;;;;;EAMzD,YAAY,mBAAmB;;;;;;;;;EAS/B,WAAW,mBAAmB;;EAE9B,QAAQ,QAAQ,cAAc,OAAO"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agimon-ai/doompi-file-edit",
3
- "version": "0.0.1-alpha.45",
3
+ "version": "0.0.1-alpha.46",
4
4
  "description": "Pi extension for session-scoped file-change timelines and external editor workflows.",
5
5
  "keywords": [
6
6
  "ai",
@@ -27,7 +27,8 @@
27
27
  },
28
28
  "files": [
29
29
  "dist",
30
- "web",
30
+ "src/web",
31
+ "src/exports/webClient.ts",
31
32
  "src/types/domain.ts",
32
33
  "src/types/webFiles.ts",
33
34
  "src/types/fileEditsApi.ts",
@@ -69,13 +70,13 @@
69
70
  "cross-spawn": "7.0.6",
70
71
  "hono": "4.13.5",
71
72
  "ignore": "7.0.6",
72
- "@agimon-ai/doompi-config": "0.0.1-alpha.44",
73
- "@agimon-ai/doompi-telemetry": "0.0.1-alpha.44",
74
- "@agimon-ai/doompi-web-components": "0.0.1-alpha.5",
75
- "@agimon-ai/doompi-ui": "0.0.1-alpha.45",
76
- "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.44",
77
- "@agimon-ai/doompi-web-contracts": "0.0.1-alpha.8",
78
- "@agimon-ai/doompi-web-security": "0.0.1-alpha.6"
73
+ "@agimon-ai/doompi-config": "0.0.1-alpha.45",
74
+ "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.45",
75
+ "@agimon-ai/doompi-web-components": "0.0.1-alpha.6",
76
+ "@agimon-ai/doompi-ui": "0.0.1-alpha.46",
77
+ "@agimon-ai/doompi-telemetry": "0.0.1-alpha.45",
78
+ "@agimon-ai/doompi-web-security": "0.0.1-alpha.7",
79
+ "@agimon-ai/doompi-web-contracts": "0.0.1-alpha.9"
79
80
  },
80
81
  "devDependencies": {
81
82
  "@earendil-works/pi-coding-agent": "0.84.4",
@@ -120,7 +121,7 @@
120
121
  "channels": [
121
122
  "file_edits"
122
123
  ],
123
- "client": "./web/index.ts",
124
+ "client": "./src/exports/webClient.ts",
124
125
  "hub": {
125
126
  "entry": "./src/exports/webHub.ts",
126
127
  "dist": "./dist/webHub.mjs"
@@ -134,7 +135,7 @@
134
135
  "scripts": {
135
136
  "build": "tsdown",
136
137
  "test": "vitest --run",
137
- "typecheck": "tsc --noEmit && tsc --noEmit -p web/tsconfig.json",
138
+ "typecheck": "tsc --noEmit && tsc --noEmit -p src/web/tsconfig.json",
138
139
  "lint": "oxlint . && oxfmt . --check",
139
140
  "fixcode": "oxlint . --fix && oxfmt ."
140
141
  }
@@ -0,0 +1 @@
1
+ export { webPlugin } from '../web/index.ts';
@@ -1,4 +1,4 @@
1
- import type { FileEditsDiffHunk } from '../src/types/fileEditsApi.ts';
1
+ import type { FileEditsDiffHunk } from '../types/fileEditsApi.ts';
2
2
  import { gutterWidth } from './fileView.ts';
3
3
 
4
4
  /**
@@ -15,7 +15,7 @@ import {
15
15
  import type { TransientTab, WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
16
16
  import { useStore } from '@tanstack/react-store';
17
17
  import { useEffect, useRef, useState } from 'react';
18
- import type { FileEditsVersionView } from '../src/types/fileEditsApi.ts';
18
+ import type { FileEditsVersionView } from '../types/fileEditsApi.ts';
19
19
  import { CommentDraft } from './CommentDraft.tsx';
20
20
  import { DeleteFileDialog } from './DeleteFileDialog.tsx';
21
21
  import { DiffView } from './DiffView.tsx';
@@ -2,7 +2,7 @@ import { Button } from '@agimon-ai/doompi-web-components';
2
2
  import type { WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
3
3
  import { useStore } from '@tanstack/react-store';
4
4
  import { useState } from 'react';
5
- import type { FilesItemView } from '../src/types/webFiles.ts';
5
+ import type { FilesItemView } from '../types/webFiles.ts';
6
6
  import { fileTab } from './FilePanel.tsx';
7
7
  import { FileActivityRow, FilesBrowser } from './FilesBrowser.tsx';
8
8
  import { files } from './filesStore.ts';
@@ -1,7 +1,7 @@
1
1
  import { Button, CloseIcon, Input } from '@agimon-ai/doompi-web-components';
2
2
  import type { ChangeEvent, KeyboardEvent as ReactKeyboardEvent } from 'react';
3
3
  import { useEffect, useRef, useState } from 'react';
4
- import type { FilesItemView } from '../src/types/webFiles.ts';
4
+ import type { FilesItemView } from '../types/webFiles.ts';
5
5
  import { filterFileItems, TOOL_LABEL } from './fileView.ts';
6
6
 
7
7
  function FileMetadata({ item }: { item: FilesItemView }) {
@@ -80,9 +80,13 @@ export function FilesBrowser({
80
80
  const shown = filterFileItems(items, query);
81
81
  const selected = shown[selectedIndex];
82
82
 
83
- useEffect(() => {
83
+ // A shrinking filter can strand the cursor past the end, so the clamp happens
84
+ // while rendering the shorter list rather than in a pass after it paints.
85
+ const [lastShownLength, setLastShownLength] = useState(shown.length);
86
+ if (lastShownLength !== shown.length) {
87
+ setLastShownLength(shown.length);
84
88
  setSelectedIndex((current) => Math.min(current, Math.max(0, shown.length - 1)));
85
- }, [shown.length]);
89
+ }
86
90
 
87
91
  useEffect(() => {
88
92
  listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' });
@@ -1,5 +1,5 @@
1
1
  import type { FileLinkSource, TransientTab } from '@agimon-ai/doompi-web-contracts';
2
- import type { FilesItemView } from '../src/types/webFiles.ts';
2
+ import type { FilesItemView } from '../types/webFiles.ts';
3
3
  import { fileTab } from './FilePanel.tsx';
4
4
  import { files } from './filesStore.ts';
5
5
 
@@ -1,7 +1,7 @@
1
1
  import { grammarKeyOf, mediaKindOf } from '@agimon-ai/doompi-web-components';
2
- import type { FileEditTool } from '../src/types/domain.ts';
3
- import type { FileEditsDiffHunk } from '../src/types/fileEditsApi.ts';
4
- import type { FilesItemView } from '../src/types/webFiles.ts';
2
+ import type { FileEditTool } from '../types/domain.ts';
3
+ import type { FileEditsDiffHunk } from '../types/fileEditsApi.ts';
4
+ import type { FilesItemView } from '../types/webFiles.ts';
5
5
  import type { FileComment } from './filesStore.ts';
6
6
 
7
7
  /**
@@ -4,7 +4,7 @@ import {
4
4
  detailUrl,
5
5
  type FileEditsDetailView,
6
6
  type FileEditsSaveView,
7
- } from '../src/types/fileEditsApi.ts';
7
+ } from '../types/fileEditsApi.ts';
8
8
  import { sealedTransport } from '@agimon-ai/doompi-web-security/browser';
9
9
 
10
10
  /**
@@ -1,6 +1,6 @@
1
1
  import { defineSessionStore } from '@agimon-ai/doompi-web-contracts';
2
- import type { FileEditsDetailView } from '../src/types/fileEditsApi.ts';
3
- import { filesChannelType, type FilesItemView } from '../src/types/webFiles.ts';
2
+ import type { FileEditsDetailView } from '../types/fileEditsApi.ts';
3
+ import { filesChannelType, type FilesItemView } from '../types/webFiles.ts';
4
4
 
5
5
  /**
6
6
  * One session's record: the hub's last report plus whatever this page does with
@@ -2,7 +2,7 @@ import { defineWebPlugin } from '@agimon-ai/doompi-web-contracts';
2
2
  import { fileLinks } from './fileLinks.ts';
3
3
  import { FilesActivitySection } from './FilesActivitySection.tsx';
4
4
  import { filesChannel } from './filesStore.ts';
5
- import { filesStatusKey } from '../src/types/webFiles.ts';
5
+ import { filesStatusKey } from '../types/webFiles.ts';
6
6
 
7
7
  /**
8
8
  * This package's cockpit presence: the files this session changed, in the
@@ -18,5 +18,5 @@
18
18
  "allowImportingTsExtensions": true,
19
19
  "forceConsistentCasingInFileNames": true
20
20
  },
21
- "include": [".", "../src/types", "../tests/web"]
21
+ "include": [".", "../types", "../exports/webClient.ts", "../../tests/web"]
22
22
  }
File without changes
File without changes