@yeaft/webchat-agent 0.1.543 → 0.1.545
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/memory/user-memory-store.js +222 -0
- package/unify/user-memory.js +29 -26
package/package.json
CHANGED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* user-memory-store.js — R6 §Δ29 User-memory store + dream + profile builder.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the R6 shard-store (task-334f) with user-specific semantics:
|
|
5
|
+
* - 5 shards: profile / preferences / projects / goals / relations
|
|
6
|
+
* - Storage path: ~/.yeaft/user/memory/
|
|
7
|
+
* - UserDreamJob: reuses dream-shard.js compact framework
|
|
8
|
+
* - buildUserProfile(): top-N recall for SEMI-DYNAMIC injection
|
|
9
|
+
*
|
|
10
|
+
* Hard constraints:
|
|
11
|
+
* - No VP/task memory imports (user memory is orthogonal)
|
|
12
|
+
* - Never throws from public API — best-effort with console.warn fallback
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { homedir } from 'os';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
import { randomUUID } from 'crypto';
|
|
18
|
+
import { existsSync } from 'fs';
|
|
19
|
+
import { openMemoryShardStore } from './shard-store.js';
|
|
20
|
+
import { USER_SHARDS } from './schema.js';
|
|
21
|
+
import { scanShards, runCompactJob } from './dream-shard.js';
|
|
22
|
+
|
|
23
|
+
/** Default storage root for user memory. */
|
|
24
|
+
export const USER_MEMORY_DIR = join(homedir(), '.yeaft', 'user', 'memory');
|
|
25
|
+
|
|
26
|
+
/** Maximum entries to include in the user_profile prompt segment. */
|
|
27
|
+
const PROFILE_TOP_N = 5;
|
|
28
|
+
|
|
29
|
+
/** Shard priority for profile builder recall (most → least relevant). */
|
|
30
|
+
const PROFILE_RECALL_SHARDS = ['profile', 'preferences', 'goals'];
|
|
31
|
+
|
|
32
|
+
// ─── Lazy singleton ───────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** @type {ReturnType<typeof openMemoryShardStore> | null} */
|
|
35
|
+
let _store = null;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get (or lazily create) the process-singleton user-memory shard store.
|
|
39
|
+
* Returns null on failure (missing dir, permissions, etc.) — caller must
|
|
40
|
+
* handle null gracefully.
|
|
41
|
+
*
|
|
42
|
+
* @param {{ dir?: string }} [opts]
|
|
43
|
+
* @returns {ReturnType<typeof openMemoryShardStore> | null}
|
|
44
|
+
*/
|
|
45
|
+
export function getUserMemoryStore(opts = {}) {
|
|
46
|
+
if (_store) return _store;
|
|
47
|
+
try {
|
|
48
|
+
const dir = opts.dir || USER_MEMORY_DIR;
|
|
49
|
+
_store = openMemoryShardStore(dir, 'user');
|
|
50
|
+
return _store;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.warn('[user-memory-store] failed to open store:', err.message);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Close and reset the singleton. Used by tests.
|
|
59
|
+
*/
|
|
60
|
+
export function _resetUserMemoryStoreForTest() {
|
|
61
|
+
if (_store) {
|
|
62
|
+
try { _store.close(); } catch { /* ignore */ }
|
|
63
|
+
}
|
|
64
|
+
_store = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Open a fresh (non-singleton) user-memory store at an arbitrary dir.
|
|
69
|
+
* Useful for tests that want isolation.
|
|
70
|
+
*/
|
|
71
|
+
export function openUserMemoryStore(dir) {
|
|
72
|
+
return openMemoryShardStore(dir, 'user');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Write / Remove ──────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Classify user-memory text into a shard based on simple heuristics.
|
|
79
|
+
* Falls back to 'profile' when uncertain.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} text
|
|
82
|
+
* @param {string[]} [tags]
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function classifyUserMemoryShard(text, tags) {
|
|
86
|
+
const lower = (text || '').toLowerCase();
|
|
87
|
+
const tagSet = new Set((tags || []).map(t => t.toLowerCase()));
|
|
88
|
+
|
|
89
|
+
// Explicit tag hints
|
|
90
|
+
if (tagSet.has('goal') || tagSet.has('goals')) return 'goals';
|
|
91
|
+
if (tagSet.has('project') || tagSet.has('projects')) return 'projects';
|
|
92
|
+
if (tagSet.has('preference') || tagSet.has('preferences')) return 'preferences';
|
|
93
|
+
if (tagSet.has('relation') || tagSet.has('relations')) return 'relations';
|
|
94
|
+
if (tagSet.has('profile')) return 'profile';
|
|
95
|
+
|
|
96
|
+
// Keyword heuristics
|
|
97
|
+
if (/\b(goal|objective|target|aim|aspir|want to|plan to|hope to)\b/i.test(lower)) return 'goals';
|
|
98
|
+
if (/\b(project|repo|codebase|app|application|product)\b/i.test(lower)) return 'projects';
|
|
99
|
+
if (/\b(prefer|like|dislike|style|format|tone|language|dark mode|theme)\b/i.test(lower)) return 'preferences';
|
|
100
|
+
if (/\b(colleague|friend|team|manager|report|partner|contact|person)\b/i.test(lower)) return 'relations';
|
|
101
|
+
|
|
102
|
+
return 'profile';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Ingest a user-memory write. Returns the entryId on success, null on failure.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} store — user-memory shard store
|
|
109
|
+
* @param {{ text: string, tags?: string[], sourceRef?: object }} params
|
|
110
|
+
* @returns {string|null} entryId
|
|
111
|
+
*/
|
|
112
|
+
export function writeUserMemory(store, { text, tags, sourceRef }) {
|
|
113
|
+
if (!store || !text || typeof text !== 'string' || !text.trim()) return null;
|
|
114
|
+
try {
|
|
115
|
+
const shard = classifyUserMemoryShard(text, tags);
|
|
116
|
+
const id = `um-${randomUUID().slice(0, 12)}`;
|
|
117
|
+
const entry = {
|
|
118
|
+
id,
|
|
119
|
+
shard,
|
|
120
|
+
kind: 'preference', // user-memory entries are preference-kind (no sourceRef required)
|
|
121
|
+
body: text.trim(),
|
|
122
|
+
tags: Array.isArray(tags) ? tags.slice() : [],
|
|
123
|
+
authoredBy: 'user:self',
|
|
124
|
+
};
|
|
125
|
+
store.put(entry);
|
|
126
|
+
return id;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.warn('[user-memory-store] write failed:', err.message);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Remove a user-memory entry by id. Returns true on success.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} store
|
|
137
|
+
* @param {string} entryId
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
export function removeUserMemory(store, entryId) {
|
|
141
|
+
if (!store || !entryId) return false;
|
|
142
|
+
try {
|
|
143
|
+
store.remove(entryId);
|
|
144
|
+
return true;
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.warn('[user-memory-store] remove failed:', err.message);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── Profile Builder ─────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Build the `user_profile` text segment for SEMI-DYNAMIC prompt injection.
|
|
155
|
+
* Reads top-N entries from profile/preferences/goals shards and formats
|
|
156
|
+
* them as a compact bullet list.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} [store] — user-memory shard store (uses singleton if omitted)
|
|
159
|
+
* @param {{ maxEntries?: number }} [opts]
|
|
160
|
+
* @returns {string} — empty string if no user-memory exists
|
|
161
|
+
*/
|
|
162
|
+
export function buildUserProfile(store, opts = {}) {
|
|
163
|
+
const s = store || getUserMemoryStore();
|
|
164
|
+
if (!s) return '';
|
|
165
|
+
|
|
166
|
+
const max = opts.maxEntries || PROFILE_TOP_N;
|
|
167
|
+
const lines = [];
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
for (const shardName of PROFILE_RECALL_SHARDS) {
|
|
171
|
+
if (lines.length >= max) break;
|
|
172
|
+
const { results } = s.query({ shard: shardName });
|
|
173
|
+
// Filter out superseded entries
|
|
174
|
+
const live = results.filter(r => !r.supersededBy);
|
|
175
|
+
// Take most recent first (results are already ordered by storage)
|
|
176
|
+
for (const rec of live) {
|
|
177
|
+
if (lines.length >= max) break;
|
|
178
|
+
const full = s.get(rec.id);
|
|
179
|
+
if (!full || !full.body) continue;
|
|
180
|
+
const body = full.body.trim();
|
|
181
|
+
if (!body) continue;
|
|
182
|
+
lines.push(`- ${body}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.warn('[user-memory-store] buildUserProfile failed:', err.message);
|
|
187
|
+
return '';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return lines.join('\n');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Dream Job ───────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Run user-memory dream maintenance (compact low-utilization shards).
|
|
197
|
+
* Reuses dream-shard.js compact framework — no LLM calls needed for
|
|
198
|
+
* user-memory (user-authored entries don't need merge/prune by an LLM;
|
|
199
|
+
* we only compact to reclaim superseded/removed tombstones).
|
|
200
|
+
*
|
|
201
|
+
* @param {{ store?: object, onPhase?: (phase:string, data:any) => void }} [opts]
|
|
202
|
+
* @returns {{ scan: object, compact: object } | null}
|
|
203
|
+
*/
|
|
204
|
+
export function runUserDreamJob(opts = {}) {
|
|
205
|
+
const store = 'store' in opts ? opts.store : getUserMemoryStore();
|
|
206
|
+
if (!store) return null;
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const scan = scanShards(store);
|
|
210
|
+
const compact = runCompactJob({
|
|
211
|
+
shardStore: store,
|
|
212
|
+
shardNames: scan.needsCompaction,
|
|
213
|
+
onCompact: opts.onPhase
|
|
214
|
+
? (shard, r) => opts.onPhase('compact', { shard, ...r })
|
|
215
|
+
: undefined,
|
|
216
|
+
});
|
|
217
|
+
return { scan: { totalEntries: scan.totalEntries, totalBytes: scan.totalBytes }, compact };
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.warn('[user-memory-store] dream job failed:', err.message);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
package/unify/user-memory.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* user-memory.js — R6 §Δ29 user-memory WS event
|
|
2
|
+
* user-memory.js — R6 §Δ29 user-memory WS event handlers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* emitter code without a dependency-cycle on 334l's storage layer.
|
|
4
|
+
* Replaces the stub (task-334h) with real ingestion backed by the R6
|
|
5
|
+
* shard-store. Writes land immediately in `~/.yeaft/user/memory/` with
|
|
6
|
+
* a real entryId; the ack carries `reason: 'accepted'`.
|
|
8
7
|
*
|
|
9
8
|
* Wire shapes (frozen by R6 §Δ31.6 table; additive fields only):
|
|
10
9
|
*
|
|
@@ -12,23 +11,19 @@
|
|
|
12
11
|
* { type, text, tags?, sourceRef?, requestId? }
|
|
13
12
|
*
|
|
14
13
|
* outbound (agent → web): `user_memory_updated`
|
|
15
|
-
* { type, entryId?, reason: 'accepted'|'
|
|
14
|
+
* { type, entryId?, reason: 'accepted'|'noop',
|
|
16
15
|
* requestId?, pending?: boolean }
|
|
17
16
|
*
|
|
18
17
|
* outbound (agent → web): `user_memory_removed`
|
|
19
18
|
* { type, entryId, requestId? }
|
|
20
|
-
*
|
|
21
|
-
* Current behaviour: every write is replied with `user_memory_updated`
|
|
22
|
-
* carrying `reason: 'deferred'` and `pending: true` — the frontend
|
|
23
|
-
* treats this as "queued but not yet persisted" and keeps the toast in
|
|
24
|
-
* a muted state. 334l will flip the reason to `'accepted'` with a
|
|
25
|
-
* concrete `entryId` once the ingestion pipeline lands.
|
|
26
|
-
*
|
|
27
|
-
* No removal path is offered yet (would require the storage layer to
|
|
28
|
-
* have produced entryIds first); the handler is exported as a named
|
|
29
|
-
* stub so the router can wire it without a second edit when 334l ships.
|
|
30
19
|
*/
|
|
31
20
|
|
|
21
|
+
import {
|
|
22
|
+
getUserMemoryStore,
|
|
23
|
+
writeUserMemory,
|
|
24
|
+
removeUserMemory,
|
|
25
|
+
} from './memory/user-memory-store.js';
|
|
26
|
+
|
|
32
27
|
/** @type {(event:object)=>void | null} */
|
|
33
28
|
let _sendUnifyEvent = null;
|
|
34
29
|
|
|
@@ -43,12 +38,12 @@ export function setUserMemorySender(fn) {
|
|
|
43
38
|
/**
|
|
44
39
|
* WS handler: `unify_user_memory_write`.
|
|
45
40
|
*
|
|
46
|
-
* Validates the minimum shape (non-empty string `text`)
|
|
47
|
-
* a `user_memory_updated` ack
|
|
41
|
+
* Validates the minimum shape (non-empty string `text`), writes to the
|
|
42
|
+
* user-memory shard store, and replies with a `user_memory_updated` ack
|
|
43
|
+
* carrying the real entryId. Never throws.
|
|
48
44
|
*
|
|
49
45
|
* @param {any} msg
|
|
50
46
|
* @param {(event:object)=>void} [sendUnifyEvent] — optional override
|
|
51
|
-
* (falls back to the module-level sender installed via setUserMemorySender)
|
|
52
47
|
*/
|
|
53
48
|
export function handleUnifyUserMemoryWrite(msg, sendUnifyEvent) {
|
|
54
49
|
const send = sendUnifyEvent || _sendUnifyEvent;
|
|
@@ -69,22 +64,27 @@ export function handleUnifyUserMemoryWrite(msg, sendUnifyEvent) {
|
|
|
69
64
|
return;
|
|
70
65
|
}
|
|
71
66
|
|
|
72
|
-
//
|
|
67
|
+
// Real ingestion via shard store.
|
|
68
|
+
const store = getUserMemoryStore();
|
|
69
|
+
const tags = Array.isArray(msg.tags) ? msg.tags : [];
|
|
70
|
+
const sourceRef = msg.sourceRef && typeof msg.sourceRef === 'object' ? msg.sourceRef : undefined;
|
|
71
|
+
const entryId = store ? writeUserMemory(store, { text, tags, sourceRef }) : null;
|
|
72
|
+
|
|
73
73
|
try {
|
|
74
74
|
send({
|
|
75
75
|
type: 'user_memory_updated',
|
|
76
|
-
reason: 'deferred',
|
|
77
|
-
pending:
|
|
76
|
+
reason: entryId ? 'accepted' : 'deferred',
|
|
77
|
+
pending: !entryId,
|
|
78
|
+
entryId: entryId || undefined,
|
|
78
79
|
...(requestId ? { requestId } : {}),
|
|
79
80
|
});
|
|
80
81
|
} catch { /* best-effort */ }
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
/**
|
|
84
|
-
* WS handler: `unify_user_memory_remove
|
|
85
|
+
* WS handler: `unify_user_memory_remove`.
|
|
85
86
|
*
|
|
86
|
-
*
|
|
87
|
-
* `user_memory_updated` so the UI can clear its toast.
|
|
87
|
+
* Removes the entry from the user-memory shard store and acks.
|
|
88
88
|
*/
|
|
89
89
|
export function handleUnifyUserMemoryRemove(msg, sendUnifyEvent) {
|
|
90
90
|
const send = sendUnifyEvent || _sendUnifyEvent;
|
|
@@ -93,11 +93,14 @@ export function handleUnifyUserMemoryRemove(msg, sendUnifyEvent) {
|
|
|
93
93
|
const requestId = msg && typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
94
94
|
const entryId = msg && typeof msg.entryId === 'string' ? msg.entryId : null;
|
|
95
95
|
|
|
96
|
+
const store = getUserMemoryStore();
|
|
97
|
+
const removed = entryId && store ? removeUserMemory(store, entryId) : false;
|
|
98
|
+
|
|
96
99
|
try {
|
|
97
100
|
send({
|
|
98
101
|
type: 'user_memory_removed',
|
|
99
102
|
entryId,
|
|
100
|
-
pending:
|
|
103
|
+
pending: !removed,
|
|
101
104
|
...(requestId ? { requestId } : {}),
|
|
102
105
|
});
|
|
103
106
|
} catch { /* best-effort */ }
|