@yeaft/webchat-agent 0.1.864 → 0.1.866
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/connection/message-router.js +5 -1
- package/history.js +29 -0
- package/package.json +1 -1
- package/providers/copilot-models.js +126 -12
- package/providers/copilot.js +7 -4
- package/yeaft/init.js +11 -0
- package/yeaft/memory/segment.js +1 -1
- package/yeaft/memory/store-v2.js +41 -2
- package/yeaft/migrate/sessions-v1.js +276 -0
- package/yeaft/sessions/coordinator.js +145 -0
- package/yeaft/sessions/pre-flow.js +212 -0
- package/yeaft/sessions/session-store.js +281 -0
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
handleGitStatus, handleGitDiff, handleGitAdd, handleGitReset, handleGitRestore, handleGitCommit, handleGitPush,
|
|
19
19
|
handleFileSearch, handleCreateFile, handleDeleteFiles, handleMoveFiles, handleCopyFiles, handleUploadToDir, handleTransferFiles
|
|
20
20
|
} from '../workbench.js';
|
|
21
|
-
import { handleListHistorySessions, handleListFolders } from '../history.js';
|
|
21
|
+
import { handleListHistorySessions, handleListFolders, handleListModels } from '../history.js';
|
|
22
22
|
import {
|
|
23
23
|
createConversation, resumeConversation, deleteConversation,
|
|
24
24
|
handleRefreshConversation, handleCancelExecution,
|
|
@@ -112,6 +112,10 @@ export async function handleMessage(msg) {
|
|
|
112
112
|
await handleListFolders(msg);
|
|
113
113
|
break;
|
|
114
114
|
|
|
115
|
+
case 'list_models':
|
|
116
|
+
await handleListModels(msg);
|
|
117
|
+
break;
|
|
118
|
+
|
|
115
119
|
case 'transfer_files':
|
|
116
120
|
await handleTransferFiles(msg);
|
|
117
121
|
break;
|
package/history.js
CHANGED
|
@@ -252,3 +252,32 @@ export async function handleListFolders(msg) {
|
|
|
252
252
|
});
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
|
+
|
|
256
|
+
// 列出指定 provider 下可选 model
|
|
257
|
+
export async function handleListModels(msg) {
|
|
258
|
+
const { requestId, _requestClientId, provider } = msg;
|
|
259
|
+
const providerName = provider || DEFAULT_PROVIDER;
|
|
260
|
+
try {
|
|
261
|
+
const driver = getProvider(providerName);
|
|
262
|
+
let models = [];
|
|
263
|
+
if (typeof driver.listModels === 'function') {
|
|
264
|
+
models = await driver.listModels();
|
|
265
|
+
}
|
|
266
|
+
ctx.sendToServer({
|
|
267
|
+
type: 'models_list',
|
|
268
|
+
requestId,
|
|
269
|
+
_requestClientId,
|
|
270
|
+
provider: providerName,
|
|
271
|
+
models: Array.isArray(models) ? models : [],
|
|
272
|
+
});
|
|
273
|
+
} catch (e) {
|
|
274
|
+
ctx.sendToServer({
|
|
275
|
+
type: 'models_list',
|
|
276
|
+
requestId,
|
|
277
|
+
_requestClientId,
|
|
278
|
+
provider: providerName,
|
|
279
|
+
models: [],
|
|
280
|
+
error: e.message,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,131 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Copilot model list.
|
|
3
|
+
*
|
|
4
|
+
* The Copilot CLI accepts `--model <id>` and the agent-facing API exposes
|
|
5
|
+
* `GET https://api.githubcopilot.com/models` — the same endpoint VS Code's
|
|
6
|
+
* model picker uses. We hit it on demand (cached in-process for 10 min),
|
|
7
|
+
* filter to picker-enabled chat models, and fall back to a curated static
|
|
8
|
+
* list if the network call fails (so the UI never shows an empty picker).
|
|
9
|
+
*
|
|
10
|
+
* Auth re-uses the existing `agent/yeaft/llm/credentials/github-copilot.js`
|
|
11
|
+
* credential pipeline — gh CLI / env / persisted OAuth all work; the user
|
|
12
|
+
* does not need to paste an API key as long as Copilot CLI is logged in.
|
|
6
13
|
*/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
|
|
15
|
+
import { getApiToken, copilotRequestHeaders, validateRawToken, resolveRawToken } from '../yeaft/llm/credentials/github-copilot.js';
|
|
16
|
+
import { readFile } from 'fs/promises';
|
|
17
|
+
import { homedir } from 'os';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
|
|
20
|
+
// Curated fallback — small, but covers the common picks if /models is down or
|
|
21
|
+
// the user has no Copilot auth available yet.
|
|
22
|
+
export const FALLBACK_COPILOT_MODELS = Object.freeze([
|
|
23
|
+
{ id: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5', vendor: 'Anthropic' },
|
|
24
|
+
{ id: 'claude-sonnet-4', label: 'Claude Sonnet 4', vendor: 'Anthropic' },
|
|
25
|
+
{ id: 'claude-opus-4.1', label: 'Claude Opus 4.1', vendor: 'Anthropic' },
|
|
26
|
+
{ id: 'gpt-5', label: 'GPT-5', vendor: 'OpenAI' },
|
|
27
|
+
{ id: 'gpt-5-mini', label: 'GPT-5 Mini', vendor: 'OpenAI' },
|
|
28
|
+
{ id: 'gpt-4.1', label: 'GPT-4.1', vendor: 'OpenAI' },
|
|
29
|
+
{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', vendor: 'Google' },
|
|
15
30
|
]);
|
|
16
31
|
|
|
17
32
|
export const DEFAULT_COPILOT_MODEL = 'claude-sonnet-4.5';
|
|
33
|
+
|
|
34
|
+
const MODELS_ENDPOINT = 'https://api.githubcopilot.com/models';
|
|
35
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
36
|
+
const COPILOT_CLI_CONFIG = join(homedir(), '.copilot', 'config.json');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Last-resort token source: the Copilot CLI itself caches an OAuth token at
|
|
40
|
+
* ~/.copilot/config.json after `copilot login`. If the standard yeaft
|
|
41
|
+
* credential pipeline (env / gh CLI / persisted device flow) didn't surface
|
|
42
|
+
* a token, we re-use the CLI's. Same auth surface — no extra perm needed.
|
|
43
|
+
*/
|
|
44
|
+
async function _resolveCopilotCliToken() {
|
|
45
|
+
try {
|
|
46
|
+
const raw = await readFile(COPILOT_CLI_CONFIG, 'utf8');
|
|
47
|
+
// Copilot CLI's config.json starts with `//` comments. Strip line comments
|
|
48
|
+
// before JSON.parse — the file otherwise has no string-context `//`.
|
|
49
|
+
const cleaned = raw.split('\n').filter(l => !l.trim().startsWith('//')).join('\n');
|
|
50
|
+
const cfg = JSON.parse(cleaned);
|
|
51
|
+
const tokens = cfg?.copilotTokens && typeof cfg.copilotTokens === 'object' ? cfg.copilotTokens : null;
|
|
52
|
+
if (!tokens) return null;
|
|
53
|
+
for (const tok of Object.values(tokens)) {
|
|
54
|
+
if (typeof tok === 'string' && validateRawToken(tok).valid) return tok;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function _resolveBearerToken() {
|
|
63
|
+
// The /models endpoint accepts the GitHub OAuth token directly as a Bearer
|
|
64
|
+
// — no token exchange needed (unlike the chat-completion endpoints). So we
|
|
65
|
+
// prefer the raw OAuth from the standard yeaft pipeline first, then fall
|
|
66
|
+
// back to the Copilot CLI's own cached OAuth at ~/.copilot/config.json.
|
|
67
|
+
try {
|
|
68
|
+
const raw = await resolveRawToken();
|
|
69
|
+
if (raw?.token) return raw.token;
|
|
70
|
+
} catch { /* fall through */ }
|
|
71
|
+
const cliRaw = await _resolveCopilotCliToken();
|
|
72
|
+
if (cliRaw) return cliRaw;
|
|
73
|
+
// Last resort: try exchanged token (works for chat endpoints; may also work
|
|
74
|
+
// if /models gained that auth path in future).
|
|
75
|
+
const cred = await getApiToken();
|
|
76
|
+
return cred?.token || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let _cache = null; // { models, fetchedAt }
|
|
80
|
+
let _inflight = null; // dedupes concurrent cold-cache calls
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns a list of `{id, label, vendor, preview, family}` records,
|
|
84
|
+
* picker-enabled chat models only. Cached for 10 minutes. Never throws —
|
|
85
|
+
* falls back to the static list on any error (including network timeout).
|
|
86
|
+
*/
|
|
87
|
+
export async function listCopilotModels({ force = false } = {}) {
|
|
88
|
+
if (!force && _cache && Date.now() - _cache.fetchedAt < CACHE_TTL_MS) {
|
|
89
|
+
return _cache.models.slice();
|
|
90
|
+
}
|
|
91
|
+
if (_inflight) return (await _inflight).slice();
|
|
92
|
+
_inflight = (async () => {
|
|
93
|
+
const ac = new AbortController();
|
|
94
|
+
const timer = setTimeout(() => ac.abort(), 5000);
|
|
95
|
+
try {
|
|
96
|
+
const token = await _resolveBearerToken();
|
|
97
|
+
if (!token) return FALLBACK_COPILOT_MODELS.slice();
|
|
98
|
+
const res = await fetch(MODELS_ENDPOINT, {
|
|
99
|
+
signal: ac.signal,
|
|
100
|
+
headers: { ...copilotRequestHeaders({ isAgentTurn: false }), Authorization: `Bearer ${token}` },
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) return FALLBACK_COPILOT_MODELS.slice();
|
|
103
|
+
const body = await res.json();
|
|
104
|
+
const data = Array.isArray(body?.data) ? body.data : [];
|
|
105
|
+
const models = data
|
|
106
|
+
.filter(m => m && m.model_picker_enabled && m.capabilities?.type === 'chat')
|
|
107
|
+
.map(m => ({
|
|
108
|
+
id: m.id,
|
|
109
|
+
label: m.name || m.id,
|
|
110
|
+
vendor: m.vendor || '',
|
|
111
|
+
preview: !!m.preview,
|
|
112
|
+
family: m.capabilities?.family || '',
|
|
113
|
+
}));
|
|
114
|
+
if (!models.length) return FALLBACK_COPILOT_MODELS.slice();
|
|
115
|
+
_cache = { models, fetchedAt: Date.now() };
|
|
116
|
+
return models.slice();
|
|
117
|
+
} catch {
|
|
118
|
+
return FALLBACK_COPILOT_MODELS.slice();
|
|
119
|
+
} finally {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
try {
|
|
124
|
+
return (await _inflight).slice();
|
|
125
|
+
} finally {
|
|
126
|
+
_inflight = null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Tests only. */
|
|
131
|
+
export function _resetCopilotModelsCacheForTests() { _cache = null; _inflight = null; }
|
package/providers/copilot.js
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'path';
|
|
|
6
6
|
import { DatabaseSync } from 'node:sqlite';
|
|
7
7
|
import ctx from '../context.js';
|
|
8
8
|
import { AcpClient } from './acp-client.js';
|
|
9
|
-
import {
|
|
9
|
+
import { listCopilotModels, DEFAULT_COPILOT_MODEL } from './copilot-models.js';
|
|
10
10
|
|
|
11
11
|
export const name = 'copilot';
|
|
12
12
|
|
|
@@ -582,9 +582,12 @@ function _knownCopilotTools() {
|
|
|
582
582
|
];
|
|
583
583
|
}
|
|
584
584
|
|
|
585
|
-
/**
|
|
586
|
-
|
|
587
|
-
|
|
585
|
+
/**
|
|
586
|
+
* Exported for the model picker UI. Async — hits Copilot's /models endpoint
|
|
587
|
+
* (cached) and falls back to a static list if auth/network fails.
|
|
588
|
+
*/
|
|
589
|
+
export async function listModels() {
|
|
590
|
+
return await listCopilotModels();
|
|
588
591
|
}
|
|
589
592
|
|
|
590
593
|
export default { name, capabilities, start, sendInput, abort, clear, listFolders, listSessions, loadHistory, listModels, respondToPermissionRequest };
|
package/yeaft/init.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
import { existsSync, mkdirSync, writeFileSync, accessSync, constants } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { homedir } from 'os';
|
|
11
|
+
// NOTE: migrateSessionsV1 is imported by tests directly from
|
|
12
|
+
// './migrate/sessions-v1.js' — not wired into initYeaftDir() yet (Phase 2
|
|
13
|
+
// will activate it once the runtime reads from sessions/).
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Check if an error is a permission error (EACCES or EPERM).
|
|
@@ -78,6 +81,7 @@ const SUBDIRS = [
|
|
|
78
81
|
'chat/cold',
|
|
79
82
|
'chat/blobs',
|
|
80
83
|
'groups',
|
|
84
|
+
'sessions',
|
|
81
85
|
'memory/entries',
|
|
82
86
|
'tasks',
|
|
83
87
|
'skills',
|
|
@@ -210,5 +214,12 @@ export function initYeaftDir(dir) {
|
|
|
210
214
|
created.push(mcpExamplePath);
|
|
211
215
|
}
|
|
212
216
|
|
|
217
|
+
// NOTE: sessions-v1 migration (collapse groups/ + chats/ → sessions/) is
|
|
218
|
+
// intentionally NOT wired here yet — Phase 1 ships the session-store +
|
|
219
|
+
// migration script + scope vocab as foundation only. Activating the
|
|
220
|
+
// migration before the runtime reads from sessions/ would move data out
|
|
221
|
+
// from under the live group/chat code paths. Phase 2 flips the runtime
|
|
222
|
+
// and then hooks `migrateSessionsV1(root)` here.
|
|
223
|
+
|
|
213
224
|
return { dir: root, created, writable, warnings };
|
|
214
225
|
}
|
package/yeaft/memory/segment.js
CHANGED
|
@@ -51,7 +51,7 @@ export const KIND_VALUES = new Set([
|
|
|
51
51
|
'fact', 'preference', 'decision', 'lesson', 'relation', 'goal', 'context',
|
|
52
52
|
]);
|
|
53
53
|
|
|
54
|
-
const SCOPE_RE = /^(user|group\/[\w-]+(?:\/(?:user|vp\/[\w-]+|feature\/[\w-]+|topic\/[\w-]+(?:\/[\w-]+)?))?|chat\/[\w-]+(?:\/vp\/[\w-]+)?)$/;
|
|
54
|
+
const SCOPE_RE = /^(user|group\/[\w-]+(?:\/(?:user|vp\/[\w-]+|feature\/[\w-]+|topic\/[\w-]+(?:\/[\w-]+)?))?|chat\/[\w-]+(?:\/vp\/[\w-]+)?|session\/[\w-]+(?:\/vp\/[\w-]+)?)$/;
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* Compute a stable id from segment content. Same body + scope + kind →
|
package/yeaft/memory/store-v2.js
CHANGED
|
@@ -64,9 +64,11 @@ export const SCOPE_KINDS = Object.freeze([
|
|
|
64
64
|
'group-topic',
|
|
65
65
|
'chat',
|
|
66
66
|
'chat-vp',
|
|
67
|
+
'session',
|
|
68
|
+
'session-vp',
|
|
67
69
|
]);
|
|
68
70
|
|
|
69
|
-
/** @typedef {'user'|'group'|'group-user'|'group-vp'|'group-feature'|'group-topic'|'chat'|'chat-vp'} ScopeKind */
|
|
71
|
+
/** @typedef {'user'|'group'|'group-user'|'group-vp'|'group-feature'|'group-topic'|'chat'|'chat-vp'|'session'|'session-vp'} ScopeKind */
|
|
70
72
|
|
|
71
73
|
/**
|
|
72
74
|
* @typedef {Object} Scope
|
|
@@ -136,6 +138,18 @@ export function scopeDir(scope) {
|
|
|
136
138
|
assertSafeSegment(scope.id, 'chat-vp.id');
|
|
137
139
|
return `chat/${scope.chatId}/vp/${scope.id}`;
|
|
138
140
|
}
|
|
141
|
+
case 'session': {
|
|
142
|
+
if (!scope.id) throw new Error('scopeDir: session scope requires id');
|
|
143
|
+
assertSafeSegment(scope.id, 'session.id');
|
|
144
|
+
return `session/${scope.id}`;
|
|
145
|
+
}
|
|
146
|
+
case 'session-vp': {
|
|
147
|
+
if (!scope.sessionId) throw new Error('scopeDir: session-vp scope requires sessionId');
|
|
148
|
+
if (!scope.id) throw new Error('scopeDir: session-vp scope requires id');
|
|
149
|
+
assertSafeSegment(scope.sessionId, 'session-vp.sessionId');
|
|
150
|
+
assertSafeSegment(scope.id, 'session-vp.id');
|
|
151
|
+
return `session/${scope.sessionId}/vp/${scope.id}`;
|
|
152
|
+
}
|
|
139
153
|
default:
|
|
140
154
|
throw new Error(`scopeDir: unknown kind ${JSON.stringify(scope.kind)}`);
|
|
141
155
|
}
|
|
@@ -201,7 +215,7 @@ export function isValidTopic(scope) {
|
|
|
201
215
|
*/
|
|
202
216
|
export function isVpForeign(relPath, currentVpId) {
|
|
203
217
|
if (!relPath || !currentVpId) return false;
|
|
204
|
-
const m = /^(?:group|chat)\/[^/]+\/vp\/([^/]+)(?:\/|$)/.exec(relPath);
|
|
218
|
+
const m = /^(?:group|chat|session)\/[^/]+\/vp\/([^/]+)(?:\/|$)/.exec(relPath);
|
|
205
219
|
if (!m) return false;
|
|
206
220
|
return m[1] !== currentVpId;
|
|
207
221
|
}
|
|
@@ -564,6 +578,31 @@ export async function listScopes(opts = {}) {
|
|
|
564
578
|
}
|
|
565
579
|
}
|
|
566
580
|
|
|
581
|
+
// session/<s>/ and session/<s>/vp/<v>/
|
|
582
|
+
const sessionRoot = join(root, 'session');
|
|
583
|
+
let sessions;
|
|
584
|
+
try { sessions = await fsp.readdir(sessionRoot, { withFileTypes: true }); }
|
|
585
|
+
catch (err) {
|
|
586
|
+
if (err && err.code === 'ENOENT') sessions = [];
|
|
587
|
+
else throw err;
|
|
588
|
+
}
|
|
589
|
+
for (const sent of sessions) {
|
|
590
|
+
if (!sent.isDirectory()) continue;
|
|
591
|
+
if (sent.name.startsWith('.')) continue;
|
|
592
|
+
if (!isSafeId(sent.name)) continue;
|
|
593
|
+
const s = sent.name;
|
|
594
|
+
out.push({ kind: 'session', id: s });
|
|
595
|
+
const vpDir = join(sessionRoot, s, 'vp');
|
|
596
|
+
let vps;
|
|
597
|
+
try { vps = await fsp.readdir(vpDir, { withFileTypes: true }); }
|
|
598
|
+
catch { vps = []; }
|
|
599
|
+
for (const vent of vps) {
|
|
600
|
+
if (!vent.isDirectory()) continue;
|
|
601
|
+
if (!isSafeId(vent.name)) continue;
|
|
602
|
+
out.push({ kind: 'session-vp', sessionId: s, id: vent.name });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
567
606
|
return out;
|
|
568
607
|
}
|
|
569
608
|
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate/sessions-v1.js — One-shot migration: collapse groups/ + chats/ → sessions/
|
|
3
|
+
*
|
|
4
|
+
* Idempotent. Marks completion with a sentinel file
|
|
5
|
+
* `<yeaftDir>/.session-migration-v1.done`.
|
|
6
|
+
*
|
|
7
|
+
* Migrates:
|
|
8
|
+
* 1. ~/.yeaft/groups/<g>/ → ~/.yeaft/sessions/<g>/
|
|
9
|
+
* group.json → meta.json with shape:
|
|
10
|
+
* { id, vpIds: roster, displayName: name, workDir, createdAt,
|
|
11
|
+
* lastTurnAt: null, archivedAt? }
|
|
12
|
+
* 2. ~/.yeaft/chats/<c>/ → ~/.yeaft/sessions/<c>/
|
|
13
|
+
* chat.json → meta.json with vpIds: [vpId]
|
|
14
|
+
* 3. ~/.yeaft/memory/group/<g>/ → ~/.yeaft/memory/session/<g>/
|
|
15
|
+
* 4. ~/.yeaft/memory/chat/<c>/ → ~/.yeaft/memory/session/<c>/
|
|
16
|
+
* Rewrites front-matter `scope:` fields from group/<id> / chat/<id>
|
|
17
|
+
* → session/<id>.
|
|
18
|
+
* 5. ~/.yeaft/memory/groups/<g>/ams.json → ~/.yeaft/memory/sessions/<g>/ams.json
|
|
19
|
+
*
|
|
20
|
+
* Collision policy: if `sessions/<x>` already exists, log + skip (we assume
|
|
21
|
+
* a prior partial run already moved it). If both groups/<x> and chats/<x>
|
|
22
|
+
* exist with the same id, bail loudly — that shouldn't happen because
|
|
23
|
+
* group ids are `grp_*` and chat ids are `chat_*`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
existsSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
readdirSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
renameSync,
|
|
32
|
+
statSync,
|
|
33
|
+
unlinkSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
} from 'fs';
|
|
36
|
+
import { join } from 'path';
|
|
37
|
+
|
|
38
|
+
const SENTINEL = '.session-migration-v1.done';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run the v1 sessions migration. No-op when sentinel exists.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} yeaftDir
|
|
44
|
+
* @returns {{ migrated: boolean, moved: number, warnings: string[] }}
|
|
45
|
+
*/
|
|
46
|
+
export function migrateSessionsV1(yeaftDir) {
|
|
47
|
+
const warnings = [];
|
|
48
|
+
if (!yeaftDir || !existsSync(yeaftDir)) {
|
|
49
|
+
return { migrated: false, moved: 0, warnings: ['yeaftDir missing'] };
|
|
50
|
+
}
|
|
51
|
+
const sentinel = join(yeaftDir, SENTINEL);
|
|
52
|
+
if (existsSync(sentinel)) {
|
|
53
|
+
return { migrated: false, moved: 0, warnings: [] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const sessionsRoot = join(yeaftDir, 'sessions');
|
|
57
|
+
const groupsRoot = join(yeaftDir, 'groups');
|
|
58
|
+
const chatsRoot = join(yeaftDir, 'chats');
|
|
59
|
+
const memoryRoot = join(yeaftDir, 'memory');
|
|
60
|
+
const memSessionRoot = join(memoryRoot, 'session');
|
|
61
|
+
const memSessionsAmsRoot = join(memoryRoot, 'sessions');
|
|
62
|
+
|
|
63
|
+
if (!existsSync(sessionsRoot)) mkdirSync(sessionsRoot, { recursive: true });
|
|
64
|
+
if (!existsSync(memSessionRoot)) mkdirSync(memSessionRoot, { recursive: true });
|
|
65
|
+
if (!existsSync(memSessionsAmsRoot)) mkdirSync(memSessionsAmsRoot, { recursive: true });
|
|
66
|
+
|
|
67
|
+
// ID collision check
|
|
68
|
+
const groupIds = listDirs(groupsRoot);
|
|
69
|
+
const chatIds = listDirs(chatsRoot);
|
|
70
|
+
const overlap = groupIds.filter((id) => chatIds.includes(id));
|
|
71
|
+
if (overlap.length > 0) {
|
|
72
|
+
throw new Error(`sessions migration: id collision between groups/ and chats/: ${overlap.join(',')}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let moved = 0;
|
|
76
|
+
|
|
77
|
+
// 0. Reconcile any half-migrated sessions/<id>/ from a prior crash:
|
|
78
|
+
// a leftover group.json/chat.json with no meta.json means the rename
|
|
79
|
+
// succeeded but the rewrite didn't. Repair before moving on so the
|
|
80
|
+
// session is usable post-migration.
|
|
81
|
+
for (const id of listDirs(sessionsRoot)) {
|
|
82
|
+
const dst = join(sessionsRoot, id);
|
|
83
|
+
if (existsSync(join(dst, 'meta.json'))) continue;
|
|
84
|
+
if (existsSync(join(dst, 'group.json'))) {
|
|
85
|
+
rewriteGroupMetaToSessionMeta(dst, warnings);
|
|
86
|
+
warnings.push(`reconciled partial migration at sessions/${id}`);
|
|
87
|
+
} else if (existsSync(join(dst, 'chat.json'))) {
|
|
88
|
+
rewriteChatMetaToSessionMeta(dst, warnings);
|
|
89
|
+
warnings.push(`reconciled partial migration at sessions/${id}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 1. groups/<g>/ → sessions/<g>/
|
|
94
|
+
for (const id of groupIds) {
|
|
95
|
+
const src = join(groupsRoot, id);
|
|
96
|
+
const dst = join(sessionsRoot, id);
|
|
97
|
+
if (existsSync(dst)) {
|
|
98
|
+
// Partial-run reconcile: if meta.json wasn't written yet, retry the
|
|
99
|
+
// rewrite from the leftover group.json at the destination. Avoids
|
|
100
|
+
// permanently broken sessions when the prior run crashed between
|
|
101
|
+
// renameSync and rewriteGroupMetaToSessionMeta.
|
|
102
|
+
if (!existsSync(join(dst, 'meta.json')) && existsSync(join(dst, 'group.json'))) {
|
|
103
|
+
rewriteGroupMetaToSessionMeta(dst, warnings);
|
|
104
|
+
warnings.push(`reconciled partial migration at sessions/${id}`);
|
|
105
|
+
} else {
|
|
106
|
+
warnings.push(`sessions/${id} already exists; skipping groups/${id}`);
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
renameSync(src, dst);
|
|
111
|
+
rewriteGroupMetaToSessionMeta(dst, warnings);
|
|
112
|
+
moved++;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 2. chats/<c>/ → sessions/<c>/
|
|
116
|
+
for (const id of chatIds) {
|
|
117
|
+
const src = join(chatsRoot, id);
|
|
118
|
+
const dst = join(sessionsRoot, id);
|
|
119
|
+
if (existsSync(dst)) {
|
|
120
|
+
if (!existsSync(join(dst, 'meta.json')) && existsSync(join(dst, 'chat.json'))) {
|
|
121
|
+
rewriteChatMetaToSessionMeta(dst, warnings);
|
|
122
|
+
warnings.push(`reconciled partial migration at sessions/${id}`);
|
|
123
|
+
} else {
|
|
124
|
+
warnings.push(`sessions/${id} already exists; skipping chats/${id}`);
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
renameSync(src, dst);
|
|
129
|
+
rewriteChatMetaToSessionMeta(dst, warnings);
|
|
130
|
+
moved++;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 3+4. memory/{group,chat}/<id>/ → memory/session/<id>/
|
|
134
|
+
for (const family of ['group', 'chat']) {
|
|
135
|
+
const root = join(memoryRoot, family);
|
|
136
|
+
if (!existsSync(root)) continue;
|
|
137
|
+
for (const id of listDirs(root)) {
|
|
138
|
+
const src = join(root, id);
|
|
139
|
+
const dst = join(memSessionRoot, id);
|
|
140
|
+
if (existsSync(dst)) {
|
|
141
|
+
warnings.push(`memory/session/${id} already exists; skipping memory/${family}/${id}`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
renameSync(src, dst);
|
|
145
|
+
rewriteSegmentScopes(dst, family, id, warnings);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 5. memory/groups/<g>/ams.json → memory/sessions/<g>/ams.json
|
|
150
|
+
const amsGroupRoot = join(memoryRoot, 'groups');
|
|
151
|
+
if (existsSync(amsGroupRoot)) {
|
|
152
|
+
for (const id of listDirs(amsGroupRoot)) {
|
|
153
|
+
const srcDir = join(amsGroupRoot, id);
|
|
154
|
+
const dstDir = join(memSessionsAmsRoot, id);
|
|
155
|
+
if (existsSync(dstDir)) {
|
|
156
|
+
warnings.push(`memory/sessions/${id} already exists; skipping memory/groups/${id}`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
renameSync(srcDir, dstDir);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const amsChatRoot = join(memoryRoot, 'chats');
|
|
163
|
+
if (existsSync(amsChatRoot)) {
|
|
164
|
+
for (const id of listDirs(amsChatRoot)) {
|
|
165
|
+
const srcDir = join(amsChatRoot, id);
|
|
166
|
+
const dstDir = join(memSessionsAmsRoot, id);
|
|
167
|
+
if (existsSync(dstDir)) {
|
|
168
|
+
warnings.push(`memory/sessions/${id} already exists; skipping memory/chats/${id}`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
renameSync(srcDir, dstDir);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 6. sentinel
|
|
176
|
+
//
|
|
177
|
+
// PHASE 2 TODO: the SQLite FTS index (memory/index-db.js) still has rows
|
|
178
|
+
// pointing at group/<id> and chat/<id> scopes after this migration. When
|
|
179
|
+
// Phase 2 activates this migration from initYeaftDir(), it MUST also
|
|
180
|
+
// delete or rebuild the FTS index so the new session/<id> scopes get
|
|
181
|
+
// re-indexed. Preflow recall is broken until then.
|
|
182
|
+
writeFileSync(sentinel, JSON.stringify({
|
|
183
|
+
version: 1,
|
|
184
|
+
migratedAt: new Date().toISOString(),
|
|
185
|
+
moved,
|
|
186
|
+
warnings,
|
|
187
|
+
}, null, 2), 'utf8');
|
|
188
|
+
|
|
189
|
+
return { migrated: true, moved, warnings };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function listDirs(root) {
|
|
193
|
+
if (!root || !existsSync(root)) return [];
|
|
194
|
+
const out = [];
|
|
195
|
+
for (const name of readdirSync(root)) {
|
|
196
|
+
if (name.startsWith('.')) continue;
|
|
197
|
+
try {
|
|
198
|
+
if (statSync(join(root, name)).isDirectory()) out.push(name);
|
|
199
|
+
} catch { /* ignore */ }
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function rewriteGroupMetaToSessionMeta(sessionDir, warnings) {
|
|
205
|
+
const oldPath = join(sessionDir, 'group.json');
|
|
206
|
+
const newPath = join(sessionDir, 'meta.json');
|
|
207
|
+
if (!existsSync(oldPath)) {
|
|
208
|
+
if (!existsSync(newPath)) warnings.push(`no group.json in ${sessionDir}`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const raw = JSON.parse(readFileSync(oldPath, 'utf8'));
|
|
213
|
+
const meta = {
|
|
214
|
+
id: raw.id,
|
|
215
|
+
displayName: raw.name || raw.id,
|
|
216
|
+
vpIds: Array.isArray(raw.roster) && raw.roster.length > 0 ? raw.roster.slice() : ['omni'],
|
|
217
|
+
// Preserve defaultVpId so Phase 2 coordinator can resolve "which VP
|
|
218
|
+
// answers when no @-mention". Easy to keep now, hard to backfill later.
|
|
219
|
+
...(raw.defaultVpId ? { defaultVpId: raw.defaultVpId } : {}),
|
|
220
|
+
workDir: typeof raw.workDir === 'string' ? raw.workDir : '',
|
|
221
|
+
createdAt: raw.createdAt || new Date().toISOString(),
|
|
222
|
+
lastTurnAt: null,
|
|
223
|
+
};
|
|
224
|
+
writeFileSync(newPath, JSON.stringify(meta, null, 2), 'utf8');
|
|
225
|
+
try { unlinkSync(oldPath); } catch { /* keep both if cannot delete */ }
|
|
226
|
+
} catch (err) {
|
|
227
|
+
warnings.push(`failed to rewrite ${oldPath}: ${err.message}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function rewriteChatMetaToSessionMeta(sessionDir, warnings) {
|
|
232
|
+
const oldPath = join(sessionDir, 'chat.json');
|
|
233
|
+
const newPath = join(sessionDir, 'meta.json');
|
|
234
|
+
if (!existsSync(oldPath)) {
|
|
235
|
+
if (!existsSync(newPath)) warnings.push(`no chat.json in ${sessionDir}`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const raw = JSON.parse(readFileSync(oldPath, 'utf8'));
|
|
240
|
+
const meta = {
|
|
241
|
+
id: raw.id,
|
|
242
|
+
displayName: raw.displayName || raw.id,
|
|
243
|
+
vpIds: [raw.vpId || 'omni'],
|
|
244
|
+
workDir: typeof raw.workDir === 'string' ? raw.workDir : '',
|
|
245
|
+
createdAt: raw.createdAt || new Date().toISOString(),
|
|
246
|
+
lastTurnAt: raw.lastTurnAt || null,
|
|
247
|
+
};
|
|
248
|
+
writeFileSync(newPath, JSON.stringify(meta, null, 2), 'utf8');
|
|
249
|
+
try { unlinkSync(oldPath); } catch { /* */ }
|
|
250
|
+
} catch (err) {
|
|
251
|
+
warnings.push(`failed to rewrite ${oldPath}: ${err.message}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function rewriteSegmentScopes(sessionMemoryDir, oldFamily, id, warnings) {
|
|
256
|
+
const segDir = join(sessionMemoryDir, 'segments');
|
|
257
|
+
if (!existsSync(segDir)) return;
|
|
258
|
+
let entries;
|
|
259
|
+
try { entries = readdirSync(segDir); } catch { return; }
|
|
260
|
+
const re = new RegExp(`^(\\s*scope:\\s*)${oldFamily}/${escapeRe(id)}\\b`, 'gm');
|
|
261
|
+
for (const name of entries) {
|
|
262
|
+
if (!name.endsWith('.md')) continue;
|
|
263
|
+
const path = join(segDir, name);
|
|
264
|
+
try {
|
|
265
|
+
const src = readFileSync(path, 'utf8');
|
|
266
|
+
const next = src.replace(re, `$1session/${id}`);
|
|
267
|
+
if (next !== src) writeFileSync(path, next, 'utf8');
|
|
268
|
+
} catch (err) {
|
|
269
|
+
warnings.push(`segment rewrite failed ${path}: ${err.message}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function escapeRe(s) {
|
|
275
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
276
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sessions/coordinator.js — Session Coordinator.
|
|
3
|
+
*
|
|
4
|
+
* Ported from groups/coordinator.js as part of the chat+group → session
|
|
5
|
+
* unification. Persists envelopes to a SessionHandle's jsonl-log and
|
|
6
|
+
* dispatches user-text turns to target VPs via pre-flow's selection
|
|
7
|
+
* matrix.
|
|
8
|
+
*
|
|
9
|
+
* N=1 (the old "chat") and N>1 (the old "group") are handled by the same
|
|
10
|
+
* logic — chat is just the degenerate case where pre-flow's @-mention
|
|
11
|
+
* matrix always falls back to the lone roster member.
|
|
12
|
+
*
|
|
13
|
+
* This module does NOT run the engine. It only:
|
|
14
|
+
* 1. Persists the message (via SessionHandle.appendMessage)
|
|
15
|
+
* 2. Asks pre-flow which VPs should respond
|
|
16
|
+
* 3. Calls deliver(vpId, envelope) per target
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { parseMentions, selectRespondingVps } from './pre-flow.js';
|
|
20
|
+
|
|
21
|
+
export { parseMentions };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {import('./session-store.js').SessionHandle} sessionHandle
|
|
25
|
+
* @param {{ deliver?: (vpId:string, envelope:any) => void, perGroupFanOut?: number }} [options]
|
|
26
|
+
*/
|
|
27
|
+
export function createCoordinator(sessionHandle, options = {}) {
|
|
28
|
+
const deliver = options.deliver || (() => {});
|
|
29
|
+
const fanOutCap = options.perGroupFanOut ?? 16;
|
|
30
|
+
|
|
31
|
+
function ingest(input, opts = {}) {
|
|
32
|
+
if (!input || typeof input !== 'object') {
|
|
33
|
+
throw new Error('ingest: input required');
|
|
34
|
+
}
|
|
35
|
+
if (typeof input.text !== 'string') {
|
|
36
|
+
throw new Error('ingest: input.text required (string)');
|
|
37
|
+
}
|
|
38
|
+
const meta = sessionHandle.getMeta();
|
|
39
|
+
if (!meta) throw new Error('session not initialised (call createSession first)');
|
|
40
|
+
|
|
41
|
+
const isRouteForwardInjection = input?.meta?.injectedBy === 'route_forward';
|
|
42
|
+
const fromUser = input.from === 'user'
|
|
43
|
+
|| input.role === 'user'
|
|
44
|
+
|| isRouteForwardInjection;
|
|
45
|
+
const mentions = parseMentions(input.text);
|
|
46
|
+
|
|
47
|
+
const persistInput = {};
|
|
48
|
+
const ephemeral = {};
|
|
49
|
+
for (const [k, v] of Object.entries(input)) {
|
|
50
|
+
if (typeof k === 'string' && k.startsWith('_')) {
|
|
51
|
+
ephemeral[k] = v;
|
|
52
|
+
} else {
|
|
53
|
+
persistInput[k] = v;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
{
|
|
57
|
+
const leaked = Object.keys(persistInput).filter((k) => typeof k === 'string' && k.startsWith('_'));
|
|
58
|
+
if (leaked.length > 0) {
|
|
59
|
+
throw new Error(`coordinator.ingest: ephemeral fields leaked into persisted record: ${leaked.join(', ')}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const stored = sessionHandle.appendMessage({
|
|
63
|
+
...persistInput,
|
|
64
|
+
mentions,
|
|
65
|
+
role: input.role || (fromUser ? 'user' : 'assistant'),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const selection = selectRespondingVps({
|
|
69
|
+
meta,
|
|
70
|
+
fromUser,
|
|
71
|
+
mentions,
|
|
72
|
+
sender: input.from,
|
|
73
|
+
fanOutCap,
|
|
74
|
+
taskMembers: opts.taskMembers,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (selection.reason === 'vp-author-no-text-routing') {
|
|
78
|
+
return {
|
|
79
|
+
message: stored,
|
|
80
|
+
dispatched: [],
|
|
81
|
+
fallback: null,
|
|
82
|
+
errors: [],
|
|
83
|
+
skipped: 'vp-author-no-text-routing',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (selection.reason === 'broadcast') {
|
|
88
|
+
const envelope = makeEnvelope(stored, meta, 'broadcast', ephemeral);
|
|
89
|
+
for (const vpId of selection.dispatched) deliver(vpId, envelope);
|
|
90
|
+
return {
|
|
91
|
+
message: stored,
|
|
92
|
+
dispatched: selection.dispatched,
|
|
93
|
+
fallback: null,
|
|
94
|
+
errors: selection.errors,
|
|
95
|
+
broadcast: true,
|
|
96
|
+
truncatedAtFanOutCap: !!selection.truncatedAtFanOutCap,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (selection.reason === 'mention') {
|
|
101
|
+
for (const vpId of selection.dispatched) {
|
|
102
|
+
deliver(vpId, makeEnvelope(stored, meta, 'mention', ephemeral));
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
message: stored,
|
|
106
|
+
dispatched: selection.dispatched,
|
|
107
|
+
fallback: null,
|
|
108
|
+
errors: selection.errors,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (selection.reason === 'fallback' && selection.fallback) {
|
|
113
|
+
deliver(selection.fallback, makeEnvelope(stored, meta, 'fallback', ephemeral));
|
|
114
|
+
return {
|
|
115
|
+
message: stored,
|
|
116
|
+
dispatched: selection.dispatched,
|
|
117
|
+
fallback: selection.fallback,
|
|
118
|
+
errors: selection.errors,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
message: stored,
|
|
124
|
+
dispatched: [],
|
|
125
|
+
fallback: null,
|
|
126
|
+
errors: selection.errors,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
session: sessionHandle,
|
|
132
|
+
ingest,
|
|
133
|
+
parseMentions,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function makeEnvelope(msg, meta, trigger, ephemeral = {}) {
|
|
138
|
+
return {
|
|
139
|
+
sessionId: meta.id,
|
|
140
|
+
taskId: msg.taskId || null,
|
|
141
|
+
msg,
|
|
142
|
+
trigger,
|
|
143
|
+
...ephemeral,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sessions/pre-flow.js — explicit pre-flow stage for Yeaft (session-scoped).
|
|
3
|
+
*
|
|
4
|
+
* Ported from groups/pre-flow.js as part of the chat+group → session
|
|
5
|
+
* unification. Scope strings use the unified `session/<id>` /
|
|
6
|
+
* `session/<id>/vp/<vp>` shape instead of the legacy
|
|
7
|
+
* `group/<g>` / `chat/<c>` shapes.
|
|
8
|
+
*
|
|
9
|
+
* NOTE: the old groups/pre-flow.js is still in place — callers (web-bridge,
|
|
10
|
+
* coordinator) will switch over in Phase A6/A7. Do not delete the old file
|
|
11
|
+
* until every importer has been migrated.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { runPreflow as runFtsPreflow } from '../memory/preflow.js';
|
|
15
|
+
|
|
16
|
+
/** Matches `@vp-id` where id is [A-Za-z0-9_-]+. Captures the id. */
|
|
17
|
+
const MENTION_RE = /(^|\s)@([A-Za-z0-9_][A-Za-z0-9_-]*)/g;
|
|
18
|
+
|
|
19
|
+
export function parseMentions(text) {
|
|
20
|
+
if (!text || typeof text !== 'string') return [];
|
|
21
|
+
const out = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
MENTION_RE.lastIndex = 0;
|
|
24
|
+
let m;
|
|
25
|
+
while ((m = MENTION_RE.exec(text))) {
|
|
26
|
+
const id = m[2];
|
|
27
|
+
if (seen.has(id)) continue;
|
|
28
|
+
seen.add(id);
|
|
29
|
+
out.push(id);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pure VP-selection for a session. Returns the list of VP ids that should
|
|
36
|
+
* respond to a user turn — mention / @all / fallback to first roster
|
|
37
|
+
* member. (Sessions have no `defaultVpId` field; the first VP in `vpIds`
|
|
38
|
+
* is the fallback.)
|
|
39
|
+
*
|
|
40
|
+
* @param {{
|
|
41
|
+
* meta: { id: string, vpIds: string[] },
|
|
42
|
+
* fromUser: boolean,
|
|
43
|
+
* mentions: string[],
|
|
44
|
+
* sender?: string,
|
|
45
|
+
* fanOutCap?: number,
|
|
46
|
+
* taskMembers?: string[],
|
|
47
|
+
* }} input
|
|
48
|
+
*/
|
|
49
|
+
export function selectRespondingVps(input) {
|
|
50
|
+
const meta = input?.meta;
|
|
51
|
+
if (!meta) {
|
|
52
|
+
return { dispatched: [], fallback: null, errors: [{ error: 'no_session_meta' }], reason: 'no-default' };
|
|
53
|
+
}
|
|
54
|
+
const fanOutCap = Number.isFinite(input.fanOutCap) ? input.fanOutCap : 16;
|
|
55
|
+
const taskMembers = Array.isArray(input.taskMembers) ? input.taskMembers : null;
|
|
56
|
+
const mentions = Array.isArray(input.mentions) ? input.mentions : [];
|
|
57
|
+
const roster = Array.isArray(meta.vpIds) ? meta.vpIds : [];
|
|
58
|
+
|
|
59
|
+
if (!input.fromUser) {
|
|
60
|
+
return {
|
|
61
|
+
dispatched: [],
|
|
62
|
+
fallback: null,
|
|
63
|
+
errors: [],
|
|
64
|
+
reason: 'vp-author-no-text-routing',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (mentions.includes('all')) {
|
|
69
|
+
const expanded = roster.filter((v) => v !== input.sender).slice(0, fanOutCap);
|
|
70
|
+
const scoped = taskMembers ? expanded.filter((v) => taskMembers.includes(v)) : expanded;
|
|
71
|
+
return {
|
|
72
|
+
dispatched: scoped,
|
|
73
|
+
fallback: null,
|
|
74
|
+
errors: [],
|
|
75
|
+
reason: 'broadcast',
|
|
76
|
+
truncatedAtFanOutCap: roster.length - 1 > fanOutCap,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (mentions.length > 0) {
|
|
81
|
+
const dispatched = [];
|
|
82
|
+
const errors = [];
|
|
83
|
+
for (const vpId of mentions) {
|
|
84
|
+
if (!roster.includes(vpId)) {
|
|
85
|
+
errors.push({ vpId, error: 'not_in_roster' });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (taskMembers && !taskMembers.includes(vpId)) {
|
|
89
|
+
errors.push({ vpId, error: 'not_in_task_members' });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!dispatched.includes(vpId)) dispatched.push(vpId);
|
|
93
|
+
}
|
|
94
|
+
return { dispatched, fallback: null, errors, reason: 'mention' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fallback = roster[0] || null;
|
|
98
|
+
if (!fallback) {
|
|
99
|
+
return {
|
|
100
|
+
dispatched: [],
|
|
101
|
+
fallback: null,
|
|
102
|
+
errors: [{ error: 'no_default_vp' }],
|
|
103
|
+
reason: 'no-default',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (taskMembers && !taskMembers.includes(fallback)) {
|
|
107
|
+
return {
|
|
108
|
+
dispatched: [],
|
|
109
|
+
fallback: null,
|
|
110
|
+
errors: [{ vpId: fallback, error: 'not_in_task_members' }],
|
|
111
|
+
reason: 'no-default',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
dispatched: [fallback],
|
|
116
|
+
fallback,
|
|
117
|
+
errors: [],
|
|
118
|
+
reason: 'fallback',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function scopeHeading(scope) {
|
|
123
|
+
if (scope === 'user') return '## Memory: User';
|
|
124
|
+
let m = /^session\/([^/]+)\/vp\/(.+)$/.exec(scope);
|
|
125
|
+
if (m) return `## Memory: VP ${m[2]}`;
|
|
126
|
+
m = /^session\/([^/]+)$/.exec(scope);
|
|
127
|
+
if (m) return `## Memory: Session ${m[1]}`;
|
|
128
|
+
if (scope.startsWith('vp/')) return `## Memory: VP ${scope.slice(3)}`;
|
|
129
|
+
return `## Memory: ${scope}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function formatPickedForInjection(picked) {
|
|
133
|
+
if (!picked || picked.length === 0) return '';
|
|
134
|
+
const byScope = new Map();
|
|
135
|
+
for (const seg of picked) {
|
|
136
|
+
const scope = seg.scope || 'unknown';
|
|
137
|
+
if (!byScope.has(scope)) byScope.set(scope, []);
|
|
138
|
+
byScope.get(scope).push(seg);
|
|
139
|
+
}
|
|
140
|
+
const parts = [];
|
|
141
|
+
for (const [scope, segs] of byScope.entries()) {
|
|
142
|
+
parts.push(scopeHeading(scope));
|
|
143
|
+
for (const s of segs) {
|
|
144
|
+
const body = (s.body || '').trim();
|
|
145
|
+
if (body) parts.push(body);
|
|
146
|
+
}
|
|
147
|
+
parts.push('');
|
|
148
|
+
}
|
|
149
|
+
return parts.join('\n').trim();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Canonical scope list for a session VP turn:
|
|
154
|
+
* ['user', 'session/<id>', 'session/<id>/vp/<vp>']
|
|
155
|
+
*
|
|
156
|
+
* @param {{ sessionId?: string, vpId?: string, extra?: string[] }} ctx
|
|
157
|
+
*/
|
|
158
|
+
export function buildRelevantScopes({ sessionId, vpId, extra } = {}) {
|
|
159
|
+
const scopes = ['user'];
|
|
160
|
+
if (sessionId) {
|
|
161
|
+
scopes.push(`session/${sessionId}`);
|
|
162
|
+
if (vpId) scopes.push(`session/${sessionId}/vp/${vpId}`);
|
|
163
|
+
}
|
|
164
|
+
if (Array.isArray(extra)) {
|
|
165
|
+
for (const s of extra) {
|
|
166
|
+
if (s && !scopes.includes(s)) scopes.push(s);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return scopes;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function runMemoryPreflow(index, opts) {
|
|
173
|
+
if (!index) {
|
|
174
|
+
return { profile: '', entries: [], formatted: '', meta: { skipped: 'no-index' } };
|
|
175
|
+
}
|
|
176
|
+
const userMsg = (opts?.userMsg || '').trim();
|
|
177
|
+
if (!userMsg) {
|
|
178
|
+
return { profile: '', entries: [], formatted: '', meta: { skipped: 'no-user-msg' } };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const relevantScopes = buildRelevantScopes({
|
|
182
|
+
sessionId: opts.sessionId,
|
|
183
|
+
vpId: opts.vpId,
|
|
184
|
+
extra: opts.extraScopes,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const result = runFtsPreflow(index, {
|
|
188
|
+
userMsg,
|
|
189
|
+
relevantScopes,
|
|
190
|
+
ownVpId: opts.vpId || null,
|
|
191
|
+
currentTags: opts.currentTags || [],
|
|
192
|
+
topK: opts.topK,
|
|
193
|
+
budgetTokens: opts.budgetTokens,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const userSeg = (result.picked || []).find((p) => p.scope === 'user');
|
|
197
|
+
const profile = userSeg ? (userSeg.body || '').trim() : '';
|
|
198
|
+
const formatted = formatPickedForInjection(result.picked || []);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
profile,
|
|
202
|
+
entries: result.picked || [],
|
|
203
|
+
formatted,
|
|
204
|
+
meta: {
|
|
205
|
+
keywords: result.keywords,
|
|
206
|
+
ftsQuery: result.ftsQuery,
|
|
207
|
+
pickedTokens: result.pickedTokens,
|
|
208
|
+
droppedCount: result.droppedCount,
|
|
209
|
+
hitCount: (result.hits || []).length,
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-store.js — Per-session persistent store (collapses group + chat).
|
|
3
|
+
*
|
|
4
|
+
* Layout:
|
|
5
|
+
* ~/.yeaft/sessions/<sessionId>/
|
|
6
|
+
* meta.json # { sessionId, vpIds[], displayName, workDir, createdAt, lastTurnAt, archivedAt? }
|
|
7
|
+
* messages/ # JSONL size-rotation log (storage/openLog)
|
|
8
|
+
* 000001.jsonl
|
|
9
|
+
* index.json
|
|
10
|
+
*
|
|
11
|
+
* A session has N≥1 VPs. N=1 is the old "chat"; N>1 is the old "group".
|
|
12
|
+
* The coordinator fan-out is identical for both — N=1 just resolves to
|
|
13
|
+
* one VP turn per ingest.
|
|
14
|
+
*
|
|
15
|
+
* Hard constraint: no @-mention parsing, no dispatch, no engine awareness.
|
|
16
|
+
* Pure persistence.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
existsSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
renameSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
statSync,
|
|
27
|
+
} from 'fs';
|
|
28
|
+
import { join } from 'path';
|
|
29
|
+
import { writeAtomic, openLog } from '../storage/index.js';
|
|
30
|
+
import {
|
|
31
|
+
nextMsgId,
|
|
32
|
+
isReservedVpId,
|
|
33
|
+
ReservedVpIdError,
|
|
34
|
+
validateVpId,
|
|
35
|
+
InvalidVpIdError,
|
|
36
|
+
} from '../groups/ids.js';
|
|
37
|
+
|
|
38
|
+
const META_FILE = 'meta.json';
|
|
39
|
+
const MESSAGES_DIR = 'messages';
|
|
40
|
+
const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Open (or partially create) the directory for a session. Returns a handle
|
|
44
|
+
* even when meta.json is absent — call createSession() to materialise it.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} sessionsRoot
|
|
47
|
+
* @param {string} sessionId
|
|
48
|
+
* @returns {SessionHandle}
|
|
49
|
+
*/
|
|
50
|
+
export function openSession(sessionsRoot, sessionId) {
|
|
51
|
+
if (!sessionId || typeof sessionId !== 'string') {
|
|
52
|
+
throw new Error('openSession: sessionId required (string)');
|
|
53
|
+
}
|
|
54
|
+
if (!SESSION_ID_RE.test(sessionId)) {
|
|
55
|
+
throw new Error(`openSession: invalid sessionId "${sessionId}"`);
|
|
56
|
+
}
|
|
57
|
+
const dir = join(sessionsRoot, sessionId);
|
|
58
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
59
|
+
|
|
60
|
+
let meta = loadSessionMeta(dir);
|
|
61
|
+
|
|
62
|
+
const messagesDir = join(dir, MESSAGES_DIR);
|
|
63
|
+
if (!existsSync(messagesDir)) mkdirSync(messagesDir, { recursive: true });
|
|
64
|
+
const log = openLog(messagesDir);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
dir,
|
|
68
|
+
id: sessionId,
|
|
69
|
+
getMeta() { return meta ? structuredClone(meta) : null; },
|
|
70
|
+
saveMeta(next) {
|
|
71
|
+
validateMeta(next);
|
|
72
|
+
meta = next;
|
|
73
|
+
writeAtomic(join(dir, META_FILE), JSON.stringify(meta, null, 2));
|
|
74
|
+
},
|
|
75
|
+
appendMessage(record) {
|
|
76
|
+
if (!record || typeof record !== 'object') {
|
|
77
|
+
throw new Error('appendMessage: record required');
|
|
78
|
+
}
|
|
79
|
+
const leaked = Object.keys(record).filter((k) => typeof k === 'string' && k.startsWith('_'));
|
|
80
|
+
if (leaked.length > 0) {
|
|
81
|
+
throw new Error(`appendMessage: ephemeral fields leaked into log: ${leaked.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
const stored = {
|
|
84
|
+
id: record.id || nextMsgId(),
|
|
85
|
+
ts: record.ts || new Date().toISOString(),
|
|
86
|
+
from: record.from,
|
|
87
|
+
role: record.role || (record.from === 'user' ? 'user' : 'assistant'),
|
|
88
|
+
text: record.text ?? '',
|
|
89
|
+
taskId: record.taskId || null,
|
|
90
|
+
mentions: Array.isArray(record.mentions) ? record.mentions.slice() : [],
|
|
91
|
+
meta: record.meta || {},
|
|
92
|
+
};
|
|
93
|
+
log.append(stored);
|
|
94
|
+
return stored;
|
|
95
|
+
},
|
|
96
|
+
*streamMessages() { yield* log.streamAll(); },
|
|
97
|
+
*readMessageRange(firstId, lastId) { yield* log.readRange(firstId, lastId); },
|
|
98
|
+
close() { log.close(); },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Create a new session on disk. Fails if meta.json already exists.
|
|
104
|
+
* @param {string} sessionsRoot
|
|
105
|
+
* @param {{
|
|
106
|
+
* id: string,
|
|
107
|
+
* vpIds: string[],
|
|
108
|
+
* displayName?: string,
|
|
109
|
+
* workDir?: string,
|
|
110
|
+
* createdAt?: string,
|
|
111
|
+
* }} spec
|
|
112
|
+
* @returns {SessionHandle}
|
|
113
|
+
*/
|
|
114
|
+
export function createSession(sessionsRoot, spec) {
|
|
115
|
+
if (!spec || !spec.id) throw new Error('createSession: spec.id required');
|
|
116
|
+
if (!Array.isArray(spec.vpIds) || spec.vpIds.length === 0) {
|
|
117
|
+
throw new Error('createSession: spec.vpIds required (non-empty array)');
|
|
118
|
+
}
|
|
119
|
+
for (const v of spec.vpIds) {
|
|
120
|
+
if (isReservedVpId(v)) throw new ReservedVpIdError(v);
|
|
121
|
+
const verdict = validateVpId(v);
|
|
122
|
+
if (!verdict.ok) throw new InvalidVpIdError(v, verdict.reason);
|
|
123
|
+
}
|
|
124
|
+
const h = openSession(sessionsRoot, spec.id);
|
|
125
|
+
if (h.getMeta()) throw new Error(`session ${spec.id} already exists`);
|
|
126
|
+
const meta = {
|
|
127
|
+
id: spec.id,
|
|
128
|
+
displayName: typeof spec.displayName === 'string' && spec.displayName.trim()
|
|
129
|
+
? spec.displayName.trim() : spec.id,
|
|
130
|
+
vpIds: Array.from(new Set(spec.vpIds)),
|
|
131
|
+
workDir: typeof spec.workDir === 'string' ? spec.workDir.trim() : '',
|
|
132
|
+
createdAt: spec.createdAt || new Date().toISOString(),
|
|
133
|
+
lastTurnAt: null,
|
|
134
|
+
};
|
|
135
|
+
h.saveMeta(meta);
|
|
136
|
+
return h;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Non-destructive load — returns null if meta.json is missing/corrupt. */
|
|
140
|
+
export function loadSessionMeta(dir) {
|
|
141
|
+
const path = join(dir, META_FILE);
|
|
142
|
+
if (!existsSync(path)) return null;
|
|
143
|
+
try {
|
|
144
|
+
const raw = readFileSync(path, 'utf8');
|
|
145
|
+
const parsed = JSON.parse(raw);
|
|
146
|
+
validateMeta(parsed);
|
|
147
|
+
if (typeof parsed.displayName !== 'string') parsed.displayName = parsed.id;
|
|
148
|
+
if (typeof parsed.workDir !== 'string') parsed.workDir = '';
|
|
149
|
+
if (parsed.lastTurnAt === undefined) parsed.lastTurnAt = null;
|
|
150
|
+
return parsed;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** List every session directory under `sessionsRoot`. */
|
|
157
|
+
export function listSessions(sessionsRoot) {
|
|
158
|
+
if (!existsSync(sessionsRoot)) return [];
|
|
159
|
+
const out = [];
|
|
160
|
+
for (const name of readdirSync(sessionsRoot)) {
|
|
161
|
+
if (name.startsWith('.')) continue;
|
|
162
|
+
const p = join(sessionsRoot, name);
|
|
163
|
+
try {
|
|
164
|
+
if (!statSync(p).isDirectory()) continue;
|
|
165
|
+
} catch { continue; }
|
|
166
|
+
const meta = loadSessionMeta(p);
|
|
167
|
+
if (meta) out.push(meta);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Update a session's displayName. */
|
|
173
|
+
export function renameSession(sessionsRoot, sessionId, displayName) {
|
|
174
|
+
const h = openSession(sessionsRoot, sessionId);
|
|
175
|
+
const meta = h.getMeta();
|
|
176
|
+
if (!meta) throw new Error(`renameSession: session ${sessionId} not found`);
|
|
177
|
+
const next = { ...meta, displayName: String(displayName || '').trim() || meta.id };
|
|
178
|
+
h.saveMeta(next);
|
|
179
|
+
h.close();
|
|
180
|
+
return next;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Patch a session's meta: add/remove VPs or change displayName/workDir.
|
|
185
|
+
* @param {string} sessionsRoot
|
|
186
|
+
* @param {string} sessionId
|
|
187
|
+
* @param {{addVpIds?: string[], removeVpIds?: string[], displayName?: string, workDir?: string}} patch
|
|
188
|
+
*/
|
|
189
|
+
export function updateSession(sessionsRoot, sessionId, patch = {}) {
|
|
190
|
+
const h = openSession(sessionsRoot, sessionId);
|
|
191
|
+
const meta = h.getMeta();
|
|
192
|
+
if (!meta) { h.close(); throw new Error(`updateSession: session ${sessionId} not found`); }
|
|
193
|
+
let vpIds = Array.from(meta.vpIds || []);
|
|
194
|
+
if (Array.isArray(patch.addVpIds)) {
|
|
195
|
+
for (const v of patch.addVpIds) {
|
|
196
|
+
if (isReservedVpId(v)) throw new ReservedVpIdError(v);
|
|
197
|
+
const verdict = validateVpId(v);
|
|
198
|
+
if (!verdict.ok) throw new InvalidVpIdError(v, verdict.reason);
|
|
199
|
+
if (!vpIds.includes(v)) vpIds.push(v);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (Array.isArray(patch.removeVpIds)) {
|
|
203
|
+
const drop = new Set(patch.removeVpIds);
|
|
204
|
+
vpIds = vpIds.filter((v) => !drop.has(v));
|
|
205
|
+
}
|
|
206
|
+
if (vpIds.length === 0) {
|
|
207
|
+
h.close();
|
|
208
|
+
throw new Error('updateSession: refusing to leave session with zero VPs');
|
|
209
|
+
}
|
|
210
|
+
const next = { ...meta, vpIds };
|
|
211
|
+
if (typeof patch.displayName === 'string' && patch.displayName.trim()) {
|
|
212
|
+
next.displayName = patch.displayName.trim();
|
|
213
|
+
}
|
|
214
|
+
if (typeof patch.workDir === 'string') {
|
|
215
|
+
next.workDir = patch.workDir.trim();
|
|
216
|
+
}
|
|
217
|
+
h.saveMeta(next);
|
|
218
|
+
h.close();
|
|
219
|
+
return next;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Stamp lastTurnAt. */
|
|
223
|
+
export function touchSession(sessionsRoot, sessionId, when = new Date().toISOString()) {
|
|
224
|
+
const h = openSession(sessionsRoot, sessionId);
|
|
225
|
+
const meta = h.getMeta();
|
|
226
|
+
if (!meta) { h.close(); return null; }
|
|
227
|
+
const next = { ...meta, lastTurnAt: when };
|
|
228
|
+
h.saveMeta(next);
|
|
229
|
+
h.close();
|
|
230
|
+
return next;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Soft-archive: rename dir to `.archived-<sessionId>-<ts>`. */
|
|
234
|
+
export function archiveSession(sessionsRoot, sessionId) {
|
|
235
|
+
const src = join(sessionsRoot, sessionId);
|
|
236
|
+
if (!existsSync(src)) return false;
|
|
237
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
238
|
+
const dst = join(sessionsRoot, `.archived-${sessionId}-${ts}`);
|
|
239
|
+
renameSync(src, dst);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Permanently delete a session directory. */
|
|
244
|
+
export function deleteSession(sessionsRoot, sessionId) {
|
|
245
|
+
const src = join(sessionsRoot, sessionId);
|
|
246
|
+
if (!existsSync(src)) return false;
|
|
247
|
+
rmSync(src, { recursive: true, force: true });
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function validateMeta(meta) {
|
|
252
|
+
if (!meta || typeof meta !== 'object') throw new Error('session meta.json must be object');
|
|
253
|
+
if (!meta.id || typeof meta.id !== 'string') throw new Error('session.id required');
|
|
254
|
+
if (!Array.isArray(meta.vpIds) || meta.vpIds.length === 0) {
|
|
255
|
+
throw new Error('session.vpIds required (non-empty array)');
|
|
256
|
+
}
|
|
257
|
+
for (const v of meta.vpIds) {
|
|
258
|
+
if (typeof v !== 'string') throw new Error('session.vpIds must be string[]');
|
|
259
|
+
}
|
|
260
|
+
if (meta.displayName != null && typeof meta.displayName !== 'string') {
|
|
261
|
+
throw new Error('session.displayName must be string');
|
|
262
|
+
}
|
|
263
|
+
if (meta.workDir != null && typeof meta.workDir !== 'string') {
|
|
264
|
+
throw new Error('session.workDir must be string');
|
|
265
|
+
}
|
|
266
|
+
if (meta.lastTurnAt != null && typeof meta.lastTurnAt !== 'string') {
|
|
267
|
+
throw new Error('session.lastTurnAt must be string|null');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* @typedef {Object} SessionHandle
|
|
273
|
+
* @property {string} dir
|
|
274
|
+
* @property {string} id
|
|
275
|
+
* @property {() => any} getMeta
|
|
276
|
+
* @property {(next:any) => void} saveMeta
|
|
277
|
+
* @property {(record:any) => any} appendMessage
|
|
278
|
+
* @property {() => Generator<any>} streamMessages
|
|
279
|
+
* @property {(first:string,last:string) => Generator<any>} readMessageRange
|
|
280
|
+
* @property {() => void} close
|
|
281
|
+
*/
|