@causyne/agent 0.0.0-stage → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +102 -0
- package/README.md +31 -2
- package/SECURITY.md +24 -0
- package/bin/causyne.js +13 -0
- package/docs/ARCHITECTURE.md +63 -0
- package/docs/PROVENANCE.md +29 -0
- package/docs/SECURITY_REVIEW.md +89 -0
- package/docs/THIRD_PARTY_NOTICES.md +49 -0
- package/native-skills/ai-engineering/context-window-management/SKILL.md +43 -0
- package/native-skills/ai-engineering/llm-evaluation/SKILL.md +46 -0
- package/native-skills/ai-engineering/model-cost-and-latency/SKILL.md +46 -0
- package/native-skills/ai-engineering/prompt-construction/SKILL.md +42 -0
- package/native-skills/ai-engineering/retrieval-augmented-generation/SKILL.md +42 -0
- package/native-skills/ai-engineering/structured-output-and-schemas/SKILL.md +45 -0
- package/native-skills/ai-engineering/tool-calling-agents/SKILL.md +43 -0
- package/native-skills/android/app-performance-profiling/SKILL.md +42 -0
- package/native-skills/android/background-work-scheduling/SKILL.md +47 -0
- package/native-skills/android/compose-state-and-recomposition/SKILL.md +43 -0
- package/native-skills/android/gradle-build-configuration/SKILL.md +41 -0
- package/native-skills/android/lifecycle-and-leaks/SKILL.md +46 -0
- package/native-skills/android/play-store-release/SKILL.md +43 -0
- package/native-skills/android/room-and-persistence/SKILL.md +40 -0
- package/native-skills/backend/authentication-and-sessions/SKILL.md +45 -0
- package/native-skills/backend/background-jobs-and-queues/SKILL.md +42 -0
- package/native-skills/backend/caching-strategy/SKILL.md +44 -0
- package/native-skills/backend/graceful-shutdown/SKILL.md +42 -0
- package/native-skills/backend/rate-limiting-and-quotas/SKILL.md +40 -0
- package/native-skills/backend/rest-api-design/SKILL.md +40 -0
- package/native-skills/backend/webhook-delivery-and-receipt/SKILL.md +43 -0
- package/native-skills/cloud/cost-optimisation/SKILL.md +46 -0
- package/native-skills/cloud/iam-least-privilege/SKILL.md +43 -0
- package/native-skills/cloud/infrastructure-as-code/SKILL.md +43 -0
- package/native-skills/cloud/multi-region-and-failover/SKILL.md +43 -0
- package/native-skills/cloud/serverless-patterns/SKILL.md +47 -0
- package/native-skills/data/data-quality-monitoring/SKILL.md +51 -0
- package/native-skills/data/etl-pipeline-reliability/SKILL.md +43 -0
- package/native-skills/data/exploratory-analysis/SKILL.md +43 -0
- package/native-skills/data/metric-definition/SKILL.md +42 -0
- package/native-skills/data/notebook-to-production/SKILL.md +43 -0
- package/native-skills/database/backup-and-recovery/SKILL.md +43 -0
- package/native-skills/database/connection-pooling/SKILL.md +42 -0
- package/native-skills/database/data-integrity-and-constraints/SKILL.md +42 -0
- package/native-skills/database/query-optimisation/SKILL.md +38 -0
- package/native-skills/database/safe-migrations/SKILL.md +47 -0
- package/native-skills/database/schema-design/SKILL.md +38 -0
- package/native-skills/database/transactions-and-isolation/SKILL.md +41 -0
- package/native-skills/design/colour-system-construction/SKILL.md +38 -0
- package/native-skills/design/design-direction-brief/SKILL.md +41 -0
- package/native-skills/design/motion-and-transitions/SKILL.md +38 -0
- package/native-skills/design/responsive-strategy/SKILL.md +39 -0
- package/native-skills/design/spacing-and-rhythm/SKILL.md +38 -0
- package/native-skills/design/type-scale-and-hierarchy/SKILL.md +39 -0
- package/native-skills/design/visual-qa-loop/SKILL.md +42 -0
- package/native-skills/devops/docker-multi-stage-builds/SKILL.md +60 -0
- package/native-skills/devops/github-actions-ci/SKILL.md +60 -0
- package/native-skills/devops/incident-triage/SKILL.md +38 -0
- package/native-skills/devops/kubernetes-deployment-hardening/SKILL.md +64 -0
- package/native-skills/devops/observability-baseline/SKILL.md +38 -0
- package/native-skills/devops/secret-management/SKILL.md +40 -0
- package/native-skills/devops/terraform-state-and-modules/SKILL.md +36 -0
- package/native-skills/devops/zero-downtime-deploys/SKILL.md +40 -0
- package/native-skills/git/bisect-regression-hunt/SKILL.md +43 -0
- package/native-skills/git/branch-strategy/SKILL.md +39 -0
- package/native-skills/git/commit-hygiene/SKILL.md +41 -0
- package/native-skills/git/history-recovery/SKILL.md +44 -0
- package/native-skills/git/large-repository-surgery/SKILL.md +46 -0
- package/native-skills/git/merge-conflict-resolution/SKILL.md +42 -0
- package/native-skills/media/cinematic-3d-shot/SKILL.md +104 -0
- package/native-skills/mobile/app-startup-optimisation/SKILL.md +41 -0
- package/native-skills/mobile/cross-platform-tradeoffs/SKILL.md +42 -0
- package/native-skills/mobile/deep-linking/SKILL.md +39 -0
- package/native-skills/mobile/mobile-storage-security/SKILL.md +45 -0
- package/native-skills/mobile/offline-first-sync/SKILL.md +42 -0
- package/native-skills/mobile/push-notifications/SKILL.md +38 -0
- package/native-skills/performance/algorithmic-complexity/SKILL.md +43 -0
- package/native-skills/performance/concurrency-and-parallelism/SKILL.md +42 -0
- package/native-skills/performance/load-testing/SKILL.md +42 -0
- package/native-skills/performance/memory-and-leak-hunting/SKILL.md +41 -0
- package/native-skills/performance/network-and-payload-efficiency/SKILL.md +42 -0
- package/native-skills/performance/profiling-methodology/SKILL.md +46 -0
- package/native-skills/python/async-correctness/SKILL.md +40 -0
- package/native-skills/python/cli-tool-construction/SKILL.md +44 -0
- package/native-skills/python/data-processing-pipelines/SKILL.md +38 -0
- package/native-skills/python/environment-isolation/SKILL.md +41 -0
- package/native-skills/python/packaging-and-distribution/SKILL.md +56 -0
- package/native-skills/python/pytest-suite-design/SKILL.md +39 -0
- package/native-skills/python/type-hints-and-static-checking/SKILL.md +42 -0
- package/native-skills/security/authorization-model/SKILL.md +42 -0
- package/native-skills/security/crypto-usage/SKILL.md +48 -0
- package/native-skills/security/dependency-supply-chain/SKILL.md +42 -0
- package/native-skills/security/input-validation-and-injection/SKILL.md +44 -0
- package/native-skills/security/secure-file-uploads/SKILL.md +46 -0
- package/native-skills/security/security-headers-and-csp/SKILL.md +44 -0
- package/native-skills/security/threat-modelling/SKILL.md +46 -0
- package/native-skills/testing/end-to-end-test-stability/SKILL.md +42 -0
- package/native-skills/testing/property-based-testing/SKILL.md +46 -0
- package/native-skills/testing/regression-test-from-a-bug/SKILL.md +41 -0
- package/native-skills/testing/snapshot-and-visual-regression/SKILL.md +42 -0
- package/native-skills/testing/test-doubles-and-mocking/SKILL.md +43 -0
- package/native-skills/testing/test-strategy-and-pyramid/SKILL.md +39 -0
- package/native-skills/testing/writing-good-assertions/SKILL.md +42 -0
- package/native-skills/web/accessible-components/SKILL.md +45 -0
- package/native-skills/web/api-client-resilience/SKILL.md +39 -0
- package/native-skills/web/bundle-size-reduction/SKILL.md +38 -0
- package/native-skills/web/core-web-vitals/SKILL.md +40 -0
- package/native-skills/web/css-layout-systems/SKILL.md +41 -0
- package/native-skills/web/form-validation-ux/SKILL.md +39 -0
- package/native-skills/web/react-state-architecture/SKILL.md +41 -0
- package/native-skills/web/ssr-hydration-correctness/SKILL.md +45 -0
- package/package.json +68 -4
- package/src/agentLoop.js +2458 -0
- package/src/appChannel.js +109 -0
- package/src/approvalGrants.js +66 -0
- package/src/auditLog.js +52 -0
- package/src/autoUpdate.js +12 -0
- package/src/capabilities.js +36 -0
- package/src/checkpoints.js +1199 -0
- package/src/cliMode.js +638 -0
- package/src/commands.js +805 -0
- package/src/compiledSkills.js +540 -0
- package/src/config.js +130 -0
- package/src/contextManager.js +498 -0
- package/src/cortex/autoCondenser.js +192 -0
- package/src/cortex/provisioner.js +27 -0
- package/src/cortex/vectorDb.js +447 -0
- package/src/designPalettes.js +99 -0
- package/src/devMode.js +6 -0
- package/src/embodimentSession.js +180 -0
- package/src/entrypoint.js +194 -0
- package/src/execSafe.js +21 -0
- package/src/gitSnapshot.js +378 -0
- package/src/mcp/jsonRpc.js +165 -0
- package/src/mcp/mcpClient.js +136 -0
- package/src/mcp/mcpRegistry.js +207 -0
- package/src/memory.js +62 -0
- package/src/memoryManager.js +124 -0
- package/src/modelCatalogue.js +132 -0
- package/src/modelIds.js +42 -0
- package/src/modelRouter.js +145 -0
- package/src/outputStore.js +246 -0
- package/src/perception.js +124 -0
- package/src/planCache.js +437 -0
- package/src/procSignal.js +32 -0
- package/src/providers/anthropic.js +408 -0
- package/src/providers/base.js +328 -0
- package/src/providers/dashscope-byok.js +86 -0
- package/src/providers/deepseek.js +80 -0
- package/src/providers/factory.js +37 -0
- package/src/providers/gemini.js +346 -0
- package/src/providers/moonshot.js +73 -0
- package/src/providers/muse.js +169 -0
- package/src/providers/ollama.js +104 -0
- package/src/providers/openai-compatible.js +84 -0
- package/src/providers/openai.js +20 -0
- package/src/providers/qwen-byok.js +76 -0
- package/src/providers/xai.js +73 -0
- package/src/provisioning/capabilityConsent.js +24 -0
- package/src/provisioning/capabilityProvisioner.js +271 -0
- package/src/provisioning/capabilityTools.js +29 -0
- package/src/reporting.js +373 -0
- package/src/sandbox.js +529 -0
- package/src/sessionStore.js +279 -0
- package/src/skillsManager.js +288 -0
- package/src/slopDetector.js +307 -0
- package/src/taskBuffer.js +45 -0
- package/src/taskShape.js +241 -0
- package/src/textDiff.js +150 -0
- package/src/tokenTracker.js +560 -0
- package/src/tool-scripts/ax_snapshot.applescript +39 -0
- package/src/tool-scripts/ax_snapshot.ps1 +10 -0
- package/src/tool-scripts/desktop_act.applescript +58 -0
- package/src/tool-scripts/resolve_automator.py +119 -0
- package/src/toolPacks.js +524 -0
- package/src/tools/archive.js +63 -0
- package/src/tools/astryxOracle.js +78 -0
- package/src/tools/axSnapshot.js +165 -0
- package/src/tools/blenderAutomator.js +349 -0
- package/src/tools/browserAutomator.js +252 -0
- package/src/tools/checkSystemSpecs.js +64 -0
- package/src/tools/clipboard.js +36 -0
- package/src/tools/codeSearchAst.js +101 -0
- package/src/tools/codeSearcher.js +230 -0
- package/src/tools/codeTools.js +262 -0
- package/src/tools/codebaseSearch.js +75 -0
- package/src/tools/createSkill.js +129 -0
- package/src/tools/deferredMcpSearch.js +246 -0
- package/src/tools/desktopAct.js +208 -0
- package/src/tools/desktopOpen.js +34 -0
- package/src/tools/dynamicLoader.js +44 -0
- package/src/tools/executeProceduralMedia.js +93 -0
- package/src/tools/fsDelete.js +51 -0
- package/src/tools/fsEdit.js +105 -0
- package/src/tools/fsFind.js +128 -0
- package/src/tools/fsList.js +77 -0
- package/src/tools/fsMove.js +57 -0
- package/src/tools/fsRead.js +87 -0
- package/src/tools/fsSearch.js +198 -0
- package/src/tools/fsWrite.js +41 -0
- package/src/tools/gameAiBehaviorTree.js +208 -0
- package/src/tools/gameAnimationRig.js +194 -0
- package/src/tools/gameAssetFetcher.js +372 -0
- package/src/tools/gameAssetHarvester.js +210 -0
- package/src/tools/gameAudioSynth.js +267 -0
- package/src/tools/gameBuilder.js +886 -0
- package/src/tools/gameCanvasAudit.js +101 -0
- package/src/tools/gameCinematicSequencer.js +151 -0
- package/src/tools/gameDataForge.js +124 -0
- package/src/tools/gameDialogueTreeGen.js +166 -0
- package/src/tools/gameEconomyBalancer.js +161 -0
- package/src/tools/gameEntitySpawner.js +207 -0
- package/src/tools/gameLoreForge.js +153 -0
- package/src/tools/gameQuestArcBuilder.js +152 -0
- package/src/tools/gameSceneBuilder.js +158 -0
- package/src/tools/gameShaderForge.js +181 -0
- package/src/tools/gameTilemapGenerator.js +164 -0
- package/src/tools/gameUiHudBuilder.js +292 -0
- package/src/tools/gameVfxEmitterGen.js +108 -0
- package/src/tools/gameWebRuntimeInspector.js +91 -0
- package/src/tools/generateMediaViaApi.js +358 -0
- package/src/tools/gitExec.js +54 -0
- package/src/tools/godotAutomator.js +437 -0
- package/src/tools/manageLocalAiDependencies.js +220 -0
- package/src/tools/memoryWrite.js +99 -0
- package/src/tools/multiEdit.js +107 -0
- package/src/tools/palmierAutomator.js +104 -0
- package/src/tools/pdfReader.js +112 -0
- package/src/tools/persistentShell.js +148 -0
- package/src/tools/printer.js +316 -0
- package/src/tools/procTerm.js +91 -0
- package/src/tools/processList.js +52 -0
- package/src/tools/readNativeSkill.js +39 -0
- package/src/tools/registry.js +277 -0
- package/src/tools/resolveAutomator.js +102 -0
- package/src/tools/screenshot.js +75 -0
- package/src/tools/subagentOrchestrator.js +305 -0
- package/src/tools/sysRun.js +125 -0
- package/src/tools/todoWrite.js +75 -0
- package/src/tools/toolDispatch.js +127 -0
- package/src/tools/toolForge.js +124 -0
- package/src/tools/updatePlan.js +88 -0
- package/src/tools/viewImage.js +82 -0
- package/src/tools/visionInspector.js +154 -0
- package/src/tools/webFetch.js +74 -0
- package/src/tools/webSearch.js +82 -0
- package/src/tui/ask.js +164 -0
- package/src/tui/banner.js +158 -0
- package/src/tui/block.js +152 -0
- package/src/tui/chatRenderer.js +953 -0
- package/src/tui/completion.js +424 -0
- package/src/tui/dialogs.js +606 -0
- package/src/tui/diff.js +296 -0
- package/src/tui/history.js +135 -0
- package/src/tui/home.js +217 -0
- package/src/tui/invocation.js +105 -0
- package/src/tui/keys.js +443 -0
- package/src/tui/lineEditor.js +256 -0
- package/src/tui/overlay.js +256 -0
- package/src/tui/prompt.js +1258 -0
- package/src/tui/selectList.js +335 -0
- package/src/tui/stage.js +330 -0
- package/src/tui/stageSurface.js +609 -0
- package/src/tui/statusLine.js +254 -0
- package/src/tui/text.js +345 -0
- package/src/tui/theme.js +393 -0
- package/src/tui/toolDisplay.js +176 -0
- package/src/tui/transcript.js +235 -0
- package/src/tui/viewport.js +337 -0
- package/src/uncertainty.js +271 -0
- package/src/userSkills.js +265 -0
- package/src/utils/cli_ui.js +0 -0
- package/src/utils/inquirer.js +12 -0
- package/src/utils/paths.js +37 -0
- package/src/version.js +38 -0
- package/src/vfeTracker.js +220 -0
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import * as gitBackend from './gitSnapshot.js';
|
|
8
|
+
import { unifiedDiff, looksBinary } from './textDiff.js';
|
|
9
|
+
import { causyneHome } from './utils/paths.js';
|
|
10
|
+
import { getActiveSessionId, SESSION_DIR } from './sessionStore.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Diagnostics, off by default.
|
|
14
|
+
*
|
|
15
|
+
* These were console.warn, which writes to a terminal the chat prompt is
|
|
16
|
+
* repainting a live region into — an uncontrolled second writer that leaves the
|
|
17
|
+
* prompt's row accounting describing a block that has moved. Housekeeping that
|
|
18
|
+
* skipped is not news to the person using Causyne; it is news to whoever is
|
|
19
|
+
* debugging it.
|
|
20
|
+
*/
|
|
21
|
+
function debugLog(message) {
|
|
22
|
+
if (process.env.CAUSYNE_DEBUG !== '1') return;
|
|
23
|
+
process.stderr.write(`[causyne] ${message}\n`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
const STORE_DIR = path.join(causyneHome(), 'checkpoints');
|
|
28
|
+
const OBJECTS_DIR = path.join(STORE_DIR, 'objects');
|
|
29
|
+
const JOURNAL_FILE = path.join(STORE_DIR, 'index.jsonl');
|
|
30
|
+
const STAT_CACHE_FILE = path.join(STORE_DIR, 'stat-cache.json');
|
|
31
|
+
|
|
32
|
+
// The LEGACY single global conversation. Kept only so a journal entry written
|
|
33
|
+
// before sessions existed can still be restored.
|
|
34
|
+
const HISTORY_FILE = path.join(causyneHome(), 'memory', 'chat_history.json');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The conversation file undo must snapshot and restore.
|
|
38
|
+
*
|
|
39
|
+
* THE REGRESSION THIS FIXES. Undo is session-level BY DESIGN: restoring files
|
|
40
|
+
* while the conversation still claims the edits happened leaves the model
|
|
41
|
+
* reasoning about a disk state that no longer exists. That is the whole reason
|
|
42
|
+
* the history is captured at all.
|
|
43
|
+
*
|
|
44
|
+
* Chat history then moved out of one global `memory/chat_history.json` into per
|
|
45
|
+
* session files under `sessions/`, and `migrateLegacyHistory()` RENAMES the old
|
|
46
|
+
* file to `.migrated`. This module was never told. So `fs.existsSync(
|
|
47
|
+
* HISTORY_FILE)` became permanently false, `history` was recorded as null, and
|
|
48
|
+
* **the conversation rewind silently stopped happening** — undo restored the
|
|
49
|
+
* files and left the agent believing in edits that were gone.
|
|
50
|
+
*
|
|
51
|
+
* It survived the test suite because tests/checkpoints.test.js builds this path
|
|
52
|
+
* itself and writes the file before asserting. A test that creates the world it
|
|
53
|
+
* measures proves nothing about production.
|
|
54
|
+
*/
|
|
55
|
+
function activeHistoryFile() {
|
|
56
|
+
try {
|
|
57
|
+
const id = getActiveSessionId();
|
|
58
|
+
if (id) {
|
|
59
|
+
const safe = String(id).replace(/[^a-zA-Z0-9_-]/g, '');
|
|
60
|
+
if (safe) return path.join(SESSION_DIR, `${safe}.json`);
|
|
61
|
+
}
|
|
62
|
+
} catch (e) { /* fall through to the legacy file */ }
|
|
63
|
+
return HISTORY_FILE;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Retention. Generous enough that yesterday's mistake is still undoable, bounded
|
|
67
|
+
// enough that the store cannot grow without limit.
|
|
68
|
+
const RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
|
69
|
+
const MAX_STORE_BYTES = 512 * 1024 * 1024;
|
|
70
|
+
const MAX_JOURNAL_ENTRIES = 4000;
|
|
71
|
+
|
|
72
|
+
// A single file above this is recorded but not stored: snapshotting a 200 MB
|
|
73
|
+
// asset on every write would turn the safety net into a disk problem.
|
|
74
|
+
const MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
75
|
+
|
|
76
|
+
// Whole-workspace snapshot budgets. Exceeding either records an honest "skipped"
|
|
77
|
+
// rather than half a snapshot that would restore into an inconsistent tree.
|
|
78
|
+
const MAX_TREE_FILES = 5000;
|
|
79
|
+
const MAX_TREE_BYTES = 64 * 1024 * 1024;
|
|
80
|
+
const MAX_TREE_FILE_BYTES = 2 * 1024 * 1024;
|
|
81
|
+
|
|
82
|
+
// Directories never worth snapshotting: regenerable, and they dwarf the source.
|
|
83
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
84
|
+
'.git', 'node_modules', 'dist', 'build', 'out', 'target',
|
|
85
|
+
'.next', '.nuxt', '.svelte-kit', '.turbo', '.cache', '.parcel-cache',
|
|
86
|
+
'venv', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache',
|
|
87
|
+
'vendor', 'Pods', '.gradle', '.idea', '.vscode', 'coverage',
|
|
88
|
+
'.causyne'
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/** Which argument of each mutating tool names the file it is about to change. */
|
|
92
|
+
const MUTATING_TOOL_PATHS = {
|
|
93
|
+
fs_write: ['path'],
|
|
94
|
+
fs_edit: ['path'],
|
|
95
|
+
MultiEdit: ['file_path'],
|
|
96
|
+
fs_delete: ['path'],
|
|
97
|
+
// An overwrite move clobbers the destination, so both ends need a snapshot.
|
|
98
|
+
fs_move: ['source', 'destination']
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export function isCheckpointingEnabled() {
|
|
102
|
+
return process.env.CAUSYNE_CHECKPOINTS !== '0';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ensureStore() {
|
|
106
|
+
if (!fs.existsSync(OBJECTS_DIR)) {
|
|
107
|
+
fs.mkdirSync(OBJECTS_DIR, { recursive: true, mode: 0o700 });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sha256(buffer) {
|
|
112
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function objectPath(hash) {
|
|
116
|
+
return path.join(OBJECTS_DIR, hash.slice(0, 2), hash);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Store bytes under their own hash. Identical content is stored once, so a file
|
|
121
|
+
* edited ten times across three distinct states costs three blobs.
|
|
122
|
+
*/
|
|
123
|
+
function writeObject(buffer) {
|
|
124
|
+
const hash = sha256(buffer);
|
|
125
|
+
const target = objectPath(hash);
|
|
126
|
+
if (fs.existsSync(target)) return hash; // already have it
|
|
127
|
+
|
|
128
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
129
|
+
// Write-then-rename: a crash mid-write must never leave a truncated blob
|
|
130
|
+
// sitting under a hash that claims to describe the full content.
|
|
131
|
+
const temp = `${target}.tmp_${process.pid}_${Date.now()}`;
|
|
132
|
+
fs.writeFileSync(temp, buffer, { mode: 0o600 });
|
|
133
|
+
fs.renameSync(temp, target);
|
|
134
|
+
return hash;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readObject(hash) {
|
|
138
|
+
return fs.readFileSync(objectPath(hash));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Expand `~` exactly the way every fs tool does, then resolve. */
|
|
142
|
+
export function resolveToolPath(value) {
|
|
143
|
+
if (typeof value !== 'string' || value.length === 0) return null;
|
|
144
|
+
const expanded = value.replace(/^~\//, os.homedir() + '/');
|
|
145
|
+
return path.resolve(expanded);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function appendJournal(entry) {
|
|
149
|
+
fs.appendFileSync(JOURNAL_FILE, JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function readJournal() {
|
|
153
|
+
if (!fs.existsSync(JOURNAL_FILE)) return [];
|
|
154
|
+
const raw = fs.readFileSync(JOURNAL_FILE, 'utf8');
|
|
155
|
+
const entries = [];
|
|
156
|
+
for (const line of raw.split('\n')) {
|
|
157
|
+
if (!line.trim()) continue;
|
|
158
|
+
try { entries.push(JSON.parse(line)); } catch (e) { /* skip a torn line */ }
|
|
159
|
+
}
|
|
160
|
+
return entries;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function rewriteJournal(entries) {
|
|
164
|
+
const temp = `${JOURNAL_FILE}.tmp_${process.pid}`;
|
|
165
|
+
fs.writeFileSync(temp, entries.map(e => JSON.stringify(e)).join('\n') + (entries.length ? '\n' : ''), { mode: 0o600 });
|
|
166
|
+
fs.renameSync(temp, JOURNAL_FILE);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function newId() {
|
|
170
|
+
return `ck_${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Snapshotting
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Snapshot one file's current state.
|
|
179
|
+
* @returns {object|null} the `before` record, or null when the file is absent
|
|
180
|
+
* (which is itself meaningful: undo of a create means delete).
|
|
181
|
+
*/
|
|
182
|
+
function snapshotFile(absolutePath) {
|
|
183
|
+
let stats;
|
|
184
|
+
try {
|
|
185
|
+
stats = fs.lstatSync(absolutePath);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
return null; // does not exist yet
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A symlink is snapshotted as a link, never followed: following it would
|
|
191
|
+
// capture (and later overwrite) a file somewhere else entirely.
|
|
192
|
+
if (stats.isSymbolicLink()) {
|
|
193
|
+
return { kind: 'symlink', target: fs.readlinkSync(absolutePath), mode: stats.mode & 0o777 };
|
|
194
|
+
}
|
|
195
|
+
if (stats.isDirectory()) {
|
|
196
|
+
return { kind: 'directory', mode: stats.mode & 0o777 };
|
|
197
|
+
}
|
|
198
|
+
if (!stats.isFile()) {
|
|
199
|
+
return { kind: 'special' };
|
|
200
|
+
}
|
|
201
|
+
if (stats.size > MAX_FILE_BYTES) {
|
|
202
|
+
return { kind: 'file', skipped: 'too_large', size: stats.size, mode: stats.mode & 0o777 };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const buffer = fs.readFileSync(absolutePath);
|
|
206
|
+
return {
|
|
207
|
+
kind: 'file',
|
|
208
|
+
hash: writeObject(buffer),
|
|
209
|
+
size: stats.size,
|
|
210
|
+
mode: stats.mode & 0o777
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Capture the state of every file a mutating tool is about to touch.
|
|
216
|
+
*
|
|
217
|
+
* FAIL-OPEN BY CONTRACT: never throws, never blocks the tool call. A read-only
|
|
218
|
+
* or full disk degrades the safety net; it does not fail the task.
|
|
219
|
+
*
|
|
220
|
+
* @returns {string[]} ids of the entries written
|
|
221
|
+
*/
|
|
222
|
+
export function captureBeforeMutation(toolName, args, meta = {}) {
|
|
223
|
+
if (!isCheckpointingEnabled()) return [];
|
|
224
|
+
const fields = MUTATING_TOOL_PATHS[toolName];
|
|
225
|
+
if (!fields || !args) return [];
|
|
226
|
+
|
|
227
|
+
const ids = [];
|
|
228
|
+
try {
|
|
229
|
+
ensureStore();
|
|
230
|
+
for (const field of fields) {
|
|
231
|
+
const absolutePath = resolveToolPath(args[field]);
|
|
232
|
+
if (!absolutePath) continue;
|
|
233
|
+
|
|
234
|
+
const before = snapshotFile(absolutePath);
|
|
235
|
+
const entry = {
|
|
236
|
+
id: newId(),
|
|
237
|
+
ts: Date.now(),
|
|
238
|
+
type: 'file',
|
|
239
|
+
taskId: meta.taskId || 'unknown',
|
|
240
|
+
step: meta.step == null ? null : meta.step,
|
|
241
|
+
tool: toolName,
|
|
242
|
+
path: absolutePath,
|
|
243
|
+
before
|
|
244
|
+
};
|
|
245
|
+
appendJournal(entry);
|
|
246
|
+
ids.push(entry.id);
|
|
247
|
+
}
|
|
248
|
+
} catch (e) {
|
|
249
|
+
debugLog(`checkpoint snapshot skipped: ${e.message}`);
|
|
250
|
+
}
|
|
251
|
+
return ids;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Workspace tree snapshot — covers changes a per-file hook cannot see
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
function loadStatCache() {
|
|
259
|
+
try {
|
|
260
|
+
return JSON.parse(fs.readFileSync(STAT_CACHE_FILE, 'utf8'));
|
|
261
|
+
} catch (e) {
|
|
262
|
+
return {};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function saveStatCache(cache) {
|
|
267
|
+
try {
|
|
268
|
+
const temp = `${STAT_CACHE_FILE}.tmp_${process.pid}`;
|
|
269
|
+
fs.writeFileSync(temp, JSON.stringify(cache), { mode: 0o600 });
|
|
270
|
+
fs.renameSync(temp, STAT_CACHE_FILE);
|
|
271
|
+
} catch (e) { /* the cache is an optimisation; losing it costs time, not correctness */ }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Walk a workspace root and record every file's content hash.
|
|
276
|
+
*
|
|
277
|
+
* The stat cache is what makes this affordable to repeat: a file whose mtime and
|
|
278
|
+
* size are unchanged since the last walk keeps its known hash and is never read
|
|
279
|
+
* again. That is git's own trick, and it is the difference between a snapshot
|
|
280
|
+
* that costs milliseconds and one that re-reads the whole tree every task.
|
|
281
|
+
*
|
|
282
|
+
* @returns {{manifest: object|null, skipped: string|null}}
|
|
283
|
+
*/
|
|
284
|
+
function snapshotTree(root) {
|
|
285
|
+
const cache = loadStatCache();
|
|
286
|
+
const files = {};
|
|
287
|
+
let fileCount = 0;
|
|
288
|
+
let totalBytes = 0;
|
|
289
|
+
const stack = [root];
|
|
290
|
+
|
|
291
|
+
while (stack.length > 0) {
|
|
292
|
+
const dir = stack.pop();
|
|
293
|
+
let entries;
|
|
294
|
+
try {
|
|
295
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
296
|
+
} catch (e) {
|
|
297
|
+
continue; // unreadable directory: skip rather than abort the snapshot
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const entry of entries) {
|
|
301
|
+
const full = path.join(dir, entry.name);
|
|
302
|
+
|
|
303
|
+
if (entry.isDirectory()) {
|
|
304
|
+
if (IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
305
|
+
stack.push(full);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// Symlinks are recorded by target, never followed — following them can
|
|
309
|
+
// walk out of the workspace entirely, or loop.
|
|
310
|
+
if (entry.isSymbolicLink()) {
|
|
311
|
+
try { files[full] = { kind: 'symlink', target: fs.readlinkSync(full) }; } catch (e) {}
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (!entry.isFile()) continue;
|
|
315
|
+
|
|
316
|
+
let stats;
|
|
317
|
+
try { stats = fs.statSync(full); } catch (e) { continue; }
|
|
318
|
+
if (stats.size > MAX_TREE_FILE_BYTES) continue;
|
|
319
|
+
|
|
320
|
+
fileCount++;
|
|
321
|
+
totalBytes += stats.size;
|
|
322
|
+
if (fileCount > MAX_TREE_FILES) return { manifest: null, skipped: 'too_many_files' };
|
|
323
|
+
if (totalBytes > MAX_TREE_BYTES) return { manifest: null, skipped: 'workspace_too_large' };
|
|
324
|
+
|
|
325
|
+
const cached = cache[full];
|
|
326
|
+
if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size
|
|
327
|
+
&& fs.existsSync(objectPath(cached.hash))) {
|
|
328
|
+
files[full] = { kind: 'file', hash: cached.hash, size: stats.size, mode: stats.mode & 0o777 };
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const hash = writeObject(fs.readFileSync(full));
|
|
334
|
+
files[full] = { kind: 'file', hash, size: stats.size, mode: stats.mode & 0o777 };
|
|
335
|
+
cache[full] = { mtimeMs: stats.mtimeMs, size: stats.size, hash };
|
|
336
|
+
} catch (e) { /* a file that vanished mid-walk is simply not in the snapshot */ }
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
saveStatCache(cache);
|
|
341
|
+
return { manifest: { root, files, fileCount, totalBytes }, skipped: null };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Open a checkpoint group for a task: snapshot the conversation, and optionally
|
|
346
|
+
* the workspace tree. Called once per task, fail-open.
|
|
347
|
+
*
|
|
348
|
+
* @returns {string|null} the task id the group is filed under
|
|
349
|
+
*/
|
|
350
|
+
/**
|
|
351
|
+
* Files that mark a directory as a PROJECT rather than a place.
|
|
352
|
+
*
|
|
353
|
+
* Ordered by how strongly each implies "this is the thing being worked on".
|
|
354
|
+
*/
|
|
355
|
+
const PROJECT_MARKERS = [
|
|
356
|
+
'.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml',
|
|
357
|
+
'pom.xml', 'build.gradle', 'build.gradle.kts', 'Gemfile', 'composer.json',
|
|
358
|
+
'requirements.txt', 'CMakeLists.txt', 'Makefile'
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
function isSnapshotableRoot(dir) {
|
|
363
|
+
if (!dir) return false;
|
|
364
|
+
let resolved;
|
|
365
|
+
try {
|
|
366
|
+
resolved = path.resolve(dir);
|
|
367
|
+
} catch (e) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const home = os.homedir();
|
|
372
|
+
const parsed = path.parse(resolved);
|
|
373
|
+
|
|
374
|
+
// The filesystem root, and the user's home itself.
|
|
375
|
+
if (resolved === parsed.root) return false;
|
|
376
|
+
if (home && path.resolve(home) === resolved) return false;
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
// lands in one of these is not in a project, it is lost.
|
|
380
|
+
const forbidden = ['/Users', '/home', '/Users/Shared', '/var', '/tmp', '/etc', '/opt', '/usr'];
|
|
381
|
+
if (forbidden.includes(resolved)) return false;
|
|
382
|
+
|
|
383
|
+
// Directly inside a system root with no project marker is not a workspace.
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The project `startDir` belongs to, or null when it belongs to none.
|
|
389
|
+
*
|
|
390
|
+
* Walks upward for a project marker, stopping at the home directory — a marker
|
|
391
|
+
* found above home would mean snapshotting somebody's whole account.
|
|
392
|
+
*/
|
|
393
|
+
export function resolveProjectRoot(startDir) {
|
|
394
|
+
let current;
|
|
395
|
+
try {
|
|
396
|
+
current = path.resolve(startDir || process.cwd());
|
|
397
|
+
} catch (e) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const home = os.homedir() ? path.resolve(os.homedir()) : null;
|
|
402
|
+
const stop = path.parse(current).root;
|
|
403
|
+
|
|
404
|
+
const start = current;
|
|
405
|
+
let nearestMarker = null;
|
|
406
|
+
|
|
407
|
+
for (let depth = 0; depth < 40; depth++) {
|
|
408
|
+
if (!isSnapshotableRoot(current)) break;
|
|
409
|
+
|
|
410
|
+
// A repository root wins outright, even from further up.
|
|
411
|
+
//
|
|
412
|
+
// Stopping at the FIRST marker found sounds right and is not: this project
|
|
413
|
+
// has package.json in causyne-agent/ and .git one level above it, so the
|
|
414
|
+
// nearest marker would scope the checkpoint to causyne-agent — which the git
|
|
415
|
+
// backend then declines, because its root is not the repository root. The
|
|
416
|
+
// native store would take it instead and immediately hit MAX_TREE_FILES on
|
|
417
|
+
// node_modules. The repository root is both the true project boundary AND
|
|
418
|
+
// the only root where git can apply the user's own .gitignore, which is what
|
|
419
|
+
// keeps a snapshot to the files that actually matter.
|
|
420
|
+
try {
|
|
421
|
+
if (fs.existsSync(path.join(current, '.git'))) return current;
|
|
422
|
+
} catch (e) {}
|
|
423
|
+
|
|
424
|
+
if (!nearestMarker) {
|
|
425
|
+
for (const marker of PROJECT_MARKERS) {
|
|
426
|
+
if (marker === '.git') continue;
|
|
427
|
+
try {
|
|
428
|
+
if (fs.existsSync(path.join(current, marker))) { nearestMarker = current; break; }
|
|
429
|
+
} catch (e) {}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const parent = path.dirname(current);
|
|
434
|
+
if (parent === current || current === stop) break;
|
|
435
|
+
if (home && parent === home) break;
|
|
436
|
+
current = parent;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// No repository anywhere above: fall back to the nearest ordinary project.
|
|
440
|
+
return nearestMarker && isSnapshotableRoot(nearestMarker) ? nearestMarker : null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Capture `root` with the git backend, or return null to use the native store.
|
|
445
|
+
*
|
|
446
|
+
* Every failure path returns null rather than throwing: a checkpoint is
|
|
447
|
+
* best-effort and must never be the reason a task fails to start.
|
|
448
|
+
*/
|
|
449
|
+
function gitRootFor(root) {
|
|
450
|
+
try {
|
|
451
|
+
if (!gitBackend.isGitAvailable()) return null;
|
|
452
|
+
const worktree = gitBackend.resolveWorktree(root);
|
|
453
|
+
// Only capture when the root IS the repository root. A subdirectory would
|
|
454
|
+
// snapshot its whole parent project, which is not what the caller asked to
|
|
455
|
+
// protect and could be enormous.
|
|
456
|
+
if (!worktree || path.resolve(worktree) !== path.resolve(root)) return null;
|
|
457
|
+
return worktree;
|
|
458
|
+
} catch (e) {
|
|
459
|
+
debugLog(`git checkpoint unavailable for ${root}: ${e.message}`);
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* What a git-backed tree would restore, and — when asked — restoring it.
|
|
466
|
+
*
|
|
467
|
+
* Returns null on any git failure so the caller falls back to the native path;
|
|
468
|
+
* reporting "nothing changed" because git errored would be a lie with a
|
|
469
|
+
* rollback button attached to it.
|
|
470
|
+
*/
|
|
471
|
+
function gitTreeChanges(tree, undoTaskId, dryRun, applyFull) {
|
|
472
|
+
try {
|
|
473
|
+
const changes = gitBackend.previewRestore(tree.root, tree.gitTree);
|
|
474
|
+
const driftFiles = changes.map((c) => c.path);
|
|
475
|
+
|
|
476
|
+
if (!applyFull || dryRun) {
|
|
477
|
+
return {
|
|
478
|
+
driftFiles,
|
|
479
|
+
results: changes.map((c) => ({
|
|
480
|
+
path: path.join(tree.root, c.path),
|
|
481
|
+
action: c.status === 'deleted' ? 'removed' : 'restored',
|
|
482
|
+
ok: true
|
|
483
|
+
}))
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
gitBackend.restoreTree(tree.root, tree.gitTree);
|
|
488
|
+
return {
|
|
489
|
+
driftFiles,
|
|
490
|
+
results: changes.map((c) => ({
|
|
491
|
+
path: path.join(tree.root, c.path),
|
|
492
|
+
action: c.status === 'deleted' ? 'removed' : 'restored',
|
|
493
|
+
ok: true
|
|
494
|
+
}))
|
|
495
|
+
};
|
|
496
|
+
} catch (e) {
|
|
497
|
+
debugLog(`git restore unavailable for ${tree.root}: ${e.message}`);
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export async function beginTask(meta = {}) {
|
|
503
|
+
if (!isCheckpointingEnabled()) return null;
|
|
504
|
+
const taskId = meta.taskId || 'unknown';
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
ensureStore();
|
|
508
|
+
|
|
509
|
+
// The conversation. Snapshotting the FILE rather than a message index,
|
|
510
|
+
// because boundHistory() trims chat_history.json to its newest 120 messages
|
|
511
|
+
// on every save — an index into that array does not survive the trimming.
|
|
512
|
+
let history = null;
|
|
513
|
+
const historyFile = activeHistoryFile();
|
|
514
|
+
if (fs.existsSync(historyFile)) {
|
|
515
|
+
const buffer = fs.readFileSync(historyFile);
|
|
516
|
+
// The PATH is recorded, not just the bytes. The active session can change
|
|
517
|
+
// between the task and the undo, and restoring a snapshot into whichever
|
|
518
|
+
// conversation happens to be current would corrupt a different one.
|
|
519
|
+
history = { hash: writeObject(buffer), size: buffer.length, file: historyFile };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
appendJournal({
|
|
523
|
+
id: newId(),
|
|
524
|
+
ts: Date.now(),
|
|
525
|
+
type: 'task_start',
|
|
526
|
+
taskId,
|
|
527
|
+
prompt: typeof meta.prompt === 'string' ? meta.prompt.slice(0, 200) : null,
|
|
528
|
+
history
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// The workspace tree, which also covers files changed by sysRun and by any
|
|
532
|
+
// other tool that does not announce its paths.
|
|
533
|
+
//
|
|
534
|
+
// De-duplicated before the slice: only three roots are snapshotted, so a
|
|
535
|
+
// repeated path spends one of those three slots twice and doubles the
|
|
536
|
+
// storage for a directory already covered. The caller's list is a
|
|
537
|
+
// concatenation of the working directory and the permitted scope, which
|
|
538
|
+
// overlap whenever the user is working inside their own workspace — that is
|
|
539
|
+
// to say, normally.
|
|
540
|
+
// A root that is not a project is not worth snapshotting, and trying is
|
|
541
|
+
// what made undo useless: $HOME blew past MAX_TREE_FILES and the fallbacks
|
|
542
|
+
// captured directories nobody edits. Filtered HERE as well as at the caller,
|
|
543
|
+
// because a checkpoint store that quietly fills with junk is worse than one
|
|
544
|
+
// that honestly captures nothing.
|
|
545
|
+
const roots = (meta.workspaceRoots || [])
|
|
546
|
+
.filter((root, index, all) => root && all.indexOf(root) === index)
|
|
547
|
+
.filter((root) => isSnapshotableRoot(root))
|
|
548
|
+
.slice(0, 3);
|
|
549
|
+
|
|
550
|
+
for (const root of roots) {
|
|
551
|
+
if (!fs.existsSync(root)) continue;
|
|
552
|
+
|
|
553
|
+
// Prefer git where the root is a repository and git is installed.
|
|
554
|
+
//
|
|
555
|
+
// The file-copy path below copies bytes, so it has to cap what it will
|
|
556
|
+
// look at — MAX_TREE_FILES is 5000, and a real home directory exceeds it
|
|
557
|
+
// and is recorded as `too_many_files`, covered by no rollback at all. Git
|
|
558
|
+
// has no such ceiling: it reuses the project's own object database, so a
|
|
559
|
+
// checkpoint is one `write-tree` rather than a walk-and-hash of every
|
|
560
|
+
// file. The shadow repository lives in ~/.causyne and the user's own .git
|
|
561
|
+
// is never written to.
|
|
562
|
+
//
|
|
563
|
+
// Fail-open in both directions: no git binary, or a root that is not a
|
|
564
|
+
// repository, or any git error at all, and this silently uses the native
|
|
565
|
+
// store exactly as before.
|
|
566
|
+
// The decision is made SYNCHRONOUSLY, and only a real git capture awaits.
|
|
567
|
+
//
|
|
568
|
+
// Awaiting unconditionally deferred the native path too: an `async`
|
|
569
|
+
// function runs synchronously only until its first await, so every caller
|
|
570
|
+
// that does not itself await — which is most of them — stopped seeing the
|
|
571
|
+
// journal entries this function had always written before returning.
|
|
572
|
+
const gitWorktree = gitRootFor(root);
|
|
573
|
+
let gitTree = null;
|
|
574
|
+
if (gitWorktree) {
|
|
575
|
+
// Async here and nowhere else: this runs at the start of every task,
|
|
576
|
+
// and a synchronous capture can stall the foreground agent loop.
|
|
577
|
+
try {
|
|
578
|
+
gitTree = await gitBackend.captureTreeAsync(gitWorktree);
|
|
579
|
+
} catch (e) {
|
|
580
|
+
debugLog(`git capture failed for ${root}: ${e.message}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (gitTree) {
|
|
584
|
+
appendJournal({
|
|
585
|
+
id: newId(),
|
|
586
|
+
ts: Date.now(),
|
|
587
|
+
type: 'tree',
|
|
588
|
+
taskId,
|
|
589
|
+
root,
|
|
590
|
+
skipped: null,
|
|
591
|
+
backend: 'git',
|
|
592
|
+
gitTree,
|
|
593
|
+
manifestHash: null,
|
|
594
|
+
fileCount: 0,
|
|
595
|
+
totalBytes: 0
|
|
596
|
+
});
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const { manifest, skipped } = snapshotTree(root);
|
|
601
|
+
appendJournal({
|
|
602
|
+
id: newId(),
|
|
603
|
+
ts: Date.now(),
|
|
604
|
+
type: 'tree',
|
|
605
|
+
taskId,
|
|
606
|
+
root,
|
|
607
|
+
skipped,
|
|
608
|
+
manifestHash: manifest ? writeObject(Buffer.from(JSON.stringify(manifest), 'utf8')) : null,
|
|
609
|
+
fileCount: manifest ? manifest.fileCount : 0,
|
|
610
|
+
totalBytes: manifest ? manifest.totalBytes : 0
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return taskId;
|
|
615
|
+
} catch (e) {
|
|
616
|
+
debugLog(`checkpoint task snapshot skipped: ${e.message}`);
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
// Restore
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Refuse to write through anything that is not a plain file.
|
|
627
|
+
*
|
|
628
|
+
* A restore writes attacker-influenced paths. If the target has been replaced by
|
|
629
|
+
* a symlink between snapshot and restore, following it writes wherever the link
|
|
630
|
+
* points; a hardlink means the write lands in a second visible location too.
|
|
631
|
+
* Both are refused rather than followed.
|
|
632
|
+
*/
|
|
633
|
+
function assertSafeTarget(absolutePath) {
|
|
634
|
+
if (!path.isAbsolute(absolutePath)) {
|
|
635
|
+
throw new Error(`refusing a non-absolute path: ${absolutePath}`);
|
|
636
|
+
}
|
|
637
|
+
if (absolutePath !== path.resolve(absolutePath)) {
|
|
638
|
+
throw new Error(`refusing an unnormalised path: ${absolutePath}`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
let stats;
|
|
642
|
+
try {
|
|
643
|
+
stats = fs.lstatSync(absolutePath);
|
|
644
|
+
} catch (e) {
|
|
645
|
+
return; // absent: nothing to be tricked by
|
|
646
|
+
}
|
|
647
|
+
if (stats.isSymbolicLink()) {
|
|
648
|
+
throw new Error(`refusing to write through a symlink: ${absolutePath}`);
|
|
649
|
+
}
|
|
650
|
+
if (stats.isFile() && stats.nlink > 1) {
|
|
651
|
+
throw new Error(`refusing to write through a hardlink (${stats.nlink} links): ${absolutePath}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function restoreFileState(absolutePath, before) {
|
|
656
|
+
assertSafeTarget(absolutePath);
|
|
657
|
+
|
|
658
|
+
// The file did not exist when we snapshotted, so undoing means removing it.
|
|
659
|
+
if (before === null) {
|
|
660
|
+
if (fs.existsSync(absolutePath)) fs.rmSync(absolutePath, { recursive: true, force: true });
|
|
661
|
+
return 'removed';
|
|
662
|
+
}
|
|
663
|
+
if (before.kind === 'symlink') {
|
|
664
|
+
// lstat, not exists(): a dangling symlink fails existsSync but still
|
|
665
|
+
// occupies the name and would make symlinkSync throw EEXIST.
|
|
666
|
+
try { fs.lstatSync(absolutePath); fs.unlinkSync(absolutePath); } catch (e) { /* nothing there */ }
|
|
667
|
+
fs.symlinkSync(before.target, absolutePath);
|
|
668
|
+
return 'restored symlink';
|
|
669
|
+
}
|
|
670
|
+
if (before.kind === 'directory') {
|
|
671
|
+
fs.mkdirSync(absolutePath, { recursive: true });
|
|
672
|
+
return 'restored directory';
|
|
673
|
+
}
|
|
674
|
+
if (before.kind !== 'file') return 'skipped (not a regular file)';
|
|
675
|
+
if (before.skipped) return `skipped (${before.skipped})`;
|
|
676
|
+
|
|
677
|
+
const buffer = readObject(before.hash);
|
|
678
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
679
|
+
// Atomic swap, matching how fs_write itself lands content.
|
|
680
|
+
const temp = `${absolutePath}.causyne_restore_${process.pid}_${Date.now()}`;
|
|
681
|
+
fs.writeFileSync(temp, buffer, { mode: before.mode || 0o644 });
|
|
682
|
+
fs.renameSync(temp, absolutePath);
|
|
683
|
+
return 'restored';
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Compare the workspace as it is NOW against the manifest taken at task start.
|
|
688
|
+
*
|
|
689
|
+
* This is what covers files changed by `sysRun` and friends: a shell command
|
|
690
|
+
* announces no paths, so the per-file hook cannot see it, but the tree snapshot
|
|
691
|
+
* did record the before-state of everything under the workspace root.
|
|
692
|
+
*
|
|
693
|
+
* @returns {{modified: string[], created: string[], deleted: string[]}|null}
|
|
694
|
+
*/
|
|
695
|
+
export function diffTree(treeEntry) {
|
|
696
|
+
if (!treeEntry || !treeEntry.manifestHash || treeEntry.skipped) return null;
|
|
697
|
+
|
|
698
|
+
let manifest;
|
|
699
|
+
try {
|
|
700
|
+
manifest = JSON.parse(readObject(treeEntry.manifestHash).toString('utf8'));
|
|
701
|
+
} catch (e) {
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const modified = [];
|
|
706
|
+
const deleted = [];
|
|
707
|
+
const known = new Set(Object.keys(manifest.files || {}));
|
|
708
|
+
|
|
709
|
+
for (const [absolutePath, record] of Object.entries(manifest.files || {})) {
|
|
710
|
+
if (record.kind !== 'file') continue;
|
|
711
|
+
let stats;
|
|
712
|
+
try {
|
|
713
|
+
stats = fs.lstatSync(absolutePath);
|
|
714
|
+
} catch (e) {
|
|
715
|
+
deleted.push(absolutePath);
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (stats.isSymbolicLink() || !stats.isFile()) continue;
|
|
719
|
+
if (stats.size !== record.size) { modified.push(absolutePath); continue; }
|
|
720
|
+
try {
|
|
721
|
+
if (sha256(fs.readFileSync(absolutePath)) !== record.hash) modified.push(absolutePath);
|
|
722
|
+
} catch (e) { /* unreadable now: leave it alone rather than guess */ }
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Files that exist now but were not in the snapshot were created during the task.
|
|
726
|
+
const created = [];
|
|
727
|
+
const { manifest: current } = snapshotTree(manifest.root);
|
|
728
|
+
if (current) {
|
|
729
|
+
for (const absolutePath of Object.keys(current.files)) {
|
|
730
|
+
if (!known.has(absolutePath)) created.push(absolutePath);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return { modified, created, deleted, manifest };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Restore every file the tree snapshot covers that has drifted since task start.
|
|
739
|
+
* Deliberately NOT the default: it reverts changes the agent did not make too
|
|
740
|
+
* (anything you edited yourself in another window while the task ran), so it is
|
|
741
|
+
* only reached through an explicit `--full`.
|
|
742
|
+
*/
|
|
743
|
+
function restoreFromTree(treeEntry, undoTaskId, dryRun) {
|
|
744
|
+
const diff = diffTree(treeEntry);
|
|
745
|
+
if (!diff) return [];
|
|
746
|
+
|
|
747
|
+
const results = [];
|
|
748
|
+
const apply = (absolutePath, before, label) => {
|
|
749
|
+
if (dryRun) {
|
|
750
|
+
results.push({ path: absolutePath, action: `would ${label}` });
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
const current = snapshotFile(absolutePath);
|
|
755
|
+
appendJournal({
|
|
756
|
+
id: newId(), ts: Date.now(), type: 'file', taskId: undoTaskId,
|
|
757
|
+
step: null, tool: 'undo-tree', path: absolutePath, before: current
|
|
758
|
+
});
|
|
759
|
+
results.push({ path: absolutePath, action: restoreFileState(absolutePath, before) });
|
|
760
|
+
} catch (e) {
|
|
761
|
+
results.push({ path: absolutePath, action: `FAILED: ${e.message}` });
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
for (const absolutePath of [...diff.modified, ...diff.deleted]) {
|
|
766
|
+
apply(absolutePath, diff.manifest.files[absolutePath], 'restore');
|
|
767
|
+
}
|
|
768
|
+
for (const absolutePath of diff.created) {
|
|
769
|
+
apply(absolutePath, null, 'remove');
|
|
770
|
+
}
|
|
771
|
+
return results;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
export function isUndoable(group) {
|
|
776
|
+
if (!group) return false;
|
|
777
|
+
return group.fileEntries.length > 0 || group.trees.some((tree) => !tree.skipped);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Group the journal into per-task groups, newest first. */
|
|
781
|
+
export function listGroups(limit = 20) {
|
|
782
|
+
const entries = readJournal();
|
|
783
|
+
const byTask = new Map();
|
|
784
|
+
|
|
785
|
+
for (const entry of entries) {
|
|
786
|
+
if (!byTask.has(entry.taskId)) {
|
|
787
|
+
byTask.set(entry.taskId, { taskId: entry.taskId, ts: entry.ts, entries: [], prompt: null });
|
|
788
|
+
}
|
|
789
|
+
const group = byTask.get(entry.taskId);
|
|
790
|
+
group.ts = Math.max(group.ts, entry.ts);
|
|
791
|
+
if (entry.type === 'task_start' && entry.prompt) group.prompt = entry.prompt;
|
|
792
|
+
group.entries.push(entry);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return [...byTask.values()]
|
|
796
|
+
.sort((a, b) => b.ts - a.ts)
|
|
797
|
+
.slice(0, limit)
|
|
798
|
+
.map(group => ({
|
|
799
|
+
...group,
|
|
800
|
+
fileEntries: group.entries.filter(e => e.type === 'file'),
|
|
801
|
+
hasHistory: group.entries.some(e => e.type === 'task_start' && e.history),
|
|
802
|
+
trees: group.entries.filter(e => e.type === 'tree')
|
|
803
|
+
}));
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Roll a task back: its files AND the conversation, together.
|
|
808
|
+
*
|
|
809
|
+
* The restore itself is checkpointed first, under a fresh `undo_<ts>` task, so
|
|
810
|
+
* an undo can itself be undone.
|
|
811
|
+
*/
|
|
812
|
+
export function undoTask(taskId, options = {}) {
|
|
813
|
+
// Fail SAFE, not dangerous.
|
|
814
|
+
//
|
|
815
|
+
// This was `=== true`, so an options object that simply lacked the flag —
|
|
816
|
+
// a malformed frame, a truncated payload, a new caller that forgot it —
|
|
817
|
+
// performed a REAL rollback over the user's files. The default for an
|
|
818
|
+
// operation that overwrites work must be the one that writes nothing; a
|
|
819
|
+
// caller that means to write now has to say so.
|
|
820
|
+
const dryRun = options.dryRun !== false;
|
|
821
|
+
const groups = listGroups(1000);
|
|
822
|
+
const group = groups.find(g => g.taskId === taskId);
|
|
823
|
+
if (!group) return { ok: false, error: `no checkpoint group for task "${taskId}"` };
|
|
824
|
+
|
|
825
|
+
const results = [];
|
|
826
|
+
const undoTaskId = `undo_${Date.now().toString(36)}`;
|
|
827
|
+
|
|
828
|
+
// A file touched three times in one task must end up back at the state it had
|
|
829
|
+
// before the FIRST of those writes — so for each path keep the OLDEST
|
|
830
|
+
// snapshot, walking the group oldest-first.
|
|
831
|
+
const earliest = new Map();
|
|
832
|
+
for (const entry of [...group.fileEntries].sort((a, b) => a.ts - b.ts)) {
|
|
833
|
+
if (!earliest.has(entry.path)) earliest.set(entry.path, entry);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const wantsFiles = options.scope !== 'conversation';
|
|
837
|
+
const wantsHistory = options.scope !== 'code';
|
|
838
|
+
|
|
839
|
+
for (const entry of (wantsFiles ? earliest.values() : [])) {
|
|
840
|
+
if (dryRun) {
|
|
841
|
+
results.push({ path: entry.path, action: entry.before === null ? 'would remove' : 'would restore' });
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
try {
|
|
845
|
+
// Checkpoint the CURRENT state before overwriting it, so this is reversible.
|
|
846
|
+
const current = snapshotFile(entry.path);
|
|
847
|
+
appendJournal({
|
|
848
|
+
id: newId(), ts: Date.now(), type: 'file', taskId: undoTaskId,
|
|
849
|
+
step: null, tool: 'undo', path: entry.path, before: current
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
const action = restoreFileState(entry.path, entry.before);
|
|
853
|
+
results.push({ path: entry.path, action });
|
|
854
|
+
} catch (e) {
|
|
855
|
+
results.push({ path: entry.path, action: `FAILED: ${e.message}` });
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// The conversation, rewound to the state it had when the task began.
|
|
860
|
+
let historyRestored = false;
|
|
861
|
+
const start = group.entries.find(e => e.type === 'task_start' && e.history);
|
|
862
|
+
|
|
863
|
+
// The two halves of a rewind are independently selectable.
|
|
864
|
+
//
|
|
865
|
+
// undoTask has always restored files AND rewound the conversation together,
|
|
866
|
+
// and the split existed internally without ever reaching the user. A rewind
|
|
867
|
+
// that only rewinds the chat is genuinely useful — it is how you re-ask a
|
|
868
|
+
// question without throwing away work the agent got right — and one that only
|
|
869
|
+
// touches files keeps the reasoning while discarding the edits. An absent
|
|
870
|
+
// scope means BOTH, so every existing caller is unchanged.
|
|
871
|
+
if (start && !dryRun && wantsHistory) {
|
|
872
|
+
// Restore into the file the snapshot was TAKEN from. Entries written before
|
|
873
|
+
// sessions existed carry no path and mean the legacy global history.
|
|
874
|
+
const target = start.history.file || HISTORY_FILE;
|
|
875
|
+
try {
|
|
876
|
+
// A journal is local and 0600, but it is still data deciding a write
|
|
877
|
+
// target. Confine the restore to Causyne's own state root so a tampered or
|
|
878
|
+
// hand-edited entry cannot direct it anywhere else.
|
|
879
|
+
const root = causyneHome();
|
|
880
|
+
const resolved = path.resolve(target);
|
|
881
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
882
|
+
throw new Error('conversation snapshot points outside the Causyne state directory');
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (fs.existsSync(resolved)) {
|
|
886
|
+
const buffer = fs.readFileSync(resolved);
|
|
887
|
+
appendJournal({
|
|
888
|
+
id: newId(), ts: Date.now(), type: 'task_start', taskId: undoTaskId,
|
|
889
|
+
prompt: 'undo restore point',
|
|
890
|
+
history: { hash: writeObject(buffer), size: buffer.length, file: resolved }
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
assertSafeTarget(resolved);
|
|
894
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
895
|
+
fs.writeFileSync(resolved, readObject(start.history.hash), { mode: 0o600 });
|
|
896
|
+
historyRestored = true;
|
|
897
|
+
} catch (e) {
|
|
898
|
+
results.push({ path: target, action: `FAILED: ${e.message}` });
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Workspace drift the per-file hook could not see — a shell command announces
|
|
903
|
+
// no paths. Reported by default, restored only when explicitly asked, because
|
|
904
|
+
// it also covers edits the user made by hand while the task ran.
|
|
905
|
+
const drift = [];
|
|
906
|
+
const treeResults = [];
|
|
907
|
+
for (const tree of (wantsFiles ? group.trees : [])) {
|
|
908
|
+
if (tree.skipped) continue;
|
|
909
|
+
|
|
910
|
+
// A git-backed tree answers both questions from the same pair of SHAs, so
|
|
911
|
+
// the preview and the restore can never disagree about what would happen.
|
|
912
|
+
if (tree.backend === 'git' && tree.gitTree) {
|
|
913
|
+
const gitChanges = gitTreeChanges(tree, undoTaskId, dryRun, options.full === true);
|
|
914
|
+
if (gitChanges) {
|
|
915
|
+
if (options.full) treeResults.push(...gitChanges.results);
|
|
916
|
+
else if (gitChanges.driftFiles.length > 0) {
|
|
917
|
+
drift.push({ root: tree.root, files: gitChanges.driftFiles });
|
|
918
|
+
}
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
// Any git failure falls through to the native path below rather than
|
|
922
|
+
// silently reporting that nothing changed.
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (options.full) {
|
|
926
|
+
treeResults.push(...restoreFromTree(tree, undoTaskId, dryRun));
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
const diff = diffTree(tree);
|
|
930
|
+
if (!diff) continue;
|
|
931
|
+
const untracked = [...diff.modified, ...diff.created, ...diff.deleted]
|
|
932
|
+
.filter(p => !earliest.has(p));
|
|
933
|
+
if (untracked.length > 0) drift.push({ root: tree.root, files: untracked });
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
ok: true,
|
|
938
|
+
taskId,
|
|
939
|
+
dryRun,
|
|
940
|
+
full: options.full === true,
|
|
941
|
+
results: [...results, ...treeResults],
|
|
942
|
+
historyRestored,
|
|
943
|
+
historyAvailable: Boolean(start),
|
|
944
|
+
scope: options.scope || 'both',
|
|
945
|
+
drift,
|
|
946
|
+
skippedTrees: group.trees.filter(t => t.skipped).map(t => ({ root: t.root, reason: t.skipped }))
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Undo the most recent task that actually changed a file. */
|
|
951
|
+
export function undoLast(options = {}) {
|
|
952
|
+
// A task is undoable if it captured file entries OR a workspace tree.
|
|
953
|
+
//
|
|
954
|
+
// This used to require `fileEntries.length > 0`, which quietly discarded the
|
|
955
|
+
// entire tree mechanism. The per-file hook only fires for the fs_* tools;
|
|
956
|
+
// a task that did its work through the shell — `npm install`, `git checkout`,
|
|
957
|
+
// a build script, `mv`, `rm` — announces no paths and produces ZERO file
|
|
958
|
+
// entries. beginTask() snapshots the whole workspace tree precisely to cover
|
|
959
|
+
// that case, and undoTask() already knows how to diff and restore it.
|
|
960
|
+
//
|
|
961
|
+
// So the store held hundreds of tree snapshots, cost megabytes to keep, and
|
|
962
|
+
// `/undo` answered "no file changes have been checkpointed yet" — which was
|
|
963
|
+
// not merely unhelpful, it was untrue.
|
|
964
|
+
const candidates = listGroups(200).filter((group) =>
|
|
965
|
+
!group.taskId.startsWith('undo_') && isUndoable(group));
|
|
966
|
+
|
|
967
|
+
if (candidates.length === 0) {
|
|
968
|
+
return { ok: false, error: 'nothing has been checkpointed yet — no task has run in this workspace' };
|
|
969
|
+
}
|
|
970
|
+
return undoTask(candidates[0].taskId, options);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Undo just the single most recent file mutation. */
|
|
974
|
+
export function undoStep(options = {}) {
|
|
975
|
+
// Fail SAFE, not dangerous.
|
|
976
|
+
//
|
|
977
|
+
// This was `=== true`, so an options object that simply lacked the flag —
|
|
978
|
+
// a malformed frame, a truncated payload, a new caller that forgot it —
|
|
979
|
+
// performed a REAL rollback over the user's files. The default for an
|
|
980
|
+
// operation that overwrites work must be the one that writes nothing; a
|
|
981
|
+
// caller that means to write now has to say so.
|
|
982
|
+
const dryRun = options.dryRun !== false;
|
|
983
|
+
const entries = readJournal().filter(e => e.type === 'file' && e.tool !== 'undo');
|
|
984
|
+
if (entries.length === 0) return { ok: false, error: 'no file mutation has been checkpointed yet' };
|
|
985
|
+
|
|
986
|
+
const entry = entries[entries.length - 1];
|
|
987
|
+
if (dryRun) {
|
|
988
|
+
return { ok: true, dryRun, results: [{ path: entry.path, action: entry.before === null ? 'would remove' : 'would restore' }] };
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
try {
|
|
992
|
+
const current = snapshotFile(entry.path);
|
|
993
|
+
appendJournal({
|
|
994
|
+
id: newId(), ts: Date.now(), type: 'file', taskId: `undo_${Date.now().toString(36)}`,
|
|
995
|
+
step: null, tool: 'undo', path: entry.path, before: current
|
|
996
|
+
});
|
|
997
|
+
const action = restoreFileState(entry.path, entry.before);
|
|
998
|
+
return { ok: true, dryRun: false, results: [{ path: entry.path, action }] };
|
|
999
|
+
} catch (e) {
|
|
1000
|
+
return { ok: false, error: e.message };
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// ---------------------------------------------------------------------------
|
|
1005
|
+
// Retention
|
|
1006
|
+
// ---------------------------------------------------------------------------
|
|
1007
|
+
|
|
1008
|
+
function directorySize(dir) {
|
|
1009
|
+
let total = 0;
|
|
1010
|
+
const stack = [dir];
|
|
1011
|
+
while (stack.length > 0) {
|
|
1012
|
+
const current = stack.pop();
|
|
1013
|
+
let entries;
|
|
1014
|
+
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch (e) { continue; }
|
|
1015
|
+
for (const entry of entries) {
|
|
1016
|
+
const full = path.join(current, entry.name);
|
|
1017
|
+
if (entry.isDirectory()) { stack.push(full); continue; }
|
|
1018
|
+
try { total += fs.statSync(full).size; } catch (e) {}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return total;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Drop journal entries past the retention window (or past the entry ceiling),
|
|
1026
|
+
* then delete every blob no surviving entry still references.
|
|
1027
|
+
*/
|
|
1028
|
+
export function pruneStore() {
|
|
1029
|
+
try {
|
|
1030
|
+
if (!fs.existsSync(JOURNAL_FILE)) return { removedEntries: 0, removedObjects: 0 };
|
|
1031
|
+
|
|
1032
|
+
let entries = readJournal();
|
|
1033
|
+
const before = entries.length;
|
|
1034
|
+
const cutoff = Date.now() - RETENTION_MS;
|
|
1035
|
+
const overSize = directorySize(OBJECTS_DIR) > MAX_STORE_BYTES;
|
|
1036
|
+
|
|
1037
|
+
entries = entries.filter(e => e.ts >= cutoff);
|
|
1038
|
+
if (entries.length > MAX_JOURNAL_ENTRIES || overSize) {
|
|
1039
|
+
const keep = overSize ? Math.floor(MAX_JOURNAL_ENTRIES / 2) : MAX_JOURNAL_ENTRIES;
|
|
1040
|
+
entries = entries.slice(-keep);
|
|
1041
|
+
}
|
|
1042
|
+
if (entries.length !== before) rewriteJournal(entries);
|
|
1043
|
+
|
|
1044
|
+
// Mark and sweep: anything no surviving entry references is unreachable.
|
|
1045
|
+
const live = new Set();
|
|
1046
|
+
for (const entry of entries) {
|
|
1047
|
+
if (entry.before && entry.before.hash) live.add(entry.before.hash);
|
|
1048
|
+
if (entry.history && entry.history.hash) live.add(entry.history.hash);
|
|
1049
|
+
if (entry.manifestHash) {
|
|
1050
|
+
live.add(entry.manifestHash);
|
|
1051
|
+
try {
|
|
1052
|
+
const manifest = JSON.parse(readObject(entry.manifestHash).toString('utf8'));
|
|
1053
|
+
for (const record of Object.values(manifest.files || {})) {
|
|
1054
|
+
if (record && record.hash) live.add(record.hash);
|
|
1055
|
+
}
|
|
1056
|
+
} catch (e) { /* an unreadable manifest keeps only itself alive */ }
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
let removedObjects = 0;
|
|
1061
|
+
let shards;
|
|
1062
|
+
try { shards = fs.readdirSync(OBJECTS_DIR); } catch (e) { shards = []; }
|
|
1063
|
+
for (const shard of shards) {
|
|
1064
|
+
const shardDir = path.join(OBJECTS_DIR, shard);
|
|
1065
|
+
let objects;
|
|
1066
|
+
try { objects = fs.readdirSync(shardDir); } catch (e) { continue; }
|
|
1067
|
+
for (const name of objects) {
|
|
1068
|
+
if (live.has(name)) continue;
|
|
1069
|
+
try { fs.unlinkSync(path.join(shardDir, name)); removedObjects++; } catch (e) {}
|
|
1070
|
+
}
|
|
1071
|
+
try { if (fs.readdirSync(shardDir).length === 0) fs.rmdirSync(shardDir); } catch (e) {}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
return { removedEntries: before - entries.length, removedObjects };
|
|
1075
|
+
} catch (e) {
|
|
1076
|
+
debugLog(`checkpoint prune skipped: ${e.message}`);
|
|
1077
|
+
return { removedEntries: 0, removedObjects: 0 };
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
export function getStoreDir() {
|
|
1082
|
+
return STORE_DIR;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
export function getStoreStats() {
|
|
1086
|
+
return {
|
|
1087
|
+
dir: STORE_DIR,
|
|
1088
|
+
entries: readJournal().length,
|
|
1089
|
+
bytes: fs.existsSync(OBJECTS_DIR) ? directorySize(OBJECTS_DIR) : 0
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ---------------------------------------------------------------------------
|
|
1094
|
+
// Task diff — what REVIEW shows
|
|
1095
|
+
// ---------------------------------------------------------------------------
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* The per-file changes a task made, as a real diff.
|
|
1099
|
+
*
|
|
1100
|
+
* This is what REVIEW opens. Before it existed, REVIEW raised the ROLLBACK
|
|
1101
|
+
* confirmation dialog — so "let me see what happened" and "undo this" were the
|
|
1102
|
+
* same button, and the only way to find out what an agent had done was to be
|
|
1103
|
+
* asked whether you wanted to destroy it. Read-only, and it never restores.
|
|
1104
|
+
*
|
|
1105
|
+
* Both backends answer: git supplies patches directly, and the native store
|
|
1106
|
+
* reconstructs them from the `before` blob it already holds, so the feature
|
|
1107
|
+
* degrades in fidelity rather than disappearing on a machine without git.
|
|
1108
|
+
*/
|
|
1109
|
+
export function buildTaskDiff(taskId) {
|
|
1110
|
+
const group = listGroups(1000).find((g) => g.taskId === taskId);
|
|
1111
|
+
if (!group) {
|
|
1112
|
+
return { ok: false, taskId: taskId || null, error: 'no checkpoint for that task', files: [] };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const files = [];
|
|
1116
|
+
|
|
1117
|
+
// 1. Git-backed trees: git computes the patch against the checkpoint tree.
|
|
1118
|
+
for (const tree of group.trees || []) {
|
|
1119
|
+
if (tree.backend !== 'git' || !tree.gitTree) continue;
|
|
1120
|
+
try {
|
|
1121
|
+
for (const change of gitBackend.previewRestore(tree.root, tree.gitTree)) {
|
|
1122
|
+
files.push({
|
|
1123
|
+
path: change.path,
|
|
1124
|
+
// The preview describes the RESTORE, which runs backwards. Invert it
|
|
1125
|
+
// so the user reads what the TASK did, which is what they asked.
|
|
1126
|
+
status: change.status === 'deleted' ? 'created'
|
|
1127
|
+
: change.status === 'added' ? 'deleted'
|
|
1128
|
+
: change.status,
|
|
1129
|
+
additions: change.deletions,
|
|
1130
|
+
deletions: change.additions,
|
|
1131
|
+
patch: change.patch || '',
|
|
1132
|
+
truncated: false
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
} catch (e) {
|
|
1136
|
+
debugLog(`task diff unavailable for ${tree.root}: ${e.message}`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// 2. Native file entries, for anything git did not already describe.
|
|
1141
|
+
const seen = new Set(files.map((f) => f.path));
|
|
1142
|
+
for (const entry of group.fileEntries || []) {
|
|
1143
|
+
// The journal stores absolute paths; a diff header showing
|
|
1144
|
+
|
|
1145
|
+
// the working directory where that makes sense, basename otherwise.
|
|
1146
|
+
const fromCwd = path.relative(process.cwd(), entry.path);
|
|
1147
|
+
const relative = (!fromCwd || fromCwd.startsWith('..'))
|
|
1148
|
+
? path.basename(entry.path)
|
|
1149
|
+
: fromCwd;
|
|
1150
|
+
if (seen.has(relative)) continue;
|
|
1151
|
+
seen.add(relative);
|
|
1152
|
+
|
|
1153
|
+
const beforeBuffer = entry.before ? readObject(entry.before.hash) : null;
|
|
1154
|
+
let afterBuffer = null;
|
|
1155
|
+
try {
|
|
1156
|
+
if (fs.existsSync(entry.path)) afterBuffer = fs.readFileSync(entry.path);
|
|
1157
|
+
} catch (e) {}
|
|
1158
|
+
|
|
1159
|
+
const status = !beforeBuffer ? 'created' : !afterBuffer ? 'deleted' : 'modified';
|
|
1160
|
+
|
|
1161
|
+
if (looksBinary(beforeBuffer) || looksBinary(afterBuffer)) {
|
|
1162
|
+
files.push({
|
|
1163
|
+
path: relative, status, additions: 0, deletions: 0, patch: '', truncated: true
|
|
1164
|
+
});
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const diff = unifiedDiff(
|
|
1169
|
+
beforeBuffer ? beforeBuffer.toString('utf8') : '',
|
|
1170
|
+
afterBuffer ? afterBuffer.toString('utf8') : '',
|
|
1171
|
+
relative
|
|
1172
|
+
);
|
|
1173
|
+
files.push({
|
|
1174
|
+
path: relative,
|
|
1175
|
+
status,
|
|
1176
|
+
additions: diff.additions,
|
|
1177
|
+
deletions: diff.deletions,
|
|
1178
|
+
patch: diff.patch,
|
|
1179
|
+
truncated: diff.truncated
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const totals = files.reduce(
|
|
1184
|
+
(acc, f) => ({ additions: acc.additions + f.additions, deletions: acc.deletions + f.deletions }),
|
|
1185
|
+
{ additions: 0, deletions: 0 }
|
|
1186
|
+
);
|
|
1187
|
+
|
|
1188
|
+
return {
|
|
1189
|
+
ok: true,
|
|
1190
|
+
taskId: group.taskId,
|
|
1191
|
+
prompt: group.prompt || null,
|
|
1192
|
+
fileCount: files.length,
|
|
1193
|
+
additions: totals.additions,
|
|
1194
|
+
deletions: totals.deletions,
|
|
1195
|
+
// Capped: a refactor can touch hundreds of files and this frame crosses a
|
|
1196
|
+
|
|
1197
|
+
files: files.slice(0, 50)
|
|
1198
|
+
};
|
|
1199
|
+
}
|