vigthoria-cli 1.9.10 → 1.9.19

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 (52) hide show
  1. package/README.md +4 -4
  2. package/dist/commands/auth.js +48 -65
  3. package/dist/commands/bridge.js +12 -19
  4. package/dist/commands/cancel.js +15 -22
  5. package/dist/commands/chat.d.ts +11 -0
  6. package/dist/commands/chat.js +404 -248
  7. package/dist/commands/config.js +31 -71
  8. package/dist/commands/deploy.js +83 -123
  9. package/dist/commands/device.d.ts +35 -0
  10. package/dist/commands/device.js +239 -0
  11. package/dist/commands/edit.js +32 -39
  12. package/dist/commands/explain.js +18 -25
  13. package/dist/commands/fork.js +22 -27
  14. package/dist/commands/generate.js +37 -44
  15. package/dist/commands/history.js +20 -25
  16. package/dist/commands/hub.js +95 -102
  17. package/dist/commands/index.js +41 -46
  18. package/dist/commands/legion.d.ts +1 -0
  19. package/dist/commands/legion.js +162 -209
  20. package/dist/commands/preview.js +60 -98
  21. package/dist/commands/replay.js +27 -32
  22. package/dist/commands/repo.js +103 -141
  23. package/dist/commands/review.js +29 -36
  24. package/dist/commands/security.js +5 -12
  25. package/dist/commands/update.js +15 -49
  26. package/dist/commands/workflow.d.ts +8 -1
  27. package/dist/commands/workflow.js +53 -19
  28. package/dist/index.js +409 -234
  29. package/dist/utils/api.d.ts +5 -0
  30. package/dist/utils/api.js +373 -166
  31. package/dist/utils/bridge-client.js +11 -52
  32. package/dist/utils/cli-state.d.ts +54 -0
  33. package/dist/utils/cli-state.js +185 -0
  34. package/dist/utils/config.d.ts +5 -0
  35. package/dist/utils/config.js +35 -14
  36. package/dist/utils/context-ranker.js +15 -21
  37. package/dist/utils/files.js +5 -42
  38. package/dist/utils/logger.js +42 -50
  39. package/dist/utils/post-write-validator.js +22 -29
  40. package/dist/utils/project-memory.d.ts +56 -0
  41. package/dist/utils/project-memory.js +289 -0
  42. package/dist/utils/session.d.ts +29 -3
  43. package/dist/utils/session.js +137 -85
  44. package/dist/utils/task-display.js +13 -20
  45. package/dist/utils/tools.d.ts +19 -0
  46. package/dist/utils/tools.js +84 -87
  47. package/dist/utils/workspace-cache.js +18 -26
  48. package/dist/utils/workspace-stream.js +26 -64
  49. package/install.ps1 +14 -0
  50. package/package.json +5 -3
  51. package/scripts/release/LOCAL_MACHINE_USER_VERIFICATION.md +1 -1
  52. package/scripts/release/validate-no-go-gates.sh +2 -2
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Vigthoria Project Memory Service
3
+ *
4
+ * Local-first project brain stored inside .vigthoria/memory. It keeps durable,
5
+ * compact facts that can be retrieved for follow-up prompts without replaying
6
+ * full chat history or noisy tool traces.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { createHash } from 'node:crypto';
11
+ const MAX_ITEMS = 240;
12
+ const MAX_TEXT_LENGTH = 320;
13
+ const MAX_CONTEXT_ITEMS = 14;
14
+ const STOP_WORDS = new Set([
15
+ 'about', 'after', 'again', 'also', 'because', 'before', 'could', 'from', 'have', 'into', 'just', 'make', 'need',
16
+ 'only', 'please', 'should', 'that', 'their', 'there', 'this', 'with', 'work', 'working', 'would', 'your', 'vigthoria',
17
+ ]);
18
+ function nowIso() {
19
+ return new Date().toISOString();
20
+ }
21
+ function normalizeText(value) {
22
+ return String(value || '')
23
+ .replace(/<tool_call>[\s\S]*?<\/tool_call>/g, ' ')
24
+ .replace(/[ \t\r\n]+/g, ' ')
25
+ .trim();
26
+ }
27
+ function clampText(value, max = MAX_TEXT_LENGTH) {
28
+ const normalized = normalizeText(value);
29
+ return normalized.length > max ? `${normalized.slice(0, max - 3)}...` : normalized;
30
+ }
31
+ function slugHash(value) {
32
+ return createHash('sha1').update(value).digest('hex').slice(0, 12);
33
+ }
34
+ function tokenize(value) {
35
+ return Array.from(new Set(String(value || '')
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9_./-]+/g, ' ')
38
+ .split(/\s+/)
39
+ .filter((word) => word.length >= 3 && !STOP_WORDS.has(word)))).slice(0, 48);
40
+ }
41
+ function inferTags(text) {
42
+ const tags = new Set();
43
+ const lower = text.toLowerCase();
44
+ if (/login|auth|token|session/.test(lower))
45
+ tags.add('auth');
46
+ if (/cli|terminal|command/.test(lower))
47
+ tags.add('cli');
48
+ if (/code|extension|vscode|webview/.test(lower))
49
+ tags.add('code');
50
+ if (/agent|v3|executor|planner/.test(lower))
51
+ tags.add('agent');
52
+ if (/memory|context|summary|follow/.test(lower))
53
+ tags.add('memory');
54
+ if (/windows|win32|desktop/.test(lower))
55
+ tags.add('windows');
56
+ if (/release|publish|version|manifest|update/.test(lower))
57
+ tags.add('release');
58
+ if (/test|validated|passed|build|verify/.test(lower))
59
+ tags.add('validation');
60
+ return Array.from(tags).slice(0, 8);
61
+ }
62
+ export class ProjectMemoryService {
63
+ workspacePath;
64
+ memoryDir;
65
+ brainPath;
66
+ constructor(workspacePath = process.cwd()) {
67
+ this.workspacePath = path.resolve(workspacePath || process.cwd());
68
+ this.memoryDir = path.join(this.workspacePath, '.vigthoria', 'memory');
69
+ this.brainPath = path.join(this.memoryDir, 'brain.json');
70
+ }
71
+ getMemoryDir() {
72
+ return this.memoryDir;
73
+ }
74
+ loadBrain() {
75
+ try {
76
+ if (fs.existsSync(this.brainPath)) {
77
+ const parsed = JSON.parse(fs.readFileSync(this.brainPath, 'utf8'));
78
+ if (parsed && parsed.version === 1 && Array.isArray(parsed.items)) {
79
+ return parsed;
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ // Corrupt memory should never break chat.
85
+ }
86
+ return {
87
+ version: 1,
88
+ workspaceName: path.basename(this.workspacePath),
89
+ updatedAt: nowIso(),
90
+ items: [],
91
+ };
92
+ }
93
+ saveBrain(brain) {
94
+ try {
95
+ fs.mkdirSync(this.memoryDir, { recursive: true });
96
+ const normalized = {
97
+ ...brain,
98
+ workspaceName: brain.workspaceName || path.basename(this.workspacePath),
99
+ updatedAt: nowIso(),
100
+ items: this.dedupeAndTrim(brain.items || []),
101
+ };
102
+ fs.writeFileSync(this.brainPath, JSON.stringify(normalized, null, 2) + '\n', 'utf8');
103
+ this.writeMarkdownViews(normalized);
104
+ }
105
+ catch {
106
+ // Project memory is best effort. Never fail the user's main request.
107
+ }
108
+ }
109
+ remember(type, text, options = {}) {
110
+ const clean = clampText(text);
111
+ if (!clean || clean.length < 12)
112
+ return;
113
+ const brain = this.loadBrain();
114
+ const item = this.createItem(type, clean, options);
115
+ brain.items.push(item);
116
+ this.saveBrain(brain);
117
+ }
118
+ rememberConversation(messages, options = {}) {
119
+ const recent = (messages || []).slice(-16);
120
+ const candidates = [];
121
+ for (const message of recent) {
122
+ const content = clampText(message.content);
123
+ if (!content || content.length < 16)
124
+ continue;
125
+ if (message.role === 'user') {
126
+ const lower = content.toLowerCase();
127
+ if (/\b(need|fix|make|implement|build|verify|publish|release|check|investigate|remember|prefer|should|must|do not|don't)\b/.test(lower)) {
128
+ candidates.push(this.createItem(this.inferUserMemoryType(content), content, { ...options, source: options.source || 'user' }));
129
+ }
130
+ continue;
131
+ }
132
+ if (message.role === 'assistant') {
133
+ const lower = content.toLowerCase();
134
+ if (/\b(done|fixed|implemented|published|validated|verified|passed|updated|created|rebuilt|released)\b/.test(lower)) {
135
+ candidates.push(this.createItem('validation', content, { ...options, source: options.source || 'assistant' }));
136
+ }
137
+ }
138
+ }
139
+ if (options.sessionSummary && options.sessionSummary.trim()) {
140
+ for (const line of options.sessionSummary.split('\n').slice(-10)) {
141
+ const clean = clampText(line.replace(/^[-*]\s*/, ''));
142
+ if (clean.length >= 16) {
143
+ candidates.push(this.createItem('fact', clean, { ...options, source: 'session-summary' }));
144
+ }
145
+ }
146
+ }
147
+ if (candidates.length === 0)
148
+ return;
149
+ const brain = this.loadBrain();
150
+ brain.items.push(...candidates);
151
+ this.saveBrain(brain);
152
+ }
153
+ buildContextForPrompt(prompt) {
154
+ const items = this.retrieveRelevantMemory(prompt, MAX_CONTEXT_ITEMS);
155
+ if (items.length === 0)
156
+ return '';
157
+ const grouped = new Map();
158
+ for (const item of items) {
159
+ if (!grouped.has(item.type))
160
+ grouped.set(item.type, []);
161
+ grouped.get(item.type).push(item);
162
+ }
163
+ const lines = [
164
+ 'Vigthoria project brain memory.',
165
+ 'Use these durable project facts for follow-up continuity. Do not repeat them unless relevant.',
166
+ ];
167
+ for (const [type, group] of grouped) {
168
+ lines.push(`${type}:`);
169
+ for (const item of group.slice(0, 5)) {
170
+ lines.push(`- ${item.text}`);
171
+ }
172
+ }
173
+ return lines.join('\n').slice(0, 3200);
174
+ }
175
+ retrieveRelevantMemory(prompt, limit = MAX_CONTEXT_ITEMS) {
176
+ const brain = this.loadBrain();
177
+ const promptTokens = new Set(tokenize(prompt));
178
+ const scored = brain.items.map((item) => {
179
+ const haystack = tokenize(`${item.type} ${item.text} ${item.tags.join(' ')}`);
180
+ let score = item.weight || 1;
181
+ for (const token of haystack) {
182
+ if (promptTokens.has(token))
183
+ score += 4;
184
+ }
185
+ if (item.type === 'preference')
186
+ score += 2;
187
+ if (item.type === 'task')
188
+ score += 1;
189
+ return { item, score };
190
+ });
191
+ return scored
192
+ .filter((entry) => entry.score > 1 || promptTokens.size === 0)
193
+ .sort((a, b) => b.score - a.score || b.item.updatedAt.localeCompare(a.item.updatedAt))
194
+ .slice(0, limit)
195
+ .map((entry) => entry.item);
196
+ }
197
+ getStatus() {
198
+ const brain = this.loadBrain();
199
+ const typeCounts = {};
200
+ for (const item of brain.items) {
201
+ typeCounts[item.type] = (typeCounts[item.type] || 0) + 1;
202
+ }
203
+ return {
204
+ memoryDir: this.memoryDir,
205
+ itemCount: brain.items.length,
206
+ updatedAt: brain.updatedAt,
207
+ typeCounts,
208
+ };
209
+ }
210
+ createItem(type, text, options) {
211
+ const clean = clampText(text);
212
+ const tags = Array.from(new Set([...inferTags(clean), ...(options.mode ? [options.mode] : [])])).slice(0, 10);
213
+ const source = options.source || 'conversation';
214
+ const id = slugHash(`${type}:${source}:${clean.toLowerCase()}`);
215
+ const timestamp = nowIso();
216
+ return {
217
+ id,
218
+ type,
219
+ text: clean,
220
+ tags,
221
+ source,
222
+ createdAt: timestamp,
223
+ updatedAt: timestamp,
224
+ weight: type === 'preference' || type === 'decision' ? 4 : type === 'task' ? 3 : 2,
225
+ };
226
+ }
227
+ inferUserMemoryType(text) {
228
+ const lower = text.toLowerCase();
229
+ if (/\b(prefer|should|must|always|never|do not|don't|dont)\b/.test(lower))
230
+ return 'preference';
231
+ if (/\b(decide|decision|architecture|approach)\b/.test(lower))
232
+ return 'decision';
233
+ if (/\b(error|bug|issue|problem|fail|broken)\b/.test(lower))
234
+ return 'issue';
235
+ return 'task';
236
+ }
237
+ dedupeAndTrim(items) {
238
+ const byId = new Map();
239
+ for (const item of items) {
240
+ const clean = clampText(item.text);
241
+ if (!clean)
242
+ continue;
243
+ const id = item.id || slugHash(`${item.type}:${clean.toLowerCase()}`);
244
+ const existing = byId.get(id);
245
+ if (existing) {
246
+ byId.set(id, {
247
+ ...existing,
248
+ text: clean,
249
+ tags: Array.from(new Set([...(existing.tags || []), ...(item.tags || [])])).slice(0, 10),
250
+ updatedAt: item.updatedAt || nowIso(),
251
+ weight: Math.max(existing.weight || 1, item.weight || 1),
252
+ });
253
+ }
254
+ else {
255
+ byId.set(id, { ...item, id, text: clean });
256
+ }
257
+ }
258
+ return Array.from(byId.values())
259
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
260
+ .slice(0, MAX_ITEMS)
261
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
262
+ }
263
+ writeMarkdownViews(brain) {
264
+ const groups = ['preference', 'decision', 'task', 'issue', 'validation', 'file', 'fact'];
265
+ const title = `# Vigthoria Project Brain\n\nUpdated: ${brain.updatedAt}\nWorkspace: ${brain.workspaceName}\n\n`;
266
+ const allLines = [title.trimEnd(), ''];
267
+ for (const type of groups) {
268
+ const items = brain.items.filter((item) => item.type === type);
269
+ if (items.length === 0)
270
+ continue;
271
+ const heading = this.headingForType(type);
272
+ const lines = [`# ${heading}`, '', ...items.map((item) => `- ${item.text}`)];
273
+ fs.writeFileSync(path.join(this.memoryDir, `${type}s.md`), lines.join('\n') + '\n', 'utf8');
274
+ allLines.push(`## ${heading}`, '', ...items.slice(-40).map((item) => `- ${item.text}`), '');
275
+ }
276
+ fs.writeFileSync(path.join(this.memoryDir, 'README.md'), allLines.join('\n').trimEnd() + '\n', 'utf8');
277
+ }
278
+ headingForType(type) {
279
+ switch (type) {
280
+ case 'preference': return 'User Preferences';
281
+ case 'decision': return 'Decisions';
282
+ case 'task': return 'Tasks';
283
+ case 'issue': return 'Known Issues';
284
+ case 'validation': return 'Validations';
285
+ case 'file': return 'Important Files';
286
+ default: return 'Facts';
287
+ }
288
+ }
289
+ }
@@ -16,10 +16,22 @@ export type CliError = {
16
16
  };
17
17
  /**
18
18
  * Validate persisted authentication state without assuming any field is present.
19
+ *
20
+ * Production hygiene: by default this helper is SILENT. Earlier revisions
21
+ * wrote ``console.warn`` for every missing-token branch, which polluted
22
+ * stderr on PowerShell (NativeCommandError styling) and surfaced as noise
23
+ * on first-run installs. Pass ``{ silent: false }`` only when the caller
24
+ * actually wants the warning visible.
19
25
  */
20
- export declare function validateSession(session: AuthState): boolean;
26
+ export declare function validateSession(session: AuthState, options?: {
27
+ silent?: boolean;
28
+ }): boolean;
21
29
  /**
22
30
  * Load persisted authentication state and normalize nullable token fields safely.
31
+ *
32
+ * Production behaviour: first-run users (no session file yet) get a clean
33
+ * ``{ token: null, expiresAt: null, isValid: false }`` instead of an
34
+ * exception. Callers that need a hard failure can inspect ``isValid``.
23
35
  */
24
36
  export declare function loadSession(): Promise<AuthState>;
25
37
  export interface Session {
@@ -39,8 +51,20 @@ export declare class SessionManager {
39
51
  private sessionsDir;
40
52
  private readonly compactThreshold;
41
53
  private readonly retainRecentMessages;
54
+ /** Keep at most this many session files on disk per CLI install. */
55
+ private readonly maxRetainedSessions;
56
+ /** Sessions older than this are pruned (90 days). */
57
+ private readonly retentionWindowMs;
58
+ /** Avoid running prune on every save — at most once per CLI process. */
59
+ private prunedThisProcess;
42
60
  constructor();
43
61
  private ensureDir;
62
+ /**
63
+ * Prune sessions older than the retention window AND cap the number of
64
+ * retained files. This is best-effort: any unreadable file is treated
65
+ * as stale and removed. Pruning is bounded to once per process.
66
+ */
67
+ private pruneIfNeeded;
44
68
  /**
45
69
  * Generate unique session ID
46
70
  */
@@ -50,7 +74,8 @@ export declare class SessionManager {
50
74
  */
51
75
  create(project: string, model: string, agentMode?: boolean, operatorMode?: boolean): Session;
52
76
  /**
53
- * Save session to disk
77
+ * Save session to disk — atomic + 0600 mode so transcripts cannot be
78
+ * read by other local users on a shared POSIX host.
54
79
  */
55
80
  save(session: Session): void;
56
81
  /**
@@ -62,7 +87,8 @@ export declare class SessionManager {
62
87
  */
63
88
  getLatest(project: string): Session | null;
64
89
  /**
65
- * List all sessions (metadata only)
90
+ * List all sessions (metadata only) — corrupt or unreadable files are
91
+ * skipped silently unless VIGTHORIA_DEBUG=1.
66
92
  */
67
93
  list(): Omit<Session, 'messages'>[];
68
94
  /**
@@ -1,48 +1,11 @@
1
- "use strict";
2
1
  /**
3
2
  * Session Manager - Persist and resume conversations
4
3
  * Similar to Vigthoria's session persistence
5
4
  */
6
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
- if (k2 === undefined) k2 = k;
8
- var desc = Object.getOwnPropertyDescriptor(m, k);
9
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
- desc = { enumerable: true, get: function() { return m[k]; } };
11
- }
12
- Object.defineProperty(o, k2, desc);
13
- }) : (function(o, m, k, k2) {
14
- if (k2 === undefined) k2 = k;
15
- o[k2] = m[k];
16
- }));
17
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
- Object.defineProperty(o, "default", { enumerable: true, value: v });
19
- }) : function(o, v) {
20
- o["default"] = v;
21
- });
22
- var __importStar = (this && this.__importStar) || (function () {
23
- var ownKeys = function(o) {
24
- ownKeys = Object.getOwnPropertyNames || function (o) {
25
- var ar = [];
26
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
- return ar;
28
- };
29
- return ownKeys(o);
30
- };
31
- return function (mod) {
32
- if (mod && mod.__esModule) return mod;
33
- var result = {};
34
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
- __setModuleDefault(result, mod);
36
- return result;
37
- };
38
- })();
39
- Object.defineProperty(exports, "__esModule", { value: true });
40
- exports.SessionManager = void 0;
41
- exports.validateSession = validateSession;
42
- exports.loadSession = loadSession;
43
- const fs = __importStar(require("fs"));
44
- const path = __importStar(require("path"));
45
- const os = __importStar(require("os"));
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import * as os from 'os';
8
+ import { atomicWriteJson, secureFileMode } from './cli-state.js';
46
9
  function createSessionLoadError(message, details) {
47
10
  return {
48
11
  code: 'SESSION_LOAD_FAILED',
@@ -53,9 +16,7 @@ function createSessionLoadError(message, details) {
53
16
  }
54
17
  function normalizeAuthState(raw) {
55
18
  if (!raw || typeof raw !== 'object') {
56
- const session = { token: null, expiresAt: null, isValid: false };
57
- session.isValid = validateSession(session);
58
- return session;
19
+ return { token: null, expiresAt: null, isValid: false };
59
20
  }
60
21
  const source = raw.auth && typeof raw.auth === 'object' ? raw.auth : raw;
61
22
  const rawToken = source.token ?? source.accessToken ?? source.jwt ?? null;
@@ -63,75 +24,89 @@ function normalizeAuthState(raw) {
63
24
  const token = typeof rawToken === 'string' && rawToken.trim().length > 0 ? rawToken.trim() : null;
64
25
  const expiresAt = typeof rawExpiresAt === 'number' && Number.isFinite(rawExpiresAt) ? rawExpiresAt : null;
65
26
  const session = { token, expiresAt, isValid: false };
66
- session.isValid = validateSession(session);
27
+ session.isValid = validateSession(session, { silent: true });
67
28
  return session;
68
29
  }
69
30
  /**
70
31
  * Validate persisted authentication state without assuming any field is present.
32
+ *
33
+ * Production hygiene: by default this helper is SILENT. Earlier revisions
34
+ * wrote ``console.warn`` for every missing-token branch, which polluted
35
+ * stderr on PowerShell (NativeCommandError styling) and surfaced as noise
36
+ * on first-run installs. Pass ``{ silent: false }`` only when the caller
37
+ * actually wants the warning visible.
71
38
  */
72
- function validateSession(session) {
39
+ export function validateSession(session, options = {}) {
40
+ const silent = options.silent !== false;
73
41
  if (!session || typeof session !== 'object') {
74
- console.warn('Invalid session: session state is missing.');
42
+ if (!silent)
43
+ console.warn('Invalid session: session state is missing.');
75
44
  return false;
76
45
  }
77
46
  if (typeof session.token !== 'string' || session.token.trim().length === 0) {
78
- console.warn('Invalid session: authentication token is missing.');
47
+ if (!silent)
48
+ console.warn('Invalid session: authentication token is missing.');
79
49
  return false;
80
50
  }
81
51
  if (typeof session.expiresAt !== 'number' || !Number.isFinite(session.expiresAt)) {
82
- console.warn('Invalid session: expiration timestamp is missing.');
52
+ if (!silent)
53
+ console.warn('Invalid session: expiration timestamp is missing.');
83
54
  return false;
84
55
  }
85
56
  if (session.expiresAt <= Date.now()) {
86
- console.warn('Invalid session: authentication token has expired.');
57
+ if (!silent)
58
+ console.warn('Invalid session: authentication token has expired.');
87
59
  return false;
88
60
  }
89
61
  return true;
90
62
  }
91
63
  /**
92
64
  * Load persisted authentication state and normalize nullable token fields safely.
65
+ *
66
+ * Production behaviour: first-run users (no session file yet) get a clean
67
+ * ``{ token: null, expiresAt: null, isValid: false }`` instead of an
68
+ * exception. Callers that need a hard failure can inspect ``isValid``.
93
69
  */
94
- async function loadSession() {
70
+ export async function loadSession() {
95
71
  const configDir = path.join(os.homedir(), '.vigthoria');
96
72
  const candidateFiles = [
97
73
  path.join(configDir, 'auth.json'),
98
74
  path.join(configDir, 'session.json'),
99
75
  path.join(configDir, 'config.json'),
100
76
  ];
101
- try {
102
- const sessionFile = candidateFiles.find((filePath) => fs.existsSync(filePath));
103
- if (!sessionFile) {
104
- const session = { token: null, expiresAt: null, isValid: false };
105
- validateSession(session);
106
- throw createSessionLoadError('Failed to load persisted authentication session: no session file found.');
77
+ const sessionFile = candidateFiles.find((filePath) => {
78
+ try {
79
+ return fs.existsSync(filePath);
80
+ }
81
+ catch {
82
+ return false;
107
83
  }
84
+ });
85
+ if (!sessionFile) {
86
+ return { token: null, expiresAt: null, isValid: false };
87
+ }
88
+ try {
108
89
  const content = fs.readFileSync(sessionFile, 'utf-8');
109
90
  const parsed = JSON.parse(content);
110
- const session = normalizeAuthState(parsed);
111
- if (session.token === null || session.expiresAt === null || !session.isValid) {
112
- throw createSessionLoadError('Failed to load persisted authentication session: token is missing or expired.', {
113
- sessionFile,
114
- hasToken: session.token !== null,
115
- expiresAt: session.expiresAt,
116
- });
117
- }
118
- return session;
91
+ return normalizeAuthState(parsed);
119
92
  }
120
93
  catch (error) {
121
- if (error?.code === 'SESSION_LOAD_FAILED') {
122
- console.error(error.message, error.details ?? '');
123
- throw error;
94
+ if (process.env.VIGTHORIA_DEBUG === '1') {
95
+ console.error('[vigthoria] Could not read session file:', error?.message ?? error);
124
96
  }
125
- console.error('Failed to load persisted authentication session.', error?.message ?? error);
126
- throw createSessionLoadError('Failed to load persisted authentication session.', {
127
- message: error?.message ?? String(error),
128
- });
97
+ return { token: null, expiresAt: null, isValid: false };
129
98
  }
130
99
  }
131
- class SessionManager {
100
+ export class SessionManager {
132
101
  sessionsDir;
133
102
  compactThreshold = 40;
134
103
  retainRecentMessages = 18;
104
+ /** Keep at most this many session files on disk per CLI install. */
105
+ maxRetainedSessions = 200;
106
+ /** Sessions older than this are pruned (90 days). */
107
+ retentionWindowMs = 90 * 24 * 60 * 60 * 1000;
108
+ /** Avoid running prune on every save — at most once per CLI process. */
109
+ prunedThisProcess = false;
135
110
  constructor() {
136
111
  this.sessionsDir = path.join(os.homedir(), '.vigthoria', 'sessions');
137
112
  this.ensureDir();
@@ -139,18 +114,77 @@ class SessionManager {
139
114
  ensureDir() {
140
115
  try {
141
116
  if (!fs.existsSync(this.sessionsDir)) {
142
- fs.mkdirSync(this.sessionsDir, { recursive: true, mode: 0o755 });
117
+ fs.mkdirSync(this.sessionsDir, { recursive: true, mode: 0o700 });
118
+ }
119
+ if (process.platform !== 'win32') {
120
+ try {
121
+ fs.chmodSync(this.sessionsDir, 0o700);
122
+ }
123
+ catch { /* best-effort */ }
143
124
  }
144
125
  }
145
126
  catch (error) {
146
- if (error.code === 'EACCES' || error.code === 'EPERM') {
127
+ if (error?.code === 'EACCES' || error?.code === 'EPERM') {
147
128
  this.sessionsDir = path.join(os.tmpdir(), 'vigthoria-sessions');
148
- if (!fs.existsSync(this.sessionsDir)) {
149
- fs.mkdirSync(this.sessionsDir, { recursive: true });
129
+ try {
130
+ if (!fs.existsSync(this.sessionsDir)) {
131
+ fs.mkdirSync(this.sessionsDir, { recursive: true });
132
+ }
133
+ }
134
+ catch {
135
+ // If even tmpdir is unwritable we'll fail at write time with a useful error.
150
136
  }
151
137
  }
152
138
  }
153
139
  }
140
+ /**
141
+ * Prune sessions older than the retention window AND cap the number of
142
+ * retained files. This is best-effort: any unreadable file is treated
143
+ * as stale and removed. Pruning is bounded to once per process.
144
+ */
145
+ pruneIfNeeded() {
146
+ if (this.prunedThisProcess)
147
+ return;
148
+ this.prunedThisProcess = true;
149
+ try {
150
+ const now = Date.now();
151
+ const files = fs.readdirSync(this.sessionsDir)
152
+ .filter((f) => f.endsWith('.json'))
153
+ .map((f) => {
154
+ const full = path.join(this.sessionsDir, f);
155
+ let mtimeMs = 0;
156
+ try {
157
+ mtimeMs = fs.statSync(full).mtimeMs;
158
+ }
159
+ catch { /* treat as stale */ }
160
+ return { file: f, full, mtimeMs };
161
+ });
162
+ // Drop everything older than the retention window.
163
+ for (const entry of files) {
164
+ if (entry.mtimeMs === 0 || now - entry.mtimeMs > this.retentionWindowMs) {
165
+ try {
166
+ fs.unlinkSync(entry.full);
167
+ }
168
+ catch { /* ignore */ }
169
+ }
170
+ }
171
+ // Cap retained-session count (keep most recent).
172
+ const remaining = files
173
+ .filter((e) => e.mtimeMs > 0 && now - e.mtimeMs <= this.retentionWindowMs)
174
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
175
+ if (remaining.length > this.maxRetainedSessions) {
176
+ for (const entry of remaining.slice(this.maxRetainedSessions)) {
177
+ try {
178
+ fs.unlinkSync(entry.full);
179
+ }
180
+ catch { /* ignore */ }
181
+ }
182
+ }
183
+ }
184
+ catch {
185
+ // Pruning is purely a hygiene step — never block on failure.
186
+ }
187
+ }
154
188
  /**
155
189
  * Generate unique session ID
156
190
  */
@@ -180,14 +214,25 @@ class SessionManager {
180
214
  return session;
181
215
  }
182
216
  /**
183
- * Save session to disk
217
+ * Save session to disk — atomic + 0600 mode so transcripts cannot be
218
+ * read by other local users on a shared POSIX host.
184
219
  */
185
220
  save(session) {
186
221
  const compacted = this.compactSession(session);
187
222
  const targetSession = compacted.session;
188
223
  targetSession.updatedAt = new Date().toISOString();
189
224
  const filePath = path.join(this.sessionsDir, `${session.id}.json`);
190
- fs.writeFileSync(filePath, JSON.stringify(targetSession, null, 2), 'utf-8');
225
+ try {
226
+ atomicWriteJson(filePath, targetSession, 0o600);
227
+ secureFileMode(filePath);
228
+ }
229
+ catch (error) {
230
+ // Surface a best-effort hint rather than crashing the active chat.
231
+ if (process.env.VIGTHORIA_DEBUG === '1') {
232
+ console.error('[vigthoria] session save failed:', error?.message ?? error);
233
+ }
234
+ }
235
+ this.pruneIfNeeded();
191
236
  }
192
237
  /**
193
238
  * Load session by ID
@@ -217,11 +262,17 @@ class SessionManager {
217
262
  return projectSessions.length > 0 ? this.load(projectSessions[0].id) : null;
218
263
  }
219
264
  /**
220
- * List all sessions (metadata only)
265
+ * List all sessions (metadata only) — corrupt or unreadable files are
266
+ * skipped silently unless VIGTHORIA_DEBUG=1.
221
267
  */
222
268
  list() {
223
- const files = fs.readdirSync(this.sessionsDir)
224
- .filter(f => f.endsWith('.json'));
269
+ let files = [];
270
+ try {
271
+ files = fs.readdirSync(this.sessionsDir).filter(f => f.endsWith('.json'));
272
+ }
273
+ catch {
274
+ return [];
275
+ }
225
276
  return files.map(f => {
226
277
  try {
227
278
  const content = fs.readFileSync(path.join(this.sessionsDir, f), 'utf-8');
@@ -230,7 +281,9 @@ class SessionManager {
230
281
  return { ...metadata, messages: [] };
231
282
  }
232
283
  catch (error) {
233
- console.warn(`Failed to read session metadata from ${f}:`, error);
284
+ if (process.env.VIGTHORIA_DEBUG === '1') {
285
+ console.warn(`Failed to read session metadata from ${f}:`, error);
286
+ }
234
287
  return null;
235
288
  }
236
289
  }).filter(Boolean);
@@ -368,4 +421,3 @@ class SessionManager {
368
421
  return `[${session.id}] ${preview} (${userMessages.length} messages)`;
369
422
  }
370
423
  }
371
- exports.SessionManager = SessionManager;