Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,6 @@ Desktop.ini
*.zip
*.tgz
.codex

# Local rollout notes (not part of the public repository)
LIVE_KBS_MVP.md
38 changes: 0 additions & 38 deletions LIVE_KBS_MVP.md

This file was deleted.

14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,20 @@ const rebuilt = await mountPack({ src: snapshot });

`LivePack` stays lexical/graph-only in v1. Cortex remains a separate append-only memory layer.

### Delta / append-only patch packs

Live mutations can be distributed as a small deterministic patch stream:

```ts
import { applyPatchPack, deserializePatchPack, mountPack } from '@knolo/core';

const base = await mountPack({ src: './knowledge.knolo' });
const patch = deserializePatchPack(patchBytes);
const live = await applyPatchPack(base, patch);
```

Patch packs contain complete stable-id upserts and tombstones, are mergeable, and bind themselves to the fingerprint of the base pack. `live.serializePatchPack()` exports the append-only mutations since the live overlay was created; `live.serialize()` still produces a normal full `.knolo` snapshot.

---

# 🧠 Knolo Cortex
Expand Down
1 change: 1 addition & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
## [Unreleased]

### Added
- Deterministic append-only document patch packs with base fingerprints, JSON serialization, merging, replay into `LivePack`, and `LivePack.serializePatchPack()`.
- Knolo Cortex, a local-first overlay memory layer for `.knolo` packs with deterministic lexical recall, append-only logs, portable serialization, and no required vector DB.
- Added the initial memory surface under `@knolo/core`, including memory normalization, immutable cortex writes, recall ranking, and consolidation helpers, while keeping the existing pack runtime API unchanged.
- Added `memoryToClaimOps()` to bridge Cortex memories into deterministic ClaimGraph ops without changing the existing graph builder.
Expand Down
19 changes: 19 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,25 @@ const rebuilt = await mountPack({ src: snapshot });

Live querying in v1 stays lexical/graph-only. Semantic live options are rejected until the embedding story exists.

### Append-only patch packs

Use patch packs to ship only stable-id document mutations instead of rebuilding and distributing a full snapshot. Patch packs are deterministic JSON bytes, carry a fingerprint of their base pack, and can be merged or replayed safely:

```ts
import {
applyPatchPack,
deserializePatchPack,
mountPack,
} from "@knolo/core";

const base = await mountPack({ src: "./dist/knowledge.knolo" });
const patch = deserializePatchPack(new Uint8Array(await fetch("./updates.knolo.patch").then(r => r.arrayBuffer())));
const live = await applyPatchPack(base, patch);
const snapshot = await live.serialize();
```

`LivePack.serializePatchPack()` exports the mutations made since the overlay was created. Each upsert is a complete document replacement and each remove is a tombstone; replay rejects a patch whose base fingerprint does not match.

For the rollout notes and constraints, see [`../../LIVE_KBS_MVP.md`](../../LIVE_KBS_MVP.md).

---
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export {
export { makeContextPatch } from './patch.js';
export { buildPack } from './builder.js';
export { LivePack, createLivePack } from './live.js';
export {
createPatchPack,
appendPatch,
mergePatchPacks,
serializePatchPack,
deserializePatchPack,
applyPatchPack,
} from './patch_pack.js';
export {
quantizeEmbeddingInt8L2Norm,
encodeScaleF16,
Expand Down Expand Up @@ -51,6 +59,7 @@ export * from './memory/index.js';
export type { MountOptions, PackMeta, Pack } from './pack.runtime.js';
export type { QueryOptions, Hit } from './query.js';
export type { LivePackOptions } from './live.js';
export type { PatchPack, PatchPackV1, PatchOpV1, PatchUpsertV1, PatchRemoveV1 } from './patch_pack.js';
export type { EmbeddingProvider, SemanticSidecar, SemanticQueryOptions, RetrievalEvidence } from './semantic/types.js';
export type { ContextPatch } from './patch.js';
export type { BuildInputDoc, BuildPackOptions } from './builder.js';
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
type Hit,
type QueryOptions,
} from './query.js';
import type { PatchOpV1 } from './patch_pack.js';

export type LivePackOptions = {
actor?: string;
graph?: {
enabled?: boolean;
maxEdgesPerDoc?: number;
Expand Down Expand Up @@ -36,6 +38,9 @@ export class LivePack {
public readonly base: Readonly<Pack>;

private readonly graph: NormalizedLivePackOptions['graph'];
private readonly actor: string;
private patchClock = 0;
private patchOps: PatchOpV1[] = [];
private readonly baseEntries: BaseDocEntry[];
private readonly baseDocsById: Map<string, BaseDocEntry>;
private overlay = new Map<string, LiveDoc>();
Expand All @@ -47,6 +52,7 @@ export class LivePack {
constructor(base: Pack, opts: LivePackOptions = {}) {
this.base = base;
this.graph = normalizeLiveGraphOptions(base, opts);
this.actor = normalizeLiveActor(opts.actor);
this.baseEntries = extractBaseEntries(base);
this.baseDocsById = indexBaseEntries(this.baseEntries);
this.merged = base;
Expand All @@ -71,6 +77,7 @@ export class LivePack {
this.overlay = nextOverlay;
this.tombstones = nextTombstones;
this.merged = nextMerged;
this.recordPatch({ op: 'upsert', id: nextDoc.id, doc: nextDoc });
});
}

Expand Down Expand Up @@ -101,6 +108,7 @@ export class LivePack {
this.overlay = nextOverlay;
this.tombstones = nextTombstones;
this.merged = nextMerged;
this.recordPatch({ op: 'upsert', id: nextDoc.id, doc: nextDoc });
});
}

Expand Down Expand Up @@ -130,6 +138,7 @@ export class LivePack {
this.overlay = nextOverlay;
this.tombstones = nextTombstones;
this.merged = nextMerged;
this.recordPatch({ op: 'remove', id: normalizedId });
});
}

Expand Down Expand Up @@ -159,6 +168,37 @@ export class LivePack {
return await buildPack(docs, buildOpts);
}

/** Return the append-only mutation stream since this LivePack was created. */
public async serializePatchPack(): Promise<Uint8Array> {
await this.mutationQueue;
const { createPatchPack, serializePatchPack } = await import('./patch_pack.js');
return serializePatchPack(createPatchPack(this.base, this.patchOps));
}

private recordPatch(input: {
op: 'upsert' | 'remove';
id: string;
doc?: LiveDoc;
}): void {
this.patchClock += 1;
if (input.op === 'upsert' && input.doc) {
this.patchOps.push({
op: 'upsert',
id: input.id,
doc: cloneLiveDoc(input.doc),
ts: this.patchClock,
actor: this.actor,
});
return;
}
this.patchOps.push({
op: 'remove',
id: input.id,
ts: this.patchClock,
actor: this.actor,
});
}

private async enqueueMutation(task: () => Promise<void>): Promise<this> {
const run = this.mutationQueue.then(() => task(), () => task());
this.mutationQueue = run.then(
Expand Down Expand Up @@ -421,6 +461,14 @@ function normalizeLiveId(id: unknown, context: string): string {
return id;
}

function normalizeLiveActor(actor: unknown): string {
if (actor === undefined) return 'live-pack';
if (typeof actor !== 'string' || !actor.trim()) {
throw new Error('LivePack actor must be a non-empty string when provided.');
}
return actor;
}

function normalizeLiveText(text: unknown, context: string): string {
if (typeof text !== 'string' || !text.trim()) {
throw new Error(`${context}: text must be a non-empty string.`);
Expand Down
160 changes: 160 additions & 0 deletions packages/core/src/patch_pack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { createPackFingerprint } from './semantic/sidecar.js';
import { getTextDecoder, getTextEncoder } from './utils/utf8.js';
import type { BuildInputDoc } from './builder.js';
import type { Pack } from './pack.runtime.js';
import { createLivePack, type LivePack } from './live.js';

/** A complete replacement for one stable-id document in a patch stream. */
export type PatchUpsertV1 = {
op: 'upsert';
id: string;
doc: BuildInputDoc & { id: string };
ts: number;
actor: string;
};

/** A tombstone for one stable-id document in a patch stream. */
export type PatchRemoveV1 = {
op: 'remove';
id: string;
ts: number;
actor: string;
};

export type PatchOpV1 = PatchUpsertV1 | PatchRemoveV1;

export type PatchPackV1 = {
version: 1;
baseFingerprint: string;
ops: PatchOpV1[];
};

export type PatchPack = PatchPackV1;

export function createPatchPack(
base: Pick<Pack, 'blocks' | 'docIds' | 'meta'>,
ops: PatchOpV1[] = []
): PatchPack {
const pack = { version: 1 as const, baseFingerprint: createPackFingerprint(base), ops };
return normalizePatchPack(pack);
}

export function appendPatch(pack: PatchPack, op: PatchOpV1): PatchPack {
return normalizePatchPack({
version: 1,
baseFingerprint: pack.baseFingerprint,
ops: [...pack.ops, op],
});
}

export function mergePatchPacks(a: PatchPack, b: PatchPack): PatchPack {
const left = normalizePatchPack(a);
const right = normalizePatchPack(b);
if (left.baseFingerprint !== right.baseFingerprint) {
throw new Error(
`Cannot merge patch packs for different bases: ${left.baseFingerprint} != ${right.baseFingerprint}.`
);
}
return normalizePatchPack({
version: 1,
baseFingerprint: left.baseFingerprint,
ops: [...left.ops, ...right.ops],
});
}

export function serializePatchPack(pack: PatchPack): Uint8Array {
return getTextEncoder().encode(JSON.stringify(normalizePatchPack(pack)));
}

export function deserializePatchPack(data: Uint8Array): PatchPack {
let parsed: unknown;
try {
parsed = JSON.parse(getTextDecoder().decode(data));
} catch {
throw new Error('Invalid patch pack payload.');
}
return normalizePatchPack(parsed);
}

/** Replay an append-only patch stream into a LivePack over the supplied base. */
export async function applyPatchPack(base: Pack, patch: PatchPack): Promise<LivePack> {
const normalized = normalizePatchPack(patch);
const expected = createPackFingerprint(base);
if (normalized.baseFingerprint !== expected) {
throw new Error(
`Patch pack base fingerprint mismatch: expected ${expected}, got ${normalized.baseFingerprint}.`
);
}

const live = await createLivePack(base);
for (const op of normalized.ops) {
if (op.op === 'upsert') {
await live.addDocument(op.doc);
} else {
// A remove for an already-absent id is intentionally idempotent when
// replaying independently produced patch streams.
try {
await live.removeDocument(op.id);
} catch (error) {
if (!(error instanceof Error) || !/unknown id/i.test(error.message)) throw error;
}
}
}
return live;
}

function normalizePatchPack(input: unknown): PatchPack {
if (!input || typeof input !== 'object') throw new Error('Invalid patch pack.');
const value = input as Partial<PatchPack>;
if (value.version !== 1 || typeof value.baseFingerprint !== 'string' || !Array.isArray(value.ops)) {
throw new Error('Invalid patch pack: expected version 1, baseFingerprint, and ops.');
}
const ops = value.ops.map(normalizePatchOp).sort(comparePatchOps);
return { version: 1, baseFingerprint: value.baseFingerprint, ops };
}

function normalizePatchOp(input: unknown): PatchOpV1 {
if (!input || typeof input !== 'object') throw new Error('Invalid patch operation.');
const op = input as Partial<PatchOpV1>;
const id = normalizeId(op.id);
const ts = op.ts;
const actor = op.actor;
if (typeof ts !== 'number' || !Number.isFinite(ts) || typeof actor !== 'string' || !actor.trim()) {
throw new Error('Invalid patch operation: ts must be finite and actor must be non-empty.');
}
if (op.op === 'remove') return { op: 'remove', id, ts, actor };
if (op.op !== 'upsert' || !op.doc || typeof op.doc !== 'object') {
throw new Error('Invalid patch operation: expected upsert or remove.');
}
const doc = op.doc as BuildInputDoc & { id: string };
if (doc.id !== id || typeof doc.text !== 'string' || !doc.text.trim()) {
throw new Error('Invalid patch upsert: doc.id must match id and doc.text must be non-empty.');
}
if (doc.heading !== undefined && typeof doc.heading !== 'string') throw new Error('Invalid patch upsert heading.');
if (doc.namespace !== undefined && typeof doc.namespace !== 'string') throw new Error('Invalid patch upsert namespace.');
return {
op: 'upsert',
id,
ts,
actor,
doc: {
id,
text: doc.text,
...(doc.heading !== undefined ? { heading: doc.heading } : {}),
...(doc.namespace !== undefined ? { namespace: doc.namespace } : {}),
},
};
}

function comparePatchOps(a: PatchOpV1, b: PatchOpV1): number {
return a.ts - b.ts || a.actor.localeCompare(b.actor) || a.id.localeCompare(b.id) || a.op.localeCompare(b.op) || stableOp(a).localeCompare(stableOp(b));
}

function stableOp(op: PatchOpV1): string {
return op.op === 'remove' ? 'remove' : `upsert|${op.doc.text}|${op.doc.heading ?? ''}|${op.doc.namespace ?? ''}`;
}

function normalizeId(id: unknown): string {
if (typeof id !== 'string' || !id.trim()) throw new Error('Patch operation id must be a non-empty string.');
return id;
}
Loading
Loading