blob: c11d99e9984944ec8eeeae0b810fd26a5eec1416 [file]
import { createHash, randomUUID } from 'node:crypto';
import {
closeSync,
constants as fsConstants,
fchmodSync,
fstatSync,
lstatSync,
openSync,
} from 'node:fs';
import { createRequire } from 'node:module';
import type { DatabaseSync } from 'node:sqlite';
import {
MemoryItemStoreConflictError,
isMemoryItemKind,
isMemoryItemOrigin,
isMemoryKeyOrigin,
isMemoryKeyType,
isMemoryLifecycleState,
isMemoryScopeType,
isMemoryStatementType,
isMemoryTemporalType,
normalizeLongTermMemoryContent,
validateMemoryTemporalBounds,
type ApplyMemoryMutationsRequest,
type CommitMemoryExtractionRequest,
type MemoryExtractionCommitResult,
type MemoryExtractionCursor,
type MemoryExtractionFailureClass,
type MemoryExtractionReceipt,
type MemoryItem,
type MemoryItemKey,
type MemoryItemKeyInput,
type MemoryItemMutation,
type MemoryItemRecord,
type MemoryItemSource,
type MemoryItemStore,
type MemoryItemWrite,
type MemoryMutationResult,
type MemoryWriteOperationResult,
type PendingMemoryExtractionFailure,
type SearchMemoryItemsByKeyRequest,
type SettleMemoryExtractionFailureRequest,
type SettleMemoryExtractionFailureResult,
} from '@maka/core';
import {
assertSupportedSqliteLongTermMemorySchemaVersion,
configureSqliteLongTermMemoryDatabase,
migrateSqliteLongTermMemoryDatabase,
readSqliteLongTermMemorySchemaVersion,
type SqliteLongTermMemoryMigrationFailpoint,
} from './sqlite-long-term-memory-schema.js';
const MAX_MUTATIONS_PER_OPERATION = 32;
const MAX_KEYS_PER_ITEM = 32;
const MAX_SOURCES_PER_ITEM = 256;
const MAX_SEARCH_TERMS = 32;
const MAX_SEARCH_RESULTS = 100;
const MAX_IDENTIFIER_CODE_POINTS = 512;
const MAX_KEY_CODE_POINTS = 256;
const MAX_OPERATION_RESULT_JSON_CODE_UNITS = 128 * 1_024;
const require = createRequire(import.meta.url);
export type SqliteMemoryItemStoreFailpoint =
| 'after_item_write'
| 'after_keys_write'
| 'after_sources_write'
| 'after_cursor_write'
| 'before_operation_write'
| 'after_commit';
export interface SqliteMemoryItemStoreOptions {
readonly now?: () => number;
readonly idFactory?: () => string;
readonly failpoint?: (point: SqliteMemoryItemStoreFailpoint) => void;
readonly migrationFailpoint?: (point: SqliteLongTermMemoryMigrationFailpoint) => void;
}
interface NormalizedMemoryWrite {
readonly content: string;
readonly kind: MemoryItem['kind'];
readonly statementType: MemoryItem['statementType'];
readonly temporalType: MemoryItem['temporalType'];
readonly scopeType: MemoryItem['scopeType'];
readonly scopeKey: string | null;
readonly eventStartedAt: number | null;
readonly eventEndedAt: number | null;
readonly observedAt: number;
readonly origin: MemoryItem['origin'];
readonly contentHash: string;
readonly keys: readonly MemoryItemKey[];
readonly sources: readonly MemoryItemSource[];
}
type NormalizedMutation =
| { readonly type: 'create'; readonly item: NormalizedMemoryWrite }
| {
readonly type: 'update';
readonly itemId: string;
readonly expectedVersion: number;
readonly item: NormalizedMemoryWrite;
}
| { readonly type: 'archive'; readonly itemId: string; readonly expectedVersion: number }
| { readonly type: 'restore'; readonly itemId: string; readonly expectedVersion: number };
interface MemoryItemRow {
item_id: unknown;
version: unknown;
content: unknown;
kind: unknown;
statement_type: unknown;
temporal_type: unknown;
scope_type: unknown;
scope_key: unknown;
event_started_at: unknown;
event_ended_at: unknown;
observed_at: unknown;
lifecycle_state: unknown;
origin: unknown;
content_hash: unknown;
created_at: unknown;
updated_at: unknown;
}
interface MemoryKeyRow {
key_text: unknown;
normalized_key: unknown;
key_type: unknown;
key_origin: unknown;
}
interface MemorySourceRow {
session_id: unknown;
run_id: unknown;
turn_id: unknown;
event_id: unknown;
}
interface MemoryOperationRow {
operation_id: unknown;
operation_type: unknown;
request_hash: unknown;
result_json: unknown;
committed_at: unknown;
}
interface MemoryExtractionCursorRow {
session_id: unknown;
processed_ordinal: unknown;
updated_at: unknown;
}
interface MemoryExtractionFailureRow {
session_id: unknown;
from_ordinal: unknown;
through_ordinal: unknown;
coverage_hash: unknown;
first_operation_id: unknown;
first_trigger: unknown;
first_failure_class: unknown;
failed_at: unknown;
}
interface MemoryExtractionReceiptRow {
operation_id: unknown;
session_id: unknown;
request_hash: unknown;
result_json: unknown;
committed_at: unknown;
}
interface SqliteMemoryKeySearchQuery {
readonly sql: string;
readonly parameters: readonly (string | number)[];
}
/** Low-level implementation; production callers must use the StorageRoot authority facade. */
export class SqliteMemoryItemStore implements MemoryItemStore {
readonly #database: DatabaseSync;
readonly #options: SqliteMemoryItemStoreOptions;
#closed = false;
constructor(path: string, options: SqliteMemoryItemStoreOptions = {}) {
if (path.trim() === '') throw new Error('Long-term memory SQLite path cannot be empty');
this.#options = options;
if (path !== ':memory:') preparePrivateDatabaseFiles(path);
const Database = loadDatabaseSync();
this.#database = new Database(path);
try {
assertSupportedSqliteLongTermMemorySchemaVersion(this.#database);
configureSqliteLongTermMemoryDatabase(this.#database);
migrateSqliteLongTermMemoryDatabase(this.#database, {
failpoint: options.migrationFailpoint,
});
if (path !== ':memory:') secureExistingDatabaseFiles(path);
} catch (error) {
this.#database.close();
this.#closed = true;
throw error;
}
}
schemaVersion(): number {
this.#assertOpen();
return readSqliteLongTermMemorySchemaVersion(this.#database);
}
journalMode(): string {
this.#assertOpen();
const row = this.#database.prepare('PRAGMA journal_mode').get() as
| { journal_mode?: unknown }
| undefined;
return typeof row?.journal_mode === 'string' ? row.journal_mode.toLowerCase() : '';
}
foreignKeysEnabled(): boolean {
this.#assertOpen();
const row = this.#database.prepare('PRAGMA foreign_keys').get() as
| { foreign_keys?: unknown }
| undefined;
return row?.foreign_keys === 1;
}
async applyMutations(request: ApplyMemoryMutationsRequest): Promise<MemoryWriteOperationResult> {
this.#assertOpen();
const committedAt = normalizeTimestamp((this.#options.now ?? Date.now)(), 'current time');
const operationId = normalizeIdentifier(request.operationId, 'operationId');
const mutations = normalizeMutations(request.mutations);
const requestHash = hashCanonical(mutations);
const operationType = mutations.length === 1 ? mutations[0]!.type : 'batch';
this.#database.exec('BEGIN IMMEDIATE');
try {
const existing = this.#readOperationRow(operationId);
if (existing) {
if (requiredHash(existing.request_hash, 'request_hash') !== requestHash) {
throw new MemoryItemStoreConflictError(
'operation_reused',
`Memory operation ${operationId} was already used for a different request`,
);
}
this.#database.exec('COMMIT');
return { ...decodeOperation(existing), replayed: true };
}
validateObservedAtForCommit(mutations, committedAt);
const results: MemoryMutationResult[] = [];
for (let index = 0; index < mutations.length; index += 1) {
const result = this.#applyMutation(mutations[index]!, index, committedAt);
results.push(result);
}
this.#options.failpoint?.('before_operation_write');
this.#database
.prepare(
`INSERT INTO memory_write_operations(
operation_id, operation_type, request_hash, result_json, committed_at
) VALUES (?, ?, ?, ?, ?)`,
)
.run(operationId, operationType, requestHash, JSON.stringify(results), committedAt);
this.#database.exec('COMMIT');
this.#options.failpoint?.('after_commit');
return { operationId, operationType, replayed: false, committedAt, results };
} catch (error) {
rollback(this.#database);
throw error;
}
}
async commitExtraction(
request: CommitMemoryExtractionRequest,
): Promise<MemoryExtractionCommitResult> {
this.#assertOpen();
const committedAt = normalizeTimestamp((this.#options.now ?? Date.now)(), 'current time');
const operationId = normalizeIdentifier(request.operationId, 'operationId');
const sessionId = normalizeIdentifier(request.sessionId, 'sessionId');
const expectedCursorOrdinal = normalizeCursorOrdinal(
request.expectedCursorOrdinal,
'expectedCursorOrdinal',
true,
);
const nextCursorOrdinal = normalizeCursorOrdinal(
request.nextCursorOrdinal,
'nextCursorOrdinal',
false,
);
const coverageHash = requiredHash(request.coverageHash, 'coverageHash');
if (nextCursorOrdinal <= expectedCursorOrdinal) {
throw new Error('Memory extraction Cursor must advance');
}
const items = normalizeExtractionItems(request.items);
const requestedItemIndexes = normalizeRequestedItemIndexes(
request.requestedItemIndexes,
items.length,
);
const noOpReason = normalizeExtractionNoOpReason(request.noOpReason);
if (request.trigger !== 'remember' && request.trigger !== 'extract') {
throw new Error('Memory extraction trigger is invalid');
}
if (request.trigger === 'extract' && requestedItemIndexes.length > 0) {
throw new Error('Incidental extraction cannot expose requested Items');
}
if (
noOpReason &&
(request.trigger !== 'remember' || items.length > 0 || requestedItemIndexes.length > 0)
) {
throw new Error('A rejected explicit Memory request must commit as an empty no-op');
}
validateExtractionObservedAtForCommit(items, committedAt);
const requestHash = hashCanonical({
kind: 'memory_extraction',
sessionId,
expectedCursorOrdinal,
nextCursorOrdinal,
coverageHash,
items,
requestedItemIndexes,
noOpReason: noOpReason ?? null,
trigger: request.trigger,
});
this.#database.exec('BEGIN IMMEDIATE');
try {
const existingReceipt = this.#readExtractionReceiptRow(operationId);
if (existingReceipt) {
if (requiredHash(existingReceipt.request_hash, 'request_hash') !== requestHash) {
throw new MemoryItemStoreConflictError(
'operation_reused',
`Memory operation ${operationId} was already used for a different request`,
);
}
const existing = this.#readOperationRow(operationId);
if (!existing) throw new Error(`Memory extraction ${operationId} is missing its operation`);
const decoded = decodeOperation(existing);
const receipt = decodeExtractionReceipt(existingReceipt);
this.#database.exec('COMMIT');
return {
...decoded,
replayed: true,
cursor: {
sessionId,
processedOrdinal: nextCursorOrdinal,
updatedAt: decoded.committedAt,
},
receipt,
};
}
const currentCursor = this.#readExtractionCursorRow(sessionId);
const currentOrdinal = currentCursor
? requiredPositiveInteger(currentCursor.processed_ordinal, 'processed_ordinal')
: 0;
if (currentOrdinal !== expectedCursorOrdinal) {
throw new MemoryItemStoreConflictError(
'cursor_conflict',
`Memory extraction Cursor for Session ${sessionId} is ${currentOrdinal}, expected ${expectedCursorOrdinal}`,
);
}
const pendingFailure = this.#readPendingExtractionFailureRow(sessionId);
if (pendingFailure) {
const pending = decodePendingExtractionFailure(pendingFailure);
if (
pending.firstOperationId === operationId ||
pending.fromOrdinal !== expectedCursorOrdinal + 1 ||
pending.throughOrdinal !== nextCursorOrdinal ||
pending.coverageHash !== coverageHash ||
pending.firstTrigger !== request.trigger
) {
throw new MemoryItemStoreConflictError(
'cursor_conflict',
`Memory extraction pending range for Session ${sessionId} does not match the commit`,
);
}
}
const results = items.map((item, index) => this.#createItem(item, index, committedAt));
const requestedItems = requestedItemIndexes.map((index) => {
const result = results[index]!;
return { itemId: result.itemId, content: items[index]!.content };
});
const receipt: MemoryExtractionReceipt = {
operationId,
sessionId,
status:
request.trigger === 'extract'
? 'extracted'
: requestedItems.length > 0
? 'remembered'
: 'not_applicable',
requestedItems,
...(noOpReason ? { noOpReason } : {}),
committedAt,
};
if (currentCursor) {
const updated = this.#database
.prepare(
`UPDATE memory_extraction_cursors
SET processed_ordinal = ?, updated_at = ?
WHERE session_id = ? AND processed_ordinal = ?`,
)
.run(nextCursorOrdinal, committedAt, sessionId, expectedCursorOrdinal);
if (updated.changes !== 1) {
throw new MemoryItemStoreConflictError(
'cursor_conflict',
`Memory extraction Cursor for Session ${sessionId} changed during commit`,
);
}
} else {
this.#database
.prepare(
`INSERT INTO memory_extraction_cursors(session_id, processed_ordinal, updated_at)
VALUES (?, ?, ?)`,
)
.run(sessionId, nextCursorOrdinal, committedAt);
}
if (pendingFailure) {
this.#database
.prepare('DELETE FROM memory_extraction_failures WHERE session_id = ?')
.run(sessionId);
}
this.#options.failpoint?.('after_cursor_write');
this.#options.failpoint?.('before_operation_write');
this.#database
.prepare(
`INSERT INTO memory_write_operations(
operation_id, operation_type, request_hash, result_json, committed_at
) VALUES (?, 'batch', ?, ?, ?)`,
)
.run(operationId, requestHash, JSON.stringify(results), committedAt);
this.#database
.prepare(
`INSERT INTO memory_extraction_receipts(
operation_id, session_id, request_hash, result_json, committed_at
) VALUES (?, ?, ?, ?, ?)`,
)
.run(operationId, sessionId, requestHash, JSON.stringify(receipt), committedAt);
this.#database.exec('COMMIT');
this.#options.failpoint?.('after_commit');
return {
operationId,
operationType: 'batch',
replayed: false,
committedAt,
results,
cursor: { sessionId, processedOrdinal: nextCursorOrdinal, updatedAt: committedAt },
receipt,
};
} catch (error) {
rollback(this.#database);
throw error;
}
}
async initializeExtractionCursor(
sessionId: string,
processedOrdinal: number,
): Promise<MemoryExtractionCursor> {
this.#assertOpen();
const normalizedSessionId = normalizeIdentifier(sessionId, 'sessionId');
const normalizedOrdinal = normalizeCursorOrdinal(processedOrdinal, 'processedOrdinal', false);
const updatedAt = normalizeTimestamp((this.#options.now ?? Date.now)(), 'current time');
this.#database.exec('BEGIN IMMEDIATE');
try {
const existing = this.#readExtractionCursorRow(normalizedSessionId);
if (existing) {
const decoded = decodeExtractionCursor(existing);
this.#database.exec('COMMIT');
return decoded;
}
if (this.#readPendingExtractionFailureRow(normalizedSessionId)) {
throw new MemoryItemStoreConflictError(
'cursor_conflict',
`Memory extraction for Session ${normalizedSessionId} has a pending failed range`,
);
}
this.#database
.prepare(
`INSERT INTO memory_extraction_cursors(session_id, processed_ordinal, updated_at)
VALUES (?, ?, ?)`,
)
.run(normalizedSessionId, normalizedOrdinal, updatedAt);
this.#database.exec('COMMIT');
return { sessionId: normalizedSessionId, processedOrdinal: normalizedOrdinal, updatedAt };
} catch (error) {
rollback(this.#database);
throw error;
}
}
async readExtractionCursor(sessionId: string): Promise<MemoryExtractionCursor | undefined> {
this.#assertOpen();
const normalizedSessionId = normalizeIdentifier(sessionId, 'sessionId');
return this.#readSnapshot(() => {
const row = this.#readExtractionCursorRow(normalizedSessionId);
return row ? decodeExtractionCursor(row) : undefined;
});
}
async readPendingExtractionFailure(
sessionId: string,
): Promise<PendingMemoryExtractionFailure | undefined> {
this.#assertOpen();
const normalizedSessionId = normalizeIdentifier(sessionId, 'sessionId');
return this.#readSnapshot(() => {
const row = this.#readPendingExtractionFailureRow(normalizedSessionId);
return row ? decodePendingExtractionFailure(row) : undefined;
});
}
async settleExtractionFailure(
request: SettleMemoryExtractionFailureRequest,
): Promise<SettleMemoryExtractionFailureResult> {
this.#assertOpen();
const operationId = normalizeIdentifier(request.operationId, 'operationId');
const sessionId = normalizeIdentifier(request.sessionId, 'sessionId');
const expectedCursorOrdinal = normalizeCursorOrdinal(
request.expectedCursorOrdinal,
'expectedCursorOrdinal',
true,
);
const failedThroughOrdinal = normalizeCursorOrdinal(
request.failedThroughOrdinal,
'failedThroughOrdinal',
false,
);
if (failedThroughOrdinal <= expectedCursorOrdinal) {
throw new Error('Memory extraction failed range must advance beyond the Cursor');
}
const coverageHash = requiredHash(request.coverageHash, 'coverageHash');
const failureClass = normalizeExtractionFailureClass(request.failureClass);
if (request.trigger !== 'remember' && request.trigger !== 'extract') {
throw new Error('Memory extraction trigger is invalid');
}
const recordedAt = normalizeTimestamp((this.#options.now ?? Date.now)(), 'current time');
this.#database.exec('BEGIN IMMEDIATE');
try {
const existingReceipt = this.#readExtractionReceiptRow(operationId);
if (existingReceipt) {
const receipt = decodeExtractionReceipt(existingReceipt);
if (receipt.status !== 'discarded') {
throw new MemoryItemStoreConflictError(
'operation_reused',
`Memory operation ${operationId} already completed successfully`,
);
}
const discarded = receipt.discardedRange!;
const replayHash = hashCanonical({
kind: 'memory_extraction_discard',
sessionId,
trigger: request.trigger,
discardedRange: discarded,
});
if (
receipt.sessionId !== sessionId ||
requiredHash(existingReceipt.request_hash, 'request_hash') !== replayHash ||
discarded.fromOrdinal !== expectedCursorOrdinal + 1 ||
discarded.throughOrdinal !== failedThroughOrdinal ||
discarded.coverageHash !== coverageHash ||
discarded.finalFailureClass !== failureClass
) {
throw new MemoryItemStoreConflictError(
'operation_reused',
`Memory operation ${operationId} was already used for a different failed range`,
);
}
const cursor = this.#readExtractionCursorRow(sessionId);
if (!cursor) throw new Error(`Discarded Memory extraction ${operationId} lost its Cursor`);
this.#database.exec('COMMIT');
return {
status: 'discarded',
replayed: true,
receipt,
cursor: decodeExtractionCursor(cursor),
};
}
const cursorRow = this.#readExtractionCursorRow(sessionId);
const currentOrdinal = cursorRow
? requiredPositiveInteger(cursorRow.processed_ordinal, 'processed_ordinal')
: 0;
if (currentOrdinal !== expectedCursorOrdinal) {
throw new MemoryItemStoreConflictError(
'cursor_conflict',
`Memory extraction Cursor for Session ${sessionId} is ${currentOrdinal}, expected ${expectedCursorOrdinal}`,
);
}
const pendingRow = this.#readPendingExtractionFailureRow(sessionId);
if (!pendingRow) {
const pending: PendingMemoryExtractionFailure = {
sessionId,
fromOrdinal: expectedCursorOrdinal + 1,
throughOrdinal: failedThroughOrdinal,
coverageHash,
firstOperationId: operationId,
firstTrigger: request.trigger,
firstFailureClass: failureClass,
failedAt: recordedAt,
};
this.#database
.prepare(
`INSERT INTO memory_extraction_failures(
session_id, from_ordinal, through_ordinal, coverage_hash,
first_operation_id, first_trigger, first_failure_class, failed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
sessionId,
pending.fromOrdinal,
pending.throughOrdinal,
pending.coverageHash,
pending.firstOperationId,
pending.firstTrigger,
pending.firstFailureClass,
pending.failedAt,
);
this.#database.exec('COMMIT');
return { status: 'retry_later', replayed: false, pending };
}
const pending = decodePendingExtractionFailure(pendingRow);
if (pending.firstOperationId === operationId) {
if (
pending.fromOrdinal !== expectedCursorOrdinal + 1 ||
pending.throughOrdinal !== failedThroughOrdinal ||
pending.coverageHash !== coverageHash ||
pending.firstTrigger !== request.trigger ||
pending.firstFailureClass !== failureClass
) {
throw new MemoryItemStoreConflictError(
'operation_reused',
`Memory operation ${operationId} was already used for a different failed range`,
);
}
this.#database.exec('COMMIT');
return { status: 'retry_later', replayed: true, pending };
}
if (
pending.fromOrdinal !== expectedCursorOrdinal + 1 ||
pending.throughOrdinal !== failedThroughOrdinal ||
pending.coverageHash !== coverageHash ||
pending.firstTrigger !== request.trigger
) {
throw new MemoryItemStoreConflictError(
'cursor_conflict',
`Memory extraction failed range for Session ${sessionId} changed before discard`,
);
}
const discardedRange = {
fromOrdinal: pending.fromOrdinal,
throughOrdinal: pending.throughOrdinal,
coverageHash: pending.coverageHash,
firstFailureClass: pending.firstFailureClass,
finalFailureClass: failureClass,
} as const;
const receipt: MemoryExtractionReceipt = {
operationId,
sessionId,
status: 'discarded',
requestedItems: [],
discardedRange,
committedAt: recordedAt,
};
const requestHash = hashCanonical({
kind: 'memory_extraction_discard',
sessionId,
trigger: request.trigger,
discardedRange,
});
if (cursorRow) {
const updated = this.#database
.prepare(
`UPDATE memory_extraction_cursors
SET processed_ordinal = ?, updated_at = ?
WHERE session_id = ? AND processed_ordinal = ?`,
)
.run(failedThroughOrdinal, recordedAt, sessionId, expectedCursorOrdinal);
if (updated.changes !== 1) {
throw new MemoryItemStoreConflictError(
'cursor_conflict',
`Memory extraction Cursor for Session ${sessionId} changed during discard`,
);
}
} else {
this.#database
.prepare(
`INSERT INTO memory_extraction_cursors(session_id, processed_ordinal, updated_at)
VALUES (?, ?, ?)`,
)
.run(sessionId, failedThroughOrdinal, recordedAt);
}
this.#options.failpoint?.('after_cursor_write');
this.#database
.prepare('DELETE FROM memory_extraction_failures WHERE session_id = ?')
.run(sessionId);
this.#options.failpoint?.('before_operation_write');
this.#database
.prepare(
`INSERT INTO memory_write_operations(
operation_id, operation_type, request_hash, result_json, committed_at
) VALUES (?, 'batch', ?, '[]', ?)`,
)
.run(operationId, requestHash, recordedAt);
this.#database
.prepare(
`INSERT INTO memory_extraction_receipts(
operation_id, session_id, request_hash, result_json, committed_at
) VALUES (?, ?, ?, ?, ?)`,
)
.run(operationId, sessionId, requestHash, JSON.stringify(receipt), recordedAt);
this.#database.exec('COMMIT');
this.#options.failpoint?.('after_commit');
return {
status: 'discarded',
replayed: false,
receipt,
cursor: { sessionId, processedOrdinal: failedThroughOrdinal, updatedAt: recordedAt },
};
} catch (error) {
rollback(this.#database);
throw error;
}
}
async readExtractionReceipt(operationId: string): Promise<MemoryExtractionReceipt | undefined> {
this.#assertOpen();
const normalizedOperationId = normalizeIdentifier(operationId, 'operationId');
return this.#readSnapshot(() => {
const row = this.#readExtractionReceiptRow(normalizedOperationId);
return row ? decodeExtractionReceipt(row) : undefined;
});
}
async readItem(itemId: string): Promise<MemoryItemRecord | undefined> {
this.#assertOpen();
return this.#readSnapshot(() => this.#readItemRecord(normalizeIdentifier(itemId, 'itemId')));
}
async searchByKeys(request: SearchMemoryItemsByKeyRequest): Promise<readonly MemoryItemRecord[]> {
this.#assertOpen();
if (request.match !== 'exact' && request.match !== 'prefix') {
throw new Error('Memory key match must be exact or prefix');
}
if (!Array.isArray(request.terms) || request.terms.length === 0) {
throw new Error('Memory key search requires at least one term');
}
if (request.terms.length > MAX_SEARCH_TERMS) {
throw new Error(`Memory key search accepts at most ${MAX_SEARCH_TERMS} terms`);
}
if (request.includeArchived !== undefined && typeof request.includeArchived !== 'boolean') {
throw new Error('Memory key includeArchived must be a boolean');
}
const terms = [...new Set(request.terms.map(normalizeSearchTerm))].sort(compareText);
const workspaceKey =
request.workspaceKey === undefined
? undefined
: normalizeIdentifier(request.workspaceKey, 'workspaceKey');
const limit = request.limit ?? 20;
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_SEARCH_RESULTS) {
throw new Error(`Memory key search limit must be between 1 and ${MAX_SEARCH_RESULTS}`);
}
const query = buildSqliteMemoryKeySearchQuery({
terms,
match: request.match,
workspaceKey,
includeArchived: request.includeArchived ?? false,
limit,
});
return this.#readSnapshot(() => {
const rows = this.#database.prepare(query.sql).all(...query.parameters) as Array<{
item_id?: unknown;
}>;
return rows.map((row) => {
if (typeof row.item_id !== 'string') throw new Error('Invalid Memory Item search result');
const record = this.#readItemRecord(row.item_id);
if (!record) throw new Error(`Memory Item ${row.item_id} disappeared during read`);
return record;
});
});
}
async readOperation(operationId: string): Promise<MemoryWriteOperationResult | undefined> {
this.#assertOpen();
const row = this.#readOperationRow(normalizeIdentifier(operationId, 'operationId'));
return row ? decodeOperation(row) : undefined;
}
close(): void {
if (this.#closed) return;
this.#database.close();
this.#closed = true;
}
#applyMutation(
mutation: NormalizedMutation,
mutationIndex: number,
committedAt: number,
): MemoryMutationResult {
switch (mutation.type) {
case 'create':
return this.#createItem(mutation.item, mutationIndex, committedAt);
case 'update':
return this.#updateItem(mutation, mutationIndex, committedAt);
case 'archive':
return this.#changeLifecycle(mutation, mutationIndex, committedAt, 'archived');
case 'restore':
return this.#changeLifecycle(mutation, mutationIndex, committedAt, 'active');
}
}
#createItem(
write: NormalizedMemoryWrite,
mutationIndex: number,
committedAt: number,
): MemoryMutationResult {
const itemId = normalizeIdentifier(
(this.#options.idFactory ?? randomUUID)(),
'generated itemId',
);
this.#database
.prepare(
`INSERT INTO memory_items(
item_id, version, content, kind, statement_type, temporal_type,
scope_type, scope_key, event_started_at, event_ended_at, observed_at,
lifecycle_state, origin, content_hash, created_at, updated_at
) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)`,
)
.run(
itemId,
write.content,
write.kind,
write.statementType,
write.temporalType,
write.scopeType,
write.scopeKey,
write.eventStartedAt,
write.eventEndedAt,
write.observedAt,
write.origin,
write.contentHash,
committedAt,
committedAt,
);
this.#options.failpoint?.('after_item_write');
this.#replaceKeys(itemId, write.keys);
this.#options.failpoint?.('after_keys_write');
this.#replaceSources(itemId, write.sources);
this.#options.failpoint?.('after_sources_write');
return mutationResult(mutationIndex, 'create', this.#requireItemRecord(itemId).item, 'created');
}
#updateItem(
mutation: Extract<NormalizedMutation, { type: 'update' }>,
mutationIndex: number,
committedAt: number,
): MemoryMutationResult {
const current = this.#requireVersion(mutation.itemId, mutation.expectedVersion);
const currentRecord = this.#requireItemRecord(mutation.itemId);
if (recordMatchesWrite(currentRecord, mutation.item)) {
return mutationResult(mutationIndex, 'update', current, 'noop');
}
const updatedAt = Math.max(committedAt, current.updatedAt);
const result = this.#database
.prepare(
`UPDATE memory_items
SET version = version + 1,
content = ?, kind = ?, statement_type = ?, temporal_type = ?,
scope_type = ?, scope_key = ?, event_started_at = ?, event_ended_at = ?,
observed_at = ?, origin = ?, content_hash = ?, updated_at = ?
WHERE item_id = ? AND version = ?`,
)
.run(
mutation.item.content,
mutation.item.kind,
mutation.item.statementType,
mutation.item.temporalType,
mutation.item.scopeType,
mutation.item.scopeKey,
mutation.item.eventStartedAt,
mutation.item.eventEndedAt,
mutation.item.observedAt,
mutation.item.origin,
mutation.item.contentHash,
updatedAt,
mutation.itemId,
mutation.expectedVersion,
);
assertChanged(result.changes, mutation.itemId);
this.#options.failpoint?.('after_item_write');
this.#replaceKeys(mutation.itemId, mutation.item.keys);
this.#options.failpoint?.('after_keys_write');
this.#replaceSources(mutation.itemId, mutation.item.sources);
this.#options.failpoint?.('after_sources_write');
return mutationResult(
mutationIndex,
'update',
this.#requireItemRecord(mutation.itemId).item,
'updated',
);
}
#changeLifecycle(
mutation: Extract<NormalizedMutation, { type: 'archive' | 'restore' }>,
mutationIndex: number,
committedAt: number,
target: MemoryItem['lifecycleState'],
): MemoryMutationResult {
const current = this.#requireVersion(mutation.itemId, mutation.expectedVersion);
const expected = target === 'archived' ? 'active' : 'archived';
if (current.lifecycleState !== expected) {
throw new MemoryItemStoreConflictError(
'invalid_lifecycle_transition',
`Memory Item ${mutation.itemId} is ${current.lifecycleState}, expected ${expected}`,
mutation.itemId,
);
}
const updatedAt = Math.max(committedAt, current.updatedAt);
const result = this.#database
.prepare(
`UPDATE memory_items
SET version = version + 1, lifecycle_state = ?, updated_at = ?
WHERE item_id = ? AND version = ? AND lifecycle_state = ?`,
)
.run(target, updatedAt, mutation.itemId, mutation.expectedVersion, expected);
assertChanged(result.changes, mutation.itemId);
this.#options.failpoint?.('after_item_write');
return mutationResult(
mutationIndex,
mutation.type,
this.#requireItemRecord(mutation.itemId).item,
target === 'active' ? 'restored' : 'archived',
);
}
#requireVersion(itemId: string, expectedVersion: number): MemoryItem {
const record = this.#readItemRecord(itemId);
if (!record) {
throw new MemoryItemStoreConflictError(
'item_not_found',
`Memory Item ${itemId} does not exist`,
itemId,
);
}
if (record.item.version !== expectedVersion) {
throw new MemoryItemStoreConflictError(
'version_conflict',
`Memory Item ${itemId} is version ${record.item.version}, expected ${expectedVersion}`,
itemId,
);
}
return record.item;
}
#replaceKeys(itemId: string, keys: readonly MemoryItemKey[]): void {
this.#database.prepare('DELETE FROM memory_item_keys WHERE item_id = ?').run(itemId);
const insert = this.#database.prepare(
`INSERT INTO memory_item_keys(item_id, key_text, normalized_key, key_type, key_origin)
VALUES (?, ?, ?, ?, ?)`,
);
for (const key of keys) {
insert.run(itemId, key.key, key.normalizedKey, key.keyType, key.keyOrigin);
}
}
#replaceSources(itemId: string, sources: readonly MemoryItemSource[]): void {
this.#database.prepare('DELETE FROM memory_item_sources WHERE item_id = ?').run(itemId);
const insert = this.#database.prepare(
`INSERT INTO memory_item_sources(item_id, session_id, run_id, turn_id, event_id)
VALUES (?, ?, ?, ?, ?)`,
);
for (const source of sources) {
insert.run(itemId, source.sessionId, source.runId, source.turnId, source.eventId);
}
}
#readItemRecord(itemId: string): MemoryItemRecord | undefined {
const row = this.#database
.prepare('SELECT * FROM memory_items WHERE item_id = ?')
.get(itemId) as MemoryItemRow | undefined;
if (!row) return undefined;
const keys = this.#database
.prepare(
`SELECT key_text, normalized_key, key_type, key_origin
FROM memory_item_keys WHERE item_id = ? ORDER BY normalized_key ASC
LIMIT ${MAX_KEYS_PER_ITEM + 1}`,
)
.all(itemId) as unknown as MemoryKeyRow[];
const sources = this.#database
.prepare(
`SELECT session_id, run_id, turn_id, event_id
FROM memory_item_sources WHERE item_id = ? ORDER BY event_id ASC
LIMIT ${MAX_SOURCES_PER_ITEM + 1}`,
)
.all(itemId) as unknown as MemorySourceRow[];
assertChildCardinality('keys', keys.length, MAX_KEYS_PER_ITEM);
assertChildCardinality('sources', sources.length, MAX_SOURCES_PER_ITEM);
return {
item: decodeItem(row),
keys: keys.map(decodeKey),
sources: sources.map(decodeSource),
};
}
#requireItemRecord(itemId: string): MemoryItemRecord {
const record = this.#readItemRecord(itemId);
if (!record) throw new Error(`Memory Item ${itemId} disappeared during transaction`);
return record;
}
#readOperationRow(operationId: string): MemoryOperationRow | undefined {
return this.#database
.prepare(
`SELECT operation_id, operation_type, request_hash, result_json, committed_at
FROM memory_write_operations WHERE operation_id = ?`,
)
.get(operationId) as MemoryOperationRow | undefined;
}
#readExtractionCursorRow(sessionId: string): MemoryExtractionCursorRow | undefined {
return this.#database
.prepare(
`SELECT session_id, processed_ordinal, updated_at
FROM memory_extraction_cursors WHERE session_id = ?`,
)
.get(sessionId) as MemoryExtractionCursorRow | undefined;
}
#readPendingExtractionFailureRow(sessionId: string): MemoryExtractionFailureRow | undefined {
return this.#database
.prepare(
`SELECT session_id, from_ordinal, through_ordinal, coverage_hash,
first_operation_id, first_trigger, first_failure_class, failed_at
FROM memory_extraction_failures WHERE session_id = ?`,
)
.get(sessionId) as MemoryExtractionFailureRow | undefined;
}
#readExtractionReceiptRow(operationId: string): MemoryExtractionReceiptRow | undefined {
return this.#database
.prepare(
`SELECT operation_id, session_id, request_hash, result_json, committed_at
FROM memory_extraction_receipts WHERE operation_id = ?`,
)
.get(operationId) as MemoryExtractionReceiptRow | undefined;
}
#assertOpen(): void {
if (this.#closed) throw new Error('SQLite Memory Item Store is closed');
}
#readSnapshot<T>(operation: () => T): T {
this.#database.exec('BEGIN');
try {
const result = operation();
this.#database.exec('COMMIT');
return result;
} catch (error) {
rollback(this.#database);
throw error;
}
}
}
function loadDatabaseSync(): typeof import('node:sqlite').DatabaseSync {
return (require('node:sqlite') as typeof import('node:sqlite')).DatabaseSync;
}
export function buildSqliteMemoryKeySearchQuery(input: {
readonly terms: readonly string[];
readonly match: 'exact' | 'prefix';
readonly workspaceKey?: string;
readonly includeArchived: boolean;
readonly limit: number;
}): SqliteMemoryKeySearchQuery {
const parameters: Array<string | number> = [];
let matchingKeysSql: string;
if (input.match === 'exact') {
matchingKeysSql = `
SELECT item_id, normalized_key AS matched_term
FROM memory_item_keys INDEXED BY memory_item_keys_by_normalized_key
WHERE normalized_key IN (${placeholders(input.terms.length)})`;
parameters.push(...input.terms);
} else {
matchingKeysSql = input.terms
.map((term, index) => {
const upperBound = prefixUpperBound(term);
parameters.push(term);
if (upperBound) {
parameters.push(upperBound);
return `
SELECT item_id, ${index} AS matched_term
FROM memory_item_keys INDEXED BY memory_item_keys_by_normalized_key
WHERE normalized_key >= ? AND normalized_key < ?`;
}
return `
SELECT item_id, ${index} AS matched_term
FROM memory_item_keys INDEXED BY memory_item_keys_by_normalized_key
WHERE normalized_key >= ?`;
})
.join('\nUNION ALL\n');
}
const scopeClause = input.workspaceKey
? `(i.scope_type = 'global' OR (i.scope_type = 'workspace' AND i.scope_key = ?))`
: `i.scope_type = 'global'`;
if (input.workspaceKey) parameters.push(input.workspaceKey);
parameters.push(input.limit);
return {
sql: `
WITH matching_keys AS MATERIALIZED (
${matchingKeysSql}
)
SELECT i.item_id
FROM matching_keys m
JOIN memory_items i ON i.item_id = m.item_id
WHERE ${scopeClause}
AND ${input.includeArchived ? '1 = 1' : `i.lifecycle_state = 'active'`}
GROUP BY i.item_id
ORDER BY COUNT(DISTINCT m.matched_term) DESC, i.updated_at DESC, i.item_id ASC
LIMIT ?`,
parameters,
};
}
function normalizeMutations(
mutations: readonly MemoryItemMutation[],
): readonly NormalizedMutation[] {
if (!Array.isArray(mutations) || mutations.length === 0) {
throw new Error('Memory operation requires at least one mutation');
}
if (mutations.length > MAX_MUTATIONS_PER_OPERATION) {
throw new Error(`Memory operation accepts at most ${MAX_MUTATIONS_PER_OPERATION} mutations`);
}
return mutations.map((mutation): NormalizedMutation => {
if (!mutation || typeof mutation !== 'object') throw new Error('Invalid Memory mutation');
switch (mutation.type) {
case 'create':
return { type: 'create', item: normalizeWrite(mutation.item) };
case 'update':
return {
type: 'update',
itemId: normalizeIdentifier(mutation.itemId, 'itemId'),
expectedVersion: normalizeVersion(mutation.expectedVersion),
item: normalizeWrite(mutation.item),
};
case 'archive':
case 'restore':
return {
type: mutation.type,
itemId: normalizeIdentifier(mutation.itemId, 'itemId'),
expectedVersion: normalizeVersion(mutation.expectedVersion),
};
default:
throw new Error('Unknown Memory mutation type');
}
});
}
function normalizeExtractionItems(
items: readonly MemoryItemWrite[],
): readonly NormalizedMemoryWrite[] {
if (!Array.isArray(items)) throw new Error('Memory extraction items must be an array');
if (items.length > MAX_MUTATIONS_PER_OPERATION) {
throw new Error(`Memory extraction accepts at most ${MAX_MUTATIONS_PER_OPERATION} Items`);
}
return items.map(normalizeWrite);
}
function assertChildCardinality(child: 'keys' | 'sources', count: number, maximum: number): void {
if (count < 1 || count > maximum) {
throw new Error(
`Invalid Memory Item ${child} cardinality: expected 1..${maximum}, got ${count}`,
);
}
}
function validateObservedAtForCommit(
mutations: readonly NormalizedMutation[],
committedAt: number,
): void {
for (const mutation of mutations) {
if (
(mutation.type === 'create' || mutation.type === 'update') &&
mutation.item.observedAt > committedAt
) {
throw new Error('observedAt cannot be later than commit time');
}
}
}
function validateExtractionObservedAtForCommit(
items: readonly NormalizedMemoryWrite[],
committedAt: number,
): void {
for (const item of items) {
if (item.observedAt > committedAt) {
throw new Error('observedAt cannot be later than commit time');
}
}
}
function normalizeWrite(input: MemoryItemWrite): NormalizedMemoryWrite {
if (!input || typeof input !== 'object') throw new Error('Memory Item write must be an object');
const content = normalizeLongTermMemoryContent(input.content);
if (!content.ok) throw new Error(content.message);
if (!isMemoryItemKind(input.kind)) throw new Error('Invalid Memory Item kind');
if (!isMemoryStatementType(input.statementType)) throw new Error('Invalid Memory statement type');
if (!isMemoryTemporalType(input.temporalType)) throw new Error('Invalid Memory temporal type');
if (!isMemoryScopeType(input.scopeType)) throw new Error('Invalid Memory scope type');
if (!isMemoryItemOrigin(input.origin)) throw new Error('Invalid Memory Item origin');
const scopeKey = normalizeScopeKey(input.scopeType, input.scopeKey);
const eventStartedAt = normalizeOptionalTimestamp(input.eventStartedAt, 'eventStartedAt');
const eventEndedAt = normalizeOptionalTimestamp(input.eventEndedAt, 'eventEndedAt');
validateMemoryTemporalBounds({
temporalType: input.temporalType,
eventStartedAt,
eventEndedAt,
});
const observedAt = normalizeTimestamp(input.observedAt, 'observedAt');
return {
content: content.value,
kind: input.kind,
statementType: input.statementType,
temporalType: input.temporalType,
scopeType: input.scopeType,
scopeKey,
eventStartedAt,
eventEndedAt,
observedAt,
origin: input.origin,
contentHash: hashText(content.value),
keys: normalizeKeys(input.keys),
sources: normalizeSources(input.sources),
};
}
function normalizeScopeKey(
scopeType: MemoryItem['scopeType'],
input: string | null | undefined,
): string | null {
if (scopeType === 'global') {
if (input !== undefined && input !== null) {
throw new Error('Global Memory Item cannot have a scopeKey');
}
return null;
}
return normalizeIdentifier(input, 'workspace scopeKey');
}
function normalizeKeys(input: readonly MemoryItemKeyInput[]): readonly MemoryItemKey[] {
if (!Array.isArray(input) || input.length === 0) {
throw new Error('Memory Item requires at least one search key');
}
if (input.length > MAX_KEYS_PER_ITEM) {
throw new Error(`Memory Item accepts at most ${MAX_KEYS_PER_ITEM} search keys`);
}
const winners = new Map<string, MemoryItemKey>();
for (const candidate of input) {
if (!candidate || typeof candidate !== 'object') throw new Error('Invalid Memory search key');
if (!isMemoryKeyType(candidate.keyType)) throw new Error('Invalid Memory key type');
if (!isMemoryKeyOrigin(candidate.keyOrigin)) throw new Error('Invalid Memory key origin');
const key = normalizeVisibleText(candidate.key, 'Memory search key', MAX_KEY_CODE_POINTS);
const normalized: MemoryItemKey = {
key,
normalizedKey: normalizeSearchTerm(key),
keyType: candidate.keyType,
keyOrigin: candidate.keyOrigin,
};
const existing = winners.get(normalized.normalizedKey);
if (!existing || keyPriority(normalized) > keyPriority(existing)) {
winners.set(normalized.normalizedKey, normalized);
} else if (
existing &&
keyPriority(normalized) === keyPriority(existing) &&
compareText(normalized.key, existing.key) < 0
) {
winners.set(normalized.normalizedKey, normalized);
}
}
return [...winners.values()].sort((left, right) =>
compareText(left.normalizedKey, right.normalizedKey),
);
}
function normalizeSources(input: readonly MemoryItemSource[]): readonly MemoryItemSource[] {
if (!Array.isArray(input) || input.length === 0) {
throw new Error('Memory Item requires at least one source Event');
}
if (input.length > MAX_SOURCES_PER_ITEM) {
throw new Error(`Memory Item accepts at most ${MAX_SOURCES_PER_ITEM} sources`);
}
const sources = new Map<string, MemoryItemSource>();
for (const candidate of input) {
if (!candidate || typeof candidate !== 'object') throw new Error('Invalid Memory Item source');
const source: MemoryItemSource = {
sessionId: normalizeIdentifier(candidate.sessionId, 'source sessionId'),
runId: normalizeIdentifier(candidate.runId, 'source runId'),
turnId: normalizeIdentifier(candidate.turnId, 'source turnId'),
eventId: normalizeIdentifier(candidate.eventId, 'source eventId'),
};
const existing = sources.get(source.eventId);
if (existing && !sameSource(existing, source)) {
throw new Error(`Source eventId ${source.eventId} has conflicting provenance`);
}
sources.set(source.eventId, source);
}
return [...sources.values()].sort((left, right) => compareText(left.eventId, right.eventId));
}
function sameSource(left: MemoryItemSource, right: MemoryItemSource): boolean {
return (
left.sessionId === right.sessionId &&
left.runId === right.runId &&
left.turnId === right.turnId &&
left.eventId === right.eventId
);
}
function normalizeIdentifier(input: unknown, name: string): string {
if (typeof input !== 'string') throw new Error(`${name} must be a string`);
if (input.normalize('NFC') !== input || input.trim() !== input) {
throw new Error(`${name} must already be NFC-normalized without surrounding whitespace`);
}
return normalizeVisibleText(input, name, MAX_IDENTIFIER_CODE_POINTS);
}
function normalizeVisibleText(input: unknown, name: string, maxCodePoints: number): string {
if (typeof input !== 'string') throw new Error(`${name} must be a string`);
const value = input.normalize('NFC').trim();
if (value === '') throw new Error(`${name} cannot be empty`);
if (/[\p{Cc}\p{Cs}\u200B\u200C\u200D\uFEFF]/u.test(value)) {
throw new Error(`${name} cannot contain control or zero-width characters`);
}
if (Array.from(value).length > maxCodePoints) {
throw new Error(`${name} must be ${maxCodePoints} code points or fewer`);
}
return value;
}
function normalizeSearchTerm(input: unknown): string {
return normalizeVisibleText(input, 'Memory search term', MAX_KEY_CODE_POINTS)
.replace(/\s+/gu, ' ')
.toLowerCase();
}
function normalizeVersion(value: unknown): number {
if (!Number.isSafeInteger(value) || (value as number) < 1) {
throw new Error('expectedVersion must be a positive safe integer');
}
return value as number;
}
function normalizeCursorOrdinal(value: unknown, name: string, allowZero: boolean): number {
if (!Number.isSafeInteger(value) || (value as number) < (allowZero ? 0 : 1)) {
throw new Error(`${name} must be ${allowZero ? 'a non-negative' : 'a positive'} safe integer`);
}
return value as number;
}
function normalizeExtractionFailureClass(value: unknown): MemoryExtractionFailureClass {
if (
value !== 'provider' &&
value !== 'schema' &&
value !== 'evidence' &&
value !== 'localization' &&
value !== 'requested_admission'
) {
throw new Error('Invalid Memory extraction failure class');
}
return value;
}
function normalizeExtractionTrigger(value: unknown): 'remember' | 'extract' {
if (value !== 'remember' && value !== 'extract') {
throw new Error('Invalid Memory extraction trigger');
}
return value;
}
function normalizeExtractionNoOpReason(value: unknown): 'sensitive_information' | undefined {
if (value === undefined) return undefined;
if (value !== 'sensitive_information') {
throw new Error('Invalid Memory extraction no-op reason');
}
return value;
}
function normalizeRequestedItemIndexes(value: unknown, itemCount: number): number[] {
if (!Array.isArray(value)) throw new Error('requestedItemIndexes must be an array');
const indexes = [...new Set(value)];
for (const index of indexes) {
if (!Number.isSafeInteger(index) || (index as number) < 0 || (index as number) >= itemCount) {
throw new Error('requestedItemIndexes contains an out-of-range index');
}
}
return (indexes as number[]).sort((left, right) => left - right);
}
function normalizeTimestamp(value: unknown, name: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw new Error(`${name} must be a non-negative integer UTC millisecond timestamp`);
}
return value as number;
}
function normalizeOptionalTimestamp(value: unknown, name: string): number | null {
if (value === undefined || value === null) return null;
return normalizeTimestamp(value, name);
}
function keyPriority(key: MemoryItemKey): number {
const origin = { user: 3, deterministic: 2, llm: 1 } as const;
const type = { code: 5, exact: 4, entity: 3, concept: 2, alias: 1 } as const;
return origin[key.keyOrigin] * 10 + type[key.keyType];
}
function recordMatchesWrite(record: MemoryItemRecord, write: NormalizedMemoryWrite): boolean {
return hashCanonical(writeFromRecord(record)) === hashCanonical(write);
}
function writeFromRecord(record: MemoryItemRecord): NormalizedMemoryWrite {
return {
content: record.item.content,
kind: record.item.kind,
statementType: record.item.statementType,
temporalType: record.item.temporalType,
scopeType: record.item.scopeType,
scopeKey: record.item.scopeKey,
eventStartedAt: record.item.eventStartedAt,
eventEndedAt: record.item.eventEndedAt,
observedAt: record.item.observedAt,
origin: record.item.origin,
contentHash: record.item.contentHash,
keys: record.keys,
sources: record.sources,
};
}
function mutationResult(
mutationIndex: number,
mutationType: MemoryMutationResult['mutationType'],
item: MemoryItem,
outcome: MemoryMutationResult['outcome'],
): MemoryMutationResult {
return {
mutationIndex,
mutationType,
itemId: item.itemId,
version: item.version,
lifecycleState: item.lifecycleState,
outcome,
};
}
function decodeItem(row: MemoryItemRow): MemoryItem {
const kind = requiredString(row.kind, 'kind');
const statementType = requiredString(row.statement_type, 'statement_type');
const temporalType = requiredString(row.temporal_type, 'temporal_type');
const scopeType = requiredString(row.scope_type, 'scope_type');
const lifecycleState = requiredString(row.lifecycle_state, 'lifecycle_state');
const origin = requiredString(row.origin, 'origin');
if (!isMemoryItemKind(kind)) throw invalidColumn('kind');
if (!isMemoryStatementType(statementType)) throw invalidColumn('statement_type');
if (!isMemoryTemporalType(temporalType)) throw invalidColumn('temporal_type');
if (!isMemoryScopeType(scopeType)) throw invalidColumn('scope_type');
if (!isMemoryLifecycleState(lifecycleState)) throw invalidColumn('lifecycle_state');
if (!isMemoryItemOrigin(origin)) throw invalidColumn('origin');
const itemId = requiredIdentifierString(row.item_id, 'item_id');
const version = requiredPositiveInteger(row.version, 'version');
const content = requiredNonEmptyString(row.content, 'content');
const normalizedContent = normalizeLongTermMemoryContent(content);
if (!normalizedContent.ok || normalizedContent.value !== content) throw invalidColumn('content');
const scopeKey = nullableIdentifierString(row.scope_key, 'scope_key');
const eventStartedAt = nullableNonNegativeInteger(row.event_started_at, 'event_started_at');
const eventEndedAt = nullableNonNegativeInteger(row.event_ended_at, 'event_ended_at');
const observedAt = requiredNonNegativeInteger(row.observed_at, 'observed_at');
const contentHash = requiredHash(row.content_hash, 'content_hash');
const createdAt = requiredNonNegativeInteger(row.created_at, 'created_at');
const updatedAt = requiredNonNegativeInteger(row.updated_at, 'updated_at');
if (
(scopeType === 'global' && scopeKey !== null) ||
(scopeType === 'workspace' && (scopeKey === null || scopeKey.length === 0))
) {
throw invalidColumn('scope_key');
}
validateMemoryTemporalBounds({ temporalType, eventStartedAt, eventEndedAt });
if (createdAt > updatedAt || observedAt > updatedAt) throw invalidColumn('timestamps');
if (hashText(content) !== contentHash) throw invalidColumn('content_hash');
return {
itemId,
version,
content,
kind,
statementType,
temporalType,
scopeType,
scopeKey,
eventStartedAt,
eventEndedAt,
observedAt,
lifecycleState,
origin,
contentHash,
createdAt,
updatedAt,
};
}
function decodeKey(row: MemoryKeyRow): MemoryItemKey {
const keyType = requiredString(row.key_type, 'key_type');
const keyOrigin = requiredString(row.key_origin, 'key_origin');
if (!isMemoryKeyType(keyType)) throw invalidColumn('key_type');
if (!isMemoryKeyOrigin(keyOrigin)) throw invalidColumn('key_origin');
const key = requiredNonEmptyString(row.key_text, 'key_text');
const normalizedKey = requiredNonEmptyString(row.normalized_key, 'normalized_key');
if (normalizeSearchTerm(key) !== normalizedKey) throw invalidColumn('normalized_key');
return {
key,
normalizedKey,
keyType,
keyOrigin,
};
}
function decodeSource(row: MemorySourceRow): MemoryItemSource {
return {
sessionId: requiredIdentifierString(row.session_id, 'session_id'),
runId: requiredIdentifierString(row.run_id, 'run_id'),
turnId: requiredIdentifierString(row.turn_id, 'turn_id'),
eventId: requiredIdentifierString(row.event_id, 'event_id'),
};
}
function decodeExtractionCursor(row: MemoryExtractionCursorRow): MemoryExtractionCursor {
return {
sessionId: requiredIdentifierString(row.session_id, 'session_id'),
processedOrdinal: requiredPositiveInteger(row.processed_ordinal, 'processed_ordinal'),
updatedAt: requiredNonNegativeInteger(row.updated_at, 'updated_at'),
};
}
function decodePendingExtractionFailure(
row: MemoryExtractionFailureRow,
): PendingMemoryExtractionFailure {
return {
sessionId: requiredIdentifierString(row.session_id, 'session_id'),
fromOrdinal: requiredPositiveInteger(row.from_ordinal, 'from_ordinal'),
throughOrdinal: requiredPositiveInteger(row.through_ordinal, 'through_ordinal'),
coverageHash: requiredHash(row.coverage_hash, 'coverage_hash'),
firstOperationId: requiredIdentifierString(row.first_operation_id, 'first_operation_id'),
firstTrigger: normalizeExtractionTrigger(row.first_trigger),
firstFailureClass: normalizeExtractionFailureClass(row.first_failure_class),
failedAt: requiredNonNegativeInteger(row.failed_at, 'failed_at'),
};
}
function decodeExtractionReceipt(row: MemoryExtractionReceiptRow): MemoryExtractionReceipt {
const operationId = requiredIdentifierString(row.operation_id, 'operation_id');
const sessionId = requiredIdentifierString(row.session_id, 'session_id');
requiredHash(row.request_hash, 'request_hash');
const committedAt = requiredNonNegativeInteger(row.committed_at, 'committed_at');
const encoded = requiredString(row.result_json, 'result_json');
if (encoded.length > MAX_OPERATION_RESULT_JSON_CODE_UNITS) {
throw new Error(`Memory extraction ${operationId} result JSON is too large`);
}
let value: unknown;
try {
value = JSON.parse(encoded);
} catch (error) {
throw new Error(`Invalid result JSON for Memory extraction ${operationId}`, { cause: error });
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`Invalid receipt for Memory extraction ${operationId}`);
}
const receipt = value as Record<string, unknown>;
if (
receipt.operationId !== operationId ||
receipt.sessionId !== sessionId ||
!['remembered', 'not_applicable', 'extracted', 'discarded'].includes(String(receipt.status)) ||
receipt.committedAt !== committedAt ||
!Array.isArray(receipt.requestedItems)
) {
throw new Error(`Invalid receipt for Memory extraction ${operationId}`);
}
const requestedItems = receipt.requestedItems.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw new Error(`Invalid requested Item in Memory extraction ${operationId}`);
}
const record = item as Record<string, unknown>;
return {
itemId: normalizeIdentifier(record.itemId, 'receipt itemId'),
content: requiredString(record.content, 'receipt content'),
};
});
if (requestedItems.length > MAX_MUTATIONS_PER_OPERATION) {
throw new Error(`Memory extraction ${operationId} has too many requested Items`);
}
if (
(receipt.status === 'remembered' && requestedItems.length === 0) ||
(receipt.status !== 'remembered' && requestedItems.length > 0)
) {
throw new Error(`Memory extraction ${operationId} has inconsistent requested Items`);
}
const noOpReason = normalizeExtractionNoOpReason(receipt.noOpReason);
if (noOpReason && receipt.status !== 'not_applicable') {
throw new Error(`Memory extraction ${operationId} has an invalid no-op reason`);
}
let discardedRange: MemoryExtractionReceipt['discardedRange'];
if (receipt.status === 'discarded') {
const value = receipt.discardedRange;
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`Memory extraction ${operationId} is missing its discarded range`);
}
const range = value as Record<string, unknown>;
discardedRange = {
fromOrdinal: requiredPositiveInteger(range.fromOrdinal, 'discarded fromOrdinal'),
throughOrdinal: requiredPositiveInteger(range.throughOrdinal, 'discarded throughOrdinal'),
coverageHash: requiredHash(range.coverageHash, 'discarded coverageHash'),
firstFailureClass: normalizeExtractionFailureClass(range.firstFailureClass),
finalFailureClass: normalizeExtractionFailureClass(range.finalFailureClass),
};
if (discardedRange.throughOrdinal < discardedRange.fromOrdinal) {
throw new Error(`Memory extraction ${operationId} has an invalid discarded range`);
}
} else if (receipt.discardedRange !== undefined) {
throw new Error(`Memory extraction ${operationId} has an unexpected discarded range`);
}
return {
operationId,
sessionId,
status: receipt.status as MemoryExtractionReceipt['status'],
requestedItems,
...(noOpReason ? { noOpReason } : {}),
...(discardedRange ? { discardedRange } : {}),
committedAt,
};
}
function decodeOperation(row: MemoryOperationRow): MemoryWriteOperationResult {
const operationId = requiredIdentifierString(row.operation_id, 'operation_id');
const operationType = requiredString(row.operation_type, 'operation_type');
if (!['create', 'update', 'archive', 'restore', 'batch'].includes(operationType)) {
throw invalidColumn('operation_type');
}
requiredHash(row.request_hash, 'request_hash');
const resultJson = requiredString(row.result_json, 'result_json');
if (resultJson.length > MAX_OPERATION_RESULT_JSON_CODE_UNITS) {
throw new Error(`Memory operation ${operationId} result JSON is too large`);
}
let results: unknown;
try {
results = JSON.parse(resultJson);
} catch (error) {
throw new Error(`Invalid result JSON for Memory operation ${operationId}`, { cause: error });
}
if (!Array.isArray(results))
throw new Error(`Invalid results for Memory operation ${operationId}`);
if (results.length > MAX_MUTATIONS_PER_OPERATION) {
throw new Error(
`Memory operation results accept at most ${MAX_MUTATIONS_PER_OPERATION} mutations`,
);
}
const decodedResults = results.map((result, index) => decodeMutationResult(result, index));
if (
operationType !== 'batch' &&
(decodedResults.length !== 1 || decodedResults[0]?.mutationType !== operationType)
) {
throw new Error(`Invalid results for Memory operation ${operationId}`);
}
for (const result of decodedResults) validateMutationResultOutcome(result);
return {
operationId,
operationType: operationType as MemoryWriteOperationResult['operationType'],
replayed: false,
committedAt: requiredNonNegativeInteger(row.committed_at, 'committed_at'),
results: decodedResults,
};
}
function decodeMutationResult(value: unknown, expectedIndex: number): MemoryMutationResult {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Invalid Memory mutation result');
}
const result = value as Record<string, unknown>;
const mutationIndex = requiredNonNegativeInteger(result.mutationIndex, 'mutationIndex');
const mutationType = requiredString(result.mutationType, 'mutationType');
const lifecycleState = requiredString(result.lifecycleState, 'lifecycleState');
const outcome = requiredString(result.outcome, 'outcome');
if (mutationIndex !== expectedIndex) throw invalidColumn('mutationIndex');
if (!['create', 'update', 'archive', 'restore'].includes(mutationType)) {
throw invalidColumn('mutationType');
}
if (!isMemoryLifecycleState(lifecycleState)) throw invalidColumn('lifecycleState');
if (!['created', 'updated', 'archived', 'restored', 'noop'].includes(outcome)) {
throw invalidColumn('outcome');
}
return {
mutationIndex,
mutationType: mutationType as MemoryMutationResult['mutationType'],
itemId: requiredIdentifierString(result.itemId, 'itemId'),
version: requiredPositiveInteger(result.version, 'version'),
lifecycleState,
outcome: outcome as MemoryMutationResult['outcome'],
};
}
function validateMutationResultOutcome(result: MemoryMutationResult): void {
const valid =
(result.mutationType === 'create' &&
result.outcome === 'created' &&
result.lifecycleState === 'active') ||
(result.mutationType === 'update' &&
(result.outcome === 'updated' || result.outcome === 'noop')) ||
(result.mutationType === 'archive' &&
result.outcome === 'archived' &&
result.lifecycleState === 'archived') ||
(result.mutationType === 'restore' &&
result.outcome === 'restored' &&
result.lifecycleState === 'active');
if (!valid) throw new Error('Invalid Memory mutation result outcome');
}
function requiredString(value: unknown, column: string): string {
if (typeof value !== 'string') throw invalidColumn(column);
return value;
}
function requiredNonEmptyString(value: unknown, column: string): string {
const result = requiredString(value, column);
if (result.length === 0) throw invalidColumn(column);
return result;
}
function requiredIdentifierString(value: unknown, column: string): string {
const result = requiredNonEmptyString(value, column);
try {
return normalizeIdentifier(result, column);
} catch {
throw invalidColumn(column);
}
}
function nullableIdentifierString(value: unknown, column: string): string | null {
return value === null ? null : requiredIdentifierString(value, column);
}
function requiredInteger(value: unknown, column: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value)) throw invalidColumn(column);
return value;
}
function requiredNonNegativeInteger(value: unknown, column: string): number {
const result = requiredInteger(value, column);
if (result < 0) throw invalidColumn(column);
return result;
}
function requiredPositiveInteger(value: unknown, column: string): number {
const result = requiredInteger(value, column);
if (result < 1) throw invalidColumn(column);
return result;
}
function nullableNonNegativeInteger(value: unknown, column: string): number | null {
return value === null ? null : requiredNonNegativeInteger(value, column);
}
function requiredHash(value: unknown, column: string): string {
const result = requiredString(value, column);
if (!/^[0-9a-f]{64}$/u.test(result)) throw invalidColumn(column);
return result;
}
function invalidColumn(column: string): Error {
return new Error(`Invalid long-term memory SQLite column ${column}`);
}
function assertChanged(changes: number | bigint, itemId: string): void {
if (Number(changes) !== 1) {
throw new MemoryItemStoreConflictError(
'version_conflict',
`Memory Item ${itemId} changed concurrently`,
itemId,
);
}
}
function hashText(value: string): string {
return createHash('sha256').update(value, 'utf8').digest('hex');
}
function hashCanonical(value: unknown): string {
return hashText(JSON.stringify(value));
}
function placeholders(count: number): string {
return Array.from({ length: count }, () => '?').join(', ');
}
/** Smallest Unicode string strictly greater than every string with this prefix. */
function prefixUpperBound(prefix: string): string | undefined {
const points = Array.from(prefix, (character) => character.codePointAt(0)!);
for (let index = points.length - 1; index >= 0; index -= 1) {
const point = points[index]!;
if (point < 0x10ffff) {
const successor = point === 0xd7ff ? 0xe000 : point + 1;
return String.fromCodePoint(...points.slice(0, index), successor);
}
}
return undefined;
}
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function preparePrivateDatabaseFiles(path: string): void {
secureFile(path, true, false);
for (const sidecar of databaseSidecars(path)) secureFile(sidecar, false, true);
}
function secureExistingDatabaseFiles(path: string): void {
secureFile(path, false, false);
for (const sidecar of databaseSidecars(path)) secureFile(sidecar, false, true);
}
function secureFile(path: string, create: boolean, allowUnlinked: boolean): void {
try {
if (lstatSync(path).isSymbolicLink()) {
throw new Error(`Long-term memory SQLite path must not be a symbolic link: ${path}`);
}
} catch (error) {
if (!(isNodeError(error) && error.code === 'ENOENT')) throw error;
}
const noFollow = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW;
let descriptor: number | undefined;
try {
descriptor = openSync(
path,
fsConstants.O_RDWR | noFollow | (create ? fsConstants.O_CREAT : 0),
0o600,
);
const status = fstatSync(descriptor);
if (!status.isFile()) {
throw new Error(`Long-term memory SQLite path is not a regular file: ${path}`);
}
if (status.nlink === 0 && allowUnlinked) return;
if (status.nlink !== 1) {
throw new Error(`Long-term memory SQLite path must not be hard-linked: ${path}`);
}
if (process.platform !== 'win32') fchmodSync(descriptor, 0o600);
} catch (error) {
if (!create && isNodeError(error) && error.code === 'ENOENT') return;
throw error;
} finally {
if (descriptor !== undefined) closeSync(descriptor);
}
}
function databaseSidecars(path: string): readonly string[] {
return [`${path}-wal`, `${path}-shm`, `${path}-journal`];
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return typeof error === 'object' && error !== null && 'code' in error;
}
function rollback(database: DatabaseSync): void {
try {
database.exec('ROLLBACK');
} catch {
// Preserve the write failure that triggered rollback.
}
}