@yeaft/webchat-agent 0.1.521 → 0.1.522
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 +1 -1
- package/unify/groups/coordinator.js +206 -0
- package/unify/groups/feature-flag.js +49 -0
- package/unify/groups/group-store.js +182 -0
- package/unify/groups/ids.js +71 -0
- package/unify/groups/index.js +47 -0
- package/unify/groups/roster.js +60 -0
- package/unify/groups/seed-default.js +47 -0
package/package.json
CHANGED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coordinator.js — Group Coordinator (task-334b).
|
|
3
|
+
*
|
|
4
|
+
* Consumes user/VP messages, persists them to the group's 334o jsonl-log,
|
|
5
|
+
* parses @-mentions (user-posted only), and dispatches to target RoleInstances'
|
|
6
|
+
* `inputQueue`. Aligned with architecture §5 / §6:
|
|
7
|
+
*
|
|
8
|
+
* - Text @-mentions trigger routing ONLY for `role === 'user'` messages.
|
|
9
|
+
* VP-authored @ mentions are surface noise; VPs dispatch via the
|
|
10
|
+
* route_forward tool (334d), not free text.
|
|
11
|
+
* - `@all` fans out to every roster member except the sender (perGroupFanOut
|
|
12
|
+
* cap honoured via options).
|
|
13
|
+
* - Unknown @-targets return `{ error: 'not_in_roster' }` in the dispatch
|
|
14
|
+
* report; coordinator does not mutate roster on stranger mentions.
|
|
15
|
+
* - No @-mention on a user message → falls back to `defaultVpId` via
|
|
16
|
+
* resolveFallbackVp (architecture G2).
|
|
17
|
+
* - taskId: if the inbound message carries `taskId`, dispatch is scoped to
|
|
18
|
+
* task.members — passed in via `options.taskMembers` (task storage lives
|
|
19
|
+
* in 334n; coordinator only enforces the filter when the caller provides
|
|
20
|
+
* the member list).
|
|
21
|
+
*
|
|
22
|
+
* This module DOES NOT run the engine. It only:
|
|
23
|
+
* 1. Persists the message (via GroupHandle.appendMessage)
|
|
24
|
+
* 2. Resolves the list of target vpIds
|
|
25
|
+
* 3. Calls a user-supplied deliver(vpId, envelope) per target
|
|
26
|
+
*
|
|
27
|
+
* That lets 334c (RoleInstance/Engine) own how a target is actually woken up
|
|
28
|
+
* (inputQueue push, status transition, etc) without coordinator owning it.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { isMember, resolveFallbackVp } from './roster.js';
|
|
32
|
+
|
|
33
|
+
/** Matches `@vp-id` where id is [A-Za-z0-9_-]+. Captures the id. */
|
|
34
|
+
const MENTION_RE = /(^|\s)@([A-Za-z0-9_][A-Za-z0-9_-]*)/g;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Extract an ordered, unique list of @-mentions from a text string.
|
|
38
|
+
* Recognises the literal token `@all` as broadcast.
|
|
39
|
+
*/
|
|
40
|
+
export function parseMentions(text) {
|
|
41
|
+
if (!text || typeof text !== 'string') return [];
|
|
42
|
+
const out = [];
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
MENTION_RE.lastIndex = 0;
|
|
45
|
+
let m;
|
|
46
|
+
while ((m = MENTION_RE.exec(text))) {
|
|
47
|
+
const id = m[2];
|
|
48
|
+
if (seen.has(id)) continue;
|
|
49
|
+
seen.add(id);
|
|
50
|
+
out.push(id);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a Group Coordinator bound to a single GroupHandle.
|
|
57
|
+
*
|
|
58
|
+
* @param {import('./group-store.js').GroupHandle} group
|
|
59
|
+
* @param {Object} [options]
|
|
60
|
+
* @param {(vpId:string, envelope:any)=>void} [options.deliver] called per target
|
|
61
|
+
* @param {number} [options.perGroupFanOut=16] @all cap (arch §5.3)
|
|
62
|
+
* @returns {GroupCoordinator}
|
|
63
|
+
*/
|
|
64
|
+
export function createCoordinator(group, options = {}) {
|
|
65
|
+
const deliver = options.deliver || (() => {});
|
|
66
|
+
const fanOutCap = options.perGroupFanOut ?? 16;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Ingest one message. Returns a dispatch report describing what would/did
|
|
70
|
+
* go out to RoleInstances.
|
|
71
|
+
*
|
|
72
|
+
* @param {{
|
|
73
|
+
* from: string, // 'user' | vpId
|
|
74
|
+
* role?: 'user'|'assistant',
|
|
75
|
+
* text: string,
|
|
76
|
+
* taskId?: string|null,
|
|
77
|
+
* meta?: any,
|
|
78
|
+
* id?: string, ts?: string,
|
|
79
|
+
* }} input
|
|
80
|
+
* @param {{ taskMembers?: string[] }} [opts]
|
|
81
|
+
* When taskId is set, restricts dispatch to vps in taskMembers (334n owns
|
|
82
|
+
* the list). If omitted, coordinator will not filter.
|
|
83
|
+
*/
|
|
84
|
+
function ingest(input, opts = {}) {
|
|
85
|
+
if (!input || typeof input !== 'object') {
|
|
86
|
+
throw new Error('ingest: input required');
|
|
87
|
+
}
|
|
88
|
+
if (typeof input.text !== 'string') {
|
|
89
|
+
throw new Error('ingest: input.text required (string)');
|
|
90
|
+
}
|
|
91
|
+
const meta = group.getMeta();
|
|
92
|
+
if (!meta) throw new Error('group not initialised (call createGroup first)');
|
|
93
|
+
|
|
94
|
+
const fromUser = input.from === 'user' || input.role === 'user';
|
|
95
|
+
const mentions = parseMentions(input.text);
|
|
96
|
+
|
|
97
|
+
// Persist first — audit log / replay works even if dispatch has bugs.
|
|
98
|
+
const stored = group.appendMessage({
|
|
99
|
+
...input,
|
|
100
|
+
mentions,
|
|
101
|
+
role: input.role || (fromUser ? 'user' : 'assistant'),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// VP-authored messages: persist but do NOT dispatch (§6: no text @ routing)
|
|
105
|
+
if (!fromUser) {
|
|
106
|
+
return {
|
|
107
|
+
message: stored,
|
|
108
|
+
dispatched: [],
|
|
109
|
+
fallback: null,
|
|
110
|
+
errors: [],
|
|
111
|
+
skipped: 'vp-author-no-text-routing',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// @all broadcast
|
|
116
|
+
if (mentions.includes('all')) {
|
|
117
|
+
const roster = meta.roster.filter((v) => v !== input.from).slice(0, fanOutCap);
|
|
118
|
+
const scoped = opts.taskMembers
|
|
119
|
+
? roster.filter((v) => opts.taskMembers.includes(v))
|
|
120
|
+
: roster;
|
|
121
|
+
const envelope = makeEnvelope(stored, meta, 'broadcast');
|
|
122
|
+
for (const vpId of scoped) deliver(vpId, envelope);
|
|
123
|
+
return {
|
|
124
|
+
message: stored,
|
|
125
|
+
dispatched: scoped,
|
|
126
|
+
fallback: null,
|
|
127
|
+
errors: [],
|
|
128
|
+
broadcast: true,
|
|
129
|
+
truncatedAtFanOutCap: meta.roster.length - 1 > fanOutCap,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Explicit @-mentions
|
|
134
|
+
if (mentions.length > 0) {
|
|
135
|
+
const dispatched = [];
|
|
136
|
+
const errors = [];
|
|
137
|
+
for (const vpId of mentions) {
|
|
138
|
+
if (!isMember(meta, vpId)) {
|
|
139
|
+
errors.push({ vpId, error: 'not_in_roster' });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (opts.taskMembers && !opts.taskMembers.includes(vpId)) {
|
|
143
|
+
errors.push({ vpId, error: 'not_in_task_members' });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
dispatched.push(vpId);
|
|
147
|
+
deliver(vpId, makeEnvelope(stored, meta, 'mention'));
|
|
148
|
+
}
|
|
149
|
+
return { message: stored, dispatched, fallback: null, errors };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// No @-mention → fallback
|
|
153
|
+
const fallback = resolveFallbackVp(meta);
|
|
154
|
+
if (!fallback) {
|
|
155
|
+
return {
|
|
156
|
+
message: stored,
|
|
157
|
+
dispatched: [],
|
|
158
|
+
fallback: null,
|
|
159
|
+
errors: [{ error: 'no_default_vp' }],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (opts.taskMembers && !opts.taskMembers.includes(fallback)) {
|
|
163
|
+
return {
|
|
164
|
+
message: stored,
|
|
165
|
+
dispatched: [],
|
|
166
|
+
fallback: null,
|
|
167
|
+
errors: [{ vpId: fallback, error: 'not_in_task_members' }],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
deliver(fallback, makeEnvelope(stored, meta, 'fallback'));
|
|
171
|
+
return { message: stored, dispatched: [fallback], fallback, errors: [] };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
group,
|
|
176
|
+
ingest,
|
|
177
|
+
parseMentions,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function makeEnvelope(msg, meta, trigger) {
|
|
182
|
+
return {
|
|
183
|
+
groupId: meta.id,
|
|
184
|
+
taskId: msg.taskId || null,
|
|
185
|
+
msg,
|
|
186
|
+
trigger, // 'broadcast' | 'mention' | 'fallback'
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @typedef {Object} GroupCoordinator
|
|
192
|
+
* @property {import('./group-store.js').GroupHandle} group
|
|
193
|
+
* @property {(input:any, opts?:any)=>DispatchReport} ingest
|
|
194
|
+
* @property {(text:string)=>string[]} parseMentions
|
|
195
|
+
*/
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @typedef {Object} DispatchReport
|
|
199
|
+
* @property {any} message
|
|
200
|
+
* @property {string[]} dispatched
|
|
201
|
+
* @property {string|null} fallback
|
|
202
|
+
* @property {Array<{vpId?:string, error:string}>} errors
|
|
203
|
+
* @property {boolean=} broadcast
|
|
204
|
+
* @property {boolean=} truncatedAtFanOutCap
|
|
205
|
+
* @property {string=} skipped
|
|
206
|
+
*/
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* feature-flag.js — Reads `config.unify.multiVp.enabled` from ~/.yeaft/config.json.
|
|
3
|
+
*
|
|
4
|
+
* Per architecture §11: multi-VP group mode is opt-in for MVP. The flag
|
|
5
|
+
* gates UI entry points and (later) migration. This module returns a plain
|
|
6
|
+
* boolean and never throws — missing/corrupt config falls back to `false`.
|
|
7
|
+
*
|
|
8
|
+
* A second helper `setMultiVpEnabled(dir, enabled)` writes through via
|
|
9
|
+
* writeAtomic so tests and future settings UI can toggle it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { writeAtomic } from '../storage/index.js';
|
|
15
|
+
|
|
16
|
+
const CONFIG_FILE = 'config.json';
|
|
17
|
+
const FLAG_PATH = ['unify', 'multiVp', 'enabled'];
|
|
18
|
+
|
|
19
|
+
function readConfig(yeaftDir) {
|
|
20
|
+
const path = join(yeaftDir, CONFIG_FILE);
|
|
21
|
+
if (!existsSync(path)) return {};
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(path, 'utf8')) || {};
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isMultiVpEnabled(yeaftDir) {
|
|
30
|
+
const cfg = readConfig(yeaftDir);
|
|
31
|
+
let cur = cfg;
|
|
32
|
+
for (const seg of FLAG_PATH) {
|
|
33
|
+
if (!cur || typeof cur !== 'object') return false;
|
|
34
|
+
cur = cur[seg];
|
|
35
|
+
}
|
|
36
|
+
return Boolean(cur);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function setMultiVpEnabled(yeaftDir, enabled) {
|
|
40
|
+
const cfg = readConfig(yeaftDir);
|
|
41
|
+
let cur = cfg;
|
|
42
|
+
for (let i = 0; i < FLAG_PATH.length - 1; i++) {
|
|
43
|
+
const seg = FLAG_PATH[i];
|
|
44
|
+
if (!cur[seg] || typeof cur[seg] !== 'object') cur[seg] = {};
|
|
45
|
+
cur = cur[seg];
|
|
46
|
+
}
|
|
47
|
+
cur[FLAG_PATH[FLAG_PATH.length - 1]] = Boolean(enabled);
|
|
48
|
+
writeAtomic(join(yeaftDir, CONFIG_FILE), JSON.stringify(cfg, null, 2));
|
|
49
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
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 } 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
|
+
appendMessage(record) {
|
|
74
|
+
if (!record || typeof record !== 'object') {
|
|
75
|
+
throw new Error('appendMessage: record required');
|
|
76
|
+
}
|
|
77
|
+
const stored = {
|
|
78
|
+
id: record.id || nextMsgId(),
|
|
79
|
+
ts: record.ts || new Date().toISOString(),
|
|
80
|
+
from: record.from, // vpId | 'user'
|
|
81
|
+
role: record.role || (record.from === 'user' ? 'user' : 'assistant'),
|
|
82
|
+
text: record.text ?? '',
|
|
83
|
+
taskId: record.taskId || null,
|
|
84
|
+
mentions: Array.isArray(record.mentions) ? record.mentions.slice() : [],
|
|
85
|
+
meta: record.meta || {},
|
|
86
|
+
};
|
|
87
|
+
log.append(stored);
|
|
88
|
+
return stored;
|
|
89
|
+
},
|
|
90
|
+
/** Iterate all messages oldest→newest. */
|
|
91
|
+
*streamMessages() {
|
|
92
|
+
yield* log.streamAll();
|
|
93
|
+
},
|
|
94
|
+
/** Iterate a message id range inclusive. */
|
|
95
|
+
*readMessageRange(firstId, lastId) {
|
|
96
|
+
yield* log.readRange(firstId, lastId);
|
|
97
|
+
},
|
|
98
|
+
/** Flush + close underlying log (on shutdown). */
|
|
99
|
+
close() { log.close(); },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create a fresh group on disk. Fails if group.json already exists.
|
|
105
|
+
* @returns {GroupHandle}
|
|
106
|
+
*/
|
|
107
|
+
export function createGroup(groupsRoot, spec) {
|
|
108
|
+
if (!spec || !spec.id) throw new Error('createGroup: spec.id required');
|
|
109
|
+
const h = openGroup(groupsRoot, spec.id);
|
|
110
|
+
if (h.getMeta()) {
|
|
111
|
+
throw new Error(`group ${spec.id} already exists`);
|
|
112
|
+
}
|
|
113
|
+
const roster = Array.isArray(spec.roster) ? spec.roster.slice() : [];
|
|
114
|
+
for (const v of roster) {
|
|
115
|
+
if (isReservedVpId(v)) throw new ReservedVpIdError(v);
|
|
116
|
+
}
|
|
117
|
+
if (spec.defaultVpId && isReservedVpId(spec.defaultVpId)) {
|
|
118
|
+
throw new ReservedVpIdError(spec.defaultVpId);
|
|
119
|
+
}
|
|
120
|
+
const meta = {
|
|
121
|
+
id: spec.id,
|
|
122
|
+
name: spec.name || spec.id,
|
|
123
|
+
roster,
|
|
124
|
+
defaultVpId: spec.defaultVpId || null,
|
|
125
|
+
createdAt: spec.createdAt || new Date().toISOString(),
|
|
126
|
+
};
|
|
127
|
+
h.saveMeta(meta);
|
|
128
|
+
return h;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Non-destructive load — returns null if group.json is missing/corrupt. */
|
|
132
|
+
export function loadGroupMeta(dir) {
|
|
133
|
+
const path = join(dir, GROUP_FILE);
|
|
134
|
+
if (!existsSync(path)) return null;
|
|
135
|
+
try {
|
|
136
|
+
const raw = readFileSync(path, 'utf8');
|
|
137
|
+
const parsed = JSON.parse(raw);
|
|
138
|
+
validateMeta(parsed);
|
|
139
|
+
return parsed;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** List every group directory under `groupsRoot`. */
|
|
146
|
+
export function listGroups(groupsRoot) {
|
|
147
|
+
if (!existsSync(groupsRoot)) return [];
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const name of readdirSync(groupsRoot)) {
|
|
150
|
+
const p = join(groupsRoot, name);
|
|
151
|
+
try {
|
|
152
|
+
if (!statSync(p).isDirectory()) continue;
|
|
153
|
+
} catch { continue; }
|
|
154
|
+
const meta = loadGroupMeta(p);
|
|
155
|
+
if (meta) out.push(meta);
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function validateMeta(meta) {
|
|
161
|
+
if (!meta || typeof meta !== 'object') throw new Error('group.json must be object');
|
|
162
|
+
if (!meta.id || typeof meta.id !== 'string') throw new Error('group.id required');
|
|
163
|
+
if (!Array.isArray(meta.roster)) throw new Error('group.roster must be array');
|
|
164
|
+
for (const v of meta.roster) {
|
|
165
|
+
if (typeof v !== 'string') throw new Error('group.roster must be string[]');
|
|
166
|
+
}
|
|
167
|
+
if (meta.defaultVpId != null && typeof meta.defaultVpId !== 'string') {
|
|
168
|
+
throw new Error('group.defaultVpId must be string|null');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @typedef {Object} GroupHandle
|
|
174
|
+
* @property {string} dir
|
|
175
|
+
* @property {string} id
|
|
176
|
+
* @property {() => any} getMeta
|
|
177
|
+
* @property {(next:any)=>void} saveMeta
|
|
178
|
+
* @property {(record:any)=>any} appendMessage
|
|
179
|
+
* @property {() => Generator<any>} streamMessages
|
|
180
|
+
* @property {(first:string,last:string)=>Generator<any>} readMessageRange
|
|
181
|
+
* @property {() => void} close
|
|
182
|
+
*/
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ids.js — ID generators for the groups slice.
|
|
3
|
+
*
|
|
4
|
+
* Per slice-spec §4 (ID format): groupId uses a slug, msgId uses ULID-ish
|
|
5
|
+
* lexicographic-sortable form. We implement a small crockford-base32 timestamp
|
|
6
|
+
* + randomness scheme that works cross-platform without external deps.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomBytes } from 'crypto';
|
|
10
|
+
|
|
11
|
+
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
12
|
+
|
|
13
|
+
function encode(num, len) {
|
|
14
|
+
let n = BigInt(num);
|
|
15
|
+
const out = [];
|
|
16
|
+
for (let i = 0; i < len; i++) {
|
|
17
|
+
out.unshift(CROCKFORD[Number(n & 31n)]);
|
|
18
|
+
n >>= 5n;
|
|
19
|
+
}
|
|
20
|
+
return out.join('');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function randEncoded(len) {
|
|
24
|
+
const bytes = randomBytes(Math.ceil(len * 5 / 8));
|
|
25
|
+
let n = 0n;
|
|
26
|
+
for (const b of bytes) n = (n << 8n) | BigInt(b);
|
|
27
|
+
return encode(n, len);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Monotonic ULID-lite (10 time chars + 16 random chars). */
|
|
31
|
+
export function newUlidLite() {
|
|
32
|
+
const time = encode(Date.now(), 10);
|
|
33
|
+
return `${time}${randEncoded(16)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function nextMsgId() {
|
|
37
|
+
return `msg_${newUlidLite()}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function nextGroupId(slug = 'default') {
|
|
41
|
+
// Slug-tolerant: lowercase a-z0-9_- only.
|
|
42
|
+
const safe = String(slug).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').slice(0, 32) || 'group';
|
|
43
|
+
return `grp_${safe}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Reserved vpIds that must never be used as actual VP identifiers — they
|
|
48
|
+
* collide with coordinator-level sentinels (`@all` broadcast, `user`/`system`
|
|
49
|
+
* sender roles) and would cause silent footguns (a vpId=`all` VP would be
|
|
50
|
+
* absorbed into broadcast). Enforced at CRUD boundaries (addVp, createGroup).
|
|
51
|
+
*
|
|
52
|
+
* prev-1 nit #4 (blocker-fix): @foo/@all/@user are the mental bedrock of all
|
|
53
|
+
* future UI — protecting the names here prevents dirty data from reaching the
|
|
54
|
+
* Engine and requiring a migration slice later.
|
|
55
|
+
*/
|
|
56
|
+
export const RESERVED_VP_IDS = Object.freeze(['all', 'user', 'system', 'everyone']);
|
|
57
|
+
|
|
58
|
+
/** True iff `id` (case-insensitive) is a reserved vp identifier. */
|
|
59
|
+
export function isReservedVpId(id) {
|
|
60
|
+
if (!id || typeof id !== 'string') return false;
|
|
61
|
+
return RESERVED_VP_IDS.includes(id.toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Thrown by CRUD entry points when a reserved vpId is supplied. */
|
|
65
|
+
export class ReservedVpIdError extends Error {
|
|
66
|
+
constructor(vpId) {
|
|
67
|
+
super(`vpId "${vpId}" is reserved (${RESERVED_VP_IDS.join(', ')})`);
|
|
68
|
+
this.name = 'ReservedVpIdError';
|
|
69
|
+
this.vpId = vpId;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* groups/ — Group Coordinator module (task-334b).
|
|
3
|
+
*
|
|
4
|
+
* Layered over 334o storage + 334a VP Registry. Provides:
|
|
5
|
+
* - Persistent group directory (group.json + messages/ jsonl-log)
|
|
6
|
+
* - Roster mutation helpers
|
|
7
|
+
* - Coordinator that parses @-mentions on USER messages and dispatches to
|
|
8
|
+
* target RoleInstances via a caller-supplied `deliver(vpId, envelope)`.
|
|
9
|
+
* - Feature flag reader for `unify.multiVp.enabled`
|
|
10
|
+
* - First-boot default group seeder
|
|
11
|
+
*
|
|
12
|
+
* See agent/unify/groups/coordinator.js for the dispatch contract.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
openGroup,
|
|
17
|
+
createGroup,
|
|
18
|
+
loadGroupMeta,
|
|
19
|
+
listGroups,
|
|
20
|
+
} from './group-store.js';
|
|
21
|
+
export {
|
|
22
|
+
addVp,
|
|
23
|
+
removeVp,
|
|
24
|
+
setDefaultVp,
|
|
25
|
+
isMember,
|
|
26
|
+
resolveFallbackVp,
|
|
27
|
+
} from './roster.js';
|
|
28
|
+
export {
|
|
29
|
+
createCoordinator,
|
|
30
|
+
parseMentions,
|
|
31
|
+
} from './coordinator.js';
|
|
32
|
+
export {
|
|
33
|
+
isMultiVpEnabled,
|
|
34
|
+
setMultiVpEnabled,
|
|
35
|
+
} from './feature-flag.js';
|
|
36
|
+
export {
|
|
37
|
+
seedDefaultGroup,
|
|
38
|
+
DEFAULT_GROUP_ID,
|
|
39
|
+
} from './seed-default.js';
|
|
40
|
+
export {
|
|
41
|
+
nextMsgId,
|
|
42
|
+
nextGroupId,
|
|
43
|
+
newUlidLite,
|
|
44
|
+
isReservedVpId,
|
|
45
|
+
RESERVED_VP_IDS,
|
|
46
|
+
ReservedVpIdError,
|
|
47
|
+
} from './ids.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* roster.js — Pure roster mutation/query helpers.
|
|
3
|
+
*
|
|
4
|
+
* Roster = `string[]` of vpIds. `defaultVpId` is stored alongside on the
|
|
5
|
+
* group meta; helpers keep both in sync:
|
|
6
|
+
* - addVp: appends if absent; preserves order.
|
|
7
|
+
* - removeVp: drops and clears defaultVpId if it matched; falls back to
|
|
8
|
+
* the first remaining entry by join order (§ D2 Fallback, G2 in arch).
|
|
9
|
+
* - setDefaultVp: validates membership; throws on stranger.
|
|
10
|
+
*
|
|
11
|
+
* Emits no events directly — callers (group-store/coordinator) persist meta
|
|
12
|
+
* and optionally notify listeners.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isReservedVpId, ReservedVpIdError } from './ids.js';
|
|
16
|
+
|
|
17
|
+
/** Returns a cloned roster array with `vpId` appended if not already present. */
|
|
18
|
+
export function addVp(meta, vpId) {
|
|
19
|
+
if (!vpId || typeof vpId !== 'string') {
|
|
20
|
+
throw new Error('addVp: vpId required (string)');
|
|
21
|
+
}
|
|
22
|
+
if (isReservedVpId(vpId)) {
|
|
23
|
+
throw new ReservedVpIdError(vpId);
|
|
24
|
+
}
|
|
25
|
+
const roster = meta.roster.slice();
|
|
26
|
+
if (!roster.includes(vpId)) roster.push(vpId);
|
|
27
|
+
const defaultVpId = meta.defaultVpId || roster[0] || null;
|
|
28
|
+
return { ...meta, roster, defaultVpId };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Remove a vpId. If it was default, pick the next join-order member. */
|
|
32
|
+
export function removeVp(meta, vpId) {
|
|
33
|
+
const roster = meta.roster.filter((v) => v !== vpId);
|
|
34
|
+
let defaultVpId = meta.defaultVpId;
|
|
35
|
+
if (defaultVpId === vpId) {
|
|
36
|
+
defaultVpId = roster[0] || null;
|
|
37
|
+
}
|
|
38
|
+
return { ...meta, roster, defaultVpId };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Set default; throws if the vp is not in roster. */
|
|
42
|
+
export function setDefaultVp(meta, vpId) {
|
|
43
|
+
if (!meta.roster.includes(vpId)) {
|
|
44
|
+
throw new Error(`setDefaultVp: ${vpId} not in roster`);
|
|
45
|
+
}
|
|
46
|
+
return { ...meta, defaultVpId: vpId };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True iff vpId is a roster member. */
|
|
50
|
+
export function isMember(meta, vpId) {
|
|
51
|
+
return meta.roster.includes(vpId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve which VP should answer a message with no explicit @-mention.
|
|
56
|
+
* Per architecture G2: defaultVpId if set, else roster[0], else null.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveFallbackVp(meta) {
|
|
59
|
+
return meta.defaultVpId || meta.roster[0] || null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* seed-default.js — First-boot default group (architecture §10 D1).
|
|
3
|
+
*
|
|
4
|
+
* When multi-VP mode is first enabled for a user, seed a default group with
|
|
5
|
+
* the provided roster (typically `[defaultVpId]`). Idempotent: if the group
|
|
6
|
+
* already exists on disk, returns the existing handle without overwriting.
|
|
7
|
+
*
|
|
8
|
+
* Separation from group-store.createGroup:
|
|
9
|
+
* - createGroup throws on duplicate; seed returns the existing handle.
|
|
10
|
+
* - seed picks a stable id `grp_default` so UI can deep-link to it.
|
|
11
|
+
* - seed is the only place that writes the "default group exists" side
|
|
12
|
+
* effect during the bootstrap flow.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
import { openGroup, createGroup, loadGroupMeta } from './group-store.js';
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_GROUP_ID = 'grp_default';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} yeaftDir
|
|
23
|
+
* @param {{ defaultVpId?: string|null, roster?: string[], name?: string }} [spec]
|
|
24
|
+
* @returns {{ group: import('./group-store.js').GroupHandle, created: boolean }}
|
|
25
|
+
*/
|
|
26
|
+
export function seedDefaultGroup(yeaftDir, spec = {}) {
|
|
27
|
+
const groupsRoot = join(yeaftDir, 'groups');
|
|
28
|
+
if (!existsSync(groupsRoot)) mkdirSync(groupsRoot, { recursive: true });
|
|
29
|
+
|
|
30
|
+
const existingDir = join(groupsRoot, DEFAULT_GROUP_ID);
|
|
31
|
+
if (existsSync(existingDir) && loadGroupMeta(existingDir)) {
|
|
32
|
+
return { group: openGroup(groupsRoot, DEFAULT_GROUP_ID), created: false };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const roster = Array.isArray(spec.roster) && spec.roster.length
|
|
36
|
+
? spec.roster.slice()
|
|
37
|
+
: (spec.defaultVpId ? [spec.defaultVpId] : []);
|
|
38
|
+
const defaultVpId = spec.defaultVpId || roster[0] || null;
|
|
39
|
+
|
|
40
|
+
const group = createGroup(groupsRoot, {
|
|
41
|
+
id: DEFAULT_GROUP_ID,
|
|
42
|
+
name: spec.name || 'Default',
|
|
43
|
+
roster,
|
|
44
|
+
defaultVpId,
|
|
45
|
+
});
|
|
46
|
+
return { group, created: true };
|
|
47
|
+
}
|