wikimemory 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -0
  3. package/dist/npm-cli/scripts/cli-options.js +29 -0
  4. package/dist/npm-cli/scripts/cli.js +79 -0
  5. package/dist/npm-cli/scripts/client-tools.js +57 -0
  6. package/dist/npm-cli/scripts/deployment-record.js +72 -0
  7. package/dist/npm-cli/scripts/dev.js +49 -0
  8. package/dist/npm-cli/scripts/lifecycle-runtime.js +38 -0
  9. package/dist/npm-cli/scripts/package-root.js +15 -0
  10. package/dist/npm-cli/scripts/passkeys.js +197 -0
  11. package/dist/npm-cli/scripts/setup.js +523 -0
  12. package/dist/npm-cli/scripts/status.js +49 -0
  13. package/dist/npm-cli/scripts/uninstall.js +220 -0
  14. package/dist/npm-cli/scripts/upgrade.js +301 -0
  15. package/dist/npm-cli/src/version.js +2 -0
  16. package/dist/web/assets/index-CjnFBnXp.css +1 -0
  17. package/dist/web/assets/index-buhGyrxi.js +72 -0
  18. package/dist/web/index.html +14 -0
  19. package/migrations/0001_initial.sql +297 -0
  20. package/migrations/0002_passkeys.sql +30 -0
  21. package/migrations/0003_passkey_management.sql +38 -0
  22. package/migrations/0004_credential_bound_registration_tokens.sql +18 -0
  23. package/package.json +89 -0
  24. package/release-manifest.json +22 -0
  25. package/skills/wikimemory-ingest/SKILL.md +24 -0
  26. package/skills/wikimemory-ingest/agents/openai.yaml +4 -0
  27. package/skills/wikimemory-install/SKILL.md +81 -0
  28. package/skills/wikimemory-install/agents/openai.yaml +4 -0
  29. package/skills/wikimemory-lint/SKILL.md +18 -0
  30. package/skills/wikimemory-lint/agents/openai.yaml +4 -0
  31. package/skills/wikimemory-recall/SKILL.md +20 -0
  32. package/skills/wikimemory-recall/agents/openai.yaml +4 -0
  33. package/src/auth/local.ts +131 -0
  34. package/src/auth/passkey-api.ts +100 -0
  35. package/src/auth/passkey-management.ts +150 -0
  36. package/src/auth/passkey.ts +908 -0
  37. package/src/auth/props.ts +96 -0
  38. package/src/auth/resource.ts +36 -0
  39. package/src/domain/crypto.ts +27 -0
  40. package/src/domain/errors.ts +32 -0
  41. package/src/domain/export-service.ts +363 -0
  42. package/src/domain/guards.ts +9 -0
  43. package/src/domain/memory-service.ts +1092 -0
  44. package/src/domain/secret-scanner.ts +45 -0
  45. package/src/domain/text-chunk.ts +24 -0
  46. package/src/domain/types.ts +169 -0
  47. package/src/env.ts +20 -0
  48. package/src/index.ts +138 -0
  49. package/src/mcp/schemas.ts +117 -0
  50. package/src/mcp/server.ts +506 -0
  51. package/src/version.ts +2 -0
  52. package/src/web/app.ts +299 -0
@@ -0,0 +1,96 @@
1
+ import type {
2
+ TokenExchangeCallbackOptions,
3
+ TokenExchangeCallbackResult
4
+ } from "@cloudflare/workers-oauth-provider";
5
+ import { getMcpAuthContext } from "agents/mcp";
6
+ import { z } from "zod";
7
+ import { DomainError } from "../domain/errors";
8
+ import { isMemoryScope } from "../domain/guards";
9
+ import type { ActorContext, MemoryScope } from "../domain/types";
10
+ import type { Env } from "../env";
11
+
12
+ export interface AuthProps extends Record<string, unknown> {
13
+ workspaceId: string;
14
+ principalId: string;
15
+ clientId: string;
16
+ agentLabel?: string;
17
+ authenticatedAt?: string;
18
+ credentialId?: string;
19
+ scopes: MemoryScope[];
20
+ }
21
+
22
+ const AUTH_PROPS_SCHEMA = z.object({
23
+ workspaceId: z.string(),
24
+ principalId: z.string(),
25
+ clientId: z.string(),
26
+ agentLabel: z.string().optional(),
27
+ authenticatedAt: z.string().optional(),
28
+ credentialId: z.string().optional(),
29
+ scopes: z.array(z.enum(["memory:read", "memory:write", "memory:admin"]))
30
+ });
31
+
32
+ export function downscopeAccessToken(
33
+ options: TokenExchangeCallbackOptions
34
+ ): TokenExchangeCallbackResult {
35
+ const props = AUTH_PROPS_SCHEMA.parse(options.props);
36
+ const requestedScopes = z
37
+ .array(z.enum(["memory:read", "memory:write", "memory:admin"]))
38
+ .parse(options.requestedScope);
39
+ const accessTokenProps: AuthProps = {
40
+ workspaceId: props.workspaceId,
41
+ principalId: props.principalId,
42
+ clientId: props.clientId,
43
+ scopes: requestedScopes,
44
+ ...(props.agentLabel === undefined ? {} : { agentLabel: props.agentLabel }),
45
+ ...(props.authenticatedAt === undefined ? {} : { authenticatedAt: props.authenticatedAt }),
46
+ ...(props.credentialId === undefined ? {} : { credentialId: props.credentialId })
47
+ };
48
+ return {
49
+ accessTokenProps,
50
+ accessTokenScope: requestedScopes
51
+ };
52
+ }
53
+
54
+ export async function actorFromAuthorization(
55
+ props: Record<string, unknown> | undefined,
56
+ env: Pick<Env, "APP_ENV" | "DB">
57
+ ): Promise<ActorContext> {
58
+ if (
59
+ props === undefined ||
60
+ typeof props["workspaceId"] !== "string" ||
61
+ typeof props["principalId"] !== "string" ||
62
+ typeof props["clientId"] !== "string" ||
63
+ !Array.isArray(props["scopes"]) ||
64
+ !props["scopes"].every(
65
+ (scope): scope is MemoryScope => typeof scope === "string" && isMemoryScope(scope)
66
+ )
67
+ ) {
68
+ throw new DomainError("forbidden", "Missing or invalid MCP authorization context");
69
+ }
70
+ if (env.APP_ENV === "production") {
71
+ const credentialId = props["credentialId"];
72
+ if (typeof credentialId !== "string")
73
+ throw new DomainError("forbidden", "The authorizing passkey is no longer valid");
74
+ const credential = await env.DB.prepare(
75
+ "SELECT 1 AS present FROM passkey_credentials WHERE credential_id = ? AND principal_id = ?"
76
+ )
77
+ .bind(credentialId, props["principalId"])
78
+ .first<{ present: number }>();
79
+ if (credential === null)
80
+ throw new DomainError("forbidden", "The authorizing passkey is no longer valid");
81
+ }
82
+ const scopes = new Set(props["scopes"]);
83
+ const agentLabel = typeof props["agentLabel"] === "string" ? props["agentLabel"] : undefined;
84
+ return {
85
+ workspaceId: props["workspaceId"],
86
+ principalId: props["principalId"],
87
+ clientId: props["clientId"],
88
+ ...(agentLabel === undefined ? {} : { agentLabel }),
89
+ scopes,
90
+ requestId: crypto.randomUUID()
91
+ };
92
+ }
93
+
94
+ export async function actorFromMcp(env: Pick<Env, "APP_ENV" | "DB">): Promise<ActorContext> {
95
+ return await actorFromAuthorization(getMcpAuthContext()?.props, env);
96
+ }
@@ -0,0 +1,36 @@
1
+ import type { AuthRequest } from "@cloudflare/workers-oauth-provider";
2
+ import { OAuthError } from "@cloudflare/workers-oauth-provider";
3
+
4
+ export function canonicalMcpResource(appBaseUrl: string | undefined): string {
5
+ if (appBaseUrl === undefined) throw new Error("APP_BASE_URL is required");
6
+ return new URL("/mcp", appBaseUrl).toString();
7
+ }
8
+
9
+ export function bindAuthorizationResource(
10
+ auth: AuthRequest,
11
+ canonicalResource: string
12
+ ): AuthRequest {
13
+ const requested = auth.resource;
14
+ if (requested !== undefined) {
15
+ const valid =
16
+ typeof requested === "string"
17
+ ? requested === canonicalResource
18
+ : requested.length === 1 && requested[0] === canonicalResource;
19
+ if (!valid)
20
+ throw new OAuthError("invalid_request", {
21
+ description: "The requested OAuth resource is not this Wikimemory MCP endpoint"
22
+ });
23
+ }
24
+ return {
25
+ responseType: auth.responseType,
26
+ clientId: auth.clientId,
27
+ redirectUri: auth.redirectUri,
28
+ scope: auth.scope,
29
+ state: auth.state,
30
+ ...(auth.codeChallenge === undefined ? {} : { codeChallenge: auth.codeChallenge }),
31
+ ...(auth.codeChallengeMethod === undefined
32
+ ? {}
33
+ : { codeChallengeMethod: auth.codeChallengeMethod }),
34
+ resource: canonicalResource
35
+ };
36
+ }
@@ -0,0 +1,27 @@
1
+ import { isRecord } from "./guards";
2
+
3
+ function canonicalize(value: unknown): unknown {
4
+ if (Array.isArray(value)) return value.map(canonicalize);
5
+ if (isRecord(value)) {
6
+ return Object.fromEntries(
7
+ Object.entries(value)
8
+ .sort(([a], [b]) => a.localeCompare(b))
9
+ .map(([key, child]) => [key, canonicalize(child)])
10
+ );
11
+ }
12
+ return value;
13
+ }
14
+
15
+ export function canonicalJson(value: unknown): string {
16
+ return JSON.stringify(canonicalize(value));
17
+ }
18
+
19
+ export async function sha256(value: string): Promise<string> {
20
+ const bytes = new TextEncoder().encode(value);
21
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
22
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
23
+ }
24
+
25
+ export async function requestHash(value: unknown): Promise<string> {
26
+ return sha256(canonicalJson(value));
27
+ }
@@ -0,0 +1,32 @@
1
+ export type DomainErrorCode =
2
+ | "not_found"
3
+ | "already_exists"
4
+ | "revision_conflict"
5
+ | "idempotency_mismatch"
6
+ | "gone"
7
+ | "validation_failed"
8
+ | "secret_detected"
9
+ | "forbidden"
10
+ | "reauthentication_required"
11
+ | "limit_exceeded"
12
+ | "internal_error";
13
+
14
+ export class DomainError extends Error {
15
+ readonly code: DomainErrorCode;
16
+ readonly details: Readonly<Record<string, unknown>>;
17
+
18
+ constructor(
19
+ code: DomainErrorCode,
20
+ message: string,
21
+ details: Readonly<Record<string, unknown>> = {}
22
+ ) {
23
+ super(message);
24
+ this.name = "DomainError";
25
+ this.code = code;
26
+ this.details = details;
27
+ }
28
+ }
29
+
30
+ export function isDomainError(error: unknown): error is DomainError {
31
+ return error instanceof DomainError;
32
+ }
@@ -0,0 +1,363 @@
1
+ import { DomainError } from "./errors";
2
+ import { isRecord } from "./guards";
3
+ import type { ActorContext, DocumentType, LinkKind } from "./types";
4
+
5
+ const MAX_EXPORT_ROWS = 10_000;
6
+
7
+ function requireRead(actor: ActorContext): void {
8
+ if (!actor.scopes.has("memory:read"))
9
+ throw new DomainError("forbidden", "Missing required scope memory:read");
10
+ }
11
+
12
+ function safeAuditDetail(value: string): Record<string, string | number | boolean> {
13
+ try {
14
+ const parsed: unknown = JSON.parse(value);
15
+ if (!isRecord(parsed)) return {};
16
+ const detail: Record<string, string | number | boolean> = {};
17
+ if (typeof parsed["revisionNumber"] === "number")
18
+ detail["revisionNumber"] = parsed["revisionNumber"];
19
+ if (typeof parsed["creating"] === "boolean") detail["creating"] = parsed["creating"];
20
+ if (typeof parsed["purgedRevisions"] === "number")
21
+ detail["purgedRevisions"] = parsed["purgedRevisions"];
22
+ if (typeof parsed["requestHash"] === "string") detail["requestHash"] = parsed["requestHash"];
23
+ return detail;
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ function aliasMap(values: string[], prefix: string): Map<string, string> {
30
+ return new Map(
31
+ [...new Set(values)].sort().map((value, index) => [value, `${prefix}-${index + 1}`])
32
+ );
33
+ }
34
+
35
+ function archiveLine(value: Record<string, unknown>): string {
36
+ return JSON.stringify(value);
37
+ }
38
+
39
+ async function bounded<T>(statement: D1PreparedStatement, label: string): Promise<T[]> {
40
+ const rows = await statement.all<T>();
41
+ if (rows.results.length > MAX_EXPORT_ROWS)
42
+ throw new DomainError("limit_exceeded", `${label} exceeds the V1 export limit`);
43
+ return rows.results;
44
+ }
45
+
46
+ interface PrincipalRow {
47
+ id: string;
48
+ }
49
+ interface WorkspaceRow {
50
+ id: string;
51
+ name: string;
52
+ created_at: string;
53
+ }
54
+ interface MembershipRow {
55
+ principal_id: string;
56
+ role: string;
57
+ created_at: string;
58
+ }
59
+ interface DocumentRow {
60
+ id: string;
61
+ type: DocumentType;
62
+ slug: string;
63
+ created_at: string;
64
+ }
65
+ interface RevisionRow {
66
+ id: string;
67
+ doc_id: string;
68
+ revision_number: number;
69
+ parent_revision_id: string | null;
70
+ title: string;
71
+ body: string;
72
+ summary: string | null;
73
+ created_at: string;
74
+ principal_id: string;
75
+ client_id: string;
76
+ agent_label: string | null;
77
+ reason: string;
78
+ request_hash: string;
79
+ restored_from_revision_id: string | null;
80
+ }
81
+ interface MetadataRow {
82
+ revision_id: string;
83
+ key: string;
84
+ value: string;
85
+ cardinality: "singleton" | "multi";
86
+ }
87
+ interface LinkRow {
88
+ revision_id: string;
89
+ kind: LinkKind;
90
+ target_slug: string;
91
+ target_document_id: string | null;
92
+ origin: "explicit" | "body";
93
+ }
94
+ interface AuditRow {
95
+ id: string;
96
+ kind: string;
97
+ created_at: string;
98
+ principal_id: string | null;
99
+ client_id: string | null;
100
+ agent_label: string | null;
101
+ document_id: string | null;
102
+ revision_id: string | null;
103
+ request_id: string;
104
+ detail_json: string;
105
+ }
106
+ interface TombstoneRow {
107
+ operation_id: string;
108
+ request_hash: string;
109
+ principal_id: string;
110
+ kind: string;
111
+ created_at: string;
112
+ }
113
+
114
+ export class ExportService {
115
+ constructor(private readonly db: D1Database) {}
116
+
117
+ async jsonl(actor: ActorContext): Promise<string> {
118
+ requireRead(actor);
119
+ const [
120
+ workspace,
121
+ principals,
122
+ memberships,
123
+ documents,
124
+ revisions,
125
+ metadata,
126
+ links,
127
+ audits,
128
+ tombstones
129
+ ] = await Promise.all([
130
+ this.db
131
+ .prepare("SELECT id, name, created_at FROM workspaces WHERE id = ?")
132
+ .bind(actor.workspaceId)
133
+ .first<WorkspaceRow>(),
134
+ bounded<PrincipalRow>(
135
+ this.db
136
+ .prepare(`SELECT DISTINCT p.id FROM principals p WHERE p.id IN (
137
+ SELECT principal_id FROM memberships WHERE workspace_id = ?
138
+ UNION SELECT principal_id FROM revisions WHERE workspace_id = ?
139
+ UNION SELECT principal_id FROM audit_events WHERE workspace_id = ? AND principal_id IS NOT NULL
140
+ ) ORDER BY p.id LIMIT ?`)
141
+ .bind(actor.workspaceId, actor.workspaceId, actor.workspaceId, MAX_EXPORT_ROWS + 1),
142
+ "principals"
143
+ ),
144
+ bounded<MembershipRow>(
145
+ this.db
146
+ .prepare(
147
+ "SELECT principal_id, role, created_at FROM memberships WHERE workspace_id = ? ORDER BY principal_id LIMIT ?"
148
+ )
149
+ .bind(actor.workspaceId, MAX_EXPORT_ROWS + 1),
150
+ "memberships"
151
+ ),
152
+ bounded<DocumentRow>(
153
+ this.db
154
+ .prepare(
155
+ "SELECT id, type, slug, created_at FROM documents WHERE workspace_id = ? ORDER BY slug LIMIT ?"
156
+ )
157
+ .bind(actor.workspaceId, MAX_EXPORT_ROWS + 1),
158
+ "documents"
159
+ ),
160
+ bounded<RevisionRow>(
161
+ this.db
162
+ .prepare(`SELECT id, doc_id, revision_number, parent_revision_id, title, body, summary,
163
+ created_at, principal_id, client_id, agent_label, reason, request_hash, restored_from_revision_id
164
+ FROM revisions WHERE workspace_id = ? ORDER BY doc_id, revision_number LIMIT ?`)
165
+ .bind(actor.workspaceId, MAX_EXPORT_ROWS + 1),
166
+ "revisions"
167
+ ),
168
+ bounded<MetadataRow>(
169
+ this.db
170
+ .prepare(
171
+ "SELECT revision_id, key, value, cardinality FROM revision_metadata WHERE workspace_id = ? ORDER BY revision_id, key, value LIMIT ?"
172
+ )
173
+ .bind(actor.workspaceId, MAX_EXPORT_ROWS + 1),
174
+ "metadata values"
175
+ ),
176
+ bounded<LinkRow>(
177
+ this.db
178
+ .prepare(
179
+ "SELECT revision_id, kind, target_slug, target_document_id, origin FROM revision_links WHERE workspace_id = ? ORDER BY revision_id, kind, target_slug, origin LIMIT ?"
180
+ )
181
+ .bind(actor.workspaceId, MAX_EXPORT_ROWS + 1),
182
+ "links"
183
+ ),
184
+ bounded<AuditRow>(
185
+ this.db
186
+ .prepare(`SELECT id, kind, created_at, principal_id, client_id, agent_label,
187
+ document_id, revision_id, request_id, detail_json FROM audit_events
188
+ WHERE workspace_id = ? ORDER BY created_at, id LIMIT ?`)
189
+ .bind(actor.workspaceId, MAX_EXPORT_ROWS + 1),
190
+ "audit events"
191
+ ),
192
+ bounded<TombstoneRow>(
193
+ this.db
194
+ .prepare(`SELECT operation_id, request_hash, principal_id, kind, created_at
195
+ FROM operations WHERE workspace_id = ? AND status = 'purged' ORDER BY created_at, operation_id LIMIT ?`)
196
+ .bind(actor.workspaceId, MAX_EXPORT_ROWS + 1),
197
+ "purge tombstones"
198
+ )
199
+ ]);
200
+ if (workspace === null) throw new DomainError("not_found", "Workspace does not exist");
201
+
202
+ const actorAliases = aliasMap(
203
+ principals.map((row) => row.id),
204
+ "actor"
205
+ );
206
+ const clientAliases = aliasMap(
207
+ [
208
+ ...revisions.map((row) => row.client_id),
209
+ ...audits.flatMap((row) => (row.client_id === null ? [] : [row.client_id]))
210
+ ],
211
+ "client"
212
+ );
213
+ const lines: string[] = [];
214
+ lines.push(
215
+ archiveLine({
216
+ record: "manifest",
217
+ format: "wikimemory-jsonl",
218
+ schemaVersion: 1,
219
+ exportedAt: new Date().toISOString(),
220
+ workspaceRef: "workspace-1",
221
+ limits: { maxRowsPerKind: MAX_EXPORT_ROWS }
222
+ })
223
+ );
224
+ lines.push(
225
+ archiveLine({
226
+ record: "workspace",
227
+ workspaceRef: "workspace-1",
228
+ name: workspace.name,
229
+ createdAt: workspace.created_at
230
+ })
231
+ );
232
+ for (const row of principals)
233
+ lines.push(
234
+ archiveLine({
235
+ record: "principal",
236
+ principalRef: actorAliases.get(row.id),
237
+ label: actorAliases.get(row.id)
238
+ })
239
+ );
240
+ for (const row of memberships)
241
+ lines.push(
242
+ archiveLine({
243
+ record: "membership",
244
+ workspaceRef: "workspace-1",
245
+ principalRef: actorAliases.get(row.principal_id),
246
+ role: row.role,
247
+ createdAt: row.created_at
248
+ })
249
+ );
250
+ for (const clientRef of clientAliases.values())
251
+ lines.push(archiveLine({ record: "client", clientRef }));
252
+ for (const row of documents)
253
+ lines.push(
254
+ archiveLine({
255
+ record: "document",
256
+ documentId: row.id,
257
+ type: row.type,
258
+ slug: row.slug,
259
+ createdAt: row.created_at
260
+ })
261
+ );
262
+ for (const row of revisions)
263
+ lines.push(
264
+ archiveLine({
265
+ record: "revision",
266
+ revisionId: row.id,
267
+ documentId: row.doc_id,
268
+ revisionNumber: row.revision_number,
269
+ parentRevisionId: row.parent_revision_id,
270
+ title: row.title,
271
+ body: row.body,
272
+ summary: row.summary,
273
+ createdAt: row.created_at,
274
+ principalRef: actorAliases.get(row.principal_id),
275
+ clientRef: clientAliases.get(row.client_id),
276
+ agentLabel: row.agent_label,
277
+ reason: row.reason,
278
+ requestHash: row.request_hash,
279
+ restoredFromRevisionId: row.restored_from_revision_id
280
+ })
281
+ );
282
+ for (const row of metadata)
283
+ lines.push(
284
+ archiveLine({
285
+ record: "metadata",
286
+ revisionId: row.revision_id,
287
+ key: row.key,
288
+ value: row.value,
289
+ cardinality: row.cardinality
290
+ })
291
+ );
292
+ for (const row of links)
293
+ lines.push(
294
+ archiveLine({
295
+ record: "link",
296
+ revisionId: row.revision_id,
297
+ kind: row.kind,
298
+ targetSlug: row.target_slug,
299
+ targetDocumentId: row.target_document_id,
300
+ origin: row.origin
301
+ })
302
+ );
303
+ for (const row of audits)
304
+ lines.push(
305
+ archiveLine({
306
+ record: "audit",
307
+ auditId: row.id,
308
+ kind: row.kind,
309
+ createdAt: row.created_at,
310
+ principalRef: row.principal_id === null ? null : actorAliases.get(row.principal_id),
311
+ clientRef: row.client_id === null ? null : clientAliases.get(row.client_id),
312
+ agentLabel: row.agent_label,
313
+ documentId: row.document_id,
314
+ revisionId: row.revision_id,
315
+ requestId: row.request_id,
316
+ detail: safeAuditDetail(row.detail_json)
317
+ })
318
+ );
319
+ for (const row of tombstones)
320
+ lines.push(
321
+ archiveLine({
322
+ record: "purge_tombstone",
323
+ operationId: row.operation_id,
324
+ requestHash: row.request_hash,
325
+ principalRef: actorAliases.get(row.principal_id),
326
+ kind: row.kind,
327
+ createdAt: row.created_at
328
+ })
329
+ );
330
+ return `${lines.join("\n")}\n`;
331
+ }
332
+
333
+ async markdown(actor: ActorContext): Promise<string> {
334
+ requireRead(actor);
335
+ const rows = await bounded<{
336
+ slug: string;
337
+ type: DocumentType;
338
+ title: string;
339
+ body: string;
340
+ summary: string | null;
341
+ revision_number: number;
342
+ created_at: string;
343
+ }>(
344
+ this.db
345
+ .prepare(`SELECT d.slug, d.type, r.title, r.body, r.summary, r.revision_number, r.created_at
346
+ FROM documents d JOIN current_revisions r ON r.doc_id = d.id
347
+ WHERE d.workspace_id = ? ORDER BY d.type, d.slug LIMIT ?`)
348
+ .bind(actor.workspaceId, MAX_EXPORT_ROWS + 1),
349
+ "documents"
350
+ );
351
+ const generatedAt = new Date().toISOString();
352
+ const index = rows
353
+ .map((row) => `- [${row.title}](#${row.slug}) — ${row.type}, revision ${row.revision_number}`)
354
+ .join("\n");
355
+ const pages = rows
356
+ .map(
357
+ (row) =>
358
+ `\n---\n\n<a id="${row.slug}"></a>\n\n# ${row.title}\n\n- Slug: \`${row.slug}\`\n- Type: \`${row.type}\`\n- Revision: ${row.revision_number}\n- Updated: ${row.created_at}\n${row.summary === null ? "" : `\n> ${row.summary.replaceAll("\n", " ")}\n`}\n${row.body}\n`
359
+ )
360
+ .join("");
361
+ return `# Wikimemory export\n\nGenerated ${generatedAt}. This is a current-state convenience export; JSONL is the lossless history archive.\n\n## Index\n\n${index || "_(empty)_"}\n${pages}`;
362
+ }
363
+ }
@@ -0,0 +1,9 @@
1
+ import type { MemoryScope } from "./types";
2
+
3
+ export function isRecord(value: unknown): value is Record<string, unknown> {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+
7
+ export function isMemoryScope(value: string): value is MemoryScope {
8
+ return value === "memory:read" || value === "memory:write" || value === "memory:admin";
9
+ }