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.
Files changed (53) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +110 -553
  3. package/ROADMAP-1.0.md +70 -0
  4. package/dist/src/agent/api.d.ts +41 -0
  5. package/dist/src/agent/api.js +37 -0
  6. package/dist/src/agent/index.d.ts +9 -1
  7. package/dist/src/agent/index.js +3 -0
  8. package/dist/src/capture/context-filter.js +2 -0
  9. package/dist/src/cli/main.js +293 -15
  10. package/dist/src/cognition/admission.d.ts +8 -0
  11. package/dist/src/cognition/admission.js +24 -0
  12. package/dist/src/cognition/evidence-graph.d.ts +4 -0
  13. package/dist/src/cognition/evidence-graph.js +187 -0
  14. package/dist/src/cognition/evolution.d.ts +36 -0
  15. package/dist/src/cognition/evolution.js +221 -0
  16. package/dist/src/cognition/import-memory.d.ts +18 -0
  17. package/dist/src/cognition/import-memory.js +124 -0
  18. package/dist/src/cognition/journal-segments.d.ts +11 -0
  19. package/dist/src/cognition/journal-segments.js +129 -0
  20. package/dist/src/cognition/journal.d.ts +13 -0
  21. package/dist/src/cognition/journal.js +186 -20
  22. package/dist/src/cognition/migration-v1.js +39 -3
  23. package/dist/src/cognition/projections.js +124 -2
  24. package/dist/src/cognition/recipes.d.ts +25 -0
  25. package/dist/src/cognition/recipes.js +149 -0
  26. package/dist/src/cognition/runtime.js +4 -0
  27. package/dist/src/cognition/skills.d.ts +45 -0
  28. package/dist/src/cognition/skills.js +184 -0
  29. package/dist/src/cognition/types.d.ts +181 -1
  30. package/dist/src/cognition/usage-report.js +30 -15
  31. package/dist/src/compiler/compile.js +101 -31
  32. package/dist/src/compiler/graph.d.ts +7 -0
  33. package/dist/src/compiler/graph.js +117 -0
  34. package/dist/src/compiler/identity.d.ts +1 -1
  35. package/dist/src/compiler/identity.js +2 -0
  36. package/dist/src/compiler/inject.d.ts +23 -0
  37. package/dist/src/compiler/inject.js +27 -0
  38. package/dist/src/compiler/sources.d.ts +3 -1
  39. package/dist/src/compiler/sources.js +10 -1
  40. package/dist/src/compiler/types.d.ts +22 -1
  41. package/dist/src/hooks/claude-code.js +17 -56
  42. package/dist/src/integrations/claude-code.js +30 -8
  43. package/dist/src/mcp/server.js +228 -1
  44. package/dist/src/runtime/knowledge.js +57 -8
  45. package/dist/src/runtime/setup.d.ts +27 -0
  46. package/dist/src/runtime/setup.js +85 -0
  47. package/dist/src/storage/layout.d.ts +5 -0
  48. package/dist/src/storage/layout.js +15 -0
  49. package/dist/src/storage/lock.js +19 -2
  50. package/dist/src/version.d.ts +1 -1
  51. package/dist/src/version.js +1 -1
  52. package/package.json +4 -1
  53. 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, mkdir, readFile, readdir, truncate } from 'node:fs/promises';
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', 'record.used',
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
- for (const file of await journalFiles(projectRoot)) {
141
- const content = await readFile(path.join(cognitionJournalDirectory(projectRoot), file), 'utf8');
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
- await mkdir(cognitionJournalDirectory(projectRoot), { recursive: true });
218
- await appendFile(path.join(cognitionJournalDirectory(projectRoot), segmentName), `${JSON.stringify(event)}\n`, 'utf8');
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, sourceHash } = await sourceRecords(projectRoot);
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:${sourceHash}:${record.kind}:${record.id}`,
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
  });