@remembrance-ai/opencode-plugin 0.1.77 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -32
- package/opencode.json +2 -2
- package/package.json +1 -1
- package/scripts/feedback-disposition.mjs +376 -0
- package/scripts/hook-core.mjs +6145 -685
- package/scripts/pending-action-payload.mjs +1044 -0
- package/scripts/private-learning-hook-policy.mjs +2515 -0
- package/servers/remembrance-mcp.mjs +62249 -6956
- package/skills/remembrancer/SKILL.md +219 -105
- package/skills/remembrancer/references/identity-preferences-topology.md +41 -0
- package/skills/remembrancer/references/remembrance-payloads.md +94 -17
- package/skills/remembrancer/references/remembrance-setup.md +186 -74
- package/src/index.mjs +131 -40
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
// The one storage accessor for a pending action's retained payload, kept on
|
|
2
|
+
// this machine so a later demand, after compaction or in a fresh process, can
|
|
3
|
+
// hand it back verbatim: for a remembrance follow-up, the exact
|
|
4
|
+
// next_step.submit_remembrance_payload a submit_feedback call returned; for a
|
|
5
|
+
// durable preference (capture_next_action), the exact record_preference
|
|
6
|
+
// arguments a capture_learning preference route returned. The hook core seals
|
|
7
|
+
// it when it records the debt and opens it when a demand shows it; the
|
|
8
|
+
// bundled MCP server's local get_pending_action_payload opens it for the
|
|
9
|
+
// agent that owes it. Node built-ins only; no network, no process access.
|
|
10
|
+
//
|
|
11
|
+
// On disk: owner decision 2 (Task 13) approved encrypted local retention with
|
|
12
|
+
// the plugin's existing local encryption, no new prompts, existing records
|
|
13
|
+
// readable and keys never switched; it set no expiry. A remembrance_followup
|
|
14
|
+
// or capture_next_action record whose payload is kept lives, until the
|
|
15
|
+
// action is resolved (completed, declined or withdrawn), in the durable
|
|
16
|
+
// per-user state directory:
|
|
17
|
+
// <state>/remembrance/private-learning-lifecycle/retained/<ledger>.obligations/<32 hex>.json
|
|
18
|
+
// (<state> is XDG_STATE_HOME or ~/.local/state; mode 0600, directories 0700,
|
|
19
|
+
// whole record at most 12 KiB). An earlier build kept it in the temporary
|
|
20
|
+
// usage directory, <usage dir>/<ledger>.obligations/<32 hex>.json, which the
|
|
21
|
+
// operating system may empty; both are read, and the hook moves such a
|
|
22
|
+
// record to the durable directory on its next read. Age never removes it.
|
|
23
|
+
// The record keeps the payload only as
|
|
24
|
+
// local_resupply_envelope: { version "completion-resupply-envelope-v1",
|
|
25
|
+
// algorithm "aes-256-gcm", key_source "os_keychain" | "mode_0600_file",
|
|
26
|
+
// nonce (12 bytes), ciphertext, auth_tag (16 bytes), base64url }
|
|
27
|
+
// sealed with the private-learning outbox key named by key_source, over the
|
|
28
|
+
// UTF-8 JSON as returned, authenticated with the obligation id, which names
|
|
29
|
+
// the kind, and the payload digest: one kind's envelope never opens as the
|
|
30
|
+
// other's. A payload is kept whole or not at all; one that was not kept says
|
|
31
|
+
// why in local_resupply_payload_omitted ("too_large" or "key_unavailable").
|
|
32
|
+
// A follow-up an earlier build kept in plaintext (local_resupply_payload)
|
|
33
|
+
// stays readable here and is never rewritten; preference arguments were never
|
|
34
|
+
// kept in plaintext, and a plaintext copy beside one is never returned.
|
|
35
|
+
import {
|
|
36
|
+
createCipheriv,
|
|
37
|
+
createDecipheriv,
|
|
38
|
+
createHash,
|
|
39
|
+
randomBytes,
|
|
40
|
+
} from "node:crypto";
|
|
41
|
+
import {
|
|
42
|
+
closeSync,
|
|
43
|
+
constants,
|
|
44
|
+
fstatSync,
|
|
45
|
+
lstatSync,
|
|
46
|
+
openSync,
|
|
47
|
+
readSync,
|
|
48
|
+
readdirSync,
|
|
49
|
+
} from "node:fs";
|
|
50
|
+
import { homedir, tmpdir, userInfo } from "node:os";
|
|
51
|
+
import { join } from "node:path";
|
|
52
|
+
|
|
53
|
+
export const RESUPPLY_ENVELOPE_VERSION = "completion-resupply-envelope-v1";
|
|
54
|
+
const OBLIGATION_VERSION = "completion-obligation-v1";
|
|
55
|
+
// The hook core's COMPLETION_OBLIGATION_MAX_BYTES.
|
|
56
|
+
const OBLIGATION_MAX_BYTES = 12 * 1024;
|
|
57
|
+
const LEDGER_ENTRY_LIMIT = 256;
|
|
58
|
+
const KEY_SOURCES = new Set(["os_keychain", "mode_0600_file"]);
|
|
59
|
+
// The kinds whose record can keep its payload sealed.
|
|
60
|
+
const RETAINED_KINDS = new Set(["remembrance_followup", "capture_next_action"]);
|
|
61
|
+
const HEX16 = /^[a-f0-9]{16}$/u;
|
|
62
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/u;
|
|
63
|
+
|
|
64
|
+
class ResupplyError extends Error {
|
|
65
|
+
constructor(code) {
|
|
66
|
+
super(`Retained payload unavailable: ${code}.`);
|
|
67
|
+
this.name = "ResupplyError";
|
|
68
|
+
this.code = code;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The hook core's payload digest: sorted-key JSON without the attestation
|
|
73
|
+
// flag an agent adds when it submits.
|
|
74
|
+
export function resupplyPayloadDigest(payload) {
|
|
75
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const normalized = JSON.parse(JSON.stringify(payload));
|
|
80
|
+
delete normalized.verified_attestation;
|
|
81
|
+
return `sha256:${createHash("sha256")
|
|
82
|
+
.update(JSON.stringify(sortKeys(normalized)), "utf8")
|
|
83
|
+
.digest("hex")}`;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sortKeys(value) {
|
|
90
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
91
|
+
if (value && typeof value === "object") {
|
|
92
|
+
return Object.keys(value)
|
|
93
|
+
.sort()
|
|
94
|
+
.reduce((result, key) => {
|
|
95
|
+
result[key] = sortKeys(value[key]);
|
|
96
|
+
return result;
|
|
97
|
+
}, {});
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function envelopeAad(obligation) {
|
|
103
|
+
return Buffer.from(
|
|
104
|
+
JSON.stringify({
|
|
105
|
+
version: RESUPPLY_ENVELOPE_VERSION,
|
|
106
|
+
obligation_id: obligation.id,
|
|
107
|
+
payload_digest: obligation.payload_digest,
|
|
108
|
+
}),
|
|
109
|
+
"utf8",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// `key` is { key: 32-byte Buffer, source }.
|
|
114
|
+
export function sealResupplyPayload(obligation, payload, key) {
|
|
115
|
+
const nonce = randomBytes(12);
|
|
116
|
+
const cipher = createCipheriv("aes-256-gcm", key.key, nonce);
|
|
117
|
+
cipher.setAAD(envelopeAad(obligation));
|
|
118
|
+
const ciphertext = Buffer.concat([
|
|
119
|
+
cipher.update(JSON.stringify(payload), "utf8"),
|
|
120
|
+
cipher.final(),
|
|
121
|
+
]);
|
|
122
|
+
return {
|
|
123
|
+
version: RESUPPLY_ENVELOPE_VERSION,
|
|
124
|
+
algorithm: "aes-256-gcm",
|
|
125
|
+
key_source: key.source,
|
|
126
|
+
nonce: nonce.toString("base64url"),
|
|
127
|
+
ciphertext: ciphertext.toString("base64url"),
|
|
128
|
+
auth_tag: cipher.getAuthTag().toString("base64url"),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function normalizeResupplyEnvelope(value) {
|
|
133
|
+
if (
|
|
134
|
+
!value ||
|
|
135
|
+
typeof value !== "object" ||
|
|
136
|
+
Array.isArray(value) ||
|
|
137
|
+
value.version !== RESUPPLY_ENVELOPE_VERSION ||
|
|
138
|
+
value.algorithm !== "aes-256-gcm" ||
|
|
139
|
+
!KEY_SOURCES.has(value.key_source) ||
|
|
140
|
+
!base64urlOfLength(value.nonce, 12) ||
|
|
141
|
+
!base64urlOfLength(value.auth_tag, 16) ||
|
|
142
|
+
typeof value.ciphertext !== "string" ||
|
|
143
|
+
!BASE64URL.test(value.ciphertext)
|
|
144
|
+
) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
version: value.version,
|
|
149
|
+
algorithm: value.algorithm,
|
|
150
|
+
key_source: value.key_source,
|
|
151
|
+
nonce: value.nonce,
|
|
152
|
+
ciphertext: value.ciphertext,
|
|
153
|
+
auth_tag: value.auth_tag,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function base64urlOfLength(value, bytes) {
|
|
158
|
+
return (
|
|
159
|
+
typeof value === "string" &&
|
|
160
|
+
BASE64URL.test(value) &&
|
|
161
|
+
Buffer.from(value, "base64url").length === bytes
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The plaintext's byte length, which GCM keeps: no key needed.
|
|
166
|
+
export function resupplyEnvelopeBytes(envelope) {
|
|
167
|
+
return Buffer.from(envelope.ciphertext, "base64url").length;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// `key` is the 32-byte Buffer of envelope.key_source. Returns the payload
|
|
171
|
+
// exactly as sealed, or throws a ResupplyError.
|
|
172
|
+
export function openResupplyEnvelope(obligation, key) {
|
|
173
|
+
const envelope = normalizeResupplyEnvelope(
|
|
174
|
+
obligation?.local_resupply_envelope,
|
|
175
|
+
);
|
|
176
|
+
if (!envelope) throw new ResupplyError("envelope_invalid");
|
|
177
|
+
let text;
|
|
178
|
+
try {
|
|
179
|
+
const decipher = createDecipheriv(
|
|
180
|
+
"aes-256-gcm",
|
|
181
|
+
key,
|
|
182
|
+
Buffer.from(envelope.nonce, "base64url"),
|
|
183
|
+
);
|
|
184
|
+
decipher.setAAD(envelopeAad(obligation));
|
|
185
|
+
decipher.setAuthTag(Buffer.from(envelope.auth_tag, "base64url"));
|
|
186
|
+
text = Buffer.concat([
|
|
187
|
+
decipher.update(Buffer.from(envelope.ciphertext, "base64url")),
|
|
188
|
+
decipher.final(),
|
|
189
|
+
]).toString("utf8");
|
|
190
|
+
} catch {
|
|
191
|
+
throw new ResupplyError("decryption_failed");
|
|
192
|
+
}
|
|
193
|
+
let payload;
|
|
194
|
+
try {
|
|
195
|
+
payload = JSON.parse(text);
|
|
196
|
+
} catch {
|
|
197
|
+
throw new ResupplyError("decryption_failed");
|
|
198
|
+
}
|
|
199
|
+
if (
|
|
200
|
+
resupplyPayloadDigest(payload) !== obligation.payload_digest ||
|
|
201
|
+
!RETAINED_KINDS.has(obligation.kind) ||
|
|
202
|
+
obligation.id !== `${obligation.kind}:${obligation.payload_digest}`
|
|
203
|
+
) {
|
|
204
|
+
throw new ResupplyError("digest_mismatch");
|
|
205
|
+
}
|
|
206
|
+
return payload;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function usageDirectory(env) {
|
|
210
|
+
return env?.REMEMBRANCE_USAGE_DIR
|
|
211
|
+
? String(env.REMEMBRANCE_USAGE_DIR)
|
|
212
|
+
: join(tmpdir(), `remembrance-usage-${localUsageOwnerId()}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The per-user state root the private-learning outbox and its lifecycle use.
|
|
216
|
+
function stateRoot(env, homeDirectory = homedir()) {
|
|
217
|
+
const configured = String(env?.XDG_STATE_HOME ?? "").trim();
|
|
218
|
+
return configured || join(homeDirectory, ".local", "state");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Where an unresolved action's retained payload lives: the durable ledgers,
|
|
222
|
+
// beside the private-learning obligations, one <ledger>.obligations
|
|
223
|
+
// directory per ledger, as in the usage directory.
|
|
224
|
+
export function retainedLedgerRoot(env, homeDirectory = homedir()) {
|
|
225
|
+
return join(
|
|
226
|
+
stateRoot(env, homeDirectory),
|
|
227
|
+
"remembrance",
|
|
228
|
+
"private-learning-lifecycle",
|
|
229
|
+
"retained",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The durable roots of retained records, private-learning-lifecycle and its
|
|
234
|
+
// retained directory, are used only while each is absent or a real directory
|
|
235
|
+
// of this user that no other user can write: a symlink there is refused,
|
|
236
|
+
// never followed. The hook's writer and readers, get_pending_action_payload
|
|
237
|
+
// and the inventory below apply this same rule, so none of them uses a root
|
|
238
|
+
// another refuses. A record the hook cannot keep there stays in its
|
|
239
|
+
// temporary ledger. With `retained: false` only the lifecycle directory is
|
|
240
|
+
// checked, for the private-learning hints the hook keeps in it.
|
|
241
|
+
export function retainedRootsUsable(
|
|
242
|
+
env,
|
|
243
|
+
homeDirectory = homedir(),
|
|
244
|
+
{ retained = true } = {},
|
|
245
|
+
) {
|
|
246
|
+
const lifecycle = join(
|
|
247
|
+
stateRoot(env, homeDirectory),
|
|
248
|
+
"remembrance",
|
|
249
|
+
"private-learning-lifecycle",
|
|
250
|
+
);
|
|
251
|
+
for (const path of retained
|
|
252
|
+
? [lifecycle, join(lifecycle, "retained")]
|
|
253
|
+
: [lifecycle]) {
|
|
254
|
+
try {
|
|
255
|
+
assertPrivateDirectory(path);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return error?.code === "ENOENT";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function currentUserId() {
|
|
264
|
+
try {
|
|
265
|
+
const uid = userInfo().uid;
|
|
266
|
+
if (Number.isSafeInteger(uid) && uid >= 0) return uid;
|
|
267
|
+
} catch {
|
|
268
|
+
// Non-POSIX runtimes use the same home hash as the hook core.
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function localUsageOwnerId() {
|
|
274
|
+
const uid = currentUserId();
|
|
275
|
+
if (uid !== null) return `uid-${uid}`;
|
|
276
|
+
return `home-${createHash("sha256")
|
|
277
|
+
.update(homedir())
|
|
278
|
+
.digest("hex")
|
|
279
|
+
.slice(0, 16)}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function assertOwned(metadata) {
|
|
283
|
+
const uid = currentUserId();
|
|
284
|
+
if (uid !== null && metadata.uid !== uid) {
|
|
285
|
+
throw new Error("Local state is not owned by the current user.");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function assertPrivateDirectory(path) {
|
|
290
|
+
const metadata = lstatSync(path);
|
|
291
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
292
|
+
throw new Error("Local state is not a regular directory.");
|
|
293
|
+
}
|
|
294
|
+
assertOwned(metadata);
|
|
295
|
+
if (currentUserId() !== null && (metadata.mode & 0o022) !== 0) {
|
|
296
|
+
throw new Error("Local state directory is writable by another user.");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// A file that is not a regular, private record of this user's, within its
|
|
301
|
+
// bound, cannot be one Remembrance wrote: it is refused unread, as unsafe.
|
|
302
|
+
// A refusal for its owner or mode alone says so (`access`): such a file can
|
|
303
|
+
// be a record whose owner or mode changed after it was written.
|
|
304
|
+
const UNSAFE_RECORD = "REMEMBRANCE_UNSAFE_RECORD";
|
|
305
|
+
const unsafeRecord = (message, access = false) =>
|
|
306
|
+
Object.assign(new Error(message), { code: UNSAFE_RECORD, access });
|
|
307
|
+
|
|
308
|
+
function assertPrivateRecord(metadata, maxBytes = OBLIGATION_MAX_BYTES) {
|
|
309
|
+
if (
|
|
310
|
+
metadata.isSymbolicLink() ||
|
|
311
|
+
!metadata.isFile() ||
|
|
312
|
+
metadata.size > maxBytes
|
|
313
|
+
) {
|
|
314
|
+
throw unsafeRecord("Local state record is unsafe.");
|
|
315
|
+
}
|
|
316
|
+
const uid = currentUserId();
|
|
317
|
+
if (uid !== null && metadata.uid !== uid) {
|
|
318
|
+
throw unsafeRecord("Local state is not owned by the current user.", true);
|
|
319
|
+
}
|
|
320
|
+
if (uid !== null && (metadata.mode & 0o077) !== 0) {
|
|
321
|
+
throw unsafeRecord("Local state record permissions are not private.", true);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function readRecord(path) {
|
|
326
|
+
return JSON.parse(readRecordText(path, OBLIGATION_MAX_BYTES));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// One regular, private, user-owned file of at most `maxBytes`, never through
|
|
330
|
+
// a symlink, read in bounded chunks.
|
|
331
|
+
function readRecordText(path, maxBytes) {
|
|
332
|
+
const before = lstatSync(path);
|
|
333
|
+
assertPrivateRecord(before, maxBytes);
|
|
334
|
+
const descriptor = openSync(
|
|
335
|
+
path,
|
|
336
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
|
337
|
+
);
|
|
338
|
+
try {
|
|
339
|
+
const opened = fstatSync(descriptor);
|
|
340
|
+
assertPrivateRecord(opened, maxBytes);
|
|
341
|
+
if (before.dev !== opened.dev || before.ino !== opened.ino) {
|
|
342
|
+
throw new Error("Local state record changed while opening.");
|
|
343
|
+
}
|
|
344
|
+
const chunks = [];
|
|
345
|
+
let total = 0;
|
|
346
|
+
while (total <= maxBytes) {
|
|
347
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
|
|
348
|
+
const read = readSync(descriptor, chunk, 0, chunk.length, null);
|
|
349
|
+
if (read === 0) break;
|
|
350
|
+
chunks.push(chunk.subarray(0, read));
|
|
351
|
+
total += read;
|
|
352
|
+
}
|
|
353
|
+
if (total > maxBytes) {
|
|
354
|
+
throw new Error("Local state record exceeds its size limit.");
|
|
355
|
+
}
|
|
356
|
+
return Buffer.concat(chunks, total).toString("utf8");
|
|
357
|
+
} finally {
|
|
358
|
+
closeSync(descriptor);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// What a retrieval says to do next, by kind and outcome; a retrieved payload's
|
|
363
|
+
// guidance also says who earned the action, as far as the record says.
|
|
364
|
+
const GUIDANCE = {
|
|
365
|
+
remembrance_followup: {
|
|
366
|
+
retrieved: {
|
|
367
|
+
first_hand:
|
|
368
|
+
"Nothing was sent. Call submit_remembrance with exactly this payload, unchanged, to complete the pending action.",
|
|
369
|
+
second_hand:
|
|
370
|
+
"Nothing was sent. A subagent earned this action and left it unpaid when it exited: submit it unchanged with submit_remembrance only if it is reusable, otherwise decline it with decline_pending_action.",
|
|
371
|
+
subagent_identity_unavailable:
|
|
372
|
+
"Nothing was sent. The host did not say whether you or a subagent earned this action: submit it unchanged with submit_remembrance only if it is reusable, otherwise decline it with decline_pending_action.",
|
|
373
|
+
pre_agent_keying:
|
|
374
|
+
"Nothing was sent. An earlier client recorded this action for the whole session, so a subagent may have earned it: submit it unchanged with submit_remembrance only if it is reusable, otherwise decline it with decline_pending_action.",
|
|
375
|
+
},
|
|
376
|
+
envelope_invalid:
|
|
377
|
+
"The retained payload is malformed and is not returned. Submit it only if you still hold it verbatim, otherwise decline the action.",
|
|
378
|
+
unavailable:
|
|
379
|
+
"The payload is retained, encrypted, but the local private-learning key cannot be read now. Retry after it is available (for example once the login keychain is unlocked); otherwise submit it only if you still hold it verbatim, or decline the action.",
|
|
380
|
+
refused:
|
|
381
|
+
"The retained payload does not match its recorded digest and is not returned. Submit it only if you still hold it verbatim, otherwise decline the action.",
|
|
382
|
+
not_retained:
|
|
383
|
+
"This action's payload was never kept on this machine. Submit it only if you still hold it verbatim, otherwise decline the action.",
|
|
384
|
+
},
|
|
385
|
+
capture_next_action: {
|
|
386
|
+
retrieved: {
|
|
387
|
+
first_hand:
|
|
388
|
+
"Nothing was recorded or applied. Call record_preference with exactly these arguments, unchanged, to complete the pending action.",
|
|
389
|
+
second_hand:
|
|
390
|
+
"Nothing was recorded or applied. A subagent captured this preference and left it unrecorded when it exited: call record_preference with exactly these arguments only if the user stated this preference, otherwise decline it with decline_pending_action.",
|
|
391
|
+
subagent_identity_unavailable:
|
|
392
|
+
"Nothing was recorded or applied. The host did not say whether you or a subagent captured this preference: call record_preference with exactly these arguments only if the user stated this preference, otherwise decline it with decline_pending_action.",
|
|
393
|
+
pre_agent_keying:
|
|
394
|
+
"Nothing was recorded or applied. An earlier client recorded this action for the whole session, so a subagent may have captured it: call record_preference with exactly these arguments only if the user stated this preference, otherwise decline it with decline_pending_action.",
|
|
395
|
+
},
|
|
396
|
+
envelope_invalid:
|
|
397
|
+
"The retained arguments are malformed and are not returned. Call record_preference only with arguments you still hold verbatim, otherwise decline the action.",
|
|
398
|
+
unavailable:
|
|
399
|
+
"The arguments are retained, encrypted, but the local private-learning key cannot be read now. Retry after it is available (for example once the login keychain is unlocked); otherwise call record_preference only with arguments you still hold verbatim, or decline the action.",
|
|
400
|
+
refused:
|
|
401
|
+
"The retained arguments do not match their recorded digest and are not returned. Call record_preference only with arguments you still hold verbatim, otherwise decline the action.",
|
|
402
|
+
not_retained:
|
|
403
|
+
"This action's arguments were never kept on this machine. Call record_preference only with arguments you still hold verbatim, otherwise decline the action.",
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// As the demand labels it: who earned the action, as far as the record says.
|
|
408
|
+
function recordProvenance(record, transferred) {
|
|
409
|
+
if (transferred) return "second_hand";
|
|
410
|
+
if (record.subagent_identity === "unavailable") {
|
|
411
|
+
return "subagent_identity_unavailable";
|
|
412
|
+
}
|
|
413
|
+
return record.agent_keyed === true ? "first_hand" : "pre_agent_keying";
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function withoutPayload(status, input, extra) {
|
|
417
|
+
return {
|
|
418
|
+
status,
|
|
419
|
+
action_id: input.action_id,
|
|
420
|
+
ledger: input.ledger,
|
|
421
|
+
...extra,
|
|
422
|
+
submitted: false,
|
|
423
|
+
local_only: true,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// For the bundled MCP server's get_pending_action_payload. Reads only the
|
|
428
|
+
// ledger the demand named and only a remembrance follow-up or a durable
|
|
429
|
+
// preference in it; private learning drafts live elsewhere and are never
|
|
430
|
+
// read. It returns a follow-up's payload as `payload` and a preference's
|
|
431
|
+
// record_preference arguments as `arguments`; it never submits or records
|
|
432
|
+
// either. `readKey(source)` resolves an existing private-learning key and
|
|
433
|
+
// never creates one.
|
|
434
|
+
export async function readPendingActionPayload(input, env, { readKey }) {
|
|
435
|
+
const request = {
|
|
436
|
+
action_id: String(input?.action_id ?? ""),
|
|
437
|
+
ledger: String(input?.ledger ?? ""),
|
|
438
|
+
};
|
|
439
|
+
const notFound = withoutPayload("not_found", request, {
|
|
440
|
+
guidance:
|
|
441
|
+
"No pending action with this action_id is retained in this ledger. It may be closed, owned by another session or agent, or never recorded; do not guess its payload.",
|
|
442
|
+
});
|
|
443
|
+
if (!HEX16.test(request.action_id) || !HEX16.test(request.ledger)) {
|
|
444
|
+
return notFound;
|
|
445
|
+
}
|
|
446
|
+
// The durable ledger first, then the temporary one an earlier build used.
|
|
447
|
+
// Age never closes an action: only completing, declining or withdrawing
|
|
448
|
+
// it does.
|
|
449
|
+
let record = null;
|
|
450
|
+
for (const directory of [
|
|
451
|
+
...(retainedRootsUsable(env)
|
|
452
|
+
? [join(retainedLedgerRoot(env), `${request.ledger}.obligations`)]
|
|
453
|
+
: []),
|
|
454
|
+
join(usageDirectory(env), `${request.ledger}.obligations`),
|
|
455
|
+
]) {
|
|
456
|
+
try {
|
|
457
|
+
assertPrivateDirectory(directory);
|
|
458
|
+
for (const name of readdirSync(directory)
|
|
459
|
+
.sort()
|
|
460
|
+
.slice(0, LEDGER_ENTRY_LIMIT)) {
|
|
461
|
+
if (
|
|
462
|
+
!name.startsWith(request.action_id) ||
|
|
463
|
+
!/^[a-f0-9]{32}\.json$/u.test(name)
|
|
464
|
+
) {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
const candidate = readRecord(join(directory, name));
|
|
469
|
+
const idHash = createHash("sha256")
|
|
470
|
+
.update(String(candidate?.id), "utf8")
|
|
471
|
+
.digest("hex");
|
|
472
|
+
if (
|
|
473
|
+
candidate?.version === OBLIGATION_VERSION &&
|
|
474
|
+
RETAINED_KINDS.has(candidate.kind) &&
|
|
475
|
+
(candidate.kind !== "capture_next_action" ||
|
|
476
|
+
candidate.action_tool === "record_preference") &&
|
|
477
|
+
`${idHash.slice(0, 32)}.json` === name &&
|
|
478
|
+
Number.isFinite(Date.parse(String(candidate?.created_at ?? "")))
|
|
479
|
+
) {
|
|
480
|
+
record = candidate;
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
} catch {
|
|
484
|
+
// One unreadable record must not answer for another.
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} catch {
|
|
488
|
+
// A missing or unsafe ledger must not hide the other one.
|
|
489
|
+
}
|
|
490
|
+
if (record) break;
|
|
491
|
+
}
|
|
492
|
+
if (!record) return notFound;
|
|
493
|
+
const preference = record.kind === "capture_next_action";
|
|
494
|
+
const guidance = GUIDANCE[record.kind];
|
|
495
|
+
const transferred =
|
|
496
|
+
record.transferred_from && typeof record.transferred_from === "object"
|
|
497
|
+
? {
|
|
498
|
+
agent_id: String(record.transferred_from.agent_id ?? ""),
|
|
499
|
+
transcript_path:
|
|
500
|
+
typeof record.transferred_from.transcript_path === "string"
|
|
501
|
+
? record.transferred_from.transcript_path
|
|
502
|
+
: null,
|
|
503
|
+
}
|
|
504
|
+
: null;
|
|
505
|
+
const described = {
|
|
506
|
+
kind: record.kind,
|
|
507
|
+
...(preference
|
|
508
|
+
? { action_tool: "record_preference" }
|
|
509
|
+
: {
|
|
510
|
+
query_id:
|
|
511
|
+
typeof record.query_id === "string" ? record.query_id : null,
|
|
512
|
+
result_id:
|
|
513
|
+
typeof record.result_id === "string" ? record.result_id : null,
|
|
514
|
+
}),
|
|
515
|
+
provenance: recordProvenance(record, transferred),
|
|
516
|
+
transferred_from: transferred,
|
|
517
|
+
payload_digest: record.payload_digest,
|
|
518
|
+
};
|
|
519
|
+
let payload = null;
|
|
520
|
+
if (record.local_resupply_envelope) {
|
|
521
|
+
const envelope = normalizeResupplyEnvelope(record.local_resupply_envelope);
|
|
522
|
+
if (!envelope) {
|
|
523
|
+
return withoutPayload("refused", request, {
|
|
524
|
+
...described,
|
|
525
|
+
reason: "envelope_invalid",
|
|
526
|
+
guidance: guidance.envelope_invalid,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
let key;
|
|
530
|
+
try {
|
|
531
|
+
key = await readKey(envelope.key_source);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
return withoutPayload("unavailable", request, {
|
|
534
|
+
...described,
|
|
535
|
+
reason: localKeyReason(error),
|
|
536
|
+
// The outbox's own content-free recovery, when the reader gives one.
|
|
537
|
+
...(typeof error?.diagnostic === "function"
|
|
538
|
+
? { diagnostic: error.diagnostic() }
|
|
539
|
+
: {}),
|
|
540
|
+
guidance: guidance.unavailable,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
payload = openResupplyEnvelope(
|
|
545
|
+
{ ...record, local_resupply_envelope: envelope },
|
|
546
|
+
key,
|
|
547
|
+
);
|
|
548
|
+
} catch (error) {
|
|
549
|
+
return withoutPayload("refused", request, {
|
|
550
|
+
...described,
|
|
551
|
+
reason: error?.code ?? "decryption_failed",
|
|
552
|
+
guidance: guidance.refused,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
} else if (
|
|
556
|
+
!preference &&
|
|
557
|
+
record.local_resupply_payload &&
|
|
558
|
+
typeof record.local_resupply_payload === "object" &&
|
|
559
|
+
!Array.isArray(record.local_resupply_payload)
|
|
560
|
+
) {
|
|
561
|
+
// A follow-up kept in plaintext by an earlier build: read as it is.
|
|
562
|
+
if (
|
|
563
|
+
resupplyPayloadDigest(record.local_resupply_payload) !==
|
|
564
|
+
record.payload_digest ||
|
|
565
|
+
record.id !== `remembrance_followup:${record.payload_digest}`
|
|
566
|
+
) {
|
|
567
|
+
return withoutPayload("refused", request, {
|
|
568
|
+
...described,
|
|
569
|
+
reason: "digest_mismatch",
|
|
570
|
+
guidance: guidance.refused,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
payload = record.local_resupply_payload;
|
|
574
|
+
} else {
|
|
575
|
+
return withoutPayload("not_retained", request, {
|
|
576
|
+
...described,
|
|
577
|
+
reason:
|
|
578
|
+
record.local_resupply_payload_omitted === "too_large" ||
|
|
579
|
+
record.local_resupply_payload_omitted === "key_unavailable"
|
|
580
|
+
? record.local_resupply_payload_omitted
|
|
581
|
+
: "not_retained",
|
|
582
|
+
guidance: guidance.not_retained,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
status: "retrieved",
|
|
587
|
+
action_id: request.action_id,
|
|
588
|
+
ledger: request.ledger,
|
|
589
|
+
...described,
|
|
590
|
+
payload_bytes: Buffer.byteLength(JSON.stringify(payload), "utf8"),
|
|
591
|
+
[preference ? "arguments" : "payload"]: payload,
|
|
592
|
+
submitted: false,
|
|
593
|
+
local_only: true,
|
|
594
|
+
guidance: guidance.retrieved[described.provenance],
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function localKeyReason(error) {
|
|
599
|
+
const code = String(error?.reasonCode ?? error?.code ?? "");
|
|
600
|
+
if (code.endsWith("key_missing")) return "local_key_missing";
|
|
601
|
+
if (code.endsWith("key_invalid")) return "local_key_invalid";
|
|
602
|
+
return "local_key_unavailable";
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Every retained consumer of the private-learning key on this machine, for
|
|
606
|
+
// the one decision a new key may never get wrong: whether something already
|
|
607
|
+
// sealed would be stranded by it. Hook core and the bundled MCP server share
|
|
608
|
+
// this function. It covers both private-learning outbox lanes, the durable
|
|
609
|
+
// private-learning obligations, the durable ledgers of unresolved actions'
|
|
610
|
+
// retained payloads, and the hook ledgers in the temporary usage directory.
|
|
611
|
+
// The work is bounded and synchronous: at most `ledgers` directories below
|
|
612
|
+
// those roots are listed and `files` files read, whatever they hold (a
|
|
613
|
+
// completion record is at most 12 KiB, a lane file at most the outbox
|
|
614
|
+
// bound), plus at most `ledgers` overflow counts of 4 KiB per ledger root
|
|
615
|
+
// for the doctor's report. A symlink is never followed. Whatever is left
|
|
616
|
+
// makes the inventory incomplete, counted by cause, so no decision rests on
|
|
617
|
+
// state left unread: bounded (past a bound), unreadable (a private file of
|
|
618
|
+
// this user's that could not be opened or read), unclassified (present but
|
|
619
|
+
// not provably a Remembrance record: not a regular private file of this
|
|
620
|
+
// user's within its bound, unparseable, or of no known kind) and unlisted (a
|
|
621
|
+
// directory refused: a symlink, another user's, writable by others, or not
|
|
622
|
+
// listable). Age is never a reason to skip a record. Counts, causes, areas
|
|
623
|
+
// and kinds only; no path or content.
|
|
624
|
+
export const INVENTORY_LIMITS = Object.freeze({
|
|
625
|
+
ledgers: 4096,
|
|
626
|
+
files: 8192,
|
|
627
|
+
});
|
|
628
|
+
// A lane file is read whole, as the outbox reads it, up to the outbox bound.
|
|
629
|
+
const LANE_FILE_MAX_BYTES = 64 * 1024 * 1024;
|
|
630
|
+
const LEDGER_DIRECTORY = /^[a-f0-9]{16}\.obligations$/u;
|
|
631
|
+
const RECORD_FILE = /^[a-f0-9]{32}\.json$/u;
|
|
632
|
+
const OVERFLOW_FILE = /^[a-f0-9]{16}\.obligations-overflow\.json$/u;
|
|
633
|
+
const LANE_ENVELOPE_KINDS = new Map([
|
|
634
|
+
["private-lesson-outbox-envelope-v1", "private_lesson"],
|
|
635
|
+
["private-skill-outbox-envelope-v1", "private_skill"],
|
|
636
|
+
]);
|
|
637
|
+
// Content-free records that never carry ciphertext, in either lane.
|
|
638
|
+
const LANE_KEY_FREE_VERSIONS = new Set([
|
|
639
|
+
"private-lesson-outbox-submitted-v1",
|
|
640
|
+
"private-lesson-outbox-deletion-v1",
|
|
641
|
+
"private-lesson-policy-floor-v1",
|
|
642
|
+
"private-lesson-policy-floor-v2",
|
|
643
|
+
"private-skill-outbox-submitted-v1",
|
|
644
|
+
"private-skill-outbox-deletion-v1",
|
|
645
|
+
"private-skill-destination-audiences-v1",
|
|
646
|
+
"private-outbox-capacity-v1",
|
|
647
|
+
"private-outbox-lock-v2",
|
|
648
|
+
// Lock records that earlier clients could leave behind.
|
|
649
|
+
"private-outbox-lock-v1",
|
|
650
|
+
"private-lesson-outbox-lock-v1",
|
|
651
|
+
]);
|
|
652
|
+
// Each lane's live lock, created and released between listing and reading.
|
|
653
|
+
const LIVE_LANE_LOCK_FILES = new Set([
|
|
654
|
+
".outbox.lock",
|
|
655
|
+
".private-skill-outbox.lock",
|
|
656
|
+
]);
|
|
657
|
+
|
|
658
|
+
export function inventoryRetainedKeyConsumers(
|
|
659
|
+
env,
|
|
660
|
+
{ limits = INVENTORY_LIMITS, lanes, homeDirectory = homedir() } = {},
|
|
661
|
+
) {
|
|
662
|
+
const kinds = {
|
|
663
|
+
private_lesson: 0,
|
|
664
|
+
private_skill: 0,
|
|
665
|
+
remembrance_followup: 0,
|
|
666
|
+
capture_next_action: 0,
|
|
667
|
+
};
|
|
668
|
+
const sources = { os_keychain: 0, mode_0600_file: 0 };
|
|
669
|
+
const unretained = { count: 0, kinds: new Set() };
|
|
670
|
+
const causes = {
|
|
671
|
+
bounded: { count: 0, areas: new Set() },
|
|
672
|
+
unreadable: { count: 0, areas: new Set() },
|
|
673
|
+
unclassified: { count: 0, areas: new Set() },
|
|
674
|
+
unlisted: { count: 0, areas: new Set() },
|
|
675
|
+
};
|
|
676
|
+
// Files and directories left, whatever the cause, as health reports them.
|
|
677
|
+
let unclassified = 0;
|
|
678
|
+
let unlisted = 0;
|
|
679
|
+
let directories = 0;
|
|
680
|
+
let files = 0;
|
|
681
|
+
const lifecycle = join(
|
|
682
|
+
stateRoot(env, homeDirectory),
|
|
683
|
+
"remembrance",
|
|
684
|
+
"private-learning-lifecycle",
|
|
685
|
+
);
|
|
686
|
+
const missing = (error) => error?.code === "ENOENT";
|
|
687
|
+
const left = (area, cause) => {
|
|
688
|
+
const entry = causes[cause];
|
|
689
|
+
entry.count += 1;
|
|
690
|
+
entry.areas.add(area);
|
|
691
|
+
};
|
|
692
|
+
const leftFile = (area, cause) => {
|
|
693
|
+
left(area, cause);
|
|
694
|
+
unclassified += 1;
|
|
695
|
+
};
|
|
696
|
+
const leftDirectory = (area, cause) => {
|
|
697
|
+
left(area, cause);
|
|
698
|
+
unlisted += 1;
|
|
699
|
+
};
|
|
700
|
+
// Why a file could not be classified: refused unread as unsafe, or read
|
|
701
|
+
// and unparseable, it is no record Remembrance wrote; otherwise it could
|
|
702
|
+
// not be read. A file named like a record that is refused for its owner or
|
|
703
|
+
// mode alone may be one whose mode changed: unreadable, so the step shown
|
|
704
|
+
// is to make it a user-owned 0600 file again, never to move it aside.
|
|
705
|
+
const fileCause = (error, name) =>
|
|
706
|
+
(error?.code === UNSAFE_RECORD &&
|
|
707
|
+
!(error.access && RECORD_FILE.test(name))) ||
|
|
708
|
+
error instanceof SyntaxError
|
|
709
|
+
? "unclassified"
|
|
710
|
+
: "unreadable";
|
|
711
|
+
const consumer = (kind, source) => {
|
|
712
|
+
if (!(kind in kinds) || !(source in sources)) return false;
|
|
713
|
+
kinds[kind] += 1;
|
|
714
|
+
sources[source] += 1;
|
|
715
|
+
return true;
|
|
716
|
+
};
|
|
717
|
+
// A file below a root, once the read bound allows it.
|
|
718
|
+
const readFile = (area, path, maxBytes) => {
|
|
719
|
+
if (files >= limits.files) {
|
|
720
|
+
leftFile(area, "bounded");
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
files += 1;
|
|
724
|
+
return readRecordText(path, maxBytes);
|
|
725
|
+
};
|
|
726
|
+
const listDirectory = (path) => {
|
|
727
|
+
assertPrivateDirectory(path);
|
|
728
|
+
return readdirSync(path, { withFileTypes: true });
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
// A completion-obligation record: a sealed payload is a consumer of the
|
|
732
|
+
// key its envelope names; a record without ciphertext needs no key.
|
|
733
|
+
const ledgerRecord = (area, directory, name) => {
|
|
734
|
+
let record;
|
|
735
|
+
try {
|
|
736
|
+
const text = readFile(area, join(directory, name), OBLIGATION_MAX_BYTES);
|
|
737
|
+
if (text === null) return;
|
|
738
|
+
record = JSON.parse(text);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
if (!missing(error)) leftFile(area, fileCause(error, name));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (
|
|
744
|
+
!record ||
|
|
745
|
+
typeof record !== "object" ||
|
|
746
|
+
Array.isArray(record) ||
|
|
747
|
+
record.version !== OBLIGATION_VERSION
|
|
748
|
+
) {
|
|
749
|
+
leftFile(area, "unclassified");
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (record.local_resupply_payload_omitted === "too_large") {
|
|
753
|
+
unretained.count += 1;
|
|
754
|
+
unretained.kinds.add(String(record.kind));
|
|
755
|
+
}
|
|
756
|
+
if (record.local_resupply_envelope == null) return;
|
|
757
|
+
const envelope = normalizeResupplyEnvelope(record.local_resupply_envelope);
|
|
758
|
+
if (
|
|
759
|
+
!envelope ||
|
|
760
|
+
!RETAINED_KINDS.has(record.kind) ||
|
|
761
|
+
!consumer(record.kind, envelope.key_source)
|
|
762
|
+
) {
|
|
763
|
+
leftFile(area, "unclassified");
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
// A flat directory of completion-obligation records.
|
|
767
|
+
const recordDirectory = (area, path, root) => {
|
|
768
|
+
let entries;
|
|
769
|
+
try {
|
|
770
|
+
entries = listDirectory(path);
|
|
771
|
+
} catch (error) {
|
|
772
|
+
if (!(root && missing(error))) leftDirectory(area, "unlisted");
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
for (const entry of entries) {
|
|
776
|
+
if (RECORD_FILE.test(entry.name)) {
|
|
777
|
+
ledgerRecord(area, path, entry.name);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
// A root holding one <ledger>.obligations directory per ledger.
|
|
782
|
+
const ledgerRoot = (area, root) => {
|
|
783
|
+
let entries;
|
|
784
|
+
try {
|
|
785
|
+
entries = listDirectory(root);
|
|
786
|
+
} catch (error) {
|
|
787
|
+
if (!missing(error)) leftDirectory(area, "unlisted");
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const ledgers = entries
|
|
791
|
+
.filter((entry) => LEDGER_DIRECTORY.test(entry.name))
|
|
792
|
+
.map((entry) => entry.name)
|
|
793
|
+
.sort();
|
|
794
|
+
for (const name of ledgers) {
|
|
795
|
+
if (directories >= limits.ledgers) {
|
|
796
|
+
leftDirectory(area, "bounded");
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
directories += 1;
|
|
800
|
+
recordDirectory(area, join(root, name), false);
|
|
801
|
+
}
|
|
802
|
+
// Debts a full ledger could not record, for the doctor's report: at
|
|
803
|
+
// most one count per listed ledger is read.
|
|
804
|
+
let counts = 0;
|
|
805
|
+
for (const entry of entries) {
|
|
806
|
+
if (!OVERFLOW_FILE.test(entry.name) || counts >= limits.ledgers) continue;
|
|
807
|
+
counts += 1;
|
|
808
|
+
try {
|
|
809
|
+
const value = JSON.parse(
|
|
810
|
+
readRecordText(join(root, entry.name), 4096),
|
|
811
|
+
);
|
|
812
|
+
if (Number.isSafeInteger(value?.count) && value.count > 0) {
|
|
813
|
+
unretained.count += value.count;
|
|
814
|
+
for (const kind of Array.isArray(value.kinds) ? value.kinds : []) {
|
|
815
|
+
unretained.kinds.add(String(kind));
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
} catch {
|
|
819
|
+
// A count that cannot be read only leaves the report short.
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
// An outbox lane, every level of it, classified by content.
|
|
824
|
+
const lane = (area, path, root) => {
|
|
825
|
+
let entries;
|
|
826
|
+
try {
|
|
827
|
+
if (!root) {
|
|
828
|
+
if (directories >= limits.ledgers) {
|
|
829
|
+
leftDirectory(area, "bounded");
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
directories += 1;
|
|
833
|
+
}
|
|
834
|
+
entries = listDirectory(path);
|
|
835
|
+
} catch (error) {
|
|
836
|
+
// Only a lane that was never created holds nothing.
|
|
837
|
+
if (!(root && missing(error))) leftDirectory(area, "unlisted");
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
for (const entry of entries) {
|
|
841
|
+
const file = join(path, entry.name);
|
|
842
|
+
if (entry.isDirectory()) {
|
|
843
|
+
lane(area, file, false);
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
const liveLock = root && LIVE_LANE_LOCK_FILES.has(entry.name);
|
|
847
|
+
let value;
|
|
848
|
+
try {
|
|
849
|
+
const text = readFile(area, file, LANE_FILE_MAX_BYTES);
|
|
850
|
+
if (text === null) continue;
|
|
851
|
+
// A live lock is empty until its holder writes the first record.
|
|
852
|
+
if (liveLock && text === "") continue;
|
|
853
|
+
value = JSON.parse(text);
|
|
854
|
+
} catch (error) {
|
|
855
|
+
// A live lock can be released between listing and reading.
|
|
856
|
+
if (!(liveLock && missing(error))) {
|
|
857
|
+
leftFile(area, fileCause(error, entry.name));
|
|
858
|
+
}
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
const kind =
|
|
862
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
863
|
+
? LANE_ENVELOPE_KINDS.get(String(value.version))
|
|
864
|
+
: undefined;
|
|
865
|
+
if (
|
|
866
|
+
kind &&
|
|
867
|
+
value.encryption &&
|
|
868
|
+
typeof value.encryption === "object" &&
|
|
869
|
+
value.encryption.algorithm === "aes-256-gcm"
|
|
870
|
+
) {
|
|
871
|
+
if (!consumer(kind, value.encryption.key_source)) {
|
|
872
|
+
leftFile(area, "unclassified");
|
|
873
|
+
}
|
|
874
|
+
} else if (
|
|
875
|
+
!value ||
|
|
876
|
+
typeof value !== "object" ||
|
|
877
|
+
Array.isArray(value) ||
|
|
878
|
+
!LANE_KEY_FREE_VERSIONS.has(String(value.version)) ||
|
|
879
|
+
Object.hasOwn(value, "encryption")
|
|
880
|
+
) {
|
|
881
|
+
leftFile(area, "unclassified");
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const outboxLanes = lanes ?? [
|
|
887
|
+
join(stateRoot(env, homeDirectory), "remembrance", "private-lessons"),
|
|
888
|
+
join(stateRoot(env, homeDirectory), "remembrance", "private-skills"),
|
|
889
|
+
];
|
|
890
|
+
for (const path of outboxLanes) {
|
|
891
|
+
lane(
|
|
892
|
+
String(path).endsWith("private-skills")
|
|
893
|
+
? "private_skill_outbox"
|
|
894
|
+
: "private_lesson_outbox",
|
|
895
|
+
path,
|
|
896
|
+
true,
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
// The hook ledgers (the source) before the durable ones (the
|
|
900
|
+
// destination): a sealed record a concurrent hook read promotes from the
|
|
901
|
+
// temporary ledger to the durable one between the two scans is then seen
|
|
902
|
+
// in the second at least. A subagent's record that moves to its parent's
|
|
903
|
+
// durable ledger during the scan of that root can still be missed when the
|
|
904
|
+
// parent's ledger sorts first; the transfer copies before it removes, so
|
|
905
|
+
// only that window remains, and only while no key is readable.
|
|
906
|
+
ledgerRoot("hook_ledgers", usageDirectory(env));
|
|
907
|
+
// Refused, never followed, as the hook refuses it: a linked lifecycle
|
|
908
|
+
// directory is named itself, and neither area below it is read.
|
|
909
|
+
let lifecycleUsable = true;
|
|
910
|
+
try {
|
|
911
|
+
assertPrivateDirectory(lifecycle);
|
|
912
|
+
} catch (error) {
|
|
913
|
+
lifecycleUsable = missing(error);
|
|
914
|
+
if (!lifecycleUsable) {
|
|
915
|
+
leftDirectory("private_learning_lifecycle", "unlisted");
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (lifecycleUsable) {
|
|
919
|
+
recordDirectory("private_obligations", join(lifecycle, "obligations"), true);
|
|
920
|
+
ledgerRoot("retained_ledgers", join(lifecycle, "retained"));
|
|
921
|
+
}
|
|
922
|
+
const total = Object.values(kinds).reduce((sum, count) => sum + count, 0);
|
|
923
|
+
const sorted = (values) => [...values].sort();
|
|
924
|
+
return {
|
|
925
|
+
consumers: { total, kinds, sources },
|
|
926
|
+
incomplete: unclassified > 0 || unlisted > 0,
|
|
927
|
+
reasons: {
|
|
928
|
+
unclassified,
|
|
929
|
+
unlisted,
|
|
930
|
+
areas: sorted(
|
|
931
|
+
new Set(Object.values(causes).flatMap((cause) => [...cause.areas])),
|
|
932
|
+
),
|
|
933
|
+
causes: Object.fromEntries(
|
|
934
|
+
Object.entries(causes).map(([cause, value]) => [
|
|
935
|
+
cause,
|
|
936
|
+
{ count: value.count, areas: sorted(value.areas) },
|
|
937
|
+
]),
|
|
938
|
+
),
|
|
939
|
+
},
|
|
940
|
+
unretained: { count: unretained.count, kinds: sorted(unretained.kinds) },
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Why no first key may be made, as content-free codes: "consumers" while a
|
|
945
|
+
// retained item needs a key that is missing, otherwise one "<cause>:<area>"
|
|
946
|
+
// for each cause and area the inventory could not account for. The hook
|
|
947
|
+
// keeps them on a debt whose payload it could not keep; the text both the
|
|
948
|
+
// bundled MCP server and the hooks show for them is the one below.
|
|
949
|
+
const HOLD_CAUSES = ["bounded", "unreadable", "unclassified", "unlisted"];
|
|
950
|
+
const HOLD_AREAS = {
|
|
951
|
+
private_lesson_outbox: "~/.local/state/remembrance/private-lessons",
|
|
952
|
+
private_skill_outbox: "~/.local/state/remembrance/private-skills",
|
|
953
|
+
private_learning_lifecycle:
|
|
954
|
+
"~/.local/state/remembrance/private-learning-lifecycle",
|
|
955
|
+
private_obligations:
|
|
956
|
+
"~/.local/state/remembrance/private-learning-lifecycle/obligations",
|
|
957
|
+
retained_ledgers:
|
|
958
|
+
"~/.local/state/remembrance/private-learning-lifecycle/retained",
|
|
959
|
+
hook_ledgers: "the temporary usage directory",
|
|
960
|
+
};
|
|
961
|
+
const HOLD_CODE = new RegExp(
|
|
962
|
+
`^(?:consumers|(?:${HOLD_CAUSES.join("|")}):(?:${Object.keys(HOLD_AREAS).join("|")}))$`,
|
|
963
|
+
"u",
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
export const RESTORE_ORIGINAL_KEY =
|
|
967
|
+
"Restore the original key: the login Keychain item or the 0600 key file private-lesson-outbox.key in the Remembrance config directory. A new key cannot read retained items, so none is made.";
|
|
968
|
+
|
|
969
|
+
export function retainedKeyHoldCodes(inventory) {
|
|
970
|
+
if (inventory.consumers.total > 0) return ["consumers"];
|
|
971
|
+
return HOLD_CAUSES.flatMap((cause) =>
|
|
972
|
+
(inventory.reasons.causes?.[cause]?.areas ?? []).map(
|
|
973
|
+
(area) => `${cause}:${area}`,
|
|
974
|
+
),
|
|
975
|
+
).filter((code) => HOLD_CODE.test(code));
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
export function normalizeRetainedKeyHoldCodes(value) {
|
|
979
|
+
if (!Array.isArray(value)) return null;
|
|
980
|
+
const codes = [
|
|
981
|
+
...new Set(
|
|
982
|
+
value.filter((code) => typeof code === "string" && HOLD_CODE.test(code)),
|
|
983
|
+
),
|
|
984
|
+
].slice(0, HOLD_CAUSES.length * Object.keys(HOLD_AREAS).length + 1);
|
|
985
|
+
return codes.length ? codes : null;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const thousands = (value) =>
|
|
989
|
+
String(value).replace(/\B(?=(\d{3})+(?!\d))/gu, ",");
|
|
990
|
+
|
|
991
|
+
// What clears a hold, by cause, naming each directory without a user path.
|
|
992
|
+
// Never "restore the original key" unless a retained item needs it.
|
|
993
|
+
export function retainedKeyHoldRemediation(codes) {
|
|
994
|
+
const held = normalizeRetainedKeyHoldCodes(codes) ?? [];
|
|
995
|
+
if (held.includes("consumers")) return RESTORE_ORIGINAL_KEY;
|
|
996
|
+
const named = new Set();
|
|
997
|
+
const where = (cause) => {
|
|
998
|
+
const areas = held
|
|
999
|
+
.filter((code) => code.startsWith(`${cause}:`))
|
|
1000
|
+
.map((code) => code.slice(cause.length + 1));
|
|
1001
|
+
for (const area of areas) named.add(area);
|
|
1002
|
+
const directories = areas.map((area) => HOLD_AREAS[area]);
|
|
1003
|
+
return directories.length > 1
|
|
1004
|
+
? `${directories.slice(0, -1).join(", ")} and ${directories.at(-1)}`
|
|
1005
|
+
: (directories[0] ?? "");
|
|
1006
|
+
};
|
|
1007
|
+
const sentences = [];
|
|
1008
|
+
const bounded = where("bounded");
|
|
1009
|
+
if (bounded) {
|
|
1010
|
+
sentences.push(
|
|
1011
|
+
`The inventory reached its bound (${thousands(INVENTORY_LIMITS.files)} files, ${thousands(INVENTORY_LIMITS.ledgers)} ledger directories) before it had read everything in ${bounded}. Complete or decline the pending actions Remembrance lists in the sessions that keep them (decline_pending_action declines one), which removes their records and, once a ledger is empty, its directory, then retry; a record without a payload also leaves 7 days after it was made, when its session's hooks next run, and empty directories named <16 hex>.obligations that an earlier build left there can be removed by hand.`,
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
const unreadable = where("unreadable");
|
|
1015
|
+
if (unreadable) {
|
|
1016
|
+
sentences.push(
|
|
1017
|
+
`Files in ${unreadable} could not be read: make them user-owned 0600 files again, or move aside those Remembrance did not write, then retry.`,
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
const unclassified = where("unclassified");
|
|
1021
|
+
if (unclassified) {
|
|
1022
|
+
sentences.push(
|
|
1023
|
+
`Move aside files in ${unclassified} that Remembrance did not write (for example .DS_Store), and make a file Remembrance wrote whose owner or mode changed a user-owned 0600 file again instead of moving it aside, then retry.`,
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
const unlisted = where("unlisted");
|
|
1027
|
+
if (unlisted) {
|
|
1028
|
+
sentences.push(
|
|
1029
|
+
`A directory at or in ${unlisted} could not be listed safely (a symlink, another user's, or writable by others): move aside what Remembrance did not create and keep the rest user-owned with mode 0700, then retry.`,
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
if ([...named].some((area) => area !== "hook_ledgers")) {
|
|
1033
|
+
sentences.push("(~/.local/state is $XDG_STATE_HOME when that is set.)");
|
|
1034
|
+
}
|
|
1035
|
+
if (named.has("hook_ledgers")) {
|
|
1036
|
+
sentences.push(
|
|
1037
|
+
"(The temporary usage directory is $REMEMBRANCE_USAGE_DIR, or remembrance-usage-uid-<uid> in the system temporary directory.)",
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
return [
|
|
1041
|
+
"The retained-item inventory could not account for everything, and an item it missed may need an earlier key.",
|
|
1042
|
+
...sentences,
|
|
1043
|
+
].join(" ");
|
|
1044
|
+
}
|