@gmickel/gno 1.8.0 → 1.10.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 (62) hide show
  1. package/README.md +6 -2
  2. package/assets/skill/SKILL.md +41 -16
  3. package/assets/skill/cli-reference.md +39 -0
  4. package/assets/skill/examples.md +41 -0
  5. package/assets/skill/mcp-reference.md +13 -1
  6. package/package.json +1 -1
  7. package/src/cli/commands/capture.ts +9 -6
  8. package/src/cli/commands/graph.ts +89 -2
  9. package/src/cli/commands/index-cmd.ts +17 -6
  10. package/src/cli/commands/links.ts +237 -54
  11. package/src/cli/commands/query.ts +212 -0
  12. package/src/cli/commands/ref-parser.ts +8 -103
  13. package/src/cli/commands/shared.ts +17 -1
  14. package/src/cli/commands/update.ts +17 -6
  15. package/src/cli/options.ts +4 -0
  16. package/src/cli/program.ts +176 -9
  17. package/src/config/content-types.ts +154 -0
  18. package/src/config/defaults.ts +1 -0
  19. package/src/config/index.ts +14 -0
  20. package/src/config/loader.ts +11 -2
  21. package/src/config/types.ts +37 -1
  22. package/src/core/config-mutation.ts +14 -2
  23. package/src/core/graph-query.ts +137 -0
  24. package/src/core/graph-resolver.ts +117 -0
  25. package/src/core/note-presets.ts +61 -5
  26. package/src/core/ref-parser.ts +145 -0
  27. package/src/ingestion/frontmatter.ts +170 -2
  28. package/src/ingestion/index.ts +2 -0
  29. package/src/ingestion/sync-options.ts +29 -0
  30. package/src/ingestion/sync.ts +385 -17
  31. package/src/ingestion/types.ts +14 -0
  32. package/src/mcp/tools/add-collection.ts +8 -5
  33. package/src/mcp/tools/capture.ts +5 -2
  34. package/src/mcp/tools/get.ts +1 -1
  35. package/src/mcp/tools/index-cmd.ts +13 -7
  36. package/src/mcp/tools/index.ts +97 -10
  37. package/src/mcp/tools/links.ts +83 -1
  38. package/src/mcp/tools/multi-get.ts +1 -1
  39. package/src/mcp/tools/query.ts +207 -0
  40. package/src/mcp/tools/sync.ts +12 -6
  41. package/src/mcp/tools/workspace-write.ts +16 -10
  42. package/src/pipeline/diagnose.ts +302 -0
  43. package/src/pipeline/filters.ts +119 -0
  44. package/src/pipeline/hybrid.ts +92 -17
  45. package/src/pipeline/search.ts +2 -0
  46. package/src/pipeline/types.ts +34 -0
  47. package/src/pipeline/vsearch.ts +4 -0
  48. package/src/publish/export-service.ts +1 -1
  49. package/src/sdk/client.ts +51 -24
  50. package/src/sdk/documents.ts +2 -2
  51. package/src/serve/background-runtime.ts +18 -4
  52. package/src/serve/config-sync.ts +9 -2
  53. package/src/serve/routes/api.ts +244 -24
  54. package/src/serve/routes/graph.ts +173 -1
  55. package/src/serve/server.ts +24 -1
  56. package/src/serve/watch-service.ts +12 -2
  57. package/src/store/migrations/009-content-type-rule-fingerprint.ts +36 -0
  58. package/src/store/migrations/010-typed-edges.ts +67 -0
  59. package/src/store/migrations/011-doc-edge-traversal-indexes.ts +30 -0
  60. package/src/store/migrations/index.ts +16 -1
  61. package/src/store/sqlite/adapter.ts +853 -114
  62. package/src/store/types.ts +147 -0
@@ -5,6 +5,16 @@
5
5
  */
6
6
 
7
7
  export { createDefaultConfig } from "./defaults";
8
+ export {
9
+ type ConfigWarning,
10
+ fingerprintContentTypeRules,
11
+ formatConfigWarning,
12
+ formatConfigWarnings,
13
+ normalizeConfigContentTypes,
14
+ normalizeContentTypes,
15
+ type NormalizedContentTypeRule,
16
+ writeConfigWarningsToStderr,
17
+ } from "./content-types";
8
18
  // Loading
9
19
  export {
10
20
  isInitialized,
@@ -40,6 +50,10 @@ export {
40
50
  CollectionSchema,
41
51
  type Config,
42
52
  ConfigSchema,
53
+ CONTENT_TYPE_GRAPH_HINTS,
54
+ type ContentTypeConfig,
55
+ type ContentTypeGraphHint,
56
+ ContentTypeSchema,
43
57
  type Context,
44
58
  ContextSchema,
45
59
  DEFAULT_EXCLUDES,
@@ -7,6 +7,10 @@
7
7
 
8
8
  import type { ZodError } from "zod";
9
9
 
10
+ import {
11
+ normalizeConfigContentTypes,
12
+ type ConfigWarning,
13
+ } from "./content-types";
10
14
  import { configExists, expandPath, getConfigPaths } from "./paths";
11
15
  import { CONFIG_VERSION, type Config, ConfigSchema } from "./types";
12
16
 
@@ -15,7 +19,7 @@ import { CONFIG_VERSION, type Config, ConfigSchema } from "./types";
15
19
  // ─────────────────────────────────────────────────────────────────────────────
16
20
 
17
21
  export type LoadResult<T> =
18
- | { ok: true; value: T }
22
+ | { ok: true; value: T; warnings: ConfigWarning[] }
19
23
  | { ok: false; error: LoadError };
20
24
 
21
25
  export type LoadError =
@@ -128,7 +132,12 @@ export async function loadConfigFromPath(
128
132
  };
129
133
  }
130
134
 
131
- return { ok: true, value: result.data };
135
+ const normalized = normalizeConfigContentTypes(result.data);
136
+ return {
137
+ ok: true,
138
+ value: normalized.config,
139
+ warnings: normalized.warnings,
140
+ };
132
141
  }
133
142
 
134
143
  /**
@@ -245,6 +245,37 @@ export const ModelConfigSchema = z.object({
245
245
 
246
246
  export type ModelConfig = z.infer<typeof ModelConfigSchema>;
247
247
 
248
+ // ─────────────────────────────────────────────────────────────────────────────
249
+ // Content Type Schema
250
+ // ─────────────────────────────────────────────────────────────────────────────
251
+
252
+ export const CONTENT_TYPE_GRAPH_HINTS = [
253
+ "mentions",
254
+ "works_at",
255
+ "attended",
256
+ "decided",
257
+ "related_to",
258
+ ] as const;
259
+
260
+ export type ContentTypeGraphHint = (typeof CONTENT_TYPE_GRAPH_HINTS)[number];
261
+
262
+ export const ContentTypeSchema = z.object({
263
+ /** Stable content type identifier */
264
+ id: z.string().min(1),
265
+ /** Relative path prefixes that map to this content type */
266
+ prefixes: z.array(z.string().min(1)),
267
+ /** Note preset ID, resolved post-parse so unknown refs warn-and-drop */
268
+ preset: z.string().min(1),
269
+ /** Reserved for fn-84 typed graph hints; accepted but no-op in fn-83 */
270
+ graphHints: z.array(z.string().min(1)).optional(),
271
+ /** Reserved for future ranking; accepted but no-op in fn-83 */
272
+ searchBoost: z.number().finite().optional(),
273
+ /** Marks time-oriented content types; accepted but no-op in fn-83 */
274
+ temporal: z.boolean().optional(),
275
+ });
276
+
277
+ export type ContentTypeConfig = z.infer<typeof ContentTypeSchema>;
278
+
248
279
  // ─────────────────────────────────────────────────────────────────────────────
249
280
  // Config Schema (root)
250
281
  // ─────────────────────────────────────────────────────────────────────────────
@@ -265,11 +296,16 @@ export const ConfigSchema = z.object({
265
296
  /** Context metadata */
266
297
  contexts: z.array(ContextSchema).default([]),
267
298
 
299
+ /** Opt-in schema-lite content type rules */
300
+ contentTypes: z.array(ContentTypeSchema).default([]),
301
+
268
302
  /** Model configuration */
269
303
  models: ModelConfigSchema.optional(),
270
304
  });
271
305
 
272
- export type Config = z.infer<typeof ConfigSchema>;
306
+ export type Config = Omit<z.infer<typeof ConfigSchema>, "contentTypes"> & {
307
+ contentTypes?: ContentTypeConfig[];
308
+ };
273
309
 
274
310
  // ─────────────────────────────────────────────────────────────────────────────
275
311
  // Scope Utilities
@@ -7,7 +7,12 @@
7
7
  import type { Config } from "../config/types";
8
8
  import type { SqliteAdapter } from "../store/sqlite/adapter";
9
9
 
10
- import { loadConfig, saveConfig } from "../config";
10
+ import {
11
+ formatConfigWarnings,
12
+ loadConfig,
13
+ normalizeConfigContentTypes,
14
+ saveConfig,
15
+ } from "../config";
11
16
 
12
17
  export interface ConfigMutationContext {
13
18
  store: SqliteAdapter;
@@ -53,6 +58,9 @@ export async function applyConfigChange<T = void>(
53
58
  code: "LOAD_ERROR",
54
59
  };
55
60
  }
61
+ for (const warning of formatConfigWarnings(loadResult.warnings)) {
62
+ console.warn(warning);
63
+ }
56
64
 
57
65
  const mutationResult = await mutate(loadResult.value);
58
66
  if (!mutationResult.ok) {
@@ -63,7 +71,11 @@ export async function applyConfigChange<T = void>(
63
71
  };
64
72
  }
65
73
 
66
- const newConfig = mutationResult.config;
74
+ const normalized = normalizeConfigContentTypes(mutationResult.config);
75
+ for (const warning of formatConfigWarnings(normalized.warnings)) {
76
+ console.warn(warning);
77
+ }
78
+ const newConfig = normalized.config;
67
79
  const saveResult = await saveConfig(newConfig, ctx.configPath);
68
80
  if (!saveResult.ok) {
69
81
  return {
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Shared bounded typed-edge graph traversal.
3
+ *
4
+ * @module src/core/graph-query
5
+ */
6
+
7
+ import type { NormalizedContentTypeRule } from "../config";
8
+ import type {
9
+ DocumentRow,
10
+ GraphQueryNode,
11
+ GraphQueryOptions,
12
+ GraphQueryResult,
13
+ StorePort,
14
+ } from "../store/types";
15
+
16
+ import { resolveDocRef } from "./ref-parser";
17
+
18
+ export type DiagnoseGraphQueryResult =
19
+ | { success: true; data: GraphQueryResult }
20
+ | { success: false; error: string; isValidation?: boolean };
21
+
22
+ export interface DiagnoseGraphQueryOptions extends GraphQueryOptions {
23
+ contentTypeRules?: NormalizedContentTypeRule[];
24
+ }
25
+
26
+ function graphHintsForDoc(
27
+ doc: DocumentRow,
28
+ rules: NormalizedContentTypeRule[]
29
+ ): string[] {
30
+ if (!doc.contentType) {
31
+ return [];
32
+ }
33
+ return rules.find((rule) => rule.id === doc.contentType)?.graphHints ?? [];
34
+ }
35
+
36
+ function toNode(
37
+ doc: DocumentRow,
38
+ depth: number,
39
+ rules: NormalizedContentTypeRule[]
40
+ ): GraphQueryNode {
41
+ return {
42
+ id: doc.docid,
43
+ uri: doc.uri,
44
+ title: doc.title,
45
+ collection: doc.collection,
46
+ relPath: doc.relPath,
47
+ depth,
48
+ graphHints: graphHintsForDoc(doc, rules),
49
+ };
50
+ }
51
+
52
+ export async function diagnoseGraphQuery(
53
+ store: StorePort,
54
+ rootRef: string,
55
+ options: DiagnoseGraphQueryOptions = {}
56
+ ): Promise<DiagnoseGraphQueryResult> {
57
+ const resolved = await resolveDocRef(store, rootRef);
58
+ if ("error" in resolved) {
59
+ return {
60
+ success: false,
61
+ error: resolved.error,
62
+ isValidation: resolved.isValidation,
63
+ };
64
+ }
65
+
66
+ const rootDoc = resolved.doc;
67
+ if (!rootDoc.active) {
68
+ return {
69
+ success: false,
70
+ error: `Document is inactive: ${rootRef}`,
71
+ isValidation: true,
72
+ };
73
+ }
74
+
75
+ const direction = options.direction ?? "both";
76
+ const traversal = await store.queryGraphTraversal(rootDoc.id, {
77
+ direction,
78
+ edgeType: options.edgeType,
79
+ maxDepth: options.maxDepth,
80
+ maxNodes: options.maxNodes,
81
+ frontierLimit: options.frontierLimit,
82
+ visitedLimit: options.visitedLimit,
83
+ });
84
+ if (!traversal.ok) {
85
+ return { success: false, error: traversal.error.message };
86
+ }
87
+
88
+ const rules = options.contentTypeRules ?? [];
89
+ const nodes = traversal.value.nodes
90
+ .map(({ doc, depth }) => toNode(doc, depth, rules))
91
+ .sort((a, b) => a.depth - b.depth || a.uri.localeCompare(b.uri));
92
+ const root =
93
+ nodes.find((node) => node.id === rootDoc.docid) ??
94
+ toNode(rootDoc, 0, rules);
95
+ const edges = traversal.value.edges
96
+ .map(({ edge, depth }) => ({
97
+ source: edge.sourceDocid,
98
+ target: edge.targetDocid,
99
+ edgeType: edge.edgeType,
100
+ relationType: edge.relationType,
101
+ confidence: edge.confidence,
102
+ edgeSource: edge.edgeSource,
103
+ depth,
104
+ }))
105
+ .sort(
106
+ (a, b) =>
107
+ a.depth - b.depth ||
108
+ a.edgeType.localeCompare(b.edgeType) ||
109
+ a.source.localeCompare(b.source) ||
110
+ a.target.localeCompare(b.target)
111
+ );
112
+
113
+ return {
114
+ success: true,
115
+ data: {
116
+ schemaVersion: "1.0",
117
+ root,
118
+ nodes,
119
+ edges,
120
+ meta: {
121
+ direction,
122
+ edgeType: options.edgeType ?? null,
123
+ maxDepth: Math.max(1, Math.min(options.maxDepth ?? 2, 6)),
124
+ maxNodes: Math.max(1, Math.min(options.maxNodes ?? 100, 1_000)),
125
+ frontierLimit: Math.max(
126
+ 1,
127
+ Math.min(options.frontierLimit ?? 100, 1_000)
128
+ ),
129
+ visitedLimit: Math.max(1, Math.min(options.visitedLimit ?? 500, 5_000)),
130
+ returnedNodes: nodes.length,
131
+ returnedEdges: edges.length,
132
+ truncated: traversal.value.truncated,
133
+ warnings: traversal.value.warnings,
134
+ },
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Shared SQL expressions for graph link target resolution.
3
+ *
4
+ * These helpers preserve getGraph's existing wiki matching order so future
5
+ * typed-edge projection/backfill can reuse the exact resolver.
6
+ *
7
+ * @module src/core/graph-resolver
8
+ */
9
+
10
+ const suffixMatch = (targetExpr: string, valueExpr: string): string =>
11
+ `(substr(${targetExpr}, -length(${valueExpr})) = ${valueExpr}
12
+ AND (length(${targetExpr}) = length(${valueExpr})
13
+ OR substr(${targetExpr}, -length(${valueExpr}) - 1, 1) = '/'))`;
14
+
15
+ const wikiTitleExpr = (alias: string): string => `lower(trim(${alias}.title))`;
16
+ const wikiRelPathExpr = (alias: string): string => `lower(${alias}.rel_path)`;
17
+
18
+ function wikiTargetExpressions(targetRefExpr: string): {
19
+ targetBaseExpr: string;
20
+ targetMdExpr: string;
21
+ } {
22
+ const targetBaseExpr = `CASE
23
+ WHEN ${targetRefExpr} LIKE '%.md' THEN substr(${targetRefExpr}, 1, length(${targetRefExpr}) - 3)
24
+ ELSE ${targetRefExpr}
25
+ END`;
26
+ return { targetBaseExpr, targetMdExpr: `${targetBaseExpr} || '.md'` };
27
+ }
28
+
29
+ export function buildWikiMatchExpression(
30
+ alias: string,
31
+ targetRefExpr: string
32
+ ): string {
33
+ const titleExpr = wikiTitleExpr(alias);
34
+ const relExpr = wikiRelPathExpr(alias);
35
+ const { targetBaseExpr, targetMdExpr } = wikiTargetExpressions(targetRefExpr);
36
+ return `(
37
+ ${titleExpr} = ${targetBaseExpr}
38
+ OR ${titleExpr} = ${targetMdExpr}
39
+ OR ${suffixMatch(targetBaseExpr, titleExpr)}
40
+ OR ${suffixMatch(targetMdExpr, `${titleExpr} || '.md'`)}
41
+ OR ${relExpr} = ${targetBaseExpr}
42
+ OR ${relExpr} = ${targetMdExpr}
43
+ OR ${suffixMatch(relExpr, targetMdExpr)}
44
+ OR ${suffixMatch(relExpr, targetBaseExpr)}
45
+ OR ${suffixMatch(targetMdExpr, relExpr)}
46
+ OR ${suffixMatch(targetBaseExpr, relExpr)}
47
+ )`;
48
+ }
49
+
50
+ export function buildWikiOrderExpression(
51
+ alias: string,
52
+ targetRefExpr: string
53
+ ): string {
54
+ const titleExpr = wikiTitleExpr(alias);
55
+ const relExpr = wikiRelPathExpr(alias);
56
+ const { targetBaseExpr, targetMdExpr } = wikiTargetExpressions(targetRefExpr);
57
+ return `CASE
58
+ WHEN ${titleExpr} = ${targetBaseExpr} THEN 1
59
+ WHEN ${titleExpr} = ${targetMdExpr} THEN 2
60
+ WHEN ${suffixMatch(targetBaseExpr, titleExpr)} THEN 3
61
+ WHEN ${suffixMatch(targetMdExpr, `${titleExpr} || '.md'`)} THEN 4
62
+ WHEN ${relExpr} = ${targetBaseExpr} THEN 5
63
+ WHEN ${relExpr} = ${targetMdExpr} THEN 6
64
+ WHEN ${suffixMatch(relExpr, targetMdExpr)} THEN 7
65
+ WHEN ${suffixMatch(relExpr, targetBaseExpr)} THEN 8
66
+ WHEN ${suffixMatch(targetMdExpr, relExpr)} THEN 9
67
+ WHEN ${suffixMatch(targetBaseExpr, relExpr)} THEN 10
68
+ ELSE 11
69
+ END`;
70
+ }
71
+
72
+ export function buildWikiBestMatchSubquery(
73
+ collectionExpr: string,
74
+ targetRefExpr: string
75
+ ): string {
76
+ return `
77
+ SELECT t.id FROM documents t
78
+ WHERE t.active = 1
79
+ AND t.collection = ${collectionExpr}
80
+ AND ${buildWikiMatchExpression("t", targetRefExpr)}
81
+ AND ${buildWikiOrderExpression("t", targetRefExpr)} = (
82
+ SELECT MIN(${buildWikiOrderExpression("t2", targetRefExpr)}) FROM documents t2
83
+ WHERE t2.active = 1
84
+ AND t2.collection = ${collectionExpr}
85
+ AND ${buildWikiMatchExpression("t2", targetRefExpr)}
86
+ )
87
+ ORDER BY t.id LIMIT 1
88
+ `;
89
+ }
90
+
91
+ export function buildWikiBestRankSubquery(
92
+ collectionExpr: string,
93
+ targetRefExpr: string
94
+ ): string {
95
+ return `
96
+ SELECT MIN(${buildWikiOrderExpression("t", targetRefExpr)}) FROM documents t
97
+ WHERE t.active = 1
98
+ AND t.collection = ${collectionExpr}
99
+ AND ${buildWikiMatchExpression("t", targetRefExpr)}
100
+ `;
101
+ }
102
+
103
+ export function buildWikiBestRankMatchCountSubquery(
104
+ collectionExpr: string,
105
+ targetRefExpr: string
106
+ ): string {
107
+ return `
108
+ SELECT COUNT(*) FROM documents t
109
+ WHERE t.active = 1
110
+ AND t.collection = ${collectionExpr}
111
+ AND ${buildWikiMatchExpression("t", targetRefExpr)}
112
+ AND ${buildWikiOrderExpression("t", targetRefExpr)} = (${buildWikiBestRankSubquery(
113
+ collectionExpr,
114
+ targetRefExpr
115
+ )})
116
+ `;
117
+ }
@@ -14,7 +14,11 @@ export type NotePresetId =
14
14
  | "research-note"
15
15
  | "decision-note"
16
16
  | "prompt-pattern"
17
- | "source-summary";
17
+ | "source-summary"
18
+ | "idea-original"
19
+ | "person"
20
+ | "company-project"
21
+ | "meeting";
18
22
 
19
23
  export interface NotePresetDefinition {
20
24
  id: NotePresetId;
@@ -104,14 +108,14 @@ export const NOTE_PRESETS: NotePresetDefinition[] = [
104
108
  {
105
109
  id: "decision-note",
106
110
  label: "Decision Note",
107
- description: "Context, decision, rationale, and consequences.",
111
+ description: "Current synthesis, decision, rationale, and timeline.",
108
112
  defaultTags: ["decision"],
109
113
  frontmatter: {
110
114
  category: "decision",
111
115
  status: "proposed",
112
116
  },
113
117
  body: (title) =>
114
- `# ${title}\n\n## Context\n\n## Decision\n\n## Rationale\n\n## Consequences\n`,
118
+ `# ${title}\n\n## Current Synthesis\n\n## Decision\n\n## Rationale\n\n## Consequences\n\n## Timeline\n`,
115
119
  },
116
120
  {
117
121
  id: "prompt-pattern",
@@ -127,14 +131,66 @@ export const NOTE_PRESETS: NotePresetDefinition[] = [
127
131
  {
128
132
  id: "source-summary",
129
133
  label: "Source Summary",
130
- description: "Summarize an article, paper, or external source cleanly.",
134
+ description: "Summarize a source with synthesis above evidence timeline.",
131
135
  defaultTags: ["source", "summary"],
132
136
  frontmatter: {
133
137
  category: "source-summary",
134
138
  sources: [],
135
139
  },
136
140
  body: (title) =>
137
- `# ${title}\n\n## Summary\n\n## Important Claims\n\n## Evidence / Quotes\n\n## Takeaways\n`,
141
+ `# ${title}\n\n## Current Synthesis\n\n## Important Claims\n\n## Assessment\n\n## Timeline\n\n## Evidence / Quotes\n`,
142
+ },
143
+ {
144
+ id: "idea-original",
145
+ label: "Original Idea",
146
+ description:
147
+ "Capture exact phrasing, context, related concepts, and publish potential.",
148
+ frontmatter: {
149
+ type: "idea-original",
150
+ category: "idea-original",
151
+ tags: [],
152
+ },
153
+ body: (title) =>
154
+ `# ${title}\n\n## Current Synthesis\n\n## Open Threads\n\n## Assessment\n\n## Timeline\n\n## Exact Phrasing\n\n## Related Concepts\n`,
155
+ },
156
+ {
157
+ id: "person",
158
+ label: "Person",
159
+ description:
160
+ "Track current state, relationship, assessment, open threads, and timeline.",
161
+ frontmatter: {
162
+ type: "person",
163
+ category: "person",
164
+ tags: [],
165
+ },
166
+ body: (title) =>
167
+ `# ${title}\n\n## Current Synthesis\n\n## Open Threads\n\n## Assessment\n\n## Timeline\n\n## Relationship\n`,
168
+ },
169
+ {
170
+ id: "company-project",
171
+ label: "Company / Project",
172
+ description:
173
+ "Track state, changes, decisions, people, open threads, and timeline.",
174
+ frontmatter: {
175
+ type: "company-project",
176
+ category: "company-project",
177
+ tags: [],
178
+ },
179
+ body: (title) =>
180
+ `# ${title}\n\n## Current Synthesis\n\n## Open Threads\n\n## Assessment\n\n## Timeline\n\n## Decisions\n\n## People\n`,
181
+ },
182
+ {
183
+ id: "meeting",
184
+ label: "Meeting",
185
+ description:
186
+ "Put analysis above the timeline; keep transcript, notes, and actions below.",
187
+ frontmatter: {
188
+ type: "meeting",
189
+ category: "meeting",
190
+ tags: [],
191
+ },
192
+ body: (title) =>
193
+ `# ${title}\n\n## Current Synthesis\n\n## Open Threads\n\n## Assessment\n\n## Timeline\n\n## Transcript / Notes\n\n## Action Items\n`,
138
194
  },
139
195
  ];
140
196
 
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Reference parser and resolver for document refs.
3
+ * Pure parsing helpers stay store-free; resolution depends only on StorePort.
4
+ *
5
+ * @module src/core/ref-parser
6
+ */
7
+
8
+ import type { DocumentRow, StorePort } from "../store/types";
9
+
10
+ export type RefType = "docid" | "uri" | "collPath";
11
+
12
+ export interface ParsedRef {
13
+ type: RefType;
14
+ /** Normalized ref (without :line suffix) */
15
+ value: string;
16
+ /** For collPath type */
17
+ collection?: string;
18
+ /** For collPath type */
19
+ relPath?: string;
20
+ /** Parsed :line suffix (1-indexed) */
21
+ line?: number;
22
+ }
23
+
24
+ export type ParseRefResult = ParsedRef | { error: string };
25
+
26
+ export type ResolvedDocRef =
27
+ | { doc: DocumentRow }
28
+ | { error: string; isValidation: boolean };
29
+
30
+ const DOCID_PATTERN = /^#[a-f0-9]{6,}$/;
31
+ const LINE_SUFFIX_PATTERN = /:(\d+)$/;
32
+ const GLOB_PATTERN = /[*?[\]]/;
33
+
34
+ /**
35
+ * Parse a single ref string.
36
+ * - Docid: starts with # (no :line suffix allowed)
37
+ * - URI: starts with gno:// (optional :N suffix)
38
+ * - Else: collection/path (optional :N suffix)
39
+ */
40
+ export function parseRef(ref: string): ParseRefResult {
41
+ if (ref.startsWith("#")) {
42
+ if (ref.includes(":")) {
43
+ return { error: "Docid refs cannot have :line suffix" };
44
+ }
45
+ if (!DOCID_PATTERN.test(ref)) {
46
+ return { error: `Invalid docid format: ${ref}` };
47
+ }
48
+ return { type: "docid", value: ref };
49
+ }
50
+
51
+ let line: number | undefined;
52
+ let baseRef = ref;
53
+ const lineMatch = ref.match(LINE_SUFFIX_PATTERN);
54
+ if (lineMatch?.[1]) {
55
+ const parsed = Number.parseInt(lineMatch[1], 10);
56
+ if (!Number.isInteger(parsed) || parsed < 1) {
57
+ return { error: `Invalid line suffix (must be >= 1): ${ref}` };
58
+ }
59
+ line = parsed;
60
+ baseRef = ref.slice(0, -lineMatch[0].length);
61
+ }
62
+
63
+ if (baseRef.startsWith("gno://")) {
64
+ return { type: "uri", value: baseRef, line };
65
+ }
66
+
67
+ const slashIdx = baseRef.indexOf("/");
68
+ if (slashIdx === -1) {
69
+ return { error: `Invalid ref format (missing /): ${ref}` };
70
+ }
71
+ const collection = baseRef.slice(0, slashIdx);
72
+ const relPath = baseRef.slice(slashIdx + 1);
73
+
74
+ return { type: "collPath", value: baseRef, collection, relPath, line };
75
+ }
76
+
77
+ /**
78
+ * Resolve a document reference against the store.
79
+ */
80
+ export async function resolveDocRef(
81
+ store: StorePort,
82
+ docRef: string
83
+ ): Promise<ResolvedDocRef> {
84
+ const parsed = parseRef(docRef);
85
+
86
+ if ("error" in parsed) {
87
+ return { error: parsed.error, isValidation: true };
88
+ }
89
+
90
+ let doc: DocumentRow | null = null;
91
+
92
+ switch (parsed.type) {
93
+ case "docid": {
94
+ const result = await store.getDocumentByDocid(parsed.value);
95
+ if (result.ok && result.value) {
96
+ doc = result.value;
97
+ }
98
+ break;
99
+ }
100
+ case "uri": {
101
+ const result = await store.getDocumentByUri(parsed.value);
102
+ if (result.ok && result.value) {
103
+ doc = result.value;
104
+ }
105
+ break;
106
+ }
107
+ case "collPath": {
108
+ const uri = `gno://${parsed.collection}/${parsed.relPath}`;
109
+ const result = await store.getDocumentByUri(uri);
110
+ if (result.ok && result.value) {
111
+ doc = result.value;
112
+ }
113
+ break;
114
+ }
115
+ }
116
+
117
+ if (!doc) {
118
+ return { error: `Document not found: ${docRef}`, isValidation: true };
119
+ }
120
+
121
+ return { doc };
122
+ }
123
+
124
+ /**
125
+ * Split comma-separated refs. Does NOT expand globs.
126
+ */
127
+ export function splitRefs(refs: string[]): string[] {
128
+ const result: string[] = [];
129
+ for (const r of refs) {
130
+ for (const part of r.split(",")) {
131
+ const trimmed = part.trim();
132
+ if (trimmed) {
133
+ result.push(trimmed);
134
+ }
135
+ }
136
+ }
137
+ return result;
138
+ }
139
+
140
+ /**
141
+ * Check if a ref contains glob characters.
142
+ */
143
+ export function isGlobPattern(ref: string): boolean {
144
+ return GLOB_PATTERN.test(ref);
145
+ }