@gmickel/gno 2.2.1 → 2.3.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.
package/README.md CHANGED
@@ -117,7 +117,7 @@ gno daemon --detach # headless indexing + resident MCP gateway
117
117
 
118
118
  <!-- public-truth:current-version -->
119
119
 
120
- > Current source version: **v2.2.1**. See [CHANGELOG.md](./CHANGELOG.md).
120
+ > Current source version: **v2.3.0**. See [CHANGELOG.md](./CHANGELOG.md).
121
121
 
122
122
  <!-- /public-truth -->
123
123
 
@@ -509,6 +509,13 @@ gno embed --collection travel
509
509
 
510
510
  MCP `gno.sync` and `gno.capture` do NOT auto-embed. Use CLI for embedding.
511
511
 
512
+ Optional index-wide YAML `chunking.maxTokens` and `chunking.overlapPercent`
513
+ control size and overlap (`0.15` means 15%). Leave defaults unless asked to
514
+ tune them. After a change, `gno index` rechunks cached mirrors and embeds;
515
+ `gno update` rechunks only. Check `gno status --json` fields
516
+ `chunking.pendingMirrors` and `embeddingBacklog`. Use separate `--config`
517
+ files and `--index` names for comparisons; see [cli-reference.md](cli-reference.md).
518
+
512
519
  ## Capture Notes
513
520
 
514
521
  Use `gno capture` for quick second-brain writes into an editable collection:
@@ -24,6 +24,22 @@ punctuation. NFC/case-equivalent spellings share one identity. See
24
24
 
25
25
  ## Initialization
26
26
 
27
+ ### Optional index-wide chunking
28
+
29
+ User YAML can set `chunking: { maxTokens: 256, overlapPercent: 0.15 }`.
30
+ Defaults remain 800 approximate tokens and 0.15 overlap; unchanged/default
31
+ config does not force a rebuild. The token estimate uses four characters per
32
+ token, and overlap accepts a fraction from 0 through 0.5.
33
+
34
+ After changing policy, `gno index` rechunks cached mirrors and embeds changed
35
+ chunks. `gno update` performs the rechunking without embedding. The policy
36
+ applies across the index even for targeted source sync; original files are not
37
+ read merely to rechunk. `gno status --json` reports configured/applied policy,
38
+ pending mirrors/documents, and a separate embedding backlog. On
39
+ `CHUNKING_POLICY_CONFLICT`, reopen the client with the intended configuration.
40
+ Compare policies with separate `--config` files and `--index` names, holding
41
+ corpus, models, query set, and type boosts constant.
42
+
27
43
  ### gno setup
28
44
 
29
45
  Preferred activation path: add one folder, prove a real exact lexical result,
@@ -0,0 +1 @@
1
+ 9eea4e537f21515ea5de8e1eaea1710b5af0a1201a8b05db399ef0b6874f5f64 gno-browser-clipper-v2.3.0.zip
@@ -21,5 +21,5 @@
21
21
  "content_security_policy": {
22
22
  "extension_pages": "script-src 'self'; object-src 'none'; connect-src http://127.0.0.1:*"
23
23
  },
24
- "version": "2.2.1"
24
+ "version": "2.3.0"
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
package/spec/cli.md CHANGED
@@ -531,6 +531,27 @@ protection remain authoritative.
531
531
  `graphHints` is active: ordered hints type
532
532
  projected wiki/markdown edges and surface in graph traversal/diagnose metadata.
533
533
 
534
+ Optional root `chunking` sets one policy per index. `maxTokens` defaults to 800
535
+ and accepts integers from 10 through 2251799813685247; the chunker estimates
536
+ four characters per token. `overlapPercent` defaults to 0.15 and accepts a
537
+ fraction from 0 through 0.5. Missing fields inherit defaults. Invalid values
538
+ fail configuration validation before index mutation.
539
+
540
+ Omitted or explicitly default settings preserve existing chunks and embeddings
541
+ on upgrade. A policy change rechunks cached Markdown mirrors during the next
542
+ index/update or targeted sync, including mirrors outside the source-refresh
543
+ collection. Rechunking does not read original files or claim source freshness.
544
+ Sync-only operations leave changed chunks pending embedding; `gno index`
545
+ also embeds. A stale writer receives `CHUNKING_POLICY_CONFLICT` and must reopen
546
+ its client/runtime with the intended configuration.
547
+
548
+ Status JSON adds `chunking` with `configured` parameters, `applied` parameters
549
+ (null for empty/mixed layouts), `state` (`empty`, `legacy-default`, `current`,
550
+ `pending`, or `mixed`), `pendingDocuments`, and `pendingMirrors`. These counts
551
+ cover active cached content, independently of source errors and embedding
552
+ backlog. Sync receipts optionally add `rechunkedMirrors` when cached layouts
553
+ were updated; existing file counters keep their source-refresh meaning.
554
+
534
555
  `collections[].sourceAvailability` is optional; omitted means `any`. Exact
535
556
  values: `any` | `local`. There is no separate public knob beyond these two
536
557
  modes and no claim that availability is egress policy. `any` preserves
package/spec/mcp.md CHANGED
@@ -1352,6 +1352,15 @@ counters; it never claims attachment to another process.
1352
1352
  normalized IDs/factors plus the rules fingerprint; path prefixes are never
1353
1353
  returned.
1354
1354
 
1355
+ `chunking` reports the index-wide configuration and cached layout readiness:
1356
+ `configured` and `applied` contain `maxTokens` and `overlapPercent` (`applied`
1357
+ is null for empty/mixed layouts); `state` is `empty`, `legacy-default`,
1358
+ `current`, `pending`, or `mixed`; `pendingDocuments` and `pendingMirrors` count
1359
+ active cached content using another policy. Source refresh errors and embedding
1360
+ backlog remain separate. Reading status never rechunks or changes the policy.
1361
+ Sync results may include `rechunkedMirrors` when a policy change updates cached
1362
+ layouts. A stale client must reopen on `CHUNKING_POLICY_CONFLICT`.
1363
+
1355
1364
  ---
1356
1365
 
1357
1366
  ### gno_recall
@@ -24,6 +24,47 @@
24
24
  "resident"
25
25
  ],
26
26
  "properties": {
27
+ "chunking": {
28
+ "type": "object",
29
+ "additionalProperties": false,
30
+ "required": [
31
+ "configured",
32
+ "applied",
33
+ "state",
34
+ "pendingDocuments",
35
+ "pendingMirrors"
36
+ ],
37
+ "properties": {
38
+ "configured": { "$ref": "#/properties/chunking/definitions/params" },
39
+ "applied": {
40
+ "oneOf": [
41
+ { "$ref": "#/properties/chunking/definitions/params" },
42
+ { "type": "null" }
43
+ ]
44
+ },
45
+ "state": {
46
+ "type": "string",
47
+ "enum": ["empty", "legacy-default", "current", "pending", "mixed"]
48
+ },
49
+ "pendingDocuments": { "type": "integer", "minimum": 0 },
50
+ "pendingMirrors": { "type": "integer", "minimum": 0 }
51
+ },
52
+ "definitions": {
53
+ "params": {
54
+ "type": "object",
55
+ "additionalProperties": false,
56
+ "required": ["maxTokens", "overlapPercent"],
57
+ "properties": {
58
+ "maxTokens": {
59
+ "type": "integer",
60
+ "minimum": 10,
61
+ "maximum": 2251799813685247
62
+ },
63
+ "overlapPercent": { "type": "number", "minimum": 0, "maximum": 0.5 }
64
+ }
65
+ }
66
+ }
67
+ },
27
68
  "resident": {
28
69
  "$ref": "gno://schemas/resident-status@1.0"
29
70
  },
@@ -160,6 +160,12 @@ export function formatSyncResultLines(
160
160
  ): string[] {
161
161
  const lines: string[] = [];
162
162
 
163
+ if (syncResult.rechunkedMirrors) {
164
+ lines.push(
165
+ `Rechunked ${syncResult.rechunkedMirrors} cached mirrors. Run gno embed if embedding was skipped.`
166
+ );
167
+ }
168
+
163
169
  for (const c of syncResult.collections) {
164
170
  lines.push(`${c.collection}:`);
165
171
  lines.push(
@@ -19,6 +19,7 @@ import {
19
19
  } from "../../config";
20
20
  import { isConnectorActivationComplete } from "../../core/activation-connector-health";
21
21
  import { buildActivationStatus } from "../../core/activation-status";
22
+ import { formatChunkingStatus } from "../../core/chunking-status";
22
23
  import {
23
24
  buildMemoryStatus,
24
25
  formatMemoryStatusLines,
@@ -112,6 +113,9 @@ function formatTerminal(
112
113
  lines.push(`Embedding backlog: ${indexStatus.embeddingBacklog} chunks`);
113
114
  }
114
115
 
116
+ const chunking = formatChunkingStatus(indexStatus.chunking);
117
+ if (chunking) lines.push(chunking);
118
+
115
119
  if (indexStatus.recentErrors > 0) {
116
120
  lines.push(`Recent errors: ${indexStatus.recentErrors} (last 24h)`);
117
121
  }
@@ -198,6 +202,9 @@ function formatMarkdown(
198
202
  lines.push(`- **Embedding backlog**: ${indexStatus.embeddingBacklog}`);
199
203
  lines.push(`- **Recent errors**: ${indexStatus.recentErrors}`);
200
204
 
205
+ const chunking = formatChunkingStatus(indexStatus.chunking);
206
+ if (chunking) lines.push(`- ${chunking}`);
207
+
201
208
  if (indexStatus.lastUpdatedAt) {
202
209
  lines.push(`- **Last updated**: ${indexStatus.lastUpdatedAt}`);
203
210
  }
@@ -274,6 +281,7 @@ export async function status(
274
281
  try {
275
282
  const statusResult = await store.getStatus({
276
283
  embedModel: resolveModelUri(config, "embed"),
284
+ chunking: config.chunking ?? {},
277
285
  });
278
286
  if (!statusResult.ok) {
279
287
  return { success: false, error: statusResult.error.message };
@@ -339,6 +347,7 @@ export function formatStatus(
339
347
  totalDocuments: s.activeDocuments,
340
348
  totalChunks: s.totalChunks,
341
349
  embeddingBacklog: s.embeddingBacklog,
350
+ chunking: s.chunking,
342
351
  lastUpdated: s.lastUpdatedAt,
343
352
  healthy: isStatusHealthy(s, result.activation),
344
353
  contentTypeBoost: result.contentTypeBoost,
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+
3
+ /** Existing character-based chunker defaults. */
4
+ export const DEFAULT_CHUNKING_PARAMS = {
5
+ maxTokens: 800,
6
+ overlapPercent: 0.15,
7
+ };
8
+
9
+ export const MAX_CHUNK_TOKENS = Math.floor(Number.MAX_SAFE_INTEGER / 4);
10
+
11
+ export const ChunkingConfigSchema = z
12
+ .object({
13
+ maxTokens: z
14
+ .number()
15
+ .int()
16
+ .min(10)
17
+ .max(MAX_CHUNK_TOKENS)
18
+ .default(DEFAULT_CHUNKING_PARAMS.maxTokens),
19
+ overlapPercent: z
20
+ .number()
21
+ .finite()
22
+ .min(0)
23
+ .max(0.5)
24
+ .default(DEFAULT_CHUNKING_PARAMS.overlapPercent),
25
+ })
26
+ .strict();
27
+
28
+ export type ChunkingParams = z.infer<typeof ChunkingConfigSchema>;
29
+
30
+ export function resolveChunkingParams(
31
+ input?: Partial<ChunkingParams>
32
+ ): ChunkingParams {
33
+ return ChunkingConfigSchema.parse(input ?? {});
34
+ }
35
+
36
+ /** Canonical JSON is the policy identity, including partial/default aliases. */
37
+ export function chunkingPolicyKey(params: ChunkingParams): string {
38
+ return JSON.stringify({
39
+ maxTokens: params.maxTokens,
40
+ overlapPercent: params.overlapPercent,
41
+ });
42
+ }
43
+
44
+ export const DEFAULT_CHUNKING_POLICY_KEY = chunkingPolicyKey(
45
+ DEFAULT_CHUNKING_PARAMS
46
+ );
@@ -12,6 +12,7 @@ import { z } from "zod";
12
12
  import { URI_PREFIX } from "../app/constants";
13
13
  import { JsonlFieldMappingSchema } from "../converters/adapters/jsonl/config";
14
14
  import { MCP_TOOL_PROFILES } from "../mcp/tool-profile";
15
+ import { ChunkingConfigSchema, type ChunkingParams } from "./chunking";
15
16
  import { RetrievalTraceConfigSchema } from "./retrieval-traces";
16
17
 
17
18
  // ─────────────────────────────────────────────────────────────────────────────
@@ -595,6 +596,9 @@ export const ConfigSchema = z.object({
595
596
  /** Opt-in schema-lite content type rules */
596
597
  contentTypes: z.array(ContentTypeSchema).default([]),
597
598
 
599
+ /** Optional index-wide chunk size/overlap; omitted preserves legacy defaults. */
600
+ chunking: ChunkingConfigSchema.optional(),
601
+
598
602
  /** Model configuration */
599
603
  models: ModelConfigSchema.optional(),
600
604
 
@@ -632,9 +636,10 @@ export const ConfigSchema = z.object({
632
636
 
633
637
  export type Config = Omit<
634
638
  z.infer<typeof ConfigSchema>,
635
- "contentTypes" | "busyTimeoutMs"
639
+ "contentTypes" | "busyTimeoutMs" | "chunking"
636
640
  > & {
637
641
  contentTypes?: ContentTypeConfig[];
642
+ chunking?: Partial<ChunkingParams>;
638
643
  /** Present after schema parse; omitted on hand-built Config objects. */
639
644
  busyTimeoutMs?: number;
640
645
  };
@@ -0,0 +1,30 @@
1
+ import type { ChunkingParams } from "../config/chunking";
2
+ import type { ChunkingStatus } from "../store/chunking";
3
+
4
+ import {
5
+ chunkingPolicyKey,
6
+ DEFAULT_CHUNKING_POLICY_KEY,
7
+ } from "../config/chunking";
8
+
9
+ const formatParams = (params: ChunkingParams): string =>
10
+ `${params.maxTokens} approximate tokens / ${Number((params.overlapPercent * 100).toFixed(4))}% overlap`;
11
+
12
+ /** Keep the healthy default terminal output unchanged. */
13
+ export function formatChunkingStatus(status?: ChunkingStatus): string | null {
14
+ if (!status) return null;
15
+ if (
16
+ chunkingPolicyKey(status.configured) === DEFAULT_CHUNKING_POLICY_KEY &&
17
+ status.pendingMirrors === 0 &&
18
+ status.state !== "mixed"
19
+ )
20
+ return null;
21
+ const applied = status.applied
22
+ ? formatParams(status.applied)
23
+ : status.state === "empty"
24
+ ? "none"
25
+ : "mixed";
26
+ const pending = status.pendingMirrors
27
+ ? `; ${status.pendingDocuments} documents / ${status.pendingMirrors} mirrors pending (run gno update)`
28
+ : "";
29
+ return `Chunking: configured ${formatParams(status.configured)}; applied ${applied}${pending}`;
30
+ }
@@ -1,6 +1,7 @@
1
1
  /** Shared mutation detection for resident content and vector generations. */
2
2
 
3
3
  interface SyncMutationCounts {
4
+ rechunkedMirrors?: number;
4
5
  filesAdded?: number;
5
6
  filesUpdated?: number;
6
7
  filesMarkedInactive?: number;
@@ -11,6 +12,7 @@ interface SyncMutationCounts {
11
12
 
12
13
  export function hasContentMutation(result: SyncMutationCounts): boolean {
13
14
  return (
15
+ (result.rechunkedMirrors ?? 0) > 0 ||
14
16
  (result.filesAdded ?? result.totalFilesAdded ?? 0) > 0 ||
15
17
  (result.filesUpdated ?? result.totalFilesUpdated ?? 0) > 0 ||
16
18
  (result.filesMarkedInactive ?? 0) > 0 ||
@@ -0,0 +1,108 @@
1
+ import type { ChunkingPolicyToken } from "../store/chunking";
2
+ import type { ChunkInput, StorePort, StoreResult } from "../store/types";
3
+ import type { ChunkerPort, SyncOptions } from "./types";
4
+
5
+ import {
6
+ chunkingPolicyKey,
7
+ DEFAULT_CHUNKING_POLICY_KEY,
8
+ resolveChunkingParams,
9
+ } from "../config/chunking";
10
+ import { ChunkingPolicyConflictError } from "../store/chunking";
11
+
12
+ function unwrapChunking<T>(result: StoreResult<T>): T {
13
+ if (result.ok) return result.value;
14
+ if (result.error.code === "CHUNKING_POLICY_CONFLICT") {
15
+ throw new ChunkingPolicyConflictError();
16
+ }
17
+ throw new Error(`Chunking failed: ${result.error.message}`);
18
+ }
19
+
20
+ /** One preparation per sync, including nested collection/path syncs. */
21
+ export async function prepareChunking(
22
+ store: StorePort,
23
+ chunker: ChunkerPort,
24
+ options: SyncOptions
25
+ ): Promise<{ options: SyncOptions; rechunkedMirrors: number }> {
26
+ if (options.chunkingToken) return { options, rechunkedMirrors: 0 };
27
+ const params = resolveChunkingParams(options.chunking);
28
+ if (
29
+ !(
30
+ store.claimChunkingPolicy &&
31
+ store.listPendingChunkingMirrors &&
32
+ store.applyChunkLayout
33
+ )
34
+ ) {
35
+ if (chunkingPolicyKey(params) !== DEFAULT_CHUNKING_POLICY_KEY) {
36
+ throw new Error("This store does not support configurable chunking");
37
+ }
38
+ return { options, rechunkedMirrors: 0 };
39
+ }
40
+
41
+ const token = unwrapChunking(await store.claimChunkingPolicy(params));
42
+ let rechunkedMirrors = 0;
43
+ let afterHash = "";
44
+ for (;;) {
45
+ const mirrors = unwrapChunking(
46
+ await store.listPendingChunkingMirrors(token, afterHash)
47
+ );
48
+ if (mirrors.length === 0) break;
49
+ for (const mirror of mirrors) {
50
+ const markdown = unwrapChunking(
51
+ await store.getContent(mirror.mirrorHash)
52
+ );
53
+ if (markdown === null)
54
+ throw new Error("Cached mirror disappeared during rechunking");
55
+ const chunks: ChunkInput[] = chunker
56
+ .chunk(markdown, token.params, mirror.languageHint, mirror.sourcePath)
57
+ .map((chunk) => ({
58
+ seq: chunk.seq,
59
+ pos: chunk.pos,
60
+ text: chunk.text,
61
+ startLine: chunk.startLine,
62
+ endLine: chunk.endLine,
63
+ language: chunk.language ?? undefined,
64
+ tokenCount: chunk.tokenCount ?? undefined,
65
+ }));
66
+ unwrapChunking(
67
+ await store.applyChunkLayout(
68
+ mirror.mirrorHash,
69
+ chunks,
70
+ token,
71
+ mirror.sourcePath,
72
+ mirror.languageHint
73
+ )
74
+ );
75
+ rechunkedMirrors += 1;
76
+ afterHash = mirror.mirrorHash;
77
+ }
78
+ }
79
+ return {
80
+ options: { ...options, chunking: token.params, chunkingToken: token },
81
+ rechunkedMirrors,
82
+ };
83
+ }
84
+
85
+ /** Preserve the original store contract for default-only test/alternate ports. */
86
+ export async function persistChunkLayout(
87
+ store: StorePort,
88
+ mirrorHash: string,
89
+ chunks: ChunkInput[],
90
+ token: ChunkingPolicyToken | undefined,
91
+ sourcePath: string,
92
+ languageHint?: string
93
+ ): Promise<void> {
94
+ if (token && store.applyChunkLayout) {
95
+ unwrapChunking(
96
+ await store.applyChunkLayout(
97
+ mirrorHash,
98
+ chunks,
99
+ token,
100
+ sourcePath,
101
+ languageHint
102
+ )
103
+ );
104
+ return;
105
+ }
106
+ unwrapChunking(await store.upsertChunks(mirrorHash, chunks));
107
+ unwrapChunking(await store.rebuildFtsForHash(mirrorHash));
108
+ }
@@ -23,6 +23,7 @@ import {
23
23
  } from "../core/change-diff";
24
24
  import { createEgressLineage } from "../core/egress-provenance";
25
25
  import { normalizeTag, validateTag } from "../core/tags";
26
+ import { persistChunkLayout } from "./chunking";
26
27
  import { runRecordAdapter } from "./record-adapter";
27
28
  import { recordVirtualPath } from "./record-path";
28
29
  import { reconcileRecordSnapshot, type RecordSyncPlan } from "./record-sync";
@@ -397,7 +398,7 @@ const persistRecord = async (
397
398
  const chunks: ChunkInput[] = input.chunker
398
399
  .chunk(
399
400
  record.markdown,
400
- DEFAULT_CHUNK_PARAMS,
401
+ input.options.chunkingToken?.params ?? DEFAULT_CHUNK_PARAMS,
401
402
  record.languageHint ?? input.collection.languageHint,
402
403
  virtualPath
403
404
  )
@@ -410,13 +411,13 @@ const persistRecord = async (
410
411
  language: chunk.language ?? undefined,
411
412
  tokenCount: chunk.tokenCount ?? undefined,
412
413
  }));
413
- mustOk(
414
- await input.store.upsertChunks(record.mirrorHash, chunks),
415
- "upsertChunks"
416
- );
417
- mustOk(
418
- await input.store.rebuildFtsForHash(record.mirrorHash),
419
- "rebuildFtsForHash"
414
+ await persistChunkLayout(
415
+ input.store,
416
+ record.mirrorHash,
417
+ chunks,
418
+ input.options.chunkingToken,
419
+ virtualPath,
420
+ record.languageHint ?? input.collection.languageHint
420
421
  );
421
422
  mustOk(
422
423
  await input.store.setDocTags(document.id, categories, "frontmatter"),
@@ -20,11 +20,13 @@ export function resolveContentTypeRules(
20
20
 
21
21
  export function withContentTypeRules(
22
22
  options: SyncOptions = {},
23
- config?: Pick<Config, "contentTypes">
23
+ config?: Pick<Config, "contentTypes" | "chunking">
24
24
  ): SyncOptions {
25
25
  const rules = options.contentTypeRules ?? resolveContentTypeRules(config);
26
+ const chunking = options.chunking ?? config?.chunking;
26
27
  return {
27
28
  ...options,
29
+ ...(chunking ? { chunking } : {}),
28
30
  contentTypeRules: rules,
29
31
  contentTypeRulesFingerprint:
30
32
  options.contentTypeRulesFingerprint ??
@@ -58,6 +58,7 @@ import {
58
58
  import { extractMemoryScopes } from "../core/memory-record";
59
59
  import { normalizeTag, validateTag } from "../core/tags";
60
60
  import { defaultChunker } from "./chunker";
61
+ import { persistChunkLayout, prepareChunking } from "./chunking";
61
62
  import {
62
63
  extractHashtags,
63
64
  parseFrontmatter,
@@ -937,7 +938,7 @@ export class SyncService {
937
938
  // 9. Chunk content
938
939
  const chunks = this.chunker.chunk(
939
940
  artifact.markdown,
940
- DEFAULT_CHUNK_PARAMS,
941
+ options.chunkingToken?.params ?? DEFAULT_CHUNK_PARAMS,
941
942
  artifact.languageHint ?? collection.languageHint,
942
943
  entry.relPath
943
944
  );
@@ -953,21 +954,15 @@ export class SyncService {
953
954
  tokenCount: c.tokenCount ?? undefined,
954
955
  }));
955
956
 
956
- // 11. Upsert chunks - CHECKED
957
- const chunksResult = await store.upsertChunks(
957
+ // 11-12. Apply chunks and lexical projection under the policy token.
958
+ await persistChunkLayout(
959
+ store,
958
960
  artifact.mirrorHash,
959
- chunkInputs
961
+ chunkInputs,
962
+ options.chunkingToken,
963
+ entry.relPath,
964
+ artifact.languageHint ?? collection.languageHint
960
965
  );
961
- mustOk(chunksResult, "upsertChunks", {
962
- mirrorHash: artifact.mirrorHash,
963
- chunkCount: chunkInputs.length,
964
- });
965
-
966
- // 12. Rebuild FTS for this hash - CHECKED
967
- const ftsResult = await store.rebuildFtsForHash(artifact.mirrorHash);
968
- mustOk(ftsResult, "rebuildFtsForHash", {
969
- mirrorHash: artifact.mirrorHash,
970
- });
971
966
 
972
967
  // 13. Extract and store tags from frontmatter and body hashtags
973
968
  // Always call setDocTags to clear removed tags on re-sync
@@ -1249,8 +1244,9 @@ export class SyncService {
1249
1244
  options: SyncOptions = {}
1250
1245
  ): Promise<CollectionSyncResult> {
1251
1246
  const startedAt = Date.now();
1247
+ const prepared = await prepareChunking(store, this.chunker, options);
1252
1248
  const syncOptions: SyncOptions = {
1253
- ...options,
1249
+ ...prepared.options,
1254
1250
  contentTypeRules: options.contentTypeRules ?? [],
1255
1251
  contentTypeRulesFingerprint:
1256
1252
  options.contentTypeRulesFingerprint ??
@@ -1511,13 +1507,18 @@ export class SyncService {
1511
1507
  syncOptions.projectTypedEdges === false
1512
1508
  ? []
1513
1509
  : await this.projectTypedEdges(store, syncOptions, projectionSourceIds);
1514
- return summarizePathResults(
1515
- collection.name,
1516
- results,
1517
- markedInactive,
1518
- startedAt,
1519
- errors
1520
- );
1510
+ return {
1511
+ ...summarizePathResults(
1512
+ collection.name,
1513
+ results,
1514
+ markedInactive,
1515
+ startedAt,
1516
+ errors
1517
+ ),
1518
+ ...(prepared.rechunkedMirrors
1519
+ ? { rechunkedMirrors: prepared.rechunkedMirrors }
1520
+ : {}),
1521
+ };
1521
1522
  }
1522
1523
 
1523
1524
  /**
@@ -1717,8 +1718,9 @@ export class SyncService {
1717
1718
  options: SyncOptions = {}
1718
1719
  ): Promise<CollectionSyncResult> {
1719
1720
  const startTime = Date.now();
1721
+ const prepared = await prepareChunking(store, this.chunker, options);
1720
1722
  const syncOptions: SyncOptions = {
1721
- ...options,
1723
+ ...prepared.options,
1722
1724
  contentTypeRules: options.contentTypeRules ?? [],
1723
1725
  contentTypeRulesFingerprint:
1724
1726
  options.contentTypeRulesFingerprint ??
@@ -2038,6 +2040,9 @@ export class SyncService {
2038
2040
 
2039
2041
  return {
2040
2042
  collection: collection.name,
2043
+ ...(prepared.rechunkedMirrors
2044
+ ? { rechunkedMirrors: prepared.rechunkedMirrors }
2045
+ : {}),
2041
2046
  filesProcessed: entries.length + inventoryErrored,
2042
2047
  filesAdded: added,
2043
2048
  filesUpdated: updated,
@@ -2060,9 +2065,10 @@ export class SyncService {
2060
2065
  options: SyncOptions = {}
2061
2066
  ): Promise<SyncResult> {
2062
2067
  const startTime = Date.now();
2068
+ const prepared = await prepareChunking(store, this.chunker, options);
2063
2069
  const results: CollectionSyncResult[] = [];
2064
2070
  const deferredProjectionOptions: SyncOptions = {
2065
- ...options,
2071
+ ...prepared.options,
2066
2072
  projectTypedEdges: false,
2067
2073
  };
2068
2074
 
@@ -2094,6 +2100,9 @@ export class SyncService {
2094
2100
 
2095
2101
  return {
2096
2102
  collections: results,
2103
+ ...(prepared.rechunkedMirrors
2104
+ ? { rechunkedMirrors: prepared.rechunkedMirrors }
2105
+ : {}),
2097
2106
  totalDurationMs: Date.now() - startTime,
2098
2107
  totalFilesProcessed: totals.processed,
2099
2108
  totalFilesAdded: totals.added,
@@ -12,8 +12,14 @@ import type {
12
12
  RecordAttachmentInventoryItem,
13
13
  } from "../converters/types";
14
14
  import type { EgressLineage } from "../core/egress-provenance";
15
+ import type { ChunkingPolicyToken } from "../store/chunking";
15
16
  import type { DirectoryAvailabilityPort } from "./source-availability/types";
16
17
 
18
+ import {
19
+ DEFAULT_CHUNKING_PARAMS,
20
+ type ChunkingParams,
21
+ } from "../config/chunking";
22
+
17
23
  // ─────────────────────────────────────────────────────────────────────────────
18
24
  // Walker Types
19
25
  // ─────────────────────────────────────────────────────────────────────────────
@@ -115,8 +121,7 @@ export interface ChunkParams {
115
121
 
116
122
  /** Default chunk params */
117
123
  export const DEFAULT_CHUNK_PARAMS: ChunkParams = {
118
- maxTokens: 800,
119
- overlapPercent: 0.15,
124
+ ...DEFAULT_CHUNKING_PARAMS,
120
125
  };
121
126
 
122
127
  /** Chunked output */
@@ -157,6 +162,10 @@ export interface ChunkerPort {
157
162
 
158
163
  /** Sync options */
159
164
  export interface SyncOptions {
165
+ /** Index-wide configured policy; omitted means the existing defaults. */
166
+ chunking?: Partial<ChunkingParams>;
167
+ /** Internal token passed from an outer sync; never a public CLI override. */
168
+ chunkingToken?: ChunkingPolicyToken;
160
169
  /** Run git pull before scanning */
161
170
  gitPull?: boolean;
162
171
  /** Run collection updateCmd before scanning */
@@ -266,6 +275,8 @@ export interface FileSyncResult {
266
275
 
267
276
  /** Collection sync summary */
268
277
  export interface CollectionSyncResult {
278
+ /** Cached layouts updated independently of source-refresh file counters. */
279
+ rechunkedMirrors?: number;
269
280
  collection: string;
270
281
  filesProcessed: number;
271
282
  filesAdded: number;
@@ -285,6 +296,8 @@ export interface CollectionSyncResult {
285
296
 
286
297
  /** Full sync summary */
287
298
  export interface SyncResult {
299
+ /** Cached layouts updated independently of source-refresh file counters. */
300
+ rechunkedMirrors?: number;
288
301
  collections: CollectionSyncResult[];
289
302
  totalDurationMs: number;
290
303
  totalFilesProcessed: number;
@@ -8,6 +8,7 @@ import type { IndexStatus } from "../../store/types";
8
8
  import type { ToolContext } from "../server";
9
9
 
10
10
  import { buildContentTypeBoostStatus } from "../../config/content-types";
11
+ import { formatChunkingStatus } from "../../core/chunking-status";
11
12
  import { resolveModelUri } from "../../llm/registry";
12
13
  import { createStandaloneResidentStatus } from "../../serve/resident-status";
13
14
  import { runTool, type ToolResult } from "./index";
@@ -55,6 +56,9 @@ function formatStatus(status: IndexStatus): string {
55
56
  lines.push(`Embedding backlog: ${status.embeddingBacklog} chunks`);
56
57
  }
57
58
 
59
+ const chunking = formatChunkingStatus(status.chunking);
60
+ if (chunking) lines.push(chunking);
61
+
58
62
  if (status.recentErrors > 0) {
59
63
  lines.push(`Recent errors: ${status.recentErrors} (last 24h)`);
60
64
  }
@@ -79,6 +83,7 @@ export function handleStatus(
79
83
  async () => {
80
84
  const result = await ctx.store.getStatus({
81
85
  embedModel: resolveModelUri(ctx.config, "embed"),
86
+ chunking: ctx.config.chunking ?? {},
82
87
  });
83
88
  if (!result.ok) {
84
89
  throw new Error(result.error.message);
package/src/sdk/client.ts CHANGED
@@ -1352,6 +1352,7 @@ class GnoClientImpl implements GnoClient {
1352
1352
  const status = unwrapStore(
1353
1353
  await this.store.getStatus({
1354
1354
  embedModel: resolveModelUri(this.config, "embed"),
1355
+ chunking: this.config.chunking ?? {},
1355
1356
  })
1356
1357
  );
1357
1358
  return {
@@ -1,5 +1,6 @@
1
1
  import type { ContentTypeBoostStatus } from "../config/content-types";
2
2
  import type { ActivationStatus } from "../core/activation-status";
3
+ import type { ChunkingStatus } from "../store/chunking";
3
4
 
4
5
  export type HealthCheckStatus = "ok" | "warn" | "error";
5
6
 
@@ -176,6 +177,7 @@ export interface BootstrapState {
176
177
  }
177
178
 
178
179
  export interface AppStatusResponse {
180
+ chunking?: ChunkingStatus;
179
181
  resident: ResidentStatus;
180
182
  indexName: string;
181
183
  configPath: string;
@@ -654,6 +654,7 @@ export async function buildAppStatus(
654
654
  ): Promise<AppStatusResponse> {
655
655
  const result = await ctx.store.getStatus({
656
656
  embedModel: resolveModelUri(ctx.config, "embed"),
657
+ chunking: ctx.config.chunking ?? {},
657
658
  });
658
659
  if (!result.ok) {
659
660
  throw result.error;
@@ -733,6 +734,7 @@ export async function buildAppStatus(
733
734
  totalDocuments: status.activeDocuments,
734
735
  totalChunks: status.totalChunks,
735
736
  embeddingBacklog: status.embeddingBacklog,
737
+ chunking: status.chunking,
736
738
  lastUpdated: status.lastUpdatedAt,
737
739
  recentErrors: status.recentErrors,
738
740
  healthy: checks.every((check) => check.status === "ok"),
@@ -0,0 +1,31 @@
1
+ import type { ChunkingParams } from "../config/chunking";
2
+
3
+ export interface ChunkingPolicyToken {
4
+ params: ChunkingParams;
5
+ generation: number;
6
+ }
7
+
8
+ export interface PendingChunkingMirror {
9
+ mirrorHash: string;
10
+ sourcePath: string;
11
+ languageHint?: string;
12
+ }
13
+
14
+ export interface ChunkingStatus {
15
+ configured: ChunkingParams;
16
+ applied: ChunkingParams | null;
17
+ state: "empty" | "legacy-default" | "current" | "pending" | "mixed";
18
+ pendingDocuments: number;
19
+ pendingMirrors: number;
20
+ }
21
+
22
+ export class ChunkingPolicyConflictError extends Error {
23
+ readonly code = "CHUNKING_POLICY_CONFLICT";
24
+
25
+ constructor() {
26
+ super(
27
+ "CHUNKING_POLICY_CONFLICT: The index chunking policy changed. Reopen the client or restart the resident runtime with the intended configuration."
28
+ );
29
+ this.name = "ChunkingPolicyConflictError";
30
+ }
31
+ }
@@ -113,6 +113,11 @@ import type {
113
113
  import type { SqliteDbProvider } from "./types";
114
114
 
115
115
  import { buildUri, deriveDocid, stripUriIndex } from "../../app/constants";
116
+ import {
117
+ DEFAULT_CHUNKING_PARAMS,
118
+ resolveChunkingParams,
119
+ type ChunkingParams,
120
+ } from "../../config/chunking";
116
121
  import {
117
122
  type Collection,
118
123
  type Context,
@@ -140,6 +145,11 @@ import {
140
145
  parseActivationReceipt,
141
146
  serializeActivationReceipt,
142
147
  } from "../activation-receipts";
148
+ import {
149
+ ChunkingPolicyConflictError,
150
+ type ChunkingPolicyToken,
151
+ type PendingChunkingMirror,
152
+ } from "../chunking";
143
153
  import { getSchemaVersion, migrations, runMigrations } from "../migrations";
144
154
  import { err, ok } from "../types";
145
155
  import { getStoredEmbeddingFingerprint } from "../vector/freshness";
@@ -164,6 +174,15 @@ import {
164
174
  purgeDocumentChanges as purgeStoredDocumentChanges,
165
175
  snapshotDocumentChange,
166
176
  } from "./change-journal-store";
177
+ import {
178
+ assertChunkingTarget,
179
+ claimChunkingTarget,
180
+ getChunkingStatus,
181
+ markChunkingApplied,
182
+ pendingChunkingMirrors,
183
+ pruneChunkingMetadata,
184
+ readChunkingTarget,
185
+ } from "./chunking-policy";
167
186
  import {
168
187
  appendEgressAuditReceipt as appendStoredEgressAuditReceipt,
169
188
  appendEgressAuditReceiptWithRetention as appendStoredEgressAuditReceiptWithRetention,
@@ -535,6 +554,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
535
554
  private shutdownFenced = false;
536
555
  private shutdownDeadline?: number;
537
556
  private contextGeneration = 0;
557
+ private chunkingGeneration = 0;
538
558
 
539
559
  // ─────────────────────────────────────────────────────────────────────────
540
560
  // Lifecycle
@@ -603,6 +623,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
603
623
  return result;
604
624
  }
605
625
 
626
+ this.chunkingGeneration = readChunkingTarget(this.db).generation;
606
627
  this.contextGeneration += 1;
607
628
  return result;
608
629
  } catch (cause) {
@@ -2631,12 +2652,20 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2631
2652
 
2632
2653
  async upsertChunks(
2633
2654
  mirrorHash: string,
2634
- chunks: ChunkInput[]
2655
+ chunks: ChunkInput[],
2656
+ policy?: ChunkingPolicyToken
2635
2657
  ): Promise<StoreResult<void>> {
2636
2658
  try {
2637
2659
  const db = this.ensureOpen();
2638
2660
 
2639
2661
  const transaction = db.transaction(() => {
2662
+ assertChunkingTarget(
2663
+ db,
2664
+ policy ?? {
2665
+ params: DEFAULT_CHUNKING_PARAMS,
2666
+ generation: this.chunkingGeneration,
2667
+ }
2668
+ );
2640
2669
  // Retain stable rows: DELETE cascades erase valid legacy vectors even
2641
2670
  // when duplicate ingestion produces exactly the same embedding input.
2642
2671
  const nextBySequence = new Map(
@@ -2693,13 +2722,83 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2693
2722
  return ok(undefined);
2694
2723
  } catch (cause) {
2695
2724
  return err(
2696
- "QUERY_FAILED",
2725
+ cause instanceof ChunkingPolicyConflictError
2726
+ ? cause.code
2727
+ : "QUERY_FAILED",
2697
2728
  cause instanceof Error ? cause.message : "Failed to upsert chunks",
2698
2729
  cause
2699
2730
  );
2700
2731
  }
2701
2732
  }
2702
2733
 
2734
+ async claimChunkingPolicy(
2735
+ input: ChunkingParams
2736
+ ): Promise<StoreResult<ChunkingPolicyToken>> {
2737
+ const params = resolveChunkingParams(input);
2738
+ const result = await this.withTransaction(async () =>
2739
+ claimChunkingTarget(this.ensureOpen(), this.chunkingGeneration, params)
2740
+ );
2741
+ if (result.ok) this.chunkingGeneration = result.value.generation;
2742
+ else if (result.error.cause instanceof ChunkingPolicyConflictError) {
2743
+ return err(
2744
+ "CHUNKING_POLICY_CONFLICT",
2745
+ result.error.message,
2746
+ result.error.cause
2747
+ );
2748
+ }
2749
+ return result;
2750
+ }
2751
+
2752
+ async listPendingChunkingMirrors(
2753
+ policy: ChunkingPolicyToken,
2754
+ afterHash = ""
2755
+ ): Promise<StoreResult<PendingChunkingMirror[]>> {
2756
+ try {
2757
+ return ok(pendingChunkingMirrors(this.ensureOpen(), policy, afterHash));
2758
+ } catch (cause) {
2759
+ return err(
2760
+ cause instanceof ChunkingPolicyConflictError
2761
+ ? cause.code
2762
+ : "QUERY_FAILED",
2763
+ cause instanceof Error
2764
+ ? cause.message
2765
+ : "Failed to list pending chunk layouts",
2766
+ cause
2767
+ );
2768
+ }
2769
+ }
2770
+
2771
+ async applyChunkLayout(
2772
+ mirrorHash: string,
2773
+ chunks: ChunkInput[],
2774
+ policy: ChunkingPolicyToken,
2775
+ sourcePath: string,
2776
+ languageHint?: string
2777
+ ): Promise<StoreResult<void>> {
2778
+ const result = await this.withTransaction(async () => {
2779
+ const db = this.ensureOpen();
2780
+ assertChunkingTarget(db, policy);
2781
+ const applied = await this.upsertChunks(mirrorHash, chunks, policy);
2782
+ if (!applied.ok)
2783
+ throw applied.error.cause ?? new Error(applied.error.message);
2784
+ const indexed = await this.rebuildFtsForHash(mirrorHash);
2785
+ if (!indexed.ok)
2786
+ throw indexed.error.cause ?? new Error(indexed.error.message);
2787
+ markChunkingApplied(db, mirrorHash, policy, sourcePath, languageHint);
2788
+ });
2789
+ if (
2790
+ !result.ok &&
2791
+ result.error.cause instanceof ChunkingPolicyConflictError
2792
+ ) {
2793
+ return err(
2794
+ "CHUNKING_POLICY_CONFLICT",
2795
+ result.error.message,
2796
+ result.error.cause
2797
+ );
2798
+ }
2799
+ return result;
2800
+ }
2801
+
2703
2802
  async getChunks(mirrorHash: string): Promise<StoreResult<ChunkRow[]>> {
2704
2803
  try {
2705
2804
  const db = this.ensureOpen();
@@ -5690,6 +5789,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5690
5789
  async getStatus(options?: {
5691
5790
  embedModel?: string;
5692
5791
  embedFingerprint?: string;
5792
+ chunking?: Partial<ChunkingParams>;
5693
5793
  }): Promise<StoreResult<IndexStatus>> {
5694
5794
  try {
5695
5795
  const db = this.ensureOpen();
@@ -5884,6 +5984,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5884
5984
  activeDocuments: totalsRow?.active ?? 0,
5885
5985
  totalChunks: chunkCount,
5886
5986
  embeddingBacklog: variantStatus?.backlog ?? backlogRow?.count ?? 0,
5987
+ chunking: getChunkingStatus(db, options?.chunking),
5887
5988
  recentErrors,
5888
5989
  lastUpdatedAt: lastUpdatedRow?.last_updated ?? null,
5889
5990
  healthy,
@@ -5968,6 +6069,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5968
6069
  )
5969
6070
  `);
5970
6071
  orphanedContent = contentResult.changes;
6072
+ pruneChunkingMetadata(db);
5971
6073
 
5972
6074
  // Delete chunks for deleted content
5973
6075
  const chunksResult = db.run(`
@@ -0,0 +1,212 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ import type {
4
+ ChunkingPolicyToken,
5
+ ChunkingStatus,
6
+ PendingChunkingMirror,
7
+ } from "../chunking";
8
+
9
+ import {
10
+ ChunkingConfigSchema,
11
+ chunkingPolicyKey,
12
+ DEFAULT_CHUNKING_PARAMS,
13
+ DEFAULT_CHUNKING_POLICY_KEY,
14
+ resolveChunkingParams,
15
+ type ChunkingParams,
16
+ } from "../../config/chunking";
17
+ import { ChunkingPolicyConflictError } from "../chunking";
18
+
19
+ const TARGET_KEY = "chunking_policy_v1";
20
+ const LAYOUT_PREFIX = "chunking_mirror_v1:";
21
+
22
+ export function readChunkingTarget(db: Database): ChunkingPolicyToken {
23
+ const row = db
24
+ .query<{ value: string }, [string]>(
25
+ "SELECT value FROM schema_meta WHERE key = ?"
26
+ )
27
+ .get(TARGET_KEY);
28
+ if (!row) return { params: { ...DEFAULT_CHUNKING_PARAMS }, generation: 0 };
29
+ const parsed: unknown = JSON.parse(row.value);
30
+ if (
31
+ !parsed ||
32
+ typeof parsed !== "object" ||
33
+ !("generation" in parsed) ||
34
+ !Number.isSafeInteger(parsed.generation) ||
35
+ typeof parsed.generation !== "number" ||
36
+ parsed.generation < 1 ||
37
+ !("params" in parsed)
38
+ ) {
39
+ throw new Error("Invalid stored chunking policy");
40
+ }
41
+ return {
42
+ params: ChunkingConfigSchema.parse(parsed.params),
43
+ generation: parsed.generation,
44
+ };
45
+ }
46
+
47
+ export function assertChunkingTarget(
48
+ db: Database,
49
+ token: ChunkingPolicyToken
50
+ ): void {
51
+ const current = readChunkingTarget(db);
52
+ if (
53
+ current.generation !== token.generation ||
54
+ chunkingPolicyKey(current.params) !== chunkingPolicyKey(token.params)
55
+ ) {
56
+ throw new ChunkingPolicyConflictError();
57
+ }
58
+ }
59
+
60
+ /** Called while the adapter holds its transaction writer. */
61
+ export function claimChunkingTarget(
62
+ db: Database,
63
+ observedGeneration: number,
64
+ params: ChunkingParams
65
+ ): ChunkingPolicyToken {
66
+ const current = readChunkingTarget(db);
67
+ if (current.generation !== observedGeneration) {
68
+ throw new ChunkingPolicyConflictError();
69
+ }
70
+ if (chunkingPolicyKey(current.params) === chunkingPolicyKey(params)) {
71
+ return current;
72
+ }
73
+ const generation = current.generation + 1;
74
+ if (!Number.isSafeInteger(generation)) {
75
+ throw new Error("Chunking policy generation exhausted");
76
+ }
77
+ const target = { params, generation };
78
+ db.run(
79
+ "INSERT INTO schema_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
80
+ [TARGET_KEY, JSON.stringify(target)]
81
+ );
82
+ return target;
83
+ }
84
+
85
+ /** Keyset paging bounds memory even for a large index. */
86
+ export function pendingChunkingMirrors(
87
+ db: Database,
88
+ token: ChunkingPolicyToken,
89
+ afterHash: string
90
+ ): PendingChunkingMirror[] {
91
+ assertChunkingTarget(db, token);
92
+ // No policy has ever changed, so every legacy/default layout is current.
93
+ if (token.generation === 0) return [];
94
+ return db
95
+ .query<
96
+ {
97
+ mirror_hash: string;
98
+ source_path: string;
99
+ language_hint: string | null;
100
+ },
101
+ [string, string, string, string]
102
+ >(
103
+ `SELECT c.mirror_hash,
104
+ COALESCE(json_extract(m.value, '$.sourcePath'), d.rel_path) AS source_path,
105
+ CASE WHEN m.value IS NOT NULL
106
+ THEN json_extract(m.value, '$.languageHint') ELSE d.language_hint END AS language_hint
107
+ FROM content c JOIN documents d ON d.id = (
108
+ SELECT id FROM documents
109
+ WHERE mirror_hash = c.mirror_hash AND active = 1
110
+ ORDER BY collection, rel_path, id LIMIT 1
111
+ )
112
+ LEFT JOIN schema_meta m ON m.key = ? || c.mirror_hash
113
+ WHERE c.mirror_hash > ?
114
+ AND COALESCE(json_extract(m.value, '$.params'), ?) <> ?
115
+ ORDER BY c.mirror_hash LIMIT 64`
116
+ )
117
+ .all(
118
+ LAYOUT_PREFIX,
119
+ afterHash,
120
+ DEFAULT_CHUNKING_POLICY_KEY,
121
+ chunkingPolicyKey(token.params)
122
+ )
123
+ .map((row) => ({
124
+ mirrorHash: row.mirror_hash,
125
+ sourcePath: row.source_path,
126
+ languageHint: row.language_hint ?? undefined,
127
+ }));
128
+ }
129
+
130
+ export function markChunkingApplied(
131
+ db: Database,
132
+ mirrorHash: string,
133
+ token: ChunkingPolicyToken,
134
+ sourcePath: string,
135
+ languageHint?: string
136
+ ): void {
137
+ const updated = db.run(
138
+ `INSERT INTO schema_meta (key, value)
139
+ SELECT ?, ? WHERE EXISTS (SELECT 1 FROM content WHERE mirror_hash = ?)
140
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
141
+ [
142
+ `${LAYOUT_PREFIX}${mirrorHash}`,
143
+ JSON.stringify({ params: token.params, sourcePath, languageHint }),
144
+ mirrorHash,
145
+ ]
146
+ );
147
+ if (updated.changes !== 1)
148
+ throw new Error("Cannot mark a missing cached mirror applied");
149
+ }
150
+
151
+ export function getChunkingStatus(
152
+ db: Database,
153
+ configured?: Partial<ChunkingParams>
154
+ ): ChunkingStatus {
155
+ const params = resolveChunkingParams(
156
+ configured ?? readChunkingTarget(db).params
157
+ );
158
+ const key = chunkingPolicyKey(params);
159
+ const groups = db
160
+ .query<
161
+ { policy: string | null; documents: number; mirrors: number },
162
+ [string]
163
+ >(
164
+ `SELECT json_extract(m.value, '$.params') AS policy, COUNT(*) AS documents,
165
+ COUNT(DISTINCT c.mirror_hash) AS mirrors
166
+ FROM content c JOIN documents d ON d.mirror_hash = c.mirror_hash
167
+ LEFT JOIN schema_meta m ON m.key = ? || c.mirror_hash
168
+ WHERE d.active = 1 GROUP BY policy`
169
+ )
170
+ .all(LAYOUT_PREFIX);
171
+ const appliedPolicies = new Map<string, ChunkingParams>();
172
+ let pendingDocuments = 0;
173
+ let pendingMirrors = 0;
174
+ for (const group of groups) {
175
+ const applied = group.policy
176
+ ? ChunkingConfigSchema.parse(JSON.parse(group.policy))
177
+ : { ...DEFAULT_CHUNKING_PARAMS };
178
+ const appliedKey = chunkingPolicyKey(applied);
179
+ appliedPolicies.set(appliedKey, applied);
180
+ if (appliedKey !== key) {
181
+ pendingDocuments += group.documents;
182
+ pendingMirrors += group.mirrors;
183
+ }
184
+ }
185
+ let state: ChunkingStatus["state"] = "current";
186
+ if (groups.length === 0) state = "empty";
187
+ else if (appliedPolicies.size > 1) state = "mixed";
188
+ else if (pendingMirrors > 0) state = "pending";
189
+ else if (groups.every((group) => group.policy === null))
190
+ state = "legacy-default";
191
+
192
+ return {
193
+ configured: params,
194
+ applied:
195
+ appliedPolicies.size === 1
196
+ ? (appliedPolicies.values().next().value ?? null)
197
+ : null,
198
+ state,
199
+ pendingDocuments,
200
+ pendingMirrors,
201
+ };
202
+ }
203
+
204
+ export function pruneChunkingMetadata(db: Database): void {
205
+ db.run(
206
+ `DELETE FROM schema_meta WHERE key GLOB ?
207
+ AND NOT EXISTS (
208
+ SELECT 1 FROM content WHERE mirror_hash = substr(schema_meta.key, ?)
209
+ )`,
210
+ [`${LAYOUT_PREFIX}*`, LAYOUT_PREFIX.length + 1]
211
+ );
212
+ }
@@ -5,6 +5,7 @@
5
5
  * @module src/store/types
6
6
  */
7
7
 
8
+ import type { ChunkingParams } from "../config/chunking";
8
9
  import type {
9
10
  Collection,
10
11
  Context,
@@ -19,6 +20,11 @@ import type {
19
20
  FileRefactorRecoveryReceipt,
20
21
  FileRefactorRecoveryReceiptDraft,
21
22
  } from "../core/file-refactor-journal";
23
+ import type {
24
+ ChunkingPolicyToken,
25
+ ChunkingStatus,
26
+ PendingChunkingMirror,
27
+ } from "./chunking";
22
28
 
23
29
  // ─────────────────────────────────────────────────────────────────────────────
24
30
  // Error Types
@@ -42,6 +48,7 @@ export type StoreErrorCode =
42
48
  | "QUERY_FAILED"
43
49
  | "TRANSACTION_FAILED"
44
50
  | "INVALID_INPUT"
51
+ | "CHUNKING_POLICY_CONFLICT"
45
52
  | "IO_ERROR"
46
53
  | "INTERNAL"
47
54
  | "EGRESS_DENIED"
@@ -806,6 +813,8 @@ export interface IndexStatus {
806
813
  totalChunks: number;
807
814
  /** Chunks without embeddings */
808
815
  embeddingBacklog: number;
816
+ /** Configuration and applied cached layouts; separate from source freshness. */
817
+ chunking?: ChunkingStatus;
809
818
  /** Recent ingest errors (last 24h) */
810
819
  recentErrors: number;
811
820
  /** Last successful update timestamp (ISO 8601) */
@@ -2036,7 +2045,28 @@ export interface StorePort {
2036
2045
  */
2037
2046
  upsertChunks(
2038
2047
  mirrorHash: string,
2039
- chunks: ChunkInput[]
2048
+ chunks: ChunkInput[],
2049
+ policy?: ChunkingPolicyToken
2050
+ ): Promise<StoreResult<void>>;
2051
+
2052
+ /** Claim an index-wide policy against the generation observed at open. */
2053
+ claimChunkingPolicy?(
2054
+ params: ChunkingParams
2055
+ ): Promise<StoreResult<ChunkingPolicyToken>>;
2056
+
2057
+ /** Page cached mirrors whose applied policy differs from the claimed target. */
2058
+ listPendingChunkingMirrors?(
2059
+ policy: ChunkingPolicyToken,
2060
+ afterHash?: string
2061
+ ): Promise<StoreResult<PendingChunkingMirror[]>>;
2062
+
2063
+ /** Atomically replace a layout, refresh FTS and mark its actual policy. */
2064
+ applyChunkLayout?(
2065
+ mirrorHash: string,
2066
+ chunks: ChunkInput[],
2067
+ policy: ChunkingPolicyToken,
2068
+ sourcePath: string,
2069
+ languageHint?: string
2040
2070
  ): Promise<StoreResult<void>>;
2041
2071
 
2042
2072
  /**
@@ -2288,6 +2318,7 @@ export interface StorePort {
2288
2318
  getStatus(options?: {
2289
2319
  embedModel?: string;
2290
2320
  embedFingerprint?: string;
2321
+ chunking?: Partial<ChunkingParams>;
2291
2322
  }): Promise<StoreResult<IndexStatus>>;
2292
2323
 
2293
2324
  // ─────────────────────────────────────────────────────────────────────────
@@ -1 +0,0 @@
1
- cf4c351a255144b6f65dd25b71096effd05c0ea93a0691fc83ee49a3166a37c2 gno-browser-clipper-v2.2.1.zip