@gmickel/gno 0.41.0 → 0.42.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.
@@ -27,6 +27,7 @@ export const CMD = {
27
27
  multiGet: "multi-get",
28
28
  ls: "ls",
29
29
  status: "status",
30
+ publishExport: "publish.export",
30
31
  collectionList: "collection.list",
31
32
  contextList: "context.list",
32
33
  contextCheck: "context.check",
@@ -49,6 +50,7 @@ const FORMAT_SUPPORT: Record<CommandId, OutputFormat[]> = {
49
50
  [CMD.multiGet]: ["terminal", "json", "files", "md"],
50
51
  [CMD.ls]: ["terminal", "json", "files", "md"],
51
52
  [CMD.status]: ["terminal", "json"],
53
+ [CMD.publishExport]: ["terminal", "json"],
52
54
  [CMD.collectionList]: ["terminal", "json", "md"],
53
55
  [CMD.contextList]: ["terminal", "json", "md"],
54
56
  [CMD.contextCheck]: ["terminal", "json", "md"],
@@ -216,6 +216,7 @@ export function createProgram(): Command {
216
216
  wireSearchCommands(program);
217
217
  wireOnboardingCommands(program);
218
218
  wireManagementCommands(program);
219
+ wirePublishCommand(program);
219
220
  wireVecCommands(program);
220
221
  wireRetrievalCommands(program);
221
222
  wireTagsCommands(program);
@@ -1623,6 +1624,77 @@ function wireVecCommands(program: Command): void {
1623
1624
  });
1624
1625
  }
1625
1626
 
1627
+ function wirePublishCommand(program: Command): void {
1628
+ const visibilityValues = [
1629
+ "public",
1630
+ "secret-link",
1631
+ "invite-only",
1632
+ "encrypted",
1633
+ ] as const;
1634
+ const publishCmd = program
1635
+ .command("publish")
1636
+ .description("Export publish artifacts for gno.sh");
1637
+
1638
+ publishCmd
1639
+ .command("export <target>")
1640
+ .description("Export a collection or document ref as a gno.sh artifact")
1641
+ .option("--out <path>", "output artifact path")
1642
+ .option(
1643
+ "--visibility <mode>",
1644
+ "visibility hint (public, secret-link, invite-only, encrypted)",
1645
+ "public"
1646
+ )
1647
+ .option("--slug <slug>", "route slug override")
1648
+ .option("--title <title>", "space title override")
1649
+ .option("--summary <summary>", "space summary override")
1650
+ .option("--json", "JSON output")
1651
+ .action(async (target: string, cmdOpts: Record<string, unknown>) => {
1652
+ const format = getFormat(cmdOpts);
1653
+ assertFormatSupported(CMD.publishExport, format);
1654
+ const globals = getGlobals();
1655
+
1656
+ const visibility = cmdOpts.visibility as string;
1657
+ if (
1658
+ !visibilityValues.includes(
1659
+ visibility as (typeof visibilityValues)[number]
1660
+ )
1661
+ ) {
1662
+ throw new CliError(
1663
+ "VALIDATION",
1664
+ `Invalid visibility: ${visibility}. Must be public, secret-link, invite-only, or encrypted.`
1665
+ );
1666
+ }
1667
+
1668
+ const { formatPublishExport, publishExport } =
1669
+ await import("./commands/publish");
1670
+ const result = await publishExport(target, {
1671
+ configPath: globals.config,
1672
+ json: format === "json",
1673
+ out: typeof cmdOpts.out === "string" ? cmdOpts.out : undefined,
1674
+ slug: cmdOpts.slug as string | undefined,
1675
+ summary: cmdOpts.summary as string | undefined,
1676
+ title: cmdOpts.title as string | undefined,
1677
+ visibility: visibility as
1678
+ | "encrypted"
1679
+ | "invite-only"
1680
+ | "public"
1681
+ | "secret-link",
1682
+ });
1683
+
1684
+ if (!result.success) {
1685
+ throw new CliError(
1686
+ result.isValidation ? "VALIDATION" : "RUNTIME",
1687
+ result.error
1688
+ );
1689
+ }
1690
+
1691
+ await writeOutput(
1692
+ formatPublishExport(result, { json: format === "json" }),
1693
+ format
1694
+ );
1695
+ });
1696
+ }
1697
+
1626
1698
  // ─────────────────────────────────────────────────────────────────────────────
1627
1699
  // Skill Commands (install, uninstall, show, paths)
1628
1700
  // ─────────────────────────────────────────────────────────────────────────────
@@ -14,8 +14,12 @@ export interface EmbedBatchRecoveryResult {
14
14
  batchFailed: boolean;
15
15
  batchError?: string;
16
16
  fallbackErrors: number;
17
+ failureSamples: string[];
18
+ retrySuggestion?: string;
17
19
  }
18
20
 
21
+ const MAX_FAILURE_SAMPLES = 5;
22
+
19
23
  function errorMessage(error: unknown): string {
20
24
  if (
21
25
  error &&
@@ -28,6 +32,27 @@ function errorMessage(error: unknown): string {
28
32
  return String(error);
29
33
  }
30
34
 
35
+ function formatFailureMessage(error: {
36
+ message: string;
37
+ cause?: unknown;
38
+ }): string {
39
+ const cause = error.cause ? errorMessage(error.cause) : "";
40
+ return cause && cause !== error.message
41
+ ? `${error.message} - ${cause}`
42
+ : error.message;
43
+ }
44
+
45
+ function isDisposedFailure(message: string): boolean {
46
+ return message.toLowerCase().includes("object is disposed");
47
+ }
48
+
49
+ async function resetEmbeddingPort(
50
+ embedPort: EmbeddingPort
51
+ ): Promise<LlmResult<void>> {
52
+ await embedPort.dispose();
53
+ return embedPort.init();
54
+ }
55
+
31
56
  export async function embedTextsWithRecovery(
32
57
  embedPort: EmbeddingPort,
33
58
  texts: string[]
@@ -39,13 +64,24 @@ export async function embedTextsWithRecovery(
39
64
  vectors: [],
40
65
  batchFailed: false,
41
66
  fallbackErrors: 0,
67
+ failureSamples: [],
42
68
  },
43
69
  };
44
70
  }
45
71
 
46
72
  const profile = getEmbeddingCompatibilityProfile(embedPort.modelUri);
47
73
  if (profile.batchEmbeddingTrusted) {
48
- const batchResult = await embedPort.embedBatch(texts);
74
+ let batchResult = await embedPort.embedBatch(texts);
75
+ if (!batchResult.ok) {
76
+ const formattedBatchError = formatFailureMessage(batchResult.error);
77
+ if (isDisposedFailure(formattedBatchError)) {
78
+ const reset = await resetEmbeddingPort(embedPort);
79
+ if (!reset.ok) {
80
+ return reset;
81
+ }
82
+ batchResult = await embedPort.embedBatch(texts);
83
+ }
84
+ }
49
85
  if (batchResult.ok && batchResult.value.length === texts.length) {
50
86
  return {
51
87
  ok: true,
@@ -53,11 +89,14 @@ export async function embedTextsWithRecovery(
53
89
  vectors: batchResult.value,
54
90
  batchFailed: false,
55
91
  fallbackErrors: 0,
92
+ failureSamples: [],
56
93
  },
57
94
  };
58
95
  }
59
96
 
60
- const recovered = await recoverIndividually(embedPort, texts);
97
+ const recovered = await recoverWithAdaptiveBatches(embedPort, texts, {
98
+ rootBatchAlreadyFailed: true,
99
+ });
61
100
  if (!recovered.ok) {
62
101
  return recovered;
63
102
  }
@@ -68,7 +107,11 @@ export async function embedTextsWithRecovery(
68
107
  batchFailed: true,
69
108
  batchError: batchResult.ok
70
109
  ? `Embedding count mismatch: got ${batchResult.value.length}, expected ${texts.length}`
71
- : batchResult.error.message,
110
+ : formatFailureMessage(batchResult.error),
111
+ retrySuggestion:
112
+ recovered.value.fallbackErrors > 0
113
+ ? "Try rerunning the same command. If failures persist, rerun with `gno --verbose embed --batch-size 1` to isolate failing chunks."
114
+ : undefined,
72
115
  },
73
116
  };
74
117
  }
@@ -83,10 +126,113 @@ export async function embedTextsWithRecovery(
83
126
  ...recovered.value,
84
127
  batchFailed: true,
85
128
  batchError: "Batch embedding disabled for this compatibility profile",
129
+ retrySuggestion:
130
+ recovered.value.fallbackErrors > 0
131
+ ? "Some chunks still failed individually. Rerun with `gno --verbose embed --batch-size 1` for exact chunk errors."
132
+ : undefined,
86
133
  },
87
134
  };
88
135
  }
89
136
 
137
+ async function recoverWithAdaptiveBatches(
138
+ embedPort: EmbeddingPort,
139
+ texts: string[],
140
+ options: { rootBatchAlreadyFailed?: boolean } = {}
141
+ ): Promise<
142
+ LlmResult<Omit<EmbedBatchRecoveryResult, "batchFailed" | "batchError">>
143
+ > {
144
+ try {
145
+ const vectors: Array<number[] | null> = Array.from(
146
+ { length: texts.length },
147
+ () => null
148
+ );
149
+ const failureSamples: string[] = [];
150
+ let fallbackErrors = 0;
151
+
152
+ const recordFailure = (message: string): void => {
153
+ if (failureSamples.length < MAX_FAILURE_SAMPLES) {
154
+ failureSamples.push(message);
155
+ }
156
+ };
157
+
158
+ const processRange = async (
159
+ rangeTexts: string[],
160
+ offset: number,
161
+ batchAlreadyFailed = false
162
+ ): Promise<void> => {
163
+ if (rangeTexts.length === 0) {
164
+ return;
165
+ }
166
+
167
+ if (rangeTexts.length === 1) {
168
+ const result = await embedPort.embed(rangeTexts[0] ?? "");
169
+ if (result.ok) {
170
+ vectors[offset] = result.value;
171
+ return;
172
+ }
173
+ fallbackErrors += 1;
174
+ recordFailure(formatFailureMessage(result.error));
175
+ return;
176
+ }
177
+
178
+ let batchResult: Awaited<ReturnType<typeof embedPort.embedBatch>> | null =
179
+ null;
180
+ if (!batchAlreadyFailed) {
181
+ batchResult = await embedPort.embedBatch(rangeTexts);
182
+ }
183
+ if (
184
+ batchResult &&
185
+ batchResult.ok &&
186
+ batchResult.value.length === rangeTexts.length
187
+ ) {
188
+ for (const [index, vector] of batchResult.value.entries()) {
189
+ vectors[offset + index] = vector;
190
+ }
191
+ return;
192
+ }
193
+
194
+ const mid = Math.ceil(rangeTexts.length / 2);
195
+ await processRange(rangeTexts.slice(0, mid), offset);
196
+ await processRange(rangeTexts.slice(mid), offset + mid);
197
+ };
198
+
199
+ await processRange(texts, 0, options.rootBatchAlreadyFailed ?? false);
200
+
201
+ if (fallbackErrors === texts.length) {
202
+ const reinit = await resetEmbeddingPort(embedPort);
203
+ if (!reinit.ok) {
204
+ return reinit;
205
+ }
206
+
207
+ const retry = await recoverIndividually(embedPort, texts);
208
+ if (!retry.ok) {
209
+ return retry;
210
+ }
211
+ return {
212
+ ok: true,
213
+ value: retry.value,
214
+ };
215
+ }
216
+
217
+ return {
218
+ ok: true,
219
+ value: {
220
+ vectors,
221
+ fallbackErrors,
222
+ failureSamples,
223
+ },
224
+ };
225
+ } catch (error) {
226
+ return {
227
+ ok: false,
228
+ error: inferenceFailedError(
229
+ embedPort.modelUri,
230
+ new Error(errorMessage(error))
231
+ ),
232
+ };
233
+ }
234
+ }
235
+
90
236
  async function recoverIndividually(
91
237
  embedPort: EmbeddingPort,
92
238
  texts: string[]
@@ -95,6 +241,7 @@ async function recoverIndividually(
95
241
  > {
96
242
  try {
97
243
  const vectors: Array<number[] | null> = [];
244
+ const failureSamples: string[] = [];
98
245
  let fallbackErrors = 0;
99
246
 
100
247
  for (const text of texts) {
@@ -104,6 +251,9 @@ async function recoverIndividually(
104
251
  } else {
105
252
  vectors.push(null);
106
253
  fallbackErrors += 1;
254
+ if (failureSamples.length < MAX_FAILURE_SAMPLES) {
255
+ failureSamples.push(formatFailureMessage(result.error));
256
+ }
107
257
  }
108
258
  }
109
259
 
@@ -112,6 +262,7 @@ async function recoverIndividually(
112
262
  value: {
113
263
  vectors,
114
264
  fallbackErrors,
265
+ failureSamples,
115
266
  },
116
267
  };
117
268
  } catch (error) {
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Publish artifact types and builders for gno.sh export.
3
+ *
4
+ * @module src/publish/artifact
5
+ */
6
+
7
+ import type { DocumentRow, TagRow } from "../store/types";
8
+
9
+ import { stripFrontmatter } from "../ingestion/frontmatter";
10
+
11
+ export type PublishVisibility =
12
+ | "encrypted"
13
+ | "invite-only"
14
+ | "public"
15
+ | "secret-link";
16
+
17
+ export interface PublishArtifactNote {
18
+ markdown: string;
19
+ metadata?: Record<string, string | string[]>;
20
+ slug: string;
21
+ summary: string;
22
+ title: string;
23
+ }
24
+
25
+ export interface PublishArtifactSpace {
26
+ homeNoteSlug?: string;
27
+ notes: PublishArtifactNote[];
28
+ routeSlug: string;
29
+ sourceType: "note" | "collection";
30
+ summary: string;
31
+ title: string;
32
+ visibility: PublishVisibility;
33
+ }
34
+
35
+ export interface PublishArtifact {
36
+ exportedAt: string;
37
+ source: string;
38
+ spaces: PublishArtifactSpace[];
39
+ version: 1;
40
+ }
41
+
42
+ export const PUBLISH_VISIBILITY_VALUES = [
43
+ "public",
44
+ "secret-link",
45
+ "invite-only",
46
+ "encrypted",
47
+ ] as const;
48
+
49
+ export const MAX_PUBLISH_SLUG_LENGTH = 80;
50
+
51
+ const ALLOWED_FRONTMATTER_METADATA_KEYS = new Set([
52
+ "audience",
53
+ "canonical",
54
+ "canonicalUrl",
55
+ "canonicalURL",
56
+ "coverAlt",
57
+ "coverImage",
58
+ "icon",
59
+ "image",
60
+ "layout",
61
+ "publishedAt",
62
+ "readingTime",
63
+ "series",
64
+ "seriesOrder",
65
+ "status",
66
+ "subtitle",
67
+ "theme",
68
+ "topic",
69
+ "topics",
70
+ ]);
71
+
72
+ export const isPublishVisibility = (
73
+ value: unknown
74
+ ): value is PublishVisibility =>
75
+ typeof value === "string" &&
76
+ PUBLISH_VISIBILITY_VALUES.includes(value as PublishVisibility);
77
+
78
+ export const slugify = (value: string) =>
79
+ value
80
+ .toLowerCase()
81
+ .replace(/[^a-z0-9]+/g, "-")
82
+ .replace(/(^-|-$)/g, "");
83
+
84
+ function toPublishSlugCandidate(value: string): string {
85
+ return slugify(value).slice(0, MAX_PUBLISH_SLUG_LENGTH).replace(/-+$/g, "");
86
+ }
87
+
88
+ export function derivePublishSlug(
89
+ candidates: Array<string>,
90
+ fallback = "untitled"
91
+ ): string {
92
+ for (const candidate of candidates) {
93
+ const slug = toPublishSlugCandidate(candidate);
94
+ if (slug.length > 0) {
95
+ return slug;
96
+ }
97
+ }
98
+
99
+ return fallback;
100
+ }
101
+
102
+ export const normalizePublishSlug = (value: string, fallback?: string) =>
103
+ derivePublishSlug([value], fallback);
104
+
105
+ const basenameWithoutExt = (value: string) =>
106
+ value
107
+ .split("/")
108
+ .pop()
109
+ ?.replace(/\.[^.]+$/, "") ?? value;
110
+
111
+ export const deriveExportedTitle = (
112
+ doc: Pick<DocumentRow, "relPath" | "title">
113
+ ) => doc.title?.trim() || basenameWithoutExt(doc.relPath);
114
+
115
+ export const deriveExportedSlug = (
116
+ doc: Pick<DocumentRow, "relPath" | "title">
117
+ ) =>
118
+ derivePublishSlug([
119
+ deriveExportedTitle(doc),
120
+ doc.relPath.replace(/\.[^.]+$/, "").replaceAll("/", "-"),
121
+ ]);
122
+
123
+ export const deriveExportedSummary = (
124
+ markdown: string,
125
+ metadata: Record<string, unknown>
126
+ ) => {
127
+ const metadataSummary =
128
+ typeof metadata.description === "string"
129
+ ? metadata.description
130
+ : typeof metadata.summary === "string"
131
+ ? metadata.summary
132
+ : null;
133
+ if (metadataSummary?.trim()) {
134
+ return metadataSummary.trim();
135
+ }
136
+
137
+ const plain = stripFrontmatter(markdown)
138
+ .split("\n")
139
+ .map((line) => line.trim())
140
+ .filter(
141
+ (line) =>
142
+ line.length > 0 &&
143
+ !line.startsWith("#") &&
144
+ !line.startsWith("!") &&
145
+ !line.startsWith("```")
146
+ )
147
+ .join(" ")
148
+ .replace(/\s+/g, " ")
149
+ .trim();
150
+
151
+ return plain.slice(0, 200).trim();
152
+ };
153
+
154
+ export const buildExportedMetadata = (
155
+ doc: Pick<
156
+ DocumentRow,
157
+ | "author"
158
+ | "categories"
159
+ | "collection"
160
+ | "contentType"
161
+ | "frontmatterDate"
162
+ | "languageHint"
163
+ | "relPath"
164
+ >,
165
+ parsedFrontmatter: Record<string, unknown>,
166
+ tags: TagRow[]
167
+ ) => {
168
+ const metadata: Record<string, string | string[]> = {};
169
+
170
+ if (doc.author) {
171
+ metadata.author = doc.author;
172
+ }
173
+ if (doc.contentType) {
174
+ metadata.contentType = doc.contentType;
175
+ }
176
+ if (doc.languageHint) {
177
+ metadata.language = doc.languageHint;
178
+ }
179
+ if (doc.frontmatterDate) {
180
+ metadata.date = doc.frontmatterDate;
181
+ }
182
+ if (doc.categories?.length) {
183
+ metadata.categories = doc.categories;
184
+ }
185
+
186
+ const tagValues = tags.map((tag) => tag.tag);
187
+ if (tagValues.length) {
188
+ metadata.tags = tagValues;
189
+ }
190
+
191
+ metadata.collection = doc.collection;
192
+ metadata.sourceRelPath = doc.relPath;
193
+
194
+ for (const [key, value] of Object.entries(parsedFrontmatter)) {
195
+ if (
196
+ key === "tags" ||
197
+ key === "title" ||
198
+ key === "summary" ||
199
+ !ALLOWED_FRONTMATTER_METADATA_KEYS.has(key)
200
+ ) {
201
+ continue;
202
+ }
203
+ if (typeof value === "string" && value.trim()) {
204
+ metadata[key] = value.trim();
205
+ continue;
206
+ }
207
+ if (Array.isArray(value)) {
208
+ const cleaned = value
209
+ .filter((entry): entry is string => typeof entry === "string")
210
+ .map((entry) => entry.trim())
211
+ .filter(Boolean);
212
+ if (cleaned.length > 0) {
213
+ metadata[key] = cleaned;
214
+ }
215
+ }
216
+ }
217
+
218
+ return metadata;
219
+ };
220
+
221
+ export const buildPublishArtifact = (input: {
222
+ homeNoteSlug?: string;
223
+ notes: PublishArtifactNote[];
224
+ routeSlug: string;
225
+ source: string;
226
+ sourceType: "note" | "collection";
227
+ summary: string;
228
+ title: string;
229
+ visibility: PublishVisibility;
230
+ }) => ({
231
+ exportedAt: new Date().toISOString(),
232
+ source: input.source,
233
+ spaces: [
234
+ {
235
+ homeNoteSlug: input.homeNoteSlug,
236
+ notes: input.notes,
237
+ routeSlug: input.routeSlug,
238
+ sourceType: input.sourceType,
239
+ summary: input.summary,
240
+ title: input.title,
241
+ visibility: input.visibility,
242
+ },
243
+ ],
244
+ version: 1 as const,
245
+ });
246
+
247
+ export const derivePublishArtifactFilename = (artifact: PublishArtifact) => {
248
+ const routeSlug =
249
+ artifact.spaces[0]?.routeSlug.trim() ||
250
+ normalizePublishSlug(artifact.source, "publish-artifact");
251
+ return `${routeSlug || "publish-artifact"}.json`;
252
+ };