@yeaft/webchat-agent 0.1.515 → 0.1.516

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.515",
3
+ "version": "0.1.516",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,8 @@
1
+ /**
2
+ * index.js — Public barrel for agent/unify/vp.
3
+ */
4
+
5
+ export { parseRoleMd, loadVpFromDir, scanVpLibrary, count, DEFAULT_VP_LIB_DIR } from './vp-store.js';
6
+ export { RoleInstance } from './role-instance.js';
7
+ export { Registry, defaultRegistry } from './registry.js';
8
+ export { VpLoader } from './vp-loader.js';
@@ -0,0 +1,169 @@
1
+ /**
2
+ * registry.js — In-memory VP + RoleInstance registry.
3
+ *
4
+ * Two maps:
5
+ * - vpMap: vpId → VP
6
+ * - instanceMap: "<groupId>::<vpId>" → RoleInstance
7
+ *
8
+ * RoleInstance creation is idempotent: `getOrCreateRoleInstance(vpId, groupId)`
9
+ * returns the same instance for the same (vpId, groupId) pair.
10
+ *
11
+ * LRU eviction (per acceptance #3): when active instance count exceeds
12
+ * `softLimit.maxActiveRoleInstances` (default 40), the least-recently-used
13
+ * idle instance is evicted. Instances with `state !== 'idle'` are skipped
14
+ * over during eviction; if no idle candidate exists, the new instance is
15
+ * still created (soft limit — we do not hard-reject).
16
+ */
17
+
18
+ import { RoleInstance } from './role-instance.js';
19
+
20
+ const DEFAULT_MAX_ACTIVE_ROLE_INSTANCES = 40;
21
+
22
+ export class Registry {
23
+ constructor(options = {}) {
24
+ /** @type {Map<string, import('./vp-store.js').VP>} */
25
+ this.vpMap = new Map();
26
+ /** @type {Map<string, RoleInstance>} */
27
+ this.instanceMap = new Map();
28
+ this.softLimit = {
29
+ maxActiveRoleInstances: options.maxActiveRoleInstances ?? DEFAULT_MAX_ACTIVE_ROLE_INSTANCES,
30
+ };
31
+ /** Listeners for eviction / persona-refresh (optional). */
32
+ this._evictListeners = new Set();
33
+ }
34
+
35
+ // ─── VP map ────────────────────────────────────────────────────
36
+
37
+ setVp(vp) {
38
+ if (!vp || !vp.id) return;
39
+ this.vpMap.set(vp.id, vp);
40
+ }
41
+
42
+ /**
43
+ * Replace a VP's persona fields in-place, preserving identity so any
44
+ * RoleInstance with `.vp === vp` keeps its reference stable across
45
+ * hot-reload. Fields copied: name, role, traits, modelHint, persona,
46
+ * mtimeMs.
47
+ */
48
+ updateVpInPlace(next) {
49
+ const cur = this.vpMap.get(next.id);
50
+ if (!cur) {
51
+ this.setVp(next);
52
+ return next;
53
+ }
54
+ cur.name = next.name;
55
+ cur.role = next.role;
56
+ cur.traits = next.traits;
57
+ cur.modelHint = next.modelHint;
58
+ cur.persona = next.persona;
59
+ cur.mtimeMs = next.mtimeMs;
60
+ // dir / memoryDir / id stable
61
+ return cur;
62
+ }
63
+
64
+ removeVp(vpId) {
65
+ this.vpMap.delete(vpId);
66
+ // Also drop any RoleInstances bound to a vanished VP.
67
+ for (const [key, ri] of this.instanceMap) {
68
+ if (ri.vpId === vpId) {
69
+ this.instanceMap.delete(key);
70
+ }
71
+ }
72
+ }
73
+
74
+ getVp(vpId) {
75
+ return this.vpMap.get(vpId);
76
+ }
77
+
78
+ listVps() {
79
+ return Array.from(this.vpMap.values());
80
+ }
81
+
82
+ vpCount() {
83
+ return this.vpMap.size;
84
+ }
85
+
86
+ // ─── RoleInstance map ─────────────────────────────────────────
87
+
88
+ _key(groupId, vpId) {
89
+ return `${groupId}::${vpId}`;
90
+ }
91
+
92
+ /**
93
+ * Idempotent create: same (vpId, groupId) returns same instance.
94
+ * Triggers LRU eviction when the soft limit is exceeded.
95
+ *
96
+ * @param {string} vpId
97
+ * @param {string} groupId
98
+ * @returns {RoleInstance}
99
+ */
100
+ getOrCreateRoleInstance(vpId, groupId) {
101
+ const vp = this.vpMap.get(vpId);
102
+ if (!vp) throw new Error(`unknown vpId: ${vpId}`);
103
+
104
+ const key = this._key(groupId, vpId);
105
+ const existing = this.instanceMap.get(key);
106
+ if (existing) {
107
+ existing.touch();
108
+ return existing;
109
+ }
110
+
111
+ const ri = new RoleInstance({ vp, groupId });
112
+ this.instanceMap.set(key, ri);
113
+ this._maybeEvict(ri);
114
+ return ri;
115
+ }
116
+
117
+ getRoleInstance(vpId, groupId) {
118
+ return this.instanceMap.get(this._key(groupId, vpId));
119
+ }
120
+
121
+ dropRoleInstance(vpId, groupId) {
122
+ const key = this._key(groupId, vpId);
123
+ const ri = this.instanceMap.get(key);
124
+ if (!ri) return false;
125
+ this.instanceMap.delete(key);
126
+ return true;
127
+ }
128
+
129
+ activeRoleInstanceCount() {
130
+ return this.instanceMap.size;
131
+ }
132
+
133
+ listRoleInstances() {
134
+ return Array.from(this.instanceMap.values());
135
+ }
136
+
137
+ onEvict(listener) {
138
+ this._evictListeners.add(listener);
139
+ return () => this._evictListeners.delete(listener);
140
+ }
141
+
142
+ _maybeEvict(exclude) {
143
+ const limit = this.softLimit.maxActiveRoleInstances;
144
+ if (this.instanceMap.size <= limit) return;
145
+
146
+ // Collect idle instances (excluding the just-created one), sort by
147
+ // lastActivityAt ascending.
148
+ const idle = [];
149
+ for (const ri of this.instanceMap.values()) {
150
+ if (ri === exclude) continue;
151
+ if (ri.state === 'idle') idle.push(ri);
152
+ }
153
+ idle.sort((a, b) => a.lastActivityAt - b.lastActivityAt);
154
+
155
+ while (this.instanceMap.size > limit && idle.length > 0) {
156
+ const victim = idle.shift();
157
+ const key = this._key(victim.groupId, victim.vpId);
158
+ this.instanceMap.delete(key);
159
+ for (const l of this._evictListeners) {
160
+ try { l(victim); } catch { /* ignore */ }
161
+ }
162
+ }
163
+ // If still over limit because no idle candidates exist, we accept the
164
+ // soft-limit breach — per spec, softLimit is a target, not a hard cap.
165
+ }
166
+ }
167
+
168
+ /** Module-level default registry (convenience). */
169
+ export const defaultRegistry = new Registry();
@@ -0,0 +1,87 @@
1
+ /**
2
+ * role-instance.js — RoleInstance class (per-group per-VP runtime handle).
3
+ *
4
+ * Per task-334 architecture §5: an Engine run targets one RoleInstance.
5
+ * This slice (334a) defines the object shape + basic lifecycle; the actual
6
+ * engine.run(roleInstance, opts) integration lives in 334c. Downstream
7
+ * stores (threadStore / subagentPool / memoryStore) are lazily attached by
8
+ * their respective slices — 334a only reserves the slots.
9
+ *
10
+ * Hard constraint (a) from slice spec: do NOT import 334o shard-store.
11
+ * `memoryStore` is a nullable placeholder here.
12
+ */
13
+
14
+ /**
15
+ * @typedef {'idle'|'running'|'queued'|'error'} RoleInstanceState
16
+ */
17
+
18
+ let _seq = 0;
19
+ function nextInstanceId(vpId, groupId) {
20
+ _seq = (_seq + 1) >>> 0;
21
+ return `ri_${groupId}_${vpId}_${Date.now().toString(36)}_${_seq.toString(36)}`;
22
+ }
23
+
24
+ export class RoleInstance {
25
+ /**
26
+ * @param {{ vp: import('./vp-store.js').VP, groupId: string }} params
27
+ */
28
+ constructor({ vp, groupId }) {
29
+ if (!vp) throw new Error('RoleInstance requires a vp');
30
+ if (!groupId) throw new Error('RoleInstance requires a groupId');
31
+
32
+ this.id = nextInstanceId(vp.id, groupId);
33
+ this.vpId = vp.id;
34
+ this.groupId = groupId;
35
+ /** Live VP reference. Persona hot-reload mutates fields in place. */
36
+ this.vp = vp;
37
+
38
+ // ── Runtime state (preserved across persona hot-reload) ─────
39
+ /** @type {RoleInstanceState} */
40
+ this.state = 'idle';
41
+ /** Conversation messages appended by 334c. */
42
+ this.messages = [];
43
+ /** Pending input queue (334d). */
44
+ this.inputQueue = [];
45
+ /** Abort controller for in-flight engine.run (334c). */
46
+ this.abortController = null;
47
+
48
+ // ── Nullable sub-system handles (attached by later slices) ──
49
+ /** threadStore — 334h/334c bootstraps. */
50
+ this.threadStore = null;
51
+ /** subagentPool — 334c. */
52
+ this.subagentPool = null;
53
+ /** memoryStore — 334f wraps 334o; NOT created here. */
54
+ this.memoryStore = null;
55
+
56
+ // ── Telemetry ───────────────────────────────────────────────
57
+ this.createdAt = Date.now();
58
+ this.lastActivityAt = this.createdAt;
59
+ }
60
+
61
+ /** Update last-activity timestamp (used by LRU eviction). */
62
+ touch() {
63
+ this.lastActivityAt = Date.now();
64
+ }
65
+
66
+ /** Transition helper. */
67
+ setState(next) {
68
+ this.state = next;
69
+ this.touch();
70
+ }
71
+
72
+ /**
73
+ * Snapshot for debug / telemetry. Excludes bulky messages.
74
+ */
75
+ snapshot() {
76
+ return {
77
+ id: this.id,
78
+ vpId: this.vpId,
79
+ groupId: this.groupId,
80
+ state: this.state,
81
+ messageCount: this.messages.length,
82
+ queueDepth: this.inputQueue.length,
83
+ lastActivityAt: this.lastActivityAt,
84
+ createdAt: this.createdAt,
85
+ };
86
+ }
87
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * vp-loader.js — Hot-reload watcher for the VP library.
3
+ *
4
+ * Watches `~/.yeaft/virtual-persons/` for additions, removals, and role.md
5
+ * changes. Changes are debounced (default 500ms) and then applied:
6
+ *
7
+ * - new dir with role.md → registry.setVp(vp)
8
+ * - removed dir → registry.removeVp(vpId)
9
+ * - role.md modified → registry.updateVpInPlace(next)
10
+ * (persona fields swap; RoleInstance.runtimeState preserved — the VP
11
+ * object identity in registry is kept, so any RoleInstance holding
12
+ * `.vp` sees the new persona without being rebuilt)
13
+ *
14
+ * Hard constraint (a): no 334o storage import.
15
+ */
16
+
17
+ import { watch, existsSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { scanVpLibrary, loadVpFromDir, DEFAULT_VP_LIB_DIR } from './vp-store.js';
20
+
21
+ const DEFAULT_DEBOUNCE_MS = 500;
22
+
23
+ export class VpLoader {
24
+ /**
25
+ * @param {{ dir?: string, registry: import('./registry.js').Registry, debounceMs?: number, onChange?: (summary) => void }} options
26
+ */
27
+ constructor(options) {
28
+ if (!options || !options.registry) {
29
+ throw new Error('VpLoader requires { registry }');
30
+ }
31
+ this.dir = options.dir || DEFAULT_VP_LIB_DIR;
32
+ this.registry = options.registry;
33
+ this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
34
+ this.onChange = typeof options.onChange === 'function' ? options.onChange : null;
35
+
36
+ /** @type {import('fs').FSWatcher|null} */
37
+ this._rootWatcher = null;
38
+ /** @type {Map<string, import('fs').FSWatcher>} */
39
+ this._dirWatchers = new Map();
40
+ /** @type {any} */
41
+ this._debounceTimer = null;
42
+ this._started = false;
43
+ }
44
+
45
+ /** Initial scan + install watchers. Returns the initial VP list. */
46
+ start() {
47
+ if (this._started) return this.registry.listVps();
48
+ this._started = true;
49
+
50
+ const vps = scanVpLibrary({ dir: this.dir });
51
+ for (const vp of vps) this.registry.setVp(vp);
52
+
53
+ if (existsSync(this.dir)) {
54
+ this._installRootWatcher();
55
+ for (const vp of vps) this._installDirWatcher(vp.dir);
56
+ }
57
+ return vps;
58
+ }
59
+
60
+ /** Stop watchers and clear timers. Safe to call multiple times. */
61
+ stop() {
62
+ this._started = false;
63
+ if (this._debounceTimer) {
64
+ clearTimeout(this._debounceTimer);
65
+ this._debounceTimer = null;
66
+ }
67
+ if (this._rootWatcher) {
68
+ try { this._rootWatcher.close(); } catch { /* ignore */ }
69
+ this._rootWatcher = null;
70
+ }
71
+ for (const w of this._dirWatchers.values()) {
72
+ try { w.close(); } catch { /* ignore */ }
73
+ }
74
+ this._dirWatchers.clear();
75
+ }
76
+
77
+ _installRootWatcher() {
78
+ try {
79
+ this._rootWatcher = watch(this.dir, { persistent: false }, () => {
80
+ this._scheduleRescan();
81
+ });
82
+ } catch {
83
+ // watch may fail on some FS; hot-reload degrades to no-op
84
+ }
85
+ }
86
+
87
+ _installDirWatcher(dir) {
88
+ if (this._dirWatchers.has(dir)) return;
89
+ try {
90
+ const w = watch(dir, { persistent: false }, () => {
91
+ this._scheduleRescan();
92
+ });
93
+ this._dirWatchers.set(dir, w);
94
+ } catch {
95
+ // ignore
96
+ }
97
+ }
98
+
99
+ _scheduleRescan() {
100
+ if (this._debounceTimer) clearTimeout(this._debounceTimer);
101
+ this._debounceTimer = setTimeout(() => {
102
+ this._debounceTimer = null;
103
+ try {
104
+ this._rescan();
105
+ } catch {
106
+ // never crash the loader from a rescan error
107
+ }
108
+ }, this.debounceMs);
109
+ }
110
+
111
+ /**
112
+ * Force an immediate rescan (bypass debounce). Useful for tests.
113
+ * @returns {{ added: string[], removed: string[], updated: string[] }}
114
+ */
115
+ rescanNow() {
116
+ if (this._debounceTimer) {
117
+ clearTimeout(this._debounceTimer);
118
+ this._debounceTimer = null;
119
+ }
120
+ return this._rescan();
121
+ }
122
+
123
+ _rescan() {
124
+ const fresh = scanVpLibrary({ dir: this.dir });
125
+ const freshById = new Map(fresh.map(v => [v.id, v]));
126
+ const oldById = new Map(this.registry.listVps().map(v => [v.id, v]));
127
+
128
+ const added = [];
129
+ const removed = [];
130
+ const updated = [];
131
+
132
+ // Added / updated
133
+ for (const [id, next] of freshById) {
134
+ const prev = oldById.get(id);
135
+ if (!prev) {
136
+ this.registry.setVp(next);
137
+ this._installDirWatcher(next.dir);
138
+ added.push(id);
139
+ } else if (
140
+ prev.mtimeMs !== next.mtimeMs ||
141
+ prev.persona !== next.persona ||
142
+ prev.name !== next.name ||
143
+ prev.role !== next.role
144
+ ) {
145
+ // In-place update preserves VP identity → RoleInstance.runtimeState
146
+ // is untouched; persona swap propagates on next read of vp.persona.
147
+ this.registry.updateVpInPlace(next);
148
+ updated.push(id);
149
+ }
150
+ }
151
+
152
+ // Removed
153
+ for (const [id, prev] of oldById) {
154
+ if (!freshById.has(id)) {
155
+ this.registry.removeVp(id);
156
+ const w = this._dirWatchers.get(prev.dir);
157
+ if (w) {
158
+ try { w.close(); } catch { /* ignore */ }
159
+ this._dirWatchers.delete(prev.dir);
160
+ }
161
+ removed.push(id);
162
+ }
163
+ }
164
+
165
+ const summary = { added, removed, updated };
166
+ if ((added.length || removed.length || updated.length) && this.onChange) {
167
+ try { this.onChange(summary); } catch { /* ignore */ }
168
+ }
169
+ return summary;
170
+ }
171
+ }
172
+
173
+ export { DEFAULT_VP_LIB_DIR };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * vp-store.js — Virtual Person (VP) store.
3
+ *
4
+ * Scans `~/.yeaft/virtual-persons/<vp-name>/role.md` and produces VP records.
5
+ * role.md is a Markdown file with YAML frontmatter:
6
+ *
7
+ * ---
8
+ * id: alice
9
+ * name: Alice
10
+ * role: Product Manager
11
+ * modelHint: primary
12
+ * traits:
13
+ * - curious
14
+ * - pragmatic
15
+ * ---
16
+ * (body = VP persona / system-prompt-shaped description)
17
+ *
18
+ * Per task-334a spec hard constraint (a): this module does NOT import the
19
+ * 334o storage layer. Memory bootstrap is `mkdir -p` only — no shard-store
20
+ * touches.
21
+ */
22
+
23
+ import { readFileSync, readdirSync, statSync, mkdirSync, existsSync } from 'fs';
24
+ import { homedir } from 'os';
25
+ import { join } from 'path';
26
+
27
+ /**
28
+ * @typedef {Object} VP
29
+ * @property {string} id — VP id (default: dir name)
30
+ * @property {string} name
31
+ * @property {string} role
32
+ * @property {string[]} traits
33
+ * @property {'fast'|'primary'|undefined} modelHint
34
+ * @property {string} persona — markdown body (persona / system prompt seed)
35
+ * @property {string} dir — absolute path to VP dir
36
+ * @property {string} memoryDir — absolute path to VP memory dir
37
+ * @property {number} mtimeMs — role.md mtime (for hot-reload)
38
+ */
39
+
40
+ export const DEFAULT_VP_LIB_DIR = join(homedir(), '.yeaft', 'virtual-persons');
41
+
42
+ /**
43
+ * Parse YAML frontmatter + body from role.md.
44
+ * Minimal parser (scalars + bullet lists), same shape as personas.js.
45
+ *
46
+ * @param {string} source
47
+ * @returns {{ meta: Record<string, any>, body: string }}
48
+ */
49
+ export function parseRoleMd(source) {
50
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
51
+ if (!match) return { meta: {}, body: source };
52
+
53
+ const [, yaml, body] = match;
54
+ /** @type {Record<string, any>} */
55
+ const meta = {};
56
+ const lines = yaml.split(/\r?\n/);
57
+ let currentList = null;
58
+
59
+ for (const line of lines) {
60
+ if (!line.trim()) continue;
61
+ const listMatch = line.match(/^\s+-\s+(.+?)\s*$/);
62
+ if (listMatch && currentList) {
63
+ currentList.push(listMatch[1].replace(/^['"]|['"]$/g, ''));
64
+ continue;
65
+ }
66
+ const kvMatch = line.match(/^([\w-]+):\s*(.*)$/);
67
+ if (kvMatch) {
68
+ const [, key, raw] = kvMatch;
69
+ const value = raw.trim();
70
+ if (!value) {
71
+ currentList = [];
72
+ meta[key] = currentList;
73
+ } else {
74
+ meta[key] = value.replace(/^['"]|['"]$/g, '');
75
+ currentList = null;
76
+ }
77
+ }
78
+ }
79
+
80
+ return { meta, body: body.trim() };
81
+ }
82
+
83
+ /**
84
+ * Load a single VP from an absolute VP directory.
85
+ * Returns null if role.md is missing or has no usable id.
86
+ *
87
+ * Side effect: ensures `<dir>/memory/` exists (mkdir -p only — per hard
88
+ * constraint (a), we do not touch shard-store).
89
+ *
90
+ * @param {string} dir
91
+ * @returns {VP|null}
92
+ */
93
+ export function loadVpFromDir(dir) {
94
+ const rolePath = join(dir, 'role.md');
95
+ let source;
96
+ let st;
97
+ try {
98
+ source = readFileSync(rolePath, 'utf-8');
99
+ st = statSync(rolePath);
100
+ } catch {
101
+ return null;
102
+ }
103
+
104
+ const { meta, body } = parseRoleMd(source);
105
+ const dirName = dir.split(/[\\/]/).filter(Boolean).pop() || '';
106
+ const id = String(meta.id || dirName).trim();
107
+ if (!id) return null;
108
+
109
+ const memoryDir = join(dir, 'memory');
110
+ try {
111
+ mkdirSync(memoryDir, { recursive: true });
112
+ } catch {
113
+ // best-effort; do not fail load on mkdir error
114
+ }
115
+
116
+ const modelHintRaw = typeof meta.modelHint === 'string' ? meta.modelHint : undefined;
117
+ const modelHint = modelHintRaw === 'primary' || modelHintRaw === 'fast' ? modelHintRaw : undefined;
118
+
119
+ /** @type {VP} */
120
+ return {
121
+ id,
122
+ name: String(meta.name || id),
123
+ role: String(meta.role || ''),
124
+ traits: Array.isArray(meta.traits) ? meta.traits.map(String) : [],
125
+ modelHint,
126
+ persona: body,
127
+ dir,
128
+ memoryDir,
129
+ mtimeMs: st.mtimeMs,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Scan a VP library root (default `~/.yeaft/virtual-persons`).
135
+ *
136
+ * @param {{ dir?: string }} [options]
137
+ * @returns {VP[]}
138
+ */
139
+ export function scanVpLibrary(options = {}) {
140
+ const { dir = DEFAULT_VP_LIB_DIR } = options;
141
+ if (!existsSync(dir)) return [];
142
+
143
+ let entries;
144
+ try {
145
+ entries = readdirSync(dir, { withFileTypes: true });
146
+ } catch {
147
+ return [];
148
+ }
149
+
150
+ const vps = [];
151
+ for (const entry of entries) {
152
+ if (!entry.isDirectory()) continue;
153
+ if (entry.name.startsWith('.')) continue;
154
+ const vp = loadVpFromDir(join(dir, entry.name));
155
+ if (vp) vps.push(vp);
156
+ }
157
+ return vps;
158
+ }
159
+
160
+ /**
161
+ * Count VPs in the library without loading full records.
162
+ * Cheap API for G1 empty-library fallback (acceptance #4).
163
+ *
164
+ * @param {{ dir?: string }} [options]
165
+ * @returns {number}
166
+ */
167
+ export function count(options = {}) {
168
+ const { dir = DEFAULT_VP_LIB_DIR } = options;
169
+ if (!existsSync(dir)) return 0;
170
+ let entries;
171
+ try {
172
+ entries = readdirSync(dir, { withFileTypes: true });
173
+ } catch {
174
+ return 0;
175
+ }
176
+ let n = 0;
177
+ for (const entry of entries) {
178
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
179
+ const rolePath = join(dir, entry.name, 'role.md');
180
+ if (existsSync(rolePath)) n++;
181
+ }
182
+ return n;
183
+ }