@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.
- package/README.md +37 -20
- package/dist/cli.d.ts +9 -6
- package/dist/cli.js +9 -43
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +7 -0
- package/dist/constants.js.map +1 -1
- package/dist/daemon/client.d.ts +10 -2
- package/dist/daemon/client.js +30 -4
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/http.js +163 -28
- package/dist/daemon/http.js.map +1 -1
- package/dist/db.d.ts +1 -0
- package/dist/db.js +35 -4
- package/dist/db.js.map +1 -1
- package/dist/decoction.d.ts +51 -0
- package/dist/decoction.js +184 -0
- package/dist/decoction.js.map +1 -0
- package/dist/install.d.ts +2 -0
- package/dist/install.js +68 -5
- package/dist/install.js.map +1 -1
- package/dist/mcp/apps.js +0 -16
- package/dist/mcp/apps.js.map +1 -1
- package/dist/mcp/insights.d.ts +4 -6
- package/dist/mcp/insights.js +4 -11
- package/dist/mcp/insights.js.map +1 -1
- package/dist/mcp/server.js +124 -21
- package/dist/mcp/server.js.map +1 -1
- package/dist/memory/files.js +32 -2
- package/dist/memory/files.js.map +1 -1
- package/dist/memory/git.d.ts +5 -1
- package/dist/memory/git.js +7 -3
- package/dist/memory/git.js.map +1 -1
- package/dist/memory/project.d.ts +3 -0
- package/dist/memory/project.js +20 -0
- package/dist/memory/project.js.map +1 -1
- package/dist/pi/extension.d.ts +10 -2
- package/dist/pi/extension.js +82 -46
- package/dist/pi/extension.js.map +1 -1
- package/dist/proposals.d.ts +42 -0
- package/dist/proposals.js +180 -0
- package/dist/proposals.js.map +1 -0
- package/dist/recall-log.d.ts +21 -0
- package/dist/recall-log.js +24 -0
- package/dist/recall-log.js.map +1 -0
- package/dist/recall.d.ts +1 -1
- package/dist/service.d.ts +197 -2
- package/dist/service.js +770 -42
- package/dist/service.js.map +1 -1
- package/dist/types.d.ts +26 -2
- package/docs/FEATURES.md +247 -33
- package/docs/verify-memory-flow.md +132 -131
- package/integrations/claude/skills/augment-context/SKILL.md +74 -0
- package/integrations/claude/skills/augment-decoction/SKILL.md +36 -0
- package/integrations/claude/skills/augment-dream/SKILL.md +34 -0
- package/integrations/claude/skills/augment-dream/agents/openai.yaml +4 -0
- package/integrations/claude/skills/augment-sleep/SKILL.md +26 -0
- package/integrations/codex/SKILL.md +33 -0
- package/integrations/pi/skills/augment-decoction/SKILL.md +37 -0
- package/integrations/pi/skills/augment-dream/SKILL.md +36 -0
- package/package.json +16 -15
- package/dist/tally.d.ts +0 -46
- package/dist/tally.js +0 -94
- 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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
167
|
+
async memoryFileExists(relativePath) {
|
|
157
168
|
try {
|
|
158
|
-
|
|
169
|
+
await stat(path.join(this.config.memoryRoot, normalizeRelativePath(relativePath)));
|
|
170
|
+
return true;
|
|
159
171
|
}
|
|
160
|
-
|
|
161
|
-
|
|
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 (
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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(
|
|
465
|
-
|
|
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,
|
|
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
|
|
506
|
-
*
|
|
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,
|