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.
- package/README.md +4 -4
- package/dist/commands/auth.js +48 -65
- package/dist/commands/bridge.js +12 -19
- package/dist/commands/cancel.js +15 -22
- package/dist/commands/chat.d.ts +11 -0
- package/dist/commands/chat.js +404 -248
- package/dist/commands/config.js +31 -71
- package/dist/commands/deploy.js +83 -123
- package/dist/commands/device.d.ts +35 -0
- package/dist/commands/device.js +239 -0
- package/dist/commands/edit.js +32 -39
- package/dist/commands/explain.js +18 -25
- package/dist/commands/fork.js +22 -27
- package/dist/commands/generate.js +37 -44
- package/dist/commands/history.js +20 -25
- package/dist/commands/hub.js +95 -102
- package/dist/commands/index.js +41 -46
- package/dist/commands/legion.d.ts +1 -0
- package/dist/commands/legion.js +162 -209
- package/dist/commands/preview.js +60 -98
- package/dist/commands/replay.js +27 -32
- package/dist/commands/repo.js +103 -141
- package/dist/commands/review.js +29 -36
- package/dist/commands/security.js +5 -12
- package/dist/commands/update.js +15 -49
- package/dist/commands/workflow.d.ts +8 -1
- package/dist/commands/workflow.js +53 -19
- package/dist/index.js +409 -234
- package/dist/utils/api.d.ts +5 -0
- package/dist/utils/api.js +373 -166
- package/dist/utils/bridge-client.js +11 -52
- package/dist/utils/cli-state.d.ts +54 -0
- package/dist/utils/cli-state.js +185 -0
- package/dist/utils/config.d.ts +5 -0
- package/dist/utils/config.js +35 -14
- package/dist/utils/context-ranker.js +15 -21
- package/dist/utils/files.js +5 -42
- package/dist/utils/logger.js +42 -50
- package/dist/utils/post-write-validator.js +22 -29
- package/dist/utils/project-memory.d.ts +56 -0
- package/dist/utils/project-memory.js +289 -0
- package/dist/utils/session.d.ts +29 -3
- package/dist/utils/session.js +137 -85
- package/dist/utils/task-display.js +13 -20
- package/dist/utils/tools.d.ts +19 -0
- package/dist/utils/tools.js +84 -87
- package/dist/utils/workspace-cache.js +18 -26
- package/dist/utils/workspace-stream.js +26 -64
- package/install.ps1 +14 -0
- package/package.json +5 -3
- package/scripts/release/LOCAL_MACHINE_USER_VERIFICATION.md +1 -1
- 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
|
+
}
|
package/dist/utils/session.d.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/dist/utils/session.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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 (
|
|
122
|
-
console.error(
|
|
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
|
-
|
|
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:
|
|
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
|
|
127
|
+
if (error?.code === 'EACCES' || error?.code === 'EPERM') {
|
|
147
128
|
this.sessionsDir = path.join(os.tmpdir(), 'vigthoria-sessions');
|
|
148
|
-
|
|
149
|
-
fs.
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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;
|