@gmickel/gno 2.5.1 → 2.6.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/README.md +60 -5
- package/assets/skill/README.md +5 -1
- package/assets/skill/SKILL.md +75 -1
- package/assets/skill/cli-reference.md +123 -0
- package/assets/skill/examples.md +30 -0
- package/assets/skill/mcp-reference.md +52 -0
- package/assets/skill/recipes/capture-and-file.md +6 -0
- package/assets/skill/recipes/memory-file-decision.md +6 -0
- package/assets/skill/recipes/memory-supersede-fact.md +5 -0
- package/assets/skill/recipes/session-evidence-lookup.md +98 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.5.1.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +347 -24
- package/spec/mcp.md +198 -2
- package/spec/output-schemas/capture-receipt.schema.json +3 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
- package/spec/output-schemas/memory-remember.schema.json +8 -2
- package/spec/output-schemas/request-status.schema.json +113 -0
- package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
- package/spec/output-schemas/sessions-discovery.schema.json +38 -0
- package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
- package/spec/output-schemas/sessions-status.schema.json +432 -0
- package/src/cli/commands/ask.ts +14 -2
- package/src/cli/commands/capture.ts +55 -96
- package/src/cli/commands/daemon.ts +41 -0
- package/src/cli/commands/ls.ts +3 -0
- package/src/cli/commands/memory.ts +12 -3
- package/src/cli/commands/request-status.ts +59 -0
- package/src/cli/commands/reset.ts +39 -5
- package/src/cli/commands/sessions.ts +713 -0
- package/src/cli/commands/shared.ts +14 -1
- package/src/cli/program.ts +388 -1
- package/src/cli/session-binding.ts +49 -0
- package/src/config/types.ts +8 -0
- package/src/core/capture-publish.ts +239 -0
- package/src/core/capture-sync.ts +3 -0
- package/src/core/memory-remember.ts +233 -122
- package/src/core/memory-types.ts +11 -0
- package/src/core/network-boundary-inventory.ts +8 -0
- package/src/core/request-receipts.ts +671 -0
- package/src/index.ts +9 -0
- package/src/mcp/context.ts +8 -0
- package/src/mcp/http-egress.ts +4 -0
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/tools/capture.ts +87 -83
- package/src/mcp/tools/index.ts +66 -0
- package/src/mcp/tools/memory-remember.ts +7 -0
- package/src/mcp/tools/memory-shared.ts +7 -1
- package/src/mcp/tools/request-status.ts +73 -0
- package/src/mcp/tools/sessions.ts +208 -0
- package/src/sdk/client.ts +180 -84
- package/src/sdk/index.ts +6 -0
- package/src/sdk/types.ts +54 -2
- package/src/serve/capture-service.ts +98 -32
- package/src/serve/config-sync.ts +3 -2
- package/src/serve/public/app.tsx +4 -1
- package/src/serve/public/components/CaptureModal.tsx +26 -8
- package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
- package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
- package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
- package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
- package/src/serve/public/components/sessions/api.ts +40 -0
- package/src/serve/public/components/sessions/snippet.tsx +53 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/hooks/use-api.ts +10 -2
- package/src/serve/public/lib/request-intent.ts +69 -0
- package/src/serve/public/lib/workspace-actions.ts +12 -1
- package/src/serve/public/lib/workspace-tabs.ts +2 -0
- package/src/serve/public/pages/Dashboard.tsx +10 -0
- package/src/serve/public/pages/DocView.tsx +15 -1
- package/src/serve/public/pages/DocumentEditor.tsx +139 -96
- package/src/serve/public/pages/Sessions.tsx +350 -0
- package/src/serve/resident-runtime.ts +43 -3
- package/src/serve/routes/api.ts +476 -147
- package/src/serve/routes/sessions.ts +766 -0
- package/src/serve/security.ts +9 -0
- package/src/serve/server.ts +205 -1
- package/src/serve/session-automation.ts +146 -0
- package/src/sessions/archive.ts +348 -0
- package/src/sessions/automation-state.ts +444 -0
- package/src/sessions/automation-status.ts +239 -0
- package/src/sessions/automation.ts +1169 -0
- package/src/sessions/binding.ts +105 -0
- package/src/sessions/claude-hook.ts +240 -0
- package/src/sessions/config.ts +176 -0
- package/src/sessions/format.ts +191 -0
- package/src/sessions/import-child-env.ts +8 -0
- package/src/sessions/import-child.ts +152 -0
- package/src/sessions/parsers/claude-code.ts +259 -0
- package/src/sessions/parsers/codex.ts +303 -0
- package/src/sessions/parsers/hermes.ts +248 -0
- package/src/sessions/parsers/openclaw.ts +496 -0
- package/src/sessions/parsers/shared.ts +184 -0
- package/src/sessions/sanitize.ts +222 -0
- package/src/sessions/service.ts +1533 -0
- package/src/sessions/setup.ts +477 -0
- package/src/sessions/sources.ts +518 -0
- package/src/sessions/state.ts +118 -0
- package/src/sessions/types.ts +457 -0
- package/src/store/sqlite/adapter.ts +54 -15
- package/src/store/sqlite/scoped-index.ts +9 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.5.1.zip.sha256 +0 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared leased capture publication for CLI, MCP, SDK and REST.
|
|
3
|
+
*
|
|
4
|
+
* One critical section under the shared write lease: plan against current
|
|
5
|
+
* state, publish the note, lexically sync it. With a request ID the same
|
|
6
|
+
* steps run through the request-receipt service so a retry replays or
|
|
7
|
+
* finishes the admitted capture instead of planning a second one.
|
|
8
|
+
*
|
|
9
|
+
* @module src/core/capture-publish
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// node:fs/promises mkdir: filesystem structure op, no Bun equivalent
|
|
13
|
+
import { mkdir } from "node:fs/promises";
|
|
14
|
+
// node:path has no Bun path utilities
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import type { Collection, Config } from "../config/types";
|
|
18
|
+
import type { StorePort } from "../store/types";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
buildCaptureReceipt,
|
|
22
|
+
type CaptureInput,
|
|
23
|
+
CaptureSyncError,
|
|
24
|
+
type CapturePlan,
|
|
25
|
+
type CaptureReceipt,
|
|
26
|
+
type CaptureSyncPaths,
|
|
27
|
+
ensureCapturedFileIndexed,
|
|
28
|
+
syncCapturedFile,
|
|
29
|
+
type SyncCapturedFileResult,
|
|
30
|
+
} from "./capture";
|
|
31
|
+
import { writeCapturePlanFile } from "./capture-write";
|
|
32
|
+
import { withWriteLock } from "./file-lock";
|
|
33
|
+
import {
|
|
34
|
+
type RequestCheckpoint,
|
|
35
|
+
requestDigest,
|
|
36
|
+
type RequestPlanState,
|
|
37
|
+
type RequestReceiptInfo,
|
|
38
|
+
runRequestedWrite,
|
|
39
|
+
} from "./request-receipts";
|
|
40
|
+
import { DEFAULT_LOCK_WAIT_MS } from "./write-lease";
|
|
41
|
+
|
|
42
|
+
export interface CaptureRequest {
|
|
43
|
+
ledgerPath: string;
|
|
44
|
+
namespace: string;
|
|
45
|
+
requestId: string;
|
|
46
|
+
/** Semantic capture input: the request digest payload. */
|
|
47
|
+
input: CaptureInput;
|
|
48
|
+
checkpoint?: (stage: RequestCheckpoint) => Promise<void> | void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PublishCaptureInput {
|
|
52
|
+
collection: Collection;
|
|
53
|
+
store: StorePort;
|
|
54
|
+
lockPath: string;
|
|
55
|
+
lockWaitMs?: number;
|
|
56
|
+
config?: Pick<Config, "contentTypes">;
|
|
57
|
+
syncPaths?: CaptureSyncPaths;
|
|
58
|
+
/** Plan against current state. Runs under the lease; must not write. */
|
|
59
|
+
plan: () => Promise<CapturePlan>;
|
|
60
|
+
beforeWrite?: (absPath: string) => void;
|
|
61
|
+
/**
|
|
62
|
+
* CLI/SDK receipt contract without a request ID: a failed lexical sync is
|
|
63
|
+
* reported in `sync` (not thrown) and an unindexed `open_existing` file is
|
|
64
|
+
* returned as `skipped` rather than synced.
|
|
65
|
+
*/
|
|
66
|
+
reportSyncFailure?: boolean;
|
|
67
|
+
/** Runs under the lease once the capture is retrievable. */
|
|
68
|
+
afterSync?: (
|
|
69
|
+
synced: SyncCapturedFileResult,
|
|
70
|
+
receipt: CaptureReceipt
|
|
71
|
+
) => Promise<void> | void;
|
|
72
|
+
request?: CaptureRequest;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PublishedCapture {
|
|
76
|
+
receipt: CaptureReceipt;
|
|
77
|
+
request?: RequestReceiptInfo;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Private recovery record: destination, expected file hashes and the
|
|
82
|
+
* pre-sync receipt (metadata only, never the note content).
|
|
83
|
+
*/
|
|
84
|
+
interface CaptureRecoveryPlan {
|
|
85
|
+
openedExisting: boolean;
|
|
86
|
+
fileHash: string;
|
|
87
|
+
baseHash: string | null;
|
|
88
|
+
receipt: CaptureReceipt;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sha256 = (value: string): string =>
|
|
92
|
+
new Bun.CryptoHasher("sha256").update(value).digest("hex");
|
|
93
|
+
|
|
94
|
+
async function readFileHash(absPath: string): Promise<string | null> {
|
|
95
|
+
const file = Bun.file(absPath);
|
|
96
|
+
if (!(await file.exists())) return null;
|
|
97
|
+
return sha256(await file.text());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Pre-sync receipt; `sync` is replaced once the capture is retrievable. */
|
|
101
|
+
function toReceipt(plan: CapturePlan, absPath: string): CaptureReceipt {
|
|
102
|
+
return buildCaptureReceipt({
|
|
103
|
+
plan,
|
|
104
|
+
absPath,
|
|
105
|
+
overwritten: plan.collisionPolicyResult === "overwritten",
|
|
106
|
+
sync: plan.provenanceConflict
|
|
107
|
+
? {
|
|
108
|
+
status: "skipped",
|
|
109
|
+
reason:
|
|
110
|
+
"Existing capture has absent or different browser provenance.",
|
|
111
|
+
}
|
|
112
|
+
: undefined,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function publishCapture(
|
|
117
|
+
options: PublishCaptureInput
|
|
118
|
+
): Promise<PublishedCapture> {
|
|
119
|
+
const absPathFor = (relPath: string) =>
|
|
120
|
+
join(options.collection.path, relPath);
|
|
121
|
+
const syncInput = (plan: Pick<CaptureReceipt, "relPath">) => ({
|
|
122
|
+
collection: options.collection,
|
|
123
|
+
store: options.store,
|
|
124
|
+
relPath: plan.relPath,
|
|
125
|
+
absPath: absPathFor(plan.relPath),
|
|
126
|
+
config: options.config,
|
|
127
|
+
syncPaths: options.syncPaths,
|
|
128
|
+
});
|
|
129
|
+
const write = async (plan: CapturePlan): Promise<void> => {
|
|
130
|
+
const absPath = absPathFor(plan.relPath);
|
|
131
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
132
|
+
options.beforeWrite?.(absPath);
|
|
133
|
+
await writeCapturePlanFile(plan, absPath);
|
|
134
|
+
};
|
|
135
|
+
const finish = async (
|
|
136
|
+
draft: CaptureReceipt,
|
|
137
|
+
openedExisting: boolean
|
|
138
|
+
): Promise<CaptureReceipt> => {
|
|
139
|
+
const synced = openedExisting
|
|
140
|
+
? await ensureCapturedFileIndexed(syncInput(draft))
|
|
141
|
+
: await syncCapturedFile(syncInput(draft));
|
|
142
|
+
await options.afterSync?.(synced, draft);
|
|
143
|
+
return { ...draft, docid: synced.docid, sync: synced.sync };
|
|
144
|
+
};
|
|
145
|
+
const lockWaitMs = options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS;
|
|
146
|
+
|
|
147
|
+
if (!options.request) {
|
|
148
|
+
const receipt = await withWriteLock(
|
|
149
|
+
options.lockPath,
|
|
150
|
+
async () => {
|
|
151
|
+
const plan = await options.plan();
|
|
152
|
+
if (plan.provenanceConflict) {
|
|
153
|
+
return toReceipt(plan, absPathFor(plan.relPath));
|
|
154
|
+
}
|
|
155
|
+
if (!plan.openedExisting) await write(plan);
|
|
156
|
+
const draft = toReceipt(plan, absPathFor(plan.relPath));
|
|
157
|
+
if (!options.reportSyncFailure) {
|
|
158
|
+
return finish(draft, plan.openedExisting);
|
|
159
|
+
}
|
|
160
|
+
if (plan.openedExisting) {
|
|
161
|
+
const existing = await options.store.getDocument(
|
|
162
|
+
plan.collection,
|
|
163
|
+
plan.relPath
|
|
164
|
+
);
|
|
165
|
+
if (!existing.ok) throw new Error(existing.error.message);
|
|
166
|
+
const opened: CaptureReceipt = {
|
|
167
|
+
...draft,
|
|
168
|
+
docid: existing.value?.docid,
|
|
169
|
+
sync: existing.value
|
|
170
|
+
? { status: "completed" }
|
|
171
|
+
: {
|
|
172
|
+
status: "skipped",
|
|
173
|
+
reason: "Existing file is not indexed yet.",
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
return opened;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
return await finish(draft, false);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (!(error instanceof CaptureSyncError)) throw error;
|
|
182
|
+
const failed: CaptureReceipt = {
|
|
183
|
+
...draft,
|
|
184
|
+
sync: { status: "failed", error: error.syncError },
|
|
185
|
+
};
|
|
186
|
+
return failed;
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
lockWaitMs
|
|
190
|
+
);
|
|
191
|
+
return { receipt };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const outcome = await runRequestedWrite<CaptureRecoveryPlan, CaptureReceipt>({
|
|
195
|
+
ledgerPath: options.request.ledgerPath,
|
|
196
|
+
namespace: options.request.namespace,
|
|
197
|
+
requestId: options.request.requestId,
|
|
198
|
+
operation: "capture",
|
|
199
|
+
digest: requestDigest("capture", options.request.input),
|
|
200
|
+
lockPath: options.lockPath,
|
|
201
|
+
lockWaitMs,
|
|
202
|
+
checkpoint: options.request.checkpoint,
|
|
203
|
+
prepare: async () => {
|
|
204
|
+
const plan = await options.plan();
|
|
205
|
+
if (plan.provenanceConflict) {
|
|
206
|
+
return { result: toReceipt(plan, absPathFor(plan.relPath)) };
|
|
207
|
+
}
|
|
208
|
+
const absPath = absPathFor(plan.relPath);
|
|
209
|
+
const baseHash = plan.overwrite ? await readFileHash(absPath) : null;
|
|
210
|
+
return {
|
|
211
|
+
plan: {
|
|
212
|
+
openedExisting: plan.openedExisting,
|
|
213
|
+
fileHash: sha256(plan.content),
|
|
214
|
+
baseHash,
|
|
215
|
+
receipt: toReceipt(plan, absPath),
|
|
216
|
+
},
|
|
217
|
+
publish: async () => {
|
|
218
|
+
if (!plan.openedExisting) await write(plan);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
inspect: async (recovery): Promise<RequestPlanState> => {
|
|
223
|
+
const onDisk = await readFileHash(absPathFor(recovery.receipt.relPath));
|
|
224
|
+
if (recovery.openedExisting) {
|
|
225
|
+
return onDisk === null ? "absent" : "published";
|
|
226
|
+
}
|
|
227
|
+
if (onDisk === recovery.fileHash) return "published";
|
|
228
|
+
if (onDisk === recovery.baseHash) return "absent";
|
|
229
|
+
return onDisk === null ? "absent" : "unexpected";
|
|
230
|
+
},
|
|
231
|
+
finish: (recovery) => finish(recovery.receipt, recovery.openedExisting),
|
|
232
|
+
resultRef: (receipt) => ({
|
|
233
|
+
uri: receipt.uri,
|
|
234
|
+
docid: receipt.docid,
|
|
235
|
+
contentHash: receipt.contentHash,
|
|
236
|
+
}),
|
|
237
|
+
});
|
|
238
|
+
return { receipt: outcome.result, request: outcome.request };
|
|
239
|
+
}
|
package/src/core/capture-sync.ts
CHANGED
|
@@ -27,6 +27,8 @@ export class CaptureSyncError extends Error {
|
|
|
27
27
|
readonly code = CAPTURE_SYNC_FAILED_CODE;
|
|
28
28
|
readonly absPath: string;
|
|
29
29
|
readonly relPath: string;
|
|
30
|
+
/** The sync failure itself, without the write half of the message. */
|
|
31
|
+
readonly syncError: string;
|
|
30
32
|
|
|
31
33
|
constructor(input: { absPath: string; relPath: string; cause: string }) {
|
|
32
34
|
super(
|
|
@@ -35,6 +37,7 @@ export class CaptureSyncError extends Error {
|
|
|
35
37
|
this.name = "CaptureSyncError";
|
|
36
38
|
this.absPath = input.absPath;
|
|
37
39
|
this.relPath = input.relPath;
|
|
40
|
+
this.syncError = input.cause;
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -56,6 +56,13 @@ import {
|
|
|
56
56
|
MEMORY_SEMANTIC_LIKELY_THRESHOLD,
|
|
57
57
|
MemoryError,
|
|
58
58
|
} from "./memory-types";
|
|
59
|
+
import {
|
|
60
|
+
isLeaseBusy,
|
|
61
|
+
RequestReceiptError,
|
|
62
|
+
requestDigest,
|
|
63
|
+
runRequestedWrite,
|
|
64
|
+
validateRequestId,
|
|
65
|
+
} from "./request-receipts";
|
|
59
66
|
|
|
60
67
|
export interface CandidateQuery {
|
|
61
68
|
text: string;
|
|
@@ -249,6 +256,12 @@ async function supersedesEdgeProjected(
|
|
|
249
256
|
// remember
|
|
250
257
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
251
258
|
|
|
259
|
+
interface RememberPlan {
|
|
260
|
+
frontmatter: MemoryRecordFrontmatter;
|
|
261
|
+
supersedes: string[];
|
|
262
|
+
relPath: string;
|
|
263
|
+
}
|
|
264
|
+
|
|
252
265
|
export async function rememberFact(
|
|
253
266
|
deps: MemoryServiceDeps,
|
|
254
267
|
rawInput: RememberInput
|
|
@@ -270,14 +283,26 @@ export async function rememberFact(
|
|
|
270
283
|
);
|
|
271
284
|
}
|
|
272
285
|
}
|
|
286
|
+
const requestId =
|
|
287
|
+
rawInput.requestId === undefined
|
|
288
|
+
? undefined
|
|
289
|
+
: validateRequestId(rawInput.requestId);
|
|
290
|
+
if (requestId !== undefined && decision === undefined) {
|
|
291
|
+
throw new RequestReceiptError(
|
|
292
|
+
"REQUEST_ID_INVALID",
|
|
293
|
+
"requestId applies only to writes: pass decision add or supersede."
|
|
294
|
+
);
|
|
295
|
+
}
|
|
273
296
|
|
|
274
297
|
const { candidates, matching } = await findMemoryCandidates(deps, {
|
|
275
298
|
text,
|
|
276
299
|
collection: collection.name,
|
|
277
300
|
scopes,
|
|
278
301
|
});
|
|
302
|
+
// With a request ID the exact-duplicate decision is made (and recorded)
|
|
303
|
+
// under the lease, so a later retry replays it instead of re-deciding.
|
|
279
304
|
const exact = candidates.find((candidate) => candidate.match === "exact");
|
|
280
|
-
if (exact && decision !== "supersede") {
|
|
305
|
+
if (exact && decision !== "supersede" && requestId === undefined) {
|
|
281
306
|
const { similarity: _similarity, match: _match, ...record } = exact;
|
|
282
307
|
return { outcome: "existing", record, matching };
|
|
283
308
|
}
|
|
@@ -285,135 +310,222 @@ export async function rememberFact(
|
|
|
285
310
|
return { outcome: "candidates", candidates, matching };
|
|
286
311
|
}
|
|
287
312
|
|
|
288
|
-
const createdAt = memoryNow(deps).toISOString();
|
|
289
313
|
const source = rawInput.source?.trim() || undefined;
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
314
|
+
const absPathOf = (plan: RememberPlan) => join(collection.path, plan.relPath);
|
|
315
|
+
|
|
316
|
+
/** Under the lease: decide against current state; never writes. */
|
|
317
|
+
const prepare = async (): Promise<
|
|
318
|
+
{ plan: RememberPlan } | { result: RememberResult }
|
|
319
|
+
> => {
|
|
320
|
+
const supersedes: string[] = [];
|
|
321
|
+
if (decision === "supersede") {
|
|
322
|
+
supersedes.push(
|
|
323
|
+
await verifyPredecessor(
|
|
324
|
+
deps,
|
|
325
|
+
collection.name,
|
|
326
|
+
rawInput.predecessorUri as string,
|
|
327
|
+
rawInput.predecessorHash as string
|
|
328
|
+
)
|
|
329
|
+
);
|
|
330
|
+
} else {
|
|
331
|
+
// The pre-lease check raced with any concurrent writer; decide
|
|
332
|
+
// idempotency on the state visible under the lease.
|
|
333
|
+
const existing = await findExactCurrent(deps, {
|
|
334
|
+
text,
|
|
335
|
+
collection: collection.name,
|
|
336
|
+
scopes,
|
|
337
|
+
contentHash,
|
|
338
|
+
});
|
|
339
|
+
if (existing) {
|
|
340
|
+
return { result: { outcome: "existing", record: existing, matching } };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const createdAt = memoryNow(deps).toISOString();
|
|
344
|
+
const frontmatter: MemoryRecordFrontmatter = {
|
|
345
|
+
recordId: buildMemoryRecordId({
|
|
346
|
+
contentHash,
|
|
347
|
+
createdAt,
|
|
348
|
+
caller: identity.caller,
|
|
349
|
+
session: identity.session,
|
|
350
|
+
}),
|
|
351
|
+
scopes,
|
|
294
352
|
caller: identity.caller,
|
|
295
353
|
session: identity.session,
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
354
|
+
createdAt,
|
|
355
|
+
contentHash,
|
|
356
|
+
...(source ? { source } : {}),
|
|
357
|
+
};
|
|
358
|
+
return {
|
|
359
|
+
plan: {
|
|
360
|
+
frontmatter,
|
|
361
|
+
supersedes,
|
|
362
|
+
relPath: buildMemoryRecordRelPath(frontmatter),
|
|
363
|
+
},
|
|
364
|
+
};
|
|
303
365
|
};
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
366
|
+
const serialize = (plan: RememberPlan): string =>
|
|
367
|
+
serializeMemoryRecord({
|
|
368
|
+
frontmatter: plan.frontmatter,
|
|
369
|
+
supersedes: plan.supersedes,
|
|
370
|
+
text,
|
|
371
|
+
});
|
|
372
|
+
const publish = async (plan: RememberPlan): Promise<void> => {
|
|
373
|
+
await mkdir(dirname(absPathOf(plan)), { recursive: true });
|
|
374
|
+
await atomicCreate(absPathOf(plan), serialize(plan));
|
|
375
|
+
};
|
|
376
|
+
/** Under the lease: lexical sync, projection check, result. */
|
|
377
|
+
const finish = async (plan: RememberPlan): Promise<RememberResult> => {
|
|
378
|
+
const { relPath, supersedes, frontmatter } = plan;
|
|
379
|
+
const absPath = absPathOf(plan);
|
|
380
|
+
// syncPaths (not syncFiles) so typed-edge projection errors surface.
|
|
381
|
+
const syncResult = await (deps.syncService ?? defaultSyncService).syncPaths(
|
|
382
|
+
collection,
|
|
383
|
+
store,
|
|
384
|
+
[relPath],
|
|
385
|
+
withContentTypeRules({ runUpdateCmd: false, gitPull: false }, config)
|
|
386
|
+
);
|
|
387
|
+
const fileResult = syncResult.files?.[0];
|
|
388
|
+
const doc = await store.getDocument(collection.name, relPath);
|
|
389
|
+
const sync: MemorySyncState =
|
|
390
|
+
fileResult?.status === "error" || !doc.ok || doc.value === null
|
|
391
|
+
? {
|
|
392
|
+
status: "failed",
|
|
393
|
+
error:
|
|
394
|
+
fileResult?.errorMessage ??
|
|
395
|
+
fileResult?.errorCode ??
|
|
396
|
+
"memory record was written but is not retrievable yet",
|
|
334
397
|
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
398
|
+
: { status: "completed" };
|
|
399
|
+
if (sync.status === "failed") {
|
|
400
|
+
throw new MemoryError(
|
|
401
|
+
"MEMORY_SYNC_FAILED",
|
|
402
|
+
`Memory record written to ${absPath} but lexical sync failed: ${sync.error}. Run gno update to retry indexing.`
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
const written = (doc as { value: DocumentRow }).value;
|
|
406
|
+
const projectionErrors = syncResult.errors
|
|
407
|
+
.map((error) => `${error.relPath}: ${error.message}`)
|
|
408
|
+
.join("; ");
|
|
409
|
+
if (decision === "supersede") {
|
|
410
|
+
// The write is only a supersession once the edge is projected;
|
|
411
|
+
// until then the predecessor still reads as current.
|
|
412
|
+
const projected =
|
|
413
|
+
projectionErrors.length === 0 &&
|
|
414
|
+
(await supersedesEdgeProjected(deps, written.id, supersedes));
|
|
415
|
+
if (!projected) {
|
|
416
|
+
throw new MemoryError(
|
|
417
|
+
"MEMORY_SUPERSEDE_PROJECTION_FAILED",
|
|
418
|
+
`Successor written to ${absPath} but its supersedes edge did not project${projectionErrors ? ` (${projectionErrors})` : ""}; the predecessor still reads as current. Run gno update to retry the projection.`
|
|
340
419
|
);
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
420
|
+
}
|
|
421
|
+
} else if (projectionErrors.length > 0) {
|
|
422
|
+
throw new MemoryError(
|
|
423
|
+
"MEMORY_SYNC_FAILED",
|
|
424
|
+
`Memory record written to ${absPath} but typed-edge projection failed: ${projectionErrors}. Run gno update to retry indexing.`
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
const record: MemoryFact = {
|
|
428
|
+
uri: written.uri,
|
|
429
|
+
docid: written.docid,
|
|
430
|
+
recordId: frontmatter.recordId,
|
|
431
|
+
text,
|
|
432
|
+
scopes: frontmatter.scopes,
|
|
433
|
+
caller: frontmatter.caller,
|
|
434
|
+
session: frontmatter.session,
|
|
435
|
+
createdAt: frontmatter.createdAt,
|
|
436
|
+
contentHash,
|
|
437
|
+
supersedes,
|
|
438
|
+
...(frontmatter.source ? { source: frontmatter.source } : {}),
|
|
439
|
+
};
|
|
440
|
+
return {
|
|
441
|
+
outcome: decision === "supersede" ? "superseded" : "added",
|
|
442
|
+
record,
|
|
443
|
+
absPath,
|
|
444
|
+
sync,
|
|
445
|
+
matching,
|
|
446
|
+
};
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const lockWaitMs = memoryLockWaitMs(deps);
|
|
450
|
+
try {
|
|
451
|
+
if (requestId === undefined) {
|
|
452
|
+
return await withWriteLock(
|
|
453
|
+
deps.lockPath,
|
|
454
|
+
async () => {
|
|
455
|
+
const prepared = await prepare();
|
|
456
|
+
if ("result" in prepared) return prepared.result;
|
|
457
|
+
await publish(prepared.plan);
|
|
458
|
+
return finish(prepared.plan);
|
|
459
|
+
},
|
|
460
|
+
lockWaitMs
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
if (!deps.requests) {
|
|
464
|
+
throw new RequestReceiptError(
|
|
465
|
+
"REQUEST_LEDGER_UNAVAILABLE",
|
|
466
|
+
"This memory service has no request ledger; retry without requestId."
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
const outcome = await runRequestedWrite<RememberPlan, RememberResult>({
|
|
470
|
+
ledgerPath: deps.requests.ledgerPath,
|
|
471
|
+
namespace: deps.requests.namespace,
|
|
472
|
+
requestId,
|
|
473
|
+
operation: "remember",
|
|
474
|
+
// Caller/session are provenance, not intent: a retry may reconnect
|
|
475
|
+
// under a new session and must still match.
|
|
476
|
+
digest: requestDigest("remember", {
|
|
477
|
+
collection: collection.name,
|
|
478
|
+
text,
|
|
479
|
+
scopes,
|
|
480
|
+
decision,
|
|
481
|
+
predecessorUri: rawInput.predecessorUri?.trim(),
|
|
482
|
+
predecessorHash: rawInput.predecessorHash,
|
|
483
|
+
source,
|
|
484
|
+
}),
|
|
485
|
+
lockPath: deps.lockPath,
|
|
486
|
+
lockWaitMs,
|
|
487
|
+
checkpoint: deps.requests.checkpoint,
|
|
488
|
+
prepare: async () => {
|
|
489
|
+
const prepared = await prepare();
|
|
490
|
+
if ("result" in prepared) return prepared;
|
|
491
|
+
return { plan: prepared.plan, publish: () => publish(prepared.plan) };
|
|
492
|
+
},
|
|
493
|
+
inspect: async (plan) => {
|
|
494
|
+
const file = Bun.file(absPathOf(plan));
|
|
495
|
+
if (!(await file.exists())) return "absent";
|
|
496
|
+
if ((await file.text()) !== serialize(plan)) return "unexpected";
|
|
497
|
+
// Another successor may have superseded the predecessor while this
|
|
498
|
+
// one sat unprojected: finishing would leave two successors.
|
|
499
|
+
const [predecessorUri] = plan.supersedes;
|
|
500
|
+
if (!predecessorUri) return "published";
|
|
501
|
+
const predecessor = await store.getDocumentByUri(predecessorUri);
|
|
502
|
+
if (!predecessor.ok || !predecessor.value) return "unexpected";
|
|
503
|
+
const successors = await store.getEdgeBacklinksForDoc(
|
|
504
|
+
predecessor.value.id,
|
|
505
|
+
{ edgeType: MEMORY_SUPERSEDES_EDGE }
|
|
349
506
|
);
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
status: "failed",
|
|
356
|
-
error:
|
|
357
|
-
fileResult?.errorMessage ??
|
|
358
|
-
fileResult?.errorCode ??
|
|
359
|
-
"memory record was written but is not retrievable yet",
|
|
360
|
-
}
|
|
361
|
-
: { status: "completed" };
|
|
362
|
-
if (sync.status === "failed") {
|
|
363
|
-
throw new MemoryError(
|
|
364
|
-
"MEMORY_SYNC_FAILED",
|
|
365
|
-
`Memory record written to ${absPath} but lexical sync failed: ${sync.error}. Run gno update to retry indexing.`
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
const written = (doc as { value: DocumentRow }).value;
|
|
369
|
-
const projectionErrors = syncResult.errors
|
|
370
|
-
.map((error) => `${error.relPath}: ${error.message}`)
|
|
371
|
-
.join("; ");
|
|
372
|
-
if (decision === "supersede") {
|
|
373
|
-
// The write is only a supersession once the edge is projected;
|
|
374
|
-
// until then the predecessor still reads as current.
|
|
375
|
-
const projected =
|
|
376
|
-
projectionErrors.length === 0 &&
|
|
377
|
-
(await supersedesEdgeProjected(deps, written.id, supersedes));
|
|
378
|
-
if (!projected) {
|
|
379
|
-
throw new MemoryError(
|
|
380
|
-
"MEMORY_SUPERSEDE_PROJECTION_FAILED",
|
|
381
|
-
`Successor written to ${absPath} but its supersedes edge did not project${projectionErrors ? ` (${projectionErrors})` : ""}; the predecessor still reads as current. Run gno update to retry the projection.`
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
} else if (projectionErrors.length > 0) {
|
|
385
|
-
throw new MemoryError(
|
|
386
|
-
"MEMORY_SYNC_FAILED",
|
|
387
|
-
`Memory record written to ${absPath} but typed-edge projection failed: ${projectionErrors}. Run gno update to retry indexing.`
|
|
388
|
-
);
|
|
389
|
-
}
|
|
390
|
-
const record: MemoryFact = {
|
|
391
|
-
uri: written.uri,
|
|
392
|
-
docid: written.docid,
|
|
393
|
-
recordId: frontmatter.recordId,
|
|
394
|
-
text,
|
|
395
|
-
scopes,
|
|
396
|
-
caller: identity.caller,
|
|
397
|
-
session: identity.session,
|
|
398
|
-
createdAt,
|
|
399
|
-
contentHash,
|
|
400
|
-
supersedes,
|
|
401
|
-
...(source ? { source } : {}),
|
|
402
|
-
};
|
|
403
|
-
return {
|
|
404
|
-
outcome: decision === "supersede" ? "superseded" : "added",
|
|
405
|
-
record,
|
|
406
|
-
absPath,
|
|
407
|
-
sync,
|
|
408
|
-
matching,
|
|
409
|
-
};
|
|
507
|
+
const ownUri = `gno://${collection.name}/${plan.relPath}`;
|
|
508
|
+
return successors.ok &&
|
|
509
|
+
successors.value.every((edge) => edge.sourceUri === ownUri)
|
|
510
|
+
? "published"
|
|
511
|
+
: "unexpected";
|
|
410
512
|
},
|
|
411
|
-
|
|
412
|
-
|
|
513
|
+
finish,
|
|
514
|
+
resultRef: (result) =>
|
|
515
|
+
result.outcome === "candidates"
|
|
516
|
+
? {}
|
|
517
|
+
: {
|
|
518
|
+
uri: result.record.uri,
|
|
519
|
+
docid: result.record.docid,
|
|
520
|
+
contentHash: result.record.contentHash,
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
return { ...outcome.result, request: outcome.request } as RememberResult;
|
|
413
524
|
} catch (error) {
|
|
414
|
-
if (error instanceof MemoryError
|
|
415
|
-
|
|
416
|
-
|
|
525
|
+
if (error instanceof MemoryError || error instanceof RequestReceiptError) {
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
if (isLeaseBusy(error)) {
|
|
417
529
|
throw new MemoryError(
|
|
418
530
|
"MEMORY_WRITE_LEASE_BUSY",
|
|
419
531
|
`Could not acquire the shared write lease at ${deps.lockPath} within ${lockWaitMs}ms: another write holds it. The memory service takes the lease itself; callers must not pre-hold it.`
|
|
@@ -421,5 +533,4 @@ export async function rememberFact(
|
|
|
421
533
|
}
|
|
422
534
|
throw error;
|
|
423
535
|
}
|
|
424
|
-
return leased;
|
|
425
536
|
}
|