scalix 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/LICENSE +100 -0
  3. package/README.md +300 -2
  4. package/app.py +55 -0
  5. package/bin/scalix.js +19 -0
  6. package/dist/agent-replay.js +166 -0
  7. package/dist/agent.js +852 -0
  8. package/dist/auto-verify.js +136 -0
  9. package/dist/change-tracker.js +53 -0
  10. package/dist/cli.js +2329 -0
  11. package/dist/commands.js +71 -0
  12. package/dist/config.js +16 -0
  13. package/dist/constants.js +324 -0
  14. package/dist/context-builder.js +151 -0
  15. package/dist/context-manager.js +148 -0
  16. package/dist/cost-tracker.js +85 -0
  17. package/dist/diff.js +140 -0
  18. package/dist/git-context.js +180 -0
  19. package/dist/git-undo.js +67 -0
  20. package/dist/headless.js +654 -0
  21. package/dist/hooks.js +94 -0
  22. package/dist/ipc-server.js +228 -0
  23. package/dist/logger.js +45 -0
  24. package/dist/markdown.js +41 -0
  25. package/dist/mcp.js +49 -0
  26. package/dist/model-router.js +68 -0
  27. package/dist/plugins.js +132 -0
  28. package/dist/project-index.js +374 -0
  29. package/dist/project-memory.js +165 -0
  30. package/dist/safe-math.js +194 -0
  31. package/dist/sandbox.js +279 -0
  32. package/dist/scoped-rules.js +82 -0
  33. package/dist/sessions.js +71 -0
  34. package/dist/settings.js +155 -0
  35. package/dist/sub-agent.js +79 -0
  36. package/dist/token-counter.js +47 -0
  37. package/dist/tool-helper.js +97 -0
  38. package/dist/tool-selector.js +77 -0
  39. package/dist/tools-advanced.js +232 -0
  40. package/dist/tools-browser.js +273 -0
  41. package/dist/tools-extra.js +383 -0
  42. package/dist/tools-vision.js +115 -0
  43. package/dist/tools-voice.js +147 -0
  44. package/dist/tools-web.js +346 -0
  45. package/dist/tools.js +370 -0
  46. package/dist/types.js +1 -0
  47. package/docs/DISTRIBUTION.md +301 -0
  48. package/package.json +67 -6
@@ -0,0 +1,148 @@
1
+ import { messageContentToString } from './agent.js';
2
+ import { estimateTokens, estimateMessageTokens as _estimateMessageTokens } from './token-counter.js';
3
+ import { CONTEXT_DEFAULTS, COMPRESSION_DEFAULTS } from './constants.js';
4
+ export { estimateTokens };
5
+ export function estimateMessageTokens(messages) {
6
+ return _estimateMessageTokens(messages);
7
+ }
8
+ export class ContextManager {
9
+ maxContextTokens;
10
+ compressionThreshold;
11
+ minRecentMessages;
12
+ maxToolResultTokens;
13
+ charsPerToken;
14
+ slidingWindowTarget;
15
+ constructor(config) {
16
+ this.maxContextTokens = config?.maxContextTokens ?? CONTEXT_DEFAULTS.maxContextTokens;
17
+ this.compressionThreshold = config?.compressionThreshold ?? CONTEXT_DEFAULTS.compressionThreshold;
18
+ this.minRecentMessages = config?.minRecentMessages ?? CONTEXT_DEFAULTS.minRecentMessages;
19
+ this.maxToolResultTokens = config?.maxToolResultTokens ?? CONTEXT_DEFAULTS.maxToolResultTokens;
20
+ this.charsPerToken = config?.charsPerToken ?? CONTEXT_DEFAULTS.charsPerToken;
21
+ this.slidingWindowTarget = config?.slidingWindowTarget ?? CONTEXT_DEFAULTS.slidingWindowTarget;
22
+ }
23
+ setMaxContextTokens(tokens) {
24
+ if (tokens > 0)
25
+ this.maxContextTokens = tokens;
26
+ }
27
+ shouldCompress(messages) {
28
+ const tokens = estimateMessageTokens(messages);
29
+ return tokens > this.maxContextTokens * this.compressionThreshold;
30
+ }
31
+ getTokenUsage(messages) {
32
+ const tokens = estimateMessageTokens(messages);
33
+ return {
34
+ tokens,
35
+ maxTokens: this.maxContextTokens,
36
+ percentage: Math.round((tokens / this.maxContextTokens) * 100),
37
+ };
38
+ }
39
+ truncateToolResults(messages) {
40
+ const maxChars = this.maxToolResultTokens * this.charsPerToken;
41
+ return messages.map((msg) => {
42
+ if (msg.role !== 'tool' || !msg.content)
43
+ return msg;
44
+ const text = messageContentToString(msg.content);
45
+ const tokens = estimateTokens(text);
46
+ if (tokens <= this.maxToolResultTokens)
47
+ return msg;
48
+ let cutoff = Math.floor(maxChars);
49
+ while (cutoff > 0 && text.charCodeAt(cutoff) >= 0xDC00 && text.charCodeAt(cutoff) <= 0xDFFF) {
50
+ cutoff--;
51
+ }
52
+ const truncated = text.slice(0, cutoff) +
53
+ '\n\n...[truncated — ' + (tokens - this.maxToolResultTokens) + ' tokens removed]';
54
+ return { ...msg, content: truncated };
55
+ });
56
+ }
57
+ splitForCompression(messages) {
58
+ if (messages.length <= this.minRecentMessages) {
59
+ return { toCompress: [], toKeep: [...messages] };
60
+ }
61
+ const keepCount = Math.max(this.minRecentMessages, Math.floor(messages.length * COMPRESSION_DEFAULTS.keepRatio));
62
+ let splitIndex = messages.length - keepCount;
63
+ // Walk backwards to avoid splitting tool-call/result pairs
64
+ while (splitIndex > 0) {
65
+ const msg = messages[splitIndex];
66
+ if (msg.role === 'tool') {
67
+ splitIndex--;
68
+ if (splitIndex > 0 && messages[splitIndex]?.role === 'assistant' && messages[splitIndex]?.tool_calls) {
69
+ splitIndex--;
70
+ }
71
+ }
72
+ else {
73
+ break;
74
+ }
75
+ }
76
+ splitIndex = Math.max(0, splitIndex);
77
+ return {
78
+ toCompress: messages.slice(0, splitIndex),
79
+ toKeep: messages.slice(splitIndex),
80
+ };
81
+ }
82
+ buildCompressionPrompt(messages) {
83
+ const maxPerMessage = 4000;
84
+ const formatted = messages.map((m) => {
85
+ let line = `[${m.role}]`;
86
+ if (m.name)
87
+ line += ` (${m.name})`;
88
+ if (m.content) {
89
+ const text = messageContentToString(m.content);
90
+ const display = text.length > maxPerMessage
91
+ ? text.slice(0, maxPerMessage) + `\n...[${text.length - maxPerMessage} chars omitted]`
92
+ : text;
93
+ line += `: ${display}`;
94
+ }
95
+ if (m.tool_calls) {
96
+ const calls = m.tool_calls.map((tc) => {
97
+ const args = tc.function.arguments;
98
+ const preview = args.length > 200 ? args.slice(0, 200) + '...' : args;
99
+ return `${tc.function.name}(${preview})`;
100
+ });
101
+ line += `: called ${calls.join(', ')}`;
102
+ }
103
+ return line;
104
+ }).join('\n');
105
+ return `You are compressing a conversation to fit within the context window. The compressed output will REPLACE the original messages — anything you omit is permanently lost.
106
+
107
+ MUST PRESERVE (verbatim where possible):
108
+ 1. The user's goal — what they are trying to accomplish and why
109
+ 2. Every file path, function name, and line number mentioned
110
+ 3. Every error message, stack trace, and fix applied
111
+ 4. What was tried, what worked, what failed, and why
112
+ 5. The user's explicit preferences, corrections, and instructions
113
+ 6. Pending/incomplete work and next steps
114
+ 7. Key code snippets, commands run, and their results
115
+ 8. Configuration values, environment details, model names
116
+
117
+ DO NOT preserve greetings, acknowledgments, thinking-out-loud, or redundant explanations.
118
+
119
+ Conversation to compress:
120
+ ${formatted}
121
+
122
+ Write a detailed, factual summary. Use specific names, paths, and values — not vague references. This summary is the agent's only memory of this work:`;
123
+ }
124
+ applySlidingWindow(messages) {
125
+ const usage = this.getTokenUsage(messages);
126
+ if (usage.percentage < 95)
127
+ return messages;
128
+ const target = this.maxContextTokens * this.slidingWindowTarget;
129
+ const result = [...messages];
130
+ while (estimateMessageTokens(result) > target && result.length > this.minRecentMessages) {
131
+ const removed = result.shift();
132
+ // Remove orphaned tool results: if we removed an assistant with tool_calls,
133
+ // also remove its paired tool results
134
+ if (removed?.role === 'assistant' && removed.tool_calls) {
135
+ const toolCallIds = new Set(removed.tool_calls.map((tc) => tc.id));
136
+ while (result.length > 0 && result[0]?.role === 'tool' && result[0]?.tool_call_id && toolCallIds.has(result[0].tool_call_id)) {
137
+ result.shift();
138
+ }
139
+ }
140
+ // If we're about to leave orphaned tool results at the front (their assistant was already removed),
141
+ // clean them up
142
+ while (result.length > 0 && result[0]?.role === 'tool') {
143
+ result.shift();
144
+ }
145
+ }
146
+ return result;
147
+ }
148
+ }
@@ -0,0 +1,85 @@
1
+ export class CostTracker {
2
+ entries = [];
3
+ sessionStart = Date.now();
4
+ record(model, usage) {
5
+ this.entries.push({
6
+ model,
7
+ promptTokens: usage.prompt_tokens ?? 0,
8
+ completionTokens: usage.completion_tokens ?? 0,
9
+ totalTokens: usage.total_tokens ?? 0,
10
+ cost: usage.cost ?? 0,
11
+ timestamp: Date.now(),
12
+ });
13
+ }
14
+ get totalTokens() {
15
+ return this.entries.reduce((s, e) => s + e.totalTokens, 0);
16
+ }
17
+ get totalCost() {
18
+ return this.entries.reduce((s, e) => s + e.cost, 0);
19
+ }
20
+ get promptTokens() {
21
+ return this.entries.reduce((s, e) => s + e.promptTokens, 0);
22
+ }
23
+ get completionTokens() {
24
+ return this.entries.reduce((s, e) => s + e.completionTokens, 0);
25
+ }
26
+ get requestCount() {
27
+ return this.entries.length;
28
+ }
29
+ get sessionDuration() {
30
+ const s = Math.floor((Date.now() - this.sessionStart) / 1000);
31
+ if (s < 60)
32
+ return `${s}s`;
33
+ if (s < 3600)
34
+ return `${Math.floor(s / 60)}m${s % 60}s`;
35
+ return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
36
+ }
37
+ summary() {
38
+ const lines = [
39
+ `Session: ${this.sessionDuration} | ${this.requestCount} requests`,
40
+ `Tokens: ${this.totalTokens.toLocaleString()} (${this.promptTokens.toLocaleString()} prompt + ${this.completionTokens.toLocaleString()} completion)`,
41
+ ];
42
+ const byModel = new Map();
43
+ for (const e of this.entries) {
44
+ if (e.model === '(previous session)')
45
+ continue;
46
+ const prev = byModel.get(e.model) ?? { tokens: 0, count: 0 };
47
+ byModel.set(e.model, { tokens: prev.tokens + e.totalTokens, count: prev.count + 1 });
48
+ }
49
+ if (byModel.size > 0) {
50
+ lines.push('By model:');
51
+ for (const [model, stats] of byModel) {
52
+ const short = model.split('/').pop()?.replace(/:free$/, '') ?? model;
53
+ lines.push(` ${short}: ${stats.tokens.toLocaleString()} tokens (${stats.count} reqs)`);
54
+ }
55
+ }
56
+ return lines.join('\n');
57
+ }
58
+ reset() {
59
+ this.entries = [];
60
+ this.sessionStart = Date.now();
61
+ }
62
+ restore(state) {
63
+ this.entries = [];
64
+ if (state.requestCount > 0 && state.totalTokens >= 0) {
65
+ const count = Math.max(1, state.requestCount);
66
+ const perReqTokens = Math.floor(state.totalTokens / count);
67
+ const remainder = state.totalTokens - perReqTokens * count;
68
+ const perReqCost = state.totalCost / count;
69
+ for (let i = 0; i < count; i++) {
70
+ this.entries.push({
71
+ model: '(previous session)',
72
+ promptTokens: 0,
73
+ completionTokens: 0,
74
+ totalTokens: perReqTokens + (i === 0 ? remainder : 0),
75
+ cost: perReqCost,
76
+ timestamp: Date.now(),
77
+ });
78
+ }
79
+ }
80
+ }
81
+ getState() {
82
+ return { totalTokens: this.totalTokens, totalCost: this.totalCost, requestCount: this.requestCount };
83
+ }
84
+ }
85
+ export const costTracker = new CostTracker();
package/dist/diff.js ADDED
@@ -0,0 +1,140 @@
1
+ function lcsLengths(a, b) {
2
+ const m = a.length;
3
+ const n = b.length;
4
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
5
+ for (let i = 1; i <= m; i++) {
6
+ for (let j = 1; j <= n; j++) {
7
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
8
+ }
9
+ }
10
+ return dp;
11
+ }
12
+ function backtrack(dp, a, b) {
13
+ const lines = [];
14
+ let i = a.length;
15
+ let j = b.length;
16
+ while (i > 0 || j > 0) {
17
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
18
+ lines.push({ type: 'context', lineOld: i, lineNew: j, text: a[i - 1] });
19
+ i--;
20
+ j--;
21
+ }
22
+ else if (j > 0 && (i === 0 || dp[i][j - 1] > dp[i - 1][j])) {
23
+ lines.push({ type: 'add', lineNew: j, text: b[j - 1] });
24
+ j--;
25
+ }
26
+ else {
27
+ lines.push({ type: 'remove', lineOld: i, text: a[i - 1] });
28
+ i--;
29
+ }
30
+ }
31
+ return lines.reverse();
32
+ }
33
+ const LARGE_FILE_THRESHOLD = 50_000;
34
+ function simpleDiff(oldLines, newLines) {
35
+ const lines = [];
36
+ const oldSet = new Map();
37
+ oldLines.forEach((l, i) => {
38
+ if (!oldSet.has(l))
39
+ oldSet.set(l, []);
40
+ oldSet.get(l).push(i + 1);
41
+ });
42
+ let commonPrefix = 0;
43
+ while (commonPrefix < oldLines.length && commonPrefix < newLines.length && oldLines[commonPrefix] === newLines[commonPrefix]) {
44
+ lines.push({ type: 'context', lineOld: commonPrefix + 1, lineNew: commonPrefix + 1, text: oldLines[commonPrefix] });
45
+ commonPrefix++;
46
+ }
47
+ let commonSuffix = 0;
48
+ while (commonSuffix < oldLines.length - commonPrefix &&
49
+ commonSuffix < newLines.length - commonPrefix &&
50
+ oldLines[oldLines.length - 1 - commonSuffix] === newLines[newLines.length - 1 - commonSuffix]) {
51
+ commonSuffix++;
52
+ }
53
+ for (let i = commonPrefix; i < oldLines.length - commonSuffix; i++) {
54
+ lines.push({ type: 'remove', lineOld: i + 1, text: oldLines[i] });
55
+ }
56
+ for (let i = commonPrefix; i < newLines.length - commonSuffix; i++) {
57
+ lines.push({ type: 'add', lineNew: i + 1, text: newLines[i] });
58
+ }
59
+ for (let s = commonSuffix - 1; s >= 0; s--) {
60
+ const oldIdx = oldLines.length - 1 - s;
61
+ const newIdx = newLines.length - 1 - s;
62
+ lines.push({ type: 'context', lineOld: oldIdx + 1, lineNew: newIdx + 1, text: oldLines[oldIdx] });
63
+ }
64
+ return lines;
65
+ }
66
+ export function computeDiff(oldText, newText) {
67
+ if (oldText === newText)
68
+ return { hunks: [], additions: 0, deletions: 0 };
69
+ const oldLines = oldText === '' ? [] : oldText.split('\n');
70
+ const newLines = newText === '' ? [] : newText.split('\n');
71
+ const isLarge = oldLines.length > LARGE_FILE_THRESHOLD || newLines.length > LARGE_FILE_THRESHOLD;
72
+ const allLines = isLarge
73
+ ? simpleDiff(oldLines, newLines)
74
+ : backtrack(lcsLengths(oldLines, newLines), oldLines, newLines);
75
+ const hunks = [];
76
+ let currentHunk = [];
77
+ let contextCount = 0;
78
+ const CONTEXT_SIZE = 3;
79
+ for (let idx = 0; idx < allLines.length; idx++) {
80
+ const line = allLines[idx];
81
+ if (line.type === 'context') {
82
+ contextCount++;
83
+ if (currentHunk.length > 0 && contextCount > CONTEXT_SIZE * 2) {
84
+ hunks.push(currentHunk);
85
+ currentHunk = [];
86
+ contextCount = 1;
87
+ }
88
+ if (currentHunk.length > 0 || contextCount <= CONTEXT_SIZE) {
89
+ currentHunk.push(line);
90
+ }
91
+ }
92
+ else {
93
+ if (contextCount > CONTEXT_SIZE && currentHunk.length === 0) {
94
+ for (let k = Math.max(0, idx - CONTEXT_SIZE); k < idx; k++) {
95
+ if (allLines[k].type === 'context')
96
+ currentHunk.push(allLines[k]);
97
+ }
98
+ }
99
+ currentHunk.push(line);
100
+ contextCount = 0;
101
+ }
102
+ }
103
+ if (currentHunk.some((l) => l.type !== 'context')) {
104
+ hunks.push(currentHunk);
105
+ }
106
+ const additions = allLines.filter((l) => l.type === 'add').length;
107
+ const deletions = allLines.filter((l) => l.type === 'remove').length;
108
+ return { hunks, additions, deletions };
109
+ }
110
+ export function formatDiff(diff, filePath) {
111
+ if (diff.hunks.length === 0)
112
+ return '(no changes)';
113
+ const lines = [];
114
+ if (filePath) {
115
+ lines.push(`--- ${filePath}`);
116
+ lines.push(`+++ ${filePath}`);
117
+ }
118
+ for (const hunk of diff.hunks) {
119
+ const firstOld = hunk.find((l) => l.lineOld)?.lineOld ?? 1;
120
+ const firstNew = hunk.find((l) => l.lineNew)?.lineNew ?? 1;
121
+ const oldCount = hunk.filter((l) => l.type !== 'add').length;
122
+ const newCount = hunk.filter((l) => l.type !== 'remove').length;
123
+ lines.push(`@@ -${firstOld},${oldCount} +${firstNew},${newCount} @@`);
124
+ for (const line of hunk) {
125
+ switch (line.type) {
126
+ case 'add':
127
+ lines.push(`\x1b[32m+ ${line.text}\x1b[0m`);
128
+ break;
129
+ case 'remove':
130
+ lines.push(`\x1b[31m- ${line.text}\x1b[0m`);
131
+ break;
132
+ case 'context':
133
+ lines.push(` ${line.text}`);
134
+ break;
135
+ }
136
+ }
137
+ }
138
+ lines.push(`\x1b[36m${diff.additions} additions, ${diff.deletions} deletions\x1b[0m`);
139
+ return lines.join('\n');
140
+ }
@@ -0,0 +1,180 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { TOOL_DEFAULTS, GIT_CONTEXT_DEFAULTS, PROJECT_INDEX_DEFAULTS } from './constants.js';
3
+ function run(cmd, cwd) {
4
+ try {
5
+ return execSync(cmd, { cwd, encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
6
+ }
7
+ catch {
8
+ return '';
9
+ }
10
+ }
11
+ function isGitRepo(cwd) {
12
+ return run('git rev-parse --is-inside-work-tree', cwd) === 'true';
13
+ }
14
+ function getBranch(cwd) {
15
+ return run('git branch --show-current', cwd) || run('git rev-parse --short HEAD', cwd) || 'unknown';
16
+ }
17
+ function getRemoteBranch(cwd) {
18
+ return run('git rev-parse --abbrev-ref @{upstream} 2>/dev/null', cwd);
19
+ }
20
+ function getRemoteUrl(cwd) {
21
+ return run('git remote get-url origin 2>/dev/null', cwd);
22
+ }
23
+ function getAheadBehind(cwd) {
24
+ const result = run('git rev-list --left-right --count @{upstream}...HEAD 2>/dev/null', cwd);
25
+ if (!result)
26
+ return { ahead: 0, behind: 0 };
27
+ const parts = result.split('\t');
28
+ if (parts.length < 2)
29
+ return { ahead: 0, behind: 0 };
30
+ return { ahead: Number(parts[1]) || 0, behind: Number(parts[0]) || 0 };
31
+ }
32
+ function getStashCount(cwd) {
33
+ const result = run('git stash list 2>/dev/null', cwd);
34
+ return result ? result.split('\n').filter(Boolean).length : 0;
35
+ }
36
+ function getStatus(cwd) {
37
+ const result = run('git status --porcelain', cwd);
38
+ if (!result)
39
+ return [];
40
+ return result.split('\n').filter(Boolean).map((line) => {
41
+ const indexStatus = line[0];
42
+ const workStatus = line[1];
43
+ const filePath = line.slice(3).trim();
44
+ let status = 'modified';
45
+ let staged = false;
46
+ if (indexStatus === '?' && workStatus === '?') {
47
+ status = 'untracked';
48
+ }
49
+ else if (indexStatus === 'A') {
50
+ status = 'added';
51
+ staged = true;
52
+ }
53
+ else if (indexStatus === 'D' || workStatus === 'D') {
54
+ status = 'deleted';
55
+ staged = indexStatus === 'D';
56
+ }
57
+ else if (indexStatus === 'R') {
58
+ status = 'renamed';
59
+ staged = true;
60
+ }
61
+ else if (indexStatus === 'M') {
62
+ status = 'staged';
63
+ staged = true;
64
+ }
65
+ else if (workStatus === 'M') {
66
+ status = 'modified';
67
+ }
68
+ return { path: filePath, status, staged };
69
+ });
70
+ }
71
+ function getRecentCommits(cwd, count = GIT_CONTEXT_DEFAULTS.recentCommitsCount) {
72
+ const SEP = '<%>';
73
+ const format = `%H${SEP}%h${SEP}%s${SEP}%an${SEP}%ar`;
74
+ const result = run(`git log --pretty=format:"${format}" --shortstat -n ${count} 2>/dev/null`, cwd);
75
+ if (!result)
76
+ return [];
77
+ const commits = [];
78
+ const lines = result.split('\n');
79
+ let i = 0;
80
+ while (i < lines.length) {
81
+ const line = lines[i].replace(/^"/, '').replace(/"$/, '');
82
+ const parts = line.split(SEP);
83
+ if (parts.length >= 5 && parts[0].length === 40) {
84
+ let filesChanged = 0;
85
+ if (i + 1 < lines.length && lines[i + 1].includes('file')) {
86
+ const match = lines[i + 1].match(/(\d+)\s+file/);
87
+ if (match)
88
+ filesChanged = parseInt(match[1], 10);
89
+ i++;
90
+ }
91
+ if (i + 1 < lines.length && lines[i + 1].trim() === '')
92
+ i++;
93
+ commits.push({
94
+ hash: parts[0],
95
+ shortHash: parts[1],
96
+ message: parts[2],
97
+ author: parts[3],
98
+ date: parts[4],
99
+ filesChanged,
100
+ });
101
+ }
102
+ i++;
103
+ }
104
+ return commits;
105
+ }
106
+ export function collectGitInfo(cwd) {
107
+ if (!isGitRepo(cwd)) {
108
+ return {
109
+ isRepo: false,
110
+ branch: '',
111
+ remoteBranch: '',
112
+ status: [],
113
+ recentCommits: [],
114
+ stashCount: 0,
115
+ aheadBehind: { ahead: 0, behind: 0 },
116
+ remoteUrl: '',
117
+ };
118
+ }
119
+ return {
120
+ isRepo: true,
121
+ branch: getBranch(cwd),
122
+ remoteBranch: getRemoteBranch(cwd),
123
+ status: getStatus(cwd),
124
+ recentCommits: getRecentCommits(cwd, GIT_CONTEXT_DEFAULTS.recentCommitsContextCount),
125
+ stashCount: getStashCount(cwd),
126
+ aheadBehind: getAheadBehind(cwd),
127
+ remoteUrl: getRemoteUrl(cwd),
128
+ };
129
+ }
130
+ export function gitInfoToContext(info) {
131
+ if (!info.isRepo)
132
+ return '## Git: Not a git repository';
133
+ const lines = ['## Git Context'];
134
+ lines.push(`Branch: ${info.branch}`);
135
+ if (info.remoteBranch) {
136
+ lines.push(`Tracking: ${info.remoteBranch}`);
137
+ }
138
+ const { ahead, behind } = info.aheadBehind;
139
+ if (ahead > 0 || behind > 0) {
140
+ const parts = [];
141
+ if (ahead > 0)
142
+ parts.push(`${ahead} ahead`);
143
+ if (behind > 0)
144
+ parts.push(`${behind} behind`);
145
+ lines.push(`Status: ${parts.join(', ')}`);
146
+ }
147
+ if (info.stashCount > 0) {
148
+ lines.push(`Stashes: ${info.stashCount}`);
149
+ }
150
+ if (info.status.length > 0) {
151
+ lines.push('');
152
+ lines.push('### Working Tree Changes');
153
+ const staged = info.status.filter((s) => s.staged);
154
+ const unstaged = info.status.filter((s) => !s.staged);
155
+ if (staged.length > 0) {
156
+ lines.push('Staged:');
157
+ for (const f of staged.slice(0, PROJECT_INDEX_DEFAULTS.statusDisplayLimit)) {
158
+ lines.push(` ${f.status.padEnd(10)} ${f.path}`);
159
+ }
160
+ if (staged.length > PROJECT_INDEX_DEFAULTS.statusDisplayLimit)
161
+ lines.push(` ... and ${staged.length - PROJECT_INDEX_DEFAULTS.statusDisplayLimit} more`);
162
+ }
163
+ if (unstaged.length > 0) {
164
+ lines.push('Unstaged:');
165
+ for (const f of unstaged.slice(0, PROJECT_INDEX_DEFAULTS.statusDisplayLimit)) {
166
+ lines.push(` ${f.status.padEnd(10)} ${f.path}`);
167
+ }
168
+ if (unstaged.length > PROJECT_INDEX_DEFAULTS.statusDisplayLimit)
169
+ lines.push(` ... and ${unstaged.length - PROJECT_INDEX_DEFAULTS.statusDisplayLimit} more`);
170
+ }
171
+ }
172
+ if (info.recentCommits.length > 0) {
173
+ lines.push('');
174
+ lines.push('### Recent Commits');
175
+ for (const c of info.recentCommits) {
176
+ lines.push(` ${c.shortHash} ${c.message} (${c.author}, ${c.date})`);
177
+ }
178
+ }
179
+ return lines.join('\n');
180
+ }
@@ -0,0 +1,67 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { logger } from './logger.js';
4
+ import { TOOL_DEFAULTS } from './constants.js';
5
+ const execFileAsync = promisify(execFile);
6
+ async function git(args, cwd) {
7
+ const { stdout } = await execFileAsync('git', args, { cwd, timeout: TOOL_DEFAULTS.gitTimeoutMs });
8
+ return stdout.trim();
9
+ }
10
+ async function isGitRepo(cwd) {
11
+ try {
12
+ await git(['rev-parse', '--is-inside-work-tree'], cwd);
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ async function hasChanges(cwd) {
20
+ const status = await git(['status', '--porcelain'], cwd);
21
+ return status.length > 0;
22
+ }
23
+ export async function createCheckpoint(cwd, label) {
24
+ if (!await isGitRepo(cwd))
25
+ return null;
26
+ if (!await hasChanges(cwd))
27
+ return null;
28
+ try {
29
+ await git(['add', '-u'], cwd);
30
+ const msg = `[scalix checkpoint] ${label || 'auto'}`;
31
+ await git(['commit', '-m', msg], cwd);
32
+ const hash = await git(['rev-parse', '--short', 'HEAD'], cwd);
33
+ logger.info('Checkpoint created', { hash, label });
34
+ return hash;
35
+ }
36
+ catch (err) {
37
+ logger.warn('Checkpoint failed', { error: err.message });
38
+ return null;
39
+ }
40
+ }
41
+ export async function undoLastCheckpoint(cwd) {
42
+ if (!await isGitRepo(cwd)) {
43
+ return { success: false, message: 'Not a git repository.' };
44
+ }
45
+ try {
46
+ const lastMsg = await git(['log', '-1', '--format=%s'], cwd);
47
+ if (!lastMsg.startsWith('[scalix checkpoint]')) {
48
+ return { success: false, message: 'Last commit is not a scalix checkpoint. Undo only works on agent-created commits.' };
49
+ }
50
+ await git(['reset', '--mixed', 'HEAD~1'], cwd);
51
+ return { success: true, message: `Undid checkpoint: ${lastMsg}` };
52
+ }
53
+ catch (err) {
54
+ return { success: false, message: `Undo failed: ${err.message}` };
55
+ }
56
+ }
57
+ export async function listCheckpoints(cwd, limit = 10) {
58
+ if (!await isGitRepo(cwd))
59
+ return [];
60
+ try {
61
+ const log = await git(['log', `--max-count=${limit}`, '--format=%h %s', '--grep=\\[scalix checkpoint\\]'], cwd);
62
+ return log ? log.split('\n') : [];
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ }