@yeaft/webchat-agent 0.1.399 → 0.1.409
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/crew/role-query.js +10 -6
- package/package.json +3 -1
- package/sdk/query.js +3 -1
- package/unify/cli.js +735 -0
- package/unify/config.js +269 -0
- package/unify/conversation/persist.js +436 -0
- package/unify/conversation/search.js +65 -0
- package/unify/debug-trace.js +398 -0
- package/unify/engine.js +511 -0
- package/unify/index.js +27 -0
- package/unify/init.js +147 -0
- package/unify/llm/adapter.js +186 -0
- package/unify/llm/anthropic.js +322 -0
- package/unify/llm/chat-completions.js +315 -0
- package/unify/memory/consolidate.js +187 -0
- package/unify/memory/extract.js +97 -0
- package/unify/memory/recall.js +243 -0
- package/unify/memory/store.js +507 -0
- package/unify/models.js +167 -0
- package/unify/prompts.js +109 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.js — Memory CRUD (read/write entries/*.md + MEMORY.md + scopes.md)
|
|
3
|
+
*
|
|
4
|
+
* Memory 3D Model:
|
|
5
|
+
* Kind = WHAT — 6 types: fact, preference, skill, lesson, context, relation
|
|
6
|
+
* Scope = WHERE — dynamic tree path: global / work/project / tech/typescript
|
|
7
|
+
* Tags = HOW — free keywords: [typescript, generics, covariance]
|
|
8
|
+
*
|
|
9
|
+
* Entry format (entries/*.md):
|
|
10
|
+
* ---
|
|
11
|
+
* name: auth-null-check-pattern
|
|
12
|
+
* kind: lesson
|
|
13
|
+
* scope: work/claude-web-chat/auth
|
|
14
|
+
* tags: [null-check, typescript, auth]
|
|
15
|
+
* importance: high
|
|
16
|
+
* frequency: 1
|
|
17
|
+
* created_at: 2026-04-09T14:30:00Z
|
|
18
|
+
* updated_at: 2026-04-09T15:00:00Z
|
|
19
|
+
* ---
|
|
20
|
+
* # Auth Null Check Pattern
|
|
21
|
+
* ...content...
|
|
22
|
+
*
|
|
23
|
+
* Reference: yeaft-unify-design.md §5.1, yeaft-unify-core-systems.md §2.2
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
|
|
27
|
+
import { join, basename } from 'path';
|
|
28
|
+
|
|
29
|
+
// ─── Constants ──────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** Valid memory kinds. */
|
|
32
|
+
export const MEMORY_KINDS = ['fact', 'preference', 'skill', 'lesson', 'context', 'relation'];
|
|
33
|
+
|
|
34
|
+
/** Maximum entries allowed (Dream prunes beyond this). */
|
|
35
|
+
export const MAX_ENTRIES = 200;
|
|
36
|
+
|
|
37
|
+
/** Maximum MEMORY.md line count. */
|
|
38
|
+
export const MAX_MEMORY_LINES = 200;
|
|
39
|
+
|
|
40
|
+
// ─── Entry Parsing ──────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse a memory entry .md file into an object.
|
|
44
|
+
* @param {string} raw — raw file content
|
|
45
|
+
* @returns {object|null}
|
|
46
|
+
*/
|
|
47
|
+
export function parseEntry(raw) {
|
|
48
|
+
if (!raw || !raw.startsWith('---')) return null;
|
|
49
|
+
|
|
50
|
+
const endIdx = raw.indexOf('\n---', 3);
|
|
51
|
+
if (endIdx === -1) return null;
|
|
52
|
+
|
|
53
|
+
const frontmatter = raw.slice(4, endIdx).trim();
|
|
54
|
+
const body = raw.slice(endIdx + 4).trim();
|
|
55
|
+
|
|
56
|
+
const entry = { content: body };
|
|
57
|
+
|
|
58
|
+
for (const line of frontmatter.split('\n')) {
|
|
59
|
+
const colonIdx = line.indexOf(':');
|
|
60
|
+
if (colonIdx === -1) continue;
|
|
61
|
+
|
|
62
|
+
const key = line.slice(0, colonIdx).trim();
|
|
63
|
+
let value = line.slice(colonIdx + 1).trim();
|
|
64
|
+
|
|
65
|
+
switch (key) {
|
|
66
|
+
case 'name': entry.name = value; break;
|
|
67
|
+
case 'kind': entry.kind = value; break;
|
|
68
|
+
case 'scope': entry.scope = value; break;
|
|
69
|
+
case 'importance': entry.importance = value; break;
|
|
70
|
+
case 'frequency': entry.frequency = parseInt(value, 10); break;
|
|
71
|
+
case 'created_at': entry.created_at = value; break;
|
|
72
|
+
case 'updated_at': entry.updated_at = value; break;
|
|
73
|
+
case 'tags': {
|
|
74
|
+
// Parse [tag1, tag2, tag3] or tag1, tag2, tag3
|
|
75
|
+
value = value.replace(/^\[|\]$/g, '');
|
|
76
|
+
entry.tags = value.split(',').map(t => t.trim()).filter(Boolean);
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case 'related': {
|
|
80
|
+
value = value.replace(/^\[|\]$/g, '');
|
|
81
|
+
entry.related = value.split(',').map(t => t.trim()).filter(Boolean);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return entry;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Serialize a memory entry to .md format.
|
|
92
|
+
* @param {object} entry
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function serializeEntry(entry) {
|
|
96
|
+
const fm = [
|
|
97
|
+
'---',
|
|
98
|
+
`name: ${entry.name}`,
|
|
99
|
+
`kind: ${entry.kind || 'fact'}`,
|
|
100
|
+
`scope: ${entry.scope || 'global'}`,
|
|
101
|
+
`tags: [${(entry.tags || []).join(', ')}]`,
|
|
102
|
+
`importance: ${entry.importance || 'normal'}`,
|
|
103
|
+
`frequency: ${entry.frequency || 1}`,
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
if (entry.related && entry.related.length > 0) {
|
|
107
|
+
fm.push(`related: [${entry.related.join(', ')}]`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fm.push(`created_at: ${entry.created_at || new Date().toISOString()}`);
|
|
111
|
+
fm.push(`updated_at: ${entry.updated_at || new Date().toISOString()}`);
|
|
112
|
+
fm.push('---');
|
|
113
|
+
fm.push('');
|
|
114
|
+
fm.push(entry.content || '');
|
|
115
|
+
|
|
116
|
+
return fm.join('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Generate a filename-safe slug from a name.
|
|
121
|
+
* @param {string} name
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function slugify(name) {
|
|
125
|
+
return name
|
|
126
|
+
.toLowerCase()
|
|
127
|
+
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-') // allow CJK chars
|
|
128
|
+
.replace(/^-+|-+$/g, '')
|
|
129
|
+
.slice(0, 60);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── MemoryStore ────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* MemoryStore — CRUD for memory entries, MEMORY.md, and scopes.md.
|
|
136
|
+
*
|
|
137
|
+
* Directory layout:
|
|
138
|
+
* memory/
|
|
139
|
+
* MEMORY.md — user profile / knowledge map (<200 lines)
|
|
140
|
+
* scopes.md — scope index (markdown table)
|
|
141
|
+
* entries/ — individual memory entries (flat)
|
|
142
|
+
*/
|
|
143
|
+
export class MemoryStore {
|
|
144
|
+
#dir; // root dir (e.g. ~/.yeaft)
|
|
145
|
+
#memoryDir; // ~/.yeaft/memory
|
|
146
|
+
#entriesDir; // ~/.yeaft/memory/entries
|
|
147
|
+
#memoryPath; // ~/.yeaft/memory/MEMORY.md
|
|
148
|
+
#scopesPath; // ~/.yeaft/memory/scopes.md
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {string} dir — Yeaft root directory (e.g. ~/.yeaft)
|
|
152
|
+
*/
|
|
153
|
+
constructor(dir) {
|
|
154
|
+
this.#dir = dir;
|
|
155
|
+
this.#memoryDir = join(dir, 'memory');
|
|
156
|
+
this.#entriesDir = join(dir, 'memory', 'entries');
|
|
157
|
+
this.#memoryPath = join(dir, 'memory', 'MEMORY.md');
|
|
158
|
+
this.#scopesPath = join(dir, 'memory', 'scopes.md');
|
|
159
|
+
|
|
160
|
+
// Ensure directories exist
|
|
161
|
+
for (const d of [this.#memoryDir, this.#entriesDir]) {
|
|
162
|
+
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── MEMORY.md (User Profile / Knowledge Map) ──────────
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Read the full MEMORY.md content.
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
readProfile() {
|
|
173
|
+
if (!existsSync(this.#memoryPath)) return '';
|
|
174
|
+
return readFileSync(this.#memoryPath, 'utf8');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Write (overwrite) MEMORY.md.
|
|
179
|
+
* @param {string} content
|
|
180
|
+
*/
|
|
181
|
+
writeProfile(content) {
|
|
182
|
+
writeFileSync(this.#memoryPath, content, 'utf8');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Read a specific section from MEMORY.md.
|
|
187
|
+
* Sections are delimited by ## headers.
|
|
188
|
+
* @param {string} section — e.g. "Facts", "Preferences"
|
|
189
|
+
* @returns {string}
|
|
190
|
+
*/
|
|
191
|
+
readSection(section) {
|
|
192
|
+
const content = this.readProfile();
|
|
193
|
+
if (!content) return '';
|
|
194
|
+
|
|
195
|
+
const regex = new RegExp(`^## ${section}\\b[^\\n]*\\n`, 'im');
|
|
196
|
+
const match = content.match(regex);
|
|
197
|
+
if (!match) return '';
|
|
198
|
+
|
|
199
|
+
const startIdx = match.index + match[0].length;
|
|
200
|
+
const nextSection = content.indexOf('\n## ', startIdx);
|
|
201
|
+
const endIdx = nextSection !== -1 ? nextSection : content.length;
|
|
202
|
+
|
|
203
|
+
return content.slice(startIdx, endIdx).trim();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Add a line to a section in MEMORY.md. Creates the section if it doesn't exist.
|
|
208
|
+
* @param {string} section — e.g. "Facts"
|
|
209
|
+
* @param {string} line — e.g. "- User prefers TypeScript"
|
|
210
|
+
*/
|
|
211
|
+
addToSection(section, line) {
|
|
212
|
+
let content = this.readProfile();
|
|
213
|
+
|
|
214
|
+
const sectionHeader = `## ${section}`;
|
|
215
|
+
const headerIdx = content.indexOf(sectionHeader);
|
|
216
|
+
|
|
217
|
+
if (headerIdx === -1) {
|
|
218
|
+
// Section doesn't exist — append it
|
|
219
|
+
content = content.trimEnd() + `\n\n${sectionHeader}\n\n${line}\n`;
|
|
220
|
+
} else {
|
|
221
|
+
// Find end of section
|
|
222
|
+
const afterHeader = headerIdx + sectionHeader.length;
|
|
223
|
+
const nextSectionIdx = content.indexOf('\n## ', afterHeader);
|
|
224
|
+
const insertIdx = nextSectionIdx !== -1 ? nextSectionIdx : content.length;
|
|
225
|
+
|
|
226
|
+
// Insert before next section
|
|
227
|
+
content = content.slice(0, insertIdx).trimEnd() + '\n' + line + '\n' + content.slice(insertIdx);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.writeProfile(content);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── Scopes Index ─────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Read scopes.md as a list of { scope, count, lastUpdated }.
|
|
237
|
+
* @returns {object[]}
|
|
238
|
+
*/
|
|
239
|
+
readScopes() {
|
|
240
|
+
if (!existsSync(this.#scopesPath)) return [];
|
|
241
|
+
|
|
242
|
+
const content = readFileSync(this.#scopesPath, 'utf8');
|
|
243
|
+
const lines = content.split('\n');
|
|
244
|
+
const scopes = [];
|
|
245
|
+
|
|
246
|
+
for (const line of lines) {
|
|
247
|
+
// Parse markdown table rows: | scope | count | lastUpdated |
|
|
248
|
+
const match = line.match(/^\|\s*([^|]+)\s*\|\s*(\d+)\s*\|\s*([^|]+)\s*\|$/);
|
|
249
|
+
if (match && match[1].trim() !== 'scope' && !match[1].includes('---')) {
|
|
250
|
+
scopes.push({
|
|
251
|
+
scope: match[1].trim(),
|
|
252
|
+
count: parseInt(match[2].trim(), 10),
|
|
253
|
+
lastUpdated: match[3].trim(),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return scopes;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Rebuild scopes.md from current entries.
|
|
263
|
+
*/
|
|
264
|
+
rebuildScopes() {
|
|
265
|
+
const entries = this.listEntries();
|
|
266
|
+
const scopeMap = new Map();
|
|
267
|
+
|
|
268
|
+
for (const entry of entries) {
|
|
269
|
+
const scope = entry.scope || 'global';
|
|
270
|
+
const existing = scopeMap.get(scope) || { count: 0, lastUpdated: '' };
|
|
271
|
+
existing.count++;
|
|
272
|
+
if (entry.updated_at > existing.lastUpdated) {
|
|
273
|
+
existing.lastUpdated = entry.updated_at;
|
|
274
|
+
}
|
|
275
|
+
scopeMap.set(scope, existing);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const lines = [
|
|
279
|
+
'# Scope Index',
|
|
280
|
+
'',
|
|
281
|
+
'| scope | count | lastUpdated |',
|
|
282
|
+
'| --- | --- | --- |',
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
for (const [scope, info] of [...scopeMap.entries()].sort()) {
|
|
286
|
+
lines.push(`| ${scope} | ${info.count} | ${info.lastUpdated} |`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
writeFileSync(this.#scopesPath, lines.join('\n') + '\n', 'utf8');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ─── Entries CRUD ─────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* List all entries with their frontmatter (no content body).
|
|
296
|
+
* @returns {object[]}
|
|
297
|
+
*/
|
|
298
|
+
listEntries() {
|
|
299
|
+
if (!existsSync(this.#entriesDir)) return [];
|
|
300
|
+
|
|
301
|
+
const files = readdirSync(this.#entriesDir).filter(f => f.endsWith('.md')).sort();
|
|
302
|
+
const entries = [];
|
|
303
|
+
|
|
304
|
+
for (const file of files) {
|
|
305
|
+
const raw = readFileSync(join(this.#entriesDir, file), 'utf8');
|
|
306
|
+
const entry = parseEntry(raw);
|
|
307
|
+
if (entry) {
|
|
308
|
+
entry._filename = file;
|
|
309
|
+
entries.push(entry);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return entries;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Read a specific entry by name (slug).
|
|
318
|
+
* @param {string} name — entry name slug (without .md)
|
|
319
|
+
* @returns {object|null}
|
|
320
|
+
*/
|
|
321
|
+
readEntry(name) {
|
|
322
|
+
const filePath = join(this.#entriesDir, `${name}.md`);
|
|
323
|
+
if (!existsSync(filePath)) return null;
|
|
324
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
325
|
+
return parseEntry(raw);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Write (create or overwrite) an entry.
|
|
330
|
+
* @param {object} entry — { name, kind, scope, tags, importance, content, ... }
|
|
331
|
+
* @returns {string} — the filename slug used
|
|
332
|
+
*/
|
|
333
|
+
writeEntry(entry) {
|
|
334
|
+
const slug = entry.name ? slugify(entry.name) : `entry-${Date.now()}`;
|
|
335
|
+
const now = new Date().toISOString();
|
|
336
|
+
|
|
337
|
+
const fullEntry = {
|
|
338
|
+
...entry,
|
|
339
|
+
name: entry.name || slug,
|
|
340
|
+
created_at: entry.created_at || now,
|
|
341
|
+
updated_at: now,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const filePath = join(this.#entriesDir, `${slug}.md`);
|
|
345
|
+
writeFileSync(filePath, serializeEntry(fullEntry), 'utf8');
|
|
346
|
+
|
|
347
|
+
return slug;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Write multiple entries at once.
|
|
352
|
+
* @param {object[]} entries
|
|
353
|
+
* @returns {string[]} — slugs
|
|
354
|
+
*/
|
|
355
|
+
writeEntries(entries) {
|
|
356
|
+
return entries.map(e => this.writeEntry(e));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Delete an entry by name (slug).
|
|
361
|
+
* @param {string} name — entry slug (without .md)
|
|
362
|
+
* @returns {boolean} — true if deleted
|
|
363
|
+
*/
|
|
364
|
+
deleteEntry(name) {
|
|
365
|
+
const filePath = join(this.#entriesDir, `${name}.md`);
|
|
366
|
+
if (!existsSync(filePath)) return false;
|
|
367
|
+
unlinkSync(filePath);
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Increment the frequency counter of an entry.
|
|
373
|
+
* @param {string} name — entry slug
|
|
374
|
+
*/
|
|
375
|
+
bumpFrequency(name) {
|
|
376
|
+
const entry = this.readEntry(name);
|
|
377
|
+
if (!entry) return;
|
|
378
|
+
entry.frequency = (entry.frequency || 1) + 1;
|
|
379
|
+
entry.updated_at = new Date().toISOString();
|
|
380
|
+
const filePath = join(this.#entriesDir, `${name}.md`);
|
|
381
|
+
writeFileSync(filePath, serializeEntry(entry), 'utf8');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ─── Search / Filter ──────────────────────────────────
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Find entries matching scope + tags.
|
|
388
|
+
* Scoring: exact scope match = 3, ancestor scope = 2, tag overlap = 1 per tag.
|
|
389
|
+
*
|
|
390
|
+
* @param {{ scope?: string, tags?: string[], limit?: number }} filters
|
|
391
|
+
* @returns {object[]} — entries sorted by score descending
|
|
392
|
+
*/
|
|
393
|
+
findByFilter({ scope, tags = [], limit = 15 } = {}) {
|
|
394
|
+
const entries = this.listEntries();
|
|
395
|
+
|
|
396
|
+
const scored = entries.map(entry => {
|
|
397
|
+
let score = 0;
|
|
398
|
+
|
|
399
|
+
// Scope scoring
|
|
400
|
+
if (scope && entry.scope) {
|
|
401
|
+
if (entry.scope === scope) {
|
|
402
|
+
score += 3; // exact match
|
|
403
|
+
} else if (scope.startsWith(entry.scope + '/') || entry.scope.startsWith(scope + '/')) {
|
|
404
|
+
score += 2; // ancestor or descendant
|
|
405
|
+
} else if (entry.scope === 'global') {
|
|
406
|
+
score += 1; // global always partially relevant
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Tag scoring
|
|
411
|
+
if (tags.length > 0 && entry.tags) {
|
|
412
|
+
const entryTagSet = new Set(entry.tags.map(t => t.toLowerCase()));
|
|
413
|
+
for (const tag of tags) {
|
|
414
|
+
if (entryTagSet.has(tag.toLowerCase())) {
|
|
415
|
+
score += 1;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return { ...entry, _score: score };
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
return scored
|
|
424
|
+
.filter(e => e._score > 0)
|
|
425
|
+
.sort((a, b) => b._score - a._score)
|
|
426
|
+
.slice(0, limit);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Keyword search across all entries.
|
|
431
|
+
* @param {string} keyword
|
|
432
|
+
* @param {number} [limit=20]
|
|
433
|
+
* @returns {object[]}
|
|
434
|
+
*/
|
|
435
|
+
search(keyword, limit = 20) {
|
|
436
|
+
if (!keyword || !keyword.trim()) return [];
|
|
437
|
+
|
|
438
|
+
const lowerKeyword = keyword.toLowerCase();
|
|
439
|
+
const entries = this.listEntries();
|
|
440
|
+
const results = [];
|
|
441
|
+
|
|
442
|
+
for (const entry of entries) {
|
|
443
|
+
if (results.length >= limit) break;
|
|
444
|
+
|
|
445
|
+
const searchable = [
|
|
446
|
+
entry.name,
|
|
447
|
+
entry.kind,
|
|
448
|
+
entry.scope,
|
|
449
|
+
(entry.tags || []).join(' '),
|
|
450
|
+
entry.content,
|
|
451
|
+
].join(' ').toLowerCase();
|
|
452
|
+
|
|
453
|
+
if (searchable.includes(lowerKeyword)) {
|
|
454
|
+
results.push(entry);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return results;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ─── Stats ────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Get memory statistics.
|
|
465
|
+
* @returns {{ entryCount: number, scopes: string[], kinds: object }}
|
|
466
|
+
*/
|
|
467
|
+
stats() {
|
|
468
|
+
const entries = this.listEntries();
|
|
469
|
+
const kinds = {};
|
|
470
|
+
const scopeSet = new Set();
|
|
471
|
+
|
|
472
|
+
for (const entry of entries) {
|
|
473
|
+
kinds[entry.kind] = (kinds[entry.kind] || 0) + 1;
|
|
474
|
+
if (entry.scope) scopeSet.add(entry.scope);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
entryCount: entries.length,
|
|
479
|
+
scopes: [...scopeSet].sort(),
|
|
480
|
+
kinds,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Clear all memory data.
|
|
486
|
+
*/
|
|
487
|
+
clear() {
|
|
488
|
+
// Clear entries
|
|
489
|
+
if (existsSync(this.#entriesDir)) {
|
|
490
|
+
for (const file of readdirSync(this.#entriesDir)) {
|
|
491
|
+
if (file.endsWith('.md')) {
|
|
492
|
+
unlinkSync(join(this.#entriesDir, file));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Clear MEMORY.md
|
|
498
|
+
if (existsSync(this.#memoryPath)) {
|
|
499
|
+
writeFileSync(this.#memoryPath, '', 'utf8');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Clear scopes.md
|
|
503
|
+
if (existsSync(this.#scopesPath)) {
|
|
504
|
+
unlinkSync(this.#scopesPath);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
package/unify/models.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* models.js — Model ID registry for Yeaft Unify
|
|
3
|
+
*
|
|
4
|
+
* Maps model IDs (e.g. "gpt-5", "claude-sonnet-4-20250514") to their
|
|
5
|
+
* adapter type, API base URL, and capabilities.
|
|
6
|
+
*
|
|
7
|
+
* Yeaft does not provide its own models. The "model" field is always a
|
|
8
|
+
* model ID from an external provider. This registry lets Yeaft auto-detect
|
|
9
|
+
* the correct adapter and endpoint from just the model ID, so users only
|
|
10
|
+
* need to set YEAFT_MODEL=gpt-5 without configuring adapter/baseUrl separately.
|
|
11
|
+
*
|
|
12
|
+
* Unknown model IDs return null — caller falls back to env-based detection.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} ModelInfo
|
|
17
|
+
* @property {'anthropic' | 'chat-completions'} adapter — Which adapter to use
|
|
18
|
+
* @property {string} baseUrl — API endpoint base URL
|
|
19
|
+
* @property {number} contextWindow — Max context tokens
|
|
20
|
+
* @property {number} maxOutputTokens — Max output tokens
|
|
21
|
+
* @property {string} displayName — Human-readable model name
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** @type {Map<string, ModelInfo>} */
|
|
25
|
+
export const MODEL_REGISTRY = new Map([
|
|
26
|
+
// ── Anthropic ──────────────────────────────────────────────────
|
|
27
|
+
['claude-sonnet-4-20250514', {
|
|
28
|
+
adapter: 'anthropic',
|
|
29
|
+
baseUrl: 'https://api.anthropic.com',
|
|
30
|
+
contextWindow: 200000,
|
|
31
|
+
maxOutputTokens: 16384,
|
|
32
|
+
displayName: 'Claude Sonnet 4',
|
|
33
|
+
}],
|
|
34
|
+
['claude-opus-4-20250514', {
|
|
35
|
+
adapter: 'anthropic',
|
|
36
|
+
baseUrl: 'https://api.anthropic.com',
|
|
37
|
+
contextWindow: 200000,
|
|
38
|
+
maxOutputTokens: 16384,
|
|
39
|
+
displayName: 'Claude Opus 4',
|
|
40
|
+
}],
|
|
41
|
+
['claude-haiku-3-20250414', {
|
|
42
|
+
adapter: 'anthropic',
|
|
43
|
+
baseUrl: 'https://api.anthropic.com',
|
|
44
|
+
contextWindow: 200000,
|
|
45
|
+
maxOutputTokens: 8192,
|
|
46
|
+
displayName: 'Claude Haiku 3',
|
|
47
|
+
}],
|
|
48
|
+
|
|
49
|
+
// ── OpenAI ─────────────────────────────────────────────────────
|
|
50
|
+
['gpt-5', {
|
|
51
|
+
adapter: 'chat-completions',
|
|
52
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
53
|
+
contextWindow: 256000,
|
|
54
|
+
maxOutputTokens: 16384,
|
|
55
|
+
displayName: 'GPT-5',
|
|
56
|
+
}],
|
|
57
|
+
['gpt-5.4', {
|
|
58
|
+
adapter: 'chat-completions',
|
|
59
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
60
|
+
contextWindow: 272000,
|
|
61
|
+
maxOutputTokens: 16384,
|
|
62
|
+
displayName: 'GPT-5.4',
|
|
63
|
+
}],
|
|
64
|
+
['gpt-4.1', {
|
|
65
|
+
adapter: 'chat-completions',
|
|
66
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
67
|
+
contextWindow: 1047576,
|
|
68
|
+
maxOutputTokens: 32768,
|
|
69
|
+
displayName: 'GPT-4.1',
|
|
70
|
+
}],
|
|
71
|
+
['gpt-4.1-mini', {
|
|
72
|
+
adapter: 'chat-completions',
|
|
73
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
74
|
+
contextWindow: 1047576,
|
|
75
|
+
maxOutputTokens: 16384,
|
|
76
|
+
displayName: 'GPT-4.1 Mini',
|
|
77
|
+
}],
|
|
78
|
+
['gpt-4.1-nano', {
|
|
79
|
+
adapter: 'chat-completions',
|
|
80
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
81
|
+
contextWindow: 1047576,
|
|
82
|
+
maxOutputTokens: 16384,
|
|
83
|
+
displayName: 'GPT-4.1 Nano',
|
|
84
|
+
}],
|
|
85
|
+
['o3', {
|
|
86
|
+
adapter: 'chat-completions',
|
|
87
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
88
|
+
contextWindow: 200000,
|
|
89
|
+
maxOutputTokens: 100000,
|
|
90
|
+
displayName: 'o3',
|
|
91
|
+
}],
|
|
92
|
+
['o4-mini', {
|
|
93
|
+
adapter: 'chat-completions',
|
|
94
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
95
|
+
contextWindow: 200000,
|
|
96
|
+
maxOutputTokens: 100000,
|
|
97
|
+
displayName: 'o4-mini',
|
|
98
|
+
}],
|
|
99
|
+
|
|
100
|
+
// ── DeepSeek ───────────────────────────────────────────────────
|
|
101
|
+
['deepseek-chat', {
|
|
102
|
+
adapter: 'chat-completions',
|
|
103
|
+
baseUrl: 'https://api.deepseek.com',
|
|
104
|
+
contextWindow: 131072,
|
|
105
|
+
maxOutputTokens: 8192,
|
|
106
|
+
displayName: 'DeepSeek Chat',
|
|
107
|
+
}],
|
|
108
|
+
['deepseek-reasoner', {
|
|
109
|
+
adapter: 'chat-completions',
|
|
110
|
+
baseUrl: 'https://api.deepseek.com',
|
|
111
|
+
contextWindow: 131072,
|
|
112
|
+
maxOutputTokens: 8192,
|
|
113
|
+
displayName: 'DeepSeek Reasoner',
|
|
114
|
+
}],
|
|
115
|
+
|
|
116
|
+
// ── Google (via OpenAI-compatible API) ─────────────────────────
|
|
117
|
+
['gemini-2.5-pro', {
|
|
118
|
+
adapter: 'chat-completions',
|
|
119
|
+
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
120
|
+
contextWindow: 1048576,
|
|
121
|
+
maxOutputTokens: 65536,
|
|
122
|
+
displayName: 'Gemini 2.5 Pro',
|
|
123
|
+
}],
|
|
124
|
+
['gemini-2.5-flash', {
|
|
125
|
+
adapter: 'chat-completions',
|
|
126
|
+
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
127
|
+
contextWindow: 1048576,
|
|
128
|
+
maxOutputTokens: 65536,
|
|
129
|
+
displayName: 'Gemini 2.5 Flash',
|
|
130
|
+
}],
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve a model name to its registry info.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} modelName — The model name (e.g., 'gpt-5', 'claude-sonnet-4-20250514')
|
|
137
|
+
* @returns {ModelInfo | null} — Model info, or null if not in registry
|
|
138
|
+
*/
|
|
139
|
+
export function resolveModel(modelName) {
|
|
140
|
+
if (!modelName) return null;
|
|
141
|
+
const info = MODEL_REGISTRY.get(modelName);
|
|
142
|
+
// Return a shallow copy so callers can't mutate the registry
|
|
143
|
+
return info ? { ...info } : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* List all known models.
|
|
148
|
+
*
|
|
149
|
+
* @returns {{ name: string, adapter: string, baseUrl: string, contextWindow: number, maxOutputTokens: number, displayName: string }[]}
|
|
150
|
+
*/
|
|
151
|
+
export function listModels() {
|
|
152
|
+
const result = [];
|
|
153
|
+
for (const [name, info] of MODEL_REGISTRY) {
|
|
154
|
+
result.push({ name, ...info });
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if a model name is in the registry.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} modelName
|
|
163
|
+
* @returns {boolean}
|
|
164
|
+
*/
|
|
165
|
+
export function isKnownModel(modelName) {
|
|
166
|
+
return MODEL_REGISTRY.has(modelName);
|
|
167
|
+
}
|