@agimon-ai/doompi-file-edit 0.0.1-alpha.71 → 0.0.1-alpha.72

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("../constants/package.cjs"),n=require("../services/doomIgnore/index.cjs"),r=require("../services/sessionStateSweep/index.cjs"),i=require("../types/webFiles.cjs");let a=require("node:path");a=e.__toESM(a,1);let o=require("node:fs");o=e.__toESM(o,1);let s=require("@agimon-ai/doompi-core/child-process"),c=require("@agimon-ai/doompi-core/ui-hub"),l=require("@agimon-ai/doompi-telemetry");function u(e){let r;try{r=o.default.readFileSync(a.default.join(e,t.DOOM_IGNORE_FILE),`utf8`)}catch{return}let i=n.createDoomIgnoreMatcher(r);if(i!==void 0)return t=>i(a.default.relative(e,t))}const d={source:t.PACKAGE_SOURCE,bindings:[{id:`editor.files`,path:[{key:`e`,label:`extension`,detail:`tools, skills and config`,order:50},{key:`f`,label:`files`,detail:`session edits`}],command:{name:t.FILES_COMMAND}}]};function f(e){let{paths:n,timeline:a,snapshots:o,editTracker:f,workflow:p}=e,m=(0,l.createDoomTelemetry)({serviceName:`doom-file-edit`,packageName:t.PACKAGE_SOURCE,env:process.env,enableLogs:!0,enableTraces:!0}),h=new Map,g=!0,_=0,v,y=async()=>{if(!g||!v?.hasUI)return;let e=await a.list();v.ui.setStatus(i.filesStatusKey,e.length===0?``:`${e.length} ${e.length===1?`file`:`files`}`)},b=async()=>{g=!1,_+=1,h.clear(),f.reset();let e=v;v=void 0;try{process.env[s.SUBAGENT_CHILD_ENV]||(await a.clear(),await o.clear()),e?.hasUI&&e.ui.setStatus(i.filesStatusKey,void 0),await m.recordEvent(`doom_file_edit.timeline_finished`,{outcome:`closed`})}finally{await m.shutdown()}},x=async(e,t)=>{try{await r.sweepSessionState({directory:n.stateDirectory(),keep:t});let i=n.legacyStateDirectory(e);if(i===void 0||i===n.stateDirectory())return;await r.sweepSessionState({directory:i,removeDirectory:!0})}catch{}};return{onDispose:b,services:[e=>{e.inject([c.DOOM_UI_HUB_SERVICE],e=>{let t=(0,c.requireDoomUiHub)(e).registerLeader(d);return()=>t.dispose()})}],commands:[[t.FILES_COMMAND,{description:`Review files edited in this session`,handler:async(e,t)=>{if(g){if(!t.hasUI){t.ui.notify(`/file-edits requires interactive mode`,`error`);return}await p.open(t)}}}]],events:{session_start:(e,t)=>{if(!g)return;_+=1,h.clear(),v=t;let r=n.sessionKey(t.sessionManager.getSessionId()),i=n.timelinePath(t.cwd,r),s=n.snapshotsPath(t.cwd,r);a.initialize(i),o.initialize(s),x(t.cwd,i);let c=u(t.cwd);f.reset({exclude:[i,`${i}.lock`,s],...c===void 0?{}:{isIgnored:c}}),y(),m.recordEvent(`doom_file_edit.timeline_started`,{outcome:`initialized`})},tool_execution_start:async(e,t)=>{if(!g)return;let n=_;if(h.set(e.toolCallId,Date.now()),await f.start(e.toolCallId,e.toolName,e.args,t.cwd),!g||n!==_){h.delete(e.toolCallId);return}m.recordEvent(`doom_file_edit.edit_started`,{"tool.name":e.toolName})},tool_execution_end:async(e,t)=>{if(!g)return;let n=h.get(e.toolCallId);h.delete(e.toolCallId),await f.end(e.toolCallId,e.isError,t.cwd),g&&(await y(),m.recordEvent(`doom_file_edit.edit_finished`,{"tool.name":e.toolName,"tool.result.error":e.isError,...n===void 0?{}:{duration_ms:Date.now()-n},outcome:e.isError?`failed`:`completed`}))}}}}exports.FILE_EDIT_LEADER_CONTRIBUTION=d,exports.createFileEditRuntime=f;
1
+ const e=require("../_virtual/_rolldown/runtime.cjs"),t=require("../constants/package.cjs"),n=require("../services/doomIgnore/index.cjs"),r=require("../services/sessionStateSweep/index.cjs"),i=require("../types/webFiles.cjs");let a=require("node:path");a=e.__toESM(a,1);let o=require("node:fs");o=e.__toESM(o,1);let s=require("@agimon-ai/doompi-core/child-process"),c=require("@agimon-ai/doompi-core/ui-hub"),l=require("@agimon-ai/doompi-telemetry");function u(e){let r;try{r=o.default.readFileSync(a.default.join(e,t.DOOM_IGNORE_FILE),`utf8`)}catch{return}let i=n.createDoomIgnoreMatcher(r);if(i!==void 0)return t=>i(a.default.relative(e,t))}const d={source:t.PACKAGE_SOURCE,bindings:[{id:`editor.files`,path:[{key:`e`,label:`extension`,detail:`tools, skills and config`,order:50},{key:`f`,label:`files`,detail:`session edits`}],command:{name:t.FILES_COMMAND}}]};function f(e){let{paths:n,timeline:a,snapshots:o,editTracker:f,workflow:p}=e,m=(0,l.createDoomTelemetry)({serviceName:`doom-file-edit`,packageName:t.PACKAGE_SOURCE,env:process.env,enableLogs:!0,enableTraces:!0}),h=new Map,g=!0,_=0,v,y=async()=>{if(!g||!v?.hasUI)return;let e=await a.list();v.ui.setStatus(i.filesStatusKey,e.length===0?``:`${e.length} ${e.length===1?`file`:`files`}`)},b=async()=>{g=!1,_+=1,h.clear(),await f.flush(),f.reset();let e=v;v=void 0;try{process.env[s.SUBAGENT_CHILD_ENV]||(await a.clear(),await o.clear()),e?.hasUI&&e.ui.setStatus(i.filesStatusKey,void 0),await m.recordEvent(`doom_file_edit.timeline_finished`,{outcome:`closed`})}finally{await m.shutdown()}},x=async(e,t)=>{try{await r.sweepSessionState({directory:n.stateDirectory(),keep:t});let i=n.legacyStateDirectory(e);if(i===void 0||i===n.stateDirectory())return;await r.sweepSessionState({directory:i,removeDirectory:!0})}catch{}};return{onDispose:b,services:[e=>{e.inject([c.DOOM_UI_HUB_SERVICE],e=>{let t=(0,c.requireDoomUiHub)(e).registerLeader(d);return()=>t.dispose()})}],commands:[[t.FILES_COMMAND,{description:`Review files edited in this session`,handler:async(e,t)=>{if(g){if(!t.hasUI){t.ui.notify(`/file-edits requires interactive mode`,`error`);return}await p.open(t)}}}]],events:{session_start:(e,t)=>{if(!g)return;_+=1,h.clear(),v=t;let r=n.sessionKey(t.sessionManager.getSessionId()),i=n.timelinePath(t.cwd,r),s=n.snapshotsPath(t.cwd,r);a.initialize(i),o.initialize(s),x(t.cwd,i);let c=u(t.cwd);f.reset({exclude:[i,`${i}.lock`,s],...c===void 0?{}:{isIgnored:c}}),y(),m.recordEvent(`doom_file_edit.timeline_started`,{outcome:`initialized`})},tool_execution_start:async(e,t)=>{if(!g)return;let n=_;h.set(e.toolCallId,Date.now());let r=Date.now();await f.start(e.toolCallId,e.toolName,e.args,t.cwd);let i=Date.now()-r;if(!g||n!==_){h.delete(e.toolCallId);return}m.recordEvent(`doom_file_edit.edit_started`,{"tool.name":e.toolName,hook_ms:i})},tool_execution_end:async(e,t)=>{if(!g)return;let n=h.get(e.toolCallId);h.delete(e.toolCallId);let r=Date.now();if(await f.end(e.toolCallId,e.isError,t.cwd),!g)return;await y();let i=Date.now()-r;m.recordEvent(`doom_file_edit.edit_finished`,{"tool.name":e.toolName,"tool.result.error":e.isError,...n===void 0?{}:{duration_ms:Date.now()-n},hook_ms:i,outcome:e.isError?`failed`:`completed`})}}}}exports.FILE_EDIT_LEADER_CONTRIBUTION=d,exports.createFileEditRuntime=f;
2
2
  //# sourceMappingURL=fileEditRuntime.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"fileEditRuntime.cjs","names":["fs","path","DOOM_IGNORE_FILE","createDoomIgnoreMatcher","PACKAGE_SOURCE","FILES_COMMAND","createDoomTelemetry","filesStatusKey","SUBAGENT_CHILD_ENV","sweepSessionState","DOOM_UI_HUB_SERVICE","requireDoomUiHub"],"sources":["../../src/controllers/fileEditRuntime.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nimport { SUBAGENT_CHILD_ENV } from '@agimon-ai/doompi-core/child-process';\nimport type { LeaderContribution } from '@agimon-ai/doompi-core/leader';\nimport type { PiPluginContributions } from '@agimon-ai/doompi-core/pi-extension';\nimport { DOOM_UI_HUB_SERVICE, requireDoomUiHub } from '@agimon-ai/doompi-core/ui-hub';\nimport { createDoomTelemetry, type DoomTelemetry } from '@agimon-ai/doompi-telemetry';\nimport type { Context } from '@deepseek-ai/cordis';\nimport type { ExtensionContext } from '@earendil-works/pi-coding-agent';\n\nimport { FILES_COMMAND, PACKAGE_SOURCE, DOOM_IGNORE_FILE } from '../constants/package';\nimport { createDoomIgnoreMatcher } from '../services/doomIgnore';\nimport { sweepSessionState } from '../services/sessionStateSweep';\nimport type { FileEditDependencies } from '../types';\nimport { filesStatusKey } from '../types/webFiles';\n/**\n * The project's ignore rules as a test over absolute paths, or nothing when the\n * file is absent, empty, or unreadable.\n *\n * Read once per session, like the exclude list it is passed beside. The web\n * reader re-reads the same file per request, so a mid-session edit reaches the\n * list before it reaches the recorder; that is a pre-existing difference and\n * not one this read introduces.\n */\nfunction readDoomIgnore(cwd: string): ((filePath: string) => boolean) | undefined {\n let content: string;\n try {\n content = fs.readFileSync(path.join(cwd, DOOM_IGNORE_FILE), 'utf8');\n } catch {\n return undefined;\n }\n const matcher = createDoomIgnoreMatcher(content);\n if (matcher === undefined) return undefined;\n return (filePath: string): boolean => matcher(path.relative(cwd, filePath));\n}\n\nexport const FILE_EDIT_LEADER_CONTRIBUTION = {\n source: PACKAGE_SOURCE,\n bindings: [\n {\n id: 'editor.files',\n path: [\n { key: 'e', label: 'extension', detail: 'tools, skills and config', order: 50 },\n { key: 'f', label: 'files', detail: 'session edits' },\n ],\n command: { name: FILES_COMMAND },\n },\n ],\n} as const satisfies LeaderContribution;\nexport function createFileEditRuntime(dependencies: FileEditDependencies): PiPluginContributions {\n const { paths, timeline, snapshots, editTracker: tracker, workflow } = dependencies;\n const telemetry: DoomTelemetry = createDoomTelemetry({\n serviceName: 'doom-file-edit',\n packageName: PACKAGE_SOURCE,\n env: process.env,\n enableLogs: true,\n enableTraces: true,\n });\n const toolStartedAt = new Map<string, number>();\n let active = true;\n let sessionGeneration = 0;\n let sessionContext: ExtensionContext | undefined;\n\n /**\n * Publishes the count the cockpit's activity group keys off. The group is\n * hidden until this status exists, so it is written on every append rather\n * than only when the count changes.\n */\n const publishStatus = async (): Promise<void> => {\n if (!active || !sessionContext?.hasUI) return;\n const entries = await timeline.list();\n sessionContext.ui.setStatus(\n filesStatusKey,\n entries.length === 0 ? '' : `${entries.length} ${entries.length === 1 ? 'file' : 'files'}`,\n );\n };\n\n const dispose = async () => {\n active = false;\n sessionGeneration += 1;\n toolStartedAt.clear();\n tracker.reset();\n const closing = sessionContext;\n sessionContext = undefined;\n try {\n if (!process.env[SUBAGENT_CHILD_ENV]) {\n await timeline.clear();\n // The snapshots exist only to diff this session, so they go with it\n // rather than accumulating a copy of the tree on every run.\n await snapshots.clear();\n }\n if (closing?.hasUI) closing.ui.setStatus(filesStatusKey, undefined);\n await telemetry.recordEvent('doom_file_edit.timeline_finished', { outcome: 'closed' });\n } finally {\n await telemetry.shutdown();\n }\n };\n\n /**\n * Clears state no session will claim again. Never awaited and never allowed\n * to throw: a sweep that cannot run is not a reason to fail a session.\n */\n const sweepAbandonedState = async (cwd: string, keep: string): Promise<void> => {\n try {\n await sweepSessionState({ directory: paths.stateDirectory(), keep });\n const legacy = paths.legacyStateDirectory(cwd);\n if (legacy === undefined || legacy === paths.stateDirectory()) return;\n // Same age rule, so a build still running against the old location is\n // not robbed of the session it is in the middle of.\n await sweepSessionState({ directory: legacy, removeDirectory: true });\n } catch {\n // Nothing here is worth interrupting a session that is starting.\n }\n };\n return {\n onDispose: dispose,\n services: [\n (cordis: Context) => {\n cordis.inject([DOOM_UI_HUB_SERVICE], (uiContext) => {\n const contribution = requireDoomUiHub(uiContext).registerLeader(FILE_EDIT_LEADER_CONTRIBUTION);\n return () => contribution.dispose();\n });\n },\n ],\n commands: [\n [\n FILES_COMMAND,\n {\n description: 'Review files edited in this session',\n handler: async (_args, ctx) => {\n if (!active) return;\n if (!ctx.hasUI) {\n ctx.ui.notify('/file-edits requires interactive mode', 'error');\n return;\n }\n await workflow.open(ctx);\n },\n },\n ],\n ],\n events: {\n session_start: (_event, ctx) => {\n if (!active) return;\n sessionGeneration += 1;\n toolStartedAt.clear();\n sessionContext = ctx;\n const sessionKey = paths.sessionKey(ctx.sessionManager.getSessionId());\n const timelinePath = paths.timelinePath(ctx.cwd, sessionKey);\n const snapshotsPath = paths.snapshotsPath(ctx.cwd, sessionKey);\n timeline.initialize(timelinePath);\n snapshots.initialize(snapshotsPath);\n // Inside a git worktree these land under the repository's own git\n // directory, which can sit inside the tree the tracker walks. Naming them\n // here is what stops the package's bookkeeping reading as session edits.\n // Whatever earlier sessions were killed before they could clean up, plus\n // anything an older build left in the repository's own git directory.\n void sweepAbandonedState(ctx.cwd, timelinePath);\n const isIgnored = readDoomIgnore(ctx.cwd);\n tracker.reset({\n exclude: [timelinePath, `${timelinePath}.lock`, snapshotsPath],\n ...(isIgnored === undefined ? {} : { isIgnored }),\n });\n // A resumed session already has a timeline, so the group must show its\n // count from the first paint rather than waiting for the next edit.\n void publishStatus();\n void telemetry.recordEvent('doom_file_edit.timeline_started', { outcome: 'initialized' });\n },\n tool_execution_start: async (event, ctx) => {\n if (!active) return;\n const ownGeneration = sessionGeneration;\n toolStartedAt.set(event.toolCallId, Date.now());\n await tracker.start(event.toolCallId, event.toolName, event.args, ctx.cwd);\n if (!active || ownGeneration !== sessionGeneration) {\n toolStartedAt.delete(event.toolCallId);\n return;\n }\n void telemetry.recordEvent('doom_file_edit.edit_started', { 'tool.name': event.toolName });\n },\n tool_execution_end: async (event, ctx) => {\n if (!active) return;\n const startedAt = toolStartedAt.get(event.toolCallId);\n toolStartedAt.delete(event.toolCallId);\n await tracker.end(event.toolCallId, event.isError, ctx.cwd);\n if (!active) return;\n await publishStatus();\n void telemetry.recordEvent('doom_file_edit.edit_finished', {\n 'tool.name': event.toolName,\n 'tool.result.error': event.isError,\n ...(startedAt === undefined ? {} : { duration_ms: Date.now() - startedAt }),\n outcome: event.isError ? 'failed' : 'completed',\n });\n },\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"fileEditRuntime.cjs","names":["fs","path","DOOM_IGNORE_FILE","createDoomIgnoreMatcher","PACKAGE_SOURCE","FILES_COMMAND","createDoomTelemetry","filesStatusKey","SUBAGENT_CHILD_ENV","sweepSessionState","DOOM_UI_HUB_SERVICE","requireDoomUiHub"],"sources":["../../src/controllers/fileEditRuntime.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nimport { SUBAGENT_CHILD_ENV } from '@agimon-ai/doompi-core/child-process';\nimport type { LeaderContribution } from '@agimon-ai/doompi-core/leader';\nimport type { PiPluginContributions } from '@agimon-ai/doompi-core/pi-extension';\nimport { DOOM_UI_HUB_SERVICE, requireDoomUiHub } from '@agimon-ai/doompi-core/ui-hub';\nimport { createDoomTelemetry, type DoomTelemetry } from '@agimon-ai/doompi-telemetry';\nimport type { Context } from '@deepseek-ai/cordis';\nimport type { ExtensionContext } from '@earendil-works/pi-coding-agent';\n\nimport { FILES_COMMAND, PACKAGE_SOURCE, DOOM_IGNORE_FILE } from '../constants/package';\nimport { createDoomIgnoreMatcher } from '../services/doomIgnore';\nimport { sweepSessionState } from '../services/sessionStateSweep';\nimport type { FileEditDependencies } from '../types';\nimport { filesStatusKey } from '../types/webFiles';\n/**\n * The project's ignore rules as a test over absolute paths, or nothing when the\n * file is absent, empty, or unreadable.\n *\n * Read once per session, like the exclude list it is passed beside. The web\n * reader re-reads the same file per request, so a mid-session edit reaches the\n * list before it reaches the recorder; that is a pre-existing difference and\n * not one this read introduces.\n */\nfunction readDoomIgnore(cwd: string): ((filePath: string) => boolean) | undefined {\n let content: string;\n try {\n content = fs.readFileSync(path.join(cwd, DOOM_IGNORE_FILE), 'utf8');\n } catch {\n return undefined;\n }\n const matcher = createDoomIgnoreMatcher(content);\n if (matcher === undefined) return undefined;\n return (filePath: string): boolean => matcher(path.relative(cwd, filePath));\n}\n\nexport const FILE_EDIT_LEADER_CONTRIBUTION = {\n source: PACKAGE_SOURCE,\n bindings: [\n {\n id: 'editor.files',\n path: [\n { key: 'e', label: 'extension', detail: 'tools, skills and config', order: 50 },\n { key: 'f', label: 'files', detail: 'session edits' },\n ],\n command: { name: FILES_COMMAND },\n },\n ],\n} as const satisfies LeaderContribution;\nexport function createFileEditRuntime(dependencies: FileEditDependencies): PiPluginContributions {\n const { paths, timeline, snapshots, editTracker: tracker, workflow } = dependencies;\n const telemetry: DoomTelemetry = createDoomTelemetry({\n serviceName: 'doom-file-edit',\n packageName: PACKAGE_SOURCE,\n env: process.env,\n enableLogs: true,\n enableTraces: true,\n });\n const toolStartedAt = new Map<string, number>();\n let active = true;\n let sessionGeneration = 0;\n let sessionContext: ExtensionContext | undefined;\n\n /**\n * Publishes the count the cockpit's activity group keys off. The group is\n * hidden until this status exists, so it is written on every append rather\n * than only when the count changes.\n */\n const publishStatus = async (): Promise<void> => {\n if (!active || !sessionContext?.hasUI) return;\n const entries = await timeline.list();\n sessionContext.ui.setStatus(\n filesStatusKey,\n entries.length === 0 ? '' : `${entries.length} ${entries.length === 1 ? 'file' : 'files'}`,\n );\n };\n\n const dispose = async () => {\n active = false;\n sessionGeneration += 1;\n toolStartedAt.clear();\n // A scan deferred by the last bash call would otherwise append after the\n // clear below and leave a timeline no session owns.\n await tracker.flush();\n tracker.reset();\n const closing = sessionContext;\n sessionContext = undefined;\n try {\n if (!process.env[SUBAGENT_CHILD_ENV]) {\n await timeline.clear();\n // The snapshots exist only to diff this session, so they go with it\n // rather than accumulating a copy of the tree on every run.\n await snapshots.clear();\n }\n if (closing?.hasUI) closing.ui.setStatus(filesStatusKey, undefined);\n await telemetry.recordEvent('doom_file_edit.timeline_finished', { outcome: 'closed' });\n } finally {\n await telemetry.shutdown();\n }\n };\n\n /**\n * Clears state no session will claim again. Never awaited and never allowed\n * to throw: a sweep that cannot run is not a reason to fail a session.\n */\n const sweepAbandonedState = async (cwd: string, keep: string): Promise<void> => {\n try {\n await sweepSessionState({ directory: paths.stateDirectory(), keep });\n const legacy = paths.legacyStateDirectory(cwd);\n if (legacy === undefined || legacy === paths.stateDirectory()) return;\n // Same age rule, so a build still running against the old location is\n // not robbed of the session it is in the middle of.\n await sweepSessionState({ directory: legacy, removeDirectory: true });\n } catch {\n // Nothing here is worth interrupting a session that is starting.\n }\n };\n return {\n onDispose: dispose,\n services: [\n (cordis: Context) => {\n cordis.inject([DOOM_UI_HUB_SERVICE], (uiContext) => {\n const contribution = requireDoomUiHub(uiContext).registerLeader(FILE_EDIT_LEADER_CONTRIBUTION);\n return () => contribution.dispose();\n });\n },\n ],\n commands: [\n [\n FILES_COMMAND,\n {\n description: 'Review files edited in this session',\n handler: async (_args, ctx) => {\n if (!active) return;\n if (!ctx.hasUI) {\n ctx.ui.notify('/file-edits requires interactive mode', 'error');\n return;\n }\n await workflow.open(ctx);\n },\n },\n ],\n ],\n events: {\n session_start: (_event, ctx) => {\n if (!active) return;\n sessionGeneration += 1;\n toolStartedAt.clear();\n sessionContext = ctx;\n const sessionKey = paths.sessionKey(ctx.sessionManager.getSessionId());\n const timelinePath = paths.timelinePath(ctx.cwd, sessionKey);\n const snapshotsPath = paths.snapshotsPath(ctx.cwd, sessionKey);\n timeline.initialize(timelinePath);\n snapshots.initialize(snapshotsPath);\n // Inside a git worktree these land under the repository's own git\n // directory, which can sit inside the tree the tracker walks. Naming them\n // here is what stops the package's bookkeeping reading as session edits.\n // Whatever earlier sessions were killed before they could clean up, plus\n // anything an older build left in the repository's own git directory.\n void sweepAbandonedState(ctx.cwd, timelinePath);\n const isIgnored = readDoomIgnore(ctx.cwd);\n tracker.reset({\n exclude: [timelinePath, `${timelinePath}.lock`, snapshotsPath],\n ...(isIgnored === undefined ? {} : { isIgnored }),\n });\n // A resumed session already has a timeline, so the group must show its\n // count from the first paint rather than waiting for the next edit.\n void publishStatus();\n void telemetry.recordEvent('doom_file_edit.timeline_started', { outcome: 'initialized' });\n },\n tool_execution_start: async (event, ctx) => {\n if (!active) return;\n const ownGeneration = sessionGeneration;\n toolStartedAt.set(event.toolCallId, Date.now());\n const hookStartedAt = Date.now();\n await tracker.start(event.toolCallId, event.toolName, event.args, ctx.cwd);\n const hookMs = Date.now() - hookStartedAt;\n if (!active || ownGeneration !== sessionGeneration) {\n toolStartedAt.delete(event.toolCallId);\n return;\n }\n void telemetry.recordEvent('doom_file_edit.edit_started', {\n 'tool.name': event.toolName,\n hook_ms: hookMs,\n });\n },\n tool_execution_end: async (event, ctx) => {\n if (!active) return;\n const startedAt = toolStartedAt.get(event.toolCallId);\n toolStartedAt.delete(event.toolCallId);\n // Measured separately from the tool's own duration: this is the part of\n // the wait that belongs to this package rather than to the tool.\n const hookStartedAt = Date.now();\n await tracker.end(event.toolCallId, event.isError, ctx.cwd);\n if (!active) return;\n await publishStatus();\n const hookMs = Date.now() - hookStartedAt;\n void telemetry.recordEvent('doom_file_edit.edit_finished', {\n 'tool.name': event.toolName,\n 'tool.result.error': event.isError,\n ...(startedAt === undefined ? {} : { duration_ms: Date.now() - startedAt }),\n hook_ms: hookMs,\n outcome: event.isError ? 'failed' : 'completed',\n });\n },\n },\n };\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import{DOOM_IGNORE_FILE as e,FILES_COMMAND as t,PACKAGE_SOURCE as n}from"../constants/package.mjs";import{createDoomIgnoreMatcher as r}from"../services/doomIgnore/index.mjs";import{sweepSessionState as i}from"../services/sessionStateSweep/index.mjs";import{filesStatusKey as a}from"../types/webFiles.mjs";import o from"node:path";import s from"node:fs";import{SUBAGENT_CHILD_ENV as c}from"@agimon-ai/doompi-core/child-process";import{DOOM_UI_HUB_SERVICE as l,requireDoomUiHub as u}from"@agimon-ai/doompi-core/ui-hub";import{createDoomTelemetry as d}from"@agimon-ai/doompi-telemetry";function f(t){let n;try{n=s.readFileSync(o.join(t,e),`utf8`)}catch{return}let i=r(n);if(i!==void 0)return e=>i(o.relative(t,e))}const p={source:n,bindings:[{id:`editor.files`,path:[{key:`e`,label:`extension`,detail:`tools, skills and config`,order:50},{key:`f`,label:`files`,detail:`session edits`}],command:{name:t}}]};function m(e){let{paths:r,timeline:o,snapshots:s,editTracker:m,workflow:h}=e,g=d({serviceName:`doom-file-edit`,packageName:n,env:process.env,enableLogs:!0,enableTraces:!0}),_=new Map,v=!0,y=0,b,x=async()=>{if(!v||!b?.hasUI)return;let e=await o.list();b.ui.setStatus(a,e.length===0?``:`${e.length} ${e.length===1?`file`:`files`}`)},S=async()=>{v=!1,y+=1,_.clear(),m.reset();let e=b;b=void 0;try{process.env[c]||(await o.clear(),await s.clear()),e?.hasUI&&e.ui.setStatus(a,void 0),await g.recordEvent(`doom_file_edit.timeline_finished`,{outcome:`closed`})}finally{await g.shutdown()}},C=async(e,t)=>{try{await i({directory:r.stateDirectory(),keep:t});let n=r.legacyStateDirectory(e);if(n===void 0||n===r.stateDirectory())return;await i({directory:n,removeDirectory:!0})}catch{}};return{onDispose:S,services:[e=>{e.inject([l],e=>{let t=u(e).registerLeader(p);return()=>t.dispose()})}],commands:[[t,{description:`Review files edited in this session`,handler:async(e,t)=>{if(v){if(!t.hasUI){t.ui.notify(`/file-edits requires interactive mode`,`error`);return}await h.open(t)}}}]],events:{session_start:(e,t)=>{if(!v)return;y+=1,_.clear(),b=t;let n=r.sessionKey(t.sessionManager.getSessionId()),i=r.timelinePath(t.cwd,n),a=r.snapshotsPath(t.cwd,n);o.initialize(i),s.initialize(a),C(t.cwd,i);let c=f(t.cwd);m.reset({exclude:[i,`${i}.lock`,a],...c===void 0?{}:{isIgnored:c}}),x(),g.recordEvent(`doom_file_edit.timeline_started`,{outcome:`initialized`})},tool_execution_start:async(e,t)=>{if(!v)return;let n=y;if(_.set(e.toolCallId,Date.now()),await m.start(e.toolCallId,e.toolName,e.args,t.cwd),!v||n!==y){_.delete(e.toolCallId);return}g.recordEvent(`doom_file_edit.edit_started`,{"tool.name":e.toolName})},tool_execution_end:async(e,t)=>{if(!v)return;let n=_.get(e.toolCallId);_.delete(e.toolCallId),await m.end(e.toolCallId,e.isError,t.cwd),v&&(await x(),g.recordEvent(`doom_file_edit.edit_finished`,{"tool.name":e.toolName,"tool.result.error":e.isError,...n===void 0?{}:{duration_ms:Date.now()-n},outcome:e.isError?`failed`:`completed`}))}}}}export{p as FILE_EDIT_LEADER_CONTRIBUTION,m as createFileEditRuntime};
1
+ import{DOOM_IGNORE_FILE as e,FILES_COMMAND as t,PACKAGE_SOURCE as n}from"../constants/package.mjs";import{createDoomIgnoreMatcher as r}from"../services/doomIgnore/index.mjs";import{sweepSessionState as i}from"../services/sessionStateSweep/index.mjs";import{filesStatusKey as a}from"../types/webFiles.mjs";import o from"node:path";import s from"node:fs";import{SUBAGENT_CHILD_ENV as c}from"@agimon-ai/doompi-core/child-process";import{DOOM_UI_HUB_SERVICE as l,requireDoomUiHub as u}from"@agimon-ai/doompi-core/ui-hub";import{createDoomTelemetry as d}from"@agimon-ai/doompi-telemetry";function f(t){let n;try{n=s.readFileSync(o.join(t,e),`utf8`)}catch{return}let i=r(n);if(i!==void 0)return e=>i(o.relative(t,e))}const p={source:n,bindings:[{id:`editor.files`,path:[{key:`e`,label:`extension`,detail:`tools, skills and config`,order:50},{key:`f`,label:`files`,detail:`session edits`}],command:{name:t}}]};function m(e){let{paths:r,timeline:o,snapshots:s,editTracker:m,workflow:h}=e,g=d({serviceName:`doom-file-edit`,packageName:n,env:process.env,enableLogs:!0,enableTraces:!0}),_=new Map,v=!0,y=0,b,x=async()=>{if(!v||!b?.hasUI)return;let e=await o.list();b.ui.setStatus(a,e.length===0?``:`${e.length} ${e.length===1?`file`:`files`}`)},S=async()=>{v=!1,y+=1,_.clear(),await m.flush(),m.reset();let e=b;b=void 0;try{process.env[c]||(await o.clear(),await s.clear()),e?.hasUI&&e.ui.setStatus(a,void 0),await g.recordEvent(`doom_file_edit.timeline_finished`,{outcome:`closed`})}finally{await g.shutdown()}},C=async(e,t)=>{try{await i({directory:r.stateDirectory(),keep:t});let n=r.legacyStateDirectory(e);if(n===void 0||n===r.stateDirectory())return;await i({directory:n,removeDirectory:!0})}catch{}};return{onDispose:S,services:[e=>{e.inject([l],e=>{let t=u(e).registerLeader(p);return()=>t.dispose()})}],commands:[[t,{description:`Review files edited in this session`,handler:async(e,t)=>{if(v){if(!t.hasUI){t.ui.notify(`/file-edits requires interactive mode`,`error`);return}await h.open(t)}}}]],events:{session_start:(e,t)=>{if(!v)return;y+=1,_.clear(),b=t;let n=r.sessionKey(t.sessionManager.getSessionId()),i=r.timelinePath(t.cwd,n),a=r.snapshotsPath(t.cwd,n);o.initialize(i),s.initialize(a),C(t.cwd,i);let c=f(t.cwd);m.reset({exclude:[i,`${i}.lock`,a],...c===void 0?{}:{isIgnored:c}}),x(),g.recordEvent(`doom_file_edit.timeline_started`,{outcome:`initialized`})},tool_execution_start:async(e,t)=>{if(!v)return;let n=y;_.set(e.toolCallId,Date.now());let r=Date.now();await m.start(e.toolCallId,e.toolName,e.args,t.cwd);let i=Date.now()-r;if(!v||n!==y){_.delete(e.toolCallId);return}g.recordEvent(`doom_file_edit.edit_started`,{"tool.name":e.toolName,hook_ms:i})},tool_execution_end:async(e,t)=>{if(!v)return;let n=_.get(e.toolCallId);_.delete(e.toolCallId);let r=Date.now();if(await m.end(e.toolCallId,e.isError,t.cwd),!v)return;await x();let i=Date.now()-r;g.recordEvent(`doom_file_edit.edit_finished`,{"tool.name":e.toolName,"tool.result.error":e.isError,...n===void 0?{}:{duration_ms:Date.now()-n},hook_ms:i,outcome:e.isError?`failed`:`completed`})}}}}export{p as FILE_EDIT_LEADER_CONTRIBUTION,m as createFileEditRuntime};
2
2
  //# sourceMappingURL=fileEditRuntime.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"fileEditRuntime.mjs","names":[],"sources":["../../src/controllers/fileEditRuntime.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nimport { SUBAGENT_CHILD_ENV } from '@agimon-ai/doompi-core/child-process';\nimport type { LeaderContribution } from '@agimon-ai/doompi-core/leader';\nimport type { PiPluginContributions } from '@agimon-ai/doompi-core/pi-extension';\nimport { DOOM_UI_HUB_SERVICE, requireDoomUiHub } from '@agimon-ai/doompi-core/ui-hub';\nimport { createDoomTelemetry, type DoomTelemetry } from '@agimon-ai/doompi-telemetry';\nimport type { Context } from '@deepseek-ai/cordis';\nimport type { ExtensionContext } from '@earendil-works/pi-coding-agent';\n\nimport { FILES_COMMAND, PACKAGE_SOURCE, DOOM_IGNORE_FILE } from '../constants/package';\nimport { createDoomIgnoreMatcher } from '../services/doomIgnore';\nimport { sweepSessionState } from '../services/sessionStateSweep';\nimport type { FileEditDependencies } from '../types';\nimport { filesStatusKey } from '../types/webFiles';\n/**\n * The project's ignore rules as a test over absolute paths, or nothing when the\n * file is absent, empty, or unreadable.\n *\n * Read once per session, like the exclude list it is passed beside. The web\n * reader re-reads the same file per request, so a mid-session edit reaches the\n * list before it reaches the recorder; that is a pre-existing difference and\n * not one this read introduces.\n */\nfunction readDoomIgnore(cwd: string): ((filePath: string) => boolean) | undefined {\n let content: string;\n try {\n content = fs.readFileSync(path.join(cwd, DOOM_IGNORE_FILE), 'utf8');\n } catch {\n return undefined;\n }\n const matcher = createDoomIgnoreMatcher(content);\n if (matcher === undefined) return undefined;\n return (filePath: string): boolean => matcher(path.relative(cwd, filePath));\n}\n\nexport const FILE_EDIT_LEADER_CONTRIBUTION = {\n source: PACKAGE_SOURCE,\n bindings: [\n {\n id: 'editor.files',\n path: [\n { key: 'e', label: 'extension', detail: 'tools, skills and config', order: 50 },\n { key: 'f', label: 'files', detail: 'session edits' },\n ],\n command: { name: FILES_COMMAND },\n },\n ],\n} as const satisfies LeaderContribution;\nexport function createFileEditRuntime(dependencies: FileEditDependencies): PiPluginContributions {\n const { paths, timeline, snapshots, editTracker: tracker, workflow } = dependencies;\n const telemetry: DoomTelemetry = createDoomTelemetry({\n serviceName: 'doom-file-edit',\n packageName: PACKAGE_SOURCE,\n env: process.env,\n enableLogs: true,\n enableTraces: true,\n });\n const toolStartedAt = new Map<string, number>();\n let active = true;\n let sessionGeneration = 0;\n let sessionContext: ExtensionContext | undefined;\n\n /**\n * Publishes the count the cockpit's activity group keys off. The group is\n * hidden until this status exists, so it is written on every append rather\n * than only when the count changes.\n */\n const publishStatus = async (): Promise<void> => {\n if (!active || !sessionContext?.hasUI) return;\n const entries = await timeline.list();\n sessionContext.ui.setStatus(\n filesStatusKey,\n entries.length === 0 ? '' : `${entries.length} ${entries.length === 1 ? 'file' : 'files'}`,\n );\n };\n\n const dispose = async () => {\n active = false;\n sessionGeneration += 1;\n toolStartedAt.clear();\n tracker.reset();\n const closing = sessionContext;\n sessionContext = undefined;\n try {\n if (!process.env[SUBAGENT_CHILD_ENV]) {\n await timeline.clear();\n // The snapshots exist only to diff this session, so they go with it\n // rather than accumulating a copy of the tree on every run.\n await snapshots.clear();\n }\n if (closing?.hasUI) closing.ui.setStatus(filesStatusKey, undefined);\n await telemetry.recordEvent('doom_file_edit.timeline_finished', { outcome: 'closed' });\n } finally {\n await telemetry.shutdown();\n }\n };\n\n /**\n * Clears state no session will claim again. Never awaited and never allowed\n * to throw: a sweep that cannot run is not a reason to fail a session.\n */\n const sweepAbandonedState = async (cwd: string, keep: string): Promise<void> => {\n try {\n await sweepSessionState({ directory: paths.stateDirectory(), keep });\n const legacy = paths.legacyStateDirectory(cwd);\n if (legacy === undefined || legacy === paths.stateDirectory()) return;\n // Same age rule, so a build still running against the old location is\n // not robbed of the session it is in the middle of.\n await sweepSessionState({ directory: legacy, removeDirectory: true });\n } catch {\n // Nothing here is worth interrupting a session that is starting.\n }\n };\n return {\n onDispose: dispose,\n services: [\n (cordis: Context) => {\n cordis.inject([DOOM_UI_HUB_SERVICE], (uiContext) => {\n const contribution = requireDoomUiHub(uiContext).registerLeader(FILE_EDIT_LEADER_CONTRIBUTION);\n return () => contribution.dispose();\n });\n },\n ],\n commands: [\n [\n FILES_COMMAND,\n {\n description: 'Review files edited in this session',\n handler: async (_args, ctx) => {\n if (!active) return;\n if (!ctx.hasUI) {\n ctx.ui.notify('/file-edits requires interactive mode', 'error');\n return;\n }\n await workflow.open(ctx);\n },\n },\n ],\n ],\n events: {\n session_start: (_event, ctx) => {\n if (!active) return;\n sessionGeneration += 1;\n toolStartedAt.clear();\n sessionContext = ctx;\n const sessionKey = paths.sessionKey(ctx.sessionManager.getSessionId());\n const timelinePath = paths.timelinePath(ctx.cwd, sessionKey);\n const snapshotsPath = paths.snapshotsPath(ctx.cwd, sessionKey);\n timeline.initialize(timelinePath);\n snapshots.initialize(snapshotsPath);\n // Inside a git worktree these land under the repository's own git\n // directory, which can sit inside the tree the tracker walks. Naming them\n // here is what stops the package's bookkeeping reading as session edits.\n // Whatever earlier sessions were killed before they could clean up, plus\n // anything an older build left in the repository's own git directory.\n void sweepAbandonedState(ctx.cwd, timelinePath);\n const isIgnored = readDoomIgnore(ctx.cwd);\n tracker.reset({\n exclude: [timelinePath, `${timelinePath}.lock`, snapshotsPath],\n ...(isIgnored === undefined ? {} : { isIgnored }),\n });\n // A resumed session already has a timeline, so the group must show its\n // count from the first paint rather than waiting for the next edit.\n void publishStatus();\n void telemetry.recordEvent('doom_file_edit.timeline_started', { outcome: 'initialized' });\n },\n tool_execution_start: async (event, ctx) => {\n if (!active) return;\n const ownGeneration = sessionGeneration;\n toolStartedAt.set(event.toolCallId, Date.now());\n await tracker.start(event.toolCallId, event.toolName, event.args, ctx.cwd);\n if (!active || ownGeneration !== sessionGeneration) {\n toolStartedAt.delete(event.toolCallId);\n return;\n }\n void telemetry.recordEvent('doom_file_edit.edit_started', { 'tool.name': event.toolName });\n },\n tool_execution_end: async (event, ctx) => {\n if (!active) return;\n const startedAt = toolStartedAt.get(event.toolCallId);\n toolStartedAt.delete(event.toolCallId);\n await tracker.end(event.toolCallId, event.isError, ctx.cwd);\n if (!active) return;\n await publishStatus();\n void telemetry.recordEvent('doom_file_edit.edit_finished', {\n 'tool.name': event.toolName,\n 'tool.result.error': event.isError,\n ...(startedAt === undefined ? {} : { duration_ms: Date.now() - startedAt }),\n outcome: event.isError ? 'failed' : 'completed',\n });\n },\n },\n };\n}\n"],"mappings":"ukBAyBA,SAAS,EAAe,EAA0D,CAChF,IAAI,EACJ,GAAI,CACF,EAAU,EAAG,aAAa,EAAK,KAAK,EAAK,CAAgB,EAAG,MAAM,CACpE,MAAQ,CACN,MACF,CACA,IAAM,EAAU,EAAwB,CAAO,EAC3C,OAAY,IAAA,GAChB,MAAQ,IAA8B,EAAQ,EAAK,SAAS,EAAK,CAAQ,CAAC,CAC5E,CAEA,MAAa,EAAgC,CAC3C,OAAQ,EACR,SAAU,CACR,CACE,GAAI,eACJ,KAAM,CACJ,CAAE,IAAK,IAAK,MAAO,YAAa,OAAQ,2BAA4B,MAAO,EAAG,EAC9E,CAAE,IAAK,IAAK,MAAO,QAAS,OAAQ,eAAgB,CACtD,EACA,QAAS,CAAE,KAAM,CAAc,CACjC,CACF,CACF,EACA,SAAgB,EAAsB,EAA2D,CAC/F,GAAM,CAAE,QAAO,WAAU,YAAW,YAAa,EAAS,YAAa,EACjE,EAA2B,EAAoB,CACnD,YAAa,iBACb,YAAa,EACb,IAAK,QAAQ,IACb,WAAY,GACZ,aAAc,EAChB,CAAC,EACK,EAAgB,IAAI,IACtB,EAAS,GACT,EAAoB,EACpB,EAOE,EAAgB,SAA2B,CAC/C,GAAI,CAAC,GAAU,CAAC,GAAgB,MAAO,OACvC,IAAM,EAAU,MAAM,EAAS,KAAK,EACpC,EAAe,GAAG,UAChB,EACA,EAAQ,SAAW,EAAI,GAAK,GAAG,EAAQ,OAAO,GAAG,EAAQ,SAAW,EAAI,OAAS,SACnF,CACF,EAEM,EAAU,SAAY,CAC1B,EAAS,GACT,GAAqB,EACrB,EAAc,MAAM,EACpB,EAAQ,MAAM,EACd,IAAM,EAAU,EAChB,EAAiB,IAAA,GACjB,GAAI,CACG,QAAQ,IAAI,KACf,MAAM,EAAS,MAAM,EAGrB,MAAM,EAAU,MAAM,GAEpB,GAAS,OAAO,EAAQ,GAAG,UAAU,EAAgB,IAAA,EAAS,EAClE,MAAM,EAAU,YAAY,mCAAoC,CAAE,QAAS,QAAS,CAAC,CACvF,QAAU,CACR,MAAM,EAAU,SAAS,CAC3B,CACF,EAMM,EAAsB,MAAO,EAAa,IAAgC,CAC9E,GAAI,CACF,MAAM,EAAkB,CAAE,UAAW,EAAM,eAAe,EAAG,MAAK,CAAC,EACnE,IAAM,EAAS,EAAM,qBAAqB,CAAG,EAC7C,GAAI,IAAW,IAAA,IAAa,IAAW,EAAM,eAAe,EAAG,OAG/D,MAAM,EAAkB,CAAE,UAAW,EAAQ,gBAAiB,EAAK,CAAC,CACtE,MAAQ,CAER,CACF,EACA,MAAO,CACL,UAAW,EACX,SAAU,CACP,GAAoB,CACnB,EAAO,OAAO,CAAC,CAAmB,EAAI,GAAc,CAClD,IAAM,EAAe,EAAiB,CAAS,CAAC,CAAC,eAAe,CAA6B,EAC7F,UAAa,EAAa,QAAQ,CACpC,CAAC,CACH,CACF,EACA,SAAU,CACR,CACE,EACA,CACE,YAAa,sCACb,QAAS,MAAO,EAAO,IAAQ,CACxB,KACL,IAAI,CAAC,EAAI,MAAO,CACd,EAAI,GAAG,OAAO,wCAAyC,OAAO,EAC9D,MACF,CACA,MAAM,EAAS,KAAK,CAAG,CADvB,CAEF,CACF,CACF,CACF,EACA,OAAQ,CACN,eAAgB,EAAQ,IAAQ,CAC9B,GAAI,CAAC,EAAQ,OACb,GAAqB,EACrB,EAAc,MAAM,EACpB,EAAiB,EACjB,IAAM,EAAa,EAAM,WAAW,EAAI,eAAe,aAAa,CAAC,EAC/D,EAAe,EAAM,aAAa,EAAI,IAAK,CAAU,EACrD,EAAgB,EAAM,cAAc,EAAI,IAAK,CAAU,EAC7D,EAAS,WAAW,CAAY,EAChC,EAAU,WAAW,CAAa,EAMlC,EAAyB,EAAI,IAAK,CAAY,EAC9C,IAAM,EAAY,EAAe,EAAI,GAAG,EACxC,EAAQ,MAAM,CACZ,QAAS,CAAC,EAAc,GAAG,EAAa,OAAQ,CAAa,EAC7D,GAAI,IAAc,IAAA,GAAY,CAAC,EAAI,CAAE,WAAU,CACjD,CAAC,EAGD,EAAmB,EACnB,EAAe,YAAY,kCAAmC,CAAE,QAAS,aAAc,CAAC,CAC1F,EACA,qBAAsB,MAAO,EAAO,IAAQ,CAC1C,GAAI,CAAC,EAAQ,OACb,IAAM,EAAgB,EAGtB,GAFA,EAAc,IAAI,EAAM,WAAY,KAAK,IAAI,CAAC,EAC9C,MAAM,EAAQ,MAAM,EAAM,WAAY,EAAM,SAAU,EAAM,KAAM,EAAI,GAAG,EACrE,CAAC,GAAU,IAAkB,EAAmB,CAClD,EAAc,OAAO,EAAM,UAAU,EACrC,MACF,CACA,EAAe,YAAY,8BAA+B,CAAE,YAAa,EAAM,QAAS,CAAC,CAC3F,EACA,mBAAoB,MAAO,EAAO,IAAQ,CACxC,GAAI,CAAC,EAAQ,OACb,IAAM,EAAY,EAAc,IAAI,EAAM,UAAU,EACpD,EAAc,OAAO,EAAM,UAAU,EACrC,MAAM,EAAQ,IAAI,EAAM,WAAY,EAAM,QAAS,EAAI,GAAG,EACrD,IACL,MAAM,EAAc,EACpB,EAAe,YAAY,+BAAgC,CACzD,YAAa,EAAM,SACnB,oBAAqB,EAAM,QAC3B,GAAI,IAAc,IAAA,GAAY,CAAC,EAAI,CAAE,YAAa,KAAK,IAAI,EAAI,CAAU,EACzE,QAAS,EAAM,QAAU,SAAW,WACtC,CAAC,EACH,CACF,CACF,CACF"}
1
+ {"version":3,"file":"fileEditRuntime.mjs","names":[],"sources":["../../src/controllers/fileEditRuntime.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nimport { SUBAGENT_CHILD_ENV } from '@agimon-ai/doompi-core/child-process';\nimport type { LeaderContribution } from '@agimon-ai/doompi-core/leader';\nimport type { PiPluginContributions } from '@agimon-ai/doompi-core/pi-extension';\nimport { DOOM_UI_HUB_SERVICE, requireDoomUiHub } from '@agimon-ai/doompi-core/ui-hub';\nimport { createDoomTelemetry, type DoomTelemetry } from '@agimon-ai/doompi-telemetry';\nimport type { Context } from '@deepseek-ai/cordis';\nimport type { ExtensionContext } from '@earendil-works/pi-coding-agent';\n\nimport { FILES_COMMAND, PACKAGE_SOURCE, DOOM_IGNORE_FILE } from '../constants/package';\nimport { createDoomIgnoreMatcher } from '../services/doomIgnore';\nimport { sweepSessionState } from '../services/sessionStateSweep';\nimport type { FileEditDependencies } from '../types';\nimport { filesStatusKey } from '../types/webFiles';\n/**\n * The project's ignore rules as a test over absolute paths, or nothing when the\n * file is absent, empty, or unreadable.\n *\n * Read once per session, like the exclude list it is passed beside. The web\n * reader re-reads the same file per request, so a mid-session edit reaches the\n * list before it reaches the recorder; that is a pre-existing difference and\n * not one this read introduces.\n */\nfunction readDoomIgnore(cwd: string): ((filePath: string) => boolean) | undefined {\n let content: string;\n try {\n content = fs.readFileSync(path.join(cwd, DOOM_IGNORE_FILE), 'utf8');\n } catch {\n return undefined;\n }\n const matcher = createDoomIgnoreMatcher(content);\n if (matcher === undefined) return undefined;\n return (filePath: string): boolean => matcher(path.relative(cwd, filePath));\n}\n\nexport const FILE_EDIT_LEADER_CONTRIBUTION = {\n source: PACKAGE_SOURCE,\n bindings: [\n {\n id: 'editor.files',\n path: [\n { key: 'e', label: 'extension', detail: 'tools, skills and config', order: 50 },\n { key: 'f', label: 'files', detail: 'session edits' },\n ],\n command: { name: FILES_COMMAND },\n },\n ],\n} as const satisfies LeaderContribution;\nexport function createFileEditRuntime(dependencies: FileEditDependencies): PiPluginContributions {\n const { paths, timeline, snapshots, editTracker: tracker, workflow } = dependencies;\n const telemetry: DoomTelemetry = createDoomTelemetry({\n serviceName: 'doom-file-edit',\n packageName: PACKAGE_SOURCE,\n env: process.env,\n enableLogs: true,\n enableTraces: true,\n });\n const toolStartedAt = new Map<string, number>();\n let active = true;\n let sessionGeneration = 0;\n let sessionContext: ExtensionContext | undefined;\n\n /**\n * Publishes the count the cockpit's activity group keys off. The group is\n * hidden until this status exists, so it is written on every append rather\n * than only when the count changes.\n */\n const publishStatus = async (): Promise<void> => {\n if (!active || !sessionContext?.hasUI) return;\n const entries = await timeline.list();\n sessionContext.ui.setStatus(\n filesStatusKey,\n entries.length === 0 ? '' : `${entries.length} ${entries.length === 1 ? 'file' : 'files'}`,\n );\n };\n\n const dispose = async () => {\n active = false;\n sessionGeneration += 1;\n toolStartedAt.clear();\n // A scan deferred by the last bash call would otherwise append after the\n // clear below and leave a timeline no session owns.\n await tracker.flush();\n tracker.reset();\n const closing = sessionContext;\n sessionContext = undefined;\n try {\n if (!process.env[SUBAGENT_CHILD_ENV]) {\n await timeline.clear();\n // The snapshots exist only to diff this session, so they go with it\n // rather than accumulating a copy of the tree on every run.\n await snapshots.clear();\n }\n if (closing?.hasUI) closing.ui.setStatus(filesStatusKey, undefined);\n await telemetry.recordEvent('doom_file_edit.timeline_finished', { outcome: 'closed' });\n } finally {\n await telemetry.shutdown();\n }\n };\n\n /**\n * Clears state no session will claim again. Never awaited and never allowed\n * to throw: a sweep that cannot run is not a reason to fail a session.\n */\n const sweepAbandonedState = async (cwd: string, keep: string): Promise<void> => {\n try {\n await sweepSessionState({ directory: paths.stateDirectory(), keep });\n const legacy = paths.legacyStateDirectory(cwd);\n if (legacy === undefined || legacy === paths.stateDirectory()) return;\n // Same age rule, so a build still running against the old location is\n // not robbed of the session it is in the middle of.\n await sweepSessionState({ directory: legacy, removeDirectory: true });\n } catch {\n // Nothing here is worth interrupting a session that is starting.\n }\n };\n return {\n onDispose: dispose,\n services: [\n (cordis: Context) => {\n cordis.inject([DOOM_UI_HUB_SERVICE], (uiContext) => {\n const contribution = requireDoomUiHub(uiContext).registerLeader(FILE_EDIT_LEADER_CONTRIBUTION);\n return () => contribution.dispose();\n });\n },\n ],\n commands: [\n [\n FILES_COMMAND,\n {\n description: 'Review files edited in this session',\n handler: async (_args, ctx) => {\n if (!active) return;\n if (!ctx.hasUI) {\n ctx.ui.notify('/file-edits requires interactive mode', 'error');\n return;\n }\n await workflow.open(ctx);\n },\n },\n ],\n ],\n events: {\n session_start: (_event, ctx) => {\n if (!active) return;\n sessionGeneration += 1;\n toolStartedAt.clear();\n sessionContext = ctx;\n const sessionKey = paths.sessionKey(ctx.sessionManager.getSessionId());\n const timelinePath = paths.timelinePath(ctx.cwd, sessionKey);\n const snapshotsPath = paths.snapshotsPath(ctx.cwd, sessionKey);\n timeline.initialize(timelinePath);\n snapshots.initialize(snapshotsPath);\n // Inside a git worktree these land under the repository's own git\n // directory, which can sit inside the tree the tracker walks. Naming them\n // here is what stops the package's bookkeeping reading as session edits.\n // Whatever earlier sessions were killed before they could clean up, plus\n // anything an older build left in the repository's own git directory.\n void sweepAbandonedState(ctx.cwd, timelinePath);\n const isIgnored = readDoomIgnore(ctx.cwd);\n tracker.reset({\n exclude: [timelinePath, `${timelinePath}.lock`, snapshotsPath],\n ...(isIgnored === undefined ? {} : { isIgnored }),\n });\n // A resumed session already has a timeline, so the group must show its\n // count from the first paint rather than waiting for the next edit.\n void publishStatus();\n void telemetry.recordEvent('doom_file_edit.timeline_started', { outcome: 'initialized' });\n },\n tool_execution_start: async (event, ctx) => {\n if (!active) return;\n const ownGeneration = sessionGeneration;\n toolStartedAt.set(event.toolCallId, Date.now());\n const hookStartedAt = Date.now();\n await tracker.start(event.toolCallId, event.toolName, event.args, ctx.cwd);\n const hookMs = Date.now() - hookStartedAt;\n if (!active || ownGeneration !== sessionGeneration) {\n toolStartedAt.delete(event.toolCallId);\n return;\n }\n void telemetry.recordEvent('doom_file_edit.edit_started', {\n 'tool.name': event.toolName,\n hook_ms: hookMs,\n });\n },\n tool_execution_end: async (event, ctx) => {\n if (!active) return;\n const startedAt = toolStartedAt.get(event.toolCallId);\n toolStartedAt.delete(event.toolCallId);\n // Measured separately from the tool's own duration: this is the part of\n // the wait that belongs to this package rather than to the tool.\n const hookStartedAt = Date.now();\n await tracker.end(event.toolCallId, event.isError, ctx.cwd);\n if (!active) return;\n await publishStatus();\n const hookMs = Date.now() - hookStartedAt;\n void telemetry.recordEvent('doom_file_edit.edit_finished', {\n 'tool.name': event.toolName,\n 'tool.result.error': event.isError,\n ...(startedAt === undefined ? {} : { duration_ms: Date.now() - startedAt }),\n hook_ms: hookMs,\n outcome: event.isError ? 'failed' : 'completed',\n });\n },\n },\n };\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- const e=require("../../_virtual/_rolldown/runtime.cjs"),t=require("../lineDiff/index.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=[];isIgnored;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??[],this.isIgnored=e.isIgnored}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),[c,l]=await Promise.all([this.snapshots.capture(s),this.manifests.fingerprint(s)]);this.pending.set(e,{tool:t,filePath:s,before:c,existed:l!==void 0});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),r){t||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=!e.existed,r=await this.countChanges(e.before,t,n);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?{created:!0}:{},...r}),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=this.isIgnored,o=this.manifests.changed(n,i).filter(e=>a===void 0||!a(e)),s=await this.writtenDuring(o,t);if(s.length===0)return;let c=await this.unchangedInGit(e,s);for(let e of s){let t=this.contents.get(e);if(t===void 0&&c.has(e))continue;let a=n.entries.get(e),o=i.entries.get(e),s=o===void 0?void 0:await this.snapshots.capture(e);if(s!==void 0&&this.contents.set(e,s),s!==void 0&&s===t)continue;let l=a===void 0||o===void 0||this.manifests.sizeChanged(a,o)||t!==void 0&&s!==void 0&&s!==t;await this.append({version:2,path:e,tool:r,at:this.now(),origin:`scan`,...l?{verified:!0}:{},...s===void 0?{}:{after:s}})}}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,r){if(n===void 0)return{};if(e===void 0){if(!r)return{};let e=await this.snapshots.read(n);if(e===void 0)return{};let i=t.lineDiffFromEmpty(e);return{additions:i.additions,removals:i.removals}}if(e===n)return{additions:0,removals:0};let[i,a]=await Promise.all([this.snapshots.read(e),this.snapshots.read(n)]);if(i===void 0||a===void 0)return{};let o=t.lineDiff(i,a);return{additions:o.additions,removals:o.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("../lineDiff/index.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=[];isIgnored;contents=new Map;now;git;scanning=Promise.resolve();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.scanning=Promise.resolve(),this.excluded=e.exclude??[],this.isIgnored=e.isIgnored}async flush(){await this.scanning}async start(e,t,a,o){if(await this.scanning,t===`edit`||t===`write`){let r=i(a,`path`);if(!r)return;let s=n.default.resolve(o,r),[c,l]=await Promise.all([this.snapshots.capture(s),this.manifests.fingerprint(s)]);this.pending.set(e,{tool:t,filePath:s,before:c,existed:l!==void 0});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),r){t||await this.recordTool(r);return}i!==void 0&&(this.scanning=this.scanning.then(async()=>{try{await this.recordScan(n,i)}catch{}}))}async recordTool(e){let t=await this.snapshots.capture(e.filePath),n=!e.existed,r=await this.countChanges(e.before,t,n);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?{created:!0}:{},...r}),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=this.isIgnored,o=this.manifests.changed(n,i).filter(e=>a===void 0||!a(e)),s=await this.writtenDuring(o,t);if(s.length===0)return;let c=await this.unchangedInGit(e,s);for(let e of s){let t=this.contents.get(e);if(t===void 0&&c.has(e))continue;let a=n.entries.get(e),o=i.entries.get(e),s=o===void 0?void 0:await this.snapshots.capture(e);if(s!==void 0&&this.contents.set(e,s),s!==void 0&&s===t)continue;let l=a===void 0||o===void 0||this.manifests.sizeChanged(a,o)||t!==void 0&&s!==void 0&&s!==t;await this.append({version:2,path:e,tool:r,at:this.now(),origin:`scan`,...l?{verified:!0}:{},...s===void 0?{}:{after:s}})}}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,r){if(n===void 0)return{};if(e===void 0){if(!r)return{};let e=await this.snapshots.read(n);if(e===void 0)return{};let i=t.lineDiffFromEmpty(e);return{additions:i.additions,removals:i.removals}}if(e===n)return{additions:0,removals:0};let[i,a]=await Promise.all([this.snapshots.read(e),this.snapshots.read(n)]);if(i===void 0||a===void 0)return{};let o=t.lineDiff(i,a);return{additions:o.additions,removals:o.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=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["path","lineDiffFromEmpty","lineDiff"],"sources":["../../../src/services/editTracker/index.ts"],"sourcesContent":["import path from 'node:path';\n\nimport type { FileEditTool, TimelineEvent } from '../../types/domain';\nimport type { IEditTracker } from '../../types/editTracker';\nimport type { GitStatusPort } from '../../types/gitStatus';\nimport type { SnapshotStorePort } from '../../types/snapshotStore';\nimport type { ITimelineStore } from '../../types/timelineStore';\nimport type { TreeManifest, TreeManifestPort } from '../../types/treeManifest';\nimport { lineDiff, lineDiffFromEmpty } from '../lineDiff';\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 called verified only when something actually\n * proves its bytes moved: it appeared, it vanished, its recorded size differs,\n * or its content hash differs from one this session already took. Absence of\n * proof is not proof, so a candidate that shows none of those is still\n * recorded, because the timeline is the evidence, but it goes out unverified\n * and every surface leaves it out.\n */\ninterface PendingEdit {\n tool: 'edit' | 'write';\n filePath: string;\n before: string | undefined;\n /**\n * Whether the file was there when the call began. A missing `before` cannot\n * say on its own: content is left uncaptured both for a file that does not\n * exist and for one too large or too binary to store.\n */\n existed: boolean;\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 project's own ignore rules. A path the project disowns is not worth a\n * git call, a read, or a stored copy of its content, so it is dropped before\n * it costs any of them.\n */\n private isIgnored: ((filePath: string) => boolean) | undefined;\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[]; isIgnored?: (filePath: string) => boolean } = {}): void {\n this.pending.clear();\n this.bracketed.clear();\n this.contents.clear();\n this.manifest = undefined;\n this.excluded = options.exclude ?? [];\n this.isIgnored = options.isIgnored;\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 // Both are read before the tool runs. The fingerprint is the only thing\n // that tells a file being created from one whose content could not be\n // captured, because a capture answers undefined for either.\n const [before, fingerprint] = await Promise.all([\n this.snapshots.capture(filePath),\n this.manifests.fingerprint(filePath),\n ]);\n this.pending.set(id, { tool, filePath, before, existed: fingerprint !== undefined });\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 (pending) {\n // A failed edit or write never landed, and its arguments named the file,\n // so there is nothing left to look for.\n if (!isError) await this.recordTool(pending);\n return;\n }\n // A failed command may still have written before it failed. Skipping the\n // walk would leave the baseline stale and hand those writes to whichever\n // call closes next.\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 created = !pending.existed;\n const counts = await this.countChanges(pending.before, after, created);\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 ...(created ? { created: true } : {}),\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 disowned = this.isIgnored;\n const named = this.manifests\n .changed(before, after)\n .filter((filePath) => disowned === undefined || !disowned(filePath));\n const candidates = await this.writtenDuring(named, startedAt);\n if (candidates.length === 0) return;\n const unchanged = await this.unchangedInGit(cwd, candidates);\n for (const filePath of candidates) {\n const known = this.contents.get(filePath);\n // Asked before the file is read: a path git vouches for is worth neither\n // the read nor the stored copy that reading it leaves behind.\n if (known === undefined && unchanged.has(filePath)) continue;\n const beforePrint = before.entries.get(filePath);\n const afterPrint = after.entries.get(filePath);\n // A path that is gone has nothing to read, and reading it back would only\n // confirm that.\n const captured = afterPrint === undefined ? undefined : await this.snapshots.capture(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 const proven =\n beforePrint === undefined ||\n afterPrint === undefined ||\n this.manifests.sizeChanged(beforePrint, afterPrint) ||\n (known !== undefined && captured !== undefined && captured !== known);\n await this.append({\n version: 2,\n path: filePath,\n tool: BASH_TOOL,\n at: this.now(),\n origin: 'scan',\n ...(proven ? { 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 there is enough captured content to say. */\n private async countChanges(\n before: string | undefined,\n after: string | undefined,\n created: boolean,\n ): Promise<{ additions?: number; removals?: number }> {\n if (after === undefined) return {};\n if (before === undefined) {\n // A file that did not exist has an empty baseline, so every line it now\n // holds is an addition. A file that existed but went uncaptured has no\n // baseline to count against at all.\n if (!created) return {};\n const createdText = await this.snapshots.read(after);\n if (createdText === undefined) return {};\n const fresh = lineDiffFromEmpty(createdText);\n return { additions: fresh.additions, removals: fresh.removals };\n }\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":"index.cjs","names":["path","lineDiffFromEmpty","lineDiff"],"sources":["../../../src/services/editTracker/index.ts"],"sourcesContent":["import path from 'node:path';\n\nimport type { FileEditTool, TimelineEvent } from '../../types/domain';\nimport type { IEditTracker } from '../../types/editTracker';\nimport type { GitStatusPort } from '../../types/gitStatus';\nimport type { SnapshotStorePort } from '../../types/snapshotStore';\nimport type { ITimelineStore } from '../../types/timelineStore';\nimport type { TreeManifest, TreeManifestPort } from '../../types/treeManifest';\nimport { lineDiff, lineDiffFromEmpty } from '../lineDiff';\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 called verified only when something actually\n * proves its bytes moved: it appeared, it vanished, its recorded size differs,\n * or its content hash differs from one this session already took. Absence of\n * proof is not proof, so a candidate that shows none of those is still\n * recorded, because the timeline is the evidence, but it goes out unverified\n * and every surface leaves it out.\n */\ninterface PendingEdit {\n tool: 'edit' | 'write';\n filePath: string;\n before: string | undefined;\n /**\n * Whether the file was there when the call began. A missing `before` cannot\n * say on its own: content is left uncaptured both for a file that does not\n * exist and for one too large or too binary to store.\n */\n existed: boolean;\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 project's own ignore rules. A path the project disowns is not worth a\n * git call, a read, or a stored copy of its content, so it is dropped before\n * it costs any of them.\n */\n private isIgnored: ((filePath: string) => boolean) | undefined;\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 * The scan deferred by the last bash call. Every bracket joins this chain, so\n * at most one walk runs at a time and each one sees the baseline the previous\n * one left behind.\n */\n private scanning: Promise<void> = Promise.resolve();\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[]; isIgnored?: (filePath: string) => boolean } = {}): void {\n this.pending.clear();\n this.bracketed.clear();\n this.contents.clear();\n this.manifest = undefined;\n this.scanning = Promise.resolve();\n this.excluded = options.exclude ?? [];\n this.isIgnored = options.isIgnored;\n }\n\n /**\n * Awaits a scan still running in the background. A caller that is about to\n * clear the timeline needs this, or the scan appends after the clear.\n */\n async flush(): Promise<void> {\n await this.scanning;\n }\n async start(id: string, tool: string, args: unknown, cwd: string): Promise<void> {\n // A deferred scan still owns the baseline manifest, so it settles before\n // this bracket reads or replaces it.\n await this.scanning;\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 // Both are read before the tool runs. The fingerprint is the only thing\n // that tells a file being created from one whose content could not be\n // captured, because a capture answers undefined for either.\n const [before, fingerprint] = await Promise.all([\n this.snapshots.capture(filePath),\n this.manifests.fingerprint(filePath),\n ]);\n this.pending.set(id, { tool, filePath, before, existed: fingerprint !== undefined });\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 (pending) {\n // A failed edit or write never landed, and its arguments named the file,\n // so there is nothing left to look for.\n if (!isError) await this.recordTool(pending);\n return;\n }\n // A failed command may still have written before it failed. Skipping the\n // walk would leave the baseline stale and hand those writes to whichever\n // call closes next.\n if (startedAt === undefined) return;\n // The walk is the expensive half of this hook and pi awaits the hook before\n // it hands the tool result back to the model. Deferring it onto the chain\n // moves that cost into the model's own thinking time instead of the user's\n // wait; start() joins the same chain, so ordering still holds.\n this.scanning = this.scanning.then(async () => {\n try {\n await this.recordScan(cwd, startedAt);\n } catch {\n // One failed scan must not poison the chain for every later call.\n }\n });\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 created = !pending.existed;\n const counts = await this.countChanges(pending.before, after, created);\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 ...(created ? { created: true } : {}),\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 disowned = this.isIgnored;\n const named = this.manifests\n .changed(before, after)\n .filter((filePath) => disowned === undefined || !disowned(filePath));\n const candidates = await this.writtenDuring(named, startedAt);\n if (candidates.length === 0) return;\n const unchanged = await this.unchangedInGit(cwd, candidates);\n for (const filePath of candidates) {\n const known = this.contents.get(filePath);\n // Asked before the file is read: a path git vouches for is worth neither\n // the read nor the stored copy that reading it leaves behind.\n if (known === undefined && unchanged.has(filePath)) continue;\n const beforePrint = before.entries.get(filePath);\n const afterPrint = after.entries.get(filePath);\n // A path that is gone has nothing to read, and reading it back would only\n // confirm that.\n const captured = afterPrint === undefined ? undefined : await this.snapshots.capture(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 const proven =\n beforePrint === undefined ||\n afterPrint === undefined ||\n this.manifests.sizeChanged(beforePrint, afterPrint) ||\n (known !== undefined && captured !== undefined && captured !== known);\n await this.append({\n version: 2,\n path: filePath,\n tool: BASH_TOOL,\n at: this.now(),\n origin: 'scan',\n ...(proven ? { 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 there is enough captured content to say. */\n private async countChanges(\n before: string | undefined,\n after: string | undefined,\n created: boolean,\n ): Promise<{ additions?: number; removals?: number }> {\n if (after === undefined) return {};\n if (before === undefined) {\n // A file that did not exist has an empty baseline, so every line it now\n // holds is an addition. A file that existed but went uncaptured has no\n // baseline to count against at all.\n if (!created) return {};\n const createdText = await this.snapshots.read(after);\n if (createdText === undefined) return {};\n const fresh = lineDiffFromEmpty(createdText);\n return { additions: fresh.additions, removals: fresh.removals };\n }\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,lineDiffFromEmpty as t}from"../lineDiff/index.mjs";import n from"node:path";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=[];isIgnored;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??[],this.isIgnored=e.isIgnored}async start(e,t,a,o){if(t===`edit`||t===`write`){let r=i(a,`path`);if(!r)return;let s=n.resolve(o,r),[c,l]=await Promise.all([this.snapshots.capture(s),this.manifests.fingerprint(s)]);this.pending.set(e,{tool:t,filePath:s,before:c,existed:l!==void 0});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),r){t||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=!e.existed,r=await this.countChanges(e.before,t,n);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?{created:!0}:{},...r}),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=this.isIgnored,o=this.manifests.changed(n,i).filter(e=>a===void 0||!a(e)),s=await this.writtenDuring(o,t);if(s.length===0)return;let c=await this.unchangedInGit(e,s);for(let e of s){let t=this.contents.get(e);if(t===void 0&&c.has(e))continue;let a=n.entries.get(e),o=i.entries.get(e),s=o===void 0?void 0:await this.snapshots.capture(e);if(s!==void 0&&this.contents.set(e,s),s!==void 0&&s===t)continue;let l=a===void 0||o===void 0||this.manifests.sizeChanged(a,o)||t!==void 0&&s!==void 0&&s!==t;await this.append({version:2,path:e,tool:r,at:this.now(),origin:`scan`,...l?{verified:!0}:{},...s===void 0?{}:{after:s}})}}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(n,r,i){if(r===void 0)return{};if(n===void 0){if(!i)return{};let e=await this.snapshots.read(r);if(e===void 0)return{};let n=t(e);return{additions:n.additions,removals:n.removals}}if(n===r)return{additions:0,removals:0};let[a,o]=await Promise.all([this.snapshots.read(n),this.snapshots.read(r)]);if(a===void 0||o===void 0)return{};let s=e(a,o);return{additions:s.additions,removals:s.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{a as EditTracker};
1
+ import{lineDiff as e,lineDiffFromEmpty as t}from"../lineDiff/index.mjs";import n from"node:path";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=[];isIgnored;contents=new Map;now;git;scanning=Promise.resolve();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.scanning=Promise.resolve(),this.excluded=e.exclude??[],this.isIgnored=e.isIgnored}async flush(){await this.scanning}async start(e,t,a,o){if(await this.scanning,t===`edit`||t===`write`){let r=i(a,`path`);if(!r)return;let s=n.resolve(o,r),[c,l]=await Promise.all([this.snapshots.capture(s),this.manifests.fingerprint(s)]);this.pending.set(e,{tool:t,filePath:s,before:c,existed:l!==void 0});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),r){t||await this.recordTool(r);return}i!==void 0&&(this.scanning=this.scanning.then(async()=>{try{await this.recordScan(n,i)}catch{}}))}async recordTool(e){let t=await this.snapshots.capture(e.filePath),n=!e.existed,r=await this.countChanges(e.before,t,n);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?{created:!0}:{},...r}),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=this.isIgnored,o=this.manifests.changed(n,i).filter(e=>a===void 0||!a(e)),s=await this.writtenDuring(o,t);if(s.length===0)return;let c=await this.unchangedInGit(e,s);for(let e of s){let t=this.contents.get(e);if(t===void 0&&c.has(e))continue;let a=n.entries.get(e),o=i.entries.get(e),s=o===void 0?void 0:await this.snapshots.capture(e);if(s!==void 0&&this.contents.set(e,s),s!==void 0&&s===t)continue;let l=a===void 0||o===void 0||this.manifests.sizeChanged(a,o)||t!==void 0&&s!==void 0&&s!==t;await this.append({version:2,path:e,tool:r,at:this.now(),origin:`scan`,...l?{verified:!0}:{},...s===void 0?{}:{after:s}})}}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(n,r,i){if(r===void 0)return{};if(n===void 0){if(!i)return{};let e=await this.snapshots.read(r);if(e===void 0)return{};let n=t(e);return{additions:n.additions,removals:n.removals}}if(n===r)return{additions:0,removals:0};let[a,o]=await Promise.all([this.snapshots.read(n),this.snapshots.read(r)]);if(a===void 0||o===void 0)return{};let s=e(a,o);return{additions:s.additions,removals:s.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{a as EditTracker};
2
2
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../../src/services/editTracker/index.ts"],"sourcesContent":["import path from 'node:path';\n\nimport type { FileEditTool, TimelineEvent } from '../../types/domain';\nimport type { IEditTracker } from '../../types/editTracker';\nimport type { GitStatusPort } from '../../types/gitStatus';\nimport type { SnapshotStorePort } from '../../types/snapshotStore';\nimport type { ITimelineStore } from '../../types/timelineStore';\nimport type { TreeManifest, TreeManifestPort } from '../../types/treeManifest';\nimport { lineDiff, lineDiffFromEmpty } from '../lineDiff';\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 called verified only when something actually\n * proves its bytes moved: it appeared, it vanished, its recorded size differs,\n * or its content hash differs from one this session already took. Absence of\n * proof is not proof, so a candidate that shows none of those is still\n * recorded, because the timeline is the evidence, but it goes out unverified\n * and every surface leaves it out.\n */\ninterface PendingEdit {\n tool: 'edit' | 'write';\n filePath: string;\n before: string | undefined;\n /**\n * Whether the file was there when the call began. A missing `before` cannot\n * say on its own: content is left uncaptured both for a file that does not\n * exist and for one too large or too binary to store.\n */\n existed: boolean;\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 project's own ignore rules. A path the project disowns is not worth a\n * git call, a read, or a stored copy of its content, so it is dropped before\n * it costs any of them.\n */\n private isIgnored: ((filePath: string) => boolean) | undefined;\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[]; isIgnored?: (filePath: string) => boolean } = {}): void {\n this.pending.clear();\n this.bracketed.clear();\n this.contents.clear();\n this.manifest = undefined;\n this.excluded = options.exclude ?? [];\n this.isIgnored = options.isIgnored;\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 // Both are read before the tool runs. The fingerprint is the only thing\n // that tells a file being created from one whose content could not be\n // captured, because a capture answers undefined for either.\n const [before, fingerprint] = await Promise.all([\n this.snapshots.capture(filePath),\n this.manifests.fingerprint(filePath),\n ]);\n this.pending.set(id, { tool, filePath, before, existed: fingerprint !== undefined });\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 (pending) {\n // A failed edit or write never landed, and its arguments named the file,\n // so there is nothing left to look for.\n if (!isError) await this.recordTool(pending);\n return;\n }\n // A failed command may still have written before it failed. Skipping the\n // walk would leave the baseline stale and hand those writes to whichever\n // call closes next.\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 created = !pending.existed;\n const counts = await this.countChanges(pending.before, after, created);\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 ...(created ? { created: true } : {}),\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 disowned = this.isIgnored;\n const named = this.manifests\n .changed(before, after)\n .filter((filePath) => disowned === undefined || !disowned(filePath));\n const candidates = await this.writtenDuring(named, startedAt);\n if (candidates.length === 0) return;\n const unchanged = await this.unchangedInGit(cwd, candidates);\n for (const filePath of candidates) {\n const known = this.contents.get(filePath);\n // Asked before the file is read: a path git vouches for is worth neither\n // the read nor the stored copy that reading it leaves behind.\n if (known === undefined && unchanged.has(filePath)) continue;\n const beforePrint = before.entries.get(filePath);\n const afterPrint = after.entries.get(filePath);\n // A path that is gone has nothing to read, and reading it back would only\n // confirm that.\n const captured = afterPrint === undefined ? undefined : await this.snapshots.capture(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 const proven =\n beforePrint === undefined ||\n afterPrint === undefined ||\n this.manifests.sizeChanged(beforePrint, afterPrint) ||\n (known !== undefined && captured !== undefined && captured !== known);\n await this.append({\n version: 2,\n path: filePath,\n tool: BASH_TOOL,\n at: this.now(),\n origin: 'scan',\n ...(proven ? { 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 there is enough captured content to say. */\n private async countChanges(\n before: string | undefined,\n after: string | undefined,\n created: boolean,\n ): Promise<{ additions?: number; removals?: number }> {\n if (after === undefined) return {};\n if (before === undefined) {\n // A file that did not exist has an empty baseline, so every line it now\n // holds is an addition. A file that existed but went uncaptured has no\n // baseline to count against at all.\n if (!created) return {};\n const createdText = await this.snapshots.read(after);\n if (createdText === undefined) return {};\n const fresh = lineDiffFromEmpty(createdText);\n return { additions: fresh.additions, removals: fresh.removals };\n }\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":"index.mjs","names":[],"sources":["../../../src/services/editTracker/index.ts"],"sourcesContent":["import path from 'node:path';\n\nimport type { FileEditTool, TimelineEvent } from '../../types/domain';\nimport type { IEditTracker } from '../../types/editTracker';\nimport type { GitStatusPort } from '../../types/gitStatus';\nimport type { SnapshotStorePort } from '../../types/snapshotStore';\nimport type { ITimelineStore } from '../../types/timelineStore';\nimport type { TreeManifest, TreeManifestPort } from '../../types/treeManifest';\nimport { lineDiff, lineDiffFromEmpty } from '../lineDiff';\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 called verified only when something actually\n * proves its bytes moved: it appeared, it vanished, its recorded size differs,\n * or its content hash differs from one this session already took. Absence of\n * proof is not proof, so a candidate that shows none of those is still\n * recorded, because the timeline is the evidence, but it goes out unverified\n * and every surface leaves it out.\n */\ninterface PendingEdit {\n tool: 'edit' | 'write';\n filePath: string;\n before: string | undefined;\n /**\n * Whether the file was there when the call began. A missing `before` cannot\n * say on its own: content is left uncaptured both for a file that does not\n * exist and for one too large or too binary to store.\n */\n existed: boolean;\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 project's own ignore rules. A path the project disowns is not worth a\n * git call, a read, or a stored copy of its content, so it is dropped before\n * it costs any of them.\n */\n private isIgnored: ((filePath: string) => boolean) | undefined;\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 * The scan deferred by the last bash call. Every bracket joins this chain, so\n * at most one walk runs at a time and each one sees the baseline the previous\n * one left behind.\n */\n private scanning: Promise<void> = Promise.resolve();\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[]; isIgnored?: (filePath: string) => boolean } = {}): void {\n this.pending.clear();\n this.bracketed.clear();\n this.contents.clear();\n this.manifest = undefined;\n this.scanning = Promise.resolve();\n this.excluded = options.exclude ?? [];\n this.isIgnored = options.isIgnored;\n }\n\n /**\n * Awaits a scan still running in the background. A caller that is about to\n * clear the timeline needs this, or the scan appends after the clear.\n */\n async flush(): Promise<void> {\n await this.scanning;\n }\n async start(id: string, tool: string, args: unknown, cwd: string): Promise<void> {\n // A deferred scan still owns the baseline manifest, so it settles before\n // this bracket reads or replaces it.\n await this.scanning;\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 // Both are read before the tool runs. The fingerprint is the only thing\n // that tells a file being created from one whose content could not be\n // captured, because a capture answers undefined for either.\n const [before, fingerprint] = await Promise.all([\n this.snapshots.capture(filePath),\n this.manifests.fingerprint(filePath),\n ]);\n this.pending.set(id, { tool, filePath, before, existed: fingerprint !== undefined });\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 (pending) {\n // A failed edit or write never landed, and its arguments named the file,\n // so there is nothing left to look for.\n if (!isError) await this.recordTool(pending);\n return;\n }\n // A failed command may still have written before it failed. Skipping the\n // walk would leave the baseline stale and hand those writes to whichever\n // call closes next.\n if (startedAt === undefined) return;\n // The walk is the expensive half of this hook and pi awaits the hook before\n // it hands the tool result back to the model. Deferring it onto the chain\n // moves that cost into the model's own thinking time instead of the user's\n // wait; start() joins the same chain, so ordering still holds.\n this.scanning = this.scanning.then(async () => {\n try {\n await this.recordScan(cwd, startedAt);\n } catch {\n // One failed scan must not poison the chain for every later call.\n }\n });\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 created = !pending.existed;\n const counts = await this.countChanges(pending.before, after, created);\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 ...(created ? { created: true } : {}),\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 disowned = this.isIgnored;\n const named = this.manifests\n .changed(before, after)\n .filter((filePath) => disowned === undefined || !disowned(filePath));\n const candidates = await this.writtenDuring(named, startedAt);\n if (candidates.length === 0) return;\n const unchanged = await this.unchangedInGit(cwd, candidates);\n for (const filePath of candidates) {\n const known = this.contents.get(filePath);\n // Asked before the file is read: a path git vouches for is worth neither\n // the read nor the stored copy that reading it leaves behind.\n if (known === undefined && unchanged.has(filePath)) continue;\n const beforePrint = before.entries.get(filePath);\n const afterPrint = after.entries.get(filePath);\n // A path that is gone has nothing to read, and reading it back would only\n // confirm that.\n const captured = afterPrint === undefined ? undefined : await this.snapshots.capture(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 const proven =\n beforePrint === undefined ||\n afterPrint === undefined ||\n this.manifests.sizeChanged(beforePrint, afterPrint) ||\n (known !== undefined && captured !== undefined && captured !== known);\n await this.append({\n version: 2,\n path: filePath,\n tool: BASH_TOOL,\n at: this.now(),\n origin: 'scan',\n ...(proven ? { 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 there is enough captured content to say. */\n private async countChanges(\n before: string | undefined,\n after: string | undefined,\n created: boolean,\n ): Promise<{ additions?: number; removals?: number }> {\n if (after === undefined) return {};\n if (before === undefined) {\n // A file that did not exist has an empty baseline, so every line it now\n // holds is an addition. A file that existed but went uncaptured has no\n // baseline to count against at all.\n if (!created) return {};\n const createdText = await this.snapshots.read(after);\n if (createdText === undefined) return {};\n const fresh = lineDiffFromEmpty(createdText);\n return { additions: fresh.additions, removals: fresh.removals };\n }\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":"iGAUA,MAEM,EAAY,OAwClB,SAAS,EAAY,EAAgB,EAAiC,CACpE,GAAI,CAAC,GAAS,OAAO,GAAU,SAAU,OACzC,IAAM,EAAS,EAAkC,GACjD,OAAO,OAAO,GAAU,SAAW,EAAQ,IAAA,EAC7C,CAqBA,IAAa,EAAb,KAAiD,CAqC5B,SACA,UACA,UAtCnB,QAA2B,IAAI,IAE/B,UAA6B,IAAI,IAMjC,SAMA,SAAsC,CAAC,EAMvC,UAKA,SAA4B,IAAI,IAChC,IACA,IAMA,SAAkC,QAAQ,QAAQ,EAElD,YACE,EACA,EACA,EACA,EAA8B,CAAC,EAC/B,CAJiB,KAAA,SAAA,EACA,KAAA,UAAA,EACA,KAAA,UAAA,EAGjB,KAAK,IAAM,EAAQ,KAAO,KAAK,IAC/B,KAAK,IAAM,EAAQ,GACrB,CAMA,MAAM,EAAsF,CAAC,EAAS,CACpG,KAAK,QAAQ,MAAM,EACnB,KAAK,UAAU,MAAM,EACrB,KAAK,SAAS,MAAM,EACpB,KAAK,SAAW,IAAA,GAChB,KAAK,SAAW,QAAQ,QAAQ,EAChC,KAAK,SAAW,EAAQ,SAAW,CAAC,EACpC,KAAK,UAAY,EAAQ,SAC3B,CAMA,MAAM,OAAuB,CAC3B,MAAM,KAAK,QACb,CACA,MAAM,MAAM,EAAY,EAAc,EAAe,EAA4B,CAI/E,GADA,MAAM,KAAK,SACP,IAAS,QAAa,IAAS,QAAY,CAC7C,IAAM,EAAW,EAAY,EAAM,MAAM,EACzC,GAAI,CAAC,EAAU,OACf,IAAM,EAAW,EAAK,QAAQ,EAAK,CAAQ,EAIrC,CAAC,EAAQ,GAAe,MAAM,QAAQ,IAAI,CAC9C,KAAK,UAAU,QAAQ,CAAQ,EAC/B,KAAK,UAAU,YAAY,CAAQ,CACrC,CAAC,EACD,KAAK,QAAQ,IAAI,EAAI,CAAE,OAAM,WAAU,SAAQ,QAAS,IAAgB,IAAA,EAAU,CAAC,EACnF,MACF,CACI,IAAS,IAGb,KAAK,UAAU,IAAI,EAAI,KAAK,IAAI,CAAC,EAGjC,KAAK,WAAa,MAAM,KAAK,UAAU,KAAK,EAAK,KAAK,QAAQ,EAChE,CAEA,MAAM,IAAI,EAAY,EAAkB,EAA4B,CAClE,IAAM,EAAU,KAAK,QAAQ,IAAI,CAAE,EACnC,KAAK,QAAQ,OAAO,CAAE,EACtB,IAAM,EAAY,KAAK,UAAU,IAAI,CAAE,EAEvC,GADA,KAAK,UAAU,OAAO,CAAE,EACpB,EAAS,CAGN,GAAS,MAAM,KAAK,WAAW,CAAO,EAC3C,MACF,CAII,IAAc,IAAA,KAKlB,KAAK,SAAW,KAAK,SAAS,KAAK,SAAY,CAC7C,GAAI,CACF,MAAM,KAAK,WAAW,EAAK,CAAS,CACtC,MAAQ,CAER,CACF,CAAC,EACH,CAGA,MAAc,WAAW,EAAqC,CAC5D,IAAM,EAAQ,MAAM,KAAK,UAAU,QAAQ,EAAQ,QAAQ,EACrD,EAAU,CAAC,EAAQ,QACnB,EAAS,MAAM,KAAK,aAAa,EAAQ,OAAQ,EAAO,CAAO,EACjE,IAAU,IAAA,IAAW,KAAK,SAAS,IAAI,EAAQ,SAAU,CAAK,EAClE,MAAM,KAAK,OAAO,CAChB,QAAS,EACT,KAAM,EAAQ,SACd,KAAM,EAAQ,KACd,GAAI,KAAK,IAAI,EACb,OAAQ,OACR,GAAI,EAAQ,SAAW,IAAA,GAAY,CAAC,EAAI,CAAE,OAAQ,EAAQ,MAAO,EACjE,GAAI,IAAU,IAAA,GAAY,CAAC,EAAI,CAAE,OAAM,EACvC,GAAI,EAAU,CAAE,QAAS,EAAK,EAAI,CAAC,EACnC,GAAG,CACL,CAAC,EAGD,MAAM,KAAK,SAAS,EAAQ,QAAQ,CACtC,CAGA,MAAc,WAAW,EAAa,EAAkC,CACtE,IAAM,EAAS,KAAK,SACpB,GAAI,IAAW,IAAA,GAAW,OAC1B,IAAM,EAAQ,MAAM,KAAK,UAAU,KAAK,EAAK,KAAK,QAAQ,EAC1D,KAAK,SAAW,EAChB,IAAM,EAAW,KAAK,UAChB,EAAQ,KAAK,UAChB,QAAQ,EAAQ,CAAK,CAAC,CACtB,OAAQ,GAAa,IAAa,IAAA,IAAa,CAAC,EAAS,CAAQ,CAAC,EAC/D,EAAa,MAAM,KAAK,cAAc,EAAO,CAAS,EAC5D,GAAI,EAAW,SAAW,EAAG,OAC7B,IAAM,EAAY,MAAM,KAAK,eAAe,EAAK,CAAU,EAC3D,IAAK,IAAM,KAAY,EAAY,CACjC,IAAM,EAAQ,KAAK,SAAS,IAAI,CAAQ,EAGxC,GAAI,IAAU,IAAA,IAAa,EAAU,IAAI,CAAQ,EAAG,SACpD,IAAM,EAAc,EAAO,QAAQ,IAAI,CAAQ,EACzC,EAAa,EAAM,QAAQ,IAAI,CAAQ,EAGvC,EAAW,IAAe,IAAA,GAAY,IAAA,GAAY,MAAM,KAAK,UAAU,QAAQ,CAAQ,EAI7F,GAHI,IAAa,IAAA,IAAW,KAAK,SAAS,IAAI,EAAU,CAAQ,EAG5D,IAAa,IAAA,IAAa,IAAa,EAAO,SAClD,IAAM,EACJ,IAAgB,IAAA,IAChB,IAAe,IAAA,IACf,KAAK,UAAU,YAAY,EAAa,CAAU,GACjD,IAAU,IAAA,IAAa,IAAa,IAAA,IAAa,IAAa,EACjE,MAAM,KAAK,OAAO,CAChB,QAAS,EACT,KAAM,EACN,KAAM,EACN,GAAI,KAAK,IAAI,EACb,OAAQ,OACR,GAAI,EAAS,CAAE,SAAU,EAAK,EAAI,CAAC,EACnC,GAAI,IAAa,IAAA,GAAY,CAAC,EAAI,CAAE,MAAO,CAAS,CACtD,CAAC,CACH,CACF,CAYA,MAAc,cAAc,EAA+B,EAAsC,CAC/F,IAAM,EAAQ,EAAY,IACpB,EAAoB,CAAC,EAC3B,IAAK,IAAM,KAAY,EAAY,CACjC,IAAM,EAAa,MAAM,KAAK,UAAU,WAAW,CAAQ,GACvD,IAAe,IAAA,IAAa,GAAc,IAAO,EAAQ,KAAK,CAAQ,CAC5E,CACA,OAAO,CACT,CAGA,MAAc,eAAe,EAAa,EAA6D,CACrG,IAAM,EAAS,EAAW,OAAQ,GAAa,CAAC,KAAK,SAAS,IAAI,CAAQ,CAAC,EAE3E,OADI,KAAK,MAAQ,IAAA,IAAa,EAAO,SAAW,EAAU,IAAI,IACvD,KAAK,IAAI,UAAU,EAAK,CAAM,CACvC,CAGA,MAAc,aACZ,EACA,EACA,EACoD,CACpD,GAAI,IAAU,IAAA,GAAW,MAAO,CAAC,EACjC,GAAI,IAAW,IAAA,GAAW,CAIxB,GAAI,CAAC,EAAS,MAAO,CAAC,EACtB,IAAM,EAAc,MAAM,KAAK,UAAU,KAAK,CAAK,EACnD,GAAI,IAAgB,IAAA,GAAW,MAAO,CAAC,EACvC,IAAM,EAAQ,EAAkB,CAAW,EAC3C,MAAO,CAAE,UAAW,EAAM,UAAW,SAAU,EAAM,QAAS,CAChE,CACA,GAAI,IAAW,EAAO,MAAO,CAAE,UAAW,EAAG,SAAU,CAAE,EACzD,GAAM,CAAC,EAAY,GAAa,MAAM,QAAQ,IAAI,CAAC,KAAK,UAAU,KAAK,CAAM,EAAG,KAAK,UAAU,KAAK,CAAK,CAAC,CAAC,EAC3G,GAAI,IAAe,IAAA,IAAa,IAAc,IAAA,GAAW,MAAO,CAAC,EACjE,IAAM,EAAO,EAAS,EAAY,CAAS,EAC3C,MAAO,CAAE,UAAW,EAAK,UAAW,SAAU,EAAK,QAAS,CAC9D,CAGA,MAAc,OAAO,EAAqC,EACpD,EAAM,SAAW,IAAA,IAAa,EAAM,SAAW,EAAM,QACzD,MAAM,KAAK,SAAS,OAAO,CAAK,CAClC,CAUA,MAAc,SAAS,EAAiC,CACtD,GAAI,KAAK,WAAa,IAAA,GAAW,OACjC,IAAM,EAAU,IAAI,IAAI,KAAK,SAAS,OAAO,EACvC,EAAc,MAAM,KAAK,UAAU,YAAY,CAAQ,EACzD,IAAgB,IAAA,GAAW,EAAQ,OAAO,CAAQ,EACjD,EAAQ,IAAI,EAAU,CAAW,EACtC,KAAK,SAAW,CAAE,UAAS,UAAW,KAAK,SAAS,SAAU,CAChE,CACF"}
@@ -6,6 +6,12 @@ export interface IEditTracker {
6
6
  * is closed by walking the tree, not by re-reading a path the arguments named.
7
7
  */
8
8
  end(id: string, isError: boolean, cwd: string): Promise<void>;
9
+ /**
10
+ * Awaits the tree walk `end` deferred so it does not hold up a tool result.
11
+ * A caller about to clear this session's state needs it, or the walk appends
12
+ * behind the clear.
13
+ */
14
+ flush(): Promise<void>;
9
15
  /**
10
16
  * Drops the tree baseline so a new session does not inherit the last one's,
11
17
  * and takes the paths this session's own bookkeeping occupies, which a tree
@@ -1 +1 @@
1
- {"version":3,"file":"editTracker.d.cts","names":[],"sources":["../../src/types/editTracker.ts"],"mappings":";iBAAiB;EACf,MAAM,YAAY,cAAc,eAAe,cAAc;;;;;EAK7D,IAAI,YAAY,kBAAkB,cAAc;;;;;;;;;EAShD,MAAM;IAAY;IAA6B,aAAa"}
1
+ {"version":3,"file":"editTracker.d.cts","names":[],"sources":["../../src/types/editTracker.ts"],"mappings":";iBAAiB;EACf,MAAM,YAAY,cAAc,eAAe,cAAc;;;;;EAK7D,IAAI,YAAY,kBAAkB,cAAc;;;;;;EAMhD,SAAS;;;;;;;;;EAST,MAAM;IAAY;IAA6B,aAAa"}
@@ -6,6 +6,12 @@ export interface IEditTracker {
6
6
  * is closed by walking the tree, not by re-reading a path the arguments named.
7
7
  */
8
8
  end(id: string, isError: boolean, cwd: string): Promise<void>;
9
+ /**
10
+ * Awaits the tree walk `end` deferred so it does not hold up a tool result.
11
+ * A caller about to clear this session's state needs it, or the walk appends
12
+ * behind the clear.
13
+ */
14
+ flush(): Promise<void>;
9
15
  /**
10
16
  * Drops the tree baseline so a new session does not inherit the last one's,
11
17
  * and takes the paths this session's own bookkeeping occupies, which a tree
@@ -1 +1 @@
1
- {"version":3,"file":"editTracker.d.mts","names":[],"sources":["../../src/types/editTracker.ts"],"mappings":";iBAAiB;EACf,MAAM,YAAY,cAAc,eAAe,cAAc;;;;;EAK7D,IAAI,YAAY,kBAAkB,cAAc;;;;;;;;;EAShD,MAAM;IAAY;IAA6B,aAAa"}
1
+ {"version":3,"file":"editTracker.d.mts","names":[],"sources":["../../src/types/editTracker.ts"],"mappings":";iBAAiB;EACf,MAAM,YAAY,cAAc,eAAe,cAAc;;;;;EAK7D,IAAI,YAAY,kBAAkB,cAAc;;;;;;EAMhD,SAAS;;;;;;;;;EAST,MAAM;IAAY;IAA6B,aAAa"}
@@ -1,2 +1,2 @@
1
- exports.API_BASE_PATH=`file-edits`,exports.PATH_QUERY_PARAM=`path`;
1
+ require("@agimon-ai/doompi-core/web"),exports.API_BASE_PATH=`file-edits`,exports.PATH_QUERY_PARAM=`path`;
2
2
  //# sourceMappingURL=fileEditsApi.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"fileEditsApi.cjs","names":[],"sources":["../../src/types/fileEditsApi.ts"],"sourcesContent":["import type { FileEditOrigin, FileEditTool } from './domain';\n\n/**\n * The wire vocabulary this package's API shares with whatever calls it.\n *\n * It lives under src/types because that is the one server root a browser\n * bundle may read: the cockpit plugin builds its URLs from these and the\n * routes answer them, so neither half can drift from the other.\n *\n * Three routes. Opening a changed file wants its whole history at once, so\n * `detail` answers in one round trip rather than making the page stitch three\n * reads together; `content` takes the manual save back; `preview` reads a file\n * the session never changed, which has no history to answer with.\n */\n\n/** Where a host mounts this package's API; the segment after /api/plugin/. */\nexport const API_BASE_PATH = 'file-edits';\n\n/** Query parameter the cockpit hub reads to pick which session server to proxy to. */\nexport const SESSION_QUERY_PARAM = 'session';\n\n/** Query parameter naming the file a request is about. */\nexport const PATH_QUERY_PARAM = 'path';\n\n/** One file's whole detail, relative to the API's own mount. */\nexport function detailPath(): string {\n return '/detail';\n}\n\n/** The manual save, and the deletion, relative to the API's own mount. */\nexport function contentPath(): string {\n return '/content';\n}\n\n/** One unchanged file, read only, relative to the API's own mount. */\nexport function previewPath(): string {\n return '/preview';\n}\n\n/**\n * The absolute URL a page fetches, through the hub. The whole query is built\n * here, session parameter included, so a caller never appends a second '?'.\n */\nexport function detailUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `/api/sessions/${encodeURIComponent(sessionId)}/plugin/${API_BASE_PATH}${detailPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page puts a manual save to. */\nexport function contentUrl(sessionId: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId });\n return `/api/sessions/${encodeURIComponent(sessionId)}/plugin/${API_BASE_PATH}${contentPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page reads an unchanged file through. */\nexport function previewUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `/api/sessions/${encodeURIComponent(sessionId)}/plugin/${API_BASE_PATH}${previewPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page deletes a file through; the path rides the query, not a body. */\nexport function deleteUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `/api/sessions/${encodeURIComponent(sessionId)}/plugin/${API_BASE_PATH}${contentPath()}?${search.toString()}`;\n}\n\n/**\n * One line of a diff. The number is the new file's for context and additions\n * and the old file's for removals, so it always says where the line lives.\n */\nexport interface FileEditsDiffRow {\n marker: '+' | '-' | ' ';\n line: number;\n content: string;\n}\n\n/**\n * A run of changed lines with its surrounding context. Hunks are sent apart\n * rather than joined by an elision marker, so the page draws the gap between\n * them instead of parsing one out of a string.\n */\nexport interface FileEditsDiffHunk {\n /** The line number the hunk opens at. */\n start: number;\n rows: FileEditsDiffRow[];\n}\n\n/**\n * One change in a file's history, each rendered on its own so a file edited\n * four times shows four distinct diffs rather than one merged blur.\n */\nexport interface FileEditsVersionView {\n /** Position in the file's history, oldest first, starting at 1. */\n index: number;\n tool: FileEditTool;\n at: number;\n origin: FileEditOrigin;\n additions: number;\n removals: number;\n /** The changed lines of this one change; absent when no baseline was captured. */\n hunks?: FileEditsDiffHunk[];\n /** Why there is no diff, shown in its place. */\n note?: string;\n}\n\n/** Everything this session did to the file: its first baseline against what is on disk now. */\nexport interface FileEditsCumulativeView {\n additions: number;\n removals: number;\n hunks?: FileEditsDiffHunk[];\n note?: string;\n}\n\n/** The file as it stands on disk right now, which is what the source view edits. */\nexport interface FileEditsWorkingView {\n content: string;\n /** sha256 of the content; the token a save hands back to prove it is not stale. */\n hash: string;\n /** True when the file is binary, missing, or past the size cap, so `content` is empty. */\n unavailable: boolean;\n reason?: string;\n}\n\n/** What the detail route answers with. */\nexport interface FileEditsDetailView {\n path: string;\n relPath: string;\n versions: FileEditsVersionView[];\n cumulative: FileEditsCumulativeView;\n working: FileEditsWorkingView;\n}\n\n/**\n * What the preview route answers with: the file as it stands, and nothing\n * about how it got there. A file this session never touched has no history and\n * no baseline, so there is no diff to send and no save to take back.\n */\nexport interface FileEditsPreviewView {\n path: string;\n relPath: string;\n working: FileEditsWorkingView;\n}\n\n/** What the save route takes. */\nexport interface FileEditsSaveRequest {\n path: string;\n /** The hash the reader loaded; a mismatch means the file moved underneath them. */\n expectedHash: string;\n content: string;\n}\n\n/** What the save route answers with. */\nexport interface FileEditsSaveView {\n hash: string;\n}\n\n/** What a route reports when it refuses; the page shows `error` verbatim. */\nexport interface FileEditsErrorView {\n error: string;\n /** On a stale save, the hash the file actually has now. */\n hash?: string;\n}\n"],"mappings":""}
1
+ {"version":3,"file":"fileEditsApi.cjs","names":[],"sources":["../../src/types/fileEditsApi.ts"],"sourcesContent":["import { sessionApiPath } from '@agimon-ai/doompi-core/web';\n\nimport type { FileEditOrigin, FileEditTool } from './domain';\n\n/**\n * The wire vocabulary this package's API shares with whatever calls it.\n *\n * It lives under src/types because that is the one server root a browser\n * bundle may read: the cockpit plugin builds its URLs from these and the\n * routes answer them, so neither half can drift from the other.\n *\n * Three routes. Opening a changed file wants its whole history at once, so\n * `detail` answers in one round trip rather than making the page stitch three\n * reads together; `content` takes the manual save back; `preview` reads a file\n * the session never changed, which has no history to answer with.\n */\n\n/** Where a host mounts this package's API; the segment after /api/plugins/. */\nexport const API_BASE_PATH = 'file-edits';\n\n/** Query parameter the cockpit hub reads to pick which session server to proxy to. */\nexport const SESSION_QUERY_PARAM = 'session';\n\n/** Query parameter naming the file a request is about. */\nexport const PATH_QUERY_PARAM = 'path';\n\n/** One file's whole detail, relative to the API's own mount. */\nexport function detailPath(): string {\n return '/detail';\n}\n\n/** The manual save, and the deletion, relative to the API's own mount. */\nexport function contentPath(): string {\n return '/content';\n}\n\n/** One unchanged file, read only, relative to the API's own mount. */\nexport function previewPath(): string {\n return '/preview';\n}\n\n/**\n * The absolute URL a page fetches, through the hub. The whole query is built\n * here, session parameter included, so a caller never appends a second '?'.\n */\nexport function detailUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `${sessionApiPath(sessionId)}/plugins/${API_BASE_PATH}${detailPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page puts a manual save to. */\nexport function contentUrl(sessionId: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId });\n return `${sessionApiPath(sessionId)}/plugins/${API_BASE_PATH}${contentPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page reads an unchanged file through. */\nexport function previewUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `${sessionApiPath(sessionId)}/plugins/${API_BASE_PATH}${previewPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page deletes a file through; the path rides the query, not a body. */\nexport function deleteUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `${sessionApiPath(sessionId)}/plugins/${API_BASE_PATH}${contentPath()}?${search.toString()}`;\n}\n\n/**\n * One line of a diff. The number is the new file's for context and additions\n * and the old file's for removals, so it always says where the line lives.\n */\nexport interface FileEditsDiffRow {\n marker: '+' | '-' | ' ';\n line: number;\n content: string;\n}\n\n/**\n * A run of changed lines with its surrounding context. Hunks are sent apart\n * rather than joined by an elision marker, so the page draws the gap between\n * them instead of parsing one out of a string.\n */\nexport interface FileEditsDiffHunk {\n /** The line number the hunk opens at. */\n start: number;\n rows: FileEditsDiffRow[];\n}\n\n/**\n * One change in a file's history, each rendered on its own so a file edited\n * four times shows four distinct diffs rather than one merged blur.\n */\nexport interface FileEditsVersionView {\n /** Position in the file's history, oldest first, starting at 1. */\n index: number;\n tool: FileEditTool;\n at: number;\n origin: FileEditOrigin;\n additions: number;\n removals: number;\n /** The changed lines of this one change; absent when no baseline was captured. */\n hunks?: FileEditsDiffHunk[];\n /** Why there is no diff, shown in its place. */\n note?: string;\n}\n\n/** Everything this session did to the file: its first baseline against what is on disk now. */\nexport interface FileEditsCumulativeView {\n additions: number;\n removals: number;\n hunks?: FileEditsDiffHunk[];\n note?: string;\n}\n\n/** The file as it stands on disk right now, which is what the source view edits. */\nexport interface FileEditsWorkingView {\n content: string;\n /** sha256 of the content; the token a save hands back to prove it is not stale. */\n hash: string;\n /** True when the file is binary, missing, or past the size cap, so `content` is empty. */\n unavailable: boolean;\n reason?: string;\n}\n\n/** What the detail route answers with. */\nexport interface FileEditsDetailView {\n path: string;\n relPath: string;\n versions: FileEditsVersionView[];\n cumulative: FileEditsCumulativeView;\n working: FileEditsWorkingView;\n}\n\n/**\n * What the preview route answers with: the file as it stands, and nothing\n * about how it got there. A file this session never touched has no history and\n * no baseline, so there is no diff to send and no save to take back.\n */\nexport interface FileEditsPreviewView {\n path: string;\n relPath: string;\n working: FileEditsWorkingView;\n}\n\n/** What the save route takes. */\nexport interface FileEditsSaveRequest {\n path: string;\n /** The hash the reader loaded; a mismatch means the file moved underneath them. */\n expectedHash: string;\n content: string;\n}\n\n/** What the save route answers with. */\nexport interface FileEditsSaveView {\n hash: string;\n}\n\n/** What a route reports when it refuses; the page shows `error` verbatim. */\nexport interface FileEditsErrorView {\n error: string;\n /** On a stale save, the hash the file actually has now. */\n hash?: string;\n}\n"],"mappings":""}
@@ -1,2 +1,2 @@
1
- const e=`file-edits`,t=`path`;export{e as API_BASE_PATH,t as PATH_QUERY_PARAM};
1
+ import"@agimon-ai/doompi-core/web";const e=`file-edits`,t=`path`;export{e as API_BASE_PATH,t as PATH_QUERY_PARAM};
2
2
  //# sourceMappingURL=fileEditsApi.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"fileEditsApi.mjs","names":[],"sources":["../../src/types/fileEditsApi.ts"],"sourcesContent":["import type { FileEditOrigin, FileEditTool } from './domain';\n\n/**\n * The wire vocabulary this package's API shares with whatever calls it.\n *\n * It lives under src/types because that is the one server root a browser\n * bundle may read: the cockpit plugin builds its URLs from these and the\n * routes answer them, so neither half can drift from the other.\n *\n * Three routes. Opening a changed file wants its whole history at once, so\n * `detail` answers in one round trip rather than making the page stitch three\n * reads together; `content` takes the manual save back; `preview` reads a file\n * the session never changed, which has no history to answer with.\n */\n\n/** Where a host mounts this package's API; the segment after /api/plugin/. */\nexport const API_BASE_PATH = 'file-edits';\n\n/** Query parameter the cockpit hub reads to pick which session server to proxy to. */\nexport const SESSION_QUERY_PARAM = 'session';\n\n/** Query parameter naming the file a request is about. */\nexport const PATH_QUERY_PARAM = 'path';\n\n/** One file's whole detail, relative to the API's own mount. */\nexport function detailPath(): string {\n return '/detail';\n}\n\n/** The manual save, and the deletion, relative to the API's own mount. */\nexport function contentPath(): string {\n return '/content';\n}\n\n/** One unchanged file, read only, relative to the API's own mount. */\nexport function previewPath(): string {\n return '/preview';\n}\n\n/**\n * The absolute URL a page fetches, through the hub. The whole query is built\n * here, session parameter included, so a caller never appends a second '?'.\n */\nexport function detailUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `/api/sessions/${encodeURIComponent(sessionId)}/plugin/${API_BASE_PATH}${detailPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page puts a manual save to. */\nexport function contentUrl(sessionId: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId });\n return `/api/sessions/${encodeURIComponent(sessionId)}/plugin/${API_BASE_PATH}${contentPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page reads an unchanged file through. */\nexport function previewUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `/api/sessions/${encodeURIComponent(sessionId)}/plugin/${API_BASE_PATH}${previewPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page deletes a file through; the path rides the query, not a body. */\nexport function deleteUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `/api/sessions/${encodeURIComponent(sessionId)}/plugin/${API_BASE_PATH}${contentPath()}?${search.toString()}`;\n}\n\n/**\n * One line of a diff. The number is the new file's for context and additions\n * and the old file's for removals, so it always says where the line lives.\n */\nexport interface FileEditsDiffRow {\n marker: '+' | '-' | ' ';\n line: number;\n content: string;\n}\n\n/**\n * A run of changed lines with its surrounding context. Hunks are sent apart\n * rather than joined by an elision marker, so the page draws the gap between\n * them instead of parsing one out of a string.\n */\nexport interface FileEditsDiffHunk {\n /** The line number the hunk opens at. */\n start: number;\n rows: FileEditsDiffRow[];\n}\n\n/**\n * One change in a file's history, each rendered on its own so a file edited\n * four times shows four distinct diffs rather than one merged blur.\n */\nexport interface FileEditsVersionView {\n /** Position in the file's history, oldest first, starting at 1. */\n index: number;\n tool: FileEditTool;\n at: number;\n origin: FileEditOrigin;\n additions: number;\n removals: number;\n /** The changed lines of this one change; absent when no baseline was captured. */\n hunks?: FileEditsDiffHunk[];\n /** Why there is no diff, shown in its place. */\n note?: string;\n}\n\n/** Everything this session did to the file: its first baseline against what is on disk now. */\nexport interface FileEditsCumulativeView {\n additions: number;\n removals: number;\n hunks?: FileEditsDiffHunk[];\n note?: string;\n}\n\n/** The file as it stands on disk right now, which is what the source view edits. */\nexport interface FileEditsWorkingView {\n content: string;\n /** sha256 of the content; the token a save hands back to prove it is not stale. */\n hash: string;\n /** True when the file is binary, missing, or past the size cap, so `content` is empty. */\n unavailable: boolean;\n reason?: string;\n}\n\n/** What the detail route answers with. */\nexport interface FileEditsDetailView {\n path: string;\n relPath: string;\n versions: FileEditsVersionView[];\n cumulative: FileEditsCumulativeView;\n working: FileEditsWorkingView;\n}\n\n/**\n * What the preview route answers with: the file as it stands, and nothing\n * about how it got there. A file this session never touched has no history and\n * no baseline, so there is no diff to send and no save to take back.\n */\nexport interface FileEditsPreviewView {\n path: string;\n relPath: string;\n working: FileEditsWorkingView;\n}\n\n/** What the save route takes. */\nexport interface FileEditsSaveRequest {\n path: string;\n /** The hash the reader loaded; a mismatch means the file moved underneath them. */\n expectedHash: string;\n content: string;\n}\n\n/** What the save route answers with. */\nexport interface FileEditsSaveView {\n hash: string;\n}\n\n/** What a route reports when it refuses; the page shows `error` verbatim. */\nexport interface FileEditsErrorView {\n error: string;\n /** On a stale save, the hash the file actually has now. */\n hash?: string;\n}\n"],"mappings":"AAgBA,MAAa,EAAgB,aAMhB,EAAmB"}
1
+ {"version":3,"file":"fileEditsApi.mjs","names":[],"sources":["../../src/types/fileEditsApi.ts"],"sourcesContent":["import { sessionApiPath } from '@agimon-ai/doompi-core/web';\n\nimport type { FileEditOrigin, FileEditTool } from './domain';\n\n/**\n * The wire vocabulary this package's API shares with whatever calls it.\n *\n * It lives under src/types because that is the one server root a browser\n * bundle may read: the cockpit plugin builds its URLs from these and the\n * routes answer them, so neither half can drift from the other.\n *\n * Three routes. Opening a changed file wants its whole history at once, so\n * `detail` answers in one round trip rather than making the page stitch three\n * reads together; `content` takes the manual save back; `preview` reads a file\n * the session never changed, which has no history to answer with.\n */\n\n/** Where a host mounts this package's API; the segment after /api/plugins/. */\nexport const API_BASE_PATH = 'file-edits';\n\n/** Query parameter the cockpit hub reads to pick which session server to proxy to. */\nexport const SESSION_QUERY_PARAM = 'session';\n\n/** Query parameter naming the file a request is about. */\nexport const PATH_QUERY_PARAM = 'path';\n\n/** One file's whole detail, relative to the API's own mount. */\nexport function detailPath(): string {\n return '/detail';\n}\n\n/** The manual save, and the deletion, relative to the API's own mount. */\nexport function contentPath(): string {\n return '/content';\n}\n\n/** One unchanged file, read only, relative to the API's own mount. */\nexport function previewPath(): string {\n return '/preview';\n}\n\n/**\n * The absolute URL a page fetches, through the hub. The whole query is built\n * here, session parameter included, so a caller never appends a second '?'.\n */\nexport function detailUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `${sessionApiPath(sessionId)}/plugins/${API_BASE_PATH}${detailPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page puts a manual save to. */\nexport function contentUrl(sessionId: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId });\n return `${sessionApiPath(sessionId)}/plugins/${API_BASE_PATH}${contentPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page reads an unchanged file through. */\nexport function previewUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `${sessionApiPath(sessionId)}/plugins/${API_BASE_PATH}${previewPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page deletes a file through; the path rides the query, not a body. */\nexport function deleteUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `${sessionApiPath(sessionId)}/plugins/${API_BASE_PATH}${contentPath()}?${search.toString()}`;\n}\n\n/**\n * One line of a diff. The number is the new file's for context and additions\n * and the old file's for removals, so it always says where the line lives.\n */\nexport interface FileEditsDiffRow {\n marker: '+' | '-' | ' ';\n line: number;\n content: string;\n}\n\n/**\n * A run of changed lines with its surrounding context. Hunks are sent apart\n * rather than joined by an elision marker, so the page draws the gap between\n * them instead of parsing one out of a string.\n */\nexport interface FileEditsDiffHunk {\n /** The line number the hunk opens at. */\n start: number;\n rows: FileEditsDiffRow[];\n}\n\n/**\n * One change in a file's history, each rendered on its own so a file edited\n * four times shows four distinct diffs rather than one merged blur.\n */\nexport interface FileEditsVersionView {\n /** Position in the file's history, oldest first, starting at 1. */\n index: number;\n tool: FileEditTool;\n at: number;\n origin: FileEditOrigin;\n additions: number;\n removals: number;\n /** The changed lines of this one change; absent when no baseline was captured. */\n hunks?: FileEditsDiffHunk[];\n /** Why there is no diff, shown in its place. */\n note?: string;\n}\n\n/** Everything this session did to the file: its first baseline against what is on disk now. */\nexport interface FileEditsCumulativeView {\n additions: number;\n removals: number;\n hunks?: FileEditsDiffHunk[];\n note?: string;\n}\n\n/** The file as it stands on disk right now, which is what the source view edits. */\nexport interface FileEditsWorkingView {\n content: string;\n /** sha256 of the content; the token a save hands back to prove it is not stale. */\n hash: string;\n /** True when the file is binary, missing, or past the size cap, so `content` is empty. */\n unavailable: boolean;\n reason?: string;\n}\n\n/** What the detail route answers with. */\nexport interface FileEditsDetailView {\n path: string;\n relPath: string;\n versions: FileEditsVersionView[];\n cumulative: FileEditsCumulativeView;\n working: FileEditsWorkingView;\n}\n\n/**\n * What the preview route answers with: the file as it stands, and nothing\n * about how it got there. A file this session never touched has no history and\n * no baseline, so there is no diff to send and no save to take back.\n */\nexport interface FileEditsPreviewView {\n path: string;\n relPath: string;\n working: FileEditsWorkingView;\n}\n\n/** What the save route takes. */\nexport interface FileEditsSaveRequest {\n path: string;\n /** The hash the reader loaded; a mismatch means the file moved underneath them. */\n expectedHash: string;\n content: string;\n}\n\n/** What the save route answers with. */\nexport interface FileEditsSaveView {\n hash: string;\n}\n\n/** What a route reports when it refuses; the page shows `error` verbatim. */\nexport interface FileEditsErrorView {\n error: string;\n /** On a stale save, the hash the file actually has now. */\n hash?: string;\n}\n"],"mappings":"mCAkBA,MAAa,EAAgB,aAMhB,EAAmB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agimon-ai/doompi-file-edit",
3
- "version": "0.0.1-alpha.71",
3
+ "version": "0.0.1-alpha.72",
4
4
  "description": "Pi extension for session-scoped file-change timelines and external editor workflows.",
5
5
  "keywords": [
6
6
  "ai",
@@ -62,17 +62,17 @@
62
62
  "registry": "https://registry.npmjs.org/"
63
63
  },
64
64
  "dependencies": {
65
+ "@agimon-ai/doompi-config": "0.0.1-alpha.71",
66
+ "@agimon-ai/doompi-core": "0.0.1-alpha.72",
67
+ "@agimon-ai/doompi-telemetry": "0.0.1-alpha.69",
68
+ "@agimon-ai/doompi-ui": "0.0.1-alpha.72",
69
+ "@agimon-ai/doompi-web-components": "0.0.1-alpha.30",
70
+ "@agimon-ai/doompi-web-security": "0.0.1-alpha.32",
65
71
  "@deepseek-ai/cordis": "4.0.2",
66
72
  "cross-spawn": "7.0.6",
67
- "hono": "4.13.5",
68
- "ignore": "7.0.8",
69
- "typebox": "1.3.25",
70
- "@agimon-ai/doompi-core": "0.0.1-alpha.71",
71
- "@agimon-ai/doompi-config": "0.0.1-alpha.70",
72
- "@agimon-ai/doompi-telemetry": "0.0.1-alpha.68",
73
- "@agimon-ai/doompi-web-components": "0.0.1-alpha.29",
74
- "@agimon-ai/doompi-ui": "0.0.1-alpha.71",
75
- "@agimon-ai/doompi-web-security": "0.0.1-alpha.31"
73
+ "hono": "4.13.7",
74
+ "ignore": "7.0.9",
75
+ "typebox": "1.3.30"
76
76
  },
77
77
  "devDependencies": {
78
78
  "@earendil-works/pi-coding-agent": "0.85.1",
@@ -80,12 +80,12 @@
80
80
  "@tanstack/react-store": "0.11.1",
81
81
  "@tanstack/store": "0.11.1",
82
82
  "@types/cross-spawn": "6.0.6",
83
- "@types/node": "26.4.1",
84
- "@types/react": "19.2.18",
85
- "@types/react-dom": "19.2.7",
83
+ "@types/node": "26.5.1",
84
+ "@types/react": "19.3.0",
85
+ "@types/react-dom": "19.3.0",
86
86
  "@vitest/coverage-v8": "5.0.0",
87
- "react": "19.2.8",
88
- "react-dom": "19.2.8",
87
+ "react": "19.3.0",
88
+ "react-dom": "19.3.0",
89
89
  "tsdown": "0.23.0",
90
90
  "typescript": "7.0.2",
91
91
  "vitest": "5.0.0"
@@ -1,3 +1,5 @@
1
+ import { sessionApiPath } from '@agimon-ai/doompi-core/web';
2
+
1
3
  import type { FileEditOrigin, FileEditTool } from './domain';
2
4
 
3
5
  /**
@@ -13,7 +15,7 @@ import type { FileEditOrigin, FileEditTool } from './domain';
13
15
  * the session never changed, which has no history to answer with.
14
16
  */
15
17
 
16
- /** Where a host mounts this package's API; the segment after /api/plugin/. */
18
+ /** Where a host mounts this package's API; the segment after /api/plugins/. */
17
19
  export const API_BASE_PATH = 'file-edits';
18
20
 
19
21
  /** Query parameter the cockpit hub reads to pick which session server to proxy to. */
@@ -43,25 +45,25 @@ export function previewPath(): string {
43
45
  */
44
46
  export function detailUrl(sessionId: string, filePath: string): string {
45
47
  const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });
46
- return `/api/sessions/${encodeURIComponent(sessionId)}/plugin/${API_BASE_PATH}${detailPath()}?${search.toString()}`;
48
+ return `${sessionApiPath(sessionId)}/plugins/${API_BASE_PATH}${detailPath()}?${search.toString()}`;
47
49
  }
48
50
 
49
51
  /** The absolute URL a page puts a manual save to. */
50
52
  export function contentUrl(sessionId: string): string {
51
53
  const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId });
52
- return `/api/sessions/${encodeURIComponent(sessionId)}/plugin/${API_BASE_PATH}${contentPath()}?${search.toString()}`;
54
+ return `${sessionApiPath(sessionId)}/plugins/${API_BASE_PATH}${contentPath()}?${search.toString()}`;
53
55
  }
54
56
 
55
57
  /** The absolute URL a page reads an unchanged file through. */
56
58
  export function previewUrl(sessionId: string, filePath: string): string {
57
59
  const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });
58
- return `/api/sessions/${encodeURIComponent(sessionId)}/plugin/${API_BASE_PATH}${previewPath()}?${search.toString()}`;
60
+ return `${sessionApiPath(sessionId)}/plugins/${API_BASE_PATH}${previewPath()}?${search.toString()}`;
59
61
  }
60
62
 
61
63
  /** The absolute URL a page deletes a file through; the path rides the query, not a body. */
62
64
  export function deleteUrl(sessionId: string, filePath: string): string {
63
65
  const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });
64
- return `/api/sessions/${encodeURIComponent(sessionId)}/plugin/${API_BASE_PATH}${contentPath()}?${search.toString()}`;
66
+ return `${sessionApiPath(sessionId)}/plugins/${API_BASE_PATH}${contentPath()}?${search.toString()}`;
65
67
  }
66
68
 
67
69
  /**
@@ -1,3 +1,4 @@
1
+ import { sessionApiPath } from '@agimon-ai/doompi-core/web';
1
2
  import { sealedTransport } from '@agimon-ai/doompi-web-security/browser';
2
3
 
3
4
  import {
@@ -37,7 +38,7 @@ const JSON_HEADERS = { 'content-type': 'application/json' };
37
38
  * the form the file list already holds.
38
39
  */
39
40
  export function sessionFileUrl(sessionId: string, relPath: string): string {
40
- return `/api/sessions/${encodeURIComponent(sessionId)}/file?path=${encodeURIComponent(relPath)}`;
41
+ return `${sessionApiPath(sessionId)}/file?path=${encodeURIComponent(relPath)}`;
41
42
  }
42
43
 
43
44
  function isRecord(value: unknown): value is Record<string, unknown> {