@yeaft/webchat-agent 1.0.290 → 1.0.293

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.290",
3
+ "version": "1.0.293",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -84,6 +84,8 @@ const AMS_ADJUST_TIMEOUT_MS = 30_000;
84
84
  /** Maximum silence while a visible turn waits for a result-producing task. */
85
85
  const DEFAULT_ASYNC_TASK_WAIT_TIMEOUT_MS = 120_000;
86
86
 
87
+ const DEFAULT_MEMORY_RECALL_LIMIT = 8;
88
+
87
89
  // ─── LLM retry policy defaults ──────────────────────────────────
88
90
  // Hard-coded floor / ceiling for retry behaviour. The engine reads the
89
91
  // effective policy from `config.llmRetry` so users can dial these via
@@ -442,6 +444,47 @@ function isZhRuntimeLanguage(language) {
442
444
  return String(language || '').toLowerCase().startsWith('zh');
443
445
  }
444
446
 
447
+ function resolveMemoryRecallLimit(config) {
448
+ const raw = config?.memoryRecallLimit ?? config?.dreamMemoryRecallLimit;
449
+ if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_MEMORY_RECALL_LIMIT;
450
+ return Math.max(1, Math.floor(raw));
451
+ }
452
+
453
+ function loadedMemoryDebugEntries(snapshot) {
454
+ const snap = snapshot || {};
455
+ return [
456
+ ...loadedResidentDebugEntries(snap.resident || []),
457
+ ...loadedSegmentDebugEntries(snap.recent || [], 'recent'),
458
+ ...loadedSegmentDebugEntries(snap.onDemand || [], 'onDemand'),
459
+ ];
460
+ }
461
+
462
+ function loadedResidentDebugEntries(entries) {
463
+ return (entries || []).map((entry, index) => ({
464
+ id: `resident:${entry.scope || index}`,
465
+ layer: 'resident',
466
+ scope: entry.scope || null,
467
+ label: memoryScopeLabel(entry.scope || ''),
468
+ kind: 'summary',
469
+ score: null,
470
+ tags: [],
471
+ body: entry.summary || '',
472
+ })).filter(entry => entry.body);
473
+ }
474
+
475
+ function loadedSegmentDebugEntries(segments, layer) {
476
+ return (segments || []).map((seg, index) => ({
477
+ id: seg.id || `${layer}:${index}`,
478
+ layer,
479
+ scope: seg.scope || null,
480
+ label: memoryScopeLabel(seg.scope || ''),
481
+ kind: seg.kind || null,
482
+ score: typeof seg.score === 'number' ? seg.score : null,
483
+ tags: Array.isArray(seg.tags) ? seg.tags : [],
484
+ body: seg.body || '',
485
+ })).filter(entry => entry.body);
486
+ }
487
+
445
488
  export class Engine {
446
489
  /** @type {import('./llm/adapter.js').LLMAdapter} */
447
490
  #adapter;
@@ -906,6 +949,7 @@ export class Engine {
906
949
  * ownVpId: string|null,
907
950
  * scopes: string[],
908
951
  * snapshotBlock: string,
952
+ * snapshot: import('./memory/ams.js').AmsSnapshot,
909
953
  * residentEntries: Array<{scope:string, summary:string}>,
910
954
  * } | null}
911
955
  */
@@ -938,14 +982,15 @@ export class Engine {
938
982
  ams.setOnDemand(segs);
939
983
 
940
984
  // (c) Snapshot — render the AMS layers as a single prompt block.
941
- const snapshotBlock = this.#renderAmsSnapshot(ams, this.#config.language || 'en', args.userMsg || '');
985
+ const snapshot = ams.snapshot({ userMsg: args.userMsg || '' });
986
+ const snapshotBlock = this.#renderAmsSnapshot(snapshot, this.#config.language || 'en');
942
987
 
943
988
  const scopes = buildRelevantScopes({
944
989
  sessionId: args.sessionId,
945
990
  vpId: ownVpId,
946
991
  });
947
992
 
948
- return { ams, sessionKey, ownVpId, scopes, snapshotBlock, residentEntries };
993
+ return { ams, sessionKey, ownVpId, scopes, snapshotBlock, snapshot, residentEntries };
949
994
  }
950
995
 
951
996
  /**
@@ -953,13 +998,11 @@ export class Engine {
953
998
  * injection. Mirrors the heading style of the existing memory blocks
954
999
  * so the LLM sees a consistent layout.
955
1000
  *
956
- * @param {import('./memory/ams.js').ActiveMemorySet} ams
1001
+ * @param {import('./memory/ams.js').AmsSnapshot} snap
957
1002
  * @param {string} [language]
958
- * @param {string} [userMsg]
959
1003
  * @returns {string}
960
1004
  */
961
- #renderAmsSnapshot(ams, language = 'en', userMsg = '') {
962
- const snap = ams.snapshot({ userMsg });
1005
+ #renderAmsSnapshot(snap, language = 'en') {
963
1006
  if (!snap) return '';
964
1007
  const parts = [];
965
1008
  if (snap.resident.length === 0 && snap.recent.length === 0 && snap.onDemand.length === 0) {
@@ -1342,7 +1385,7 @@ export class Engine {
1342
1385
  * @returns {Promise<{ profile: string, entries: object[], formatted: string }|null>}
1343
1386
  */
1344
1387
  async #recallMemory(prompt, ctx = {}) {
1345
- const memory = { profile: '', entries: [], formatted: '' };
1388
+ const memory = { profile: '', entries: [], formatted: '', meta: {} };
1346
1389
  if (!this.#memoryIndex) return memory;
1347
1390
  try {
1348
1391
  const result = runMemoryPreflow(this.#memoryIndex, {
@@ -1351,11 +1394,13 @@ export class Engine {
1351
1394
  chatId: ctx.chatId || this.#chatId,
1352
1395
  vpId: ctx.vpId,
1353
1396
  extraScopes: ctx.extraScopes,
1397
+ pickLimit: resolveMemoryRecallLimit(this.#config),
1354
1398
  fallbackOnEmpty: true,
1355
1399
  });
1356
1400
  memory.profile = result.profile || '';
1357
1401
  memory.entries = result.entries || [];
1358
1402
  memory.formatted = result.formatted || '';
1403
+ memory.meta = result.meta || {};
1359
1404
  } catch {
1360
1405
  // Fail soft — empty injection.
1361
1406
  }
@@ -2029,6 +2074,7 @@ export class Engine {
2029
2074
  if (amsContext && amsContext.snapshotBlock) {
2030
2075
  memoryInjection = amsContext.snapshotBlock;
2031
2076
  }
2077
+ const loadedMemoryForDebug = loadedMemoryDebugEntries(amsContext?.snapshot);
2032
2078
 
2033
2079
  // Diagnostic payload for the Dream debug panel. The full AMS Resident
2034
2080
  // layer can include user and per-VP summaries, but the browser-facing
@@ -2282,19 +2328,20 @@ export class Engine {
2282
2328
  yield { type: 'skill_error', turnId: queryTurnId, skillName: explicitSkillName, message: skillResolutionError };
2283
2329
  }
2284
2330
 
2285
- // Surface memory recall to the debug panel right after turn_open.
2286
- // recallResult was loaded above; emit a structured `memory_used`
2287
- // event so the UI can show "loaded N segments" without parsing
2288
- // the legacy `recall` event (which only carried entryCount).
2289
- if (recallResult && Array.isArray(recallResult.entries) && recallResult.entries.length > 0) {
2331
+ // Surface the exact memory that entered the prompt. This must be based on
2332
+ // the AMS snapshot, not raw FTS candidates, otherwise debug can claim memory
2333
+ // was loaded even when prompt cleanup, dedupe, or token budget dropped it.
2334
+ if (loadedMemoryForDebug.length > 0) {
2290
2335
  yield {
2291
2336
  type: 'memory_used',
2292
2337
  turnId: queryTurnId,
2293
- loaded: recallResult.entries.map(e => ({
2294
- id: e && e.id || null,
2295
- score: e && typeof e.score === 'number' ? e.score : null,
2296
- kind: e && e.kind || null,
2297
- })),
2338
+ loaded: loadedMemoryForDebug,
2339
+ meta: {
2340
+ recallLimit: resolveMemoryRecallLimit(this.#config),
2341
+ recallCandidates: Number.isFinite(recallResult?.meta?.hitCount)
2342
+ ? recallResult.meta.hitCount
2343
+ : (recallResult && Array.isArray(recallResult.entries) ? recallResult.entries.length : 0),
2344
+ },
2298
2345
  };
2299
2346
  }
2300
2347
 
@@ -19,6 +19,8 @@ import { extractKeywords } from './keywords.js';
19
19
  import { approxTokens } from './budget.js';
20
20
  import { isVpForeign } from './store.js';
21
21
 
22
+ export const DEFAULT_PICK_LIMIT = 8;
23
+
22
24
  /**
23
25
  * @typedef {object} PreflowOptions
24
26
  * @property {string} userMsg
@@ -27,6 +29,7 @@ import { isVpForeign } from './store.js';
27
29
  * @property {string[]} [currentTags] tags from the current group/feature context
28
30
  * @property {number} [topK] max FTS rows to fetch (default 50)
29
31
  * @property {number} [budgetTokens] onDemand budget (caller-supplied)
32
+ * @property {number} [pickLimit] max picked segments (default 8)
30
33
  */
31
34
 
32
35
  /**
@@ -54,6 +57,8 @@ export function runPreflow(index, opts) {
54
57
  const topK = Number.isFinite(opts.topK) && opts.topK > 0 ? opts.topK : 50;
55
58
  const budgetTokens = Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0
56
59
  ? opts.budgetTokens : Infinity;
60
+ const pickLimit = Number.isFinite(opts.pickLimit) && opts.pickLimit > 0
61
+ ? Math.floor(opts.pickLimit) : DEFAULT_PICK_LIMIT;
57
62
 
58
63
  const keywords = extractKeywords(userMsg);
59
64
  if (keywords.length === 0) {
@@ -80,7 +85,7 @@ export function runPreflow(index, opts) {
80
85
  let dropped = 0;
81
86
  for (const h of reranked) {
82
87
  const tk = approxTokens(h.body);
83
- if (cost + tk <= budgetTokens) {
88
+ if (picked.length < pickLimit && cost + tk <= budgetTokens) {
84
89
  picked.push(toSegment(h));
85
90
  cost += tk;
86
91
  } else {
@@ -152,7 +157,7 @@ export function rerank(hits, ctx) {
152
157
  return { ...h, _score: score };
153
158
  })
154
159
  .sort((a, b) => a._score - b._score)
155
- .map(({ _score, ...rest }) => rest);
160
+ .map(({ _score, ...rest }) => ({ ...rest, score: _score }));
156
161
  }
157
162
 
158
163
  function toSegment(h) {
@@ -163,6 +168,7 @@ function toSegment(h) {
163
168
  tags: h.tags,
164
169
  sourceMessages: h.sourceMessages,
165
170
  body: h.body,
171
+ score: typeof h.score === 'number' ? h.score : (typeof h.rank === 'number' ? h.rank : undefined),
166
172
  createdAt: h.createdAt,
167
173
  updatedAt: h.updatedAt,
168
174
  };
@@ -0,0 +1,152 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { writeAtomic } from '../storage/atomic.js';
5
+
6
+ const PROJECTS_FILE = 'projects.json';
7
+ const PROJECTS_VERSION = 1;
8
+
9
+ export class ProjectStoreError extends Error {
10
+ constructor(code, message) {
11
+ super(message || code);
12
+ this.name = 'ProjectStoreError';
13
+ this.code = code;
14
+ }
15
+ }
16
+
17
+ function projectsPath(yeaftDir) {
18
+ return join(yeaftDir, PROJECTS_FILE);
19
+ }
20
+
21
+ function normalizeSessionIds(values) {
22
+ const out = [];
23
+ const seen = new Set();
24
+ for (const value of Array.isArray(values) ? values : []) {
25
+ const id = typeof value === 'string' ? value.trim() : '';
26
+ if (!id || seen.has(id)) continue;
27
+ seen.add(id);
28
+ out.push(id);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ function normalizeProject(row, index = 0) {
34
+ if (!row || typeof row !== 'object' || typeof row.id !== 'string' || !row.id) return null;
35
+ const name = typeof row.name === 'string' ? row.name.trim() : '';
36
+ if (!name) return null;
37
+ return {
38
+ id: row.id,
39
+ name,
40
+ sessionIds: normalizeSessionIds(row.sessionIds),
41
+ sortOrder: Number.isFinite(row.sortOrder) ? row.sortOrder : index,
42
+ createdAt: typeof row.createdAt === 'string' ? row.createdAt : '',
43
+ updatedAt: typeof row.updatedAt === 'string' ? row.updatedAt : '',
44
+ };
45
+ }
46
+
47
+ export function loadProjects(yeaftDir) {
48
+ if (!yeaftDir || !existsSync(projectsPath(yeaftDir))) return [];
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(projectsPath(yeaftDir), 'utf8'));
51
+ const rows = Array.isArray(parsed?.projects) ? parsed.projects : [];
52
+ return rows.map(normalizeProject).filter(Boolean).sort((a, b) => a.sortOrder - b.sortOrder);
53
+ } catch {
54
+ const path = projectsPath(yeaftDir);
55
+ try { renameSync(path, `${path}.corrupt-${Date.now()}`); } catch { /* keep the unreadable file if quarantine fails */ }
56
+ return [];
57
+ }
58
+ }
59
+
60
+ function saveProjects(yeaftDir, projects) {
61
+ if (!yeaftDir) throw new ProjectStoreError('missing_root', 'Yeaft directory is required');
62
+ mkdirSync(yeaftDir, { recursive: true });
63
+ const now = new Date().toISOString();
64
+ const normalized = projects.map(normalizeProject).filter(Boolean).map((project, index) => ({
65
+ ...project,
66
+ sortOrder: index,
67
+ updatedAt: project.updatedAt || now,
68
+ }));
69
+ writeAtomic(projectsPath(yeaftDir), `${JSON.stringify({
70
+ version: PROJECTS_VERSION,
71
+ updatedAt: now,
72
+ projects: normalized,
73
+ }, null, 2)}\n`);
74
+ return normalized;
75
+ }
76
+
77
+ function requireName(value) {
78
+ const name = typeof value === 'string' ? value.trim() : '';
79
+ if (!name) throw new ProjectStoreError('invalid_name', 'Project name is required');
80
+ return name.slice(0, 120);
81
+ }
82
+
83
+ function requireProject(projects, projectId) {
84
+ const project = projects.find(row => row.id === projectId);
85
+ if (!project) throw new ProjectStoreError('not_found', 'Project not found');
86
+ return project;
87
+ }
88
+
89
+ export function createProject(yeaftDir, name) {
90
+ const projects = loadProjects(yeaftDir);
91
+ const now = new Date().toISOString();
92
+ const project = {
93
+ id: `project-${randomUUID().slice(0, 8)}`,
94
+ name: requireName(name),
95
+ sessionIds: [],
96
+ sortOrder: projects.length,
97
+ createdAt: now,
98
+ updatedAt: now,
99
+ };
100
+ saveProjects(yeaftDir, [...projects, project]);
101
+ return project;
102
+ }
103
+
104
+ export function renameProject(yeaftDir, projectId, name) {
105
+ const projects = loadProjects(yeaftDir);
106
+ const project = requireProject(projects, projectId);
107
+ project.name = requireName(name);
108
+ project.updatedAt = new Date().toISOString();
109
+ saveProjects(yeaftDir, projects);
110
+ return project;
111
+ }
112
+
113
+ export function deleteProject(yeaftDir, projectId) {
114
+ const projects = loadProjects(yeaftDir);
115
+ requireProject(projects, projectId);
116
+ saveProjects(yeaftDir, projects.filter(row => row.id !== projectId));
117
+ return { projectId };
118
+ }
119
+
120
+ export function moveSessionToProject(yeaftDir, sessionId, projectId = null) {
121
+ const id = typeof sessionId === 'string' ? sessionId.trim() : '';
122
+ if (!id) throw new ProjectStoreError('invalid_session_id', 'Session id is required');
123
+ const projects = loadProjects(yeaftDir);
124
+ const target = projectId ? requireProject(projects, projectId) : null;
125
+ for (const project of projects) {
126
+ project.sessionIds = project.sessionIds.filter(value => value !== id);
127
+ }
128
+ if (target) target.sessionIds.push(id);
129
+ saveProjects(yeaftDir, projects);
130
+ return { sessionId: id, projectId: target?.id || null };
131
+ }
132
+
133
+ export function removeSessionFromProjects(yeaftDir, sessionId) {
134
+ const projects = loadProjects(yeaftDir);
135
+ let changed = false;
136
+ for (const project of projects) {
137
+ const next = project.sessionIds.filter(id => id !== sessionId);
138
+ if (next.length !== project.sessionIds.length) changed = true;
139
+ project.sessionIds = next;
140
+ }
141
+ if (changed) saveProjects(yeaftDir, projects);
142
+ return changed;
143
+ }
144
+
145
+ export function findProjectForSession(yeaftDir, sessionId) {
146
+ return loadProjects(yeaftDir).find(project => project.sessionIds.includes(sessionId)) || null;
147
+ }
148
+
149
+ export function sharedSessionIdsForProject(yeaftDir, sessionId) {
150
+ const project = findProjectForSession(yeaftDir, sessionId);
151
+ return project ? project.sessionIds.filter(id => id !== sessionId) : [];
152
+ }
@@ -259,6 +259,7 @@ export function formatPickedForInjection(picked) {
259
259
  * @property {string[]} [currentTags] Contextual tags for rerank
260
260
  * @property {number} [topK] Max FTS rows fetched (default 50)
261
261
  * @property {number} [budgetTokens] Token budget for picked segments
262
+ * @property {number} [pickLimit] Max picked segments (default 8)
262
263
  * @property {boolean} [fallbackOnEmpty] Include bounded recent scoped segments when FTS has no hits
263
264
  * @property {number} [fallbackPerScope] Max fallback segments per scope
264
265
  */
@@ -353,6 +354,7 @@ export function runMemoryPreflow(index, opts) {
353
354
  currentTags: opts.currentTags || [],
354
355
  topK: opts.topK,
355
356
  budgetTokens: opts.budgetTokens,
357
+ pickLimit: opts.pickLimit,
356
358
  });
357
359
 
358
360
  let fallbackUsed = false;
@@ -362,6 +364,7 @@ export function runMemoryPreflow(index, opts) {
362
364
  ownVpId: opts.vpId || null,
363
365
  budgetTokens: opts.budgetTokens,
364
366
  perScope: opts.fallbackPerScope,
367
+ pickLimit: opts.pickLimit,
365
368
  });
366
369
  if (fallback.length > 0) {
367
370
  fallbackUsed = true;
@@ -400,6 +403,7 @@ function fallbackScopedSegments(index, opts) {
400
403
  const scopes = prioritizeFallbackScopes(filterScopes(opts.relevantScopes || [], opts.ownVpId || null));
401
404
  const perScope = Number.isFinite(opts.perScope) && opts.perScope > 0 ? Math.floor(opts.perScope) : 2;
402
405
  const budgetTokens = Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0 ? opts.budgetTokens : 1200;
406
+ const pickLimit = Number.isFinite(opts.pickLimit) && opts.pickLimit > 0 ? Math.floor(opts.pickLimit) : 8;
403
407
  const buckets = [];
404
408
  for (const scope of scopes) {
405
409
  let segs = [];
@@ -419,6 +423,7 @@ function fallbackScopedSegments(index, opts) {
419
423
  if (!seg) continue;
420
424
  const tk = approxTokens(seg.body || '');
421
425
  if (tk <= 0 || cost + tk > budgetTokens) continue;
426
+ if (out.length >= pickLimit) return out;
422
427
  out.push(seg);
423
428
  cost += tk;
424
429
  }
@@ -64,6 +64,17 @@ import { persistYeaftAttachments, attachmentsForPersistence, persistedAttachment
64
64
  import { normalizeSessionMessageQuote, sessionMessageQuotePrompt } from './session-message-quote.js';
65
65
  import { ConversationStore, parseSeqFromId, projectVisibleSessionMessages } from './conversation/persist.js';
66
66
  import { isHiddenConversationRow, isVisibleConversationRow } from './conversation/internal-control.js';
67
+ import {
68
+ ProjectStoreError,
69
+ createProject,
70
+ deleteProject,
71
+ loadProjects,
72
+ moveSessionToProject,
73
+ removeSessionFromProjects,
74
+ renameProject,
75
+ } from './projects/store.js';
76
+ import { readSummary as readScopeSummary } from './memory/store.js';
77
+ import { estimateTokens } from './dream/segment.js';
67
78
  import { imageMetadataForPersistence } from './image-assets.js';
68
79
  import { sliceLastNTurns } from './turn-utils.js';
69
80
  import { pairSanitize } from './pair-sanitize.js';
@@ -3008,6 +3019,54 @@ function decorateSessionsWithRuntimeState(sessions) {
3008
3019
  });
3009
3020
  }
3010
3021
 
3022
+ const PROJECT_CONTEXT_MAX_SIBLINGS = 8;
3023
+ const PROJECT_CONTEXT_MAX_TOKENS = 4096;
3024
+ const PROJECT_CONTEXT_TRUNCATION_NOTICE = '\n[Summary truncated to Project context budget]';
3025
+
3026
+ async function sharedProjectContext(yeaftDir, sessionId, options = {}) {
3027
+ const project = loadProjects(yeaftDir).find(row => row.sessionIds.includes(sessionId));
3028
+ if (!project) return '';
3029
+ const memoryRoot = join(yeaftDir, 'memory');
3030
+ const language = options.language || 'en';
3031
+ const configuredBudget = Number.isFinite(options.tokenBudget) && options.tokenBudget > 0
3032
+ ? options.tokenBudget
3033
+ : PROJECT_CONTEXT_MAX_TOKENS;
3034
+ const tokenBudget = Math.min(configuredBudget, PROJECT_CONTEXT_MAX_TOKENS);
3035
+ let context = '';
3036
+ const siblingIds = project.sessionIds
3037
+ .filter(id => id !== sessionId)
3038
+ .slice(0, PROJECT_CONTEXT_MAX_SIBLINGS);
3039
+ for (const siblingId of siblingIds) {
3040
+ const summary = await readScopeSummary(
3041
+ { kind: 'session', id: siblingId },
3042
+ { root: memoryRoot, language },
3043
+ ).catch(() => '');
3044
+ if (!summary) continue;
3045
+ const separator = context ? '\n\n' : '';
3046
+ const header = `[Session ${siblingId}]\n`;
3047
+ const fullContext = `${context}${separator}${header}${summary}`;
3048
+ if (estimateTokens(fullContext) <= tokenBudget) {
3049
+ context = fullContext;
3050
+ continue;
3051
+ }
3052
+
3053
+ const prefix = `${context}${separator}${header}`;
3054
+ if (estimateTokens(`${prefix}${PROJECT_CONTEXT_TRUNCATION_NOTICE}`) <= tokenBudget) {
3055
+ let low = 0;
3056
+ let high = summary.length;
3057
+ while (low < high) {
3058
+ const middle = Math.ceil((low + high) / 2);
3059
+ const candidate = `${prefix}${summary.slice(0, middle)}${PROJECT_CONTEXT_TRUNCATION_NOTICE}`;
3060
+ if (estimateTokens(candidate) <= tokenBudget) low = middle;
3061
+ else high = middle - 1;
3062
+ }
3063
+ context = `${prefix}${summary.slice(0, low)}${PROJECT_CONTEXT_TRUNCATION_NOTICE}`;
3064
+ }
3065
+ break;
3066
+ }
3067
+ return context;
3068
+ }
3069
+
3011
3070
  function sendSessionCrudResult(payload) {
3012
3071
  const next = payload && payload.ok && Array.isArray(payload.sessions)
3013
3072
  ? { ...payload, sessions: decorateSessionsWithRuntimeState(payload.sessions) }
@@ -3020,7 +3079,7 @@ function sendSessionSnapshotBroadcast() {
3020
3079
  const yeaftDir = ctx.CONFIG?.yeaftDir;
3021
3080
  if (!yeaftDir) return;
3022
3081
  const sessions = decorateSessionsWithRuntimeState(snapshotSessions(yeaftDir));
3023
- sendSessionEvent({ type: 'session_list_updated', sessions });
3082
+ sendSessionEvent({ type: 'session_list_updated', sessions, projects: loadProjects(yeaftDir) });
3024
3083
  } catch (err) {
3025
3084
  console.warn('[Yeaft] sendSessionSnapshotBroadcast failed:', err?.message || err);
3026
3085
  }
@@ -3059,6 +3118,7 @@ function sessionErrorPayload(err) {
3059
3118
  let code = 'unknown';
3060
3119
  if (err instanceof SessionCrudError) code = err.code;
3061
3120
  else if (err instanceof SessionConfigError) code = err.code;
3121
+ else if (err instanceof ProjectStoreError) code = err.code;
3062
3122
  return {
3063
3123
  code,
3064
3124
  sessionId: err && err.sessionId,
@@ -3071,12 +3131,49 @@ export function handleYeaftListSessions(msg) {
3071
3131
  try {
3072
3132
  const yeaftDir = ctx.CONFIG?.yeaftDir;
3073
3133
  const groups = snapshotSessions(yeaftDir);
3074
- sendSessionCrudResult({ op: 'list', requestId, ok: true, sessions: groups });
3134
+ sendSessionCrudResult({ op: 'list', requestId, ok: true, sessions: groups, projects: loadProjects(yeaftDir) });
3075
3135
  } catch (err) {
3076
3136
  sendSessionCrudResult({ op: 'list', requestId, ok: false, error: sessionErrorPayload(err) });
3077
3137
  }
3078
3138
  }
3079
3139
 
3140
+ export function handleYeaftProjectMutation(msg) {
3141
+ const requestId = msg && msg.requestId;
3142
+ const op = msg && msg.op;
3143
+ try {
3144
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
3145
+ let result = null;
3146
+ if (op === 'create') result = createProject(yeaftDir, msg.name);
3147
+ else if (op === 'rename') result = renameProject(yeaftDir, msg.projectId, msg.name);
3148
+ else if (op === 'delete') result = deleteProject(yeaftDir, msg.projectId);
3149
+ else if (op === 'move_session') {
3150
+ if (!snapshotSessions(yeaftDir).some(row => row.id === msg.sessionId)) {
3151
+ throw new ProjectStoreError('session_not_found', 'Session not found');
3152
+ }
3153
+ result = moveSessionToProject(yeaftDir, msg.sessionId, msg.projectId || null);
3154
+ } else {
3155
+ throw new ProjectStoreError('invalid_op', 'Unknown Project operation');
3156
+ }
3157
+ sendSessionEvent({
3158
+ type: 'project_mutation_result',
3159
+ requestId,
3160
+ op,
3161
+ ok: true,
3162
+ result,
3163
+ projects: loadProjects(yeaftDir),
3164
+ }, { requestId });
3165
+ sendSessionSnapshotBroadcast();
3166
+ } catch (err) {
3167
+ sendSessionEvent({
3168
+ type: 'project_mutation_result',
3169
+ requestId,
3170
+ op,
3171
+ ok: false,
3172
+ error: sessionErrorPayload(err),
3173
+ }, { requestId });
3174
+ }
3175
+ }
3176
+
3080
3177
  export function handleYeaftCreateSession(msg) {
3081
3178
  const requestId = msg && msg.requestId;
3082
3179
  const payload = (msg && msg.payload) || {};
@@ -3260,6 +3357,7 @@ export function handleYeaftDeleteSession(msg) {
3260
3357
  try {
3261
3358
  const yeaftDir = ctx.CONFIG?.yeaftDir;
3262
3359
  const result = deleteSession(yeaftDir, sessionId);
3360
+ removeSessionFromProjects(yeaftDir, sessionId);
3263
3361
  ctx.assetOutbox?.removeSession(sessionId);
3264
3362
  // Cascade: remove every persisted message stamped with this group id.
3265
3363
  // Hard delete (per user spec): no soft-archive, the bytes are gone.
@@ -3978,6 +4076,7 @@ function handleEngineEvent(event, hctx) {
3978
4076
  type: 'memory_used',
3979
4077
  turnId: event.turnId,
3980
4078
  loaded: event.loaded || [],
4079
+ meta: event.meta || null,
3981
4080
  }, envelope);
3982
4081
  break;
3983
4082
 
@@ -5054,6 +5153,18 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5054
5153
  envelope: inboundEnvelope,
5055
5154
  threadId,
5056
5155
  });
5156
+ if (queryOpts) {
5157
+ const projectContext = await sharedProjectContext(ctx.CONFIG?.yeaftDir, sessionId, {
5158
+ language: session?.config?.language,
5159
+ tokenBudget: Math.max(512, Math.floor((session?.config?.messageTokenBudget || 32768) / 8)),
5160
+ });
5161
+ if (projectContext) {
5162
+ const sharedBlock = `[Project Shared Context]\nRead-only memory summaries from sibling Sessions in the same Project. Preserve each source Session identity.\n\n${projectContext}`;
5163
+ queryOpts.sessionAnnouncement = queryOpts.sessionAnnouncement
5164
+ ? `${queryOpts.sessionAnnouncement}\n\n${sharedBlock}`
5165
+ : sharedBlock;
5166
+ }
5167
+ }
5057
5168
  let turnSessionMeta = null;
5058
5169
  try { turnSessionMeta = sessionCoordinator?.group?.getMeta?.() || null; } catch { turnSessionMeta = null; }
5059
5170
  const projectRuntime = getProjectRuntimeForTurn(turnSessionMeta);
@@ -7171,6 +7282,8 @@ export async function handleYeaftMcpReload(msg = {}) {
7171
7282
  }
7172
7283
 
7173
7284
  export const __testHooks = {
7285
+ loadProjects,
7286
+ sharedProjectContext,
7174
7287
  loadVisibleGroupHistoryPage,
7175
7288
  projectVisibleHistoryChunkMessages,
7176
7289
  persistInboundMessageOnceByMsgId,