u-foo 2.2.3 → 2.3.0

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.
Files changed (67) hide show
  1. package/SKILLS/ufoo/SKILL.md +56 -12
  2. package/SKILLS/uinit/SKILL.md +3 -2
  3. package/modules/AGENTS.template.md +2 -1
  4. package/modules/bus/README.md +1 -1
  5. package/modules/context/SKILLS/uctx/SKILL.md +6 -4
  6. package/package.json +1 -1
  7. package/src/agent/codexThreadProvider.js +2 -2
  8. package/src/agent/controllerToolExecutor.js +24 -1
  9. package/src/agent/credentials/claude.js +85 -16
  10. package/src/agent/credentials/codex.js +251 -23
  11. package/src/agent/defaultBootstrap.js +3 -1
  12. package/src/agent/directAuthStatus.js +264 -0
  13. package/src/agent/internalRunner.js +18 -12
  14. package/src/agent/loopObservability.js +10 -0
  15. package/src/agent/loopRuntime.js +19 -0
  16. package/src/agent/ufooAgent.js +43 -13
  17. package/src/agent/upstreamTransport.js +23 -8
  18. package/src/bus/index.js +13 -5
  19. package/src/bus/message.js +157 -9
  20. package/src/bus/nickname.js +14 -3
  21. package/src/bus/subscriber.js +30 -10
  22. package/src/chat/agentDirectory.js +2 -2
  23. package/src/chat/commandExecutor.js +190 -8
  24. package/src/chat/commands.js +23 -4
  25. package/src/chat/completionController.js +30 -7
  26. package/src/chat/daemonMessageRouter.js +2 -1
  27. package/src/chat/index.js +9 -8
  28. package/src/cli/groupCoreCommands.js +5 -0
  29. package/src/cli.js +309 -0
  30. package/src/code/UCODE_PROMPT.md +3 -2
  31. package/src/code/nativeRunner.js +2 -1
  32. package/src/code/prompts/ufoo.js +3 -2
  33. package/src/config.js +16 -3
  34. package/src/context/doctor.js +1 -1
  35. package/src/daemon/groupOrchestrator.js +13 -9
  36. package/src/daemon/index.js +35 -18
  37. package/src/daemon/nicknameScope.js +37 -0
  38. package/src/daemon/ops.js +22 -10
  39. package/src/daemon/promptRequest.js +11 -2
  40. package/src/daemon/reporting.js +2 -3
  41. package/src/daemon/soloBootstrap.js +15 -3
  42. package/src/daemon/status.js +5 -1
  43. package/src/group/bootstrap.js +1 -1
  44. package/src/group/promptProfiles.js +106 -22
  45. package/src/group/templates.js +1 -0
  46. package/src/init/index.js +4 -0
  47. package/src/memory/historySearch.js +308 -0
  48. package/src/memory/index.js +653 -8
  49. package/src/providerapi/redactor.js +4 -1
  50. package/src/status/index.js +26 -2
  51. package/src/tools/handlers/memory.js +168 -0
  52. package/src/tools/index.js +12 -0
  53. package/src/tools/registry.js +12 -0
  54. package/src/tools/schemaFixtures.js +213 -0
  55. package/src/tools/tier1/editMemory.js +14 -0
  56. package/src/tools/tier1/forget.js +14 -0
  57. package/src/tools/tier1/recall.js +14 -0
  58. package/src/tools/tier1/remember.js +14 -0
  59. package/src/tools/tier1/searchHistory.js +14 -0
  60. package/src/tools/tier1/searchMemory.js +14 -0
  61. package/templates/groups/build-lane.json +44 -6
  62. package/templates/groups/build-ultra.json +6 -5
  63. package/templates/groups/design-system.json +84 -0
  64. package/templates/groups/product-discovery.json +9 -4
  65. package/templates/groups/ui-plan-review.json +84 -0
  66. package/templates/groups/ui-polish.json +6 -2
  67. package/templates/groups/verify-ship.json +9 -4
@@ -1,24 +1,669 @@
1
- const { ensureDir, appendJSONL, getTimestamp } = require("../bus/utils");
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const matter = require("gray-matter");
4
+
5
+ const {
6
+ ensureDir,
7
+ appendJSONL,
8
+ getTimestamp,
9
+ writeFileAtomic,
10
+ } = require("../bus/utils");
11
+ const { redactSecrets } = require("../providerapi/redactor");
2
12
  const { canonicalProjectRoot } = require("../projects/projectId");
3
13
  const { getUfooPaths } = require("../ufoo/paths");
4
14
 
15
+ const SCHEMA_VERSION = "1.0";
16
+ const ID_PREFIX = "mem-";
17
+ const ID_RE = /^mem-(\d{4,})$/;
18
+ const ENTRY_RE = /^mem-\d{4,}\.md$/;
19
+ const HISTORY_CACHE_TTL_MS = 60 * 60 * 1000;
20
+ const DEFAULT_PREFIX_MAX_TOKENS = 1500;
21
+ const prefixCache = new Map();
22
+
23
+ function sleepSync(ms) {
24
+ const buffer = new SharedArrayBuffer(4);
25
+ const array = new Int32Array(buffer);
26
+ Atomics.wait(array, 0, 0, ms);
27
+ }
28
+
29
+ function normalizeId(value = "") {
30
+ return String(value || "").trim();
31
+ }
32
+
33
+ function normalizeTitle(value = "") {
34
+ const title = String(value || "").trim().replace(/\s+/g, " ");
35
+ if (!title) throw buildMemoryError("invalid_memory_title", "memory title is required");
36
+ if (title.length > 150) {
37
+ throw buildMemoryError("invalid_memory_title", "memory title must be 150 characters or less");
38
+ }
39
+ return title;
40
+ }
41
+
42
+ function normalizeBody(value = "") {
43
+ const body = String(value || "").trim();
44
+ if (!body) throw buildMemoryError("invalid_memory_body", "memory body is required");
45
+ return body;
46
+ }
47
+
48
+ function normalizeTags(value = []) {
49
+ const source = Array.isArray(value)
50
+ ? value
51
+ : String(value || "").split(",");
52
+ const seen = new Set();
53
+ const tags = [];
54
+ for (const item of source) {
55
+ const tag = String(item || "").trim().toLowerCase();
56
+ if (!tag || seen.has(tag)) continue;
57
+ seen.add(tag);
58
+ tags.push(tag);
59
+ }
60
+ return tags;
61
+ }
62
+
63
+ function buildMemoryError(code, message, extra = {}) {
64
+ const err = new Error(String(message || "memory operation failed"));
65
+ err.code = String(code || "memory_error");
66
+ Object.assign(err, extra);
67
+ return err;
68
+ }
69
+
70
+ function frontmatterValue(value) {
71
+ if (typeof value === "string") return value.trim();
72
+ if (value === null || value === undefined) return "";
73
+ return String(value).trim();
74
+ }
75
+
76
+ function splitEntryContent(markdown = "") {
77
+ const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
78
+ let title = "";
79
+ let start = 0;
80
+ while (start < lines.length && !lines[start].trim()) start += 1;
81
+ if (start < lines.length && /^#\s+/.test(lines[start])) {
82
+ title = lines[start].replace(/^#\s+/, "").trim();
83
+ start += 1;
84
+ if (start < lines.length && !lines[start].trim()) start += 1;
85
+ }
86
+ return {
87
+ title,
88
+ body: lines.slice(start).join("\n").trim(),
89
+ };
90
+ }
91
+
92
+ function composeEntry(entry) {
93
+ const data = {
94
+ id: entry.id,
95
+ tags: normalizeTags(entry.tags),
96
+ source: frontmatterValue(entry.source) || "user",
97
+ created_at: frontmatterValue(entry.created_at),
98
+ updated_at: frontmatterValue(entry.updated_at),
99
+ status: frontmatterValue(entry.status) || "active",
100
+ schema_version: frontmatterValue(entry.schema_version) || SCHEMA_VERSION,
101
+ };
102
+ return matter.stringify(`# ${entry.title}\n\n${entry.body.trim()}\n`, data);
103
+ }
104
+
105
+ function parseIndexLine(line = "") {
106
+ const match = String(line || "").match(/^-\s+(mem-\d{4,})\s+\[([^\]]*)\]\s+(.+)$/);
107
+ if (!match) return null;
108
+ return {
109
+ id: match[1],
110
+ tags: normalizeTags(match[2]),
111
+ title: match[3].trim(),
112
+ status: "active",
113
+ };
114
+ }
115
+
116
+ function isProbablyRedacted(value) {
117
+ const redacted = redactSecrets(value);
118
+ return JSON.stringify(redacted) !== JSON.stringify(value);
119
+ }
120
+
121
+ function normalizeEchoText(value = "") {
122
+ return String(value || "")
123
+ .toLowerCase()
124
+ .replace(/\s+/g, " ")
125
+ .replace(/[^\p{L}\p{N}\s]/gu, "")
126
+ .trim();
127
+ }
128
+
129
+ function echoOverlapRatio(body = "", snippet = "") {
130
+ const normalizedBody = normalizeEchoText(body);
131
+ const normalizedSnippet = normalizeEchoText(snippet);
132
+ if (!normalizedBody || !normalizedSnippet) return 0;
133
+ if (normalizedBody.length >= 80 && normalizedSnippet.includes(normalizedBody)) return 1;
134
+ if (normalizedSnippet.length >= 80 && normalizedBody.includes(normalizedSnippet)) return 1;
135
+ const bodyTokens = new Set(normalizedBody.split(/\s+/).filter((token) => token.length > 2));
136
+ const snippetTokens = new Set(normalizedSnippet.split(/\s+/).filter((token) => token.length > 2));
137
+ if (bodyTokens.size === 0 || snippetTokens.size === 0) return 0;
138
+ let overlap = 0;
139
+ for (const token of bodyTokens) {
140
+ if (snippetTokens.has(token)) overlap += 1;
141
+ }
142
+ return overlap / Math.min(bodyTokens.size, snippetTokens.size);
143
+ }
144
+
145
+ function estimateTokens(value = "") {
146
+ const text = String(value || "");
147
+ if (!text) return 0;
148
+ return Math.max(1, Math.ceil(text.length / 4));
149
+ }
150
+
5
151
  class MemoryManager {
6
- constructor(projectRoot) {
7
- // Phase 0 scaffolding only: this seam must stay dormant until loop/runtime
8
- // wiring passes an explicit projectRoot into the memory tool path.
152
+ constructor(projectRoot, options = {}) {
9
153
  this.projectRoot = canonicalProjectRoot(projectRoot);
10
154
  const paths = getUfooPaths(this.projectRoot);
11
155
  this.memoryDir = paths.memoryDir;
12
- this.memoryFile = paths.memoryFile;
156
+ this.memoryFile = paths.memoryFile; // Legacy append-only path kept for compatibility.
157
+ this.indexFile = path.join(this.memoryDir, "INDEX.md");
158
+ this.auditFile = path.join(this.memoryDir, "audit.jsonl");
159
+ this.historySearchCacheFile = path.join(this.memoryDir, ".history-search-cache.jsonl");
160
+ this.lockDir = path.join(this.memoryDir, ".lock");
161
+ this.idCounterFile = path.join(this.memoryDir, ".id-counter");
162
+ this.archiveDir = path.join(this.memoryDir, "archive");
163
+ if (options.ensure !== false) {
164
+ ensureDir(this.memoryDir);
165
+ ensureDir(this.archiveDir);
166
+ }
167
+ }
168
+
169
+ withLock(fn) {
13
170
  ensureDir(this.memoryDir);
171
+ const started = Date.now();
172
+ while (true) {
173
+ try {
174
+ fs.mkdirSync(this.lockDir);
175
+ break;
176
+ } catch (err) {
177
+ if (err && err.code !== "EEXIST") throw err;
178
+ if (Date.now() - started > 5000) {
179
+ throw buildMemoryError("memory_lock_timeout", "timed out waiting for memory lock");
180
+ }
181
+ sleepSync(10);
182
+ }
183
+ }
184
+ try {
185
+ return fn();
186
+ } finally {
187
+ fs.rmSync(this.lockDir, { recursive: true, force: true });
188
+ }
189
+ }
190
+
191
+ entryPath(id) {
192
+ return path.join(this.memoryDir, `${normalizeId(id)}.md`);
193
+ }
194
+
195
+ archivePath(id) {
196
+ return path.join(this.archiveDir, `${normalizeId(id)}.md`);
197
+ }
198
+
199
+ readCounter() {
200
+ try {
201
+ const raw = fs.readFileSync(this.idCounterFile, "utf8").trim();
202
+ const parsed = parseInt(raw, 10);
203
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
204
+ } catch {
205
+ return 0;
206
+ }
207
+ }
208
+
209
+ maxExistingNumber() {
210
+ let max = 0;
211
+ for (const dir of [this.memoryDir, this.archiveDir]) {
212
+ if (!fs.existsSync(dir)) continue;
213
+ for (const file of fs.readdirSync(dir)) {
214
+ if (!ENTRY_RE.test(file)) continue;
215
+ const match = file.replace(/\.md$/, "").match(ID_RE);
216
+ if (!match) continue;
217
+ max = Math.max(max, parseInt(match[1], 10) || 0);
218
+ }
219
+ }
220
+ return max;
221
+ }
222
+
223
+ allocateId() {
224
+ const next = Math.max(this.readCounter(), this.maxExistingNumber()) + 1;
225
+ writeFileAtomic(this.idCounterFile, `${next}\n`);
226
+ return `${ID_PREFIX}${String(next).padStart(4, "0")}`;
227
+ }
228
+
229
+ readEntryFile(filePath, statusHint = "") {
230
+ const raw = fs.readFileSync(filePath, "utf8");
231
+ const parsed = matter(raw);
232
+ const id = normalizeId(parsed.data.id || path.basename(filePath, ".md"));
233
+ const split = splitEntryContent(parsed.content);
234
+ const stat = fs.statSync(filePath);
235
+ const tags = normalizeTags(parsed.data.tags || []);
236
+ return {
237
+ id,
238
+ title: split.title || id,
239
+ body: split.body,
240
+ tags,
241
+ source: frontmatterValue(parsed.data.source) || "user",
242
+ created_at: frontmatterValue(parsed.data.created_at) || stat.birthtime.toISOString(),
243
+ updated_at: frontmatterValue(parsed.data.updated_at) || stat.mtime.toISOString(),
244
+ status: frontmatterValue(parsed.data.status) || statusHint || "active",
245
+ schema_version: frontmatterValue(parsed.data.schema_version) || SCHEMA_VERSION,
246
+ file_path: filePath,
247
+ };
248
+ }
249
+
250
+ get(id, options = {}) {
251
+ const memoryId = normalizeId(id);
252
+ if (!memoryId) throw buildMemoryError("invalid_memory_id", "memory id is required");
253
+ const activePath = this.entryPath(memoryId);
254
+ if (fs.existsSync(activePath)) return this.readEntryFile(activePath, "active");
255
+ if (options.includeArchived) {
256
+ const archivedPath = this.archivePath(memoryId);
257
+ if (fs.existsSync(archivedPath)) return this.readEntryFile(archivedPath, "archived");
258
+ }
259
+ throw buildMemoryError("memory_not_found", `memory ${memoryId} not found`, { id: memoryId });
260
+ }
261
+
262
+ list(options = {}) {
263
+ const includeArchived = options.all === true || options.includeArchived === true;
264
+ const tag = String(options.tag || "").trim().toLowerCase();
265
+ const entries = [];
266
+ const readDir = (dir, statusHint) => {
267
+ if (!fs.existsSync(dir)) return;
268
+ for (const file of fs.readdirSync(dir)) {
269
+ if (!ENTRY_RE.test(file)) continue;
270
+ const entry = this.readEntryFile(path.join(dir, file), statusHint);
271
+ if (tag && !entry.tags.includes(tag)) continue;
272
+ entries.push(entry);
273
+ }
274
+ };
275
+ readDir(this.memoryDir, "active");
276
+ if (includeArchived) readDir(this.archiveDir, "archived");
277
+ entries.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
278
+ const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
279
+ ? Math.floor(Number(options.limit))
280
+ : 0;
281
+ return limit ? entries.slice(0, limit) : entries;
282
+ }
283
+
284
+ indexEntries() {
285
+ return this.list({ includeArchived: false });
286
+ }
287
+
288
+ buildIndexContent(entries = this.indexEntries()) {
289
+ if (!entries.length) return "# Project Memory\n\n";
290
+ const lines = ["# Project Memory", ""];
291
+ for (const entry of entries) {
292
+ const tagText = entry.tags.length ? `[${entry.tags.join(",")}]` : "[]";
293
+ lines.push(`- ${entry.id} ${tagText} ${entry.title}`);
294
+ }
295
+ return `${lines.join("\n")}\n`;
296
+ }
297
+
298
+ rebuildIndex() {
299
+ const entries = this.indexEntries();
300
+ writeFileAtomic(this.indexFile, this.buildIndexContent(entries));
301
+ return entries;
302
+ }
303
+
304
+ readIndexSummaries(options = {}) {
305
+ if (!fs.existsSync(this.indexFile)) return [];
306
+ const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
307
+ ? Math.floor(Number(options.limit))
308
+ : 0;
309
+ const entries = fs.readFileSync(this.indexFile, "utf8")
310
+ .split(/\r?\n/)
311
+ .map(parseIndexLine)
312
+ .filter(Boolean);
313
+ return limit ? entries.slice(0, limit) : entries;
314
+ }
315
+
316
+ audit(action, entry, detail = {}) {
317
+ appendJSONL(this.auditFile, {
318
+ schema_version: SCHEMA_VERSION,
319
+ ts: getTimestamp(),
320
+ action,
321
+ id: entry && entry.id ? entry.id : detail.id || "",
322
+ title: entry && entry.title ? entry.title : detail.title || "",
323
+ source: detail.source || "",
324
+ actor: detail.actor || "",
325
+ turn_id: detail.turn_id || "",
326
+ tool_call_id: detail.tool_call_id || "",
327
+ caller_tier: detail.caller_tier || "",
328
+ history_session_id: detail.history_session_id || "",
329
+ history_offset: detail.history_offset || "",
330
+ recall_ids: Array.isArray(detail.recall_ids) ? detail.recall_ids : [],
331
+ query: detail.query || "",
332
+ result_count: Number.isFinite(Number(detail.result_count)) ? Number(detail.result_count) : undefined,
333
+ snippet_summaries: Array.isArray(detail.snippet_summaries) ? detail.snippet_summaries : undefined,
334
+ before: detail.before || null,
335
+ after: detail.after || null,
336
+ });
337
+ }
338
+
339
+ recordHistorySearch(query = "", snippets = [], detail = {}) {
340
+ const rows = Array.isArray(snippets) ? snippets : [];
341
+ for (const snippet of rows) {
342
+ appendJSONL(this.historySearchCacheFile, {
343
+ ts: getTimestamp(),
344
+ query: String(query || ""),
345
+ source: snippet.source || "",
346
+ session_id: snippet.session_id || "",
347
+ role: snippet.role || "",
348
+ text: String(snippet.text || "").slice(0, 2000),
349
+ });
350
+ }
351
+ this.audit("search_history", null, {
352
+ ...(detail.audit || detail),
353
+ query: String(query || ""),
354
+ result_count: rows.length,
355
+ snippet_summaries: rows.map((snippet) => ({
356
+ source: snippet.source || "",
357
+ session_id: snippet.session_id || "",
358
+ ts: snippet.ts || "",
359
+ role: snippet.role || "",
360
+ chars: String(snippet.text || "").length,
361
+ tool_name: snippet.tool_name || "",
362
+ })),
363
+ });
364
+ }
365
+
366
+ recentHistorySnippets(options = {}) {
367
+ if (!fs.existsSync(this.historySearchCacheFile)) return [];
368
+ const cutoff = Date.now() - (
369
+ Number.isFinite(Number(options.ttlMs)) && Number(options.ttlMs) > 0
370
+ ? Number(options.ttlMs)
371
+ : HISTORY_CACHE_TTL_MS
372
+ );
373
+ return fs.readFileSync(this.historySearchCacheFile, "utf8")
374
+ .split(/\r?\n/)
375
+ .filter(Boolean)
376
+ .map((line) => {
377
+ try { return JSON.parse(line); } catch { return null; }
378
+ })
379
+ .filter((row) => {
380
+ if (!row || !row.text) return false;
381
+ const ts = Date.parse(row.ts || "");
382
+ return Number.isFinite(ts) ? ts >= cutoff : true;
383
+ });
384
+ }
385
+
386
+ assertNoHistoryEcho(body = "") {
387
+ const text = String(body || "");
388
+ if (text.length < 80) return;
389
+ for (const snippet of this.recentHistorySnippets()) {
390
+ if (echoOverlapRatio(text, snippet.text || "") >= 0.8) {
391
+ throw buildMemoryError(
392
+ "memory_history_echo",
393
+ "memory body overlaps recent search_history output; restate the durable fact in your own words",
394
+ { history_session_id: snippet.session_id || "" }
395
+ );
396
+ }
397
+ }
398
+ }
399
+
400
+ readAudit(id = "") {
401
+ if (!fs.existsSync(this.auditFile)) return [];
402
+ const target = normalizeId(id);
403
+ const rows = fs.readFileSync(this.auditFile, "utf8")
404
+ .split(/\r?\n/)
405
+ .filter(Boolean)
406
+ .map((line) => {
407
+ try {
408
+ return JSON.parse(line);
409
+ } catch {
410
+ return null;
411
+ }
412
+ })
413
+ .filter(Boolean);
414
+ return target ? rows.filter((row) => row.id === target) : rows;
415
+ }
416
+
417
+ assertNoSecret(entry) {
418
+ if (isProbablyRedacted({
419
+ title: entry.title || "",
420
+ body: entry.body || "",
421
+ tags: entry.tags || [],
422
+ })) {
423
+ throw buildMemoryError("memory_secret_detected", "memory contains a value that looks like a secret");
424
+ }
425
+ }
426
+
427
+ add(input = {}, options = {}) {
428
+ return this.withLock(() => {
429
+ const now = getTimestamp();
430
+ const entry = {
431
+ id: this.allocateId(),
432
+ title: normalizeTitle(input.title || input.description || input.content || ""),
433
+ body: normalizeBody(input.body || input.content || input.description || input.title || ""),
434
+ tags: normalizeTags(input.tags || []),
435
+ source: input.source || options.source || "user",
436
+ created_at: now,
437
+ updated_at: now,
438
+ status: "active",
439
+ schema_version: SCHEMA_VERSION,
440
+ };
441
+ this.assertNoSecret(entry);
442
+ this.assertNoHistoryEcho(entry.body);
443
+ writeFileAtomic(this.entryPath(entry.id), composeEntry(entry));
444
+ this.rebuildIndex();
445
+ this.audit("add", entry, options.audit || options);
446
+ return this.get(entry.id);
447
+ });
14
448
  }
15
449
 
16
450
  addEntry(entry) {
17
- appendJSONL(this.memoryFile, {
18
- timestamp: getTimestamp(),
19
- ...entry,
451
+ return this.add({
452
+ title: entry.title || entry.content || entry.type || "Memory entry",
453
+ body: entry.body || entry.content || JSON.stringify(entry),
454
+ tags: entry.tags || [],
455
+ source: entry.source || "agent:legacy",
456
+ });
457
+ }
458
+
459
+ update(id, patch = {}, options = {}) {
460
+ return this.withLock(() => {
461
+ const prior = this.get(id);
462
+ const expected = String(patch.expected_updated_at || options.expected_updated_at || "").trim();
463
+ if (expected && expected !== prior.updated_at) {
464
+ throw buildMemoryError("memory_conflict", `memory ${prior.id} was updated by another writer`, {
465
+ id: prior.id,
466
+ expected_updated_at: expected,
467
+ actual_updated_at: prior.updated_at,
468
+ });
469
+ }
470
+ if (!Object.prototype.hasOwnProperty.call(patch, "title")
471
+ && !Object.prototype.hasOwnProperty.call(patch, "body")
472
+ && !Object.prototype.hasOwnProperty.call(patch, "tags")) {
473
+ throw buildMemoryError("invalid_memory_update", "memory update requires title, body, or tags");
474
+ }
475
+ const next = {
476
+ ...prior,
477
+ title: Object.prototype.hasOwnProperty.call(patch, "title")
478
+ ? normalizeTitle(patch.title)
479
+ : prior.title,
480
+ body: Object.prototype.hasOwnProperty.call(patch, "body")
481
+ ? normalizeBody(patch.body)
482
+ : prior.body,
483
+ tags: Object.prototype.hasOwnProperty.call(patch, "tags")
484
+ ? normalizeTags(patch.tags)
485
+ : prior.tags,
486
+ updated_at: getTimestamp(),
487
+ status: "active",
488
+ };
489
+ this.assertNoSecret(next);
490
+ if (Object.prototype.hasOwnProperty.call(patch, "body")) {
491
+ this.assertNoHistoryEcho(next.body);
492
+ }
493
+ writeFileAtomic(this.entryPath(next.id), composeEntry(next));
494
+ this.rebuildIndex();
495
+ this.audit("update", next, {
496
+ ...(options.audit || options),
497
+ before: { title: prior.title, tags: prior.tags, updated_at: prior.updated_at },
498
+ after: { title: next.title, tags: next.tags, updated_at: next.updated_at },
499
+ });
500
+ return this.get(next.id);
501
+ });
502
+ }
503
+
504
+ archive(id, options = {}) {
505
+ return this.withLock(() => {
506
+ const prior = this.get(id);
507
+ const archived = {
508
+ ...prior,
509
+ status: "archived",
510
+ updated_at: getTimestamp(),
511
+ };
512
+ writeFileAtomic(this.archivePath(archived.id), composeEntry(archived));
513
+ fs.rmSync(this.entryPath(archived.id), { force: true });
514
+ this.rebuildIndex();
515
+ this.audit("archive", archived, {
516
+ ...(options.audit || options),
517
+ before: { title: prior.title, tags: prior.tags, updated_at: prior.updated_at },
518
+ after: { title: archived.title, tags: archived.tags, updated_at: archived.updated_at },
519
+ });
520
+ return this.get(archived.id, { includeArchived: true });
20
521
  });
21
522
  }
523
+
524
+ search(query = "", options = {}) {
525
+ const tokens = String(query || "")
526
+ .toLowerCase()
527
+ .split(/[^a-z0-9_\u4e00-\u9fff]+/i)
528
+ .map((token) => token.trim())
529
+ .filter(Boolean);
530
+ if (tokens.length === 0) return [];
531
+ const entries = this.list({ includeArchived: options.includeArchived === true });
532
+ const scored = entries.map((entry) => {
533
+ const haystack = [
534
+ entry.id,
535
+ entry.title,
536
+ entry.body,
537
+ entry.tags.join(" "),
538
+ ].join(" ").toLowerCase();
539
+ let score = 0;
540
+ for (const token of tokens) {
541
+ if (haystack.includes(token)) score += 1;
542
+ if (entry.title.toLowerCase().includes(token)) score += 2;
543
+ if (entry.tags.some((tag) => tag.includes(token))) score += 2;
544
+ }
545
+ return { entry, score };
546
+ }).filter((item) => item.score > 0);
547
+ scored.sort((a, b) => b.score - a.score || String(b.entry.updated_at).localeCompare(String(a.entry.updated_at)));
548
+ const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
549
+ ? Math.floor(Number(options.limit))
550
+ : 5;
551
+ return scored.slice(0, limit).map((item) => item.entry);
552
+ }
553
+
554
+ buildPrefix(options = {}) {
555
+ return this.buildPrefixResult(options).prefix;
556
+ }
557
+
558
+ buildPrefixResult(options = {}) {
559
+ const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
560
+ ? Math.floor(Number(options.limit))
561
+ : 0;
562
+ const maxTokens = Number.isFinite(Number(options.maxTokens)) && Number(options.maxTokens) > 0
563
+ ? Math.floor(Number(options.maxTokens))
564
+ : DEFAULT_PREFIX_MAX_TOKENS;
565
+ let entries = this.readIndexSummaries({ limit });
566
+ if (entries.length === 0 && this.list({ limit: 1 }).length > 0) {
567
+ entries = this.list({ limit }).map((entry) => ({
568
+ id: entry.id,
569
+ title: entry.title,
570
+ tags: entry.tags,
571
+ status: entry.status,
572
+ }));
573
+ }
574
+ if (entries.length === 0) {
575
+ return {
576
+ prefix: "",
577
+ entry_count: 0,
578
+ emitted_count: 0,
579
+ truncated: false,
580
+ estimated_tokens: 0,
581
+ };
582
+ }
583
+ const lines = ["## Project Memory", ""];
584
+ let emitted = 0;
585
+ let truncated = false;
586
+ for (const entry of entries) {
587
+ const tagText = entry.tags.length ? `[${entry.tags.join(",")}]` : "[]";
588
+ const nextLine = `- ${entry.id} ${tagText} ${entry.title}`;
589
+ const candidate = `${[...lines, nextLine].join("\n")}\n`;
590
+ if (estimateTokens(candidate) > maxTokens) {
591
+ truncated = true;
592
+ break;
593
+ }
594
+ lines.push(nextLine);
595
+ emitted += 1;
596
+ }
597
+ const prefix = emitted > 0 ? `${lines.join("\n")}\n` : "";
598
+ return {
599
+ prefix,
600
+ entry_count: entries.length,
601
+ emitted_count: emitted,
602
+ truncated: truncated || emitted < entries.length,
603
+ estimated_tokens: estimateTokens(prefix),
604
+ };
605
+ }
606
+
607
+ readObservabilitySummary(options = {}) {
608
+ const sinceMs = Date.now() - (
609
+ Number.isFinite(Number(options.windowMs)) && Number(options.windowMs) > 0
610
+ ? Number(options.windowMs)
611
+ : HISTORY_CACHE_TTL_MS
612
+ );
613
+ const rows = this.readAudit().filter((row) => {
614
+ const ts = Date.parse(row.ts || "");
615
+ return Number.isFinite(ts) ? ts >= sinceMs : true;
616
+ });
617
+ const writeActions = new Set(["add", "update", "archive"]);
618
+ const byActor = new Map();
619
+ for (const row of rows) {
620
+ if (!writeActions.has(row.action)) continue;
621
+ const actor = String(row.actor || row.source || "unknown");
622
+ const current = byActor.get(actor) || { actor, writes: 0, remember: 0, edit_memory: 0, forget: 0, warning: false };
623
+ current.writes += 1;
624
+ if (row.action === "add") current.remember += 1;
625
+ if (row.action === "update") current.edit_memory += 1;
626
+ if (row.action === "archive") current.forget += 1;
627
+ byActor.set(actor, current);
628
+ }
629
+ const rowsByActor = Array.from(byActor.values()).map((row) => ({
630
+ ...row,
631
+ warning: row.writes > 5,
632
+ }));
633
+ return {
634
+ window_ms: Number.isFinite(Number(options.windowMs)) && Number(options.windowMs) > 0
635
+ ? Number(options.windowMs)
636
+ : HISTORY_CACHE_TTL_MS,
637
+ actor_count: rowsByActor.length,
638
+ actors: rowsByActor.sort((a, b) => b.writes - a.writes || a.actor.localeCompare(b.actor)),
639
+ };
640
+ }
641
+ }
642
+
643
+ function buildCachedMemoryPrefix(projectRoot, options = {}) {
644
+ const root = canonicalProjectRoot(projectRoot);
645
+ const key = `${root}:${Number(options.limit) || 0}:${Number(options.maxTokens) || DEFAULT_PREFIX_MAX_TOKENS}`;
646
+ if (prefixCache.has(key)) {
647
+ return {
648
+ ...prefixCache.get(key),
649
+ cache_hit: true,
650
+ cache_semistatic_hit: prefixCache.get(key).estimated_tokens || 0,
651
+ cache_semistatic_miss: 0,
652
+ };
653
+ }
654
+ const manager = new MemoryManager(root, { ensure: false });
655
+ const result = manager.buildPrefixResult(options);
656
+ prefixCache.set(key, result);
657
+ return {
658
+ ...result,
659
+ cache_hit: false,
660
+ cache_semistatic_hit: 0,
661
+ cache_semistatic_miss: result.estimated_tokens || 0,
662
+ };
22
663
  }
23
664
 
24
665
  module.exports = MemoryManager;
666
+ module.exports.MemoryManager = MemoryManager;
667
+ module.exports.buildMemoryError = buildMemoryError;
668
+ module.exports.buildCachedMemoryPrefix = buildCachedMemoryPrefix;
669
+ module.exports.estimateTokens = estimateTokens;
@@ -3,6 +3,7 @@
3
3
  const REDACTED = "[REDACTED]";
4
4
  const SENSITIVE_KEY_PATTERN = /(^|_|-)(authorization|accesstoken|access_token|refreshtoken|refresh_token|apikey|api_key|tokenhash|token_hash)$/i;
5
5
  const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+\b/gi;
6
+ const INLINE_SECRET_ASSIGNMENT_PATTERN = /\b(api[_-]?key|access[_-]?token|refresh[_-]?token|token)\s*[:=]\s*["']?([A-Za-z0-9._~+/=-]{8,})["']?/gi;
6
7
 
7
8
  function isPlainObject(value) {
8
9
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -16,7 +17,9 @@ function isSensitiveKey(key = "") {
16
17
  }
17
18
 
18
19
  function redactString(value) {
19
- return String(value || "").replace(BEARER_PATTERN, "Bearer [REDACTED]");
20
+ return String(value || "")
21
+ .replace(BEARER_PATTERN, "Bearer [REDACTED]")
22
+ .replace(INLINE_SECRET_ASSIGNMENT_PATTERN, "$1=[REDACTED]");
20
23
  }
21
24
 
22
25
  function redactSecrets(value, options = {}) {