@cairnkeep/cli 2.0.0 → 2.1.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.
- package/CHANGELOG.md +21 -0
- package/docs/storage.md +25 -0
- package/mcp-memory-server/dist/index.js +245 -8
- package/mcp-memory-server/package.json +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
3
3
|
All notable user-facing changes are documented here. This project follows
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [2.1.0] - 2026-07-16
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Add `memory_apply_reviewed` for idempotent application of externally reviewed
|
|
11
|
+
memory revisions with durable provenance and collision-safe history.
|
|
12
|
+
- Add `memory_invalidate_reviewed` for revision-matched retraction and durable
|
|
13
|
+
tombstones that prevent delayed writes from resurrecting invalid memory.
|
|
14
|
+
- Add end-to-end checks for replay, supersession, manual changes, retraction,
|
|
15
|
+
tombstones, and reserved provenance records.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Make history snapshot keys unique when multiple revisions occur in the same
|
|
20
|
+
millisecond.
|
|
21
|
+
|
|
22
|
+
The existing memory tools, database locations, and remote HTTP routing contract
|
|
23
|
+
are unchanged. Existing clients remain compatible and reviewed-memory records
|
|
24
|
+
are created only when an integration explicitly invokes the new tools.
|
|
25
|
+
|
|
6
26
|
## [2.0.0] - 2026-07-15
|
|
7
27
|
|
|
8
28
|
### Breaking
|
|
@@ -69,6 +89,7 @@ stored memories.
|
|
|
69
89
|
- Preserve executable permissions and Bash 3.2 portability on macOS.
|
|
70
90
|
- Add backup-first uninstall and SQLite-safe memory export/import guidance.
|
|
71
91
|
|
|
92
|
+
[2.1.0]: https://github.com/cairnkeep/cairnkeep/compare/v2.0.0...v2.1.0
|
|
72
93
|
[2.0.0]: https://github.com/cairnkeep/cairnkeep/compare/v1.1.3...v2.0.0
|
|
73
94
|
[1.1.3]: https://github.com/cairnkeep/cairnkeep/compare/v1.1.2...v1.1.3
|
|
74
95
|
[1.1.2]: https://github.com/cairnkeep/cairnkeep/compare/v1.1.1...v1.1.2
|
package/docs/storage.md
CHANGED
|
@@ -112,6 +112,31 @@ sqlite3 /path/to/project/.agentfs/project.db \
|
|
|
112
112
|
Treat every database and export archive as sensitive. They may contain source
|
|
113
113
|
paths, decisions, incident details, and other project context.
|
|
114
114
|
|
|
115
|
+
## Reviewed-memory integration
|
|
116
|
+
|
|
117
|
+
External review systems should not compose `memory_write`, `memory_read`, and
|
|
118
|
+
`memory_delete` into their own promotion protocol. That sequence is not atomic
|
|
119
|
+
and can delete a newer manual revision during lifecycle invalidation.
|
|
120
|
+
|
|
121
|
+
Cairnkeep exposes two additive MCP tools for reviewed integrations:
|
|
122
|
+
|
|
123
|
+
- `memory_apply_reviewed` takes `scope`, `review_id`, `key`, and `value`. It is
|
|
124
|
+
idempotent for the same review and content, preserves a displaced live value
|
|
125
|
+
in memory history, and rejects reuse of the review ID with different content.
|
|
126
|
+
- `memory_invalidate_reviewed` takes `scope`, `review_id`, `key`, and an optional
|
|
127
|
+
`reason`. It removes the live value only when it still matches that reviewed
|
|
128
|
+
revision. If apply has not arrived yet, it records a tombstone so a delayed
|
|
129
|
+
apply cannot resurrect invalid memory.
|
|
130
|
+
|
|
131
|
+
Provenance and tombstones use a hidden reserved namespace in the same scoped
|
|
132
|
+
database. Generic memory writes and deletes cannot modify those records. Review
|
|
133
|
+
IDs must be stable, non-secret idempotency identifiers; evidence payloads and
|
|
134
|
+
credentials do not belong in them.
|
|
135
|
+
|
|
136
|
+
These tools are for an explicit, trusted integration. Cairnkeep itself does not
|
|
137
|
+
approve candidates, discover an evidence service, or promote memory
|
|
138
|
+
automatically. Existing clients and the original memory tools remain unchanged.
|
|
139
|
+
|
|
115
140
|
## Automating trusted personal clients
|
|
116
141
|
|
|
117
142
|
Keep fleet-specific values in a private dotfiles repository or secret manager,
|
|
@@ -29,6 +29,8 @@ const anythingllmSyncScript = process.env.CAIRN_ANYTHINGLLM_SYNC_SCRIPT
|
|
|
29
29
|
? resolve(expandHome(process.env.CAIRN_ANYTHINGLLM_SYNC_SCRIPT))
|
|
30
30
|
: join(infraRoot, "examples", "anythingllm", "sync_to_anythingllm.py");
|
|
31
31
|
const HISTORY_NAMESPACE = "__history__";
|
|
32
|
+
const REVIEWED_NAMESPACE = "__reviewed__";
|
|
33
|
+
const REVIEW_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
32
34
|
const PROJECT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
33
35
|
const WORKSPACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
34
36
|
function expandHome(value) {
|
|
@@ -180,17 +182,52 @@ async function openScope(scope, create, options = {}) {
|
|
|
180
182
|
function isHistoryKey(key) {
|
|
181
183
|
return key === HISTORY_NAMESPACE || key.startsWith(`${HISTORY_NAMESPACE}/`);
|
|
182
184
|
}
|
|
185
|
+
function isReviewedKey(key) {
|
|
186
|
+
return key === REVIEWED_NAMESPACE || key.startsWith(`${REVIEWED_NAMESPACE}/`);
|
|
187
|
+
}
|
|
188
|
+
function assertWritableMemoryKey(key) {
|
|
189
|
+
if (isHistoryKey(key) || isReviewedKey(key)) {
|
|
190
|
+
throw new Error(`Keys under ${HISTORY_NAMESPACE}/ and ${REVIEWED_NAMESPACE}/ are reserved.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
183
193
|
function historyPrefix(baseKey) {
|
|
184
194
|
return `${HISTORY_NAMESPACE}/${baseKey}/`;
|
|
185
195
|
}
|
|
186
196
|
function historySnapshotKey(baseKey, timestamp) {
|
|
187
|
-
return `${historyPrefix(baseKey)}${timestamp}`;
|
|
197
|
+
return `${historyPrefix(baseKey)}${timestamp}-${randomUUID()}`;
|
|
198
|
+
}
|
|
199
|
+
const reviewedRecordSchema = z.object({
|
|
200
|
+
schema_version: z.literal(1),
|
|
201
|
+
review_id: z.string(),
|
|
202
|
+
key: z.string(),
|
|
203
|
+
value_hash: z.string(),
|
|
204
|
+
state: z.enum(["active", "superseded", "invalidated"]),
|
|
205
|
+
applied_at: z.string(),
|
|
206
|
+
superseded_at: z.string().optional(),
|
|
207
|
+
superseded_by: z.string().optional(),
|
|
208
|
+
invalidated_at: z.string().optional(),
|
|
209
|
+
invalidation_reason: z.string().optional(),
|
|
210
|
+
});
|
|
211
|
+
function reviewedRecordKey(reviewId) {
|
|
212
|
+
return `${REVIEWED_NAMESPACE}/${reviewId}`;
|
|
213
|
+
}
|
|
214
|
+
function parseReviewedRecord(value) {
|
|
215
|
+
const parsed = reviewedRecordSchema.safeParse(value);
|
|
216
|
+
if (!parsed.success) {
|
|
217
|
+
throw new Error("Stored reviewed-memory provenance is invalid; refusing to continue.");
|
|
218
|
+
}
|
|
219
|
+
return parsed.data;
|
|
220
|
+
}
|
|
221
|
+
async function inImmediateScopeTransaction(agent, operation) {
|
|
222
|
+
const transaction = agent.getDatabase().transaction(operation);
|
|
223
|
+
const immediate = transaction.immediate;
|
|
224
|
+
return immediate();
|
|
188
225
|
}
|
|
189
226
|
function visibleEntries(entries, includeHistory) {
|
|
190
227
|
if (includeHistory) {
|
|
191
228
|
return entries;
|
|
192
229
|
}
|
|
193
|
-
return entries.filter((entry) => !isHistoryKey(entry.key));
|
|
230
|
+
return entries.filter((entry) => !isHistoryKey(entry.key) && !isReviewedKey(entry.key));
|
|
194
231
|
}
|
|
195
232
|
async function listEntries(scope, prefix = "", options = {}) {
|
|
196
233
|
const agent = await openScope(scope, false, options);
|
|
@@ -759,9 +796,7 @@ function createMemoryServer(context = {}) {
|
|
|
759
796
|
promote_to: z.string().optional(),
|
|
760
797
|
}),
|
|
761
798
|
}, async ({ scope, key, value, promote_to }) => {
|
|
762
|
-
|
|
763
|
-
throw new Error(`Keys under ${HISTORY_NAMESPACE}/ are reserved for memory history.`);
|
|
764
|
-
}
|
|
799
|
+
assertWritableMemoryKey(key);
|
|
765
800
|
const targets = promote_to && promote_to !== scope ? [scope, promote_to] : [scope];
|
|
766
801
|
// Collision-safe: in the unified store, writes from different repos/machines
|
|
767
802
|
// can share a key. If a different value already exists, preserve the old one
|
|
@@ -824,6 +859,9 @@ function createMemoryServer(context = {}) {
|
|
|
824
859
|
key: z.string().min(1),
|
|
825
860
|
}),
|
|
826
861
|
}, async ({ scope, key }) => {
|
|
862
|
+
if (isReviewedKey(key)) {
|
|
863
|
+
throw new Error(`Keys under ${REVIEWED_NAMESPACE}/ are reserved for reviewed-memory provenance.`);
|
|
864
|
+
}
|
|
827
865
|
const agent = await openScope(scope, false, scopeOptions);
|
|
828
866
|
if (agent) {
|
|
829
867
|
try {
|
|
@@ -887,9 +925,7 @@ function createMemoryServer(context = {}) {
|
|
|
887
925
|
reason: z.string().optional(),
|
|
888
926
|
}),
|
|
889
927
|
}, async ({ scope, key, value, reason }) => {
|
|
890
|
-
|
|
891
|
-
throw new Error(`Keys under ${HISTORY_NAMESPACE}/ are reserved for memory history.`);
|
|
892
|
-
}
|
|
928
|
+
assertWritableMemoryKey(key);
|
|
893
929
|
const agent = await openScope(scope, true, scopeOptions);
|
|
894
930
|
if (!agent) {
|
|
895
931
|
throw new Error(`Unable to open scope ${scope}.`);
|
|
@@ -929,6 +965,207 @@ function createMemoryServer(context = {}) {
|
|
|
929
965
|
await agent.close();
|
|
930
966
|
}
|
|
931
967
|
});
|
|
968
|
+
server.registerTool("memory_apply_reviewed", {
|
|
969
|
+
description: "Idempotently apply a human-reviewed memory revision with durable provenance.",
|
|
970
|
+
inputSchema: z.object({
|
|
971
|
+
scope: z.string(),
|
|
972
|
+
review_id: z.string().regex(REVIEW_ID_PATTERN),
|
|
973
|
+
key: z.string().min(1),
|
|
974
|
+
value: z.string(),
|
|
975
|
+
}),
|
|
976
|
+
}, async ({ scope, review_id, key, value }) => {
|
|
977
|
+
assertWritableMemoryKey(key);
|
|
978
|
+
const agent = await openScope(scope, true, scopeOptions);
|
|
979
|
+
if (!agent) {
|
|
980
|
+
throw new Error(`Unable to open scope ${scope}.`);
|
|
981
|
+
}
|
|
982
|
+
try {
|
|
983
|
+
const payload = await inImmediateScopeTransaction(agent, async () => {
|
|
984
|
+
const recordKey = reviewedRecordKey(review_id);
|
|
985
|
+
const valueHash = hashText(value);
|
|
986
|
+
const stored = await agent.kv.get(recordKey);
|
|
987
|
+
if (stored !== undefined) {
|
|
988
|
+
const existing = parseReviewedRecord(stored);
|
|
989
|
+
if (existing.key !== key || existing.value_hash !== valueHash) {
|
|
990
|
+
throw new Error(`Review id ${review_id} was already used with different content.`);
|
|
991
|
+
}
|
|
992
|
+
if (existing.state !== "active") {
|
|
993
|
+
throw new Error(`Review id ${review_id} is ${existing.state} and cannot be reapplied.`);
|
|
994
|
+
}
|
|
995
|
+
return {
|
|
996
|
+
ok: true,
|
|
997
|
+
scope,
|
|
998
|
+
review_id,
|
|
999
|
+
key,
|
|
1000
|
+
applied: false,
|
|
1001
|
+
idempotent: true,
|
|
1002
|
+
snapshot_key: null,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
const appliedAt = new Date().toISOString();
|
|
1006
|
+
const provenance = await agent.kv.list(`${REVIEWED_NAMESPACE}/`);
|
|
1007
|
+
for (const entry of provenance) {
|
|
1008
|
+
const previousRecord = parseReviewedRecord(entry.value);
|
|
1009
|
+
if (previousRecord.state === "active" && previousRecord.key === key) {
|
|
1010
|
+
await agent.kv.set(entry.key, {
|
|
1011
|
+
...previousRecord,
|
|
1012
|
+
state: "superseded",
|
|
1013
|
+
superseded_at: appliedAt,
|
|
1014
|
+
superseded_by: review_id,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
const previous = await agent.kv.get(key);
|
|
1019
|
+
const previousValue = previous === undefined ? undefined : normalizeValue(previous);
|
|
1020
|
+
let snapshotKey = null;
|
|
1021
|
+
if (previousValue !== undefined && previousValue !== value) {
|
|
1022
|
+
snapshotKey = historySnapshotKey(key, appliedAt);
|
|
1023
|
+
await agent.kv.set(snapshotKey, {
|
|
1024
|
+
value: previousValue,
|
|
1025
|
+
superseded_at: appliedAt,
|
|
1026
|
+
superseded_reason: `reviewed memory ${review_id}`,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
await agent.kv.set(key, value);
|
|
1030
|
+
await agent.kv.set(recordKey, {
|
|
1031
|
+
schema_version: 1,
|
|
1032
|
+
review_id,
|
|
1033
|
+
key,
|
|
1034
|
+
value_hash: valueHash,
|
|
1035
|
+
state: "active",
|
|
1036
|
+
applied_at: appliedAt,
|
|
1037
|
+
});
|
|
1038
|
+
return {
|
|
1039
|
+
ok: true,
|
|
1040
|
+
scope,
|
|
1041
|
+
review_id,
|
|
1042
|
+
key,
|
|
1043
|
+
applied: true,
|
|
1044
|
+
idempotent: false,
|
|
1045
|
+
snapshot_key: snapshotKey,
|
|
1046
|
+
};
|
|
1047
|
+
});
|
|
1048
|
+
return {
|
|
1049
|
+
content: [{ type: "text", text: asToolText(payload) }],
|
|
1050
|
+
structuredContent: payload,
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
finally {
|
|
1054
|
+
await agent.close();
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
server.registerTool("memory_invalidate_reviewed", {
|
|
1058
|
+
description: "Invalidate a reviewed revision, removing it only when the live value still matches.",
|
|
1059
|
+
inputSchema: z.object({
|
|
1060
|
+
scope: z.string(),
|
|
1061
|
+
review_id: z.string().regex(REVIEW_ID_PATTERN),
|
|
1062
|
+
key: z.string().min(1),
|
|
1063
|
+
reason: z.string().max(512).optional(),
|
|
1064
|
+
}),
|
|
1065
|
+
}, async ({ scope, review_id, key, reason }) => {
|
|
1066
|
+
assertWritableMemoryKey(key);
|
|
1067
|
+
const agent = await openScope(scope, true, scopeOptions);
|
|
1068
|
+
if (!agent) {
|
|
1069
|
+
throw new Error(`Unable to open scope ${scope}.`);
|
|
1070
|
+
}
|
|
1071
|
+
try {
|
|
1072
|
+
const payload = await inImmediateScopeTransaction(agent, async () => {
|
|
1073
|
+
const recordKey = reviewedRecordKey(review_id);
|
|
1074
|
+
const stored = await agent.kv.get(recordKey);
|
|
1075
|
+
if (stored === undefined) {
|
|
1076
|
+
const invalidatedAt = new Date().toISOString();
|
|
1077
|
+
await agent.kv.set(recordKey, {
|
|
1078
|
+
schema_version: 1,
|
|
1079
|
+
review_id,
|
|
1080
|
+
key,
|
|
1081
|
+
value_hash: "",
|
|
1082
|
+
state: "invalidated",
|
|
1083
|
+
applied_at: invalidatedAt,
|
|
1084
|
+
invalidated_at: invalidatedAt,
|
|
1085
|
+
...(reason ? { invalidation_reason: reason } : {}),
|
|
1086
|
+
});
|
|
1087
|
+
return {
|
|
1088
|
+
ok: true,
|
|
1089
|
+
scope,
|
|
1090
|
+
review_id,
|
|
1091
|
+
key,
|
|
1092
|
+
invalidated: true,
|
|
1093
|
+
idempotent: false,
|
|
1094
|
+
missing: true,
|
|
1095
|
+
removed: false,
|
|
1096
|
+
current_changed: false,
|
|
1097
|
+
snapshot_key: null,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
const record = parseReviewedRecord(stored);
|
|
1101
|
+
if (record.key !== key) {
|
|
1102
|
+
throw new Error(`Review id ${review_id} belongs to a different memory key.`);
|
|
1103
|
+
}
|
|
1104
|
+
if (record.state === "invalidated") {
|
|
1105
|
+
return {
|
|
1106
|
+
ok: true,
|
|
1107
|
+
scope,
|
|
1108
|
+
review_id,
|
|
1109
|
+
key: record.key,
|
|
1110
|
+
invalidated: false,
|
|
1111
|
+
idempotent: true,
|
|
1112
|
+
missing: record.value_hash === "",
|
|
1113
|
+
removed: false,
|
|
1114
|
+
current_changed: false,
|
|
1115
|
+
snapshot_key: null,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
const invalidatedAt = new Date().toISOString();
|
|
1119
|
+
let currentChanged = false;
|
|
1120
|
+
let removed = false;
|
|
1121
|
+
let snapshotKey = null;
|
|
1122
|
+
if (record.state === "active") {
|
|
1123
|
+
const current = await agent.kv.get(record.key);
|
|
1124
|
+
if (current !== undefined) {
|
|
1125
|
+
const currentValue = normalizeValue(current);
|
|
1126
|
+
if (hashText(currentValue) === record.value_hash) {
|
|
1127
|
+
snapshotKey = historySnapshotKey(record.key, invalidatedAt);
|
|
1128
|
+
await agent.kv.set(snapshotKey, {
|
|
1129
|
+
value: currentValue,
|
|
1130
|
+
superseded_at: invalidatedAt,
|
|
1131
|
+
superseded_reason: reason ?? `reviewed memory ${review_id} invalidated`,
|
|
1132
|
+
});
|
|
1133
|
+
await agent.kv.delete(record.key);
|
|
1134
|
+
removed = true;
|
|
1135
|
+
}
|
|
1136
|
+
else {
|
|
1137
|
+
currentChanged = true;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
await agent.kv.set(recordKey, {
|
|
1142
|
+
...record,
|
|
1143
|
+
state: "invalidated",
|
|
1144
|
+
invalidated_at: invalidatedAt,
|
|
1145
|
+
...(reason ? { invalidation_reason: reason } : {}),
|
|
1146
|
+
});
|
|
1147
|
+
return {
|
|
1148
|
+
ok: true,
|
|
1149
|
+
scope,
|
|
1150
|
+
review_id,
|
|
1151
|
+
key: record.key,
|
|
1152
|
+
invalidated: true,
|
|
1153
|
+
idempotent: false,
|
|
1154
|
+
missing: false,
|
|
1155
|
+
removed,
|
|
1156
|
+
current_changed: currentChanged,
|
|
1157
|
+
snapshot_key: snapshotKey,
|
|
1158
|
+
};
|
|
1159
|
+
});
|
|
1160
|
+
return {
|
|
1161
|
+
content: [{ type: "text", text: asToolText(payload) }],
|
|
1162
|
+
structuredContent: payload,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
finally {
|
|
1166
|
+
await agent.close();
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
932
1169
|
server.registerTool("memory_history", {
|
|
933
1170
|
description: "Read prior versions of a memory entry from the hidden history namespace.",
|
|
934
1171
|
inputSchema: z.object({
|
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
"check:explore-cache": "node scripts/smoke-explore-cache.mjs",
|
|
23
23
|
"check:explore-crossref": "node scripts/smoke-explore-crossref.mjs",
|
|
24
24
|
"check:route-guard": "node scripts/smoke-route-guard.mjs",
|
|
25
|
+
"check:reviewed-memory": "node scripts/smoke-reviewed-memory.mjs",
|
|
25
26
|
"test": "npm run test:smoke",
|
|
26
|
-
"test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard",
|
|
27
|
+
"test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory",
|
|
27
28
|
"test:search": "npm run build && npm run check:search",
|
|
28
29
|
"test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
|
|
29
30
|
"test:all": "npm run test:memory"
|