@yeaft/webchat-agent 0.1.533 → 0.1.535
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 +27 -1
- package/package.json +1 -1
- package/unify/groups/group-crud.js +250 -0
- package/unify/groups/index.js +12 -0
- package/unify/memory/migrate-r5-to-r6.js +566 -16
- package/unify/session.js +15 -0
- package/unify/web-bridge.js +161 -0
|
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
|
|
|
36
36
|
import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
|
|
37
37
|
import { loadMcpServers, updateMcpConfig } from '../mcp.js';
|
|
38
38
|
import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings } from '../unify/config-api.js';
|
|
39
|
-
import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyTaskMessage, handleUnifyUserMemoryWrite, handleUnifyUserMemoryRemove } from '../unify/web-bridge.js';
|
|
39
|
+
import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyTaskMessage, handleUnifyUserMemoryWrite, handleUnifyUserMemoryRemove, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp } from '../unify/web-bridge.js';
|
|
40
40
|
|
|
41
41
|
export async function handleMessage(msg) {
|
|
42
42
|
switch (msg.type) {
|
|
@@ -436,6 +436,32 @@ export async function handleMessage(msg) {
|
|
|
436
436
|
handleUnifyUserMemoryRemove(msg);
|
|
437
437
|
break;
|
|
438
438
|
|
|
439
|
+
// task-334m: Group CRUD + D1 seed wiring (§Δ10 334m + R6 §Δ31.2).
|
|
440
|
+
// All handlers reply via `group_crud_result`; mutating ops additionally
|
|
441
|
+
// emit `group_roster_changed` (add/remove/default) or
|
|
442
|
+
// `group_list_updated` (create/rename/archive) for listener sync.
|
|
443
|
+
case 'unify_list_groups':
|
|
444
|
+
handleUnifyListGroups(msg);
|
|
445
|
+
break;
|
|
446
|
+
case 'unify_create_group':
|
|
447
|
+
handleUnifyCreateGroup(msg);
|
|
448
|
+
break;
|
|
449
|
+
case 'unify_rename_group':
|
|
450
|
+
handleUnifyRenameGroup(msg);
|
|
451
|
+
break;
|
|
452
|
+
case 'unify_archive_group':
|
|
453
|
+
handleUnifyArchiveGroup(msg);
|
|
454
|
+
break;
|
|
455
|
+
case 'unify_add_member':
|
|
456
|
+
handleUnifyAddMember(msg);
|
|
457
|
+
break;
|
|
458
|
+
case 'unify_remove_member':
|
|
459
|
+
handleUnifyRemoveMember(msg);
|
|
460
|
+
break;
|
|
461
|
+
case 'unify_set_default_vp':
|
|
462
|
+
handleUnifySetDefaultVp(msg);
|
|
463
|
+
break;
|
|
464
|
+
|
|
439
465
|
// Expert roles definition (for ExpertPanel detail view)
|
|
440
466
|
case 'get_expert_roles': {
|
|
441
467
|
const { getExpertRolesDefinition } = await import('../expert-roles.js');
|
package/package.json
CHANGED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* group-crud.js — High-level Group CRUD API (task-334m).
|
|
3
|
+
*
|
|
4
|
+
* Wraps the primitives from group-store.js + roster.js into the 5 operations
|
|
5
|
+
* wired to WS events (§Δ10 334m + R6 §Δ31.2):
|
|
6
|
+
* createGroupFromSpec — wizard "create new group" (empty or user-picked roster)
|
|
7
|
+
* renameGroup — update meta.name; preserves roster / defaultVpId
|
|
8
|
+
* archiveGroup — rename dir to `.archived-<ts>-<id>` (soft delete)
|
|
9
|
+
* addMember — roster.addVp + save; sets defaultVpId if first
|
|
10
|
+
* removeMember — roster.removeVp + save; clears/rotates defaultVpId
|
|
11
|
+
*
|
|
12
|
+
* Plus the D1 bootstrap helper:
|
|
13
|
+
* ensureDefaultGroupIfEmpty(yeaftDir, {libDir}) — if NO group exists on
|
|
14
|
+
* disk, seed `grp_default` with roster = every VP in the library, and
|
|
15
|
+
* defaultVpId = alphabetically first vpId. No-op when ≥1 group present.
|
|
16
|
+
*
|
|
17
|
+
* Hard constraints (PM):
|
|
18
|
+
* (a) We don't touch 334o storage primitives (storage/index.js) — we call
|
|
19
|
+
* group-store.openGroup / saveMeta which already go through openLog.
|
|
20
|
+
* (b) We don't touch VP entity (vp-store.js / vp-loader.js) — only read
|
|
21
|
+
* via scanVpLibrary to know which VPs exist at seed time.
|
|
22
|
+
* (c) When `addMember` is called with an empty roster and no defaultVpId
|
|
23
|
+
* resolvable, callers surface `no_default_vp` via `createGroupFromSpec`;
|
|
24
|
+
* on `removeMember` we permit the empty state (UI nudges the user).
|
|
25
|
+
*
|
|
26
|
+
* Error shape — every throw is a `GroupCrudError` with a stable `.code`:
|
|
27
|
+
* 'not_found' — group id has no dir / meta
|
|
28
|
+
* 'duplicate' — createGroup collided with an existing id
|
|
29
|
+
* 'invalid_name' — display name empty after trim
|
|
30
|
+
* 'no_default_vp' — seed with empty VP library OR roster empties to []
|
|
31
|
+
* and the caller asked for a defaultVpId. (D1 spec)
|
|
32
|
+
* 'reserved'/'invalid_vp_id'/... — bubbled from ids.js validators
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { existsSync, renameSync } from 'fs';
|
|
36
|
+
import { randomBytes } from 'crypto';
|
|
37
|
+
import { join } from 'path';
|
|
38
|
+
import {
|
|
39
|
+
openGroup, createGroup, listGroups, loadGroupMeta,
|
|
40
|
+
} from './group-store.js';
|
|
41
|
+
import { addVp as rosterAdd, removeVp as rosterRemove, setDefaultVp } from './roster.js';
|
|
42
|
+
import { seedDefaultGroup, DEFAULT_GROUP_ID } from './seed-default.js';
|
|
43
|
+
import { nextGroupId, validateVpId, isReservedVpId } from './ids.js';
|
|
44
|
+
import { scanVpLibrary, DEFAULT_VP_LIB_DIR } from '../vp/vp-store.js';
|
|
45
|
+
|
|
46
|
+
export class GroupCrudError extends Error {
|
|
47
|
+
constructor(code, groupId, message) {
|
|
48
|
+
super(message || `${code}: ${groupId}`);
|
|
49
|
+
this.name = 'GroupCrudError';
|
|
50
|
+
this.code = code;
|
|
51
|
+
this.groupId = groupId;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function groupsRoot(yeaftDir) {
|
|
56
|
+
return join(yeaftDir, 'groups');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Build a safe group id from a display name (slug + ulid-lite suffix). */
|
|
60
|
+
export function makeGroupId(name) {
|
|
61
|
+
const slug = String(name || 'group')
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
64
|
+
.replace(/^-+|-+$/g, '')
|
|
65
|
+
.slice(0, 24) || 'group';
|
|
66
|
+
return nextGroupId(slug);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* (B) D1 seed — called at boot (or when multi-VP is first enabled). Idempotent:
|
|
71
|
+
* returns `{seeded:false}` if any group already exists on disk (including
|
|
72
|
+
* `grp_default`). When empty, seeds with roster = full VP library, sorted
|
|
73
|
+
* alphabetically; defaultVpId = roster[0].
|
|
74
|
+
*
|
|
75
|
+
* When the VP library is also empty, we still seed an empty-roster group so
|
|
76
|
+
* the UI has somewhere to land — but defaultVpId is null and downstream
|
|
77
|
+
* message send will return `no_default_vp` until the user adds a VP.
|
|
78
|
+
*/
|
|
79
|
+
export function ensureDefaultGroupIfEmpty(yeaftDir, options = {}) {
|
|
80
|
+
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
81
|
+
const existing = listGroups(groupsRoot(yeaftDir));
|
|
82
|
+
if (existing.length > 0) {
|
|
83
|
+
return { seeded: false, groupId: existing[0].id };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Sort VP ids alphabetically (stable for tests / deterministic UI).
|
|
87
|
+
// NB: vp-store returns records with `.id` (not `.vpId`) — keep this in sync.
|
|
88
|
+
const vps = scanVpLibrary({ dir: libDir })
|
|
89
|
+
.map(v => v && v.id)
|
|
90
|
+
.filter(v => typeof v === 'string' && v.length > 0);
|
|
91
|
+
vps.sort((a, b) => a.localeCompare(b));
|
|
92
|
+
|
|
93
|
+
const defaultVpId = vps[0] || null;
|
|
94
|
+
const { group, created } = seedDefaultGroup(yeaftDir, {
|
|
95
|
+
name: options.name || 'Default',
|
|
96
|
+
roster: vps,
|
|
97
|
+
defaultVpId,
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
seeded: created,
|
|
101
|
+
groupId: group.id,
|
|
102
|
+
defaultVpId,
|
|
103
|
+
rosterSize: vps.length,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* (A.1) Create group from a wizard spec. `spec.roster` is authoritative —
|
|
109
|
+
* we do NOT auto-expand to the full VP library here. That's D1's job only.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} yeaftDir
|
|
112
|
+
* @param {{name:string, roster?:string[], defaultVpId?:string|null}} spec
|
|
113
|
+
* @returns {{id:string, name:string, roster:string[], defaultVpId:string|null}}
|
|
114
|
+
*/
|
|
115
|
+
export function createGroupFromSpec(yeaftDir, spec) {
|
|
116
|
+
const name = String(spec && spec.name || '').trim();
|
|
117
|
+
if (!name) throw new GroupCrudError('invalid_name', null, 'group name required');
|
|
118
|
+
|
|
119
|
+
const roster = Array.isArray(spec.roster) ? spec.roster.slice() : [];
|
|
120
|
+
// Validate every member up-front so we fail before touching fs.
|
|
121
|
+
for (const vpId of roster) {
|
|
122
|
+
if (isReservedVpId(vpId)) {
|
|
123
|
+
throw new GroupCrudError('reserved', null, `reserved vpId: ${vpId}`);
|
|
124
|
+
}
|
|
125
|
+
const v = validateVpId(vpId);
|
|
126
|
+
if (!v.ok) throw new GroupCrudError(v.reason, null, `invalid vpId: ${vpId}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// defaultVpId resolution: explicit > roster[0] > null. Null is allowed at
|
|
130
|
+
// create time (empty roster) — the wizard modal warns the user downstream
|
|
131
|
+
// (task-334m spec: `no_default_vp` surfaced on first send, not on create).
|
|
132
|
+
let defaultVpId = spec.defaultVpId || null;
|
|
133
|
+
if (defaultVpId && !roster.includes(defaultVpId)) {
|
|
134
|
+
throw new GroupCrudError('default_not_in_roster', null, `${defaultVpId} not in roster`);
|
|
135
|
+
}
|
|
136
|
+
if (!defaultVpId) defaultVpId = roster[0] || null;
|
|
137
|
+
|
|
138
|
+
const id = makeGroupId(name);
|
|
139
|
+
const root = groupsRoot(yeaftDir);
|
|
140
|
+
if (existsSync(join(root, id))) {
|
|
141
|
+
// Extremely unlikely (ulid suffix), but surface deterministically.
|
|
142
|
+
throw new GroupCrudError('duplicate', id);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const handle = createGroup(root, { id, name, roster, defaultVpId });
|
|
146
|
+
const meta = handle.getMeta();
|
|
147
|
+
handle.close();
|
|
148
|
+
return meta;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* (A.2) Rename — updates meta.name; preserves everything else.
|
|
153
|
+
*/
|
|
154
|
+
export function renameGroup(yeaftDir, groupId, newName) {
|
|
155
|
+
const name = String(newName || '').trim();
|
|
156
|
+
if (!name) throw new GroupCrudError('invalid_name', groupId);
|
|
157
|
+
const handle = requireGroup(yeaftDir, groupId);
|
|
158
|
+
const meta = handle.getMeta();
|
|
159
|
+
handle.saveMeta({ ...meta, name });
|
|
160
|
+
const next = handle.getMeta();
|
|
161
|
+
handle.close();
|
|
162
|
+
return next;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* (A.3) Archive — renames the dir to `.archived-<ts>-<id>`. Directory
|
|
167
|
+
* prefix `.` keeps `listGroups` from picking it up (readdirSync filter in
|
|
168
|
+
* the caller). Reversible: user can rename back manually for recovery.
|
|
169
|
+
*
|
|
170
|
+
* We do NOT support hard-delete here — that's an upstream UI flow with its
|
|
171
|
+
* own second-confirm modal (acceptance #4 in task-334-slice-specs.md 334m).
|
|
172
|
+
*/
|
|
173
|
+
export function archiveGroup(yeaftDir, groupId) {
|
|
174
|
+
const root = groupsRoot(yeaftDir);
|
|
175
|
+
const srcDir = join(root, groupId);
|
|
176
|
+
if (!existsSync(srcDir) || !loadGroupMeta(srcDir)) {
|
|
177
|
+
throw new GroupCrudError('not_found', groupId);
|
|
178
|
+
}
|
|
179
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
180
|
+
// Append 4 hex chars to disambiguate same-millisecond archives (nit #5).
|
|
181
|
+
const suffix = randomBytes(2).toString('hex');
|
|
182
|
+
const dstDir = join(root, `.archived-${ts}-${suffix}-${groupId}`);
|
|
183
|
+
renameSync(srcDir, dstDir);
|
|
184
|
+
return { groupId, archivedAs: dstDir };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* (A.4) Add a VP to the group roster. Idempotent — no-op if already present.
|
|
189
|
+
* Returns the new meta.
|
|
190
|
+
*/
|
|
191
|
+
export function addMember(yeaftDir, groupId, vpId) {
|
|
192
|
+
const handle = requireGroup(yeaftDir, groupId);
|
|
193
|
+
try {
|
|
194
|
+
const meta = handle.getMeta();
|
|
195
|
+
const next = rosterAdd(meta, vpId);
|
|
196
|
+
handle.saveMeta(next);
|
|
197
|
+
return handle.getMeta();
|
|
198
|
+
} finally {
|
|
199
|
+
handle.close();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* (A.5) Remove a VP from the group roster. If the removed id was default,
|
|
205
|
+
* roster.removeVp rotates to the next member (or null).
|
|
206
|
+
*/
|
|
207
|
+
export function removeMember(yeaftDir, groupId, vpId) {
|
|
208
|
+
const handle = requireGroup(yeaftDir, groupId);
|
|
209
|
+
try {
|
|
210
|
+
const meta = handle.getMeta();
|
|
211
|
+
if (!meta.roster.includes(vpId)) {
|
|
212
|
+
// Treat as idempotent no-op — UI wants the post-state.
|
|
213
|
+
return meta;
|
|
214
|
+
}
|
|
215
|
+
const next = rosterRemove(meta, vpId);
|
|
216
|
+
handle.saveMeta(next);
|
|
217
|
+
return handle.getMeta();
|
|
218
|
+
} finally {
|
|
219
|
+
handle.close();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Expose default-VP setter for UI "set as default" affordance. */
|
|
224
|
+
export function setGroupDefaultVp(yeaftDir, groupId, vpId) {
|
|
225
|
+
const handle = requireGroup(yeaftDir, groupId);
|
|
226
|
+
try {
|
|
227
|
+
const meta = handle.getMeta();
|
|
228
|
+
const next = setDefaultVp(meta, vpId);
|
|
229
|
+
handle.saveMeta(next);
|
|
230
|
+
return handle.getMeta();
|
|
231
|
+
} finally {
|
|
232
|
+
handle.close();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function requireGroup(yeaftDir, groupId) {
|
|
237
|
+
const root = groupsRoot(yeaftDir);
|
|
238
|
+
const dir = join(root, groupId);
|
|
239
|
+
if (!existsSync(dir) || !loadGroupMeta(dir)) {
|
|
240
|
+
throw new GroupCrudError('not_found', groupId);
|
|
241
|
+
}
|
|
242
|
+
return openGroup(root, groupId);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Convenience: snapshot all non-archived groups for WS broadcast. */
|
|
246
|
+
export function snapshotGroups(yeaftDir) {
|
|
247
|
+
return listGroups(groupsRoot(yeaftDir));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export { DEFAULT_GROUP_ID };
|
package/unify/groups/index.js
CHANGED
|
@@ -37,6 +37,18 @@ export {
|
|
|
37
37
|
seedDefaultGroup,
|
|
38
38
|
DEFAULT_GROUP_ID,
|
|
39
39
|
} from './seed-default.js';
|
|
40
|
+
export {
|
|
41
|
+
GroupCrudError,
|
|
42
|
+
makeGroupId,
|
|
43
|
+
ensureDefaultGroupIfEmpty,
|
|
44
|
+
createGroupFromSpec,
|
|
45
|
+
renameGroup,
|
|
46
|
+
archiveGroup,
|
|
47
|
+
addMember,
|
|
48
|
+
removeMember,
|
|
49
|
+
setGroupDefaultVp,
|
|
50
|
+
snapshotGroups,
|
|
51
|
+
} from './group-crud.js';
|
|
40
52
|
export {
|
|
41
53
|
nextMsgId,
|
|
42
54
|
nextGroupId,
|
|
@@ -1,22 +1,89 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* migrate-r5-to-r6.js — task-
|
|
2
|
+
* migrate-r5-to-r6.js — task-334i (wave-4) R5 → R6 storage migration.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Continuation of task-334i-v0 (PR #552, shipped v0.1.521) which established
|
|
5
|
+
* the general `~/.yeaft/` tree via `v0-to-v1.js`. This slice implements the
|
|
6
|
+
* R5→R6 *memory shard + conversation rotation* pass that fleshes out the
|
|
7
|
+
* previously-stubbed `applyR5ToR6Migration`.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
* Semantic shards: skill / relations / lessons / preferences /
|
|
9
|
-
* project-<slug>
|
|
9
|
+
* Spec: .crew/context/task-334i-impl-spec.md
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Key invariants:
|
|
12
|
+
* - This is independently idempotent from v0→v1. State version bumps r5→r6.
|
|
13
|
+
* - Does NOT touch 334f `shard-store.js` or 334o storage primitives —
|
|
14
|
+
* only consumes their public APIs.
|
|
15
|
+
* - Legacy R5 data is archived to `.legacy/r6-state.tar.gz` BEFORE any
|
|
16
|
+
* write. Rollback restores the archive but never deletes it.
|
|
17
|
+
* - `migration-state.json` is the single source of truth; writes go
|
|
18
|
+
* through `writeAtomic` (tmp+rename) mirroring 334f commitRecompression.
|
|
19
|
+
*
|
|
20
|
+
* Two-pass algorithm (pre-emptive discovery #2):
|
|
21
|
+
* Pass 1 — write each entry to its default semantic shard
|
|
22
|
+
* (skill / relations / lessons / preferences / project-legacy)
|
|
23
|
+
* while counting entries per groupId.
|
|
24
|
+
* Pass 2 — for each groupId with count ≥ PROJECT_DERIVE_THRESHOLD (30),
|
|
25
|
+
* derive a `project-<slug>` shard and move entries via
|
|
26
|
+
* stageRecompression / commitRecompression.
|
|
27
|
+
*
|
|
28
|
+
* Name drift fix (pre-emptive discovery #1):
|
|
29
|
+
* `map-fields.js` (shipped) defines MIGRATION_AUTHOR='system:migration-v0-to-v1'.
|
|
30
|
+
* Correct spec value is 'system:migration-v0-v1'. This file overrides via
|
|
31
|
+
* local constants without editing the shipped pure-mapper module.
|
|
13
32
|
*/
|
|
14
33
|
|
|
15
|
-
import {
|
|
16
|
-
|
|
34
|
+
import {
|
|
35
|
+
existsSync,
|
|
36
|
+
mkdirSync,
|
|
37
|
+
readdirSync,
|
|
38
|
+
readFileSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
renameSync,
|
|
41
|
+
rmSync,
|
|
42
|
+
statSync,
|
|
43
|
+
} from 'fs';
|
|
44
|
+
import { join, dirname } from 'path';
|
|
45
|
+
import { createHash } from 'crypto';
|
|
46
|
+
import { execFileSync } from 'child_process';
|
|
47
|
+
|
|
48
|
+
import { openLog, writeAtomic } from '../storage/index.js';
|
|
17
49
|
import { parseEntry } from './store.js';
|
|
18
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
openMemoryShardStore,
|
|
52
|
+
classifyLegacyEntryToShard,
|
|
53
|
+
} from './shard-store.js';
|
|
54
|
+
import { PROJECT_DERIVE_THRESHOLD } from './schema.js';
|
|
55
|
+
import {
|
|
56
|
+
parseFrontmatter,
|
|
57
|
+
mapMessageMdToJsonl,
|
|
58
|
+
splitCoordinatorTurns,
|
|
59
|
+
LEGACY_GROUP_ID,
|
|
60
|
+
LEGACY_VP_ID,
|
|
61
|
+
} from '../migration/map-fields.js';
|
|
62
|
+
|
|
63
|
+
// ─── Name-drift fix (spec §3, §4) ────────────────────────────────
|
|
64
|
+
export const R5_TO_R6_AUTHOR_SYS = 'system:migration-v0-v1';
|
|
65
|
+
export const R5_TO_R6_AUTHOR_USER = 'user:migration-v0-v1';
|
|
66
|
+
const SOURCE_HINT = 'legacy-r5-migration';
|
|
67
|
+
const STATE_FILE = 'migration-state.json';
|
|
68
|
+
const ARCHIVE_REL = join('.legacy', 'r6-state.tar.gz');
|
|
69
|
+
|
|
70
|
+
// authoredBy inference table (spec §3 deliverable E).
|
|
71
|
+
function inferAuthoredBy(kind) {
|
|
72
|
+
switch (kind) {
|
|
73
|
+
case 'preference':
|
|
74
|
+
case 'identity':
|
|
75
|
+
return R5_TO_R6_AUTHOR_USER;
|
|
76
|
+
case 'fact':
|
|
77
|
+
case 'skill':
|
|
78
|
+
case 'lesson':
|
|
79
|
+
case 'context':
|
|
80
|
+
case 'relation':
|
|
81
|
+
default:
|
|
82
|
+
return R5_TO_R6_AUTHOR_SYS;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
19
85
|
|
|
86
|
+
// ─── Planner (dry-run, unchanged behaviour from 334f stub) ───────
|
|
20
87
|
/**
|
|
21
88
|
* Produce a migration plan without applying it.
|
|
22
89
|
*
|
|
@@ -50,12 +117,495 @@ export function planR5ToR6Migration(legacyEntriesDir) {
|
|
|
50
117
|
return { totalEntries: plan.length, plan, byShard };
|
|
51
118
|
}
|
|
52
119
|
|
|
120
|
+
// ─── State I/O ────────────────────────────────────────────────────
|
|
121
|
+
function stateFilePath(yeaftDir) {
|
|
122
|
+
return join(yeaftDir, STATE_FILE);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function loadState(yeaftDir) {
|
|
126
|
+
const p = stateFilePath(yeaftDir);
|
|
127
|
+
if (!existsSync(p)) return null;
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
130
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function saveState(yeaftDir, state) {
|
|
137
|
+
writeAtomic(stateFilePath(yeaftDir), JSON.stringify(state, null, 2));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function clearState(yeaftDir) {
|
|
141
|
+
const p = stateFilePath(yeaftDir);
|
|
142
|
+
if (existsSync(p)) rmSync(p);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function nowIso() { return new Date().toISOString(); }
|
|
146
|
+
|
|
147
|
+
function stableId(slug) {
|
|
148
|
+
const h = createHash('sha1').update(String(slug)).digest('hex').slice(0, 12);
|
|
149
|
+
return `mem_legacy_${h}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Archive helper (spec §11 step 3) ────────────────────────────
|
|
153
|
+
/**
|
|
154
|
+
* tar+gzip `memory/entries/` and `conversations/` into .legacy/r6-state.tar.gz.
|
|
155
|
+
* Uses the `tar` CLI via execFileSync (same pattern as 334i-v0). Any failure
|
|
156
|
+
* throws so the caller can bail before making destructive changes.
|
|
157
|
+
*/
|
|
158
|
+
export function archiveR5State(yeaftDir) {
|
|
159
|
+
const legacyDir = join(yeaftDir, '.legacy');
|
|
160
|
+
mkdirSync(legacyDir, { recursive: true });
|
|
161
|
+
const archivePath = join(yeaftDir, ARCHIVE_REL);
|
|
162
|
+
const entriesDir = join(yeaftDir, 'memory', 'entries');
|
|
163
|
+
const conversationsDir = join(yeaftDir, 'conversations');
|
|
164
|
+
const args = ['-czf', archivePath, '-C', yeaftDir];
|
|
165
|
+
let added = 0;
|
|
166
|
+
if (existsSync(entriesDir)) { args.push(join('memory', 'entries')); added++; }
|
|
167
|
+
if (existsSync(conversationsDir)) { args.push('conversations'); added++; }
|
|
168
|
+
if (added === 0) {
|
|
169
|
+
// Write a zero-content marker so rollback has a file to inspect.
|
|
170
|
+
writeAtomic(archivePath, '');
|
|
171
|
+
return archivePath;
|
|
172
|
+
}
|
|
173
|
+
execFileSync('tar', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
174
|
+
return archivePath;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Detect helpers ──────────────────────────────────────────────
|
|
178
|
+
/**
|
|
179
|
+
* Classify the shape of the R5 memory layout in `yeaftDir`.
|
|
180
|
+
*/
|
|
181
|
+
export function detectR5MemoryLayout(yeaftDir) {
|
|
182
|
+
const entriesDir = join(yeaftDir, 'memory', 'entries');
|
|
183
|
+
const conversationsDir = join(yeaftDir, 'conversations');
|
|
184
|
+
const groupsDir = join(yeaftDir, 'groups');
|
|
185
|
+
const hasEntries = existsSync(entriesDir) && readdirSync(entriesDir).some(f => f.endsWith('.md'));
|
|
186
|
+
const hasConversationsMd = existsSync(conversationsDir)
|
|
187
|
+
&& readdirSync(conversationsDir).some(() => true);
|
|
188
|
+
const hasGroupsJsonl = existsSync(groupsDir);
|
|
189
|
+
return {
|
|
190
|
+
entriesDir,
|
|
191
|
+
conversationsDir,
|
|
192
|
+
groupsDir,
|
|
193
|
+
hasEntries,
|
|
194
|
+
hasConversationsMd,
|
|
195
|
+
hasGroupsJsonl,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Pass 1: write entries to default shards ─────────────────────
|
|
200
|
+
function runPass1({ yeaftDir, layout, vpDir, log, existingState }) {
|
|
201
|
+
const shardStore = openMemoryShardStore(vpDir, 'vp');
|
|
202
|
+
const files = layout.hasEntries
|
|
203
|
+
? readdirSync(layout.entriesDir).filter(f => f.endsWith('.md')).sort()
|
|
204
|
+
: [];
|
|
205
|
+
const counts = (existingState && existingState.counts) || {};
|
|
206
|
+
const migrated = [];
|
|
207
|
+
const errors = [];
|
|
208
|
+
|
|
209
|
+
for (const file of files) {
|
|
210
|
+
const slug = file.replace(/\.md$/, '');
|
|
211
|
+
const id = stableId(slug);
|
|
212
|
+
// Idempotency: skip already-migrated ids.
|
|
213
|
+
if (shardStore.get(id)) {
|
|
214
|
+
migrated.push({ id, slug, shard: shardStore.get(id).shard, skipped: true });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
let raw;
|
|
218
|
+
try {
|
|
219
|
+
raw = readFileSync(join(layout.entriesDir, file), 'utf8');
|
|
220
|
+
} catch (e) {
|
|
221
|
+
errors.push({ file, error: String(e.message || e) });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const legacy = parseEntry(raw);
|
|
225
|
+
if (!legacy) {
|
|
226
|
+
errors.push({ file, error: 'parseEntry returned null (malformed frontmatter)' });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const shard = classifyLegacyEntryToShard(legacy);
|
|
230
|
+
const kind = legacy.kind || 'fact';
|
|
231
|
+
const tags = Array.isArray(legacy.tags) ? legacy.tags.slice() : [];
|
|
232
|
+
const createdAt = legacy.created_at || nowIso();
|
|
233
|
+
const updatedAt = legacy.updated_at || createdAt;
|
|
234
|
+
// Determine groupId for project-derive counting. Legacy schema has no
|
|
235
|
+
// explicit groupId; fall back to scope's top segment, else LEGACY_GROUP_ID.
|
|
236
|
+
const groupId = deriveGroupId(legacy);
|
|
237
|
+
|
|
238
|
+
// identity/preference kinds are allowed empty msgIds per §Δ23.
|
|
239
|
+
// Other kinds rely on the hint='legacy-r5-migration' to legitimise [].
|
|
240
|
+
// `validateR6Entry` requires non-empty msgIds for non-identity/preference —
|
|
241
|
+
// so we put a synthetic legacy marker to keep the validator happy while
|
|
242
|
+
// still conveying "migrated, no real messages attached" semantically.
|
|
243
|
+
const needsMsgIdMarker = !(kind === 'identity' || kind === 'preference');
|
|
244
|
+
const msgIds = needsMsgIdMarker ? [`legacy:${slug}`] : [];
|
|
245
|
+
|
|
246
|
+
const entry = {
|
|
247
|
+
id,
|
|
248
|
+
shard,
|
|
249
|
+
kind,
|
|
250
|
+
tags,
|
|
251
|
+
pinned: legacy.importance === 'high',
|
|
252
|
+
sourceRef: {
|
|
253
|
+
groupId,
|
|
254
|
+
taskId: null,
|
|
255
|
+
msgIds,
|
|
256
|
+
timeWindow: `[${createdAt}, ${updatedAt}]`,
|
|
257
|
+
hint: SOURCE_HINT,
|
|
258
|
+
},
|
|
259
|
+
supersedes: null,
|
|
260
|
+
supersededBy: null,
|
|
261
|
+
authoredBy: inferAuthoredBy(kind),
|
|
262
|
+
createdAt,
|
|
263
|
+
updatedAt,
|
|
264
|
+
body: legacy.content || '',
|
|
265
|
+
};
|
|
266
|
+
try {
|
|
267
|
+
shardStore.put(entry);
|
|
268
|
+
counts[groupId] = (counts[groupId] || 0) + 1;
|
|
269
|
+
migrated.push({ id, slug, shard, groupId });
|
|
270
|
+
} catch (e) {
|
|
271
|
+
errors.push({ file, error: String(e.message || e) });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
log('pass1', { migrated: migrated.length, errors: errors.length });
|
|
276
|
+
return { counts, migrated, errors };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function deriveGroupId(legacyEntry) {
|
|
280
|
+
// scope is a path like "work/project-name/auth". Use first segment as
|
|
281
|
+
// coarse groupId; fall back to LEGACY_GROUP_ID.
|
|
282
|
+
const scope = legacyEntry && legacyEntry.scope;
|
|
283
|
+
if (typeof scope === 'string' && scope.trim()) {
|
|
284
|
+
const first = scope.split('/').map(s => s.trim()).filter(Boolean)[0];
|
|
285
|
+
if (first) return first;
|
|
286
|
+
}
|
|
287
|
+
return LEGACY_GROUP_ID;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ─── Pass 2: project-<slug> derive ───────────────────────────────
|
|
291
|
+
function runPass2({ vpDir, counts, log }) {
|
|
292
|
+
const derived = [];
|
|
293
|
+
const qualifyingShards = Object.entries(counts || {})
|
|
294
|
+
.filter(([, c]) => c >= PROJECT_DERIVE_THRESHOLD)
|
|
295
|
+
.map(([g]) => `project-${slugify(g)}`);
|
|
296
|
+
// Re-open with project-<slug> allow-listed up front so put() validates.
|
|
297
|
+
const shardStore = openMemoryShardStore(vpDir, 'vp', { extraShards: qualifyingShards });
|
|
298
|
+
for (const [groupId, count] of Object.entries(counts || {})) {
|
|
299
|
+
if (count < PROJECT_DERIVE_THRESHOLD) continue;
|
|
300
|
+
const slug = slugify(groupId);
|
|
301
|
+
const targetShard = `project-${slug}`;
|
|
302
|
+
// Skip if this project shard already exists and is populated — re-entry safe.
|
|
303
|
+
const stats = shardStore.stats();
|
|
304
|
+
if (stats.shards[targetShard] && stats.shards[targetShard].count > 0) {
|
|
305
|
+
derived.push({ groupId, shard: targetShard, moved: 0, skipped: true });
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// Collect entries in the relevant default shard matching this groupId.
|
|
309
|
+
// Search across all default shards (classification is kind-driven; a
|
|
310
|
+
// groupId may span skill/lessons/etc).
|
|
311
|
+
const { results } = shardStore.query({});
|
|
312
|
+
const moveIds = results
|
|
313
|
+
.filter(r => r.groupId === groupId)
|
|
314
|
+
.map(r => r.id);
|
|
315
|
+
let moved = 0;
|
|
316
|
+
for (const id of moveIds) {
|
|
317
|
+
const full = shardStore.get(id);
|
|
318
|
+
if (!full) continue;
|
|
319
|
+
// Re-put with new shard; old entry removal happens via supersede-free
|
|
320
|
+
// rewrite by removing old id after the new one lands.
|
|
321
|
+
const newEntry = {
|
|
322
|
+
...full,
|
|
323
|
+
shard: targetShard,
|
|
324
|
+
body: full.body || '',
|
|
325
|
+
};
|
|
326
|
+
try {
|
|
327
|
+
shardStore.put(newEntry);
|
|
328
|
+
// shardStore.put upserts by id (see 334o shard-store put semantics
|
|
329
|
+
// removing any prior shard copy of the same id) — so the entry now
|
|
330
|
+
// lives in targetShard exclusively.
|
|
331
|
+
moved++;
|
|
332
|
+
} catch {
|
|
333
|
+
// best-effort — keep legacy in default shard if move fails.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
derived.push({ groupId, shard: targetShard, moved });
|
|
337
|
+
}
|
|
338
|
+
log('pass2', { derived: derived.length });
|
|
339
|
+
return derived;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function slugify(s) {
|
|
343
|
+
return String(s || '')
|
|
344
|
+
.toLowerCase()
|
|
345
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
346
|
+
.replace(/^-+|-+$/g, '')
|
|
347
|
+
.slice(0, 40) || 'legacy';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─── Conversation migration ──────────────────────────────────────
|
|
351
|
+
function migrateConversations({ yeaftDir, layout, log }) {
|
|
352
|
+
if (!layout.hasConversationsMd) {
|
|
353
|
+
log('conversations', { messages: 0, shards: 0 });
|
|
354
|
+
return { messages: 0, shards: 0 };
|
|
355
|
+
}
|
|
356
|
+
const groupDir = join(yeaftDir, 'groups', LEGACY_GROUP_ID, 'messages');
|
|
357
|
+
mkdirSync(groupDir, { recursive: true });
|
|
358
|
+
const log_ = openLog(groupDir);
|
|
359
|
+
let messagesWritten = 0;
|
|
360
|
+
const convos = readdirSync(layout.conversationsDir);
|
|
361
|
+
for (const cId of convos) {
|
|
362
|
+
const msgDir = join(layout.conversationsDir, cId, 'messages');
|
|
363
|
+
if (!existsSync(msgDir)) continue;
|
|
364
|
+
const files = readdirSync(msgDir).filter(f => f.endsWith('.md')).sort();
|
|
365
|
+
for (const file of files) {
|
|
366
|
+
const raw = readFileSync(join(msgDir, file), 'utf8');
|
|
367
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
368
|
+
const originalId = `${cId}_${file.replace(/\.md$/, '')}`;
|
|
369
|
+
const row = mapMessageMdToJsonl({ meta, body, originalId, fallbackTaskId: null });
|
|
370
|
+
try {
|
|
371
|
+
log_.append(row);
|
|
372
|
+
messagesWritten++;
|
|
373
|
+
} catch {
|
|
374
|
+
// Skip malformed row; keep going.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Also handle coordinator.md if present
|
|
378
|
+
const coordPath = join(layout.conversationsDir, cId, 'coordinator.md');
|
|
379
|
+
if (existsSync(coordPath)) {
|
|
380
|
+
const raw = readFileSync(coordPath, 'utf8');
|
|
381
|
+
const turns = splitCoordinatorTurns(raw);
|
|
382
|
+
for (const turn of turns) {
|
|
383
|
+
const row = {
|
|
384
|
+
id: `msg_legacy_${cId}_coord_${turn.index}`,
|
|
385
|
+
ts: turn.ts || null,
|
|
386
|
+
type: 'chat',
|
|
387
|
+
authorKind: 'unknown',
|
|
388
|
+
authorId: `legacy:${turn.role}`,
|
|
389
|
+
groupId: LEGACY_GROUP_ID,
|
|
390
|
+
taskId: null,
|
|
391
|
+
body: turn.body,
|
|
392
|
+
mentions: [],
|
|
393
|
+
replyTo: null,
|
|
394
|
+
viaTool: null,
|
|
395
|
+
};
|
|
396
|
+
try { log_.append(row); messagesWritten++; } catch { /* skip */ }
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
log_.close();
|
|
401
|
+
const index = log_.getIndex();
|
|
402
|
+
log('conversations', { messages: messagesWritten, shards: (index.segments || []).length });
|
|
403
|
+
return { messages: messagesWritten, shards: (index.segments || []).length };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ─── Main entry ──────────────────────────────────────────────────
|
|
407
|
+
/**
|
|
408
|
+
* Apply the R5 → R6 migration.
|
|
409
|
+
*
|
|
410
|
+
* @param {object} opts
|
|
411
|
+
* @param {string} opts.yeaftDir required
|
|
412
|
+
* @param {string} [opts.vpId] legacy VP id (default LEGACY_VP_ID)
|
|
413
|
+
* @param {boolean} [opts.dryRun]
|
|
414
|
+
* @param {boolean} [opts.force] clear existing r6 state and re-run from scratch
|
|
415
|
+
* @param {(step, info)=>void} [opts.onStep]
|
|
416
|
+
* @returns {Promise<object>}
|
|
417
|
+
*/
|
|
418
|
+
export async function applyR5ToR6Migration(opts = {}) {
|
|
419
|
+
const { yeaftDir, dryRun = false, force = false, onStep } = opts;
|
|
420
|
+
const vpId = opts.vpId || LEGACY_VP_ID;
|
|
421
|
+
if (!yeaftDir || typeof yeaftDir !== 'string') {
|
|
422
|
+
throw new Error('applyR5ToR6Migration: yeaftDir (string) required');
|
|
423
|
+
}
|
|
424
|
+
if (!existsSync(yeaftDir)) {
|
|
425
|
+
throw new Error(`applyR5ToR6Migration: yeaftDir does not exist: ${yeaftDir}`);
|
|
426
|
+
}
|
|
427
|
+
const log = typeof onStep === 'function' ? onStep : () => {};
|
|
428
|
+
const layout = detectR5MemoryLayout(yeaftDir);
|
|
429
|
+
|
|
430
|
+
if (dryRun) {
|
|
431
|
+
const plan = planR5ToR6Migration(layout.entriesDir);
|
|
432
|
+
const counts = {};
|
|
433
|
+
for (const p of plan.plan) {
|
|
434
|
+
// coarse counting keyed by synthetic groupId based on slug prefix (best-effort preview)
|
|
435
|
+
counts[LEGACY_GROUP_ID] = (counts[LEGACY_GROUP_ID] || 0) + 1;
|
|
436
|
+
}
|
|
437
|
+
const wouldDerive = Object.entries(counts)
|
|
438
|
+
.filter(([, c]) => c >= PROJECT_DERIVE_THRESHOLD)
|
|
439
|
+
.map(([g]) => `project-${slugify(g)}`);
|
|
440
|
+
let estMessages = 0;
|
|
441
|
+
if (layout.hasConversationsMd) {
|
|
442
|
+
for (const cId of readdirSync(layout.conversationsDir)) {
|
|
443
|
+
const msgDir = join(layout.conversationsDir, cId, 'messages');
|
|
444
|
+
if (existsSync(msgDir)) {
|
|
445
|
+
estMessages += readdirSync(msgDir).filter(f => f.endsWith('.md')).length;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const preview = {
|
|
450
|
+
pass1: plan.byShard,
|
|
451
|
+
pass2Candidates: wouldDerive,
|
|
452
|
+
conversations: {
|
|
453
|
+
count: layout.hasConversationsMd ? readdirSync(layout.conversationsDir).length : 0,
|
|
454
|
+
estimatedMessages: estMessages,
|
|
455
|
+
estimatedShards: Math.max(1, Math.ceil(estMessages / 5000)),
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
log('dry-run', preview);
|
|
459
|
+
return { status: 'dry-run', dryRun: true, preview };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Force: wipe only r6 state, never touch legacy archive.
|
|
463
|
+
if (force) clearState(yeaftDir);
|
|
464
|
+
|
|
465
|
+
let state = loadState(yeaftDir);
|
|
466
|
+
if (state && state.version === 'r6' && state.pass2CompletedAt) {
|
|
467
|
+
log('already-done', { migratedAt: state.migratedAt });
|
|
468
|
+
return { status: 'already-done', state };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Fresh state — but preserve a pre-existing r5 state (from PR #552) if present.
|
|
472
|
+
if (!state || state.version !== 'r6') {
|
|
473
|
+
const prior = state || {};
|
|
474
|
+
state = {
|
|
475
|
+
version: 'r6',
|
|
476
|
+
startedAt: nowIso(),
|
|
477
|
+
legacyArchive: null,
|
|
478
|
+
pass1CompletedAt: null,
|
|
479
|
+
pass2CompletedAt: null,
|
|
480
|
+
migratedAt: null,
|
|
481
|
+
counts: {},
|
|
482
|
+
derivedProjects: [],
|
|
483
|
+
messageCount: 0,
|
|
484
|
+
entryCount: 0,
|
|
485
|
+
// Preserve reference to prior r5 state for audit.
|
|
486
|
+
priorR5: prior && prior.version === 'r5' ? { completedAt: prior.completedAt || null } : null,
|
|
487
|
+
};
|
|
488
|
+
saveState(yeaftDir, state);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
// Archive R5 state BEFORE any writes (or skip if already archived in a prior resume).
|
|
493
|
+
if (!state.legacyArchive) {
|
|
494
|
+
const archivePath = archiveR5State(yeaftDir);
|
|
495
|
+
state.legacyArchive = archivePath;
|
|
496
|
+
saveState(yeaftDir, state);
|
|
497
|
+
log('archive', { path: archivePath });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const vpDir = join(yeaftDir, 'memory', 'vp', vpId);
|
|
501
|
+
mkdirSync(vpDir, { recursive: true });
|
|
502
|
+
|
|
503
|
+
// Pass 1
|
|
504
|
+
if (!state.pass1CompletedAt) {
|
|
505
|
+
const p1 = runPass1({ yeaftDir, layout, vpDir, log, existingState: state });
|
|
506
|
+
state.counts = p1.counts;
|
|
507
|
+
state.entryCount = (state.entryCount || 0) + p1.migrated.filter(m => !m.skipped).length;
|
|
508
|
+
state.pass1CompletedAt = nowIso();
|
|
509
|
+
state.pass1Errors = p1.errors;
|
|
510
|
+
saveState(yeaftDir, state);
|
|
511
|
+
} else {
|
|
512
|
+
log('pass1', { skipped: true });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Pass 2
|
|
516
|
+
if (!state.pass2CompletedAt) {
|
|
517
|
+
const derived = runPass2({ vpDir, counts: state.counts, log });
|
|
518
|
+
state.derivedProjects = derived.map(d => d.shard);
|
|
519
|
+
state.pass2CompletedAt = nowIso();
|
|
520
|
+
saveState(yeaftDir, state);
|
|
521
|
+
} else {
|
|
522
|
+
log('pass2', { skipped: true });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Conversations (always run once — guarded by index existence).
|
|
526
|
+
if (!state.conversationsMigratedAt) {
|
|
527
|
+
const convRes = migrateConversations({ yeaftDir, layout, log });
|
|
528
|
+
state.messageCount = convRes.messages;
|
|
529
|
+
state.conversationsMigratedAt = nowIso();
|
|
530
|
+
saveState(yeaftDir, state);
|
|
531
|
+
} else {
|
|
532
|
+
log('conversations', { skipped: true });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
state.migratedAt = nowIso();
|
|
536
|
+
saveState(yeaftDir, state);
|
|
537
|
+
log('done', { migratedAt: state.migratedAt });
|
|
538
|
+
return { status: 'done', state };
|
|
539
|
+
} catch (err) {
|
|
540
|
+
// On error: state file preserved so next run resumes. Archive untouched.
|
|
541
|
+
state.lastError = String(err && err.message || err);
|
|
542
|
+
saveState(yeaftDir, state);
|
|
543
|
+
throw err;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ─── Rollback (deliverable G) ────────────────────────────────────
|
|
53
548
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
549
|
+
* Roll back an R5→R6 migration. Restores from .legacy/r6-state.tar.gz and
|
|
550
|
+
* clears r6-specific state. Never touches the separate r5 archive created
|
|
551
|
+
* by v0-to-v1.js. Idempotent: safe to call when no r6 state is present.
|
|
56
552
|
*
|
|
57
|
-
* @param {object}
|
|
553
|
+
* @param {object} opts
|
|
554
|
+
* @param {string} opts.yeaftDir required
|
|
555
|
+
* @param {string} [opts.vpId] legacy VP id (default LEGACY_VP_ID)
|
|
556
|
+
* @param {(step, info)=>void} [opts.onStep]
|
|
58
557
|
*/
|
|
59
|
-
export async function
|
|
60
|
-
|
|
558
|
+
export async function rollbackR5ToR6Migration(opts = {}) {
|
|
559
|
+
const { yeaftDir, onStep } = opts;
|
|
560
|
+
const vpId = opts.vpId || LEGACY_VP_ID;
|
|
561
|
+
if (!yeaftDir || typeof yeaftDir !== 'string') {
|
|
562
|
+
throw new Error('rollbackR5ToR6Migration: yeaftDir required');
|
|
563
|
+
}
|
|
564
|
+
const log = typeof onStep === 'function' ? onStep : () => {};
|
|
565
|
+
const state = loadState(yeaftDir);
|
|
566
|
+
if (!state || state.version !== 'r6') {
|
|
567
|
+
log('noop', { reason: 'no r6 state file present' });
|
|
568
|
+
return { status: 'noop' };
|
|
569
|
+
}
|
|
570
|
+
const archivePath = state.legacyArchive;
|
|
571
|
+
if (!archivePath || !existsSync(archivePath)) {
|
|
572
|
+
throw new Error(`rollbackR5ToR6Migration: archive missing at ${archivePath}`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Delete R6-specific paths first (only what this migration created).
|
|
576
|
+
const vpDir = join(yeaftDir, 'memory', 'vp', vpId);
|
|
577
|
+
if (existsSync(vpDir)) {
|
|
578
|
+
rmSync(vpDir, { recursive: true, force: true });
|
|
579
|
+
log('rm-vp-memory', { path: vpDir });
|
|
580
|
+
}
|
|
581
|
+
const groupsDir = join(yeaftDir, 'groups', LEGACY_GROUP_ID, 'messages');
|
|
582
|
+
if (existsSync(groupsDir)) {
|
|
583
|
+
rmSync(groupsDir, { recursive: true, force: true });
|
|
584
|
+
log('rm-group-messages', { path: groupsDir });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Restore archive back to yeaftDir. tar -xzf will overwrite paths it owns.
|
|
588
|
+
// Only do this if archive is non-empty (empty marker = nothing was archived).
|
|
589
|
+
const sz = statSync(archivePath).size;
|
|
590
|
+
if (sz > 0) {
|
|
591
|
+
execFileSync('tar', ['-xzf', archivePath, '-C', yeaftDir], {
|
|
592
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
593
|
+
});
|
|
594
|
+
log('restore', { from: archivePath });
|
|
595
|
+
} else {
|
|
596
|
+
log('restore', { from: archivePath, note: 'empty archive — nothing to restore' });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Downgrade state to r5 marker (archive left on disk for audit).
|
|
600
|
+
const newState = {
|
|
601
|
+
version: 'r5',
|
|
602
|
+
rolledBackAt: nowIso(),
|
|
603
|
+
previousR6: {
|
|
604
|
+
migratedAt: state.migratedAt,
|
|
605
|
+
legacyArchive: state.legacyArchive,
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
saveState(yeaftDir, newState);
|
|
609
|
+
log('done', { rolledBackAt: newState.rolledBackAt });
|
|
610
|
+
return { status: 'done', state: newState };
|
|
61
611
|
}
|
package/unify/session.js
CHANGED
|
@@ -31,6 +31,7 @@ import { getThreadStore } from './threads/store.js';
|
|
|
31
31
|
import { createIntentClassifier } from './router/intent-classifier.js';
|
|
32
32
|
import { initInputQueueStore } from './input-queue/store.js';
|
|
33
33
|
import { createDispatcher } from './pipeline/dispatcher.js';
|
|
34
|
+
import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
|
|
34
35
|
import { join } from 'path';
|
|
35
36
|
import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe } from 'fs';
|
|
36
37
|
|
|
@@ -164,6 +165,20 @@ export async function loadSession(options = {}) {
|
|
|
164
165
|
idleArchiveDays: config.unify?.autoArchiveIdleDays ?? 0,
|
|
165
166
|
});
|
|
166
167
|
|
|
168
|
+
// ─── 5c. D1 first-boot seed (task-334m) ─────────────────
|
|
169
|
+
// When no groups exist on disk AND we're not in read-only mode,
|
|
170
|
+
// seed a default group with roster = all VPs in the library,
|
|
171
|
+
// defaultVpId = alphabetically first. Idempotent — no-op when
|
|
172
|
+
// any group already exists. Never throws; failure logs a warning
|
|
173
|
+
// so session load always succeeds.
|
|
174
|
+
if (!config._readOnly) {
|
|
175
|
+
try {
|
|
176
|
+
ensureDefaultGroupIfEmpty(yeaftDir);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.warn(`[Yeaft] ensureDefaultGroupIfEmpty failed: ${err?.message || err}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
167
182
|
// ─── 6. Load skills ────────────────────────────────────
|
|
168
183
|
let skillManager;
|
|
169
184
|
if (skipSkills) {
|
package/unify/web-bridge.js
CHANGED
|
@@ -33,6 +33,16 @@ import {
|
|
|
33
33
|
handleUnifyUserMemoryWrite as _handleUnifyUserMemoryWrite,
|
|
34
34
|
handleUnifyUserMemoryRemove as _handleUnifyUserMemoryRemove,
|
|
35
35
|
} from './user-memory.js';
|
|
36
|
+
import {
|
|
37
|
+
GroupCrudError,
|
|
38
|
+
createGroupFromSpec,
|
|
39
|
+
renameGroup,
|
|
40
|
+
archiveGroup,
|
|
41
|
+
addMember,
|
|
42
|
+
removeMember,
|
|
43
|
+
setGroupDefaultVp,
|
|
44
|
+
snapshotGroups,
|
|
45
|
+
} from './groups/group-crud.js';
|
|
36
46
|
|
|
37
47
|
/** @type {import('./session.js').Session | null} */
|
|
38
48
|
let session = null;
|
|
@@ -253,6 +263,151 @@ export function handleUnifyVpRead(msg) {
|
|
|
253
263
|
sendVpCrudResult({ op: 'read', requestId, ok: true, vpId, vp });
|
|
254
264
|
}
|
|
255
265
|
|
|
266
|
+
/**
|
|
267
|
+
* task-334m: Group CRUD wired to WS events (§Δ10 334m + R6 §Δ31.2).
|
|
268
|
+
*
|
|
269
|
+
* Message shapes (wire):
|
|
270
|
+
* unify_list_groups { requestId? }
|
|
271
|
+
* unify_create_group { payload: {name, roster?, defaultVpId?}, requestId? }
|
|
272
|
+
* unify_rename_group { groupId, name, requestId? }
|
|
273
|
+
* unify_archive_group { groupId, requestId? }
|
|
274
|
+
* unify_add_member { groupId, vpId, requestId? }
|
|
275
|
+
* unify_remove_member { groupId, vpId, requestId? }
|
|
276
|
+
* unify_set_default_vp { groupId, vpId, requestId? }
|
|
277
|
+
*
|
|
278
|
+
* Replies (sendUnifyEvent):
|
|
279
|
+
* { type: 'group_crud_result', op, requestId, ok, group?, groups?, error?: {code, groupId?, message?} }
|
|
280
|
+
*
|
|
281
|
+
* Post-change broadcast (when meta mutates):
|
|
282
|
+
* { type: 'group_roster_changed', groupId, roster, defaultVpId, name }
|
|
283
|
+
*/
|
|
284
|
+
function sendGroupCrudResult(payload) {
|
|
285
|
+
sendUnifyEvent({ type: 'group_crud_result', ...payload });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function sendGroupSnapshotBroadcast() {
|
|
289
|
+
try {
|
|
290
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
291
|
+
if (!yeaftDir) return;
|
|
292
|
+
const groups = snapshotGroups(yeaftDir);
|
|
293
|
+
sendUnifyEvent({ type: 'group_list_updated', groups });
|
|
294
|
+
} catch (err) {
|
|
295
|
+
console.warn('[Unify] sendGroupSnapshotBroadcast failed:', err?.message || err);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function sendGroupRosterChanged(group) {
|
|
300
|
+
if (!group) return;
|
|
301
|
+
sendUnifyEvent({
|
|
302
|
+
type: 'group_roster_changed',
|
|
303
|
+
groupId: group.id,
|
|
304
|
+
name: group.name,
|
|
305
|
+
roster: group.roster,
|
|
306
|
+
defaultVpId: group.defaultVpId,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function groupErrorPayload(err) {
|
|
311
|
+
return {
|
|
312
|
+
code: err instanceof GroupCrudError ? err.code : 'unknown',
|
|
313
|
+
groupId: err && err.groupId,
|
|
314
|
+
message: err && err.message,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function handleUnifyListGroups(msg) {
|
|
319
|
+
const requestId = msg && msg.requestId;
|
|
320
|
+
try {
|
|
321
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
322
|
+
const groups = snapshotGroups(yeaftDir);
|
|
323
|
+
sendGroupCrudResult({ op: 'list', requestId, ok: true, groups });
|
|
324
|
+
} catch (err) {
|
|
325
|
+
sendGroupCrudResult({ op: 'list', requestId, ok: false, error: groupErrorPayload(err) });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function handleUnifyCreateGroup(msg) {
|
|
330
|
+
const requestId = msg && msg.requestId;
|
|
331
|
+
const payload = (msg && msg.payload) || {};
|
|
332
|
+
try {
|
|
333
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
334
|
+
const group = createGroupFromSpec(yeaftDir, payload);
|
|
335
|
+
sendGroupCrudResult({ op: 'create', requestId, ok: true, group });
|
|
336
|
+
sendGroupSnapshotBroadcast();
|
|
337
|
+
} catch (err) {
|
|
338
|
+
sendGroupCrudResult({ op: 'create', requestId, ok: false, error: groupErrorPayload(err) });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function handleUnifyRenameGroup(msg) {
|
|
343
|
+
const requestId = msg && msg.requestId;
|
|
344
|
+
const groupId = msg && msg.groupId;
|
|
345
|
+
const name = msg && msg.name;
|
|
346
|
+
try {
|
|
347
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
348
|
+
const group = renameGroup(yeaftDir, groupId, name);
|
|
349
|
+
sendGroupCrudResult({ op: 'rename', requestId, ok: true, group });
|
|
350
|
+
sendGroupSnapshotBroadcast();
|
|
351
|
+
} catch (err) {
|
|
352
|
+
sendGroupCrudResult({ op: 'rename', requestId, ok: false, error: groupErrorPayload(err) });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function handleUnifyArchiveGroup(msg) {
|
|
357
|
+
const requestId = msg && msg.requestId;
|
|
358
|
+
const groupId = msg && msg.groupId;
|
|
359
|
+
try {
|
|
360
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
361
|
+
const result = archiveGroup(yeaftDir, groupId);
|
|
362
|
+
sendGroupCrudResult({ op: 'archive', requestId, ok: true, groupId: result.groupId });
|
|
363
|
+
sendGroupSnapshotBroadcast();
|
|
364
|
+
} catch (err) {
|
|
365
|
+
sendGroupCrudResult({ op: 'archive', requestId, ok: false, error: groupErrorPayload(err) });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function handleUnifyAddMember(msg) {
|
|
370
|
+
const requestId = msg && msg.requestId;
|
|
371
|
+
const groupId = msg && msg.groupId;
|
|
372
|
+
const vpId = msg && msg.vpId;
|
|
373
|
+
try {
|
|
374
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
375
|
+
const group = addMember(yeaftDir, groupId, vpId);
|
|
376
|
+
sendGroupCrudResult({ op: 'add_member', requestId, ok: true, group });
|
|
377
|
+
sendGroupRosterChanged(group);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
sendGroupCrudResult({ op: 'add_member', requestId, ok: false, error: groupErrorPayload(err) });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function handleUnifyRemoveMember(msg) {
|
|
384
|
+
const requestId = msg && msg.requestId;
|
|
385
|
+
const groupId = msg && msg.groupId;
|
|
386
|
+
const vpId = msg && msg.vpId;
|
|
387
|
+
try {
|
|
388
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
389
|
+
const group = removeMember(yeaftDir, groupId, vpId);
|
|
390
|
+
sendGroupCrudResult({ op: 'remove_member', requestId, ok: true, group });
|
|
391
|
+
sendGroupRosterChanged(group);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
sendGroupCrudResult({ op: 'remove_member', requestId, ok: false, error: groupErrorPayload(err) });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function handleUnifySetDefaultVp(msg) {
|
|
398
|
+
const requestId = msg && msg.requestId;
|
|
399
|
+
const groupId = msg && msg.groupId;
|
|
400
|
+
const vpId = msg && msg.vpId;
|
|
401
|
+
try {
|
|
402
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
403
|
+
const group = setGroupDefaultVp(yeaftDir, groupId, vpId);
|
|
404
|
+
sendGroupCrudResult({ op: 'set_default_vp', requestId, ok: true, group });
|
|
405
|
+
sendGroupRosterChanged(group);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
sendGroupCrudResult({ op: 'set_default_vp', requestId, ok: false, error: groupErrorPayload(err) });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
256
411
|
/**
|
|
257
412
|
* task-318 rev-1 fix: install live-setter bridge between the session's
|
|
258
413
|
* runtime handles (engineRegistry + threadStore) and `ctx.unifyRuntimeSettings`,
|
|
@@ -845,6 +1000,10 @@ export async function handleUnifyChat(msg) {
|
|
|
845
1000
|
// serverTime) so a freshly-connected client can restore inflight
|
|
846
1001
|
// status without waiting for the next engine event.
|
|
847
1002
|
sendThreadListSnapshot();
|
|
1003
|
+
// task-334m: push initial groups snapshot so the Sidebar Groups
|
|
1004
|
+
// section renders the full list immediately (including the D1
|
|
1005
|
+
// default group seeded during session bootstrap).
|
|
1006
|
+
sendGroupSnapshotBroadcast();
|
|
848
1007
|
}
|
|
849
1008
|
|
|
850
1009
|
// ─── Per-call AbortController (task-320) ──
|
|
@@ -1318,6 +1477,8 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
1318
1477
|
// mutation-delta stream; `thread_list_snapshot` is the single
|
|
1319
1478
|
// authoritative "everything right now" payload.
|
|
1320
1479
|
sendThreadListSnapshot();
|
|
1480
|
+
// task-334m: replay groups snapshot so Sidebar Groups rebuilds on refresh.
|
|
1481
|
+
sendGroupSnapshotBroadcast();
|
|
1321
1482
|
|
|
1322
1483
|
const limit = msg.limit || 50;
|
|
1323
1484
|
const messages = session.conversationStore.loadRecent(limit);
|