@fingerskier/augment 0.4.0 → 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.
package/dist/service.js CHANGED
@@ -3,9 +3,10 @@ import path from "node:path";
3
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";
10
11
  import { listProposals, readProposal, renderProposalsText, setProposalStatus, writeProposal, } from "./proposals.js";
11
12
  import { clusterSleepCandidates, renderSleepCandidatesText } from "./sleep.js";
@@ -33,6 +34,10 @@ export class AugmentService {
33
34
  * searches. */
34
35
  corpusPromise;
35
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();
36
41
  constructor(config, embeddingProvider = new TransformersEmbeddingProvider()) {
37
42
  this.config = config;
38
43
  this.embeddingProvider = embeddingProvider;
@@ -71,12 +76,14 @@ export class AugmentService {
71
76
  return this.db.listProjects(name);
72
77
  }
73
78
  async rebuildIndex() {
74
- try {
75
- return await this.rebuildIndexCore();
76
- }
77
- finally {
78
- this.invalidateCorpusSnapshot();
79
- }
79
+ return this.serializeMutation(async () => {
80
+ try {
81
+ return await this.rebuildIndexCore();
82
+ }
83
+ finally {
84
+ this.invalidateCorpusSnapshot();
85
+ }
86
+ });
80
87
  }
81
88
  async rebuildIndexCore() {
82
89
  await ensureConfigDirs(this.config);
@@ -114,12 +121,15 @@ export class AugmentService {
114
121
  * deletion is not authorization to rewrite other files (unlike {@link deleteMemory}).
115
122
  */
116
123
  async indexPath(relativePath) {
117
- try {
118
- await this.indexPathCore(relativePath);
119
- }
120
- finally {
121
- this.invalidateCorpusSnapshot();
122
- }
124
+ await this.init();
125
+ return this.serializeMutation(async () => {
126
+ try {
127
+ await this.indexPathCore(relativePath);
128
+ }
129
+ finally {
130
+ this.invalidateCorpusSnapshot();
131
+ }
132
+ });
123
133
  }
124
134
  async indexPathCore(relativePath) {
125
135
  await this.init();
@@ -154,14 +164,32 @@ export class AugmentService {
154
164
  recordSelfWrite(relativePath) {
155
165
  this.selfWrites.set(normalizeRelativePath(relativePath), Date.now());
156
166
  }
157
- async upsert(input) {
167
+ async memoryFileExists(relativePath) {
158
168
  try {
159
- return await this.upsertCore(input);
169
+ await stat(path.join(this.config.memoryRoot, normalizeRelativePath(relativePath)));
170
+ return true;
160
171
  }
161
- finally {
162
- 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;
163
180
  }
164
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
+ }
165
193
  async upsertCore(input) {
166
194
  await this.init();
167
195
  if (input.content.length > DEFAULT_MEMORY_HARD_LIMIT_CHARS) {
@@ -276,12 +304,15 @@ export class AugmentService {
276
304
  return { project, memories, links };
277
305
  }
278
306
  async deleteMemory(id) {
279
- try {
280
- return await this.deleteMemoryCore(id);
281
- }
282
- finally {
283
- this.invalidateCorpusSnapshot();
284
- }
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
+ });
285
316
  }
286
317
  async deleteMemoryCore(id) {
287
318
  await this.init();
@@ -343,12 +374,15 @@ export class AugmentService {
343
374
  return memory;
344
375
  }
345
376
  async link(fromRef, toRef, typeInput) {
346
- try {
347
- return await this.linkCore(fromRef, toRef, typeInput);
348
- }
349
- finally {
350
- this.invalidateCorpusSnapshot();
351
- }
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
+ });
352
386
  }
353
387
  async linkCore(fromRef, toRef, typeInput) {
354
388
  await this.init();
@@ -387,12 +421,15 @@ export class AugmentService {
387
421
  return this.db.listLinks(memoryId);
388
422
  }
389
423
  async unlink(linkId) {
390
- try {
391
- return await this.unlinkCore(linkId);
392
- }
393
- finally {
394
- this.invalidateCorpusSnapshot();
395
- }
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
+ });
396
433
  }
397
434
  async unlinkCore(linkId) {
398
435
  await this.init();
@@ -423,6 +460,14 @@ export class AugmentService {
423
460
  invalidateCorpusSnapshot() {
424
461
  this.corpusPromise = undefined;
425
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
+ }
426
471
  corpus() {
427
472
  if (!this.corpusPromise) {
428
473
  const build = (async () => ({
@@ -463,6 +508,10 @@ export class AugmentService {
463
508
  }
464
509
  async search(input) {
465
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;
466
515
  const limit = input.limit ?? DEFAULT_SEARCH_LIMIT;
467
516
  const maxChars = input.max_chars ?? DEFAULT_SEARCH_MAX_CHARS;
468
517
  const minScore = clamp01(input.min_score ?? DEFAULT_SEARCH_MIN_SCORE);
@@ -477,7 +526,7 @@ export class AugmentService {
477
526
  for (const memory of memories) {
478
527
  const semanticScore = queryEmbedding ? normalizedCosine(queryEmbedding, embeddings.get(memory.id)) : 0;
479
528
  const lexScore = lexicalScoreWith(queryTokens, searchDocument(memory));
480
- const metadataScore = metadataScoreFor(input.project_name, memory);
529
+ const metadataScore = metadataScoreFor(activeProjectName, memory);
481
530
  let score = clamp01(semanticScore * 0.55 + lexScore * 0.25 + metadataScore * 0.1);
482
531
  // Down-weight (never exclude) an archived memory's own relevance, after
483
532
  // the blend and before the relevance floor below -- so a down-weighted
@@ -521,13 +570,243 @@ export class AugmentService {
521
570
  .slice(0, limit);
522
571
  return {
523
572
  results,
524
- 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"),
525
804
  };
526
805
  }
527
806
  /**
528
807
  * READ-ONLY sleep preview: which settled WORK memories WOULD consolidate.
529
- * Performs zero writes the actual write-back (`sleep_apply`) is deferred
530
- * 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.
531
810
  */
532
811
  async sleepCandidates(input = {}, now = Date.now()) {
533
812
  await this.init();
@@ -588,12 +867,15 @@ export class AugmentService {
588
867
  * git: the route commits the whole pass as one revertible commit.
589
868
  */
590
869
  async sleepApply(input) {
591
- try {
592
- return await this.sleepApplyCore(input);
593
- }
594
- finally {
595
- this.invalidateCorpusSnapshot();
596
- }
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
+ });
597
879
  }
598
880
  async sleepApplyCore(input) {
599
881
  await this.init();
@@ -620,13 +902,13 @@ export class AugmentService {
620
902
  if (!project) {
621
903
  throw new Error(`Sleep proposal project not found in corpus: ${projectName}`);
622
904
  }
623
- // 4. Write the consolidated record through the public upsert (snapshot
624
- // invalidation + provenance handled there). skip_similarity_fold pins the
905
+ // 4. Write the consolidated record inside this compound mutation's critical
906
+ // section. skip_similarity_fold pins the
625
907
  // write to the approved target's deterministic path: the user approved
626
908
  // THIS identity (validateSleepSources above already guaranteed it's
627
909
  // free), so upsertCore must not divert it into some other lexically-
628
910
  // similar same-kind record it happens to subsume.
629
- const upserted = await this.upsert({
911
+ const upserted = await this.upsertCore({
630
912
  project_id: project.id,
631
913
  kind,
632
914
  name,
@@ -655,8 +937,207 @@ export class AugmentService {
655
937
  }
656
938
  async sleepReject(filename) {
657
939
  await this.init();
658
- const proposal = await setProposalStatus(this.config.stateDir, "sleep", filename, "rejected");
659
- return { proposal, text: renderProposalsText([proposal], "sleep") };
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) };
660
1141
  }
661
1142
  /**
662
1143
  * Shared sleep validation, run at propose time and re-run against the live
@@ -685,7 +1166,7 @@ export class AugmentService {
685
1166
  }
686
1167
  }
687
1168
  const targetPath = memoryRelativePath(projectName, kind, name);
688
- if (await this.db.getMemoryByPath(targetPath)) {
1169
+ if ((await this.db.getMemoryByPath(targetPath)) || (await this.memoryFileExists(targetPath))) {
689
1170
  throw new Error(`Sleep target already exists in corpus: ${targetPath}`);
690
1171
  }
691
1172
  }
@@ -766,6 +1247,52 @@ function mergeFrontmatterLink(links, link) {
766
1247
  }
767
1248
  return [...links, link];
768
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
+ }
769
1296
  function searchDocument(memory) {
770
1297
  return [
771
1298
  memory.project_name,