@gmickel/gno 1.19.0 → 1.21.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 (85) hide show
  1. package/README.md +28 -8
  2. package/assets/skill/SKILL.md +73 -27
  3. package/assets/skill/mcp-reference.md +7 -2
  4. package/assets/skill/recipes/citation-and-provenance.md +32 -9
  5. package/package.json +1 -1
  6. package/spec/cli.md +142 -17
  7. package/spec/db/schema.sql +170 -0
  8. package/spec/evals-agentic.md +87 -5
  9. package/spec/mcp.md +75 -3
  10. package/spec/output-schemas/ask.schema.json +198 -0
  11. package/spec/output-schemas/capsule-reverified-event.schema.json +47 -0
  12. package/spec/output-schemas/changes.schema.json +280 -0
  13. package/spec/output-schemas/claim-verification.schema.json +291 -0
  14. package/spec/output-schemas/context-capsule-v1.schema.json +36 -1
  15. package/spec/output-schemas/document-diff.schema.json +185 -0
  16. package/spec/output-schemas/impact.schema.json +122 -0
  17. package/spec/output-schemas/saved-capsule-list.schema.json +16 -0
  18. package/spec/output-schemas/saved-capsule-registration.schema.json +172 -0
  19. package/spec/output-schemas/saved-capsule-reverification.schema.json +59 -0
  20. package/spec/output-schemas/saved-capsule-unwatch.schema.json +16 -0
  21. package/spec/output-schemas/saved-capsule-watch.schema.json +17 -0
  22. package/src/app/context-runtime-contract.ts +10 -5
  23. package/src/app/context-runtime-input.ts +29 -1
  24. package/src/app/context-runtime-types.ts +4 -0
  25. package/src/app/context-runtime.ts +5 -1
  26. package/src/app/context-surface.ts +4 -0
  27. package/src/app/verified-ask.ts +291 -0
  28. package/src/cli/commands/ask-format.ts +255 -0
  29. package/src/cli/commands/ask.ts +40 -149
  30. package/src/cli/commands/changes.ts +160 -0
  31. package/src/cli/commands/context-saved.ts +189 -0
  32. package/src/cli/options.ts +8 -0
  33. package/src/cli/program.ts +227 -1
  34. package/src/core/capsule-registry.ts +279 -0
  35. package/src/core/capsule-reverification-scheduler.ts +218 -0
  36. package/src/core/capsule-reverification.ts +289 -0
  37. package/src/core/change-diff.ts +182 -0
  38. package/src/core/change-journal.ts +228 -0
  39. package/src/core/context-budget.ts +6 -0
  40. package/src/core/context-capsule-retrieval-schema.ts +4 -0
  41. package/src/core/context-capsule-schema.ts +17 -0
  42. package/src/core/context-capsule-validation.ts +3 -2
  43. package/src/core/context-capsule.ts +18 -0
  44. package/src/core/context-compiler.ts +33 -21
  45. package/src/core/context-evidence.ts +6 -0
  46. package/src/core/knowledge-delta.ts +395 -0
  47. package/src/core/knowledge-impact.ts +202 -0
  48. package/src/core/retrieval-trace-evidence-origin.ts +3 -0
  49. package/src/core/retrieval-trace-session.ts +15 -2
  50. package/src/ingestion/sync.ts +214 -165
  51. package/src/llm/errors.ts +10 -1
  52. package/src/llm/httpGeneration.ts +11 -1
  53. package/src/llm/nodeLlamaCpp/generation.ts +54 -10
  54. package/src/llm/types.ts +6 -0
  55. package/src/mcp/tools/ask.ts +228 -0
  56. package/src/mcp/tools/changes.ts +80 -0
  57. package/src/mcp/tools/context.ts +28 -7
  58. package/src/mcp/tools/index.ts +38 -0
  59. package/src/pipeline/claim-verification-schema.ts +235 -0
  60. package/src/pipeline/claim-verification.ts +487 -0
  61. package/src/pipeline/claim-verifier.ts +474 -0
  62. package/src/pipeline/types.ts +25 -0
  63. package/src/sdk/client.ts +77 -2
  64. package/src/sdk/index.ts +7 -0
  65. package/src/sdk/types.ts +22 -0
  66. package/src/serve/doc-events.ts +12 -1
  67. package/src/serve/public/components/AskVerificationPanel.tsx +189 -0
  68. package/src/serve/public/globals.built.css +1 -1
  69. package/src/serve/public/pages/Ask.tsx +42 -4
  70. package/src/serve/resident-runtime.ts +22 -0
  71. package/src/serve/routes/api.ts +162 -3
  72. package/src/serve/routes/changes.ts +102 -0
  73. package/src/serve/server.ts +34 -0
  74. package/src/serve/watch-service.ts +9 -0
  75. package/src/store/index.ts +21 -0
  76. package/src/store/migrations/015-document-change-journal.ts +85 -0
  77. package/src/store/migrations/016-saved-capsules.ts +131 -0
  78. package/src/store/migrations/017-document-change-retention-counters.ts +33 -0
  79. package/src/store/migrations/018-saved-capsule-registration-epoch.ts +24 -0
  80. package/src/store/migrations/019-saved-capsule-registration-generation.ts +53 -0
  81. package/src/store/migrations/index.ts +10 -0
  82. package/src/store/sqlite/adapter.ts +291 -7
  83. package/src/store/sqlite/capsule-registry-store.ts +534 -0
  84. package/src/store/sqlite/change-journal-store.ts +473 -0
  85. package/src/store/types.ts +262 -0
@@ -40,6 +40,13 @@ import {
40
40
  getDefaultPipeline,
41
41
  } from "../converters/pipeline";
42
42
  import { DEFAULT_LIMITS } from "../converters/types";
43
+ import {
44
+ diffDocumentStructure,
45
+ extractDocumentStructure,
46
+ isRelationMap,
47
+ normalizeRelationEdgeType,
48
+ normalizeRelationTarget,
49
+ } from "../core/change-diff";
43
50
  import {
44
51
  normalizeMarkdownPath,
45
52
  normalizeWikiName,
@@ -83,35 +90,6 @@ const NON_RETRYABLE_CONVERSION_ERROR_CODES = new Set([
83
90
  "UNSUPPORTED",
84
91
  ]);
85
92
 
86
- type RelationMap = Record<string, string[]>;
87
-
88
- function isRelationMap(value: unknown): value is RelationMap {
89
- if (!value || typeof value !== "object" || Array.isArray(value)) {
90
- return false;
91
- }
92
-
93
- return Object.values(value).every(
94
- (targets) =>
95
- Array.isArray(targets) &&
96
- targets.every((target) => typeof target === "string")
97
- );
98
- }
99
-
100
- function normalizeRelationTarget(raw: string): string {
101
- const trimmed = raw.trim();
102
- if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) {
103
- return trimmed.slice(2, -2).split("|")[0]?.trim() ?? "";
104
- }
105
- return trimmed;
106
- }
107
-
108
- function normalizeRelationEdgeType(raw: string): string {
109
- return raw
110
- .trim()
111
- .toLowerCase()
112
- .replace(/[-\s]+/g, "_");
113
- }
114
-
115
93
  function findDocByWikiRef(
116
94
  docs: DocumentRow[],
117
95
  targetRef: string,
@@ -747,7 +725,24 @@ export class SyncService {
747
725
  // Log but continue - error recording is best-effort
748
726
  }
749
727
 
750
- // Upsert document with error info, explicitly clear mirrorHash
728
+ const hadRetrievableEvidence = Boolean(existing?.mirrorHash);
729
+ const structureDelta = hadRetrievableEvidence
730
+ ? diffDocumentStructure(
731
+ await this.readPreviousStructure(store, existing),
732
+ {
733
+ headings: [],
734
+ links: [],
735
+ typedEdges: [],
736
+ dates: {},
737
+ }
738
+ ).delta
739
+ : undefined;
740
+
741
+ // Upsert document with error info, explicitly clear mirrorHash. This
742
+ // transition is journaled only when retrievable evidence previously
743
+ // existed; initial/repeated error placeholders are not evidence
744
+ // creates. Real evidence disappearance stays in the same transaction
745
+ // so saved Capsule freshness checks cannot miss it.
751
746
  const upsertResult = await store.upsertDocument({
752
747
  collection: collection.name,
753
748
  relPath: entry.relPath,
@@ -761,6 +756,7 @@ export class SyncService {
761
756
  lastErrorMessage: convertResult.error.message,
762
757
  ingestVersion: INGEST_VERSION,
763
758
  contentTypeRulesFingerprint,
759
+ changeJournal: structureDelta ? { structureDelta } : false,
764
760
  // mirrorHash intentionally omitted (will be null)
765
761
  });
766
762
 
@@ -788,155 +784,186 @@ export class SyncService {
788
784
  mime.ext,
789
785
  contentTypeRules
790
786
  );
791
-
792
- // 7. Upsert document - EXPLICITLY clear error fields on success
793
- const docidResult = await store.upsertDocument({
794
- collection: collection.name,
795
- relPath: entry.relPath,
796
- sourceHash,
797
- sourceMime: mime.mime,
798
- sourceExt: mime.ext,
799
- sourceSize,
800
- sourceMtime,
801
- sourceCtime,
802
- title: artifact.title,
803
- mirrorHash: artifact.mirrorHash,
804
- converterId: artifact.meta.converterId,
805
- converterVersion: artifact.meta.converterVersion,
806
- languageHint: artifact.languageHint ?? collection.languageHint,
807
- contentType: extractedMetadata.contentType,
808
- contentTypeSource: extractedMetadata.contentTypeSource,
809
- categories: extractedMetadata.categories,
810
- author: extractedMetadata.author,
811
- frontmatterDate: extractedMetadata.frontmatterDate,
812
- dateFields: extractedMetadata.dateFields,
813
- contentTypeRulesFingerprint,
814
- // Clear error fields on success (requires store to handle undefined → null)
815
- lastErrorCode: undefined,
816
- lastErrorMessage: undefined,
817
- ingestVersion: INGEST_VERSION,
818
- });
819
-
820
- const { id: docId, docid } = mustOk(docidResult, "upsertDocument", {
821
- collection: collection.name,
822
- relPath: entry.relPath,
823
- });
824
-
825
- // 8. Upsert content (content-addressed dedupe) - CHECKED
826
- const contentResult = await store.upsertContent(
827
- artifact.mirrorHash,
828
- artifact.markdown
787
+ const previousStructure = await this.readPreviousStructure(
788
+ store,
789
+ existing
829
790
  );
830
- mustOk(contentResult, "upsertContent", {
831
- mirrorHash: artifact.mirrorHash,
832
- });
833
-
834
- // 9. Chunk content
835
- const chunks = this.chunker.chunk(
791
+ const nextStructure = extractDocumentStructure(
836
792
  artifact.markdown,
837
- DEFAULT_CHUNK_PARAMS,
838
- artifact.languageHint ?? collection.languageHint,
839
- entry.relPath
793
+ entry.relPath,
794
+ extractedMetadata.dateFields
840
795
  );
796
+ const structureDelta = diffDocumentStructure(
797
+ previousStructure,
798
+ nextStructure
799
+ ).delta;
800
+
801
+ const persistSuccessfulFile = async (): Promise<FileSyncResult> => {
802
+ // 7. Upsert document - EXPLICITLY clear error fields on success
803
+ const docidResult = await store.upsertDocument({
804
+ collection: collection.name,
805
+ relPath: entry.relPath,
806
+ sourceHash,
807
+ sourceMime: mime.mime,
808
+ sourceExt: mime.ext,
809
+ sourceSize,
810
+ sourceMtime,
811
+ sourceCtime,
812
+ title: artifact.title,
813
+ mirrorHash: artifact.mirrorHash,
814
+ converterId: artifact.meta.converterId,
815
+ converterVersion: artifact.meta.converterVersion,
816
+ languageHint: artifact.languageHint ?? collection.languageHint,
817
+ contentType: extractedMetadata.contentType,
818
+ contentTypeSource: extractedMetadata.contentTypeSource,
819
+ categories: extractedMetadata.categories,
820
+ author: extractedMetadata.author,
821
+ frontmatterDate: extractedMetadata.frontmatterDate,
822
+ dateFields: extractedMetadata.dateFields,
823
+ contentTypeRulesFingerprint,
824
+ // Clear error fields on success (requires store to handle undefined → null)
825
+ lastErrorCode: undefined,
826
+ lastErrorMessage: undefined,
827
+ ingestVersion: INGEST_VERSION,
828
+ changeJournal: { structureDelta },
829
+ });
841
830
 
842
- // 10. Convert to ChunkInput for store
843
- const chunkInputs: ChunkInput[] = chunks.map((c) => ({
844
- seq: c.seq,
845
- pos: c.pos,
846
- text: c.text,
847
- startLine: c.startLine,
848
- endLine: c.endLine,
849
- language: c.language ?? undefined,
850
- tokenCount: c.tokenCount ?? undefined,
851
- }));
852
-
853
- // 11. Upsert chunks - CHECKED
854
- const chunksResult = await store.upsertChunks(
855
- artifact.mirrorHash,
856
- chunkInputs
857
- );
858
- mustOk(chunksResult, "upsertChunks", {
859
- mirrorHash: artifact.mirrorHash,
860
- chunkCount: chunkInputs.length,
861
- });
831
+ const { id: docId, docid } = mustOk(docidResult, "upsertDocument", {
832
+ collection: collection.name,
833
+ relPath: entry.relPath,
834
+ });
862
835
 
863
- // 12. Rebuild FTS for this hash - CHECKED
864
- const ftsResult = await store.rebuildFtsForHash(artifact.mirrorHash);
865
- mustOk(ftsResult, "rebuildFtsForHash", {
866
- mirrorHash: artifact.mirrorHash,
867
- });
836
+ // 8. Upsert content (content-addressed dedupe) - CHECKED
837
+ const contentResult = await store.upsertContent(
838
+ artifact.mirrorHash,
839
+ artifact.markdown
840
+ );
841
+ mustOk(contentResult, "upsertContent", {
842
+ mirrorHash: artifact.mirrorHash,
843
+ });
868
844
 
869
- // 13. Extract and store tags from frontmatter and body hashtags
870
- // Always call setDocTags to clear removed tags on re-sync
871
- const extractedTags = extractTags(artifact.markdown);
872
- const tagsResult = await store.setDocTags(
873
- docId,
874
- extractedTags,
875
- "frontmatter"
876
- );
877
- mustOk(tagsResult, "setDocTags", {
878
- docId,
879
- tagCount: extractedTags.length,
880
- });
845
+ // 9. Chunk content
846
+ const chunks = this.chunker.chunk(
847
+ artifact.markdown,
848
+ DEFAULT_CHUNK_PARAMS,
849
+ artifact.languageHint ?? collection.languageHint,
850
+ entry.relPath
851
+ );
881
852
 
882
- // 14. Extract and store links (wiki and markdown links)
883
- const excludedRanges = getExcludedRanges(artifact.markdown);
884
- const lineOffsets = buildLineOffsets(artifact.markdown);
885
- const parsedLinks = parseLinks(
886
- artifact.markdown,
887
- lineOffsets,
888
- excludedRanges
889
- );
853
+ // 10. Convert to ChunkInput for store
854
+ const chunkInputs: ChunkInput[] = chunks.map((c) => ({
855
+ seq: c.seq,
856
+ pos: c.pos,
857
+ text: c.text,
858
+ startLine: c.startLine,
859
+ endLine: c.endLine,
860
+ language: c.language ?? undefined,
861
+ tokenCount: c.tokenCount ?? undefined,
862
+ }));
863
+
864
+ // 11. Upsert chunks - CHECKED
865
+ const chunksResult = await store.upsertChunks(
866
+ artifact.mirrorHash,
867
+ chunkInputs
868
+ );
869
+ mustOk(chunksResult, "upsertChunks", {
870
+ mirrorHash: artifact.mirrorHash,
871
+ chunkCount: chunkInputs.length,
872
+ });
890
873
 
891
- const linkInputs: DocLinkInput[] = [];
892
- for (const link of parsedLinks) {
893
- // Compute target_ref_norm based on link type
894
- let targetRefNorm: string;
895
- if (link.kind === "wiki") {
896
- targetRefNorm = normalizeWikiName(link.targetRef);
897
- } else {
898
- // Markdown links with collection prefix are not supported
899
- // (use wiki links for cross-collection references)
900
- if (link.targetCollection) {
901
- continue;
902
- }
903
- const resolved = normalizeMarkdownPath(link.targetRef, entry.relPath);
904
- if (!resolved) {
905
- // Link escapes collection root - skip silently
906
- continue;
874
+ // 12. Rebuild FTS for this hash - CHECKED
875
+ const ftsResult = await store.rebuildFtsForHash(artifact.mirrorHash);
876
+ mustOk(ftsResult, "rebuildFtsForHash", {
877
+ mirrorHash: artifact.mirrorHash,
878
+ });
879
+
880
+ // 13. Extract and store tags from frontmatter and body hashtags
881
+ // Always call setDocTags to clear removed tags on re-sync
882
+ const extractedTags = extractTags(artifact.markdown);
883
+ const tagsResult = await store.setDocTags(
884
+ docId,
885
+ extractedTags,
886
+ "frontmatter"
887
+ );
888
+ mustOk(tagsResult, "setDocTags", {
889
+ docId,
890
+ tagCount: extractedTags.length,
891
+ });
892
+
893
+ // 14. Extract and store links (wiki and markdown links)
894
+ const excludedRanges = getExcludedRanges(artifact.markdown);
895
+ const lineOffsets = buildLineOffsets(artifact.markdown);
896
+ const parsedLinks = parseLinks(
897
+ artifact.markdown,
898
+ lineOffsets,
899
+ excludedRanges
900
+ );
901
+
902
+ const linkInputs: DocLinkInput[] = [];
903
+ for (const link of parsedLinks) {
904
+ // Compute target_ref_norm based on link type
905
+ let targetRefNorm: string;
906
+ if (link.kind === "wiki") {
907
+ targetRefNorm = normalizeWikiName(link.targetRef);
908
+ } else {
909
+ // Markdown links with collection prefix are not supported
910
+ // (use wiki links for cross-collection references)
911
+ if (link.targetCollection) {
912
+ continue;
913
+ }
914
+ const resolved = normalizeMarkdownPath(
915
+ link.targetRef,
916
+ entry.relPath
917
+ );
918
+ if (!resolved) {
919
+ // Link escapes collection root - skip silently
920
+ continue;
921
+ }
922
+ targetRefNorm = resolved;
907
923
  }
908
- targetRefNorm = resolved;
924
+
925
+ linkInputs.push({
926
+ targetRef: link.targetRef,
927
+ targetRefNorm,
928
+ targetAnchor: link.targetAnchor,
929
+ targetCollection: link.targetCollection,
930
+ linkType: link.kind,
931
+ linkText: link.displayText,
932
+ startLine: link.startLine,
933
+ startCol: link.startCol,
934
+ endLine: link.endLine,
935
+ endCol: link.endCol,
936
+ });
909
937
  }
910
938
 
911
- linkInputs.push({
912
- targetRef: link.targetRef,
913
- targetRefNorm,
914
- targetAnchor: link.targetAnchor,
915
- targetCollection: link.targetCollection,
916
- linkType: link.kind,
917
- linkText: link.displayText,
918
- startLine: link.startLine,
919
- startCol: link.startCol,
920
- endLine: link.endLine,
921
- endCol: link.endCol,
939
+ const linksResult = await store.setDocLinks(
940
+ docId,
941
+ linkInputs,
942
+ "parsed"
943
+ );
944
+ mustOk(linksResult, "setDocLinks", {
945
+ docId,
946
+ linkCount: linkInputs.length,
922
947
  });
923
- }
924
948
 
925
- const linksResult = await store.setDocLinks(docId, linkInputs, "parsed");
926
- mustOk(linksResult, "setDocLinks", {
927
- docId,
928
- linkCount: linkInputs.length,
929
- });
930
-
931
- const status = existing ? "updated" : "added";
932
- return {
933
- relPath: entry.relPath,
934
- status,
935
- docid,
936
- mirrorHash: artifact.mirrorHash,
937
- contentType: extractedMetadata.contentType,
938
- contentTypeSource: extractedMetadata.contentTypeSource,
949
+ const status = existing ? "updated" : "added";
950
+ return {
951
+ relPath: entry.relPath,
952
+ status,
953
+ docid,
954
+ mirrorHash: artifact.mirrorHash,
955
+ contentType: extractedMetadata.contentType,
956
+ contentTypeSource: extractedMetadata.contentTypeSource,
957
+ };
939
958
  };
959
+ if (!store.withTransaction) {
960
+ return await persistSuccessfulFile();
961
+ }
962
+ const persisted = await store.withTransaction(persistSuccessfulFile);
963
+ return mustOk(persisted, "persistSuccessfulFile", {
964
+ collection: collection.name,
965
+ relPath: entry.relPath,
966
+ });
940
967
  } catch (error) {
941
968
  const message = error instanceof Error ? error.message : "Unknown error";
942
969
  // Distinguish store errors from other internal errors
@@ -977,6 +1004,7 @@ export class SyncService {
977
1004
  existingResult.value.sourceMtime,
978
1005
  lastErrorCode: code,
979
1006
  lastErrorMessage: message,
1007
+ changeJournal: false,
980
1008
  });
981
1009
  }
982
1010
  } catch {
@@ -992,6 +1020,27 @@ export class SyncService {
992
1020
  }
993
1021
  }
994
1022
 
1023
+ private async readPreviousStructure(
1024
+ store: StorePort,
1025
+ existing: DocumentRow | null
1026
+ ): Promise<ReturnType<typeof extractDocumentStructure> | null | undefined> {
1027
+ if (!existing) return null;
1028
+ if (!existing.mirrorHash) return undefined;
1029
+
1030
+ const content = await store.getContent(existing.mirrorHash);
1031
+ if (!content.ok) {
1032
+ throw new Error(`Store operation failed: ${content.error.message}`, {
1033
+ cause: content.error,
1034
+ });
1035
+ }
1036
+ if (content.value === null) return undefined;
1037
+ return extractDocumentStructure(
1038
+ content.value,
1039
+ existing.relPath,
1040
+ existing.dateFields
1041
+ );
1042
+ }
1043
+
995
1044
  /**
996
1045
  * Sync a specific set of files within a collection.
997
1046
  */
package/src/llm/errors.ts CHANGED
@@ -22,7 +22,8 @@ export type LlmErrorCode =
22
22
  | "OUT_OF_MEMORY"
23
23
  | "INVALID_URI"
24
24
  | "LOCK_FAILED"
25
- | "AUTO_DOWNLOAD_DISABLED";
25
+ | "AUTO_DOWNLOAD_DISABLED"
26
+ | "STRUCTURED_OUTPUT_UNAVAILABLE";
26
27
 
27
28
  export interface LlmError {
28
29
  code: LlmErrorCode;
@@ -248,3 +249,11 @@ export function autoDownloadDisabledError(uri: string): LlmError {
248
249
  suggestion: "Run 'gno models pull' to download models manually.",
249
250
  });
250
251
  }
252
+
253
+ export function structuredOutputUnavailableError(uri: string): LlmError {
254
+ return llmError("STRUCTURED_OUTPUT_UNAVAILABLE", {
255
+ message: `JSON Schema constrained generation is unavailable for model: ${uri}`,
256
+ modelUri: uri,
257
+ retryable: false,
258
+ });
259
+ }
@@ -7,7 +7,10 @@
7
7
 
8
8
  import type { GenerationPort, GenParams, LlmResult } from "./types";
9
9
 
10
- import { inferenceFailedError } from "./errors";
10
+ import {
11
+ inferenceFailedError,
12
+ structuredOutputUnavailableError,
13
+ } from "./errors";
11
14
 
12
15
  // ─────────────────────────────────────────────────────────────────────────────
13
16
  // Types
@@ -42,6 +45,7 @@ export class HttpGeneration implements GenerationPort {
42
45
  private readonly apiUrl: string;
43
46
  private readonly modelName: string;
44
47
  readonly modelUri: string;
48
+ readonly structuredOutput = "none" as const;
45
49
 
46
50
  constructor(modelUri: string) {
47
51
  this.modelUri = modelUri;
@@ -63,6 +67,12 @@ export class HttpGeneration implements GenerationPort {
63
67
  prompt: string,
64
68
  params?: GenParams
65
69
  ): Promise<LlmResult<string>> {
70
+ if (params?.jsonSchema) {
71
+ return {
72
+ ok: false,
73
+ error: structuredOutputUnavailableError(this.modelUri),
74
+ };
75
+ }
66
76
  try {
67
77
  const response = await fetch(this.apiUrl, {
68
78
  method: "POST",
@@ -18,6 +18,39 @@ type LlamaModel = Awaited<
18
18
  Awaited<ReturnType<typeof import("node-llama-cpp").getLlama>>["loadModel"]
19
19
  >
20
20
  >;
21
+ type Llama = Awaited<ReturnType<typeof import("node-llama-cpp").getLlama>>;
22
+ type JsonGrammarSchema = Parameters<Llama["createGrammarForJsonSchema"]>[0];
23
+
24
+ export interface JsonSchemaGrammarLike {
25
+ parse(response: string): unknown;
26
+ }
27
+
28
+ export interface StructuredPromptSession {
29
+ prompt(
30
+ prompt: string,
31
+ options: {
32
+ temperature: number;
33
+ seed: number;
34
+ maxTokens: number;
35
+ grammar?: JsonSchemaGrammarLike;
36
+ }
37
+ ): Promise<string>;
38
+ }
39
+
40
+ export const promptWithJsonSchemaGrammar = async (
41
+ session: StructuredPromptSession,
42
+ prompt: string,
43
+ options: {
44
+ temperature: number;
45
+ seed: number;
46
+ maxTokens: number;
47
+ },
48
+ grammar?: JsonSchemaGrammarLike
49
+ ): Promise<string> => {
50
+ const response = await session.prompt(prompt, { ...options, grammar });
51
+ grammar?.parse(response);
52
+ return response;
53
+ };
21
54
 
22
55
  // ─────────────────────────────────────────────────────────────────────────────
23
56
  // Default Parameters (for determinism)
@@ -34,6 +67,7 @@ const DEFAULT_MAX_TOKENS = 256;
34
67
  export class NodeLlamaCppGeneration implements GenerationPort {
35
68
  private readonly manager: ModelManager;
36
69
  readonly modelUri: string;
70
+ readonly structuredOutput = "json_schema" as const;
37
71
  private readonly modelPath: string;
38
72
 
39
73
  constructor(manager: ModelManager, modelUri: string, modelPath: string) {
@@ -56,11 +90,16 @@ export class NodeLlamaCppGeneration implements GenerationPort {
56
90
  }
57
91
 
58
92
  const llamaModel = model.value.model as LlamaModel;
59
- const context = await llamaModel.createContext(
60
- params?.contextSize ? { contextSize: params.contextSize } : undefined
61
- );
62
-
93
+ let context: Awaited<ReturnType<LlamaModel["createContext"]>> | null = null;
63
94
  try {
95
+ const grammar = params?.jsonSchema
96
+ ? await (
97
+ await this.manager.getLlama()
98
+ ).createGrammarForJsonSchema(params.jsonSchema as JsonGrammarSchema)
99
+ : undefined;
100
+ context = await llamaModel.createContext(
101
+ params?.contextSize ? { contextSize: params.contextSize } : undefined
102
+ );
64
103
  // Import LlamaChatSession dynamically
65
104
  const { LlamaChatSession } = await import("node-llama-cpp");
66
105
  const session = new LlamaChatSession({
@@ -68,17 +107,22 @@ export class NodeLlamaCppGeneration implements GenerationPort {
68
107
  });
69
108
 
70
109
  // Note: stop sequences not yet supported - requires stopOnTrigger API
71
- const response = await session.prompt(prompt, {
72
- temperature: params?.temperature ?? DEFAULT_TEMPERATURE,
73
- seed: params?.seed ?? DEFAULT_SEED,
74
- maxTokens: params?.maxTokens ?? DEFAULT_MAX_TOKENS,
75
- });
110
+ const response = await promptWithJsonSchemaGrammar(
111
+ session as StructuredPromptSession,
112
+ prompt,
113
+ {
114
+ temperature: params?.temperature ?? DEFAULT_TEMPERATURE,
115
+ seed: params?.seed ?? DEFAULT_SEED,
116
+ maxTokens: params?.maxTokens ?? DEFAULT_MAX_TOKENS,
117
+ },
118
+ grammar
119
+ );
76
120
 
77
121
  return { ok: true, value: response };
78
122
  } catch (e) {
79
123
  return { ok: false, error: inferenceFailedError(this.modelUri, e) };
80
124
  } finally {
81
- await context.dispose().catch(() => {
125
+ await context?.dispose().catch(() => {
82
126
  // Ignore disposal errors
83
127
  });
84
128
  }
package/src/llm/types.ts CHANGED
@@ -58,8 +58,12 @@ export interface GenParams {
58
58
  contextSize?: number;
59
59
  /** Stop sequences */
60
60
  stop?: string[];
61
+ /** Closed JSON Schema enforced by a capable generation backend. */
62
+ jsonSchema?: Readonly<Record<string, unknown>>;
61
63
  }
62
64
 
65
+ export type StructuredOutputCapability = "json_schema" | "none";
66
+
63
67
  // ─────────────────────────────────────────────────────────────────────────────
64
68
  // Rerank Types
65
69
  // ─────────────────────────────────────────────────────────────────────────────
@@ -90,6 +94,8 @@ export interface EmbeddingPort {
90
94
 
91
95
  export interface GenerationPort {
92
96
  readonly modelUri: string;
97
+ /** Undefined is treated as unsupported for backwards-compatible ports. */
98
+ readonly structuredOutput?: StructuredOutputCapability;
93
99
  generate(prompt: string, params?: GenParams): Promise<LlmResult<string>>;
94
100
  dispose(): Promise<void>;
95
101
  }