@oscharko-dev/keiko-memory-vault 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/access.d.ts +12 -0
  3. package/dist/access.d.ts.map +1 -0
  4. package/dist/access.js +79 -0
  5. package/dist/cipher.d.ts +17 -0
  6. package/dist/cipher.d.ts.map +1 -0
  7. package/dist/cipher.js +95 -0
  8. package/dist/db.d.ts +12 -0
  9. package/dist/db.d.ts.map +1 -0
  10. package/dist/db.js +80 -0
  11. package/dist/edges.d.ts +8 -0
  12. package/dist/edges.d.ts.map +1 -0
  13. package/dist/edges.js +47 -0
  14. package/dist/embeddings.d.ts +10 -0
  15. package/dist/embeddings.d.ts.map +1 -0
  16. package/dist/embeddings.js +116 -0
  17. package/dist/errors.d.ts +14 -0
  18. package/dist/errors.d.ts.map +1 -0
  19. package/dist/errors.js +20 -0
  20. package/dist/index.d.ts +7 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +9 -0
  23. package/dist/memories.d.ts +11 -0
  24. package/dist/memories.d.ts.map +1 -0
  25. package/dist/memories.js +195 -0
  26. package/dist/migrate-encrypt.d.ts +4 -0
  27. package/dist/migrate-encrypt.d.ts.map +1 -0
  28. package/dist/migrate-encrypt.js +69 -0
  29. package/dist/paths.d.ts +6 -0
  30. package/dist/paths.d.ts.map +1 -0
  31. package/dist/paths.js +82 -0
  32. package/dist/redact-record.d.ts +8 -0
  33. package/dist/redact-record.d.ts.map +1 -0
  34. package/dist/redact-record.js +67 -0
  35. package/dist/schema.d.ts +5 -0
  36. package/dist/schema.d.ts.map +1 -0
  37. package/dist/schema.js +206 -0
  38. package/dist/scope-key.d.ts +4 -0
  39. package/dist/scope-key.d.ts.map +1 -0
  40. package/dist/scope-key.js +27 -0
  41. package/dist/serialize.d.ts +67 -0
  42. package/dist/serialize.d.ts.map +1 -0
  43. package/dist/serialize.js +213 -0
  44. package/dist/tombstones.d.ts +8 -0
  45. package/dist/tombstones.d.ts.map +1 -0
  46. package/dist/tombstones.js +57 -0
  47. package/dist/types.d.ts +127 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +4 -0
  50. package/dist/validate.d.ts +8 -0
  51. package/dist/validate.d.ts.map +1 -0
  52. package/dist/validate.js +71 -0
  53. package/dist/vault.d.ts +3 -0
  54. package/dist/vault.d.ts.map +1 -0
  55. package/dist/vault.js +301 -0
  56. package/dist/version.d.ts +2 -0
  57. package/dist/version.d.ts.map +1 -0
  58. package/dist/version.js +1 -0
  59. package/package.json +31 -0
@@ -0,0 +1,116 @@
1
+ // Embedding I/O. Vectors are encoded as a packed Float32 little-endian byte buffer so a vault
2
+ // written on one host opens byte-identical on another, regardless of platform endianness.
3
+ // Reading a host-endian `Float32Array` directly off the buffer would be silently wrong on a
4
+ // big-endian host (rare in practice, but the round-trip test catches the regression class).
5
+ //
6
+ // Storage is upsert by primary key (memory_id). Replacing an embedding is the common path: the
7
+ // retrieval layer (#210) reschedules a re-embedding when the model identity drifts.
8
+ import { MemoryStorageError } from "./errors.js";
9
+ // Hard upper bound on vector dimensions. The largest production embedding model in scope today
10
+ // (OpenAI text-embedding-3-large) is 3072 dims; 4096 gives one binary-doubling of headroom while
11
+ // capping the per-row BLOB at 16 KiB. Without this bound a caller could request a 2^31 element
12
+ // Float32 allocation (8 GiB) via the in-process API and crash the process — CWE-400.
13
+ export const MAX_EMBEDDING_DIMENSIONS = 4096;
14
+ export const ALLOWED_EMBEDDING_METRICS = [
15
+ "cosine",
16
+ "euclidean",
17
+ "dot",
18
+ ];
19
+ const UPSERT_SQL = `
20
+ INSERT INTO memory_embeddings (
21
+ memory_id, provider, model_id, model_revision, vector_dimensions,
22
+ vector_metric, vector, created_at
23
+ ) VALUES (?,?,?,?,?,?,?,?)
24
+ ON CONFLICT(memory_id) DO UPDATE SET
25
+ provider = excluded.provider,
26
+ model_id = excluded.model_id,
27
+ model_revision = excluded.model_revision,
28
+ vector_dimensions = excluded.vector_dimensions,
29
+ vector_metric = excluded.vector_metric,
30
+ vector = excluded.vector,
31
+ created_at = excluded.created_at
32
+ `;
33
+ const SELECT_SQL = "SELECT * FROM memory_embeddings WHERE memory_id = ?";
34
+ const BULK_SELECT_CHUNK_SIZE = 500;
35
+ const BYTES_PER_FLOAT32 = 4;
36
+ function encodeVectorLE(vector) {
37
+ const buf = Buffer.alloc(vector.length * BYTES_PER_FLOAT32);
38
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
39
+ for (let i = 0; i < vector.length; i += 1) {
40
+ // setFloat32(..., true) writes little-endian regardless of host endianness so the on-disk
41
+ // byte layout is identical across Linux/macOS/Windows on x86/arm and theoretical big-endian
42
+ // hosts.
43
+ view.setFloat32(i * BYTES_PER_FLOAT32, vector[i] ?? 0, true);
44
+ }
45
+ return buf;
46
+ }
47
+ function decodeVectorLE(bytes, dimensions) {
48
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
49
+ const out = new Float32Array(dimensions);
50
+ for (let i = 0; i < dimensions; i += 1) {
51
+ out[i] = view.getFloat32(i * BYTES_PER_FLOAT32, true);
52
+ }
53
+ return out;
54
+ }
55
+ export function upsertEmbeddingRow(db, memoryId, embedding, nowMs, cipher) {
56
+ // The vector is memory CONTENT (ADR-0035), so the packed LE bytes are sealed before they touch
57
+ // the BLOB column. vector_dimensions / vector_metric stay cleartext for retrieval-side dispatch.
58
+ const bytes = cipher.sealBytes(encodeVectorLE(embedding.vector));
59
+ db.prepare(UPSERT_SQL).run(memoryId, embedding.provider, embedding.modelId, embedding.modelRevision ?? null, embedding.vector.length, embedding.metric, bytes, nowMs);
60
+ }
61
+ function narrowMetric(raw) {
62
+ if (!ALLOWED_EMBEDDING_METRICS.includes(raw)) {
63
+ throw new MemoryStorageError("schema-mismatch", "Stored embedding metric is not in the allowed set.");
64
+ }
65
+ return raw;
66
+ }
67
+ function openVectorBytes(stored, cipher) {
68
+ // The BLOB is ALWAYS a sealed binary envelope here: the v1->v2 migration (run before any read in
69
+ // openMemoryDatabase) seals every embedding row, and there is no unambiguous plaintext-vs-sealed
70
+ // marker for raw Float32 bytes, so we never guess. openBytes throws loudly on a wrong key or a
71
+ // tampered/corrupt envelope rather than silently returning plaintext.
72
+ return cipher.openBytes(Buffer.from(stored));
73
+ }
74
+ export function getEmbeddingRow(db, memoryId, cipher) {
75
+ const row = db.prepare(SELECT_SQL).get(memoryId);
76
+ if (row === undefined)
77
+ return undefined;
78
+ return rowToEmbedding(row, cipher);
79
+ }
80
+ function rowToEmbedding(row, cipher) {
81
+ const plainVector = openVectorBytes(row.vector, cipher);
82
+ // Read-side soundness: a tampered DB row (or a future schema drift) must not silently land a
83
+ // bad metric string in the typed return shape, and the DECRYPTED BLOB length must match the
84
+ // declared dimension count so callers can rely on `vector.length === dimensions`.
85
+ const expectedBytes = row.vector_dimensions * BYTES_PER_FLOAT32;
86
+ if (plainVector.byteLength !== expectedBytes) {
87
+ throw new MemoryStorageError("schema-mismatch", "Stored embedding vector byte length does not match declared dimensions.");
88
+ }
89
+ const base = {
90
+ memoryId: row.memory_id,
91
+ provider: row.provider,
92
+ modelId: row.model_id,
93
+ dimensions: row.vector_dimensions,
94
+ metric: narrowMetric(row.vector_metric),
95
+ vector: decodeVectorLE(plainVector, row.vector_dimensions),
96
+ createdAt: row.created_at,
97
+ };
98
+ return row.model_revision === null ? base : { ...base, modelRevision: row.model_revision };
99
+ }
100
+ export function getEmbeddingRows(db, memoryIds, cipher) {
101
+ const out = new Map();
102
+ if (memoryIds.length === 0)
103
+ return out;
104
+ const uniqueIds = [...new Set(memoryIds)];
105
+ for (let i = 0; i < uniqueIds.length; i += BULK_SELECT_CHUNK_SIZE) {
106
+ const chunk = uniqueIds.slice(i, i + BULK_SELECT_CHUNK_SIZE);
107
+ const placeholders = chunk.map(() => "?").join(",");
108
+ const rows = db
109
+ .prepare(`SELECT * FROM memory_embeddings WHERE memory_id IN (${placeholders})`)
110
+ .all(...chunk);
111
+ for (const row of rows) {
112
+ out.set(row.memory_id, rowToEmbedding(row, cipher));
113
+ }
114
+ }
115
+ return out;
116
+ }
@@ -0,0 +1,14 @@
1
+ export type MemoryStorageErrorCode = "invalid-path" | "invalid-input" | "not-found" | "constraint-violation" | "schema-mismatch" | "internal";
2
+ export declare class MemoryStorageError extends Error {
3
+ readonly code: MemoryStorageErrorCode;
4
+ constructor(code: MemoryStorageErrorCode, message: string);
5
+ }
6
+ export interface MemoryStorageValidationFailure {
7
+ readonly path: readonly string[];
8
+ readonly message: string;
9
+ }
10
+ export declare class MemoryStorageValidationError extends MemoryStorageError {
11
+ readonly failures: readonly MemoryStorageValidationFailure[];
12
+ constructor(message: string, failures: readonly MemoryStorageValidationFailure[]);
13
+ }
14
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,sBAAsB,GAC9B,cAAc,GACd,eAAe,GACf,WAAW,GACX,sBAAsB,GACtB,iBAAiB,GACjB,UAAU,CAAC;AAEf,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,SAAgB,IAAI,EAAE,sBAAsB,CAAC;gBAE1B,IAAI,EAAE,sBAAsB,EAAE,OAAO,EAAE,MAAM;CAKjE;AAKD,MAAM,WAAW,8BAA8B;IAC7C,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,qBAAa,4BAA6B,SAAQ,kBAAkB;IAClE,SAAgB,QAAQ,EAAE,SAAS,8BAA8B,EAAE,CAAC;gBAEjD,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,8BAA8B,EAAE;CAKxF"}
package/dist/errors.js ADDED
@@ -0,0 +1,20 @@
1
+ // Typed error taxonomy for keiko-memory-vault. Stable string codes so callers in audit (#214),
2
+ // capture (#207), and the eventual UI (#211) can branch on `error.code` without parsing messages.
3
+ // Messages MUST NOT include raw paths, SQL fragments, or system error strings — those surface to
4
+ // downstream BFF envelopes that may log to disk.
5
+ export class MemoryStorageError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.name = "MemoryStorageError";
10
+ this.code = code;
11
+ }
12
+ }
13
+ export class MemoryStorageValidationError extends MemoryStorageError {
14
+ failures;
15
+ constructor(message, failures) {
16
+ super("invalid-input", message);
17
+ this.name = "MemoryStorageValidationError";
18
+ this.failures = failures;
19
+ }
20
+ }
@@ -0,0 +1,7 @@
1
+ export { KEIKO_MEMORY_VAULT_VERSION } from "./version.js";
2
+ export { createMemoryVault } from "./vault.js";
3
+ export { MemoryStorageError, MemoryStorageValidationError, type MemoryStorageErrorCode, type MemoryStorageValidationFailure, } from "./errors.js";
4
+ export { MEMORY_DB_FILENAME, MEMORY_DIR_NAME, DEFAULT_STATE_DIR, resolveMemoryDir, resolveMemoryDbPath, } from "./paths.js";
5
+ export { MEMORY_VAULT_SCHEMA_VERSION } from "./schema.js";
6
+ export type { DeleteMemoryOptions, ListMemoriesOptions, MemoryAccessStat, MemoryBatchDelete, MemoryBatchUpdate, MemoryDeleteResult, MemoryEmbeddingInput, MemoryEmbeddingMetric, MemoryEmbeddingRow, MemoryEvent, MemoryTombstone, MemoryUpdatePatch, MemoryVaultFactoryOptions, MemoryVaultStore, } from "./types.js";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,0BAA0B,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EACL,kBAAkB,EAClB,4BAA4B,EAC5B,KAAK,sBAAsB,EAC3B,KAAK,8BAA8B,GACpC,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAC1D,YAAY,EACV,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,kBAAkB,EAClB,oBAAoB,EACpB,qBAAqB,EACrB,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,iBAAiB,EACjB,yBAAyB,EACzB,gBAAgB,GACjB,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // Public surface of @oscharko-dev/keiko-memory-vault (Epic #204 child #206). Keeping this file
2
+ // the SOLE entry point prevents downstream packages from reaching into private modules
3
+ // (ADR-0019 trust rule 7). Subpath exports are intentionally absent; the package is small
4
+ // enough that a single barrel is the lowest-friction surface for #207-#214 consumers.
5
+ export { KEIKO_MEMORY_VAULT_VERSION } from "./version.js";
6
+ export { createMemoryVault } from "./vault.js";
7
+ export { MemoryStorageError, MemoryStorageValidationError, } from "./errors.js";
8
+ export { MEMORY_DB_FILENAME, MEMORY_DIR_NAME, DEFAULT_STATE_DIR, resolveMemoryDir, resolveMemoryDbPath, } from "./paths.js";
9
+ export { MEMORY_VAULT_SCHEMA_VERSION } from "./schema.js";
@@ -0,0 +1,11 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ import type { MemoryId, MemoryRecord, MemoryScope } from "@oscharko-dev/keiko-contracts/memory";
3
+ import type { ListMemoriesOptions } from "./types.js";
4
+ import type { MemoryContentCipher } from "./cipher.js";
5
+ export declare function insertMemoryRow(db: DatabaseSync, record: MemoryRecord, cipher: MemoryContentCipher): void;
6
+ export declare function getMemoryRow(db: DatabaseSync, id: MemoryId, cipher: MemoryContentCipher): MemoryRecord | undefined;
7
+ export declare function updateMemoryRow(db: DatabaseSync, record: MemoryRecord, cipher: MemoryContentCipher): void;
8
+ export declare function deleteMemoryRow(db: DatabaseSync, id: MemoryId): boolean;
9
+ export declare function listMemoriesRows(db: DatabaseSync, options: ListMemoriesOptions, nowMs: number, cipher: MemoryContentCipher): readonly MemoryRecord[];
10
+ export declare function listMemoriesByScopeRows(db: DatabaseSync, scope: MemoryScope, options: ListMemoriesOptions, nowMs: number, cipher: MemoryContentCipher): readonly MemoryRecord[];
11
+ //# sourceMappingURL=memories.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memories.d.ts","sourceRoot":"","sources":["../src/memories.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,WAAW,EAEZ,MAAM,sCAAsC,CAAC;AAG9C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAuFvD,wBAAgB,eAAe,CAC7B,EAAE,EAAE,YAAY,EAChB,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,mBAAmB,GAC1B,IAAI,CAEN;AAED,wBAAgB,YAAY,CAC1B,EAAE,EAAE,YAAY,EAChB,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,mBAAmB,GAC1B,YAAY,GAAG,SAAS,CAG1B;AAED,wBAAgB,eAAe,CAC7B,EAAE,EAAE,YAAY,EAChB,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,mBAAmB,GAC1B,IAAI,CAkCN;AAED,wBAAgB,eAAe,CAAC,EAAE,EAAE,YAAY,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAGvE;AAiED,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,YAAY,EAChB,OAAO,EAAE,mBAAmB,EAC5B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,mBAAmB,GAC1B,SAAS,YAAY,EAAE,CAkBzB;AAED,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,mBAAmB,EAC5B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,mBAAmB,GAC1B,SAAS,YAAY,EAAE,CAoBzB"}
@@ -0,0 +1,195 @@
1
+ // Prepared SQL for the memories table. Every parameter binds positionally; no template
2
+ // concatenation with caller data so SQL injection is structurally impossible at this layer.
3
+ // The validator gate sits in vault.ts, BEFORE these functions are called, so this module assumes
4
+ // inputs are already shape-valid and only owes the SQL.
5
+ import { memoryRecordToRow, rowToMemoryRecord } from "./serialize.js";
6
+ import { scopeCoordinateOf, scopeKindOf } from "./scope-key.js";
7
+ import { MemoryStorageError } from "./errors.js";
8
+ const INSERT_SQL = `
9
+ INSERT INTO memories (
10
+ id, schema_version, type, scope_kind, scope_coordinate, body, payload_json,
11
+ status, sensitivity, pinned, confidence, valid_from, valid_until, stale_reason,
12
+ tags_json, source_kind, source_conversation_id, source_workflow_run_id,
13
+ source_evidence_manifest_id, captured_at, capture_rationale, model_provider,
14
+ model_id, model_revision, retention_policy_key, retention_retain_until,
15
+ retention_notes, created_at, updated_at
16
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
17
+ `;
18
+ const SELECT_BY_ID_SQL = "SELECT * FROM memories WHERE id = ?";
19
+ const DELETE_SQL = "DELETE FROM memories WHERE id = ?";
20
+ // UPDATE rewrites every column from the resolved record so a partial patch can land without
21
+ // touching SQL per-field. The vault composes the merge in TypeScript (validator-safe) and hands
22
+ // us a final record; we just write it.
23
+ const UPDATE_SQL = `
24
+ UPDATE memories SET
25
+ type = ?,
26
+ body = ?,
27
+ payload_json = ?,
28
+ status = ?,
29
+ sensitivity = ?,
30
+ pinned = ?,
31
+ confidence = ?,
32
+ valid_from = ?,
33
+ valid_until = ?,
34
+ stale_reason = ?,
35
+ tags_json = ?,
36
+ source_kind = ?,
37
+ source_conversation_id = ?,
38
+ source_workflow_run_id = ?,
39
+ source_evidence_manifest_id = ?,
40
+ captured_at = ?,
41
+ capture_rationale = ?,
42
+ model_provider = ?,
43
+ model_id = ?,
44
+ model_revision = ?,
45
+ retention_policy_key = ?,
46
+ retention_retain_until = ?,
47
+ retention_notes = ?,
48
+ updated_at = ?
49
+ WHERE id = ?
50
+ `;
51
+ function bindValues(record, cipher) {
52
+ const r = memoryRecordToRow(record, cipher);
53
+ return [
54
+ r.id,
55
+ r.schema_version,
56
+ r.type,
57
+ r.scope_kind,
58
+ r.scope_coordinate,
59
+ r.body,
60
+ r.payload_json,
61
+ r.status,
62
+ r.sensitivity,
63
+ r.pinned,
64
+ r.confidence,
65
+ r.valid_from,
66
+ r.valid_until,
67
+ r.stale_reason,
68
+ r.tags_json,
69
+ r.source_kind,
70
+ r.source_conversation_id,
71
+ r.source_workflow_run_id,
72
+ r.source_evidence_manifest_id,
73
+ r.captured_at,
74
+ r.capture_rationale,
75
+ r.model_provider,
76
+ r.model_id,
77
+ r.model_revision,
78
+ r.retention_policy_key,
79
+ r.retention_retain_until,
80
+ r.retention_notes,
81
+ r.created_at,
82
+ r.updated_at,
83
+ ];
84
+ }
85
+ export function insertMemoryRow(db, record, cipher) {
86
+ db.prepare(INSERT_SQL).run(...bindValues(record, cipher));
87
+ }
88
+ export function getMemoryRow(db, id, cipher) {
89
+ const row = db.prepare(SELECT_BY_ID_SQL).get(id);
90
+ return row === undefined ? undefined : rowToMemoryRecord(row, cipher);
91
+ }
92
+ export function updateMemoryRow(db, record, cipher) {
93
+ const r = memoryRecordToRow(record, cipher);
94
+ const info = db
95
+ .prepare(UPDATE_SQL)
96
+ .run(r.type, r.body, r.payload_json, r.status, r.sensitivity, r.pinned, r.confidence, r.valid_from, r.valid_until, r.stale_reason, r.tags_json, r.source_kind, r.source_conversation_id, r.source_workflow_run_id, r.source_evidence_manifest_id, r.captured_at, r.capture_rationale, r.model_provider, r.model_id, r.model_revision, r.retention_policy_key, r.retention_retain_until, r.retention_notes, r.updated_at, r.id);
97
+ if (info.changes === 0) {
98
+ throw new MemoryStorageError("not-found", "Memory not found.");
99
+ }
100
+ }
101
+ export function deleteMemoryRow(db, id) {
102
+ const info = db.prepare(DELETE_SQL).run(id);
103
+ return info.changes > 0;
104
+ }
105
+ function buildEnumClause(column, values) {
106
+ if (values === undefined || values.length === 0)
107
+ return undefined;
108
+ const placeholders = values.map(() => "?").join(",");
109
+ return { clauses: [`${column} IN (${placeholders})`], params: values };
110
+ }
111
+ function buildExpiryClause(includeExpired, nowMs) {
112
+ if (includeExpired === true)
113
+ return undefined;
114
+ return { clauses: ["(valid_until IS NULL OR valid_until > ?)"], params: [nowMs] };
115
+ }
116
+ function buildPinnedClause(pinned) {
117
+ if (pinned === undefined)
118
+ return undefined;
119
+ return { clauses: ["pinned = ?"], params: [pinned ? 1 : 0] };
120
+ }
121
+ const ORDER_COLUMN_MAP = {
122
+ createdAt: "created_at",
123
+ updatedAt: "updated_at",
124
+ validFrom: "valid_from",
125
+ };
126
+ function resolveOrderBy(options) {
127
+ const column = ORDER_COLUMN_MAP[options.orderBy ?? "createdAt"];
128
+ const dir = options.orderDir === "asc" ? "ASC" : "DESC";
129
+ return { column, dir };
130
+ }
131
+ function buildListSql(where, options) {
132
+ const { column, dir } = resolveOrderBy(options);
133
+ let sql = "SELECT * FROM memories";
134
+ if (where.length > 0) {
135
+ sql += ` WHERE ${where.join(" AND ")}`;
136
+ }
137
+ sql += ` ORDER BY ${column} ${dir}`;
138
+ if (typeof options.limit === "number") {
139
+ sql += " LIMIT ?";
140
+ if (typeof options.offset === "number") {
141
+ sql += " OFFSET ?";
142
+ }
143
+ }
144
+ return sql;
145
+ }
146
+ function appendPagingParams(params, options) {
147
+ if (typeof options.limit !== "number")
148
+ return;
149
+ params.push(options.limit);
150
+ if (typeof options.offset === "number") {
151
+ params.push(options.offset);
152
+ }
153
+ }
154
+ export function listMemoriesRows(db, options, nowMs, cipher) {
155
+ const params = [];
156
+ const where = [];
157
+ for (const built of [
158
+ buildEnumClause("type", options.type),
159
+ buildEnumClause("status", options.status),
160
+ buildPinnedClause(options.pinned),
161
+ buildExpiryClause(options.includeExpired, nowMs),
162
+ ]) {
163
+ if (built === undefined)
164
+ continue;
165
+ where.push(...built.clauses);
166
+ params.push(...built.params);
167
+ }
168
+ appendPagingParams(params, options);
169
+ const rows = db
170
+ .prepare(buildListSql(where, options))
171
+ .all(...params);
172
+ return rows.map((row) => rowToMemoryRecord(row, cipher));
173
+ }
174
+ export function listMemoriesByScopeRows(db, scope, options, nowMs, cipher) {
175
+ const kind = scopeKindOf(scope);
176
+ const coordinate = scopeCoordinateOf(scope);
177
+ const params = [kind, coordinate];
178
+ const where = ["scope_kind = ?", "scope_coordinate = ?"];
179
+ for (const built of [
180
+ buildEnumClause("type", options.type),
181
+ buildEnumClause("status", options.status),
182
+ buildPinnedClause(options.pinned),
183
+ buildExpiryClause(options.includeExpired, nowMs),
184
+ ]) {
185
+ if (built === undefined)
186
+ continue;
187
+ where.push(...built.clauses);
188
+ params.push(...built.params);
189
+ }
190
+ appendPagingParams(params, options);
191
+ const rows = db
192
+ .prepare(buildListSql(where, options))
193
+ .all(...params);
194
+ return rows.map((row) => rowToMemoryRecord(row, cipher));
195
+ }
@@ -0,0 +1,4 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ import type { MemoryContentCipher } from "./cipher.js";
3
+ export declare function encryptExistingContent(db: DatabaseSync, cipher: MemoryContentCipher): void;
4
+ //# sourceMappingURL=migrate-encrypt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrate-encrypt.d.ts","sourceRoot":"","sources":["../src/migrate-encrypt.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAyEvD,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,mBAAmB,GAAG,IAAI,CAK1F"}
@@ -0,0 +1,69 @@
1
+ // Eager v1→v2 encryption sweep (ADR-0035). v1 DBs stored content columns in plaintext; this sweep
2
+ // re-encrypts every existing plaintext content value in place, inside the migration transaction, so
3
+ // the upgrade is atomic. It is IDEMPOTENT: the user_version gate in runMigrations ensures this sweep
4
+ // runs at most once (when user_version < ENCRYPTION_VERSION), so a half-migrated DB (interrupted
5
+ // before COMMIT) re-runs cleanly because user_version stayed < 2. Each string column is sealed
6
+ // unless it is already a genuine envelope (see isAlreadySealed: a try-open, NOT a "kv1." prefix
7
+ // sniff — a plaintext value that merely starts with "kv1." must still be encrypted, not skipped).
8
+ // That keeps the sweep idempotent for a genuinely sealed value without the prefix false-positive.
9
+ // (table, column) pairs whose TEXT values are sealed as kv1 string envelopes.
10
+ const STRING_TARGETS = [
11
+ { table: "memories", column: "body" },
12
+ { table: "memories", column: "payload_json" },
13
+ { table: "memories", column: "tags_json" },
14
+ { table: "memories", column: "capture_rationale" },
15
+ { table: "memories", column: "stale_reason" },
16
+ { table: "memory_edges", column: "provenance_summary" },
17
+ { table: "memory_tombstones", column: "reason" },
18
+ ];
19
+ // A value is "already sealed" only if it actually AES-GCM-opens. The "kv1." prefix alone is
20
+ // ambiguous — a legacy plaintext body can literally start with "kv1." (see KV1_PREFIX_BODY in the
21
+ // tests); a prefix sniff would wrongly skip it, leave it as plaintext, and crash on the next read.
22
+ // A genuine envelope opens cleanly; a "kv1."-prefixed plaintext throws. This keeps the sweep
23
+ // idempotent (a genuinely sealed value — e.g. from an interrupted or mixed migration — is skipped
24
+ // rather than double-sealed) without the prefix false-positive.
25
+ function isAlreadySealed(cipher, value) {
26
+ if (!cipher.isSealed(value))
27
+ return false;
28
+ try {
29
+ cipher.openString(value);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ function sweepStringColumn(db, table, column, cipher) {
37
+ // Identifiers come from the hard-coded STRING_TARGETS list, never from caller data, so the
38
+ // interpolation is not an injection surface (the same rule schema.ts relies on for PRAGMA).
39
+ const rows = db
40
+ .prepare(`SELECT rowid AS rowid, ${column} AS value FROM ${table}`)
41
+ .all();
42
+ const update = db.prepare(`UPDATE ${table} SET ${column} = ? WHERE rowid = ?`);
43
+ for (const row of rows) {
44
+ if (row.value === null || isAlreadySealed(cipher, row.value))
45
+ continue;
46
+ update.run(cipher.sealString(row.value), row.rowid);
47
+ }
48
+ }
49
+ // Unlike the string columns, the embedding BLOB has NO unambiguous "already sealed" marker: a
50
+ // legacy plaintext Float32-LE vector can legitimately start with byte 0x01, so a magic-byte sniff
51
+ // would wrongly skip it and leave plaintext that then fails to decrypt. Correctness instead rests on
52
+ // the user_version gate in runMigrations: this sweep runs exactly once, in the same transaction that
53
+ // sets user_version = 2, at which point EVERY embedding row is still v1 plaintext. So we seal all of
54
+ // them unconditionally. An interrupted run rolls back (user_version stays < 2) and re-seals cleanly.
55
+ function sweepEmbeddingVectors(db, cipher) {
56
+ const rows = db
57
+ .prepare("SELECT memory_id, vector FROM memory_embeddings")
58
+ .all();
59
+ const update = db.prepare("UPDATE memory_embeddings SET vector = ? WHERE memory_id = ?");
60
+ for (const row of rows) {
61
+ update.run(cipher.sealBytes(Buffer.from(row.vector)), row.memory_id);
62
+ }
63
+ }
64
+ export function encryptExistingContent(db, cipher) {
65
+ for (const target of STRING_TARGETS) {
66
+ sweepStringColumn(db, target.table, target.column, cipher);
67
+ }
68
+ sweepEmbeddingVectors(db, cipher);
69
+ }
@@ -0,0 +1,6 @@
1
+ export declare const MEMORY_DB_FILENAME = "keiko-memory.db";
2
+ export declare const MEMORY_DIR_NAME = "memory";
3
+ export declare const DEFAULT_STATE_DIR = ".keiko";
4
+ export declare function resolveMemoryDir(explicit: string | undefined, env: Readonly<Record<string, string | undefined>>): string;
5
+ export declare function resolveMemoryDbPath(explicit: string | undefined, env: Readonly<Record<string, string | undefined>>): string;
6
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAiBA,eAAO,MAAM,kBAAkB,oBAAoB,CAAC;AACpD,eAAO,MAAM,eAAe,WAAW,CAAC;AACxC,eAAO,MAAM,iBAAiB,WAAW,CAAC;AAsD1C,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,GAChD,MAAM,CAgBR;AAED,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,GAChD,MAAM,CAER"}
package/dist/paths.js ADDED
@@ -0,0 +1,82 @@
1
+ // Path resolution for the local memory vault. The precedence ladder mirrors ADR-0013 D4 (UI DB):
2
+ // 1. explicit `memoryDir` factory option
3
+ // 2. $KEIKO_MEMORY_DIR
4
+ // 3. $KEIKO_STATE_DIR/memory/ (shared keiko local-state convention)
5
+ // 4. homedir()/.keiko/memory/ (fallback)
6
+ //
7
+ // Every configured path is forced to be absolute, outside the current working directory except for
8
+ // the gitignored .keiko/ runtime root, not a symlink, and not under a symlinked ancestor. These
9
+ // guards prevent a stray relative path from silently storing a customer's enterprise memory inside
10
+ // their project tree (where it would be committed by accident) or being aimed at a symlink that
11
+ // points back into a sensitive location.
12
+ import { homedir } from "node:os";
13
+ import { existsSync, lstatSync } from "node:fs";
14
+ import { dirname, isAbsolute, join, normalize, parse, resolve, sep } from "node:path";
15
+ import { MemoryStorageError } from "./errors.js";
16
+ export const MEMORY_DB_FILENAME = "keiko-memory.db";
17
+ export const MEMORY_DIR_NAME = "memory";
18
+ export const DEFAULT_STATE_DIR = ".keiko";
19
+ function invalidPath(message) {
20
+ return new MemoryStorageError("invalid-path", message);
21
+ }
22
+ function isInsideCwd(candidate) {
23
+ const cwd = resolve(process.cwd());
24
+ const r = resolve(candidate);
25
+ return r === cwd || r.startsWith(`${cwd}${sep}`);
26
+ }
27
+ function isInsideRuntimeStateRoot(candidate) {
28
+ const runtimeRoot = resolve(process.cwd(), DEFAULT_STATE_DIR);
29
+ const r = resolve(candidate);
30
+ return r === runtimeRoot || r.startsWith(`${runtimeRoot}${sep}`);
31
+ }
32
+ function hasSymlinkAncestor(path) {
33
+ let current = dirname(path);
34
+ const root = parse(current).root;
35
+ while (current !== root) {
36
+ if (existsSync(current)) {
37
+ return lstatSync(current).isSymbolicLink();
38
+ }
39
+ current = dirname(current);
40
+ }
41
+ return false;
42
+ }
43
+ function guard(path, label) {
44
+ // NUL bypass (CWE-22): path.normalize() leaves NUL bytes intact, so a string like
45
+ // "/safe/path\0/etc/passwd" satisfies the CWD-containment check but open(2) truncates
46
+ // at the NUL and lands on a completely different file. Reject NUL bytes first so the
47
+ // downstream guards reason about the same string the kernel will syscall on.
48
+ if (path.includes("\0")) {
49
+ throw invalidPath(`${label} must not contain NUL bytes.`);
50
+ }
51
+ if (!isAbsolute(path)) {
52
+ throw invalidPath(`${label} must be absolute.`);
53
+ }
54
+ const normalized = normalize(path);
55
+ if (isInsideCwd(normalized) && !isInsideRuntimeStateRoot(normalized)) {
56
+ throw invalidPath(`${label} must not be inside the current workspace.`);
57
+ }
58
+ if (existsSync(normalized) && lstatSync(normalized).isSymbolicLink()) {
59
+ throw invalidPath(`${label} must not be a symlink.`);
60
+ }
61
+ if (hasSymlinkAncestor(normalized)) {
62
+ throw invalidPath(`${label} must not be inside a symlinked directory.`);
63
+ }
64
+ return normalized;
65
+ }
66
+ export function resolveMemoryDir(explicit, env) {
67
+ if (explicit !== undefined && explicit.length > 0) {
68
+ return guard(explicit, "Memory vault directory");
69
+ }
70
+ const fromEnv = env.KEIKO_MEMORY_DIR;
71
+ if (fromEnv !== undefined && fromEnv.length > 0) {
72
+ return guard(fromEnv, "KEIKO_MEMORY_DIR");
73
+ }
74
+ const stateDir = env.KEIKO_STATE_DIR;
75
+ if (stateDir !== undefined && stateDir.length > 0) {
76
+ return guard(join(guard(stateDir, "KEIKO_STATE_DIR"), MEMORY_DIR_NAME), "KEIKO_STATE_DIR/memory");
77
+ }
78
+ return join(homedir(), DEFAULT_STATE_DIR, MEMORY_DIR_NAME);
79
+ }
80
+ export function resolveMemoryDbPath(explicit, env) {
81
+ return join(resolveMemoryDir(explicit, env), MEMORY_DB_FILENAME);
82
+ }
@@ -0,0 +1,8 @@
1
+ import type { MemoryEdge, MemoryRecord } from "@oscharko-dev/keiko-contracts/memory";
2
+ import type { MemoryTombstone } from "./types.js";
3
+ type Redactor = (input: string) => string;
4
+ export declare function redactMemoryRecord(record: MemoryRecord, redact: Redactor): MemoryRecord;
5
+ export declare function redactMemoryEdge(edge: MemoryEdge, redact: Redactor): MemoryEdge;
6
+ export declare function redactTombstone(t: MemoryTombstone, redact: Redactor): MemoryTombstone;
7
+ export {};
8
+ //# sourceMappingURL=redact-record.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redact-record.d.ts","sourceRoot":"","sources":["../src/redact-record.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EACV,UAAU,EACV,YAAY,EAGb,MAAM,sCAAsC,CAAC;AAC9C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD,KAAK,QAAQ,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;AA0B1C,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,QAAQ,GAAG,YAAY,CAoBvF;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,GAAG,UAAU,CAG/E;AAED,wBAAgB,eAAe,CAAC,CAAC,EAAE,eAAe,EAAE,MAAM,EAAE,QAAQ,GAAG,eAAe,CAGrF"}
@@ -0,0 +1,67 @@
1
+ // Boundary redaction for memory records. Applies the caller-supplied redactString to every
2
+ // free-text field that may carry secret-shaped content. Keys redacted:
3
+ // - body
4
+ // - tags[]
5
+ // - structured payload (string-list items / key-value entries)
6
+ // - provenance.captureRationale
7
+ // - staleReason
8
+ // - retentionHint.notes
9
+ // - edge.provenanceSummary
10
+ // - tombstone.reason
11
+ //
12
+ // This is defence-in-depth, NOT a substitute for the capture-policy gate in #207. The contract
13
+ // validator runs FIRST (so we never persist a structurally bad record); redaction runs SECOND on
14
+ // the validator-approved record so the only string transformation between contract-valid input
15
+ // and SQL bind is this redact step.
16
+ function redactPayload(payload, redact) {
17
+ switch (payload.kind) {
18
+ case "string-list":
19
+ return { kind: "string-list", items: payload.items.map(redact) };
20
+ case "key-value":
21
+ return {
22
+ kind: "key-value",
23
+ entries: payload.entries.map((e) => ({ key: e.key, value: redact(e.value) })),
24
+ };
25
+ }
26
+ }
27
+ function redactRetentionHint(hint, redact) {
28
+ const base = {
29
+ policyKey: hint.policyKey,
30
+ };
31
+ if (hint.retainUntil !== undefined)
32
+ base.retainUntil = hint.retainUntil;
33
+ if (hint.notes !== undefined)
34
+ base.notes = redact(hint.notes);
35
+ return base;
36
+ }
37
+ export function redactMemoryRecord(record, redact) {
38
+ const prov = record.provenance;
39
+ const newProvenance = {
40
+ ...prov,
41
+ ...(prov.captureRationale !== undefined
42
+ ? { captureRationale: redact(prov.captureRationale) }
43
+ : {}),
44
+ };
45
+ const out = {
46
+ ...record,
47
+ body: redact(record.body),
48
+ tags: record.tags.map(redact),
49
+ provenance: newProvenance,
50
+ ...(record.payload !== undefined ? { payload: redactPayload(record.payload, redact) } : {}),
51
+ ...(record.staleReason !== undefined ? { staleReason: redact(record.staleReason) } : {}),
52
+ ...(record.retentionHint !== undefined
53
+ ? { retentionHint: redactRetentionHint(record.retentionHint, redact) }
54
+ : {}),
55
+ };
56
+ return out;
57
+ }
58
+ export function redactMemoryEdge(edge, redact) {
59
+ if (edge.provenanceSummary === undefined)
60
+ return edge;
61
+ return { ...edge, provenanceSummary: redact(edge.provenanceSummary) };
62
+ }
63
+ export function redactTombstone(t, redact) {
64
+ if (t.reason === undefined)
65
+ return t;
66
+ return { ...t, reason: redact(t.reason) };
67
+ }
@@ -0,0 +1,5 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ import type { MemoryContentCipher } from "./cipher.js";
3
+ export declare const MEMORY_VAULT_SCHEMA_VERSION = 6;
4
+ export declare function runMigrations(db: DatabaseSync, cipher: MemoryContentCipher): void;
5
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAoBvD,eAAO,MAAM,2BAA2B,IAAI,CAAC;AAmJ7C,wBAAgB,aAAa,CAAC,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,mBAAmB,GAAG,IAAI,CAmCjF"}