@yeaft/webchat-agent 0.1.440 → 0.1.442
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/claude.js +41 -1
- package/package.json +1 -1
- package/unify/conversation/persist.js +78 -8
- package/unify/engine.js +7 -1
- package/unify/index.js +1 -1
- package/unify/init.js +81 -11
- package/unify/memory/store.js +80 -9
- package/unify/session.js +15 -2
- package/unify/stop-hooks.js +28 -4
- package/unify/web-bridge.js +69 -19
package/claude.js
CHANGED
|
@@ -5,6 +5,24 @@ import ctx from './context.js';
|
|
|
5
5
|
import { sendConversationList, sendOutput, sendError, handleAskUserQuestion } from './conversation.js';
|
|
6
6
|
import { startSubagentWatcher, stopSubagentWatcher, cleanupSubagentWatchers } from './subagent.js';
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Detect whether a user message is a Claude Code compact summary.
|
|
10
|
+
* These appear after context compaction and should not be displayed in the UI.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} text — user message content
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
function isCompactSummary(text) {
|
|
16
|
+
if (!text || text.length < 200) return false;
|
|
17
|
+
// Claude Code compact summary always starts with this exact text
|
|
18
|
+
if (text.includes('This session is being continued from a previous conversation')) return true;
|
|
19
|
+
// Alternate compact summary indicator (Claude Code uses <system-reminder> blocks)
|
|
20
|
+
if (text.includes('The summary below covers the earlier portion of the conversation')) return true;
|
|
21
|
+
// Context compaction with numbered sections (1. Primary Request, 2. Key Technical Concepts, etc.)
|
|
22
|
+
if (/^[\s\S]*Summary:[\s\S]*\d+\.\s+(Primary Request|Key Technical|Current Work)/m.test(text)) return true;
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
8
26
|
/**
|
|
9
27
|
* Determine maxContextTokens and autoCompactThreshold from model name.
|
|
10
28
|
* Returns defaults suitable for the model's context window size.
|
|
@@ -423,7 +441,7 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
|
|
|
423
441
|
|
|
424
442
|
// 过滤 compact summary 消息(compact_boundary 之后的 user 消息)
|
|
425
443
|
if (message.type === 'user' && state._compactSummaryPending) {
|
|
426
|
-
console.log(`[${conversationId}] Filtering compact summary message`);
|
|
444
|
+
console.log(`[${conversationId}] Filtering compact summary message (pending flag)`);
|
|
427
445
|
continue;
|
|
428
446
|
}
|
|
429
447
|
// compact 后的 <local-command-stdout>Compacted </local-command-stdout> 标记 summary 结束
|
|
@@ -431,6 +449,28 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
|
|
|
431
449
|
state._compactSummaryPending = false;
|
|
432
450
|
}
|
|
433
451
|
|
|
452
|
+
// 兜底过滤: Claude Code 的 compact summary 有时不触发 compact_boundary,
|
|
453
|
+
// 直接以 user 消息形式出现。通过内容特征检测过滤。
|
|
454
|
+
if (message.type === 'user') {
|
|
455
|
+
const userText = typeof message.content === 'string'
|
|
456
|
+
? message.content
|
|
457
|
+
: (Array.isArray(message.content) ? message.content.map(b => b.text || '').join('') : '');
|
|
458
|
+
if (userText && isCompactSummary(userText)) {
|
|
459
|
+
console.log(`[${conversationId}] Filtering compact summary message (content match)`);
|
|
460
|
+
// 补发 compact 完成通知(如果之前没发过)
|
|
461
|
+
if (!state._compactCompleteSent) {
|
|
462
|
+
state._compactCompleteSent = true;
|
|
463
|
+
ctx.sendToServer({
|
|
464
|
+
type: 'compact_status',
|
|
465
|
+
conversationId,
|
|
466
|
+
status: 'completed',
|
|
467
|
+
message: 'Context compacted successfully'
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
434
474
|
// 捕获 result 消息中的 usage 信息
|
|
435
475
|
if (message.type === 'result') {
|
|
436
476
|
// Log result message keys for debugging slash command output
|
package/package.json
CHANGED
|
@@ -20,9 +20,16 @@
|
|
|
20
20
|
|
|
21
21
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync } from 'fs';
|
|
22
22
|
import { join, basename } from 'path';
|
|
23
|
+
import { isPermissionError } from '../init.js';
|
|
23
24
|
|
|
24
25
|
// ─── Token estimation ────────────────────────────────────────
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Whether a permission warning has already been logged for this store instance.
|
|
29
|
+
* Used to avoid spamming the console with repeated warnings.
|
|
30
|
+
*/
|
|
31
|
+
let _permissionWarned = false;
|
|
32
|
+
|
|
26
33
|
/** Rough token estimation: ~4 chars per token. */
|
|
27
34
|
export function estimateTokens(text) {
|
|
28
35
|
if (!text) return 0;
|
|
@@ -169,9 +176,20 @@ export class ConversationStore {
|
|
|
169
176
|
this.#compactPath = join(dir, 'conversation', 'compact.md');
|
|
170
177
|
this.#nextSeq = null;
|
|
171
178
|
|
|
172
|
-
// Ensure directories exist
|
|
179
|
+
// Ensure directories exist (graceful on permission errors)
|
|
173
180
|
for (const d of [this.#convDir, this.#msgDir, this.#coldDir]) {
|
|
174
|
-
|
|
181
|
+
try {
|
|
182
|
+
if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (isPermissionError(err)) {
|
|
185
|
+
if (!_permissionWarned) {
|
|
186
|
+
console.warn(`[Yeaft] Cannot create directory ${d}: ${err.code} — persistence disabled`);
|
|
187
|
+
_permissionWarned = true;
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
175
193
|
}
|
|
176
194
|
}
|
|
177
195
|
|
|
@@ -194,7 +212,18 @@ export class ConversationStore {
|
|
|
194
212
|
};
|
|
195
213
|
|
|
196
214
|
const filePath = join(this.#msgDir, `${id}.md`);
|
|
197
|
-
|
|
215
|
+
try {
|
|
216
|
+
writeFileSync(filePath, serializeMessage(fullMsg), { encoding: 'utf8', mode: 0o644 });
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if (isPermissionError(err)) {
|
|
219
|
+
if (!_permissionWarned) {
|
|
220
|
+
console.warn(`[Yeaft] Cannot write message ${id}: ${err.code} — message not persisted`);
|
|
221
|
+
_permissionWarned = true;
|
|
222
|
+
}
|
|
223
|
+
return fullMsg; // Return the message but don't persist
|
|
224
|
+
}
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
198
227
|
|
|
199
228
|
this.#nextSeq = seq + 1;
|
|
200
229
|
|
|
@@ -220,7 +249,18 @@ export class ConversationStore {
|
|
|
220
249
|
const src = join(this.#msgDir, `${id}.md`);
|
|
221
250
|
const dst = join(this.#coldDir, `${id}.md`);
|
|
222
251
|
if (existsSync(src)) {
|
|
223
|
-
|
|
252
|
+
try {
|
|
253
|
+
renameSync(src, dst);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (isPermissionError(err)) {
|
|
256
|
+
if (!_permissionWarned) {
|
|
257
|
+
console.warn(`[Yeaft] Cannot move message ${id} to cold: ${err.code}`);
|
|
258
|
+
_permissionWarned = true;
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
224
264
|
}
|
|
225
265
|
}
|
|
226
266
|
|
|
@@ -248,7 +288,18 @@ export class ConversationStore {
|
|
|
248
288
|
|
|
249
289
|
const date = new Date().toISOString().split('T')[0];
|
|
250
290
|
const entry = `\n## ${date}\n\n${summary}\n`;
|
|
251
|
-
|
|
291
|
+
try {
|
|
292
|
+
writeFileSync(this.#compactPath, existing + entry, { encoding: 'utf8', mode: 0o644 });
|
|
293
|
+
} catch (err) {
|
|
294
|
+
if (isPermissionError(err)) {
|
|
295
|
+
if (!_permissionWarned) {
|
|
296
|
+
console.warn(`[Yeaft] Cannot write compact summary: ${err.code}`);
|
|
297
|
+
_permissionWarned = true;
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
252
303
|
}
|
|
253
304
|
|
|
254
305
|
/**
|
|
@@ -285,7 +336,18 @@ export class ConversationStore {
|
|
|
285
336
|
'This file tracks the conversation state for the "one eternal conversation" model.',
|
|
286
337
|
].join('\n');
|
|
287
338
|
|
|
288
|
-
|
|
339
|
+
try {
|
|
340
|
+
writeFileSync(this.#indexPath, content, { encoding: 'utf8', mode: 0o644 });
|
|
341
|
+
} catch (err) {
|
|
342
|
+
if (isPermissionError(err)) {
|
|
343
|
+
if (!_permissionWarned) {
|
|
344
|
+
console.warn(`[Yeaft] Cannot write conversation index: ${err.code}`);
|
|
345
|
+
_permissionWarned = true;
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
289
351
|
}
|
|
290
352
|
|
|
291
353
|
/**
|
|
@@ -296,14 +358,22 @@ export class ConversationStore {
|
|
|
296
358
|
if (existsSync(dir)) {
|
|
297
359
|
for (const file of readdirSync(dir)) {
|
|
298
360
|
if (file.endsWith('.md')) {
|
|
299
|
-
|
|
361
|
+
try {
|
|
362
|
+
unlinkSync(join(dir, file));
|
|
363
|
+
} catch (err) {
|
|
364
|
+
if (!isPermissionError(err)) throw err;
|
|
365
|
+
}
|
|
300
366
|
}
|
|
301
367
|
}
|
|
302
368
|
}
|
|
303
369
|
}
|
|
304
370
|
// Reset compact
|
|
305
371
|
if (existsSync(this.#compactPath)) {
|
|
306
|
-
|
|
372
|
+
try {
|
|
373
|
+
writeFileSync(this.#compactPath, '', { encoding: 'utf8', mode: 0o644 });
|
|
374
|
+
} catch (err) {
|
|
375
|
+
if (!isPermissionError(err)) throw err;
|
|
376
|
+
}
|
|
307
377
|
}
|
|
308
378
|
this.#nextSeq = 1;
|
|
309
379
|
this.updateIndex({ totalMessages: 0, lastMessageId: null });
|
package/unify/engine.js
CHANGED
|
@@ -255,6 +255,7 @@ export class Engine {
|
|
|
255
255
|
|
|
256
256
|
/**
|
|
257
257
|
* Persist user message and assistant response to conversation store.
|
|
258
|
+
* Skipped in read-only mode (config._readOnly).
|
|
258
259
|
*
|
|
259
260
|
* @param {string} userContent
|
|
260
261
|
* @param {string} assistantContent
|
|
@@ -263,6 +264,7 @@ export class Engine {
|
|
|
263
264
|
*/
|
|
264
265
|
#persistMessages(userContent, assistantContent, mode, toolCalls) {
|
|
265
266
|
if (!this.#conversationStore) return;
|
|
267
|
+
if (this.#config._readOnly) return;
|
|
266
268
|
|
|
267
269
|
// Persist user message
|
|
268
270
|
this.#conversationStore.append({
|
|
@@ -286,11 +288,13 @@ export class Engine {
|
|
|
286
288
|
|
|
287
289
|
/**
|
|
288
290
|
* Check and trigger consolidation if needed.
|
|
291
|
+
* Skipped in read-only mode.
|
|
289
292
|
*
|
|
290
293
|
* @returns {Promise<{ archivedCount: number, extractedCount: number }|null>}
|
|
291
294
|
*/
|
|
292
295
|
async #maybeConsolidate() {
|
|
293
296
|
if (!this.#conversationStore || !this.#memoryStore) return null;
|
|
297
|
+
if (this.#config._readOnly) return null;
|
|
294
298
|
|
|
295
299
|
const budget = this.#config.messageTokenBudget || 8192;
|
|
296
300
|
if (!shouldConsolidate(this.#conversationStore, budget)) return null;
|
|
@@ -520,7 +524,9 @@ export class Engine {
|
|
|
520
524
|
yield { type: 'turn_end', turnNumber, stopReason };
|
|
521
525
|
|
|
522
526
|
// ─── Post-query: StopHooks or Legacy ─────────────
|
|
523
|
-
if (this.#
|
|
527
|
+
if (this.#config._readOnly) {
|
|
528
|
+
// Read-only mode: skip all persistence operations
|
|
529
|
+
} else if (this.#yeaftDir && this.#conversationStore) {
|
|
524
530
|
// Full pipeline: persist + consolidate + dream gate
|
|
525
531
|
// Note: stopHooks uses fastConfig for consolidation/dream (cheaper internal tasks)
|
|
526
532
|
// but receives both configs — messages are persisted with primary model name
|
package/unify/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Re-exports all public APIs for external consumption.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export { initYeaftDir, DEFAULT_YEAFT_DIR } from './init.js';
|
|
7
|
+
export { initYeaftDir, DEFAULT_YEAFT_DIR, isWritable, isPermissionError } from './init.js';
|
|
8
8
|
export { loadConfig, parseFrontmatter, loadMCPConfig } from './config.js';
|
|
9
9
|
export { DebugTrace, NullTrace, createTrace } from './debug-trace.js';
|
|
10
10
|
export {
|
package/unify/init.js
CHANGED
|
@@ -5,10 +5,70 @@
|
|
|
5
5
|
* Creates default config.md, MEMORY.md, and conversation/index.md if missing.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
8
|
+
import { existsSync, mkdirSync, writeFileSync, accessSync, constants } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { homedir } from 'os';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Check if an error is a permission error (EACCES or EPERM).
|
|
14
|
+
* @param {Error} err
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export function isPermissionError(err) {
|
|
18
|
+
return err?.code === 'EACCES' || err?.code === 'EPERM';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Try to write a file, catching permission errors gracefully.
|
|
23
|
+
* @param {string} filePath
|
|
24
|
+
* @param {string} content
|
|
25
|
+
* @param {string[]} warnings — array to push warning messages into
|
|
26
|
+
*/
|
|
27
|
+
function safeWriteFile(filePath, content, warnings) {
|
|
28
|
+
try {
|
|
29
|
+
writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o644 });
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (isPermissionError(err)) {
|
|
32
|
+
warnings.push(`Cannot write ${filePath}: ${err.code}`);
|
|
33
|
+
} else {
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Try to create a directory, catching permission errors gracefully.
|
|
41
|
+
* @param {string} dirPath
|
|
42
|
+
* @param {string[]} warnings — array to push warning messages into
|
|
43
|
+
* @returns {boolean} — true if directory exists (created or already existed)
|
|
44
|
+
*/
|
|
45
|
+
function safeMkdir(dirPath, warnings) {
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(dirPath, { recursive: true, mode: 0o755 });
|
|
48
|
+
return true;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (isPermissionError(err)) {
|
|
51
|
+
warnings.push(`Cannot create directory ${dirPath}: ${err.code}`);
|
|
52
|
+
return existsSync(dirPath);
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if a directory is writable.
|
|
60
|
+
* @param {string} dirPath
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
export function isWritable(dirPath) {
|
|
64
|
+
try {
|
|
65
|
+
accessSync(dirPath, constants.W_OK);
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
12
72
|
/** Default directory for Yeaft data. */
|
|
13
73
|
export const DEFAULT_YEAFT_DIR = join(homedir(), '.yeaft');
|
|
14
74
|
|
|
@@ -92,24 +152,34 @@ This file tracks the conversation state for the "one eternal conversation" model
|
|
|
92
152
|
* Initialize the Yeaft data directory structure.
|
|
93
153
|
*
|
|
94
154
|
* @param {string} [dir] — Root directory path. Defaults to ~/.yeaft/
|
|
95
|
-
* @returns {{ dir: string, created: string[] }} — The root dir
|
|
155
|
+
* @returns {{ dir: string, created: string[], writable: boolean, warnings: string[] }} — The root dir, list of created paths, writability status, and any warnings
|
|
96
156
|
*/
|
|
97
157
|
export function initYeaftDir(dir) {
|
|
98
158
|
const root = dir || DEFAULT_YEAFT_DIR;
|
|
99
159
|
const created = [];
|
|
160
|
+
const warnings = [];
|
|
100
161
|
|
|
101
162
|
// Ensure root exists
|
|
102
163
|
if (!existsSync(root)) {
|
|
103
|
-
|
|
104
|
-
|
|
164
|
+
if (safeMkdir(root, warnings)) {
|
|
165
|
+
created.push(root);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check if root is writable early — if not, skip file creation
|
|
170
|
+
const writable = isWritable(root);
|
|
171
|
+
if (!writable) {
|
|
172
|
+
warnings.push(`Directory ${root} is not writable — session will run in read-only mode`);
|
|
173
|
+
return { dir: root, created, writable, warnings };
|
|
105
174
|
}
|
|
106
175
|
|
|
107
176
|
// Ensure all subdirectories exist
|
|
108
177
|
for (const sub of SUBDIRS) {
|
|
109
178
|
const fullPath = join(root, sub);
|
|
110
179
|
if (!existsSync(fullPath)) {
|
|
111
|
-
|
|
112
|
-
|
|
180
|
+
if (safeMkdir(fullPath, warnings)) {
|
|
181
|
+
created.push(fullPath);
|
|
182
|
+
}
|
|
113
183
|
}
|
|
114
184
|
}
|
|
115
185
|
|
|
@@ -117,28 +187,28 @@ export function initYeaftDir(dir) {
|
|
|
117
187
|
// config.json — default configuration (user edits this directly)
|
|
118
188
|
const configJsonPath = join(root, 'config.json');
|
|
119
189
|
if (!existsSync(configJsonPath)) {
|
|
120
|
-
|
|
190
|
+
safeWriteFile(configJsonPath, DEFAULT_CONFIG_JSON, warnings);
|
|
121
191
|
created.push(configJsonPath);
|
|
122
192
|
}
|
|
123
193
|
|
|
124
194
|
const memoryPath = join(root, 'memory', 'MEMORY.md');
|
|
125
195
|
if (!existsSync(memoryPath)) {
|
|
126
|
-
|
|
196
|
+
safeWriteFile(memoryPath, DEFAULT_MEMORY, warnings);
|
|
127
197
|
created.push(memoryPath);
|
|
128
198
|
}
|
|
129
199
|
|
|
130
200
|
const indexPath = join(root, 'conversation', 'index.md');
|
|
131
201
|
if (!existsSync(indexPath)) {
|
|
132
|
-
|
|
202
|
+
safeWriteFile(indexPath, DEFAULT_CONVERSATION_INDEX, warnings);
|
|
133
203
|
created.push(indexPath);
|
|
134
204
|
}
|
|
135
205
|
|
|
136
206
|
// mcp.json.example — reference template for MCP server configuration
|
|
137
207
|
const mcpExamplePath = join(root, 'mcp.json.example');
|
|
138
208
|
if (!existsSync(mcpExamplePath)) {
|
|
139
|
-
|
|
209
|
+
safeWriteFile(mcpExamplePath, DEFAULT_MCP_EXAMPLE, warnings);
|
|
140
210
|
created.push(mcpExamplePath);
|
|
141
211
|
}
|
|
142
212
|
|
|
143
|
-
return { dir: root, created };
|
|
213
|
+
return { dir: root, created, writable, warnings };
|
|
144
214
|
}
|
package/unify/memory/store.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
|
|
27
27
|
import { join, basename } from 'path';
|
|
28
|
+
import { isPermissionError } from '../init.js';
|
|
28
29
|
|
|
29
30
|
// ─── Constants ──────────────────────────────────────────────────
|
|
30
31
|
|
|
@@ -37,6 +38,9 @@ export const MAX_ENTRIES = 200;
|
|
|
37
38
|
/** Maximum MEMORY.md line count. */
|
|
38
39
|
export const MAX_MEMORY_LINES = 200;
|
|
39
40
|
|
|
41
|
+
/** Whether a permission warning has been logged for this store. */
|
|
42
|
+
let _permissionWarned = false;
|
|
43
|
+
|
|
40
44
|
// ─── Entry Parsing ──────────────────────────────────────────────
|
|
41
45
|
|
|
42
46
|
/**
|
|
@@ -157,9 +161,20 @@ export class MemoryStore {
|
|
|
157
161
|
this.#memoryPath = join(dir, 'memory', 'MEMORY.md');
|
|
158
162
|
this.#scopesPath = join(dir, 'memory', 'scopes.md');
|
|
159
163
|
|
|
160
|
-
// Ensure directories exist
|
|
164
|
+
// Ensure directories exist (graceful on permission errors)
|
|
161
165
|
for (const d of [this.#memoryDir, this.#entriesDir]) {
|
|
162
|
-
|
|
166
|
+
try {
|
|
167
|
+
if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (isPermissionError(err)) {
|
|
170
|
+
if (!_permissionWarned) {
|
|
171
|
+
console.warn(`[Yeaft] Cannot create directory ${d}: ${err.code} — memory persistence disabled`);
|
|
172
|
+
_permissionWarned = true;
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
163
178
|
}
|
|
164
179
|
}
|
|
165
180
|
|
|
@@ -179,7 +194,18 @@ export class MemoryStore {
|
|
|
179
194
|
* @param {string} content
|
|
180
195
|
*/
|
|
181
196
|
writeProfile(content) {
|
|
182
|
-
|
|
197
|
+
try {
|
|
198
|
+
writeFileSync(this.#memoryPath, content, { encoding: 'utf8', mode: 0o644 });
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (isPermissionError(err)) {
|
|
201
|
+
if (!_permissionWarned) {
|
|
202
|
+
console.warn(`[Yeaft] Cannot write MEMORY.md: ${err.code}`);
|
|
203
|
+
_permissionWarned = true;
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
183
209
|
}
|
|
184
210
|
|
|
185
211
|
/**
|
|
@@ -286,7 +312,18 @@ export class MemoryStore {
|
|
|
286
312
|
lines.push(`| ${scope} | ${info.count} | ${info.lastUpdated} |`);
|
|
287
313
|
}
|
|
288
314
|
|
|
289
|
-
|
|
315
|
+
try {
|
|
316
|
+
writeFileSync(this.#scopesPath, lines.join('\n') + '\n', { encoding: 'utf8', mode: 0o644 });
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (isPermissionError(err)) {
|
|
319
|
+
if (!_permissionWarned) {
|
|
320
|
+
console.warn(`[Yeaft] Cannot write scopes.md: ${err.code}`);
|
|
321
|
+
_permissionWarned = true;
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
throw err;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
290
327
|
}
|
|
291
328
|
|
|
292
329
|
// ─── Entries CRUD ─────────────────────────────────────
|
|
@@ -342,7 +379,18 @@ export class MemoryStore {
|
|
|
342
379
|
};
|
|
343
380
|
|
|
344
381
|
const filePath = join(this.#entriesDir, `${slug}.md`);
|
|
345
|
-
|
|
382
|
+
try {
|
|
383
|
+
writeFileSync(filePath, serializeEntry(fullEntry), { encoding: 'utf8', mode: 0o644 });
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (isPermissionError(err)) {
|
|
386
|
+
if (!_permissionWarned) {
|
|
387
|
+
console.warn(`[Yeaft] Cannot write memory entry ${slug}: ${err.code}`);
|
|
388
|
+
_permissionWarned = true;
|
|
389
|
+
}
|
|
390
|
+
return slug; // Return slug but don't persist
|
|
391
|
+
}
|
|
392
|
+
throw err;
|
|
393
|
+
}
|
|
346
394
|
|
|
347
395
|
return slug;
|
|
348
396
|
}
|
|
@@ -378,7 +426,18 @@ export class MemoryStore {
|
|
|
378
426
|
entry.frequency = (entry.frequency || 1) + 1;
|
|
379
427
|
entry.updated_at = new Date().toISOString();
|
|
380
428
|
const filePath = join(this.#entriesDir, `${name}.md`);
|
|
381
|
-
|
|
429
|
+
try {
|
|
430
|
+
writeFileSync(filePath, serializeEntry(entry), { encoding: 'utf8', mode: 0o644 });
|
|
431
|
+
} catch (err) {
|
|
432
|
+
if (isPermissionError(err)) {
|
|
433
|
+
if (!_permissionWarned) {
|
|
434
|
+
console.warn(`[Yeaft] Cannot bump frequency for ${name}: ${err.code}`);
|
|
435
|
+
_permissionWarned = true;
|
|
436
|
+
}
|
|
437
|
+
} else {
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
382
441
|
}
|
|
383
442
|
|
|
384
443
|
// ─── Search / Filter ──────────────────────────────────
|
|
@@ -489,19 +548,31 @@ export class MemoryStore {
|
|
|
489
548
|
if (existsSync(this.#entriesDir)) {
|
|
490
549
|
for (const file of readdirSync(this.#entriesDir)) {
|
|
491
550
|
if (file.endsWith('.md')) {
|
|
492
|
-
|
|
551
|
+
try {
|
|
552
|
+
unlinkSync(join(this.#entriesDir, file));
|
|
553
|
+
} catch (err) {
|
|
554
|
+
if (!isPermissionError(err)) throw err;
|
|
555
|
+
}
|
|
493
556
|
}
|
|
494
557
|
}
|
|
495
558
|
}
|
|
496
559
|
|
|
497
560
|
// Clear MEMORY.md
|
|
498
561
|
if (existsSync(this.#memoryPath)) {
|
|
499
|
-
|
|
562
|
+
try {
|
|
563
|
+
writeFileSync(this.#memoryPath, '', { encoding: 'utf8', mode: 0o644 });
|
|
564
|
+
} catch (err) {
|
|
565
|
+
if (!isPermissionError(err)) throw err;
|
|
566
|
+
}
|
|
500
567
|
}
|
|
501
568
|
|
|
502
569
|
// Clear scopes.md
|
|
503
570
|
if (existsSync(this.#scopesPath)) {
|
|
504
|
-
|
|
571
|
+
try {
|
|
572
|
+
unlinkSync(this.#scopesPath);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
if (!isPermissionError(err)) throw err;
|
|
575
|
+
}
|
|
505
576
|
}
|
|
506
577
|
}
|
|
507
578
|
}
|
package/unify/session.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* a fully wired Session ready for queries.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { initYeaftDir, DEFAULT_YEAFT_DIR } from './init.js';
|
|
16
|
+
import { initYeaftDir, DEFAULT_YEAFT_DIR, isWritable } from './init.js';
|
|
17
17
|
import { loadConfig, loadMCPConfig } from './config.js';
|
|
18
18
|
import { createTrace } from './debug-trace.js';
|
|
19
19
|
import { createLLMAdapter } from './llm/adapter.js';
|
|
@@ -85,12 +85,25 @@ export async function loadSession(options = {}) {
|
|
|
85
85
|
if (debug !== undefined) overrides.debug = debug;
|
|
86
86
|
|
|
87
87
|
const yeaftDir = overrides.dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
88
|
-
initYeaftDir(yeaftDir);
|
|
88
|
+
const initResult = initYeaftDir(yeaftDir);
|
|
89
89
|
overrides.dir = yeaftDir;
|
|
90
90
|
|
|
91
|
+
// Log any warnings from directory initialization
|
|
92
|
+
for (const w of initResult.warnings) {
|
|
93
|
+
console.warn(`[Yeaft] ${w}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
91
96
|
// ─── 2. Load config ───────────────────────────────────
|
|
92
97
|
const config = loadConfig(overrides);
|
|
93
98
|
|
|
99
|
+
// ─── 2a. Permission pre-check ─────────────────────────
|
|
100
|
+
// If the data dir is not writable, mark session as read-only.
|
|
101
|
+
// Persistence (conversation, memory, dream) is skipped in this mode.
|
|
102
|
+
if (!initResult.writable) {
|
|
103
|
+
config._readOnly = true;
|
|
104
|
+
console.warn(`[Yeaft] ${yeaftDir} is not writable — running in read-only mode`);
|
|
105
|
+
}
|
|
106
|
+
|
|
94
107
|
// ─── 3. Create debug trace ─────────────────────────────
|
|
95
108
|
const trace = createTrace({
|
|
96
109
|
enabled: config.debug,
|
package/unify/stop-hooks.js
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
import { shouldConsolidate, consolidate } from './memory/consolidate.js';
|
|
14
14
|
import { checkDreamGate, incrementQueryCount, dream } from './memory/dream.js';
|
|
15
|
+
import { isPermissionError } from './init.js';
|
|
16
|
+
|
|
17
|
+
/** Track whether we've already warned about permission issues in stop hooks. */
|
|
18
|
+
let _permissionWarned = false;
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* Run all stop hooks after a query completes.
|
|
@@ -77,7 +81,14 @@ export async function runStopHooks(context) {
|
|
|
77
81
|
}
|
|
78
82
|
}
|
|
79
83
|
} catch (err) {
|
|
80
|
-
|
|
84
|
+
if (isPermissionError(err)) {
|
|
85
|
+
if (!_permissionWarned) {
|
|
86
|
+
result.errors.push('Cannot write to ~/.yeaft/ — check directory permissions');
|
|
87
|
+
_permissionWarned = true;
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
result.errors.push(`Persist failed: ${err.message}`);
|
|
91
|
+
}
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
// 2. Consolidate check (non-blocking, but awaited for correctness)
|
|
@@ -102,7 +113,14 @@ export async function runStopHooks(context) {
|
|
|
102
113
|
}
|
|
103
114
|
}
|
|
104
115
|
} catch (err) {
|
|
105
|
-
|
|
116
|
+
if (isPermissionError(err)) {
|
|
117
|
+
if (!_permissionWarned) {
|
|
118
|
+
result.errors.push('Cannot write to ~/.yeaft/ — consolidation skipped');
|
|
119
|
+
_permissionWarned = true;
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
result.errors.push(`Consolidate failed: ${err.message}`);
|
|
123
|
+
}
|
|
106
124
|
}
|
|
107
125
|
|
|
108
126
|
// 3. Increment dream query counter
|
|
@@ -111,7 +129,11 @@ export async function runStopHooks(context) {
|
|
|
111
129
|
incrementQueryCount(yeaftDir);
|
|
112
130
|
}
|
|
113
131
|
} catch (err) {
|
|
114
|
-
|
|
132
|
+
if (isPermissionError(err)) {
|
|
133
|
+
// Silent — already warned about permission issues
|
|
134
|
+
} else {
|
|
135
|
+
result.errors.push(`Dream counter failed: ${err.message}`);
|
|
136
|
+
}
|
|
115
137
|
}
|
|
116
138
|
|
|
117
139
|
// 4. Dream gate check (fire-and-forget, background)
|
|
@@ -136,7 +158,9 @@ export async function runStopHooks(context) {
|
|
|
136
158
|
}
|
|
137
159
|
}
|
|
138
160
|
} catch (err) {
|
|
139
|
-
|
|
161
|
+
if (!isPermissionError(err)) {
|
|
162
|
+
result.errors.push(`Dream gate check failed: ${err.message}`);
|
|
163
|
+
}
|
|
140
164
|
}
|
|
141
165
|
|
|
142
166
|
return result;
|
package/unify/web-bridge.js
CHANGED
|
@@ -32,6 +32,20 @@ let unifyConversationId = null;
|
|
|
32
32
|
/** Current query mode: 'chat' or 'work' */
|
|
33
33
|
let currentMode = 'chat';
|
|
34
34
|
|
|
35
|
+
/** Whether we've already sent a permission warning to the UI */
|
|
36
|
+
let _permissionDiagnosticSent = false;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if an error message is a permission error.
|
|
40
|
+
* @param {string} msg
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
function isPermissionErrorMsg(msg) {
|
|
44
|
+
if (!msg) return false;
|
|
45
|
+
const lower = msg.toLowerCase();
|
|
46
|
+
return lower.includes('eacces') || lower.includes('eperm') || lower.includes('permission denied');
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
/**
|
|
36
50
|
* Send a unify_output message carrying claude_output-format data.
|
|
37
51
|
* The server forwards this as-is to the web client.
|
|
@@ -251,17 +265,36 @@ export async function handleUnifyChat(msg) {
|
|
|
251
265
|
break;
|
|
252
266
|
|
|
253
267
|
// ── Errors ──
|
|
254
|
-
case 'error':
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
268
|
+
case 'error': {
|
|
269
|
+
const errMsg = event.error?.message || 'Unknown error';
|
|
270
|
+
// Filter permission errors: show friendly one-time diagnostic instead of raw error
|
|
271
|
+
if (isPermissionErrorMsg(errMsg)) {
|
|
272
|
+
if (!_permissionDiagnosticSent) {
|
|
273
|
+
_permissionDiagnosticSent = true;
|
|
274
|
+
sendUnifyOutput({
|
|
275
|
+
type: 'assistant',
|
|
276
|
+
message: {
|
|
277
|
+
content: [{
|
|
278
|
+
type: 'text',
|
|
279
|
+
text: '⚠️ Cannot write to ~/.yeaft/ directory — some features (memory, history) are unavailable. Please check directory permissions: `chmod -R u+rw ~/.yeaft/`',
|
|
280
|
+
}],
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
// Don't show subsequent permission errors
|
|
285
|
+
} else {
|
|
286
|
+
sendUnifyOutput({
|
|
287
|
+
type: 'assistant',
|
|
288
|
+
message: {
|
|
289
|
+
content: [{
|
|
290
|
+
type: 'text',
|
|
291
|
+
text: `⚠️ Error: ${errMsg}`,
|
|
292
|
+
}],
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
264
296
|
break;
|
|
297
|
+
}
|
|
265
298
|
|
|
266
299
|
default:
|
|
267
300
|
// Silently consume unknown events
|
|
@@ -306,15 +339,32 @@ export async function handleUnifyChat(msg) {
|
|
|
306
339
|
}
|
|
307
340
|
|
|
308
341
|
console.error('[Unify] query error:', err.message);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
342
|
+
|
|
343
|
+
// Filter permission errors at the session level too
|
|
344
|
+
if (isPermissionErrorMsg(err.message)) {
|
|
345
|
+
if (!_permissionDiagnosticSent) {
|
|
346
|
+
_permissionDiagnosticSent = true;
|
|
347
|
+
sendUnifyOutput({
|
|
348
|
+
type: 'assistant',
|
|
349
|
+
message: {
|
|
350
|
+
content: [{
|
|
351
|
+
type: 'text',
|
|
352
|
+
text: '⚠️ Cannot write to ~/.yeaft/ directory — some features (memory, history) are unavailable. Please check directory permissions: `chmod -R u+rw ~/.yeaft/`',
|
|
353
|
+
}],
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
sendUnifyOutput({
|
|
359
|
+
type: 'assistant',
|
|
360
|
+
message: {
|
|
361
|
+
content: [{
|
|
362
|
+
type: 'text',
|
|
363
|
+
text: `⚠️ Session error: ${err.message}`,
|
|
364
|
+
}],
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
}
|
|
318
368
|
// Still send result to clear processing state
|
|
319
369
|
sendUnifyOutput({
|
|
320
370
|
type: 'result',
|