@yeaft/webchat-agent 0.1.873 → 0.1.875
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 +20 -13
- package/package.json +1 -1
- package/providers/copilot-models.js +105 -89
- package/providers/copilot.js +3 -1
- package/yeaft/attachments.js +2 -2
- package/yeaft/cli.js +13 -13
- package/yeaft/compact/compactor.js +20 -20
- package/yeaft/conversation/persist.js +95 -95
- package/yeaft/debug-trace.js +12 -12
- package/yeaft/dream-v2/apply.js +15 -15
- package/yeaft/dream-v2/merge.js +12 -12
- package/yeaft/dream-v2/prompts/{extract-group.md → extract-session.md} +12 -12
- package/yeaft/dream-v2/prompts/index.js +3 -3
- package/yeaft/dream-v2/prompts/triage-pass1.md +1 -1
- package/yeaft/dream-v2/runner.js +37 -37
- package/yeaft/dream-v2/segment.js +3 -3
- package/yeaft/dream-v2/session-wiring.js +22 -22
- package/yeaft/dream-v2/state.js +7 -7
- package/yeaft/dream-v2/triage.js +22 -22
- package/yeaft/engine.js +65 -65
- package/yeaft/memory/ams-registry.js +22 -22
- package/yeaft/memory/seed-backfill.js +9 -9
- package/yeaft/memory/store-v2.js +27 -27
- package/yeaft/prompts.js +9 -9
- package/yeaft/routing/loop-guard.js +14 -14
- package/yeaft/routing/router.js +5 -5
- package/yeaft/session.js +5 -5
- package/yeaft/sessions/coordinator.js +96 -20
- package/yeaft/{groups → sessions}/ids.js +3 -3
- package/yeaft/{groups → sessions}/index.js +28 -28
- package/yeaft/sessions/pre-flow.js +178 -42
- package/yeaft/{groups → sessions}/seed-default.js +19 -19
- package/yeaft/{groups/group-config.js → sessions/session-config.js} +29 -29
- package/yeaft/{groups/group-crud.js → sessions/session-crud.js} +113 -113
- package/yeaft/sessions/session-store.js +85 -154
- package/yeaft/stop-hooks.js +4 -4
- package/yeaft/tools/todo-write.js +1 -1
- package/yeaft/tools/types.js +2 -2
- package/yeaft/vp/registry.js +1 -1
- package/yeaft/vp/vp-crud.js +1 -1
- package/yeaft/vp-status-broker.js +28 -28
- package/yeaft/web-bridge.js +411 -398
- package/yeaft/groups/coordinator.js +0 -221
- package/yeaft/groups/group-store.js +0 -212
- package/yeaft/groups/pre-flow.js +0 -329
- /package/yeaft/{groups → sessions}/feature-flag.js +0 -0
- /package/yeaft/{groups → sessions}/project-doc.js +0 -0
- /package/yeaft/{groups → sessions}/roster.js +0 -0
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* coordinator.js — Group Coordinator (task-334b).
|
|
3
|
-
*
|
|
4
|
-
* Consumes user/VP messages, persists them to the group's 334o jsonl-log,
|
|
5
|
-
* and dispatches user-text turns to target RoleInstances'
|
|
6
|
-
* `inputQueue`.
|
|
7
|
-
*
|
|
8
|
-
* As of GC.1 Commit B, VP-selection (parseMentions + dispatch matrix:
|
|
9
|
-
* mention / @all / fallback / vp-author no-op) lives in
|
|
10
|
-
* `groups/pre-flow.js` so the same logic can be invoked directly by
|
|
11
|
-
* the parallel fan-out path in web-bridge.js. Coordinator's job is now
|
|
12
|
-
* narrower: persist the message and translate the selection result
|
|
13
|
-
* into deliver() calls.
|
|
14
|
-
*
|
|
15
|
-
* This module DOES NOT run the engine. It only:
|
|
16
|
-
* 1. Persists the message (via GroupHandle.appendMessage)
|
|
17
|
-
* 2. Asks pre-flow for the list of target vpIds
|
|
18
|
-
* 3. Calls a user-supplied deliver(vpId, envelope) per target
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { parseMentions, selectRespondingVps } from './pre-flow.js';
|
|
22
|
-
|
|
23
|
-
// Re-export so existing importers (`createCoordinator(...).parseMentions`,
|
|
24
|
-
// or modules importing `parseMentions` from coordinator) keep working
|
|
25
|
-
// without churn. New code should import from `./pre-flow.js` directly.
|
|
26
|
-
export { parseMentions };
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Build a Group Coordinator bound to a single GroupHandle.
|
|
30
|
-
*
|
|
31
|
-
* @param {import('./group-store.js').GroupHandle} group
|
|
32
|
-
* @param {Object} [options]
|
|
33
|
-
* @param {(vpId:string, envelope:any)=>void} [options.deliver] called per target
|
|
34
|
-
* @param {number} [options.perGroupFanOut=16] @all cap (arch §5.3)
|
|
35
|
-
* @returns {GroupCoordinator}
|
|
36
|
-
*/
|
|
37
|
-
export function createCoordinator(group, options = {}) {
|
|
38
|
-
const deliver = options.deliver || (() => {});
|
|
39
|
-
const fanOutCap = options.perGroupFanOut ?? 16;
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Ingest one message. Returns a dispatch report describing what would/did
|
|
43
|
-
* go out to RoleInstances.
|
|
44
|
-
*
|
|
45
|
-
* @param {{
|
|
46
|
-
* from: string, // 'user' | vpId
|
|
47
|
-
* role?: 'user'|'assistant',
|
|
48
|
-
* text: string,
|
|
49
|
-
* taskId?: string|null,
|
|
50
|
-
* meta?: any,
|
|
51
|
-
* id?: string, ts?: string,
|
|
52
|
-
* }} input
|
|
53
|
-
* @param {{ taskMembers?: string[] }} [opts]
|
|
54
|
-
* When taskId is set, restricts dispatch to vps in taskMembers (334n owns
|
|
55
|
-
* the list). If omitted, coordinator will not filter.
|
|
56
|
-
*/
|
|
57
|
-
function ingest(input, opts = {}) {
|
|
58
|
-
if (!input || typeof input !== 'object') {
|
|
59
|
-
throw new Error('ingest: input required');
|
|
60
|
-
}
|
|
61
|
-
if (typeof input.text !== 'string') {
|
|
62
|
-
throw new Error('ingest: input.text required (string)');
|
|
63
|
-
}
|
|
64
|
-
const meta = group.getMeta();
|
|
65
|
-
if (!meta) throw new Error('group not initialised (call createGroup first)');
|
|
66
|
-
|
|
67
|
-
// `fromUser` drives `selectRespondingVps` — when true, the @-mention
|
|
68
|
-
// matrix runs (mention/broadcast/fallback). When false, VPs cannot
|
|
69
|
-
// text-@-route (VP-authored free text is surface noise per arch §6).
|
|
70
|
-
//
|
|
71
|
-
// route_forward injection is a special case: the message is VP-authored
|
|
72
|
-
// (role='assistant') but it MUST trigger dispatch (target VP needs to
|
|
73
|
-
// run). We detect it via `meta.injectedBy === 'route_forward'` and
|
|
74
|
-
// treat it as "user-like" for dispatch purposes only. Persistence still
|
|
75
|
-
// honours the caller's `role` field so the on-disk record correctly
|
|
76
|
-
// attributes the turn to the sending VP, not to the user.
|
|
77
|
-
const isRouteForwardInjection = input?.meta?.injectedBy === 'route_forward';
|
|
78
|
-
const fromUser = input.from === 'user'
|
|
79
|
-
|| input.role === 'user'
|
|
80
|
-
|| isRouteForwardInjection;
|
|
81
|
-
const mentions = parseMentions(input.text);
|
|
82
|
-
|
|
83
|
-
// Persist first — audit log / replay works even if dispatch has bugs.
|
|
84
|
-
//
|
|
85
|
-
// Convention: any field on `input` that starts with `_` is treated
|
|
86
|
-
// as ephemeral and is forwarded to the envelope (so per-turn driver
|
|
87
|
-
// payloads — image base64 blocks, prompt suffixes — reach the LLM
|
|
88
|
-
// call) but is NEVER passed to appendMessage. The jsonl-log must
|
|
89
|
-
// stay lean: base64 in audit history would blow up replay.
|
|
90
|
-
//
|
|
91
|
-
// The split is enforced structurally — see the assertion below the
|
|
92
|
-
// partition loop. Don't loosen it. If a new ephemeral key is added,
|
|
93
|
-
// it gets the `_` prefix at its source and inherits the protection
|
|
94
|
-
// for free; no allowlist to maintain.
|
|
95
|
-
const persistInput = {};
|
|
96
|
-
const ephemeral = {};
|
|
97
|
-
for (const [k, v] of Object.entries(input)) {
|
|
98
|
-
if (typeof k === 'string' && k.startsWith('_')) {
|
|
99
|
-
ephemeral[k] = v;
|
|
100
|
-
} else {
|
|
101
|
-
persistInput[k] = v;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
// Structural guarantee: nothing with a `_` prefix may reach the
|
|
105
|
-
// jsonl-log via `persistInput`. If this ever throws, the `_` rule
|
|
106
|
-
// got bypassed — fix the caller, not this assertion.
|
|
107
|
-
{
|
|
108
|
-
const leaked = Object.keys(persistInput).filter((k) => typeof k === 'string' && k.startsWith('_'));
|
|
109
|
-
if (leaked.length > 0) {
|
|
110
|
-
throw new Error(`coordinator.ingest: ephemeral fields leaked into persisted record: ${leaked.join(', ')}`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
const stored = group.appendMessage({
|
|
114
|
-
...persistInput,
|
|
115
|
-
mentions,
|
|
116
|
-
role: input.role || (fromUser ? 'user' : 'assistant'),
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// Ask pre-flow which VPs (if any) should respond.
|
|
120
|
-
const selection = selectRespondingVps({
|
|
121
|
-
meta,
|
|
122
|
-
fromUser,
|
|
123
|
-
mentions,
|
|
124
|
-
sender: input.from,
|
|
125
|
-
fanOutCap,
|
|
126
|
-
taskMembers: opts.taskMembers,
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// VP-authored: persist but no dispatch.
|
|
130
|
-
if (selection.reason === 'vp-author-no-text-routing') {
|
|
131
|
-
return {
|
|
132
|
-
message: stored,
|
|
133
|
-
dispatched: [],
|
|
134
|
-
fallback: null,
|
|
135
|
-
errors: [],
|
|
136
|
-
skipped: 'vp-author-no-text-routing',
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (selection.reason === 'broadcast') {
|
|
141
|
-
const envelope = makeEnvelope(stored, meta, 'broadcast', ephemeral);
|
|
142
|
-
for (const vpId of selection.dispatched) deliver(vpId, envelope);
|
|
143
|
-
return {
|
|
144
|
-
message: stored,
|
|
145
|
-
dispatched: selection.dispatched,
|
|
146
|
-
fallback: null,
|
|
147
|
-
errors: selection.errors,
|
|
148
|
-
broadcast: true,
|
|
149
|
-
truncatedAtFanOutCap: !!selection.truncatedAtFanOutCap,
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (selection.reason === 'mention') {
|
|
154
|
-
for (const vpId of selection.dispatched) {
|
|
155
|
-
deliver(vpId, makeEnvelope(stored, meta, 'mention', ephemeral));
|
|
156
|
-
}
|
|
157
|
-
return {
|
|
158
|
-
message: stored,
|
|
159
|
-
dispatched: selection.dispatched,
|
|
160
|
-
fallback: null,
|
|
161
|
-
errors: selection.errors,
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (selection.reason === 'fallback' && selection.fallback) {
|
|
166
|
-
deliver(selection.fallback, makeEnvelope(stored, meta, 'fallback', ephemeral));
|
|
167
|
-
return {
|
|
168
|
-
message: stored,
|
|
169
|
-
dispatched: selection.dispatched,
|
|
170
|
-
fallback: selection.fallback,
|
|
171
|
-
errors: selection.errors,
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// no-default / nothing to dispatch
|
|
176
|
-
return {
|
|
177
|
-
message: stored,
|
|
178
|
-
dispatched: [],
|
|
179
|
-
fallback: null,
|
|
180
|
-
errors: selection.errors,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return {
|
|
185
|
-
group,
|
|
186
|
-
ingest,
|
|
187
|
-
parseMentions,
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function makeEnvelope(msg, meta, trigger, ephemeral = {}) {
|
|
192
|
-
return {
|
|
193
|
-
groupId: meta.id,
|
|
194
|
-
taskId: msg.taskId || null,
|
|
195
|
-
msg,
|
|
196
|
-
trigger, // 'broadcast' | 'mention' | 'fallback'
|
|
197
|
-
// Ephemeral fields (any `_`-prefixed key on coord.ingest input).
|
|
198
|
-
// Used to ferry per-turn payloads (e.g. image base64 blocks) that
|
|
199
|
-
// must reach the driver but must NOT be persisted to the group log.
|
|
200
|
-
...ephemeral,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* @typedef {Object} GroupCoordinator
|
|
206
|
-
* @property {import('./group-store.js').GroupHandle} group
|
|
207
|
-
* @property {(input:any, opts?:any)=>DispatchReport} ingest
|
|
208
|
-
* @property {(text:string)=>string[]} parseMentions
|
|
209
|
-
*/
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* @typedef {Object} DispatchReport
|
|
213
|
-
* @property {any} message
|
|
214
|
-
* @property {string[]} dispatched
|
|
215
|
-
* @property {string|null} fallback
|
|
216
|
-
* @property {Array<{vpId?:string, error:string}>} errors
|
|
217
|
-
* @property {boolean=} broadcast
|
|
218
|
-
* @property {boolean=} truncatedAtFanOutCap
|
|
219
|
-
* @property {string=} skipped
|
|
220
|
-
*/
|
|
221
|
-
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* group-store.js — Per-group persistent store for task-334b.
|
|
3
|
-
*
|
|
4
|
-
* Layout (see architecture §2):
|
|
5
|
-
* ~/.yeaft/groups/<group-id>/
|
|
6
|
-
* group.json # { id, name, roster: [vpId...], defaultVpId, createdAt }
|
|
7
|
-
* messages/ # JSONL size-rotation log (334o openLog)
|
|
8
|
-
* 000001.jsonl
|
|
9
|
-
* index.json
|
|
10
|
-
* tasks/ # populated by 334n — reserved here
|
|
11
|
-
* vps/ # populated by 334c RoleInstance runtime — reserved here
|
|
12
|
-
*
|
|
13
|
-
* This module owns only the group.json + messages/ log. Roster mutation
|
|
14
|
-
* logic lives in roster.js so coordinator and group-store both compose it.
|
|
15
|
-
*
|
|
16
|
-
* Hard constraint: the store does not parse @-mentions, does not dispatch,
|
|
17
|
-
* and has no knowledge of VP/RoleInstance. It is pure persistence over 334o.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
existsSync,
|
|
22
|
-
mkdirSync,
|
|
23
|
-
readFileSync,
|
|
24
|
-
readdirSync,
|
|
25
|
-
statSync,
|
|
26
|
-
} from 'fs';
|
|
27
|
-
import { join } from 'path';
|
|
28
|
-
import { writeAtomic, openLog } from '../storage/index.js';
|
|
29
|
-
import { nextMsgId, isReservedVpId, ReservedVpIdError, validateVpId, InvalidVpIdError } from './ids.js';
|
|
30
|
-
|
|
31
|
-
const GROUP_FILE = 'group.json';
|
|
32
|
-
const MESSAGES_DIR = 'messages';
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Load (or create) the directory for a single group.
|
|
36
|
-
*
|
|
37
|
-
* @param {string} groupsRoot e.g. `${yeaftDir}/groups`
|
|
38
|
-
* @param {string} groupId
|
|
39
|
-
* @returns {GroupHandle}
|
|
40
|
-
*/
|
|
41
|
-
export function openGroup(groupsRoot, groupId) {
|
|
42
|
-
if (!groupId || typeof groupId !== 'string') {
|
|
43
|
-
throw new Error('openGroup: groupId required (string)');
|
|
44
|
-
}
|
|
45
|
-
const dir = join(groupsRoot, groupId);
|
|
46
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
47
|
-
|
|
48
|
-
let meta = loadGroupMeta(dir);
|
|
49
|
-
if (!meta) {
|
|
50
|
-
// Fresh group — caller must call initGroup() next; we return a handle
|
|
51
|
-
// with meta=null so createGroup() can write the initial file.
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const messagesDir = join(dir, MESSAGES_DIR);
|
|
55
|
-
if (!existsSync(messagesDir)) mkdirSync(messagesDir, { recursive: true });
|
|
56
|
-
const log = openLog(messagesDir);
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
dir,
|
|
60
|
-
id: groupId,
|
|
61
|
-
/** Return current meta (reads fresh from memory after last save). */
|
|
62
|
-
getMeta() { return meta ? structuredClone(meta) : null; },
|
|
63
|
-
/** Overwrite group.json atomically. */
|
|
64
|
-
saveMeta(next) {
|
|
65
|
-
validateMeta(next);
|
|
66
|
-
meta = next;
|
|
67
|
-
writeAtomic(join(dir, GROUP_FILE), JSON.stringify(meta, null, 2));
|
|
68
|
-
},
|
|
69
|
-
/**
|
|
70
|
-
* Append a message to the group log. Assigns an id if absent.
|
|
71
|
-
* Returns the stored record (with id + ts).
|
|
72
|
-
*
|
|
73
|
-
* Structural invariant: NO field on `record` may start with `_`.
|
|
74
|
-
* The `_` prefix is reserved for ephemeral per-turn payloads (image
|
|
75
|
-
* base64, prompt suffixes) that must reach the driver but must
|
|
76
|
-
* never hit the persisted jsonl-log. If this throws, the caller
|
|
77
|
-
* forgot to partition ephemeral fields off — fix the caller.
|
|
78
|
-
*/
|
|
79
|
-
appendMessage(record) {
|
|
80
|
-
if (!record || typeof record !== 'object') {
|
|
81
|
-
throw new Error('appendMessage: record required');
|
|
82
|
-
}
|
|
83
|
-
{
|
|
84
|
-
const leaked = Object.keys(record).filter((k) => typeof k === 'string' && k.startsWith('_'));
|
|
85
|
-
if (leaked.length > 0) {
|
|
86
|
-
throw new Error(`appendMessage: ephemeral fields leaked into log: ${leaked.join(', ')}`);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
const stored = {
|
|
90
|
-
id: record.id || nextMsgId(),
|
|
91
|
-
ts: record.ts || new Date().toISOString(),
|
|
92
|
-
from: record.from, // vpId | 'user'
|
|
93
|
-
role: record.role || (record.from === 'user' ? 'user' : 'assistant'),
|
|
94
|
-
text: record.text ?? '',
|
|
95
|
-
taskId: record.taskId || null,
|
|
96
|
-
mentions: Array.isArray(record.mentions) ? record.mentions.slice() : [],
|
|
97
|
-
meta: record.meta || {},
|
|
98
|
-
};
|
|
99
|
-
log.append(stored);
|
|
100
|
-
return stored;
|
|
101
|
-
},
|
|
102
|
-
/** Iterate all messages oldest→newest. */
|
|
103
|
-
*streamMessages() {
|
|
104
|
-
yield* log.streamAll();
|
|
105
|
-
},
|
|
106
|
-
/** Iterate a message id range inclusive. */
|
|
107
|
-
*readMessageRange(firstId, lastId) {
|
|
108
|
-
yield* log.readRange(firstId, lastId);
|
|
109
|
-
},
|
|
110
|
-
/** Flush + close underlying log (on shutdown). */
|
|
111
|
-
close() { log.close(); },
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Create a fresh group on disk. Fails if group.json already exists.
|
|
117
|
-
* @returns {GroupHandle}
|
|
118
|
-
*/
|
|
119
|
-
export function createGroup(groupsRoot, spec) {
|
|
120
|
-
if (!spec || !spec.id) throw new Error('createGroup: spec.id required');
|
|
121
|
-
const h = openGroup(groupsRoot, spec.id);
|
|
122
|
-
if (h.getMeta()) {
|
|
123
|
-
throw new Error(`group ${spec.id} already exists`);
|
|
124
|
-
}
|
|
125
|
-
const roster = Array.isArray(spec.roster) ? spec.roster.slice() : [];
|
|
126
|
-
for (const v of roster) {
|
|
127
|
-
if (isReservedVpId(v)) throw new ReservedVpIdError(v);
|
|
128
|
-
const verdict = validateVpId(v);
|
|
129
|
-
if (!verdict.ok) throw new InvalidVpIdError(v, verdict.reason);
|
|
130
|
-
}
|
|
131
|
-
if (spec.defaultVpId) {
|
|
132
|
-
if (isReservedVpId(spec.defaultVpId)) throw new ReservedVpIdError(spec.defaultVpId);
|
|
133
|
-
const dverdict = validateVpId(spec.defaultVpId);
|
|
134
|
-
if (!dverdict.ok) throw new InvalidVpIdError(spec.defaultVpId, dverdict.reason);
|
|
135
|
-
}
|
|
136
|
-
const meta = {
|
|
137
|
-
id: spec.id,
|
|
138
|
-
name: spec.name || spec.id,
|
|
139
|
-
roster,
|
|
140
|
-
defaultVpId: spec.defaultVpId || null,
|
|
141
|
-
announcement: typeof spec.announcement === 'string' ? spec.announcement : '',
|
|
142
|
-
workDir: typeof spec.workDir === 'string' ? spec.workDir.trim() : '',
|
|
143
|
-
createdAt: spec.createdAt || new Date().toISOString(),
|
|
144
|
-
};
|
|
145
|
-
h.saveMeta(meta);
|
|
146
|
-
return h;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Non-destructive load — returns null if group.json is missing/corrupt. */
|
|
150
|
-
export function loadGroupMeta(dir) {
|
|
151
|
-
const path = join(dir, GROUP_FILE);
|
|
152
|
-
if (!existsSync(path)) return null;
|
|
153
|
-
try {
|
|
154
|
-
const raw = readFileSync(path, 'utf8');
|
|
155
|
-
const parsed = JSON.parse(raw);
|
|
156
|
-
validateMeta(parsed);
|
|
157
|
-
// Legacy groups created before optional fields were added are
|
|
158
|
-
// forward-compat: missing fields read back as safe empty strings.
|
|
159
|
-
if (typeof parsed.announcement !== 'string') parsed.announcement = '';
|
|
160
|
-
if (typeof parsed.workDir !== 'string') parsed.workDir = '';
|
|
161
|
-
return parsed;
|
|
162
|
-
} catch {
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** List every group directory under `groupsRoot`. */
|
|
168
|
-
export function listGroups(groupsRoot) {
|
|
169
|
-
if (!existsSync(groupsRoot)) return [];
|
|
170
|
-
const out = [];
|
|
171
|
-
for (const name of readdirSync(groupsRoot)) {
|
|
172
|
-
// Skip dotfiles and legacy soft-archive dirs (`.archived-*`).
|
|
173
|
-
if (name.startsWith('.')) continue;
|
|
174
|
-
const p = join(groupsRoot, name);
|
|
175
|
-
try {
|
|
176
|
-
if (!statSync(p).isDirectory()) continue;
|
|
177
|
-
} catch { continue; }
|
|
178
|
-
const meta = loadGroupMeta(p);
|
|
179
|
-
if (meta) out.push(meta);
|
|
180
|
-
}
|
|
181
|
-
return out;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function validateMeta(meta) {
|
|
185
|
-
if (!meta || typeof meta !== 'object') throw new Error('group.json must be object');
|
|
186
|
-
if (!meta.id || typeof meta.id !== 'string') throw new Error('group.id required');
|
|
187
|
-
if (!Array.isArray(meta.roster)) throw new Error('group.roster must be array');
|
|
188
|
-
for (const v of meta.roster) {
|
|
189
|
-
if (typeof v !== 'string') throw new Error('group.roster must be string[]');
|
|
190
|
-
}
|
|
191
|
-
if (meta.defaultVpId != null && typeof meta.defaultVpId !== 'string') {
|
|
192
|
-
throw new Error('group.defaultVpId must be string|null');
|
|
193
|
-
}
|
|
194
|
-
if (meta.announcement != null && typeof meta.announcement !== 'string') {
|
|
195
|
-
throw new Error('group.announcement must be string');
|
|
196
|
-
}
|
|
197
|
-
if (meta.workDir != null && typeof meta.workDir !== 'string') {
|
|
198
|
-
throw new Error('group.workDir must be string');
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* @typedef {Object} GroupHandle
|
|
204
|
-
* @property {string} dir
|
|
205
|
-
* @property {string} id
|
|
206
|
-
* @property {() => any} getMeta
|
|
207
|
-
* @property {(next:any)=>void} saveMeta
|
|
208
|
-
* @property {(record:any)=>any} appendMessage
|
|
209
|
-
* @property {() => Generator<any>} streamMessages
|
|
210
|
-
* @property {(first:string,last:string)=>Generator<any>} readMessageRange
|
|
211
|
-
* @property {() => void} close
|
|
212
|
-
*/
|