@gmickel/gno 1.32.0 → 1.34.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 (54) hide show
  1. package/README.md +17 -3
  2. package/assets/skill/SKILL.md +30 -0
  3. package/assets/skill/cli-reference.md +10 -2
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.34.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.34.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +5 -1
  8. package/spec/cli.md +60 -1
  9. package/spec/mcp.md +21 -0
  10. package/spec/output-schemas/audit-report.schema.json +284 -0
  11. package/spec/output-schemas/publish-artifact.schema.json +76 -1
  12. package/src/cli/commands/audit.ts +231 -0
  13. package/src/cli/commands/publish.ts +43 -7
  14. package/src/cli/errors.ts +9 -2
  15. package/src/cli/program.ts +112 -0
  16. package/src/core/audit-contract.ts +296 -0
  17. package/src/core/audit-freshness.ts +233 -0
  18. package/src/core/audit-links.ts +222 -0
  19. package/src/core/audit-provenance.ts +154 -0
  20. package/src/core/audit-report.ts +318 -0
  21. package/src/core/audit-workspace.ts +678 -0
  22. package/src/core/audit.ts +569 -0
  23. package/src/core/capture.ts +196 -3
  24. package/src/core/document-capabilities.ts +9 -8
  25. package/src/core/record-metadata.ts +33 -0
  26. package/src/ingestion/strip.ts +152 -26
  27. package/src/mcp/http-egress.ts +8 -0
  28. package/src/mcp/tools/audit.ts +97 -0
  29. package/src/mcp/tools/index.ts +13 -0
  30. package/src/publish/artifact-asset-codec.ts +75 -0
  31. package/src/publish/artifact-asset-contract.ts +152 -0
  32. package/src/publish/artifact-asset-parse.ts +401 -0
  33. package/src/publish/artifact-asset-sniff.ts +108 -0
  34. package/src/publish/artifact-asset-validate.ts +209 -0
  35. package/src/publish/artifact-assets.ts +58 -0
  36. package/src/publish/artifact-validation.ts +32 -6
  37. package/src/publish/artifact.ts +50 -3
  38. package/src/publish/attachment-bundle.ts +145 -0
  39. package/src/publish/attachment-discover.ts +203 -0
  40. package/src/publish/attachment-load.ts +133 -0
  41. package/src/publish/attachment-obsidian.ts +45 -0
  42. package/src/publish/attachment-path.ts +334 -0
  43. package/src/publish/attachment-raster.ts +852 -0
  44. package/src/publish/attachment-resolver.ts +280 -0
  45. package/src/publish/attachment-types.ts +54 -0
  46. package/src/publish/encrypted-export.ts +121 -44
  47. package/src/publish/export-attachments.ts +224 -0
  48. package/src/publish/export-service.ts +142 -80
  49. package/src/publish/obsidian-sanitize.ts +121 -13
  50. package/src/serve/routes/api.ts +2 -1
  51. package/src/store/sqlite/adapter.ts +82 -0
  52. package/src/store/sqlite/graph-link-bulk-resolver.ts +191 -0
  53. package/src/store/sqlite/graph-link-resolver.ts +241 -2
  54. package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
@@ -2,12 +2,23 @@
2
2
  * Obsidian-aware markdown pre-processor for publish export.
3
3
  *
4
4
  * Strips wikilinks, drops navigation sidebar idioms, removes references to
5
- * private (`_internal/`) paths, and warns on unresolvable image embeds before
6
- * the markdown enters the publish artifact.
5
+ * private (`_internal/`) paths, and resolves/bundles local raster embeds when
6
+ * an attachment context is provided (otherwise drops image embeds with a
7
+ * warning for backward compatibility).
7
8
  *
8
9
  * @module src/publish/obsidian-sanitize
9
10
  */
10
11
 
12
+ import { discoverImageOccurrences } from "./attachment-discover";
13
+ import { safePercentDecode } from "./attachment-path";
14
+ import {
15
+ rewriteAttachmentsInMarkdown,
16
+ type AttachmentDiagnostic,
17
+ type AttachmentResolveContext,
18
+ type AttachmentRewriteResult,
19
+ type PendingAssetPayload,
20
+ } from "./attachment-resolver";
21
+
11
22
  const WIKILINK_INTERNAL_PREFIX = /_internal\//i;
12
23
  const NAV_SIDEBAR_LINE = /^(!?\[\[[^\]]+\]\]\s*\|?\s*)+$/;
13
24
  const IMAGE_EMBED = /!\[\[([^\]]+)\]\]/g;
@@ -16,6 +27,7 @@ const ALIASED_WIKILINK = /\[\[([^\]|]+)\|([^\]]+)\]\]/g;
16
27
  const BARE_WIKILINK = /\[\[([^\]]+)\]\]/g;
17
28
  const TAIL_SEGMENT = /[^/]+$/;
18
29
  const BLOCK_ID_SUFFIX = /#\^?[\w-]+$/;
30
+ const EXTERNAL_DESTINATION = /^(?:\/\/|[a-z][a-z0-9+.-]*:)/iu;
19
31
 
20
32
  export interface SanitizeWarning {
21
33
  kind:
@@ -31,6 +43,13 @@ export interface SanitizeResult {
31
43
  warnings: SanitizeWarning[];
32
44
  }
33
45
 
46
+ export interface PublishSanitizeResult extends SanitizeResult {
47
+ diagnostics: AttachmentDiagnostic[];
48
+ externalCount: number;
49
+ payloads: Map<string, PendingAssetPayload>;
50
+ preDedupRawBytes: number;
51
+ }
52
+
34
53
  const splitFrontmatter = (
35
54
  source: string
36
55
  ): { body: string; frontmatter: string } => {
@@ -61,8 +80,57 @@ const deriveLinkDisplay = (target: string): string => {
61
80
  return tail.trim() || raw;
62
81
  };
63
82
 
64
- export function sanitizeObsidianMarkdown(source: string): SanitizeResult {
65
- const { body, frontmatter } = splitFrontmatter(source);
83
+ const isPrivateImageReference = (sourceRef: string): boolean => {
84
+ const trimmed = sourceRef.trim();
85
+ if (EXTERNAL_DESTINATION.test(trimmed)) {
86
+ return false;
87
+ }
88
+ const decoded = safePercentDecode(trimmed);
89
+ if (decoded === null) {
90
+ return false;
91
+ }
92
+ return (
93
+ decoded
94
+ .split(/[?#]/u, 1)[0]
95
+ ?.split("/")
96
+ .some((segment) => segment.toLowerCase() === "_internal") ?? false
97
+ );
98
+ };
99
+
100
+ /**
101
+ * Remove private image references before attachment resolution can read bytes
102
+ * or replace the authored path with an opaque gno-asset sentinel.
103
+ */
104
+ const stripPrivateImageReferences = (
105
+ body: string
106
+ ): { markdown: string; warnings: SanitizeWarning[] } => {
107
+ const replacements = discoverImageOccurrences(body)
108
+ .filter((occurrence) => isPrivateImageReference(occurrence.sourceRef))
109
+ .map((occurrence) => ({
110
+ detail: occurrence.sourceRef.trim(),
111
+ end: occurrence.end,
112
+ start: occurrence.start,
113
+ }));
114
+ let markdown = body;
115
+ for (const replacement of [...replacements].sort(
116
+ (left, right) => right.start - left.start
117
+ )) {
118
+ markdown =
119
+ markdown.slice(0, replacement.start) + markdown.slice(replacement.end);
120
+ }
121
+ return {
122
+ markdown,
123
+ warnings: replacements.map((replacement) => ({
124
+ detail: replacement.detail,
125
+ kind: "internal-reference-stripped" as const,
126
+ })),
127
+ };
128
+ };
129
+
130
+ const applyWikilinkSanitize = (
131
+ body: string,
132
+ options: { dropImageEmbeds: boolean }
133
+ ): { markdown: string; warnings: SanitizeWarning[] } => {
66
134
  const warnings: SanitizeWarning[] = [];
67
135
  const lines = body.split("\n");
68
136
  const output: string[] = [];
@@ -81,13 +149,15 @@ export function sanitizeObsidianMarkdown(source: string): SanitizeResult {
81
149
 
82
150
  let line = rawLine;
83
151
 
84
- line = line.replace(IMAGE_EMBED, (_match, target: string) => {
85
- warnings.push({
86
- kind: "image-embed-dropped",
87
- detail: target.trim(),
152
+ if (options.dropImageEmbeds) {
153
+ line = line.replace(IMAGE_EMBED, (_match, target: string) => {
154
+ warnings.push({
155
+ kind: "image-embed-dropped",
156
+ detail: target.trim(),
157
+ });
158
+ return "";
88
159
  });
89
- return "";
90
- });
160
+ }
91
161
 
92
162
  line = line.replace(INTERNAL_WIKILINK, (match) => {
93
163
  warnings.push({
@@ -134,9 +204,46 @@ export function sanitizeObsidianMarkdown(source: string): SanitizeResult {
134
204
  output.push(line);
135
205
  }
136
206
 
207
+ return { markdown: output.join("\n"), warnings };
208
+ };
209
+
210
+ /**
211
+ * Legacy sync sanitizer: drops Obsidian image embeds and converts wikilinks.
212
+ * Prefer `sanitizePublishMarkdown` when attachment bundling is enabled.
213
+ */
214
+ export function sanitizeObsidianMarkdown(source: string): SanitizeResult {
215
+ const { body, frontmatter } = splitFrontmatter(source);
216
+ const sanitized = applyWikilinkSanitize(body, { dropImageEmbeds: true });
217
+ return {
218
+ markdown: `${frontmatter}${sanitized.markdown}`,
219
+ warnings: sanitized.warnings,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Parser-aware publish sanitize: resolve/rewrite local rasters in the same
225
+ * pass as Obsidian cleanup (image discovery is not a second independent scan).
226
+ */
227
+ export async function sanitizePublishMarkdown(
228
+ source: string,
229
+ attachmentCtx: AttachmentResolveContext
230
+ ): Promise<PublishSanitizeResult> {
231
+ const { body, frontmatter } = splitFrontmatter(source);
232
+ const privateImages = stripPrivateImageReferences(body);
233
+ const rewritten: AttachmentRewriteResult = await rewriteAttachmentsInMarkdown(
234
+ privateImages.markdown,
235
+ attachmentCtx
236
+ );
237
+ const sanitized = applyWikilinkSanitize(rewritten.markdown, {
238
+ dropImageEmbeds: false,
239
+ });
137
240
  return {
138
- markdown: `${frontmatter}${output.join("\n")}`,
139
- warnings,
241
+ diagnostics: rewritten.diagnostics,
242
+ externalCount: rewritten.externalCount,
243
+ markdown: `${frontmatter}${sanitized.markdown}`,
244
+ payloads: rewritten.payloads,
245
+ preDedupRawBytes: rewritten.preDedupRawBytes,
246
+ warnings: [...privateImages.warnings, ...sanitized.warnings],
140
247
  };
141
248
  }
142
249
 
@@ -159,7 +266,8 @@ export function formatSanitizeWarnings(warnings: SanitizeWarning[]): string[] {
159
266
  }
160
267
 
161
268
  const labels: Record<SanitizeWarning["kind"], string> = {
162
- "image-embed-dropped": "Image embeds dropped (attachments not bundled yet)",
269
+ "image-embed-dropped":
270
+ "Image embeds dropped (unsupported, missing, or unresolved)",
163
271
  "internal-reference-stripped": "Private `_internal/` references stripped",
164
272
  "nav-sidebar-dropped": "Navigation sidebar lines dropped",
165
273
  "wikilink-unresolved":
@@ -1280,7 +1280,7 @@ export async function handlePublishExport(
1280
1280
  }
1281
1281
 
1282
1282
  try {
1283
- const { artifact, warnings } = await exportPublishArtifact({
1283
+ const { artifact, assetSummary, warnings } = await exportPublishArtifact({
1284
1284
  collections: config.collections,
1285
1285
  options: {
1286
1286
  encryptionPassphrase: body.encryptionPassphrase,
@@ -1295,6 +1295,7 @@ export async function handlePublishExport(
1295
1295
 
1296
1296
  return jsonResponse({
1297
1297
  artifact,
1298
+ assetSummary,
1298
1299
  fileName: derivePublishArtifactFilename(artifact),
1299
1300
  uploadUrl: "https://gno.sh/studio",
1300
1301
  warnings,
@@ -515,6 +515,28 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
515
515
  }
516
516
  }
517
517
 
518
+ /** Open an existing index with SQLite enforced query-only semantics. */
519
+ openReadOnly(dbPath: string): StoreResult<void> {
520
+ try {
521
+ this.db = new Database(dbPath, { readonly: true, strict: true });
522
+ this.dbPath = dbPath;
523
+ this.db.exec("PRAGMA query_only = ON");
524
+ this.db.exec("PRAGMA busy_timeout = 5000");
525
+ this.contextGeneration += 1;
526
+ return ok(undefined);
527
+ } catch (cause) {
528
+ this.db?.close();
529
+ this.db = null;
530
+ return err(
531
+ "CONNECTION_FAILED",
532
+ cause instanceof Error
533
+ ? cause.message
534
+ : "Failed to open database read-only",
535
+ cause
536
+ );
537
+ }
538
+ }
539
+
518
540
  async close(): Promise<void> {
519
541
  if (this.db) {
520
542
  this.db.close();
@@ -1559,6 +1581,66 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1559
1581
  }
1560
1582
  }
1561
1583
 
1584
+ async listDocumentsForAudit(options: {
1585
+ collections: readonly string[];
1586
+ pathPrefixes: readonly string[];
1587
+ tags: readonly string[];
1588
+ limit: number;
1589
+ }): Promise<StoreResult<{ documents: DocumentRow[]; total: number }>> {
1590
+ try {
1591
+ const db = this.ensureOpen();
1592
+ const conditions = ["d.active = 1"];
1593
+ const params: (string | number)[] = [];
1594
+ if (options.collections.length > 0) {
1595
+ conditions.push("d.collection IN (SELECT value FROM json_each(?))");
1596
+ params.push(JSON.stringify(options.collections));
1597
+ }
1598
+ if (options.pathPrefixes.length > 0) {
1599
+ conditions.push(`EXISTS (
1600
+ SELECT 1 FROM json_each(?) prefix
1601
+ WHERE COALESCE(NULLIF(d.record_source_path, ''), d.rel_path) = prefix.value
1602
+ OR (substr(COALESCE(NULLIF(d.record_source_path, ''), d.rel_path), 1, length(prefix.value)) = prefix.value
1603
+ AND substr(COALESCE(NULLIF(d.record_source_path, ''), d.rel_path), length(prefix.value) + 1, 1) = '/')
1604
+ )`);
1605
+ params.push(JSON.stringify(options.pathPrefixes));
1606
+ }
1607
+ if (options.tags.length > 0) {
1608
+ conditions.push(`NOT EXISTS (
1609
+ SELECT 1 FROM json_each(?) requested_tag
1610
+ WHERE NOT EXISTS (
1611
+ SELECT 1 FROM doc_tags dt
1612
+ WHERE dt.document_id = d.id AND dt.tag = requested_tag.value
1613
+ )
1614
+ )`);
1615
+ params.push(JSON.stringify(options.tags));
1616
+ }
1617
+ const where = conditions.join(" AND ");
1618
+ const total =
1619
+ db
1620
+ .query<{ count: number }, (string | number)[]>(
1621
+ `SELECT COUNT(*) AS count FROM documents d WHERE ${where}`
1622
+ )
1623
+ .get(...params)?.count ?? 0;
1624
+ const rows = db
1625
+ .query<DbDocumentRow, (string | number)[]>(
1626
+ `SELECT d.* FROM documents d
1627
+ WHERE ${where}
1628
+ ORDER BY d.id
1629
+ LIMIT ?`
1630
+ )
1631
+ .all(...params, options.limit);
1632
+ return ok({ documents: rows.map(mapDocumentRow), total });
1633
+ } catch (cause) {
1634
+ return err(
1635
+ "QUERY_FAILED",
1636
+ cause instanceof Error
1637
+ ? cause.message
1638
+ : "Failed to select bounded audit documents",
1639
+ cause
1640
+ );
1641
+ }
1642
+ }
1643
+
1562
1644
  async listRecordDocuments(
1563
1645
  collection: string,
1564
1646
  sourcePath: string
@@ -0,0 +1,191 @@
1
+ /** Linear-time bulk target resolution for large graph-link inventories. */
2
+
3
+ import type { Database } from "bun:sqlite";
4
+
5
+ import type {
6
+ GraphLinkTarget,
7
+ ResolvedGraphLinkTarget,
8
+ } from "./graph-link-resolver";
9
+
10
+ import { stripWikiMdExt } from "../../core/links";
11
+
12
+ interface IndexedDocument {
13
+ id: number;
14
+ docid: string;
15
+ collection: string;
16
+ titleNorm: string | null;
17
+ relNorm: string;
18
+ relRaw: string;
19
+ }
20
+
21
+ type Lookup = Map<string, IndexedDocument[]>;
22
+
23
+ export const GRAPH_LINK_BULK_MAX_DOCUMENTS = 100_000;
24
+
25
+ const lookupKey = (collection: string, value: string): string =>
26
+ `${collection}\0${value}`;
27
+
28
+ const suffixes = (value: string): string[] => {
29
+ const output = [value];
30
+ let separator = value.indexOf("/");
31
+ while (separator >= 0) {
32
+ const suffix = value.slice(separator + 1);
33
+ if (suffix) output.push(suffix);
34
+ separator = value.indexOf("/", separator + 1);
35
+ }
36
+ return output;
37
+ };
38
+
39
+ const appendLookup = (
40
+ lookup: Lookup,
41
+ collection: string,
42
+ value: string,
43
+ document: IndexedDocument
44
+ ): void => {
45
+ const key = lookupKey(collection, value);
46
+ const documents = lookup.get(key) ?? [];
47
+ documents.push(document);
48
+ lookup.set(key, documents);
49
+ };
50
+
51
+ const candidatesFor = (
52
+ lookup: Lookup,
53
+ collection: string,
54
+ values: readonly string[]
55
+ ): IndexedDocument[] => {
56
+ const byId = new Map<number, IndexedDocument>();
57
+ for (const value of values) {
58
+ for (const document of lookup.get(lookupKey(collection, value)) ?? []) {
59
+ byId.set(document.id, document);
60
+ }
61
+ }
62
+ return [...byId.values()].sort((left, right) => left.id - right.id);
63
+ };
64
+
65
+ const resolved = (
66
+ documents: readonly IndexedDocument[],
67
+ matchRank: number
68
+ ): ResolvedGraphLinkTarget | null => {
69
+ const first = documents[0];
70
+ if (!first) return null;
71
+ return {
72
+ targetId: first.id,
73
+ targetDocid: first.docid,
74
+ matchRank,
75
+ matchCount: documents.length,
76
+ };
77
+ };
78
+
79
+ /**
80
+ * Resolve a large target set with lookup tables equivalent to the ranked SQL
81
+ * resolver. SQLite still performs lower()/trim(), preserving its normalization
82
+ * semantics; resolution then scales with documents plus path segments.
83
+ */
84
+ export const resolveGraphLinkTargetsBulk = (
85
+ db: Database,
86
+ targets: readonly GraphLinkTarget[],
87
+ maxDocuments = GRAPH_LINK_BULK_MAX_DOCUMENTS
88
+ ): Array<ResolvedGraphLinkTarget | null> | null => {
89
+ const boundedMaxDocuments = Math.max(
90
+ 1,
91
+ Math.min(GRAPH_LINK_BULK_MAX_DOCUMENTS, maxDocuments)
92
+ );
93
+ const rows = db
94
+ .query<
95
+ {
96
+ id: number;
97
+ docid: string;
98
+ collection: string;
99
+ title_norm: string | null;
100
+ rel_norm: string;
101
+ rel_raw: string;
102
+ },
103
+ [number]
104
+ >(
105
+ `SELECT id, docid, collection, lower(trim(title)) AS title_norm,
106
+ lower(rel_path) AS rel_norm, rel_path AS rel_raw
107
+ FROM documents
108
+ WHERE active = 1
109
+ ORDER BY id
110
+ LIMIT ?`
111
+ )
112
+ .all(boundedMaxDocuments + 1);
113
+ // The fast lookup-table path is intentionally bounded. Callers fall back to
114
+ // set-oriented SQL batches when the active index exceeds this memory cap.
115
+ if (rows.length > boundedMaxDocuments) return null;
116
+ const titleExact: Lookup = new Map();
117
+ const relExact: Lookup = new Map();
118
+ const relExactRaw: Lookup = new Map();
119
+ const relSuffix: Lookup = new Map();
120
+ for (const row of rows) {
121
+ const document: IndexedDocument = {
122
+ id: row.id,
123
+ docid: row.docid,
124
+ collection: row.collection,
125
+ titleNorm: row.title_norm,
126
+ relNorm: row.rel_norm,
127
+ relRaw: row.rel_raw,
128
+ };
129
+ if (document.titleNorm !== null) {
130
+ appendLookup(titleExact, row.collection, document.titleNorm, document);
131
+ }
132
+ appendLookup(relExact, row.collection, document.relNorm, document);
133
+ appendLookup(relExactRaw, row.collection, document.relRaw, document);
134
+ for (const suffix of suffixes(document.relNorm)) {
135
+ appendLookup(relSuffix, row.collection, suffix, document);
136
+ }
137
+ }
138
+
139
+ const cache = new Map<string, ResolvedGraphLinkTarget | null>();
140
+ return targets.map((target) => {
141
+ const cacheKey = JSON.stringify([
142
+ target.linkType,
143
+ target.targetCollection,
144
+ target.targetRefNorm,
145
+ ]);
146
+ if (cache.has(cacheKey)) return cache.get(cacheKey) ?? null;
147
+ let result: ResolvedGraphLinkTarget | null;
148
+ if (target.linkType === "markdown") {
149
+ result = resolved(
150
+ candidatesFor(relExactRaw, target.targetCollection, [
151
+ target.targetRefNorm,
152
+ ]),
153
+ 5
154
+ );
155
+ } else {
156
+ const baseRef = stripWikiMdExt(target.targetRefNorm);
157
+ const baseRefMd = `${baseRef}.md`;
158
+ const rankedCandidates: Array<[number, Lookup, string[]]> = [
159
+ [1, titleExact, [baseRef]],
160
+ [2, titleExact, [baseRefMd]],
161
+ [3, titleExact, suffixes(baseRef)],
162
+ [
163
+ 4,
164
+ titleExact,
165
+ suffixes(baseRefMd)
166
+ .filter((value) => value.endsWith(".md"))
167
+ .map((value) => value.slice(0, -3)),
168
+ ],
169
+ [5, relExact, [baseRef]],
170
+ [6, relExact, [baseRefMd]],
171
+ [7, relSuffix, [baseRefMd]],
172
+ [8, relSuffix, [baseRef]],
173
+ [9, relExact, suffixes(baseRefMd)],
174
+ [10, relExact, suffixes(baseRef)],
175
+ ];
176
+ result = null;
177
+ for (const [rank, lookup, values] of rankedCandidates) {
178
+ const documents = candidatesFor(
179
+ lookup,
180
+ target.targetCollection,
181
+ values
182
+ );
183
+ if (documents.length === 0) continue;
184
+ result = resolved(documents, rank);
185
+ break;
186
+ }
187
+ }
188
+ cache.set(cacheKey, result);
189
+ return result;
190
+ });
191
+ };