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,194 @@
1
+ const FUNCTIONS = {
2
+ sqrt: Math.sqrt,
3
+ abs: Math.abs,
4
+ sin: Math.sin,
5
+ cos: Math.cos,
6
+ tan: Math.tan,
7
+ log: Math.log,
8
+ log10: Math.log10,
9
+ floor: Math.floor,
10
+ ceil: Math.ceil,
11
+ round: Math.round,
12
+ min: Math.min,
13
+ max: Math.max,
14
+ pow: Math.pow,
15
+ };
16
+ const CONSTANTS = {
17
+ pi: Math.PI,
18
+ e: Math.E,
19
+ };
20
+ function tokenize(expr) {
21
+ const tokens = [];
22
+ let i = 0;
23
+ while (i < expr.length) {
24
+ if (/\s/.test(expr[i])) {
25
+ i++;
26
+ continue;
27
+ }
28
+ if (/[0-9.]/.test(expr[i])) {
29
+ let num = '';
30
+ while (i < expr.length && /[0-9.]/.test(expr[i])) {
31
+ num += expr[i++];
32
+ }
33
+ if (i < expr.length && (expr[i] === 'e' || expr[i] === 'E')) {
34
+ num += expr[i++];
35
+ if (i < expr.length && (expr[i] === '+' || expr[i] === '-')) {
36
+ num += expr[i++];
37
+ }
38
+ while (i < expr.length && /[0-9]/.test(expr[i])) {
39
+ num += expr[i++];
40
+ }
41
+ }
42
+ const value = parseFloat(num);
43
+ if (isNaN(value))
44
+ throw new Error(`Invalid number: ${num}`);
45
+ tokens.push({ type: 'number', value });
46
+ continue;
47
+ }
48
+ if (/[a-zA-Z_]/.test(expr[i])) {
49
+ let name = '';
50
+ while (i < expr.length && /[a-zA-Z0-9_]/.test(expr[i])) {
51
+ name += expr[i++];
52
+ }
53
+ const lower = name.toLowerCase();
54
+ if (lower in CONSTANTS)
55
+ tokens.push({ type: 'number', value: CONSTANTS[lower] });
56
+ else if (lower in FUNCTIONS)
57
+ tokens.push({ type: 'func', value: lower });
58
+ else
59
+ throw new Error(`Unknown identifier: ${name}`);
60
+ continue;
61
+ }
62
+ if (expr[i] === '(') {
63
+ tokens.push({ type: 'lparen' });
64
+ i++;
65
+ continue;
66
+ }
67
+ if (expr[i] === ')') {
68
+ tokens.push({ type: 'rparen' });
69
+ i++;
70
+ continue;
71
+ }
72
+ if (expr[i] === ',') {
73
+ tokens.push({ type: 'comma' });
74
+ i++;
75
+ continue;
76
+ }
77
+ if ('+-*/%^'.includes(expr[i])) {
78
+ tokens.push({ type: 'op', value: expr[i] });
79
+ i++;
80
+ continue;
81
+ }
82
+ throw new Error(`Unexpected character: ${expr[i]}`);
83
+ }
84
+ return tokens;
85
+ }
86
+ class Parser {
87
+ tokens;
88
+ pos = 0;
89
+ constructor(tokens) { this.tokens = tokens; }
90
+ peek() { return this.tokens[this.pos]; }
91
+ consume() { return this.tokens[this.pos++]; }
92
+ parse() {
93
+ const result = this.expression();
94
+ if (this.pos < this.tokens.length)
95
+ throw new Error('Unexpected token after expression');
96
+ return result;
97
+ }
98
+ peekOp() {
99
+ const t = this.peek();
100
+ return t?.type === 'op' ? t.value : null;
101
+ }
102
+ expression() {
103
+ let left = this.term();
104
+ let op = this.peekOp();
105
+ while (op === '+' || op === '-') {
106
+ this.consume();
107
+ const right = this.term();
108
+ left = op === '+' ? left + right : left - right;
109
+ op = this.peekOp();
110
+ }
111
+ return left;
112
+ }
113
+ term() {
114
+ let left = this.power();
115
+ let op = this.peekOp();
116
+ while (op === '*' || op === '/' || op === '%') {
117
+ this.consume();
118
+ const right = this.power();
119
+ if (op === '*')
120
+ left *= right;
121
+ else if (op === '/') {
122
+ if (right === 0)
123
+ throw new Error('Division by zero');
124
+ left /= right;
125
+ }
126
+ else
127
+ left %= right;
128
+ op = this.peekOp();
129
+ }
130
+ return left;
131
+ }
132
+ power() {
133
+ let base = this.unary();
134
+ if (this.peekOp() === '^') {
135
+ this.consume();
136
+ base = Math.pow(base, this.power());
137
+ }
138
+ return base;
139
+ }
140
+ unary() {
141
+ const op = this.peekOp();
142
+ if (op === '+' || op === '-') {
143
+ this.consume();
144
+ const val = this.unary();
145
+ return op === '-' ? -val : val;
146
+ }
147
+ return this.primary();
148
+ }
149
+ primary() {
150
+ const tok = this.peek();
151
+ if (!tok)
152
+ throw new Error('Unexpected end of expression');
153
+ if (tok.type === 'number') {
154
+ this.consume();
155
+ return tok.value;
156
+ }
157
+ if (tok.type === 'func') {
158
+ const name = tok.value;
159
+ this.consume();
160
+ if (this.peek()?.type !== 'lparen')
161
+ throw new Error(`Expected ( after ${name}`);
162
+ this.consume();
163
+ const args = [];
164
+ if (this.peek()?.type !== 'rparen') {
165
+ args.push(this.expression());
166
+ while (this.peek()?.type === 'comma') {
167
+ this.consume();
168
+ args.push(this.expression());
169
+ }
170
+ }
171
+ if (this.peek()?.type !== 'rparen')
172
+ throw new Error(`Expected ) after ${name} arguments`);
173
+ this.consume();
174
+ return FUNCTIONS[name](...args);
175
+ }
176
+ if (tok.type === 'lparen') {
177
+ this.consume();
178
+ const val = this.expression();
179
+ if (this.peek()?.type !== 'rparen')
180
+ throw new Error('Expected closing )');
181
+ this.consume();
182
+ return val;
183
+ }
184
+ throw new Error(`Unexpected token: ${JSON.stringify(tok)}`);
185
+ }
186
+ }
187
+ export function safeEvaluate(expression) {
188
+ if (expression.length > 10_000)
189
+ throw new Error('Expression too long (max 10000 chars)');
190
+ const tokens = tokenize(expression);
191
+ if (tokens.length === 0)
192
+ throw new Error('Empty expression');
193
+ return new Parser(tokens).parse();
194
+ }
@@ -0,0 +1,279 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { execSync } from 'node:child_process';
4
+ import { TOOL_DEFAULTS, UNIX_SHELL_FALLBACK } from './constants.js';
5
+ let _sandboxRoot = null;
6
+ const IS_WINDOWS = process.platform === 'win32';
7
+ export function setSandboxRoot(root) {
8
+ _sandboxRoot = path.resolve(root);
9
+ }
10
+ function getSandboxRoot() {
11
+ return _sandboxRoot || process.cwd();
12
+ }
13
+ function normalizeSep(p) {
14
+ return process.platform === 'win32' ? p.replace(/\//g, '\\') : p;
15
+ }
16
+ function isWithinRoot(p, root) {
17
+ const np = normalizeSep(p).toLowerCase();
18
+ const nr = normalizeSep(root).toLowerCase();
19
+ return np === nr || np.startsWith(nr + path.sep);
20
+ }
21
+ export function sandboxPath(filePath) {
22
+ const root = getSandboxRoot();
23
+ const resolved = path.resolve(root, filePath);
24
+ if (!isWithinRoot(resolved, root)) {
25
+ throw new Error(`Path traversal blocked: "${filePath}" resolves outside working directory`);
26
+ }
27
+ return resolved;
28
+ }
29
+ export async function sandboxPathAsync(filePath) {
30
+ const root = getSandboxRoot();
31
+ const resolved = path.resolve(root, filePath);
32
+ if (!isWithinRoot(resolved, root)) {
33
+ throw new Error(`Path traversal blocked: "${filePath}" resolves outside working directory`);
34
+ }
35
+ try {
36
+ const real = await fs.realpath(resolved);
37
+ if (!isWithinRoot(real, root)) {
38
+ throw new Error(`Symlink traversal blocked: "${filePath}" resolves to "${real}" outside working directory`);
39
+ }
40
+ return real;
41
+ }
42
+ catch (err) {
43
+ if (err.code === 'ENOENT')
44
+ return resolved;
45
+ throw err;
46
+ }
47
+ }
48
+ const READ_ONLY_TOOLS = new Set([
49
+ 'get_current_time',
50
+ 'calculate',
51
+ 'read_file',
52
+ 'read_file_lines',
53
+ 'list_files',
54
+ 'search_files',
55
+ 'find_files',
56
+ 'tree',
57
+ 'git_status',
58
+ 'git_diff',
59
+ 'web_search',
60
+ 'monitor',
61
+ ]);
62
+ const WRITE_TOOLS = new Set([
63
+ 'write_file',
64
+ 'edit_file',
65
+ 'web_fetch',
66
+ 'todo_write',
67
+ 'notebook_edit',
68
+ ]);
69
+ const DANGEROUS_TOOLS = new Set([
70
+ 'run_command',
71
+ 'git_commit',
72
+ 'add_mcp_server',
73
+ 'agent',
74
+ ]);
75
+ export function getToolPermission(toolName, mode) {
76
+ switch (mode) {
77
+ case 'plan':
78
+ return READ_ONLY_TOOLS.has(toolName) ? 'allow' : 'block';
79
+ case 'default':
80
+ if (READ_ONLY_TOOLS.has(toolName))
81
+ return 'allow';
82
+ return 'ask';
83
+ case 'auto':
84
+ if (READ_ONLY_TOOLS.has(toolName))
85
+ return 'allow';
86
+ if (WRITE_TOOLS.has(toolName))
87
+ return 'allow';
88
+ return 'ask';
89
+ case 'yolo':
90
+ return 'allow';
91
+ default:
92
+ return 'ask';
93
+ }
94
+ }
95
+ export function isDangerousTool(toolName) {
96
+ return DANGEROUS_TOOLS.has(toolName) || WRITE_TOOLS.has(toolName);
97
+ }
98
+ export function getModeDescription(mode) {
99
+ switch (mode) {
100
+ case 'plan':
101
+ return 'Read-only. Allowed: read, search, list, git status/diff. Blocked: writes, commands, git commit.';
102
+ case 'default':
103
+ return 'Ask before writes & commands. Auto-approve: reads, search. Confirm: edit, write, run_command, git commit.';
104
+ case 'auto':
105
+ return 'Auto-approve reads & writes. Confirm only: run_command, git_commit, agent, add_mcp_server.';
106
+ case 'yolo':
107
+ return 'Full autonomy. All tools execute without confirmation. Sandbox safety checks still apply.';
108
+ }
109
+ }
110
+ export const PERMISSION_MODES = ['plan', 'default', 'auto', 'yolo'];
111
+ let cachedShell = null;
112
+ function detectShellFromEnv() {
113
+ if (process.env.SCALIX_SHELL)
114
+ return process.env.SCALIX_SHELL;
115
+ if (process.env.SHELL)
116
+ return process.env.SHELL;
117
+ if (IS_WINDOWS && process.env.COMSPEC)
118
+ return process.env.COMSPEC;
119
+ return null;
120
+ }
121
+ function classifyShell(shellPath) {
122
+ const name = path.basename(shellPath).toLowerCase().replace(/\.exe$/i, '');
123
+ if (name === 'bash')
124
+ return 'bash';
125
+ if (name === 'zsh')
126
+ return 'zsh';
127
+ if (name === 'fish')
128
+ return 'fish';
129
+ if (name === 'pwsh' || name === 'powershell')
130
+ return 'pwsh';
131
+ if (name === 'cmd')
132
+ return 'cmd';
133
+ return 'sh';
134
+ }
135
+ export function getShellInfo() {
136
+ if (cachedShell)
137
+ return cachedShell;
138
+ const envShell = detectShellFromEnv();
139
+ if (envShell) {
140
+ const shellType = classifyShell(envShell);
141
+ cachedShell = {
142
+ type: shellType,
143
+ path: envShell,
144
+ args: shellType === 'cmd' ? ['/d', '/s', '/c'] : ['-c'],
145
+ };
146
+ return cachedShell;
147
+ }
148
+ if (IS_WINDOWS) {
149
+ // Prefer PowerShell if available
150
+ try {
151
+ execSync('pwsh --version', { stdio: 'ignore', timeout: TOOL_DEFAULTS.shellDetectionTimeoutMs });
152
+ cachedShell = { type: 'pwsh', path: 'pwsh', args: ['-NoProfile', '-NonInteractive', '-Command'] };
153
+ return cachedShell;
154
+ }
155
+ catch { }
156
+ try {
157
+ execSync('powershell -Command "exit 0"', { stdio: 'ignore', timeout: TOOL_DEFAULTS.shellDetectionTimeoutMs });
158
+ cachedShell = { type: 'pwsh', path: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command'] };
159
+ return cachedShell;
160
+ }
161
+ catch { }
162
+ cachedShell = { type: 'cmd', path: process.env.COMSPEC || 'cmd.exe', args: ['/d', '/s', '/c'] };
163
+ return cachedShell;
164
+ }
165
+ // Unix fallback
166
+ cachedShell = { type: 'sh', path: UNIX_SHELL_FALLBACK, args: ['-c'] };
167
+ return cachedShell;
168
+ }
169
+ export function isPowerShell() {
170
+ const shell = getShellInfo();
171
+ return shell.type === 'pwsh';
172
+ }
173
+ export function isCmd() {
174
+ const shell = getShellInfo();
175
+ return shell.type === 'cmd';
176
+ }
177
+ // ─── Command Security ───
178
+ // Unix command blocklist
179
+ const UNIX_BLOCKLIST = [
180
+ /\brm\s+-rf\s+\//,
181
+ /\bmkfs\b/,
182
+ /\bdd\s+if=/,
183
+ />(\/dev\/[a-z])/,
184
+ /\bshutdown\b/,
185
+ /\breboot\b/,
186
+ /\bcurl\b.*\|\s*(ba)?sh/,
187
+ /\bwget\b.*\|\s*(ba)?sh/,
188
+ ];
189
+ // Windows/PowerShell command blocklist
190
+ const WINDOWS_BLOCKLIST = [
191
+ /\bdel\s+\/[sf]/i,
192
+ /\brd\s+\/s/i,
193
+ /\brmdir\s+\/s/i,
194
+ /\bformat\s+[a-z]:/i,
195
+ /\bdiskpart\b/i,
196
+ /\bnet\s+user\b/i,
197
+ /\bnet\s+localgroup\s+administrators/i,
198
+ /\breg\s+delete\b/i,
199
+ /\bsc\s+(delete|stop)\b/i,
200
+ /\bbcdedit\b/i,
201
+ /\bsfc\s+\/scannow/i,
202
+ ];
203
+ // PowerShell-specific blocklist
204
+ const POWERSHELL_BLOCKLIST = [
205
+ /Remove-Item\s+.*-Recurse\s+.*-Force/i,
206
+ /Remove-Item\s+.*[/\\]\s*\*/i,
207
+ /Format-Volume\b/i,
208
+ /Clear-Disk\b/i,
209
+ /Stop-Service\b.*-Force/i,
210
+ /Set-ExecutionPolicy\s+Unrestricted/i,
211
+ /Invoke-WebRequest\b.*\|\s*Invoke-Expression/i,
212
+ /iwr\b.*\|\s*iex\b/i,
213
+ /Invoke-Expression\b/i,
214
+ /\biex\s+/i,
215
+ /New-LocalUser\b/i,
216
+ /Add-LocalGroupMember\b.*Administrators/i,
217
+ ];
218
+ const COMMAND_BLOCKLIST = [
219
+ ...UNIX_BLOCKLIST,
220
+ ...WINDOWS_BLOCKLIST,
221
+ ...POWERSHELL_BLOCKLIST,
222
+ ];
223
+ // Unix injection patterns
224
+ const UNIX_INJECTION_PATTERNS = [
225
+ /`[^`]*`/,
226
+ /\$\([^)]*\)/,
227
+ /;\s*(rm|mkfs|dd|shutdown|reboot|curl|wget)\b/,
228
+ /\|\s*(ba)?sh\b/,
229
+ /\beval\s+/,
230
+ />\s*\/etc\//,
231
+ /\bchmod\s+[0-7]*7[0-7]*\s/,
232
+ /\bchown\s+root\b/,
233
+ /\bnc\s+-[elp]/,
234
+ /\bpython[23]?\s+-c\b.*__(import|exec|eval)/,
235
+ ];
236
+ // Windows/PowerShell injection patterns
237
+ const WINDOWS_INJECTION_PATTERNS = [
238
+ /&\s*\{[^}]*\}/,
239
+ /\$\([^)]*\)/,
240
+ /;\s*(del|rd|rmdir|format|diskpart|net\s+user|reg\s+delete)\b/i,
241
+ /\|\s*powershell\b/i,
242
+ /\|\s*pwsh\b/i,
243
+ /\|\s*cmd\b/i,
244
+ /\bStart-Process\b.*-Verb\s+RunAs/i,
245
+ /-EncodedCommand\b/i,
246
+ /-e\s+[A-Za-z0-9+/=]{20,}/i,
247
+ /\[System\.Reflection\.Assembly\]::Load/i,
248
+ /\[Net\.WebClient\].*DownloadString/i,
249
+ /\bAdd-Type\b.*-TypeDefinition/i,
250
+ ];
251
+ const INJECTION_PATTERNS = [
252
+ ...UNIX_INJECTION_PATTERNS,
253
+ ...WINDOWS_INJECTION_PATTERNS,
254
+ ];
255
+ const SENSITIVE_ENV_PATTERNS = [
256
+ /API_KEY/i, /SECRET/i, /TOKEN/i, /PASSWORD/i, /CREDENTIAL/i, /AUTH/i, /PRIVATE/i,
257
+ ];
258
+ export function isBlockedCommand(command) {
259
+ return COMMAND_BLOCKLIST.some((re) => re.test(command));
260
+ }
261
+ export function detectInjection(command) {
262
+ for (const pattern of INJECTION_PATTERNS) {
263
+ if (pattern.test(command)) {
264
+ return `Suspicious pattern detected: ${pattern.source}`;
265
+ }
266
+ }
267
+ return null;
268
+ }
269
+ export function sanitizeEnvForCommand() {
270
+ const clean = {};
271
+ for (const [key, val] of Object.entries(process.env)) {
272
+ if (val === undefined)
273
+ continue;
274
+ if (SENSITIVE_ENV_PATTERNS.some((p) => p.test(key)))
275
+ continue;
276
+ clean[key] = val;
277
+ }
278
+ return clean;
279
+ }
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { logger } from './logger.js';
4
+ import { PROJECT_LOOKUP_DIRS } from './constants.js';
5
+ const RULE_DIRS = PROJECT_LOOKUP_DIRS.map(d => `${d}/rules`);
6
+ const RULE_EXTENSIONS = ['.md', '.txt'];
7
+ export async function loadScopedRules(cwd) {
8
+ const rules = [];
9
+ for (const dir of RULE_DIRS) {
10
+ const fullDir = path.resolve(cwd, dir);
11
+ let entries;
12
+ try {
13
+ entries = await fs.readdir(fullDir, { withFileTypes: true });
14
+ }
15
+ catch {
16
+ continue;
17
+ }
18
+ for (const entry of entries) {
19
+ if (!entry.isFile())
20
+ continue;
21
+ if (!RULE_EXTENSIONS.some((ext) => entry.name.endsWith(ext)))
22
+ continue;
23
+ const filePath = path.join(fullDir, entry.name);
24
+ try {
25
+ const raw = await fs.readFile(filePath, 'utf-8');
26
+ const { pattern, body } = parseRuleFrontmatter(raw);
27
+ rules.push({
28
+ pattern: pattern || '*',
29
+ content: body.trim(),
30
+ source: filePath,
31
+ });
32
+ }
33
+ catch (err) {
34
+ logger.warn('Failed to load scoped rule', { file: filePath, error: err.message });
35
+ }
36
+ }
37
+ }
38
+ return rules;
39
+ }
40
+ function parseRuleFrontmatter(content) {
41
+ const normalized = content.replace(/\r\n/g, '\n');
42
+ const match = normalized.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
43
+ if (!match)
44
+ return { pattern: '*', body: normalized };
45
+ let pattern = '*';
46
+ for (const line of match[1].split('\n')) {
47
+ const idx = line.indexOf(':');
48
+ if (idx > 0) {
49
+ const key = line.slice(0, idx).trim();
50
+ const val = line.slice(idx + 1).trim();
51
+ if (key === 'pattern' || key === 'glob' || key === 'match')
52
+ pattern = val;
53
+ }
54
+ }
55
+ return { pattern, body: match[2] };
56
+ }
57
+ export function matchRulesForFile(rules, filePath) {
58
+ return rules.filter((rule) => {
59
+ if (rule.pattern === '*')
60
+ return true;
61
+ const normalized = filePath.replace(/\\/g, '/');
62
+ const pattern = rule.pattern.replace(/\\/g, '/');
63
+ if (pattern.startsWith('*')) {
64
+ return normalized.endsWith(pattern.slice(1));
65
+ }
66
+ if (pattern.endsWith('*')) {
67
+ return normalized.startsWith(pattern.slice(0, -1));
68
+ }
69
+ return normalized === pattern || normalized.endsWith('/' + pattern);
70
+ });
71
+ }
72
+ export function rulesToContext(rules) {
73
+ if (rules.length === 0)
74
+ return '';
75
+ const lines = ['## Project Rules'];
76
+ for (const rule of rules) {
77
+ if (rule.pattern !== '*')
78
+ lines.push(`### Rules for ${rule.pattern}`);
79
+ lines.push(rule.content);
80
+ }
81
+ return lines.join('\n\n');
82
+ }
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { logger } from './logger.js';
6
+ import { USER_CONFIG_DIR } from './constants.js';
7
+ const SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR, 'sessions');
8
+ async function ensureDir() {
9
+ await fs.mkdir(SESSIONS_DIR, { recursive: true });
10
+ }
11
+ function sessionPath(id) {
12
+ return path.join(SESSIONS_DIR, `${id}.json`);
13
+ }
14
+ export async function saveSession(id, messages, model, name, costState) {
15
+ await ensureDir();
16
+ const existing = await loadSession(id).catch(() => null);
17
+ const now = new Date().toISOString();
18
+ const meta = {
19
+ id,
20
+ name: name || existing?.meta.name || `Session ${id.slice(0, 8)}`,
21
+ model,
22
+ messageCount: messages.length,
23
+ createdAt: existing?.meta.createdAt || now,
24
+ updatedAt: now,
25
+ };
26
+ const data = { meta, messages, costState };
27
+ // Atomic write: write to temp file then rename to prevent corruption
28
+ const tmpPath = path.join(os.tmpdir(), `scalix-session-${id}-${Date.now()}-${randomUUID().slice(0, 8)}.tmp`);
29
+ await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
30
+ await fs.rename(tmpPath, sessionPath(id));
31
+ return meta;
32
+ }
33
+ export async function loadSession(id) {
34
+ const raw = await fs.readFile(sessionPath(id), 'utf-8');
35
+ try {
36
+ return JSON.parse(raw);
37
+ }
38
+ catch {
39
+ throw new Error(`Corrupted session file: ${id}`);
40
+ }
41
+ }
42
+ export async function listSessions() {
43
+ await ensureDir();
44
+ const files = await fs.readdir(SESSIONS_DIR);
45
+ const sessions = [];
46
+ for (const file of files) {
47
+ if (!file.endsWith('.json'))
48
+ continue;
49
+ try {
50
+ const raw = await fs.readFile(path.join(SESSIONS_DIR, file), 'utf-8');
51
+ const data = JSON.parse(raw);
52
+ sessions.push(data.meta);
53
+ }
54
+ catch (e) {
55
+ logger.warn('Failed to read session file', { file, error: e.message });
56
+ }
57
+ }
58
+ return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
59
+ }
60
+ export async function deleteSession(id) {
61
+ try {
62
+ await fs.unlink(sessionPath(id));
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ export function generateSessionId() {
70
+ return randomUUID();
71
+ }