fluffy-context 0.7.7 → 1.0.0-beta.1
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/CHANGELOG.md +35 -0
- package/README.md +110 -553
- package/ROADMAP-1.0.md +70 -0
- package/dist/src/agent/api.d.ts +41 -0
- package/dist/src/agent/api.js +37 -0
- package/dist/src/agent/index.d.ts +9 -1
- package/dist/src/agent/index.js +3 -0
- package/dist/src/capture/context-filter.js +2 -0
- package/dist/src/cli/main.js +293 -15
- package/dist/src/cognition/admission.d.ts +8 -0
- package/dist/src/cognition/admission.js +24 -0
- package/dist/src/cognition/evidence-graph.d.ts +4 -0
- package/dist/src/cognition/evidence-graph.js +187 -0
- package/dist/src/cognition/evolution.d.ts +36 -0
- package/dist/src/cognition/evolution.js +221 -0
- package/dist/src/cognition/import-memory.d.ts +18 -0
- package/dist/src/cognition/import-memory.js +124 -0
- package/dist/src/cognition/journal-segments.d.ts +11 -0
- package/dist/src/cognition/journal-segments.js +129 -0
- package/dist/src/cognition/journal.d.ts +13 -0
- package/dist/src/cognition/journal.js +186 -20
- package/dist/src/cognition/migration-v1.js +39 -3
- package/dist/src/cognition/projections.js +124 -2
- package/dist/src/cognition/recipes.d.ts +25 -0
- package/dist/src/cognition/recipes.js +149 -0
- package/dist/src/cognition/runtime.js +4 -0
- package/dist/src/cognition/skills.d.ts +45 -0
- package/dist/src/cognition/skills.js +184 -0
- package/dist/src/cognition/types.d.ts +181 -1
- package/dist/src/cognition/usage-report.js +30 -15
- package/dist/src/compiler/compile.js +101 -31
- package/dist/src/compiler/graph.d.ts +7 -0
- package/dist/src/compiler/graph.js +117 -0
- package/dist/src/compiler/identity.d.ts +1 -1
- package/dist/src/compiler/identity.js +2 -0
- package/dist/src/compiler/inject.d.ts +23 -0
- package/dist/src/compiler/inject.js +27 -0
- package/dist/src/compiler/sources.d.ts +3 -1
- package/dist/src/compiler/sources.js +10 -1
- package/dist/src/compiler/types.d.ts +22 -1
- package/dist/src/hooks/claude-code.js +17 -56
- package/dist/src/integrations/claude-code.js +30 -8
- package/dist/src/mcp/server.js +228 -1
- package/dist/src/runtime/knowledge.js +57 -8
- package/dist/src/runtime/setup.d.ts +27 -0
- package/dist/src/runtime/setup.js +85 -0
- package/dist/src/storage/layout.d.ts +5 -0
- package/dist/src/storage/layout.js +15 -0
- package/dist/src/storage/lock.js +19 -2
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +4 -1
- package/skills/fluffy-context/SKILL.md +46 -8
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { gzip, gunzip } from 'node:zlib';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { cognitionJournalDirectory } from '../storage/layout.js';
|
|
7
|
+
const compress = promisify(gzip);
|
|
8
|
+
const decompress = promisify(gunzip);
|
|
9
|
+
export const JOURNAL_SEGMENT_BYTES = 1024 * 1024;
|
|
10
|
+
export async function journalFiles(root) {
|
|
11
|
+
let files;
|
|
12
|
+
try {
|
|
13
|
+
files = await readdir(cognitionJournalDirectory(root));
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code === 'ENOENT')
|
|
17
|
+
return [];
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
const segments = new Map();
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
if (!file.endsWith('.ndjson') && !file.endsWith('.ndjson.gz'))
|
|
23
|
+
continue;
|
|
24
|
+
if (!/^\d{8}\.ndjson(?:\.gz)?$/.test(file))
|
|
25
|
+
throw new Error(`unrecognized journal segment: ${file}`);
|
|
26
|
+
const base = file.replace(/\.gz$/, '');
|
|
27
|
+
if (!segments.has(base) || file.endsWith('.gz'))
|
|
28
|
+
segments.set(base, file);
|
|
29
|
+
}
|
|
30
|
+
return [...segments.values()].sort();
|
|
31
|
+
}
|
|
32
|
+
async function optional(file) {
|
|
33
|
+
try {
|
|
34
|
+
return await readFile(file);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT')
|
|
38
|
+
return null;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function readSegment(root, name) {
|
|
43
|
+
const file = path.join(cognitionJournalDirectory(root), name);
|
|
44
|
+
if (!name.endsWith('.gz')) {
|
|
45
|
+
const raw = await optional(file);
|
|
46
|
+
// An unlocked reader may race a successful archive publication.
|
|
47
|
+
return raw ?? readSegment(root, `${name}.gz`);
|
|
48
|
+
}
|
|
49
|
+
let decoded;
|
|
50
|
+
try {
|
|
51
|
+
decoded = await decompress(await readFile(file));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new Error(`invalid journal archive ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
const raw = await optional(file.slice(0, -3));
|
|
57
|
+
if (raw && !raw.equals(decoded))
|
|
58
|
+
throw new Error(`journal archive/raw mismatch: ${name}`);
|
|
59
|
+
return decoded;
|
|
60
|
+
}
|
|
61
|
+
// Caller holds cognition-journal.lock. Publish and validate before removing raw
|
|
62
|
+
// bytes; a crash with both representations is safe and validated on every read.
|
|
63
|
+
export async function archiveSegment(root, name) {
|
|
64
|
+
if (name.endsWith('.gz')) {
|
|
65
|
+
await readSegment(root, name);
|
|
66
|
+
await rm(path.join(cognitionJournalDirectory(root), name.slice(0, -3)), { force: true });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const source = path.join(cognitionJournalDirectory(root), name);
|
|
70
|
+
const raw = await readFile(source);
|
|
71
|
+
if (!raw.length)
|
|
72
|
+
return;
|
|
73
|
+
if (raw.at(-1) !== 10)
|
|
74
|
+
throw new Error(`cannot archive incomplete journal segment: ${name}`);
|
|
75
|
+
const target = `${source}.gz`;
|
|
76
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
77
|
+
try {
|
|
78
|
+
await writeFile(temporary, await compress(raw, { level: 9 }), { flag: 'wx' });
|
|
79
|
+
await rename(temporary, target);
|
|
80
|
+
if (!(await readSegment(root, `${name}.gz`)).equals(raw))
|
|
81
|
+
throw new Error(`archive verification failed: ${name}`);
|
|
82
|
+
await rm(source);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await rm(temporary, { force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function appendJournalLine(root, line) {
|
|
89
|
+
const directory = cognitionJournalDirectory(root);
|
|
90
|
+
await mkdir(directory, { recursive: true });
|
|
91
|
+
const latest = (await journalFiles(root)).at(-1);
|
|
92
|
+
let number = latest ? Number(latest.slice(0, 8)) : 1;
|
|
93
|
+
if (latest?.endsWith('.gz'))
|
|
94
|
+
number += 1;
|
|
95
|
+
else if (latest && (await stat(path.join(directory, latest))).size >= JOURNAL_SEGMENT_BYTES) {
|
|
96
|
+
await archiveSegment(root, latest);
|
|
97
|
+
number += 1;
|
|
98
|
+
}
|
|
99
|
+
if (number > 99_999_999)
|
|
100
|
+
throw new Error('journal segment limit exceeded');
|
|
101
|
+
await appendFile(path.join(directory, `${String(number).padStart(8, '0')}.ndjson`), line, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
export async function journalStorage(root, expand = false) {
|
|
104
|
+
const files = await journalFiles(root);
|
|
105
|
+
let bytes = 0;
|
|
106
|
+
let expandedBytes = expand ? 0 : null;
|
|
107
|
+
let archives = 0;
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
let metadata;
|
|
110
|
+
try {
|
|
111
|
+
metadata = await stat(path.join(cognitionJournalDirectory(root), file));
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error.code === 'ENOENT' && !file.endsWith('.gz')) {
|
|
115
|
+
metadata = await stat(path.join(cognitionJournalDirectory(root), `${file}.gz`));
|
|
116
|
+
}
|
|
117
|
+
else
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
bytes += metadata.size;
|
|
121
|
+
if (file.endsWith('.gz')) {
|
|
122
|
+
archives += 1;
|
|
123
|
+
bytes += (await optional(path.join(cognitionJournalDirectory(root), file.slice(0, -3))))?.length ?? 0;
|
|
124
|
+
}
|
|
125
|
+
if (expandedBytes !== null)
|
|
126
|
+
expandedBytes += (await readSegment(root, file)).length;
|
|
127
|
+
}
|
|
128
|
+
return { bytes, expandedBytes, archives, segments: files.length };
|
|
129
|
+
}
|
|
@@ -21,6 +21,19 @@ export declare function readJournal(startPath?: string, options?: {
|
|
|
21
21
|
allowPartialTail?: boolean;
|
|
22
22
|
}): Promise<CognitionEvent[]>;
|
|
23
23
|
export declare function verifyJournal(startPath?: string): Promise<CognitionEvent[]>;
|
|
24
|
+
export declare function compactJournal(startPath?: string, options?: {
|
|
25
|
+
apply?: boolean;
|
|
26
|
+
minBytes?: number;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
applied: boolean;
|
|
29
|
+
eligible: string[];
|
|
30
|
+
events: number;
|
|
31
|
+
headHash: string | null;
|
|
32
|
+
bytesBefore: number;
|
|
33
|
+
bytesAfter: number;
|
|
34
|
+
savedBytes: number;
|
|
35
|
+
archives: number;
|
|
36
|
+
}>;
|
|
24
37
|
export declare function appendEvent<T>(startPath: string | undefined, input: AppendEventInput<T>): Promise<{
|
|
25
38
|
event: CognitionEvent<T>;
|
|
26
39
|
appended: boolean;
|
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
-
import { appendFile,
|
|
2
|
+
import { appendFile, readFile, truncate } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { resolveProjectRoot } from '../project/project-resolver.js';
|
|
5
5
|
import { loadManifest } from '../runtime/init.js';
|
|
6
6
|
import { withLock } from '../storage/lock.js';
|
|
7
7
|
import { cognitionJournalDirectory, locksDirectory } from '../storage/layout.js';
|
|
8
|
+
import { appendJournalLine, archiveSegment, journalFiles, journalStorage, readSegment } from './journal-segments.js';
|
|
9
|
+
import { loadRuntimePolicy } from './policy.js';
|
|
10
|
+
import { recordAdmissionDrop } from './admission.js';
|
|
8
11
|
const eventTypes = new Set([
|
|
9
12
|
'runtime.enabled', 'runtime.started', 'runtime.stopped', 'host.session.started', 'host.prompt.submitted',
|
|
10
13
|
'git.state.observed', 'git.branch.changed', 'git.head.changed', 'workspace.paths.changed',
|
|
11
14
|
'context.checkpoint.imported', 'context.state.recorded', 'note.recorded', 'note.resolved', 'note.absorbed',
|
|
12
15
|
'knowledge.proposed', 'knowledge.verified', 'knowledge.deprecated', 'knowledge.rejected',
|
|
13
|
-
'deadend.proposed', 'deadend.verified', 'deadend.obsoleted', 'deadend.rejected',
|
|
16
|
+
'deadend.proposed', 'deadend.verified', 'deadend.obsoleted', 'deadend.rejected',
|
|
17
|
+
'experience.recorded',
|
|
18
|
+
'evolution.proposed', 'evolution.candidate.accepted', 'evolution.verified', 'evolution.published', 'evolution.rejected', 'evolution.deprecated', 'evolution.superseded',
|
|
19
|
+
'skill.materialized', 'recipe.materialized', 'skill.invoked', 'skill.outcome.recorded', 'record.used',
|
|
14
20
|
'usage.operation.completed',
|
|
15
21
|
'projection.advanced', 'projection.failed',
|
|
16
22
|
]);
|
|
17
23
|
const sourceKinds = new Set(['cli', 'claude-hook', 'mcp', 'agent', 'git-observer', 'fs-observer', 'migration']);
|
|
18
|
-
const segmentName = '00000001.ndjson';
|
|
19
24
|
function canonical(value) {
|
|
20
25
|
if (value === null || typeof value !== 'object')
|
|
21
26
|
return JSON.stringify(value);
|
|
@@ -33,16 +38,6 @@ function eventId() {
|
|
|
33
38
|
export function eventHash(event) {
|
|
34
39
|
return digest(event);
|
|
35
40
|
}
|
|
36
|
-
async function journalFiles(projectRoot) {
|
|
37
|
-
try {
|
|
38
|
-
return (await readdir(cognitionJournalDirectory(projectRoot))).filter((file) => file.endsWith('.ndjson')).sort();
|
|
39
|
-
}
|
|
40
|
-
catch (error) {
|
|
41
|
-
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT')
|
|
42
|
-
return [];
|
|
43
|
-
throw error;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
41
|
function isRecord(value) {
|
|
47
42
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
48
43
|
}
|
|
@@ -56,6 +51,79 @@ function validGitAnchor(value) {
|
|
|
56
51
|
function validIsoDate(value) {
|
|
57
52
|
return typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
58
53
|
}
|
|
54
|
+
function validEvolutionId(value, label) {
|
|
55
|
+
if (typeof value !== 'string' || value.trim().length === 0)
|
|
56
|
+
throw new Error(`${label} must not be empty`);
|
|
57
|
+
}
|
|
58
|
+
function validNonNegativeInteger(value) {
|
|
59
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
60
|
+
}
|
|
61
|
+
function validLineage(value) {
|
|
62
|
+
return isRecord(value)
|
|
63
|
+
&& (value.entity === 'knowledge' || value.entity === 'deadend' || value.entity === 'skill' || value.entity === 'compiler-recipe' || value.entity === 'experience' || value.entity === 'proposal')
|
|
64
|
+
&& typeof value.entityId === 'string' && value.entityId.trim().length > 0
|
|
65
|
+
&& (value.relation === 'supports' || value.relation === 'contradicts' || value.relation === 'supersedes' || value.relation === 'used_for');
|
|
66
|
+
}
|
|
67
|
+
function validEvolutionEvidence(value) {
|
|
68
|
+
return isRecord(value)
|
|
69
|
+
&& typeof value.eventId === 'string' && value.eventId.trim().length > 0
|
|
70
|
+
&& (value.relation === 'derived_from' || value.relation === 'supports' || value.relation === 'contradicts' || value.relation === 'validated_by');
|
|
71
|
+
}
|
|
72
|
+
function validApplicability(value) {
|
|
73
|
+
return isRecord(value)
|
|
74
|
+
&& value.project === true
|
|
75
|
+
&& (value.branchMode === 'global' || value.branchMode === 'exact' || value.branchMode === 'ancestor-of-source' || value.branchMode === 'descendant-of-source')
|
|
76
|
+
&& (value.branches === undefined || Array.isArray(value.branches) && value.branches.every((item) => typeof item === 'string' && item.trim().length > 0))
|
|
77
|
+
&& (value.paths === undefined || Array.isArray(value.paths) && value.paths.every((item) => typeof item === 'string' && item.trim().length > 0))
|
|
78
|
+
&& (value.sourceCommit === undefined || typeof value.sourceCommit === 'string' || value.sourceCommit === null)
|
|
79
|
+
&& (value.dependencyConstraints === undefined || Array.isArray(value.dependencyConstraints) && value.dependencyConstraints.every((item) => typeof item === 'string' && item.trim().length > 0));
|
|
80
|
+
}
|
|
81
|
+
function validEvolutionTransition(from, to) {
|
|
82
|
+
if ((from !== 'candidate' && from !== 'verified' && from !== 'published' && from !== 'rejected' && from !== 'deprecated' && from !== 'superseded' && from !== 'obsolete') || (to !== 'verified' && to !== 'published' && to !== 'rejected' && to !== 'deprecated' && to !== 'superseded' && to !== 'obsolete'))
|
|
83
|
+
return false;
|
|
84
|
+
return (from === 'candidate' && (to === 'verified' || to === 'rejected' || to === 'deprecated'))
|
|
85
|
+
|| (from === 'verified' && (to === 'published' || to === 'deprecated' || to === 'superseded' || to === 'obsolete'))
|
|
86
|
+
|| (from === 'published' && (to === 'deprecated' || to === 'superseded' || to === 'obsolete'));
|
|
87
|
+
}
|
|
88
|
+
function validSkill(value) {
|
|
89
|
+
return isRecord(value)
|
|
90
|
+
&& typeof value.skillId === 'string' && value.skillId.trim().length > 0
|
|
91
|
+
&& value.schemaVersion === 1
|
|
92
|
+
&& typeof value.proposalId === 'string' && value.proposalId.trim().length > 0
|
|
93
|
+
&& typeof value.title === 'string' && value.title.trim().length > 0
|
|
94
|
+
&& Array.isArray(value.procedure) && value.procedure.length > 0 && value.procedure.length <= 32 && value.procedure.every((step) => typeof step === 'string' && step.trim().length > 0)
|
|
95
|
+
&& validApplicability(value.applicability)
|
|
96
|
+
&& value.status === 'candidate'
|
|
97
|
+
&& typeof value.contentHash === 'string' && value.contentHash.trim().length > 0
|
|
98
|
+
&& Array.isArray(value.lineage) && value.lineage.every(validLineage)
|
|
99
|
+
&& validIsoDate(value.createdAt);
|
|
100
|
+
}
|
|
101
|
+
const recipeSources = new Set(['context', 'knowledge', 'deadend', 'note']);
|
|
102
|
+
const recipeSections = new Set(['summary', 'pending', 'decision', 'risk', 'note', 'knowledge', 'deadend', 'activity']);
|
|
103
|
+
function validRecipe(value) {
|
|
104
|
+
if (!isRecord(value))
|
|
105
|
+
return false;
|
|
106
|
+
const allowed = new Set(['recipeId', 'schemaVersion', 'proposalId', 'title', 'applicability', 'preferredSources', 'preferredSections', 'sectionBudgets', 'status', 'contentHash', 'lineage', 'createdAt']);
|
|
107
|
+
if (Object.keys(value).some((key) => !allowed.has(key)))
|
|
108
|
+
return false;
|
|
109
|
+
const validPreferences = (items, allowedItems) => items === undefined
|
|
110
|
+
|| (Array.isArray(items) && items.length <= allowedItems.size && items.every((item) => typeof item === 'string' && allowedItems.has(item)) && new Set(items).size === items.length);
|
|
111
|
+
const sectionBudgets = isRecord(value.sectionBudgets) ? value.sectionBudgets : null;
|
|
112
|
+
const validBudgets = value.sectionBudgets === undefined || (sectionBudgets !== null
|
|
113
|
+
&& Object.keys(sectionBudgets).every((key) => recipeSections.has(key) && validNonNegativeInteger(sectionBudgets[key]) && sectionBudgets[key] <= 100_000));
|
|
114
|
+
return typeof value.recipeId === 'string' && value.recipeId.trim().length > 0
|
|
115
|
+
&& value.schemaVersion === 1
|
|
116
|
+
&& typeof value.proposalId === 'string' && value.proposalId.trim().length > 0
|
|
117
|
+
&& typeof value.title === 'string' && value.title.trim().length > 0
|
|
118
|
+
&& validApplicability(value.applicability)
|
|
119
|
+
&& validPreferences(value.preferredSources, recipeSources)
|
|
120
|
+
&& validPreferences(value.preferredSections, recipeSections)
|
|
121
|
+
&& validBudgets
|
|
122
|
+
&& value.status === 'candidate'
|
|
123
|
+
&& typeof value.contentHash === 'string' && value.contentHash.trim().length > 0
|
|
124
|
+
&& Array.isArray(value.lineage) && value.lineage.every(validLineage)
|
|
125
|
+
&& validIsoDate(value.createdAt);
|
|
126
|
+
}
|
|
59
127
|
function validatePayloadBoundary(input) {
|
|
60
128
|
const payload = input.payload;
|
|
61
129
|
if (input.type === 'host.prompt.submitted' && (typeof payload.intent !== 'string' || payload.intent.trim().length === 0)) {
|
|
@@ -82,6 +150,58 @@ function validatePayloadBoundary(input) {
|
|
|
82
150
|
if (!Array.isArray(changes) || changes.length === 0)
|
|
83
151
|
throw new Error('workspace activity must contain changes');
|
|
84
152
|
}
|
|
153
|
+
if (input.type === 'experience.recorded') {
|
|
154
|
+
const experience = payload.record;
|
|
155
|
+
if (!isRecord(experience))
|
|
156
|
+
throw new Error('experience record is required');
|
|
157
|
+
validEvolutionId(experience.experienceId, 'experience id');
|
|
158
|
+
if (experience.schemaVersion !== 1 || !Array.isArray(experience.sourceEventIds) || experience.sourceEventIds.length === 0 || !experience.sourceEventIds.every((value) => typeof value === 'string' && value.trim().length > 0) || !Array.isArray(experience.sourceContextIds) || !experience.sourceContextIds.every((value) => typeof value === 'string') || !Array.isArray(experience.sourceSnapshotIds) || !experience.sourceSnapshotIds.every((value) => typeof value === 'string') || !Array.isArray(experience.paths) || !experience.paths.every((value) => typeof value === 'string') || !validGitAnchor(experience.git) || !isRecord(experience.privacy) || (experience.privacy.classification !== 'metadata' && experience.privacy.classification !== 'redacted-text') || !validNonNegativeInteger(experience.privacy.redactionCount) || !isRecord(experience.extractor) || typeof experience.extractor.id !== 'string' || experience.extractor.id.trim().length === 0 || typeof experience.extractor.version !== 'string' || experience.extractor.version.trim().length === 0 || typeof experience.contentHash !== 'string' || experience.contentHash.trim().length === 0 || typeof experience.summary !== 'string' || experience.summary.trim().length === 0 || !validIsoDate(experience.createdAt))
|
|
159
|
+
throw new Error('invalid experience record');
|
|
160
|
+
}
|
|
161
|
+
if (input.type === 'evolution.proposed') {
|
|
162
|
+
const proposal = payload.record;
|
|
163
|
+
if (!isRecord(proposal))
|
|
164
|
+
throw new Error('evolution proposal record is required');
|
|
165
|
+
validEvolutionId(proposal.proposalId, 'proposal id');
|
|
166
|
+
if (proposal.schemaVersion !== 1 || (proposal.targetKind !== 'knowledge' && proposal.targetKind !== 'deadend' && proposal.targetKind !== 'skill' && proposal.targetKind !== 'compiler-recipe') || typeof proposal.targetId !== 'string' || proposal.targetId.trim().length === 0 || proposal.status !== 'candidate' || !Array.isArray(proposal.sourceExperienceIds) || proposal.sourceExperienceIds.length === 0 || !proposal.sourceExperienceIds.every((value) => typeof value === 'string' && value.trim().length > 0) || !Array.isArray(proposal.evidence) || proposal.evidence.length === 0 || !proposal.evidence.every(validEvolutionEvidence) || !Array.isArray(proposal.lineage) || !proposal.lineage.every(validLineage) || !validApplicability(proposal.applicability) || typeof proposal.contentHash !== 'string' || proposal.contentHash.trim().length === 0 || typeof proposal.summary !== 'string' || proposal.summary.trim().length === 0 || !isRecord(proposal.extractor) || typeof proposal.extractor.id !== 'string' || proposal.extractor.id.trim().length === 0 || typeof proposal.extractor.version !== 'string' || proposal.extractor.version.trim().length === 0 || !validIsoDate(proposal.createdAt))
|
|
167
|
+
throw new Error('invalid evolution proposal record');
|
|
168
|
+
}
|
|
169
|
+
if (input.type === 'evolution.candidate.accepted') {
|
|
170
|
+
const artifact = payload.record;
|
|
171
|
+
if (!isRecord(artifact) || typeof artifact.artifactId !== 'string' || artifact.artifactId.trim().length === 0 || artifact.schemaVersion !== 1 || typeof artifact.proposalId !== 'string' || artifact.proposalId.trim().length === 0 || (artifact.kind !== 'skill' && artifact.kind !== 'compiler-recipe') || artifact.status !== 'candidate' || !validApplicability(artifact.applicability) || typeof artifact.contentHash !== 'string' || artifact.contentHash.trim().length === 0 || !Array.isArray(artifact.lineage) || !artifact.lineage.every(validLineage) || typeof artifact.rationale !== 'string' || artifact.rationale.trim().length === 0 || !validIsoDate(artifact.createdAt))
|
|
172
|
+
throw new Error('invalid evolution candidate artifact');
|
|
173
|
+
}
|
|
174
|
+
if (['evolution.verified', 'evolution.published', 'evolution.rejected', 'evolution.deprecated', 'evolution.superseded'].includes(input.type)) {
|
|
175
|
+
const transition = payload.transition;
|
|
176
|
+
if (!isRecord(transition) || (transition.entity !== 'knowledge' && transition.entity !== 'deadend' && transition.entity !== 'skill' && transition.entity !== 'compiler-recipe' && transition.entity !== 'proposal') || typeof transition.entityId !== 'string' || transition.entityId.trim().length === 0 || !validEvolutionTransition(transition.from, transition.to) || typeof transition.reason !== 'string' || transition.reason.trim().length === 0 || (transition.supersedes !== undefined && !validLineage(transition.supersedes)))
|
|
177
|
+
throw new Error('invalid evolution lifecycle transition');
|
|
178
|
+
if ((input.type === 'evolution.verified' && transition.to !== 'verified') || (input.type === 'evolution.published' && transition.to !== 'published') || (input.type === 'evolution.rejected' && transition.to !== 'rejected') || (input.type === 'evolution.deprecated' && transition.to !== 'deprecated') || (input.type === 'evolution.superseded' && (transition.to !== 'superseded' || !validLineage(transition.supersedes))))
|
|
179
|
+
throw new Error('evolution lifecycle event does not match transition');
|
|
180
|
+
}
|
|
181
|
+
if (input.type === 'skill.materialized') {
|
|
182
|
+
if (!validSkill(payload.record))
|
|
183
|
+
throw new Error('invalid skill record');
|
|
184
|
+
}
|
|
185
|
+
if (input.type === 'recipe.materialized') {
|
|
186
|
+
if (!validRecipe(payload.record))
|
|
187
|
+
throw new Error('invalid compiler recipe record');
|
|
188
|
+
}
|
|
189
|
+
if (input.type === 'skill.invoked') {
|
|
190
|
+
const invocation = payload.record;
|
|
191
|
+
if (!isRecord(invocation))
|
|
192
|
+
throw new Error('skill invocation record is required');
|
|
193
|
+
validEvolutionId(invocation.invocationId, 'invocation id');
|
|
194
|
+
if (typeof invocation.skillId !== 'string' || invocation.skillId.trim().length === 0 || typeof invocation.callerEventId !== 'string' || invocation.callerEventId.trim().length === 0 || typeof invocation.inputHash !== 'string' || invocation.inputHash.trim().length === 0 || !validIsoDate(invocation.occurredAt))
|
|
195
|
+
throw new Error('invalid skill invocation record');
|
|
196
|
+
}
|
|
197
|
+
if (input.type === 'skill.outcome.recorded') {
|
|
198
|
+
const outcome = payload.record;
|
|
199
|
+
if (!isRecord(outcome))
|
|
200
|
+
throw new Error('skill outcome record is required');
|
|
201
|
+
validEvolutionId(outcome.outcomeId, 'outcome id');
|
|
202
|
+
if (typeof outcome.invocationId !== 'string' || outcome.invocationId.trim().length === 0 || typeof outcome.callerEventId !== 'string' || outcome.callerEventId.trim().length === 0 || (outcome.outcome !== 'success' && outcome.outcome !== 'failure' && outcome.outcome !== 'partial' && outcome.outcome !== 'irrelevant' && outcome.outcome !== 'unknown') || typeof outcome.summary !== 'string' || outcome.summary.trim().length === 0 || !validIsoDate(outcome.occurredAt))
|
|
203
|
+
throw new Error('invalid skill outcome record');
|
|
204
|
+
}
|
|
85
205
|
}
|
|
86
206
|
function validateEventShape(event, projectId, file, line) {
|
|
87
207
|
if (!isRecord(event)
|
|
@@ -116,7 +236,7 @@ function validateEventShape(event, projectId, file, line) {
|
|
|
116
236
|
async function recoverPartialTail(projectRoot) {
|
|
117
237
|
const files = await journalFiles(projectRoot);
|
|
118
238
|
const file = files.at(-1);
|
|
119
|
-
if (!file)
|
|
239
|
+
if (!file || file.endsWith('.gz'))
|
|
120
240
|
return;
|
|
121
241
|
const filePath = path.join(cognitionJournalDirectory(projectRoot), file);
|
|
122
242
|
const content = await readFile(filePath, 'utf8');
|
|
@@ -129,7 +249,7 @@ async function recoverPartialTail(projectRoot) {
|
|
|
129
249
|
await appendFile(filePath, '\n', 'utf8');
|
|
130
250
|
}
|
|
131
251
|
catch {
|
|
132
|
-
await truncate(filePath, boundary < 0 ? 0 : boundary + 1);
|
|
252
|
+
await truncate(filePath, boundary < 0 ? 0 : Buffer.byteLength(content.slice(0, boundary + 1), 'utf8'));
|
|
133
253
|
}
|
|
134
254
|
}
|
|
135
255
|
export async function readJournal(startPath, options = {}) {
|
|
@@ -137,8 +257,9 @@ export async function readJournal(startPath, options = {}) {
|
|
|
137
257
|
const projectId = (await loadManifest(projectRoot)).projectId;
|
|
138
258
|
const events = [];
|
|
139
259
|
let previousHash = null;
|
|
140
|
-
|
|
141
|
-
|
|
260
|
+
const files = await journalFiles(projectRoot);
|
|
261
|
+
for (const file of files) {
|
|
262
|
+
const content = (await readSegment(projectRoot, file)).toString('utf8');
|
|
142
263
|
const lines = content.split('\n');
|
|
143
264
|
for (let index = 0; index < lines.length; index += 1) {
|
|
144
265
|
const line = lines[index];
|
|
@@ -149,11 +270,12 @@ export async function readJournal(startPath, options = {}) {
|
|
|
149
270
|
event = JSON.parse(line);
|
|
150
271
|
}
|
|
151
272
|
catch (error) {
|
|
152
|
-
if (options.allowPartialTail !== false && index === lines.length - 1 && !content.endsWith('\n'))
|
|
273
|
+
if (options.allowPartialTail !== false && file === files.at(-1) && !file.endsWith('.gz') && index === lines.length - 1 && !content.endsWith('\n'))
|
|
153
274
|
break;
|
|
154
275
|
throw new Error(`invalid journal record ${file}:${index + 1}: ${error instanceof Error ? error.message : 'invalid JSON'}`);
|
|
155
276
|
}
|
|
156
277
|
validateEventShape(event, projectId, file, index + 1);
|
|
278
|
+
validatePayloadBoundary(event);
|
|
157
279
|
const { hash, ...withoutHash } = event;
|
|
158
280
|
if (event.previousHash !== previousHash || hash !== eventHash(withoutHash)) {
|
|
159
281
|
throw new Error(`invalid journal hash chain at ${file}:${index + 1}`);
|
|
@@ -167,6 +289,36 @@ export async function readJournal(startPath, options = {}) {
|
|
|
167
289
|
export async function verifyJournal(startPath) {
|
|
168
290
|
return readJournal(startPath, { allowPartialTail: false });
|
|
169
291
|
}
|
|
292
|
+
export async function compactJournal(startPath, options = {}) {
|
|
293
|
+
const root = await resolveProjectRoot(startPath);
|
|
294
|
+
const minBytes = options.minBytes ?? 1;
|
|
295
|
+
if (!Number.isSafeInteger(minBytes) || minBytes < 0)
|
|
296
|
+
throw new Error('minBytes must be a non-negative integer');
|
|
297
|
+
const operation = async () => {
|
|
298
|
+
const events = await verifyJournal(root);
|
|
299
|
+
const before = await journalStorage(root);
|
|
300
|
+
const eligible = [];
|
|
301
|
+
for (const file of await journalFiles(root)) {
|
|
302
|
+
if (!file.endsWith('.gz') && (await readSegment(root, file)).length >= minBytes)
|
|
303
|
+
eligible.push(file);
|
|
304
|
+
}
|
|
305
|
+
if (options.apply) {
|
|
306
|
+
for (const file of eligible)
|
|
307
|
+
await archiveSegment(root, file);
|
|
308
|
+
// Complete deletion after an interrupted publication, but only after validation.
|
|
309
|
+
for (const file of await journalFiles(root))
|
|
310
|
+
if (file.endsWith('.gz'))
|
|
311
|
+
await archiveSegment(root, file);
|
|
312
|
+
const replay = await verifyJournal(root);
|
|
313
|
+
if (replay.length !== events.length || replay.at(-1)?.hash !== events.at(-1)?.hash)
|
|
314
|
+
throw new Error('journal replay changed during compaction');
|
|
315
|
+
}
|
|
316
|
+
const after = options.apply ? await journalStorage(root) : before;
|
|
317
|
+
return { applied: options.apply === true, eligible, events: events.length, headHash: events.at(-1)?.hash ?? null,
|
|
318
|
+
bytesBefore: before.bytes, bytesAfter: after.bytes, savedBytes: before.bytes - after.bytes, archives: after.archives };
|
|
319
|
+
};
|
|
320
|
+
return options.apply ? withLock(`${locksDirectory(root)}/cognition-journal.lock`, operation) : operation();
|
|
321
|
+
}
|
|
170
322
|
export async function appendEvent(startPath, input) {
|
|
171
323
|
validatePayloadBoundary(input);
|
|
172
324
|
const projectRoot = await resolveProjectRoot(startPath);
|
|
@@ -214,8 +366,22 @@ export async function appendEvent(startPath, input) {
|
|
|
214
366
|
previousHash: existing.at(-1)?.hash ?? null,
|
|
215
367
|
};
|
|
216
368
|
const event = { ...eventWithoutHash, hash: eventHash(eventWithoutHash) };
|
|
217
|
-
|
|
218
|
-
|
|
369
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
370
|
+
if (['workspace.paths.changed', 'host.prompt.submitted', 'usage.operation.completed'].includes(input.type)) {
|
|
371
|
+
let limit;
|
|
372
|
+
try {
|
|
373
|
+
limit = (await loadRuntimePolicy(projectRoot)).retentionMaxBytes;
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (error.code !== 'ENOENT')
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
if (limit !== undefined && (await journalStorage(projectRoot)).bytes + Buffer.byteLength(line) > limit) {
|
|
380
|
+
await recordAdmissionDrop(projectRoot, input.type);
|
|
381
|
+
throw new Error('observation budget exceeded; semantic records remain writable');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
await appendJournalLine(projectRoot, line);
|
|
219
385
|
return { event, appended: true };
|
|
220
386
|
});
|
|
221
387
|
}
|
|
@@ -10,7 +10,7 @@ import { atomicWriteJson } from '../storage/atomic-write.js';
|
|
|
10
10
|
import { withLock } from '../storage/lock.js';
|
|
11
11
|
import { cognitionManifestPath, cognitionPolicyPath, cognitionQuarantineDirectory, cognitionV1ImportPath, cognitionV1SyncPath, contextMetadataPath, contextsRoot, locksDirectory, manifestPath, snapshotPath, snapshotsDirectory, } from '../storage/layout.js';
|
|
12
12
|
import { readJson } from '../storage/json-store.js';
|
|
13
|
-
import { appendEvent } from './journal.js';
|
|
13
|
+
import { appendEvent, readJournal } from './journal.js';
|
|
14
14
|
import { rebuildProjections } from './projections.js';
|
|
15
15
|
import { v1Fingerprint } from './v1-bridge.js';
|
|
16
16
|
import { migrationReadiness } from './readiness.js';
|
|
@@ -162,13 +162,49 @@ export async function reconcileV1State(startPath) {
|
|
|
162
162
|
const readiness = await migrationReadiness(projectRoot);
|
|
163
163
|
if (!readiness.eligible)
|
|
164
164
|
return;
|
|
165
|
-
const { records
|
|
165
|
+
const { records } = await sourceRecords(projectRoot);
|
|
166
|
+
const existing = await readJournal(projectRoot);
|
|
167
|
+
// Compare the latest entity state, not the whole-store hash. Read counters
|
|
168
|
+
// are not project knowledge and must not duplicate historical checkpoints.
|
|
169
|
+
const semantic = (record) => {
|
|
170
|
+
if (record.context && record.snapshot) {
|
|
171
|
+
const context = record.context;
|
|
172
|
+
return sha256(JSON.stringify({ snapshot: record.snapshot, status: context.status, title: context.title, tags: context.tags, dependencies: context.dependencies }));
|
|
173
|
+
}
|
|
174
|
+
return sha256(JSON.stringify(record));
|
|
175
|
+
};
|
|
176
|
+
const identity = (record) => {
|
|
177
|
+
if (record.context && record.snapshot) {
|
|
178
|
+
const snapshot = record.snapshot;
|
|
179
|
+
return `context:${snapshot.contextId}:${snapshot.snapshotId}`;
|
|
180
|
+
}
|
|
181
|
+
if (typeof record.noteId === 'string')
|
|
182
|
+
return `note:${record.noteId}`;
|
|
183
|
+
if (typeof record.knowledgeId === 'string')
|
|
184
|
+
return `knowledge:${record.knowledgeId}`;
|
|
185
|
+
if (typeof record.deadendId === 'string')
|
|
186
|
+
return `deadend:${record.deadendId}`;
|
|
187
|
+
return null;
|
|
188
|
+
};
|
|
189
|
+
const latest = new Map();
|
|
190
|
+
for (const event of existing) {
|
|
191
|
+
const record = event.payload.record;
|
|
192
|
+
if (record) {
|
|
193
|
+
const key = identity(record);
|
|
194
|
+
if (key)
|
|
195
|
+
latest.set(key, semantic(record));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
166
198
|
for (const record of records) {
|
|
199
|
+
const key = `${record.kind}:${record.id}`;
|
|
200
|
+
const contentHash = semantic(record.record);
|
|
201
|
+
if (latest.get(key) === contentHash)
|
|
202
|
+
continue;
|
|
167
203
|
await appendEvent(projectRoot, {
|
|
168
204
|
type: eventType(record, true),
|
|
169
205
|
occurredAt: record.occurredAt,
|
|
170
206
|
source: { kind: 'migration' },
|
|
171
|
-
idempotencyKey: `v1-live-sync:${
|
|
207
|
+
idempotencyKey: `v1-live-sync-v2:${existing.at(-1)?.hash ?? 'empty'}:${key}:${contentHash}`,
|
|
172
208
|
git: { worktreeId: null, branch: record.git.branch, ref: record.git.branch, head: record.git.head },
|
|
173
209
|
payload: { record: record.record, sourcePath: record.sourcePath, sourceHash: record.sourceHash, anchorProvenance: record.git.branch === null && record.git.head === null ? 'global' : 'snapshot' },
|
|
174
210
|
});
|