@fingerskier/augment 0.3.1 → 0.5.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/README.md +37 -20
  2. package/dist/cli.d.ts +9 -6
  3. package/dist/cli.js +9 -43
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config.d.ts +2 -0
  6. package/dist/config.js +2 -1
  7. package/dist/config.js.map +1 -1
  8. package/dist/constants.d.ts +1 -0
  9. package/dist/constants.js +7 -0
  10. package/dist/constants.js.map +1 -1
  11. package/dist/daemon/client.d.ts +10 -2
  12. package/dist/daemon/client.js +30 -4
  13. package/dist/daemon/client.js.map +1 -1
  14. package/dist/daemon/http.js +163 -28
  15. package/dist/daemon/http.js.map +1 -1
  16. package/dist/db.d.ts +1 -0
  17. package/dist/db.js +35 -4
  18. package/dist/db.js.map +1 -1
  19. package/dist/decoction.d.ts +51 -0
  20. package/dist/decoction.js +184 -0
  21. package/dist/decoction.js.map +1 -0
  22. package/dist/install.d.ts +2 -0
  23. package/dist/install.js +68 -5
  24. package/dist/install.js.map +1 -1
  25. package/dist/mcp/apps.js +0 -16
  26. package/dist/mcp/apps.js.map +1 -1
  27. package/dist/mcp/insights.d.ts +4 -6
  28. package/dist/mcp/insights.js +4 -11
  29. package/dist/mcp/insights.js.map +1 -1
  30. package/dist/mcp/server.js +124 -21
  31. package/dist/mcp/server.js.map +1 -1
  32. package/dist/memory/files.js +32 -2
  33. package/dist/memory/files.js.map +1 -1
  34. package/dist/memory/git.d.ts +5 -1
  35. package/dist/memory/git.js +7 -3
  36. package/dist/memory/git.js.map +1 -1
  37. package/dist/memory/project.d.ts +3 -0
  38. package/dist/memory/project.js +20 -0
  39. package/dist/memory/project.js.map +1 -1
  40. package/dist/pi/extension.d.ts +10 -2
  41. package/dist/pi/extension.js +82 -46
  42. package/dist/pi/extension.js.map +1 -1
  43. package/dist/proposals.d.ts +42 -0
  44. package/dist/proposals.js +180 -0
  45. package/dist/proposals.js.map +1 -0
  46. package/dist/recall-log.d.ts +21 -0
  47. package/dist/recall-log.js +24 -0
  48. package/dist/recall-log.js.map +1 -0
  49. package/dist/recall.d.ts +1 -1
  50. package/dist/service.d.ts +197 -2
  51. package/dist/service.js +770 -42
  52. package/dist/service.js.map +1 -1
  53. package/dist/types.d.ts +26 -2
  54. package/docs/FEATURES.md +247 -33
  55. package/docs/verify-memory-flow.md +132 -131
  56. package/integrations/claude/skills/augment-context/SKILL.md +74 -0
  57. package/integrations/claude/skills/augment-decoction/SKILL.md +36 -0
  58. package/integrations/claude/skills/augment-dream/SKILL.md +34 -0
  59. package/integrations/claude/skills/augment-dream/agents/openai.yaml +4 -0
  60. package/integrations/claude/skills/augment-sleep/SKILL.md +26 -0
  61. package/integrations/codex/SKILL.md +33 -0
  62. package/integrations/pi/skills/augment-decoction/SKILL.md +37 -0
  63. package/integrations/pi/skills/augment-dream/SKILL.md +36 -0
  64. package/package.json +16 -15
  65. package/dist/tally.d.ts +0 -46
  66. package/dist/tally.js +0 -94
  67. package/dist/tally.js.map +0 -1
package/dist/service.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { DEFAULT_MEMORY_HARD_LIMIT_CHARS, DEFAULT_SEARCH_LIMIT, DEFAULT_SEARCH_MAX_CHARS, DEFAULT_SEARCH_MIN_SCORE, SEMANTIC_COSINE_BASELINE, SEMANTIC_COSINE_MATCH, SLEEP_MAX_CLUSTERS, SLEEP_MIN_AGE_DAYS, SLEEP_MIN_COSINE, } from "./constants.js";
3
+ import { ARCHIVED_SEARCH_WEIGHT, DEFAULT_MEMORY_HARD_LIMIT_CHARS, DEFAULT_SEARCH_LIMIT, DEFAULT_SEARCH_MAX_CHARS, DEFAULT_SEARCH_MIN_SCORE, SEMANTIC_COSINE_BASELINE, SEMANTIC_COSINE_MATCH, SLEEP_MAX_CLUSTERS, SLEEP_MIN_AGE_DAYS, SLEEP_MIN_COSINE, } from "./constants.js";
4
4
  import { ensureConfigDirs } from "./config.js";
5
5
  import { AugmentDatabase } from "./db.js";
6
+ import { discoverDecoctionCandidates, } from "./decoction.js";
6
7
  import { TransformersEmbeddingProvider } from "./embeddings.js";
7
8
  import { deleteMemoryFile, normalizeKind, normalizeLinkType, normalizeRelativePath, memoryRelativePath, readMemory, writeMemory } from "./memory/files.js";
8
- import { inferProjectName } from "./memory/project.js";
9
+ import { GENERIC_PROJECT_NAME, inferProjectName, isGenericProjectName } from "./memory/project.js";
9
10
  import { findMemoryFiles } from "./memory/scan.js";
11
+ import { listProposals, readProposal, renderProposalsText, setProposalStatus, writeProposal, } from "./proposals.js";
10
12
  import { clusterSleepCandidates, renderSleepCandidatesText } from "./sleep.js";
11
13
  import { clamp01, cosineSimilarity, lexicalScoreWith, tokens } from "./util/text.js";
12
14
  /** How long a self-write marker stays claimable. Long enough to cover the watcher's
@@ -32,6 +34,10 @@ export class AugmentService {
32
34
  * searches. */
33
35
  corpusPromise;
34
36
  queryEmbeddingCache = new Map();
37
+ /** FIFO critical section for every corpus mutation. Compound operations use
38
+ * their private `*Core` helpers while holding this lock so their validation,
39
+ * frontmatter rewrites, and deletes cannot interleave with another request. */
40
+ mutationTail = Promise.resolve();
35
41
  constructor(config, embeddingProvider = new TransformersEmbeddingProvider()) {
36
42
  this.config = config;
37
43
  this.embeddingProvider = embeddingProvider;
@@ -70,12 +76,14 @@ export class AugmentService {
70
76
  return this.db.listProjects(name);
71
77
  }
72
78
  async rebuildIndex() {
73
- try {
74
- return await this.rebuildIndexCore();
75
- }
76
- finally {
77
- this.invalidateCorpusSnapshot();
78
- }
79
+ return this.serializeMutation(async () => {
80
+ try {
81
+ return await this.rebuildIndexCore();
82
+ }
83
+ finally {
84
+ this.invalidateCorpusSnapshot();
85
+ }
86
+ });
79
87
  }
80
88
  async rebuildIndexCore() {
81
89
  await ensureConfigDirs(this.config);
@@ -113,12 +121,15 @@ export class AugmentService {
113
121
  * deletion is not authorization to rewrite other files (unlike {@link deleteMemory}).
114
122
  */
115
123
  async indexPath(relativePath) {
116
- try {
117
- await this.indexPathCore(relativePath);
118
- }
119
- finally {
120
- this.invalidateCorpusSnapshot();
121
- }
124
+ await this.init();
125
+ return this.serializeMutation(async () => {
126
+ try {
127
+ await this.indexPathCore(relativePath);
128
+ }
129
+ finally {
130
+ this.invalidateCorpusSnapshot();
131
+ }
132
+ });
122
133
  }
123
134
  async indexPathCore(relativePath) {
124
135
  await this.init();
@@ -153,14 +164,32 @@ export class AugmentService {
153
164
  recordSelfWrite(relativePath) {
154
165
  this.selfWrites.set(normalizeRelativePath(relativePath), Date.now());
155
166
  }
156
- async upsert(input) {
167
+ async memoryFileExists(relativePath) {
157
168
  try {
158
- return await this.upsertCore(input);
169
+ await stat(path.join(this.config.memoryRoot, normalizeRelativePath(relativePath)));
170
+ return true;
159
171
  }
160
- finally {
161
- this.invalidateCorpusSnapshot();
172
+ catch (error) {
173
+ if (typeof error === "object" &&
174
+ error !== null &&
175
+ "code" in error &&
176
+ error.code === "ENOENT") {
177
+ return false;
178
+ }
179
+ throw error;
162
180
  }
163
181
  }
182
+ async upsert(input) {
183
+ await this.init();
184
+ return this.serializeMutation(async () => {
185
+ try {
186
+ return await this.upsertCore(input);
187
+ }
188
+ finally {
189
+ this.invalidateCorpusSnapshot();
190
+ }
191
+ });
192
+ }
164
193
  async upsertCore(input) {
165
194
  await this.init();
166
195
  if (input.content.length > DEFAULT_MEMORY_HARD_LIMIT_CHARS) {
@@ -204,7 +233,9 @@ export class AugmentService {
204
233
  // memory at its deterministic path instead. Both versions stay
205
234
  // recoverable from the store and the prior body never pollutes the new
206
235
  // memory's embedding/lexical document.
207
- if (similar && lexicalScoreWith(new Set(tokens(similar.body)), input.content) >= 1) {
236
+ if (!input.skip_similarity_fold &&
237
+ similar &&
238
+ lexicalScoreWith(new Set(tokens(similar.body)), input.content) >= 1) {
208
239
  existing = similar;
209
240
  relativePath = similar.relative_path;
210
241
  action = "similar-updated";
@@ -220,6 +251,9 @@ export class AugmentService {
220
251
  links: existing.links,
221
252
  created_at: existing.created_at,
222
253
  updated_at: now,
254
+ archived: existing.archived,
255
+ consolidated_into: existing.consolidated_into,
256
+ derived_from: input.derived_from ?? existing.derived_from,
223
257
  }
224
258
  : {
225
259
  project: project.name,
@@ -229,6 +263,7 @@ export class AugmentService {
229
263
  links: [],
230
264
  created_at: now,
231
265
  updated_at: now,
266
+ derived_from: input.derived_from,
232
267
  };
233
268
  await writeMemory(this.config.memoryRoot, relativePath, normalizeMemoryFrontmatter(metadata), input.content);
234
269
  this.recordSelfWrite(relativePath);
@@ -269,12 +304,15 @@ export class AugmentService {
269
304
  return { project, memories, links };
270
305
  }
271
306
  async deleteMemory(id) {
272
- try {
273
- return await this.deleteMemoryCore(id);
274
- }
275
- finally {
276
- this.invalidateCorpusSnapshot();
277
- }
307
+ await this.init();
308
+ return this.serializeMutation(async () => {
309
+ try {
310
+ return await this.deleteMemoryCore(id);
311
+ }
312
+ finally {
313
+ this.invalidateCorpusSnapshot();
314
+ }
315
+ });
278
316
  }
279
317
  async deleteMemoryCore(id) {
280
318
  await this.init();
@@ -306,6 +344,9 @@ export class AugmentService {
306
344
  links: memory.links.filter((link) => link.to !== targetPath),
307
345
  created_at: memory.created_at,
308
346
  updated_at: new Date().toISOString(),
347
+ archived: memory.archived,
348
+ consolidated_into: memory.consolidated_into,
349
+ derived_from: memory.derived_from,
309
350
  });
310
351
  await writeMemory(this.config.memoryRoot, memory.relative_path, frontmatter, memory.body);
311
352
  this.recordSelfWrite(memory.relative_path);
@@ -333,12 +374,15 @@ export class AugmentService {
333
374
  return memory;
334
375
  }
335
376
  async link(fromRef, toRef, typeInput) {
336
- try {
337
- return await this.linkCore(fromRef, toRef, typeInput);
338
- }
339
- finally {
340
- this.invalidateCorpusSnapshot();
341
- }
377
+ await this.init();
378
+ return this.serializeMutation(async () => {
379
+ try {
380
+ return await this.linkCore(fromRef, toRef, typeInput);
381
+ }
382
+ finally {
383
+ this.invalidateCorpusSnapshot();
384
+ }
385
+ });
342
386
  }
343
387
  async linkCore(fromRef, toRef, typeInput) {
344
388
  await this.init();
@@ -353,6 +397,9 @@ export class AugmentService {
353
397
  links: mergeFrontmatterLink(from.links, { to: to.relative_path, type }),
354
398
  created_at: from.created_at,
355
399
  updated_at: new Date().toISOString(),
400
+ archived: from.archived,
401
+ consolidated_into: from.consolidated_into,
402
+ derived_from: from.derived_from,
356
403
  });
357
404
  await writeMemory(this.config.memoryRoot, from.relative_path, frontmatter, from.body);
358
405
  this.recordSelfWrite(from.relative_path);
@@ -374,12 +421,15 @@ export class AugmentService {
374
421
  return this.db.listLinks(memoryId);
375
422
  }
376
423
  async unlink(linkId) {
377
- try {
378
- return await this.unlinkCore(linkId);
379
- }
380
- finally {
381
- this.invalidateCorpusSnapshot();
382
- }
424
+ await this.init();
425
+ return this.serializeMutation(async () => {
426
+ try {
427
+ return await this.unlinkCore(linkId);
428
+ }
429
+ finally {
430
+ this.invalidateCorpusSnapshot();
431
+ }
432
+ });
383
433
  }
384
434
  async unlinkCore(linkId) {
385
435
  await this.init();
@@ -396,6 +446,9 @@ export class AugmentService {
396
446
  links: from.links.filter((candidate) => !(candidate.to === link.to_relative_path && candidate.type === link.type)),
397
447
  created_at: from.created_at,
398
448
  updated_at: new Date().toISOString(),
449
+ archived: from.archived,
450
+ consolidated_into: from.consolidated_into,
451
+ derived_from: from.derived_from,
399
452
  });
400
453
  await writeMemory(this.config.memoryRoot, from.relative_path, frontmatter, from.body);
401
454
  this.recordSelfWrite(from.relative_path);
@@ -407,6 +460,14 @@ export class AugmentService {
407
460
  invalidateCorpusSnapshot() {
408
461
  this.corpusPromise = undefined;
409
462
  }
463
+ /** Queue one corpus mutation after the previous request, keeping the queue
464
+ * usable after failures. Assignment happens synchronously before any caller
465
+ * can enqueue the next request. */
466
+ serializeMutation(mutation) {
467
+ const result = this.mutationTail.then(mutation);
468
+ this.mutationTail = result.then(() => undefined, () => undefined);
469
+ return result;
470
+ }
410
471
  corpus() {
411
472
  if (!this.corpusPromise) {
412
473
  const build = (async () => ({
@@ -447,6 +508,10 @@ export class AugmentService {
447
508
  }
448
509
  async search(input) {
449
510
  await this.init();
511
+ // `.generic` is a storage pseudo-project, never an active/current project.
512
+ // Explicit generic CRUD remains valid, but search must not grant generics
513
+ // exact-project priority when a caller passes the reserved name.
514
+ const activeProjectName = input.project_name && isGenericProjectName(input.project_name) ? undefined : input.project_name;
450
515
  const limit = input.limit ?? DEFAULT_SEARCH_LIMIT;
451
516
  const maxChars = input.max_chars ?? DEFAULT_SEARCH_MAX_CHARS;
452
517
  const minScore = clamp01(input.min_score ?? DEFAULT_SEARCH_MIN_SCORE);
@@ -461,8 +526,16 @@ export class AugmentService {
461
526
  for (const memory of memories) {
462
527
  const semanticScore = queryEmbedding ? normalizedCosine(queryEmbedding, embeddings.get(memory.id)) : 0;
463
528
  const lexScore = lexicalScoreWith(queryTokens, searchDocument(memory));
464
- const metadataScore = metadataScoreFor(input.project_name, memory);
465
- const score = clamp01(semanticScore * 0.55 + lexScore * 0.25 + metadataScore * 0.1);
529
+ const metadataScore = metadataScoreFor(activeProjectName, memory);
530
+ let score = clamp01(semanticScore * 0.55 + lexScore * 0.25 + metadataScore * 0.1);
531
+ // Down-weight (never exclude) an archived memory's own relevance, after
532
+ // the blend and before the relevance floor below -- so a down-weighted
533
+ // score CAN fall under the floor and be abstained on, but read() still
534
+ // returns its full body verbatim (archival is a search-time concern
535
+ // only). See ARCHIVED_SEARCH_WEIGHT in constants.ts.
536
+ if (memory.archived) {
537
+ score = clamp01(score * ARCHIVED_SEARCH_WEIGHT);
538
+ }
466
539
  direct.set(memory.id, {
467
540
  memory,
468
541
  score,
@@ -497,13 +570,243 @@ export class AugmentService {
497
570
  .slice(0, limit);
498
571
  return {
499
572
  results,
500
- text: renderSearchText(input.query, input.project_name, results, maxChars),
573
+ text: renderSearchText(input.query, activeProjectName, results, maxChars),
574
+ };
575
+ }
576
+ async decoctionGlean(input) {
577
+ await this.init();
578
+ if (input.action === "discover") {
579
+ const limit = input.limit ?? 20;
580
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
581
+ throw new RangeError("Decoction discovery limit must be an integer between 1 and 50");
582
+ }
583
+ const snapshot = await this.corpus();
584
+ const discovered = discoverDecoctionCandidates({
585
+ memories: snapshot.memories,
586
+ embeddings: snapshot.embeddings,
587
+ ...(input.min_cosine !== undefined ? { minCosine: input.min_cosine } : {}),
588
+ });
589
+ const clusters = discovered.clusters.slice(0, limit);
590
+ return {
591
+ action: "discover",
592
+ ...discovered,
593
+ clusters,
594
+ text: renderDecoctionDiscoveryText(clusters, discovered.eligible, discovered.min_cosine),
595
+ };
596
+ }
597
+ if (input.action !== "write") {
598
+ throw new Error(`Unsupported decoction glean action: ${String(input.action)}`);
599
+ }
600
+ return this.serializeMutation(async () => {
601
+ try {
602
+ return await this.decoctionGleanWrite(input);
603
+ }
604
+ finally {
605
+ this.invalidateCorpusSnapshot();
606
+ }
607
+ });
608
+ }
609
+ async decoctionGleanWrite(input) {
610
+ const kind = normalizeKind(input.kind);
611
+ if (kind !== "SPEC" && kind !== "ARCH") {
612
+ throw new Error(`A generic memory created by glean must be SPEC or ARCH, not ${kind}`);
613
+ }
614
+ if (!input.name.trim()) {
615
+ throw new Error("A generic memory requires a non-empty name");
616
+ }
617
+ if (!Array.isArray(input.derived_from)) {
618
+ throw new Error("A generic memory requires derived_from evidence paths");
619
+ }
620
+ const sourcePaths = input.derived_from.map((sourcePath) => normalizeRelativePath(sourcePath));
621
+ if (new Set(sourcePaths).size !== sourcePaths.length) {
622
+ throw new Error("Generic memory evidence paths must be unique");
623
+ }
624
+ const sources = [];
625
+ for (const sourcePath of sourcePaths) {
626
+ const source = await this.db.getMemoryByPath(sourcePath);
627
+ if (!source) {
628
+ throw new Error(`Decoction evidence source not found in corpus: ${sourcePath}`);
629
+ }
630
+ if (isGenericProjectName(source.project_name) || source.relative_path.startsWith(`${GENERIC_PROJECT_NAME}/`)) {
631
+ throw new Error(`A generic memory cannot be used as original decoction evidence: ${sourcePath}`);
632
+ }
633
+ if (source.archived) {
634
+ throw new Error(`Archived memory cannot be used as decoction evidence: ${sourcePath}`);
635
+ }
636
+ if (!["WORK", "ISSUE", "SPEC", "ARCH"].includes(source.kind)) {
637
+ throw new Error(`Unsupported decoction evidence kind ${source.kind}: ${sourcePath}`);
638
+ }
639
+ const excludedTag = source.tags.find((tag) => ["private", "no-generalize"].includes(tag.trim().toLowerCase()));
640
+ if (excludedTag) {
641
+ throw new Error(`Memory tagged ${excludedTag} cannot be used as decoction evidence: ${sourcePath}`);
642
+ }
643
+ sources.push(source);
644
+ }
645
+ const distinctProjects = new Set(sources.map((source) => source.project_name));
646
+ if (distinctProjects.size < 4) {
647
+ throw new Error(`Generic memory evidence must span at least 4 distinct projects; received ${distinctProjects.size}`);
648
+ }
649
+ const sourcePathSet = new Set(sourcePaths);
650
+ const suggestedRemovals = (input.suggested_removals ?? []).map((suggestion) => {
651
+ const normalizedPath = normalizeRelativePath(suggestion.path);
652
+ if (!sourcePathSet.has(normalizedPath)) {
653
+ throw new Error(`Suggested removal must also be a derived_from evidence source: ${suggestion.path}`);
654
+ }
655
+ const reason = suggestion.reason.trim();
656
+ if (!reason) {
657
+ throw new Error(`Suggested removal requires a reason: ${suggestion.path}`);
658
+ }
659
+ return { path: normalizedPath, reason };
660
+ });
661
+ if (new Set(suggestedRemovals.map((suggestion) => suggestion.path)).size !== suggestedRemovals.length) {
662
+ throw new Error("Suggested removal paths must be unique");
663
+ }
664
+ const genericProject = await this.db.upsertProject(GENERIC_PROJECT_NAME);
665
+ const targetPath = memoryRelativePath(GENERIC_PROJECT_NAME, kind, input.name);
666
+ let existing = await this.db.getMemoryByPath(targetPath);
667
+ let liveTarget;
668
+ try {
669
+ liveTarget = await readMemory(this.config.memoryRoot, targetPath);
670
+ }
671
+ catch (error) {
672
+ if (typeof error !== "object" ||
673
+ error === null ||
674
+ !("code" in error) ||
675
+ error.code !== "ENOENT") {
676
+ throw error;
677
+ }
678
+ }
679
+ const hasExpectedContent = input.expected_content_hash !== undefined;
680
+ const hasExpectedFrontmatter = input.expected_frontmatter_hash !== undefined;
681
+ if (liveTarget) {
682
+ if (!hasExpectedContent || !hasExpectedFrontmatter) {
683
+ throw new Error(`Generic memory ${targetPath} already exists; expected_content_hash and expected_frontmatter_hash are required for compare-and-swap update`);
684
+ }
685
+ if (liveTarget.contentHash !== input.expected_content_hash ||
686
+ liveTarget.frontmatterHash !== input.expected_frontmatter_hash) {
687
+ throw new Error(`Generic memory ${targetPath} changed since it was read; expected hashes are stale`);
688
+ }
689
+ // The filesystem is canonical. If a matching live file has not reached
690
+ // the watcher/index yet, bring its metadata into the DB before upsertCore
691
+ // preserves tags, links, timestamps, and identity from the existing row.
692
+ if (!existing ||
693
+ existing.content_hash !== liveTarget.contentHash ||
694
+ existing.frontmatter_hash !== liveTarget.frontmatterHash) {
695
+ existing = await this.db.upsertMemory(liveTarget);
696
+ }
697
+ }
698
+ else if (existing) {
699
+ throw new Error(`Generic memory ${targetPath} changed since it was read; the live target file is missing`);
700
+ }
701
+ else if (hasExpectedContent || hasExpectedFrontmatter) {
702
+ throw new Error(`Generic memory ${targetPath} does not exist; expected hashes are only valid for updates`);
703
+ }
704
+ const upserted = await this.upsertCore({
705
+ project_id: genericProject.id,
706
+ ...(existing ? { memory_id: existing.id } : {}),
707
+ kind,
708
+ name: input.name,
709
+ content: input.content,
710
+ tags: input.tags,
711
+ derived_from: sourcePaths,
712
+ skip_similarity_fold: true,
713
+ });
714
+ if (upserted.memory.relative_path !== targetPath) {
715
+ throw new Error(`Decoction write diverted from ${targetPath} to ${upserted.memory.relative_path}`);
716
+ }
717
+ const warnings = [
718
+ ...credentialWarnings(input.content, "generic draft"),
719
+ ...sources.flatMap((source) => credentialWarnings(source.body, `evidence ${source.relative_path}`)),
720
+ ];
721
+ return {
722
+ action: "write",
723
+ memory: upserted.memory,
724
+ upsert_action: upserted.action,
725
+ suggested_removals: suggestedRemovals,
726
+ warnings,
727
+ text: renderDecoctionWriteText(upserted.memory, suggestedRemovals, warnings),
728
+ };
729
+ }
730
+ async decoctionCleanup(input) {
731
+ await this.init();
732
+ return this.serializeMutation(async () => {
733
+ try {
734
+ return await this.decoctionCleanupCore(input);
735
+ }
736
+ finally {
737
+ this.invalidateCorpusSnapshot();
738
+ }
739
+ });
740
+ }
741
+ async decoctionCleanupCore(input) {
742
+ await this.init();
743
+ const genericPath = normalizeRelativePath(input.generic_path);
744
+ const generic = await this.db.getMemoryByPath(genericPath);
745
+ if (!generic) {
746
+ throw new Error(`Generic memory not found in corpus: ${input.generic_path}`);
747
+ }
748
+ if (!isGenericProjectName(generic.project_name) || !generic.relative_path.startsWith(`${GENERIC_PROJECT_NAME}/`)) {
749
+ throw new Error(`Decoction cleanup target must be a generic memory: ${input.generic_path}`);
750
+ }
751
+ if (!Array.isArray(input.source_paths) || input.source_paths.length === 0) {
752
+ throw new Error("Decoction cleanup requires at least one source path");
753
+ }
754
+ const sourcePaths = input.source_paths.map((sourcePath) => normalizeRelativePath(sourcePath));
755
+ if (new Set(sourcePaths).size !== sourcePaths.length) {
756
+ throw new Error("Decoction cleanup source paths must be unique");
757
+ }
758
+ const sources = [];
759
+ for (const sourcePath of sourcePaths) {
760
+ const source = await this.db.getMemoryByPath(sourcePath);
761
+ if (!source) {
762
+ throw new Error(`Unknown memory path: ${sourcePath}`);
763
+ }
764
+ if (isGenericProjectName(source.project_name) || source.relative_path.startsWith(`${GENERIC_PROJECT_NAME}/`)) {
765
+ throw new Error(`A generic memory cannot be a decoction cleanup source: ${sourcePath}`);
766
+ }
767
+ sources.push(source);
768
+ }
769
+ const results = [];
770
+ for (const source of sources) {
771
+ let retargetedLinks = 0;
772
+ try {
773
+ const inboundLinks = (await this.db.listLinks(source.id)).filter((link) => link.to_memory_id === source.id && link.from_memory_id !== generic.id);
774
+ for (const inbound of inboundLinks) {
775
+ await this.linkCore(inbound.from_memory_id, generic.id, inbound.type);
776
+ retargetedLinks += 1;
777
+ }
778
+ await this.deleteMemoryCore(source.id);
779
+ results.push({
780
+ source_path: source.relative_path,
781
+ status: "deleted",
782
+ retargeted_links: retargetedLinks,
783
+ });
784
+ }
785
+ catch (error) {
786
+ results.push({
787
+ source_path: source.relative_path,
788
+ status: "failed",
789
+ retargeted_links: retargetedLinks,
790
+ error: error instanceof Error ? error.message : String(error),
791
+ });
792
+ }
793
+ }
794
+ const deleted = results.filter((result) => result.status === "deleted").length;
795
+ const failed = results.length - deleted;
796
+ return {
797
+ generic_path: generic.relative_path,
798
+ results,
799
+ text: [
800
+ `decoction cleanup for ${generic.relative_path}: ${deleted} deleted, ${failed} failed`,
801
+ ...results.map((result) => `- ${result.source_path}: status=${result.status}; retargeted_links=${result.retargeted_links}` +
802
+ (result.error ? `; error=${result.error}` : "")),
803
+ ].join("\n"),
501
804
  };
502
805
  }
503
806
  /**
504
807
  * READ-ONLY sleep preview: which settled WORK memories WOULD consolidate.
505
- * Performs zero writes the actual write-back (`sleep_apply`) is deferred
506
- * behind the Phase-2 go/no-go and does not exist.
808
+ * Performs zero writes; an agent must draft a proposal and receive explicit
809
+ * user approval before `sleep_apply` changes the corpus.
507
810
  */
508
811
  async sleepCandidates(input = {}, now = Date.now()) {
509
812
  await this.init();
@@ -513,6 +816,7 @@ export class AugmentService {
513
816
  const snapshot = await this.corpus();
514
817
  const cutoff = now - minAgeDays * 86_400_000;
515
818
  const eligible = snapshot.memories.filter((memory) => memory.kind === "WORK" &&
819
+ !memory.archived && // a consolidated source shouldn't be re-consolidated
516
820
  (!input.project_name || memory.project_name === input.project_name) &&
517
821
  Date.parse(memory.updated_at) <= cutoff);
518
822
  const clusters = clusterSleepCandidates({
@@ -527,6 +831,371 @@ export class AugmentService {
527
831
  text: renderSleepCandidatesText(clusters, eligible.length, minCosine, minAgeDays),
528
832
  };
529
833
  }
834
+ /**
835
+ * Draft a sleep consolidation as a review artifact OUTSIDE the corpus. Writes
836
+ * nothing to the memory store: the corpus is only mutated by {@link sleepApply}
837
+ * on an explicitly approved proposal. Validates the sources up front (same
838
+ * checks apply re-runs against the live corpus) so an impossible proposal fails
839
+ * loudly at draft time.
840
+ */
841
+ async sleepPropose(input) {
842
+ await this.init();
843
+ const kind = normalizeKind(input.kind);
844
+ await this.validateSleepSources(input.project_name, kind, input.name, input.derived_from);
845
+ const proposal = await writeProposal(this.config.stateDir, {
846
+ type: "sleep",
847
+ project: input.project_name,
848
+ kind,
849
+ name: input.name,
850
+ derived_from: input.derived_from,
851
+ ...(input.rationale !== undefined ? { rationale: input.rationale } : {}),
852
+ }, input.content);
853
+ return { proposal, text: renderProposalsText([proposal], "sleep") };
854
+ }
855
+ async sleepProposals(status) {
856
+ await this.init();
857
+ const proposals = await listProposals(this.config.stateDir, "sleep", status);
858
+ return { proposals, text: renderProposalsText(proposals, "sleep") };
859
+ }
860
+ /**
861
+ * Apply an approved sleep proposal — the ONLY corpus mutation in the flow. The
862
+ * sequence is a contract: read the proposal fresh from disk (user edits count),
863
+ * require it still be `proposed`, re-validate against the LIVE corpus (drafts
864
+ * sit while the corpus moves underneath them), upsert the consolidated record
865
+ * (provenance threaded into frontmatter), archive each source in place (body
866
+ * untouched, verbatim), then flip the proposal to `applied`. Does NOT commit
867
+ * git: the route commits the whole pass as one revertible commit.
868
+ */
869
+ async sleepApply(input) {
870
+ await this.init();
871
+ return this.serializeMutation(async () => {
872
+ try {
873
+ return await this.sleepApplyCore(input);
874
+ }
875
+ finally {
876
+ this.invalidateCorpusSnapshot();
877
+ }
878
+ });
879
+ }
880
+ async sleepApplyCore(input) {
881
+ await this.init();
882
+ // 1. Read the proposal fresh from disk so hand-edits (body, sources) count.
883
+ const proposal = await readProposal(this.config.stateDir, "sleep", input.filename);
884
+ const fm = proposal.frontmatter;
885
+ // 2. Only a still-proposed draft may apply; a second apply is rejected here.
886
+ if (fm.status !== "proposed") {
887
+ throw new Error(`Sleep proposal ${input.filename} is already ${fm.status}, not proposed (already applied?)`);
888
+ }
889
+ if (!fm.kind) {
890
+ throw new Error(`Sleep proposal ${input.filename} is missing a target kind`);
891
+ }
892
+ if (!fm.name) {
893
+ throw new Error(`Sleep proposal ${input.filename} is missing a target name`);
894
+ }
895
+ const kind = normalizeKind(fm.kind);
896
+ const name = fm.name;
897
+ const projectName = fm.project;
898
+ const derivedFrom = fm.derived_from;
899
+ // 3. Re-validate against the live corpus (sources may have moved since draft).
900
+ await this.validateSleepSources(projectName, kind, name, derivedFrom);
901
+ const project = await this.db.getProjectByName(projectName);
902
+ if (!project) {
903
+ throw new Error(`Sleep proposal project not found in corpus: ${projectName}`);
904
+ }
905
+ // 4. Write the consolidated record inside this compound mutation's critical
906
+ // section. skip_similarity_fold pins the
907
+ // write to the approved target's deterministic path: the user approved
908
+ // THIS identity (validateSleepSources above already guaranteed it's
909
+ // free), so upsertCore must not divert it into some other lexically-
910
+ // similar same-kind record it happens to subsume.
911
+ const upserted = await this.upsertCore({
912
+ project_id: project.id,
913
+ kind,
914
+ name,
915
+ content: proposal.body,
916
+ derived_from: derivedFrom,
917
+ skip_similarity_fold: true,
918
+ });
919
+ const expectedTargetPath = memoryRelativePath(projectName, kind, name);
920
+ if (upserted.memory.relative_path !== expectedTargetPath) {
921
+ throw new Error(`Sleep apply diverted from the approved target ${expectedTargetPath} to ${upserted.memory.relative_path}; aborting before archiving any source`);
922
+ }
923
+ const consolidatedInto = upserted.memory.relative_path;
924
+ // 5. Archive each source in place, pointing it at the consolidated record.
925
+ const archived = [];
926
+ for (const sourcePath of derivedFrom) {
927
+ archived.push(await this.archiveSource(sourcePath, consolidatedInto));
928
+ }
929
+ // 6. Flip the proposal to applied (outside the corpus).
930
+ const appliedProposal = await setProposalStatus(this.config.stateDir, "sleep", input.filename, "applied");
931
+ return {
932
+ memory: upserted.memory,
933
+ archived,
934
+ proposal: appliedProposal,
935
+ text: `applied sleep ${input.filename}: ${derivedFrom.length} WORK -> ${kind}/${name} (${projectName})`,
936
+ };
937
+ }
938
+ async sleepReject(filename) {
939
+ await this.init();
940
+ return this.serializeMutation(async () => {
941
+ const existing = await readProposal(this.config.stateDir, "sleep", filename);
942
+ if (existing.frontmatter.status !== "proposed") {
943
+ throw new Error(`Sleep proposal ${filename} is already ${existing.frontmatter.status}, not proposed`);
944
+ }
945
+ const proposal = await setProposalStatus(this.config.stateDir, "sleep", filename, "rejected");
946
+ return { proposal, text: renderProposalsText([proposal], "sleep") };
947
+ });
948
+ }
949
+ /**
950
+ * Draft one speculative memory or link outside the corpus. The daemon never
951
+ * synthesizes a dream: an agent supplies the complete candidate and this
952
+ * method only validates its live references and writes a review artifact.
953
+ */
954
+ async dreamPropose(input) {
955
+ await this.init();
956
+ const rawInput = input;
957
+ const hasLinkForm = rawInput.link !== undefined;
958
+ const hasMemoryForm = ["kind", "name", "content", "derived_from"].some((key) => rawInput[key] !== undefined);
959
+ if (hasLinkForm && hasMemoryForm) {
960
+ throw new Error("A dream proposal requires exactly one form: dream-memory or dream-link");
961
+ }
962
+ if (hasLinkForm) {
963
+ if (!rawInput.link || typeof rawInput.link !== "object") {
964
+ throw new Error("A dream-link proposal requires link endpoints");
965
+ }
966
+ const inputLink = rawInput.link;
967
+ const link = {
968
+ from: String(inputLink.from ?? ""),
969
+ to: String(inputLink.to ?? ""),
970
+ type: normalizeLinkType(String(inputLink.type ?? "")),
971
+ };
972
+ await this.validateDreamLink(input.project_name, link);
973
+ const proposal = await writeProposal(this.config.stateDir, {
974
+ type: "dream-link",
975
+ project: input.project_name,
976
+ derived_from: [],
977
+ link,
978
+ ...(input.rationale !== undefined ? { rationale: input.rationale } : {}),
979
+ }, "");
980
+ return { proposal, text: renderDreamProposalsText([proposal]) };
981
+ }
982
+ const memoryInput = input;
983
+ if (typeof memoryInput.name !== "string" || !memoryInput.name.trim()) {
984
+ throw new Error("A dream-memory proposal requires a target name");
985
+ }
986
+ if (typeof memoryInput.content !== "string") {
987
+ throw new Error("A dream-memory proposal requires content");
988
+ }
989
+ if (!Array.isArray(memoryInput.derived_from)) {
990
+ throw new Error("A dream-memory proposal requires derived_from inspiration paths");
991
+ }
992
+ if (typeof memoryInput.kind !== "string") {
993
+ throw new Error("A dream-memory proposal requires a target kind");
994
+ }
995
+ const kind = normalizeKind(memoryInput.kind);
996
+ await this.validateDreamMemory(memoryInput.project_name, kind, memoryInput.name, memoryInput.derived_from);
997
+ const proposal = await writeProposal(this.config.stateDir, {
998
+ type: "dream-memory",
999
+ project: memoryInput.project_name,
1000
+ kind,
1001
+ name: memoryInput.name,
1002
+ derived_from: memoryInput.derived_from,
1003
+ ...(memoryInput.rationale !== undefined ? { rationale: memoryInput.rationale } : {}),
1004
+ }, memoryInput.content);
1005
+ return { proposal, text: renderDreamProposalsText([proposal]) };
1006
+ }
1007
+ /** List the whole dreams/ proposal family, including both dream-memory and
1008
+ * dream-link records. `dream-memory` is intentionally only the directory
1009
+ * selector used by the shared proposal store. */
1010
+ async dreamProposals(status) {
1011
+ await this.init();
1012
+ const proposals = await listProposals(this.config.stateDir, "dream-memory", status);
1013
+ return { proposals, text: renderDreamProposalsText(proposals) };
1014
+ }
1015
+ /** Apply the explicitly approved proposal after re-reading and re-validating
1016
+ * it against the live corpus. The HTTP route owns the single git commit. */
1017
+ async dreamApply(input) {
1018
+ await this.init();
1019
+ return this.serializeMutation(async () => {
1020
+ try {
1021
+ return await this.dreamApplyCore(input);
1022
+ }
1023
+ finally {
1024
+ this.invalidateCorpusSnapshot();
1025
+ }
1026
+ });
1027
+ }
1028
+ async dreamApplyCore(input) {
1029
+ await this.init();
1030
+ const proposal = await readProposal(this.config.stateDir, "dream-memory", input.filename);
1031
+ const fm = proposal.frontmatter;
1032
+ if (fm.status !== "proposed") {
1033
+ throw new Error(`Dream proposal ${input.filename} is already ${fm.status}, not proposed`);
1034
+ }
1035
+ if (fm.type === "dream-memory") {
1036
+ if (!fm.kind) {
1037
+ throw new Error(`Dream proposal ${input.filename} is missing a target kind`);
1038
+ }
1039
+ if (!fm.name) {
1040
+ throw new Error(`Dream proposal ${input.filename} is missing a target name`);
1041
+ }
1042
+ const kind = normalizeKind(fm.kind);
1043
+ await this.validateDreamMemory(fm.project, kind, fm.name, fm.derived_from);
1044
+ const project = await this.db.getProjectByName(fm.project);
1045
+ if (!project) {
1046
+ throw new Error(`Dream proposal project not found in corpus: ${fm.project}`);
1047
+ }
1048
+ const upserted = await this.upsertCore({
1049
+ project_id: project.id,
1050
+ kind,
1051
+ name: fm.name,
1052
+ content: proposal.body,
1053
+ tags: ["dream"],
1054
+ derived_from: fm.derived_from,
1055
+ skip_similarity_fold: true,
1056
+ });
1057
+ const expectedTargetPath = memoryRelativePath(fm.project, kind, fm.name);
1058
+ if (upserted.memory.relative_path !== expectedTargetPath) {
1059
+ throw new Error(`Dream apply diverted from the approved target ${expectedTargetPath} to ${upserted.memory.relative_path}`);
1060
+ }
1061
+ const appliedProposal = await setProposalStatus(this.config.stateDir, "dream-memory", input.filename, "applied");
1062
+ return {
1063
+ type: "dream-memory",
1064
+ proposal: appliedProposal,
1065
+ memory: upserted.memory,
1066
+ text: `applied dream memory ${kind}/${fm.name} (${fm.project})`,
1067
+ };
1068
+ }
1069
+ if (fm.type === "dream-link") {
1070
+ if (!fm.link) {
1071
+ throw new Error(`Dream proposal ${input.filename} is missing link endpoints`);
1072
+ }
1073
+ const validated = await this.validateDreamLink(fm.project, fm.link);
1074
+ const linked = await this.linkCore(validated.from.relative_path, validated.to.relative_path, validated.type);
1075
+ const appliedProposal = await setProposalStatus(this.config.stateDir, "dream-memory", input.filename, "applied");
1076
+ return {
1077
+ type: "dream-link",
1078
+ proposal: appliedProposal,
1079
+ link: linked.link,
1080
+ text: `applied dream link ${validated.from.relative_path} -> ${validated.to.relative_path} (${validated.type})`,
1081
+ };
1082
+ }
1083
+ throw new Error(`Unsupported dream proposal type in ${input.filename}: ${String(fm.type)}`);
1084
+ }
1085
+ async dreamReject(filename) {
1086
+ await this.init();
1087
+ return this.serializeMutation(async () => {
1088
+ const existing = await readProposal(this.config.stateDir, "dream-memory", filename);
1089
+ if (existing.frontmatter.status !== "proposed") {
1090
+ throw new Error(`Dream proposal ${filename} is already ${existing.frontmatter.status}, not proposed`);
1091
+ }
1092
+ const proposal = await setProposalStatus(this.config.stateDir, "dream-memory", filename, "rejected");
1093
+ return { proposal, text: renderDreamProposalsText([proposal]) };
1094
+ });
1095
+ }
1096
+ async validateDreamMemory(projectName, kind, name, derivedFrom) {
1097
+ if (!projectName) {
1098
+ throw new Error("A dream-memory proposal requires project_name");
1099
+ }
1100
+ if (isGenericProjectName(projectName)) {
1101
+ throw new Error("The generic pseudo-project cannot be a dream active project");
1102
+ }
1103
+ if (derivedFrom.length < 1) {
1104
+ throw new Error("A dream-memory proposal needs at least 1 inspiration source");
1105
+ }
1106
+ if (!(await this.db.getProjectByName(projectName))) {
1107
+ throw new Error(`Dream proposal project not found in corpus: ${projectName}`);
1108
+ }
1109
+ for (const sourcePath of derivedFrom) {
1110
+ const source = await this.db.getMemoryByPath(normalizeRelativePath(sourcePath));
1111
+ if (!source) {
1112
+ throw new Error(`Dream source not found in corpus: ${sourcePath}`);
1113
+ }
1114
+ // There is deliberately no project check here. Normal global search may
1115
+ // inspire dreams from the current, generic, or any foreign project.
1116
+ }
1117
+ const targetPath = memoryRelativePath(projectName, kind, name);
1118
+ if ((await this.db.getMemoryByPath(targetPath)) || (await this.memoryFileExists(targetPath))) {
1119
+ throw new Error(`Dream target already exists in corpus: ${targetPath}`);
1120
+ }
1121
+ }
1122
+ async validateDreamLink(projectName, link) {
1123
+ if (!projectName) {
1124
+ throw new Error("A dream-link proposal requires project_name");
1125
+ }
1126
+ if (isGenericProjectName(projectName)) {
1127
+ throw new Error("The generic pseudo-project cannot be a dream active project");
1128
+ }
1129
+ const from = await this.db.getMemoryByPath(normalizeRelativePath(link.from));
1130
+ if (!from) {
1131
+ throw new Error(`Dream link source not found in corpus: ${link.from}`);
1132
+ }
1133
+ if (from.project_name !== projectName) {
1134
+ throw new Error(`Dream link source ${link.from} belongs to ${from.project_name}, not ${projectName}`);
1135
+ }
1136
+ const to = await this.db.getMemoryByPath(normalizeRelativePath(link.to));
1137
+ if (!to) {
1138
+ throw new Error(`Dream link target not found in corpus: ${link.to}`);
1139
+ }
1140
+ return { from, to, type: normalizeLinkType(link.type) };
1141
+ }
1142
+ /**
1143
+ * Shared sleep validation, run at propose time and re-run against the live
1144
+ * corpus at apply time. Every source must resolve, be a WORK memory in
1145
+ * `projectName` (single-project constraint), and be unarchived; there must be
1146
+ * >=2 of them; and the consolidation target must not already exist. Every
1147
+ * violation names the offending path.
1148
+ */
1149
+ async validateSleepSources(projectName, kind, name, derivedFrom) {
1150
+ if (derivedFrom.length < 2) {
1151
+ throw new Error(`A sleep consolidation needs at least 2 WORK sources; got ${derivedFrom.length}`);
1152
+ }
1153
+ for (const sourcePath of derivedFrom) {
1154
+ const source = await this.db.getMemoryByPath(normalizeRelativePath(sourcePath));
1155
+ if (!source) {
1156
+ throw new Error(`Sleep source not found in corpus: ${sourcePath}`);
1157
+ }
1158
+ if (source.kind !== "WORK") {
1159
+ throw new Error(`Sleep source must be a WORK memory: ${sourcePath} is ${source.kind}`);
1160
+ }
1161
+ if (source.project_name !== projectName) {
1162
+ throw new Error(`Sleep source ${sourcePath} belongs to ${source.project_name}, not ${projectName}`);
1163
+ }
1164
+ if (source.archived) {
1165
+ throw new Error(`Sleep source is already archived: ${sourcePath}`);
1166
+ }
1167
+ }
1168
+ const targetPath = memoryRelativePath(projectName, kind, name);
1169
+ if ((await this.db.getMemoryByPath(targetPath)) || (await this.memoryFileExists(targetPath))) {
1170
+ throw new Error(`Sleep target already exists in corpus: ${targetPath}`);
1171
+ }
1172
+ }
1173
+ /** Rewrite one source's frontmatter to archived + consolidated_into, leaving
1174
+ * the body verbatim, and reindex it. Mirrors {@link scrubInboundLinks}. Returns
1175
+ * the source's relative path. */
1176
+ async archiveSource(sourcePath, consolidatedInto) {
1177
+ const memory = await this.db.getMemoryByPath(normalizeRelativePath(sourcePath));
1178
+ if (!memory) {
1179
+ throw new Error(`Sleep source not found in corpus: ${sourcePath}`);
1180
+ }
1181
+ const frontmatter = normalizeMemoryFrontmatter({
1182
+ project: memory.project_name,
1183
+ kind: memory.kind,
1184
+ name: memory.name,
1185
+ tags: memory.tags,
1186
+ links: memory.links,
1187
+ created_at: memory.created_at,
1188
+ updated_at: new Date().toISOString(),
1189
+ archived: true,
1190
+ consolidated_into: consolidatedInto,
1191
+ derived_from: memory.derived_from,
1192
+ });
1193
+ await writeMemory(this.config.memoryRoot, memory.relative_path, frontmatter, memory.body);
1194
+ this.recordSelfWrite(memory.relative_path);
1195
+ const parsed = await readMemory(this.config.memoryRoot, memory.relative_path);
1196
+ await this.db.upsertMemory(parsed);
1197
+ return memory.relative_path;
1198
+ }
530
1199
  async findSimilarMemory(projectId, kind, content) {
531
1200
  const candidates = await this.db.listMemoriesForProject(projectId, kind);
532
1201
  const contentTokens = new Set(tokens(content));
@@ -557,6 +1226,19 @@ function normalizeMemoryFrontmatter(input) {
557
1226
  links: input.links ?? [],
558
1227
  created_at: input.created_at,
559
1228
  updated_at: input.updated_at,
1229
+ // Every corpus-mutating write reconstructs frontmatter through this
1230
+ // function. Keys are included ONLY when set so an ordinary (never
1231
+ // archived) memory never gains "archived: false" churn -- but every
1232
+ // caller MUST thread archived/consolidated_into/derived_from through from
1233
+ // the existing record, or a write that has nothing to do with archival
1234
+ // state (link/unlink/scrub) would silently strip it.
1235
+ ...(input.archived === true ? { archived: true } : {}),
1236
+ ...(typeof input.consolidated_into === "string"
1237
+ ? { consolidated_into: normalizeRelativePath(input.consolidated_into) }
1238
+ : {}),
1239
+ ...(Array.isArray(input.derived_from)
1240
+ ? { derived_from: input.derived_from.map((entry) => normalizeRelativePath(entry)) }
1241
+ : {}),
560
1242
  };
561
1243
  }
562
1244
  function mergeFrontmatterLink(links, link) {
@@ -565,6 +1247,52 @@ function mergeFrontmatterLink(links, link) {
565
1247
  }
566
1248
  return [...links, link];
567
1249
  }
1250
+ function renderDecoctionDiscoveryText(clusters, eligible, minCosine) {
1251
+ if (clusters.length === 0) {
1252
+ return `No decoction candidates found among ${eligible} eligible memories at complete-link cosine ${minCosine.toFixed(2)}. Discovery is READ-ONLY.`;
1253
+ }
1254
+ const lines = [
1255
+ `Decoction discovery is READ-ONLY: ${clusters.length} candidate cluster(s) among ${eligible} eligible memories.`,
1256
+ ];
1257
+ clusters.forEach((cluster, index) => {
1258
+ lines.push(`\n${index + 1}. ${cluster.distinct_projects} projects, ${cluster.sources.length} sources, minimum pairwise cosine ${cluster.min_pairwise_cosine.toFixed(3)}`);
1259
+ for (const source of cluster.sources) {
1260
+ lines.push(` - ${source.relative_path} [${source.kind}; project ${source.project_name}; id ${source.id}]`);
1261
+ }
1262
+ });
1263
+ return lines.join("\n");
1264
+ }
1265
+ function renderDecoctionWriteText(memory, suggestions, warnings) {
1266
+ const lines = [`wrote generic memory ${memory.id} at ${memory.relative_path}`];
1267
+ if (suggestions.length > 0) {
1268
+ lines.push("Suggested removals (not yet deleted):");
1269
+ for (const suggestion of suggestions) {
1270
+ lines.push(`- ${suggestion.path}: ${suggestion.reason}`);
1271
+ }
1272
+ }
1273
+ else {
1274
+ lines.push("No source removals suggested.");
1275
+ }
1276
+ if (warnings.length > 0) {
1277
+ lines.push("Warnings (write allowed):", ...warnings.map((warning) => `- ${warning}`));
1278
+ }
1279
+ return lines.join("\n");
1280
+ }
1281
+ function credentialWarnings(content, location) {
1282
+ const patterns = [
1283
+ { name: "AWS access-key-shaped credential", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ },
1284
+ { name: "GitHub-token-shaped credential", pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
1285
+ { name: "API-key-shaped credential", pattern: /\bsk-[A-Za-z0-9]{20,}\b/ },
1286
+ { name: "private-key material", pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
1287
+ ];
1288
+ return patterns
1289
+ .filter(({ pattern }) => pattern.test(content))
1290
+ .map(({ name }) => `Potential ${name} in ${location}; review before cleanup.`);
1291
+ }
1292
+ function renderDreamProposalsText(proposals) {
1293
+ const rendered = renderProposalsText(proposals, "dream-memory");
1294
+ return rendered.replace(/^No dream-memory proposals\./, "No dream proposals.").replace(/^dream-memory proposals/, "dream proposals");
1295
+ }
568
1296
  function searchDocument(memory) {
569
1297
  return [
570
1298
  memory.project_name,