@semiont/make-meaning 0.5.24 → 0.5.26

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/dist/index.js CHANGED
@@ -1,14 +1,13 @@
1
1
  import { STALL_THRESHOLD_MS, FsJobQueue } from '@semiont/jobs';
2
2
  import { FilesystemViewStorage, resolveStorageUri, EventQuery, createEventStore } from '@semiont/event-sourcing';
3
- import { getResourceEntityTypes, getResourceId, getTargetSource, resourceId, getPrimaryRepresentation, decodeRepresentation, getBodySource, getTargetSelector, deriveViews, getTextPositionSelector, annotationId, errField, userId, generateUuid, getExactText, busRequest, cloneToken, baseMediaType, isSupportedMediaType, capabilitiesOf, didToAgent, assembleAnnotation, jobId, entityType, baseUrl, busLog, BRIDGED_CHANNELS, burstBuffer, textExtractionOf, getPrimaryMediaType, extensionForMediaType, applyBodyOperations, softwareToAgent } from '@semiont/core';
3
+ import { getResourceEntityTypes, getResourceId, getTargetSource, resourceId, getPrimaryRepresentation, decodeRepresentation, getBodySource, getTargetSelector, deriveViews, getTextPositionSelector, annotationId, errField, userId, generateUuid, getExactText, busRequest, cloneToken, baseMediaType, isSupportedMediaType, capabilitiesOf, didToAgent, assembleAnnotation, jobId, entityType, baseUrl, busLog, BRIDGED_CHANNELS, burstBuffer, textExtractionOf, chunkText, getPrimaryMediaType, extensionForMediaType, applyBodyOperations, softwareToAgent } from '@semiont/core';
4
4
  import { recordGatherDegrade, withActorSpan, recordBusEmit, withSpan, SpanKind, registerJobQueueProvider, registerVectorIndexSizeProvider } from '@semiont/observability';
5
5
  import { createInferenceClient } from '@semiont/inference';
6
- import { getGraphDatabase } from '@semiont/graph';
7
- import { WorkingTreeStore, deriveStorageUri, calculateChecksum } from '@semiont/content';
6
+ import { compareByRecencyThenId, getGraphDatabase } from '@semiont/graph';
7
+ import { WorkingTreeStore, createAnchoredTextStore, deriveStorageUri, anchoredTextStoreOverTransport, EXTRACTORS, calculateChecksum } from '@semiont/content';
8
8
  import { getEntityTypes, DEFAULT_ENTITY_TYPES } from '@semiont/ontology';
9
9
  import { promises } from 'fs';
10
10
  import * as path3 from 'path';
11
- import { chunkText } from '@semiont/vectors';
12
11
  import { createGzip, createGunzip } from 'zlib';
13
12
  import { pipeline, Readable } from 'stream';
14
13
  import { promisify } from 'util';
@@ -9889,6 +9888,10 @@ async function createKnowledgeBase(eventStore, project, graphDb, eventBus, logge
9889
9888
  project,
9890
9889
  logger.child({ component: "working-tree-store" })
9891
9890
  );
9891
+ const anchoredText = createAnchoredTextStore(
9892
+ project.anchoredTextDir,
9893
+ logger.child({ component: "anchored-text-store" })
9894
+ );
9892
9895
  const weaveProgress = createWeaveProgress(eventBus);
9893
9896
  const smeltProgress = createSmeltProgress(eventBus);
9894
9897
  if (!options?.skipRebuild) {
@@ -9898,6 +9901,7 @@ async function createKnowledgeBase(eventStore, project, graphDb, eventBus, logge
9898
9901
  eventStore,
9899
9902
  views,
9900
9903
  content,
9904
+ anchoredText,
9901
9905
  graph: graphDb,
9902
9906
  weaveProgress,
9903
9907
  smeltProgress,
@@ -9933,13 +9937,6 @@ var GraphContext = class {
9933
9937
  static async getResourceConnections(resourceId10, kb) {
9934
9938
  return kb.graph.getResourceConnections(resourceId10);
9935
9939
  }
9936
- /**
9937
- * Search resources by name (cross-resource query)
9938
- * Requires full-text search - must use graph database
9939
- */
9940
- static async searchResources(query, kb, limit) {
9941
- return kb.graph.searchResources(query, limit);
9942
- }
9943
9940
  /**
9944
9941
  * Build the unified knowledge graph for a resource's neighborhood:
9945
9942
  * resources AND annotations as typed nodes, typed/directional edges.
@@ -10060,7 +10057,7 @@ Format as a simple list, one suggestion per line.`;
10060
10057
  }
10061
10058
  return response.split("\n").map((line) => line.replace(/^[-*•]\s*/, "").trim()).filter((line) => line.length > 0).slice(0, 3);
10062
10059
  }
10063
- var ResourceContext = class _ResourceContext {
10060
+ var ResourceContext = class {
10064
10061
  /**
10065
10062
  * Get resource metadata from view storage
10066
10063
  */
@@ -10072,38 +10069,34 @@ var ResourceContext = class _ResourceContext {
10072
10069
  return view.resource;
10073
10070
  }
10074
10071
  /**
10075
- * List resources, optionally filtered.
10072
+ * List resources, optionally filtered, as one page plus the size of the whole
10073
+ * match set. Every filter is applied before pagination on both paths — a
10074
+ * filter applied afterwards narrows the page rather than the match set, which
10075
+ * is how a search scoped to an entity type can come back empty while hundreds
10076
+ * of resources match.
10076
10077
  *
10077
- * When `search` is set, delegates to `kb.graph.searchResources`, which runs
10078
- * the name match in the graph engine instead of scanning every view in JS.
10079
- * The graph result is then narrowed by `archived` if requested.
10078
+ * When `search` is set, the entire query filtering, ordering and
10079
+ * pagination runs inside the graph engine.
10080
10080
  *
10081
- * When `search` is unset, falls back to scanning all materialized views.
10082
- * (TODO: also push the listing path through the graph for large KBs.)
10081
+ * When `search` is unset, the materialized views answer instead. They are the
10082
+ * barrier-stamped projection, so an unsearched listing is read-your-writes
10083
+ * where the graph is only eventually consistent.
10083
10084
  */
10084
10085
  static async listResources(filters, kb) {
10085
- if (filters?.search) {
10086
- const matches = await kb.graph.searchResources(filters.search);
10087
- const filtered = filters.archived !== void 0 ? matches.filter((doc) => doc.archived === filters.archived) : matches;
10088
- return _ResourceContext.sortByDateDesc(filtered);
10086
+ const { search: rawSearch, archived, entityType: entityType2, offset = 0, limit = 50 } = filters ?? {};
10087
+ const search = rawSearch?.trim() || void 0;
10088
+ if (search) {
10089
+ return kb.graph.listResources({
10090
+ search,
10091
+ archived,
10092
+ entityTypes: entityType2 ? [entityType2] : void 0,
10093
+ offset,
10094
+ limit
10095
+ });
10089
10096
  }
10090
10097
  const allViews = await kb.views.getAll();
10091
- const resources = [];
10092
- for (const view of allViews) {
10093
- const doc = view.resource;
10094
- if (filters?.archived !== void 0 && doc.archived !== filters.archived) {
10095
- continue;
10096
- }
10097
- resources.push(doc);
10098
- }
10099
- return _ResourceContext.sortByDateDesc(resources);
10100
- }
10101
- static sortByDateDesc(resources) {
10102
- return [...resources].sort((a, b) => {
10103
- const aTime = a.dateCreated ? new Date(a.dateCreated).getTime() : 0;
10104
- const bTime = b.dateCreated ? new Date(b.dateCreated).getTime() : 0;
10105
- return bTime - aTime;
10106
- });
10098
+ const matches = allViews.map((view) => view.resource).filter((doc) => archived === void 0 || doc.archived === archived).filter((doc) => !entityType2 || getResourceEntityTypes(doc).includes(entityType2)).sort(compareByRecencyThenId);
10099
+ return { resources: matches.slice(offset, offset + limit), total: matches.length };
10107
10100
  }
10108
10101
  /**
10109
10102
  * Add content previews to resources (for search results)
@@ -10300,7 +10293,8 @@ Summary:`;
10300
10293
  resourceId: r.resourceId,
10301
10294
  annotationId: r.annotationId,
10302
10295
  score: r.score,
10303
- entityTypes: r.entityTypes
10296
+ entityTypes: r.entityTypes,
10297
+ ...r.machineRead ? { machineRead: true } : {}
10304
10298
  }))
10305
10299
  };
10306
10300
  logger?.debug("Semantic context found", { matches: results.length });
@@ -10642,7 +10636,8 @@ var LLMContext = class {
10642
10636
  resourceId: m.resourceId,
10643
10637
  ...m.annotationId ? { annotationId: m.annotationId } : {},
10644
10638
  score: m.score,
10645
- ...m.entityTypes ? { entityTypes: m.entityTypes } : {}
10639
+ ...m.entityTypes ? { entityTypes: m.entityTypes } : {},
10640
+ ...m.machineRead ? { machineRead: true } : {}
10646
10641
  })),
10647
10642
  ...excludeEntityTypes.length ? { excludedEntityTypes: excludeEntityTypes } : {}
10648
10643
  };
@@ -10879,7 +10874,7 @@ var Matcher = class {
10879
10874
  * Context-driven search: multi-source retrieval + composite scoring
10880
10875
  *
10881
10876
  * Retrieval sources:
10882
- * 1. Name match — graph.searchResources(searchTerm)
10877
+ * 1. Name match — graph.listResources({ search: searchTerm })
10883
10878
  * 2. Entity type match — graph.listResources({ entityTypes })
10884
10879
  * 3. Graph neighborhood — connections from GatheredContext
10885
10880
  *
@@ -10895,7 +10890,7 @@ var Matcher = class {
10895
10890
  const views = deriveViews(context.graph, mainResourceId);
10896
10891
  const connections = views.connections;
10897
10892
  const [nameMatches, entityTypeMatches, semanticMatches] = await Promise.all([
10898
- this.kb.graph.searchResources(searchTerm),
10893
+ this.kb.graph.listResources({ search: searchTerm, limit: 20 }).then((r) => r.resources),
10899
10894
  annotationEntityTypes.length > 0 ? this.kb.graph.listResources({ entityTypes: annotationEntityTypes, limit: 50 }).then((r) => r.resources) : Promise.resolve([]),
10900
10895
  // 4. Semantic match — vector similarity search (if vectors configured)
10901
10896
  this.searchVectors(searchTerm)
@@ -11632,6 +11627,21 @@ var Stower = class {
11632
11627
  // src/browser.ts
11633
11628
  var import_rxjs4 = __toESM(require_cjs());
11634
11629
  var import_operators4 = __toESM(require_operators());
11630
+ var ANCHORED_TEXT_SETTLE_TIMEOUT_MS = 15e3;
11631
+ async function readAnchoredText(kb, resourceId10) {
11632
+ const view = await kb.views.get(resourceId(resourceId10));
11633
+ const checksum = getPrimaryRepresentation(view?.resource)?.checksum;
11634
+ if (!checksum) return null;
11635
+ const hit = await kb.anchoredText.read(checksum);
11636
+ if (hit) return hit;
11637
+ try {
11638
+ const outcome = await kb.smeltProgress.whenSettled(resourceId10, checksum, ANCHORED_TEXT_SETTLE_TIMEOUT_MS);
11639
+ return outcome === "indexed" ? kb.anchoredText.read(checksum) : null;
11640
+ } catch (error) {
11641
+ if (error instanceof SmeltProgressTimeout) return null;
11642
+ throw error;
11643
+ }
11644
+ }
11635
11645
  async function readTagSchemasProjection(project) {
11636
11646
  const tagSchemasPath = path3.join(
11637
11647
  project.stateDir,
@@ -11746,6 +11756,7 @@ var Browser = class {
11746
11756
  );
11747
11757
  this.subscriptions.push(
11748
11758
  pipe("browse:resource-requested", (e) => this.handleBrowseResource(e)).subscribe({ error: errorHandler }),
11759
+ pipe("browse:anchored-text-requested", (e) => this.handleAnchoredText(e)).subscribe({ error: errorHandler }),
11749
11760
  pipe("browse:resources-requested", (e) => this.handleBrowseResources(e)).subscribe({ error: errorHandler }),
11750
11761
  pipe("browse:annotations-requested", (e) => this.handleBrowseAnnotations(e)).subscribe({ error: errorHandler }),
11751
11762
  pipe("browse:annotation-requested", (e) => this.handleBrowseAnnotation(e)).subscribe({ error: errorHandler }),
@@ -11761,6 +11772,34 @@ var Browser = class {
11761
11772
  // ========================================================================
11762
11773
  // KB read handlers
11763
11774
  // ========================================================================
11775
+ /**
11776
+ * Serve a resource's derived coordinate map (ANCHORED-TEXT-CACHE Lane 5).
11777
+ *
11778
+ * Read-your-writes on the same barrier `llm-context` uses for vectors: a
11779
+ * caller may arrive before the Smelter has finished the resource it just
11780
+ * uploaded, so a miss waits for that content generation to settle rather than
11781
+ * reporting "no map" for a document that is merely still being read.
11782
+ *
11783
+ * **This path never invokes the engine.** The Smelter is the sole producer.
11784
+ * A miss that survives the barrier answers `null`, and the caller degrades —
11785
+ * for a PDF annotation that means geometry with no quoted text, which is what
11786
+ * shipped before any of this existed. OCR in a request path is precisely what
11787
+ * this design exists to avoid.
11788
+ */
11789
+ async handleAnchoredText(event) {
11790
+ try {
11791
+ this.eventBus.get("browse:anchored-text-result").next({
11792
+ correlationId: event.correlationId,
11793
+ response: await readAnchoredText(this.kb, event.resourceId)
11794
+ });
11795
+ } catch (error) {
11796
+ this.logger.error("Browse anchored text failed", { resourceId: event.resourceId, error: errField(error) });
11797
+ this.eventBus.get("browse:anchored-text-failed").next({
11798
+ correlationId: event.correlationId,
11799
+ message: error instanceof Error ? error.message : String(error)
11800
+ });
11801
+ }
11802
+ }
11764
11803
  async handleBrowseResource(event) {
11765
11804
  try {
11766
11805
  const response = await assembleResourceGraph(this.kb, resourceId(event.resourceId));
@@ -11785,22 +11824,21 @@ var Browser = class {
11785
11824
  }
11786
11825
  async handleBrowseResources(event) {
11787
11826
  try {
11788
- let filteredDocs = await ResourceContext.listResources({
11789
- search: event.search,
11790
- archived: event.archived
11791
- }, this.kb);
11792
- if (event.entityType) {
11793
- filteredDocs = filteredDocs.filter((doc) => getResourceEntityTypes(doc).includes(event.entityType));
11794
- }
11795
11827
  const offset = event.offset ?? 0;
11796
11828
  const limit = event.limit ?? 50;
11797
- const paginatedDocs = filteredDocs.slice(offset, offset + limit);
11798
- const formattedDocs = event.search ? await ResourceContext.addContentPreviews(paginatedDocs, this.kb) : paginatedDocs;
11829
+ const { resources, total } = await ResourceContext.listResources({
11830
+ search: event.search,
11831
+ archived: event.archived,
11832
+ entityType: event.entityType,
11833
+ offset,
11834
+ limit
11835
+ }, this.kb);
11836
+ const formattedDocs = event.search ? await ResourceContext.addContentPreviews(resources, this.kb) : resources;
11799
11837
  this.eventBus.get("browse:resources-result").next({
11800
11838
  correlationId: event.correlationId,
11801
11839
  response: {
11802
11840
  resources: formattedDocs,
11803
- total: filteredDocs.length,
11841
+ total,
11804
11842
  offset,
11805
11843
  limit
11806
11844
  }
@@ -12916,6 +12954,7 @@ async function startMakeMeaning(project, config, eventBus, logger, options) {
12916
12954
  return {
12917
12955
  knowledgeSystem,
12918
12956
  jobQueue,
12957
+ project,
12919
12958
  stop: async () => {
12920
12959
  logger.info("Stopping Make-Meaning service");
12921
12960
  jobStatusSubscription.unsubscribe();
@@ -13029,6 +13068,40 @@ var LocalContentTransport = class {
13029
13068
  "LocalContentTransport does not support putBinary() \u2014 create resources via bus emits (mark/yield namespaces) in local mode"
13030
13069
  );
13031
13070
  }
13071
+ /**
13072
+ * Store a derived coordinate map under the content checksum the producer
13073
+ * read (PERSIST-ANCHORS decision A — see the interface doc for why the
13074
+ * producer supplies the key). In local mode this is the same store the
13075
+ * HTTP route writes to — one storage authority, reached the same way from
13076
+ * every process (ANCHORED-TEXT-CACHE Lane 5).
13077
+ */
13078
+ async putAnchoredText(checksum, outcome, _options) {
13079
+ busLog("PUT", "anchored-text", { checksum });
13080
+ await this.ks.kb.anchoredText.write(checksum, outcome);
13081
+ }
13082
+ /** The stored outcome, or null when nothing has derived one — the common case. */
13083
+ async getAnchoredText(resourceId10, _options) {
13084
+ busLog("GET", "anchored-text", { resourceId: resourceId10 });
13085
+ return readAnchoredText(this.ks.kb, resourceId10);
13086
+ }
13087
+ /**
13088
+ * The cache-consult read (PERSIST-ANCHORS P2c), straight from the store —
13089
+ * checksum-addressed, so no view resolution and no settle barrier: the
13090
+ * caller holds the content identity already.
13091
+ */
13092
+ async getAnchoredTextByChecksum(checksum, _options) {
13093
+ busLog("GET", "anchored-text-by-checksum", { checksum });
13094
+ return this.ks.kb.anchoredText.read(checksum);
13095
+ }
13096
+ /**
13097
+ * The store's would-hit keys, straight from the store — planning data for
13098
+ * the reconcile diff (PERSIST-ANCHORS P0), so no settle barrier applies:
13099
+ * presence is being asked, not content at a moment.
13100
+ */
13101
+ async listAnchoredTextKeys(_options) {
13102
+ busLog("GET", "anchored-text-keys", {});
13103
+ return this.ks.kb.anchoredText.list();
13104
+ }
13032
13105
  async getBinary(resourceId10, _options) {
13033
13106
  busLog("GET", "content", { resourceId: resourceId10 });
13034
13107
  return withSpan(
@@ -13111,6 +13184,7 @@ function sameStringSet(a, b) {
13111
13184
  var WORK_ITEM_TYPES = /* @__PURE__ */ new Set([
13112
13185
  "smelt:embed",
13113
13186
  "smelt:restamp",
13187
+ "smelt:reanchor",
13114
13188
  "smelt:purge",
13115
13189
  "smelt:embed-annotation",
13116
13190
  "smelt:purge-annotation"
@@ -13119,8 +13193,9 @@ function isWorkItem(input) {
13119
13193
  return WORK_ITEM_TYPES.has(input.type);
13120
13194
  }
13121
13195
  var Smelter = class _Smelter {
13122
- constructor(events$, vectorStore, embeddingProvider, content, bus, chunkingConfig, timing, logger) {
13196
+ constructor(events$, rebuildAnchors$, vectorStore, embeddingProvider, content, bus, chunkingConfig, timing, logger) {
13123
13197
  this.events$ = events$;
13198
+ this.rebuildAnchors$ = rebuildAnchors$;
13124
13199
  this.vectorStore = vectorStore;
13125
13200
  this.embeddingProvider = embeddingProvider;
13126
13201
  this.content = content;
@@ -13128,8 +13203,10 @@ var Smelter = class _Smelter {
13128
13203
  this.chunkingConfig = chunkingConfig;
13129
13204
  this.timing = timing;
13130
13205
  this.logger = logger;
13206
+ this.anchoredStore = anchoredTextStoreOverTransport(content, logger.child({ component: "anchored-text-cache" }));
13131
13207
  }
13132
13208
  events$;
13209
+ rebuildAnchors$;
13133
13210
  vectorStore;
13134
13211
  embeddingProvider;
13135
13212
  content;
@@ -13142,11 +13219,21 @@ var Smelter = class _Smelter {
13142
13219
  static RECONCILE_WAVE = 8;
13143
13220
  eventSubject = new import_rxjs9.Subject();
13144
13221
  sourceSubscription = null;
13222
+ commandSubscription = null;
13145
13223
  pipelineSubscription = null;
13146
13224
  _eventsProcessed = 0;
13147
13225
  _reconcileState = { phase: "pending" };
13148
13226
  workDone = 0;
13227
+ workFailed = 0;
13149
13228
  workWaiter = null;
13229
+ /**
13230
+ * Serializes every planner drain (reconcile, anchored-text rebuilds):
13231
+ * there is one waiter slot, and the weave:rebuild rule — rebuilds never
13232
+ * interleave — applies to every unit here being a potential multi-second
13233
+ * OCR pass.
13234
+ */
13235
+ drainChain = Promise.resolve();
13236
+ anchoredStore;
13150
13237
  get eventsProcessed() {
13151
13238
  return this._eventsProcessed;
13152
13239
  }
@@ -13174,7 +13261,7 @@ var Smelter = class _Smelter {
13174
13261
  return (0, import_rxjs9.from)(
13175
13262
  withActorSpan("smelter", inputOrBatch.type, async () => {
13176
13263
  const ok = await this.safeProcessEvent(inputOrBatch);
13177
- if (isWorkItem(inputOrBatch)) this.noteWorkDone(1);
13264
+ if (isWorkItem(inputOrBatch)) this.noteWorkDone(1, ok ? 0 : 1);
13178
13265
  else if (ok) this._eventsProcessed++;
13179
13266
  })
13180
13267
  );
@@ -13188,18 +13275,26 @@ var Smelter = class _Smelter {
13188
13275
  this.logger.debug("Bus event received", { type: event.type, resourceId: event.resourceId });
13189
13276
  this.eventSubject.next(event);
13190
13277
  });
13278
+ this.commandSubscription = this.rebuildAnchors$.pipe(
13279
+ (0, import_operators7.concatMap)((command) => (0, import_rxjs9.from)(this.rebuildAnchors(command)))
13280
+ ).subscribe({
13281
+ error: (err) => this.logger.error("Smelter command pipeline error", { error: errField(err) })
13282
+ });
13191
13283
  this.logger.info("Smelter pipeline initialized");
13192
13284
  }
13193
13285
  stop() {
13194
13286
  this.sourceSubscription?.unsubscribe();
13195
13287
  this.sourceSubscription = null;
13288
+ this.commandSubscription?.unsubscribe();
13289
+ this.commandSubscription = null;
13196
13290
  this.pipelineSubscription?.unsubscribe();
13197
13291
  this.pipelineSubscription = null;
13198
13292
  this.eventSubject.complete();
13199
13293
  this.logger.info("Smelter stopped");
13200
13294
  }
13201
- noteWorkDone(count) {
13295
+ noteWorkDone(count, failed) {
13202
13296
  this.workDone += count;
13297
+ this.workFailed += failed;
13203
13298
  if (this.workWaiter && this.workDone >= this.workWaiter.target) {
13204
13299
  this.workWaiter.resolve();
13205
13300
  this.workWaiter = null;
@@ -13213,12 +13308,15 @@ var Smelter = class _Smelter {
13213
13308
  let wireProcessed = 0;
13214
13309
  for (const run of partitionByType(events)) {
13215
13310
  const workRun = isWorkItem(run[0]);
13311
+ let succeeded = 0;
13216
13312
  try {
13217
13313
  if (run.length === 1) {
13218
13314
  const ok = await this.safeProcessEvent(run[0]);
13315
+ if (ok) succeeded = 1;
13219
13316
  if (ok && !workRun) wireProcessed++;
13220
13317
  } else {
13221
13318
  const processed = await this.applyBatchByType(run);
13319
+ succeeded = processed;
13222
13320
  if (!workRun) wireProcessed += processed;
13223
13321
  }
13224
13322
  } catch (error) {
@@ -13228,7 +13326,7 @@ var Smelter = class _Smelter {
13228
13326
  error: errField(error)
13229
13327
  });
13230
13328
  } finally {
13231
- if (workRun) this.noteWorkDone(run.length);
13329
+ if (workRun) this.noteWorkDone(run.length, run.length - succeeded);
13232
13330
  }
13233
13331
  }
13234
13332
  return wireProcessed;
@@ -13300,6 +13398,9 @@ var Smelter = class _Smelter {
13300
13398
  case "smelt:restamp":
13301
13399
  await this.restampResource(event);
13302
13400
  break;
13401
+ case "smelt:reanchor":
13402
+ await this.reanchorResource(event);
13403
+ break;
13303
13404
  case "smelt:purge":
13304
13405
  await this.handleResourcePurge(event);
13305
13406
  break;
@@ -13326,6 +13427,45 @@ var Smelter = class _Smelter {
13326
13427
  await this.vectorStore.updateResourceEntityTypes(resourceId(rid), entityTypes);
13327
13428
  this.logger.info("Restamped resource entity types", { resourceId: rid, entityTypes });
13328
13429
  }
13430
+ /**
13431
+ * Re-derive a lost anchored-text artifact from the resource's current
13432
+ * bytes (PERSIST-ANCHORS P0, the third drift class). Extraction is the
13433
+ * cost here — the vectors are already correct, so this NEVER calls the
13434
+ * embedding provider, the vector store, or the settled signal: the index
13435
+ * decision was already made and announced at its checksum; only the map
13436
+ * is missing. Name the work for what it does (the S13 discipline).
13437
+ *
13438
+ * The publish is STRICT, unlike the embed path's best-effort side
13439
+ * publish: here the artifact IS the job, so a store failure must throw —
13440
+ * the pipeline logs and counts it, and the rebuild command's partial-
13441
+ * failure accounting depends on that throw.
13442
+ */
13443
+ async reanchorResource(event) {
13444
+ const rid = event.resourceId;
13445
+ if (!rid) return;
13446
+ const { data, contentType } = await this.content.getBinary(resourceId(rid));
13447
+ const bytes = Buffer.from(data);
13448
+ const extractor = EXTRACTORS[textExtractionOf(contentType)];
13449
+ if (!extractor?.yieldsGeometry) {
13450
+ this.logger.info("Re-anchor found no geometry-capable extractor", { resourceId: rid, contentType });
13451
+ return;
13452
+ }
13453
+ const checksum = calculateChecksum(bytes);
13454
+ const extracted = await extractor.extract(bytes, contentType, {
13455
+ key: checksum,
13456
+ store: this.anchoredStore
13457
+ });
13458
+ if ("declined" in extracted || !extracted.items?.length) {
13459
+ this.logger.info("Re-anchor extraction yielded no geometry", {
13460
+ resourceId: rid,
13461
+ contentType,
13462
+ ..."declined" in extracted ? { declined: extracted.declined } : {}
13463
+ });
13464
+ return;
13465
+ }
13466
+ await this.content.putAnchoredText(checksum, { ...extracted, items: extracted.items });
13467
+ this.logger.info("Re-anchored resource", { resourceId: rid, checksum, items: extracted.items.length });
13468
+ }
13329
13469
  async handleResourcePurge(event) {
13330
13470
  const rid = event.resourceId;
13331
13471
  if (!rid) return;
@@ -13345,12 +13485,34 @@ var Smelter = class _Smelter {
13345
13485
  const { data, contentType } = await this.content.getBinary(resourceId(resourceId10));
13346
13486
  const bytes = Buffer.from(data);
13347
13487
  const checksum = calculateChecksum(bytes);
13348
- if (textExtractionOf(contentType) !== "decode") {
13349
- this.logger.debug("Skipping resource that does not decode as text", { resourceId: resourceId10, contentType });
13350
- return { kind: "skipped", checksum };
13488
+ const extractor = EXTRACTORS[textExtractionOf(contentType)];
13489
+ if (!extractor) {
13490
+ this.logger.debug("Skipping resource with no extractor for its media type", { resourceId: resourceId10, contentType });
13491
+ return { kind: "skipped", checksum, reason: "no-extractor" };
13492
+ }
13493
+ const extracted = await extractor.extract(bytes, contentType, {
13494
+ key: checksum,
13495
+ store: this.anchoredStore
13496
+ });
13497
+ if ("declined" in extracted) {
13498
+ this.logger.debug("Extractor declined", { resourceId: resourceId10, contentType, reason: extracted.declined });
13499
+ return { kind: "skipped", checksum, reason: extracted.declined };
13351
13500
  }
13352
- const text = decodeRepresentation(bytes, contentType);
13353
- return text.trim() ? { kind: "text", text, checksum } : { kind: "skipped", checksum };
13501
+ if (extracted.ocrConfidence && extracted.ocrConfidence.lowConfidenceWords > 0) {
13502
+ this.logger.info("OCR read words it was unsure of", {
13503
+ resourceId: resourceId10,
13504
+ contentType,
13505
+ ...extracted.ocrConfidence
13506
+ });
13507
+ }
13508
+ if (extracted.unreadPages?.length) {
13509
+ this.logger.info("Partial extraction coverage", {
13510
+ resourceId: resourceId10,
13511
+ contentType,
13512
+ unreadPages: extracted.unreadPages
13513
+ });
13514
+ }
13515
+ return extracted.text.trim() ? { kind: "text", text: extracted.text, checksum, machineRead: extracted.method === "ocr" } : { kind: "skipped", checksum, reason: "empty" };
13354
13516
  } catch (error) {
13355
13517
  this.logger.warn("Content unavailable for embedding", { resourceId: resourceId10, error: errField(error) });
13356
13518
  return { kind: "unavailable" };
@@ -13362,9 +13524,9 @@ var Smelter = class _Smelter {
13362
13524
  * fold. Best-effort — waiters degrade to their bounded timeout; a signal
13363
13525
  * failure must never fail the embed.
13364
13526
  */
13365
- async emitSettled(resourceId10, contentChecksum, outcome) {
13527
+ async emitSettled(resourceId10, contentChecksum, outcome, reason) {
13366
13528
  try {
13367
- await this.bus.emit("smelt:settled", { resourceId: resourceId10, contentChecksum, outcome });
13529
+ await this.bus.emit("smelt:settled", { resourceId: resourceId10, contentChecksum, outcome, ...reason ? { reason } : {} });
13368
13530
  } catch (error) {
13369
13531
  this.logger.warn("Failed to emit smelt:settled", { resourceId: resourceId10, outcome, error: errField(error) });
13370
13532
  }
@@ -13392,12 +13554,14 @@ var Smelter = class _Smelter {
13392
13554
  const fetched = await this.fetchEmbeddableText(rid);
13393
13555
  if (fetched.kind === "unavailable") return;
13394
13556
  if (fetched.kind === "skipped") {
13395
- await this.emitSettled(rid, fetched.checksum, "skipped");
13557
+ await this.vectorStore.deleteResourceVectors(resourceId(rid));
13558
+ await this.emitSettled(rid, fetched.checksum, "skipped", fetched.reason);
13396
13559
  return;
13397
13560
  }
13398
13561
  const chunks = chunkText(fetched.text, this.chunkingConfig);
13399
13562
  if (chunks.length === 0) {
13400
- await this.emitSettled(rid, fetched.checksum, "skipped");
13563
+ await this.vectorStore.deleteResourceVectors(resourceId(rid));
13564
+ await this.emitSettled(rid, fetched.checksum, "skipped", "empty");
13401
13565
  return;
13402
13566
  }
13403
13567
  const entityTypes = await this.resolveEntityTypes(rid);
@@ -13407,7 +13571,7 @@ var Smelter = class _Smelter {
13407
13571
  text: t,
13408
13572
  embedding: embeddings[i]
13409
13573
  }));
13410
- await this.vectorStore.upsertResourceVectors(resourceId(rid), embeddingChunks, fetched.checksum, entityTypes);
13574
+ await this.vectorStore.upsertResourceVectors(resourceId(rid), embeddingChunks, fetched.checksum, entityTypes, fetched.machineRead);
13411
13575
  await this.emitSettled(rid, fetched.checksum, "indexed");
13412
13576
  this.logger.info(logMessage, { resourceId: rid, chunks: chunks.length });
13413
13577
  }
@@ -13451,12 +13615,14 @@ var Smelter = class _Smelter {
13451
13615
  if (!exactText?.trim()) return;
13452
13616
  const aid = annotationId(annotation.id);
13453
13617
  const embedding = await this.embeddingProvider.embed(exactText);
13618
+ const stamp = await this.vectorStore.getResourceStamp(resourceId(rid));
13454
13619
  const payload = {
13455
13620
  annotationId: aid,
13456
13621
  resourceId: resourceId(rid),
13457
13622
  motivation: annotation.motivation ?? "",
13458
13623
  entityTypes: annotation.entityTypes ?? [],
13459
- exactText
13624
+ exactText,
13625
+ ...stamp?.machineRead ? { machineRead: true } : {}
13460
13626
  };
13461
13627
  await this.vectorStore.upsertAnnotationVector(aid, embedding, payload);
13462
13628
  this.logger.info("Indexed annotation", { annotationId: String(aid) });
@@ -13481,28 +13647,30 @@ var Smelter = class _Smelter {
13481
13647
  const fetched = await this.fetchEmbeddableText(rid);
13482
13648
  if (fetched.kind === "unavailable") continue;
13483
13649
  if (fetched.kind === "skipped") {
13484
- await this.emitSettled(rid, fetched.checksum, "skipped");
13650
+ await this.vectorStore.deleteResourceVectors(resourceId(rid));
13651
+ await this.emitSettled(rid, fetched.checksum, "skipped", fetched.reason);
13485
13652
  continue;
13486
13653
  }
13487
13654
  const chunks = chunkText(fetched.text, this.chunkingConfig);
13488
13655
  if (chunks.length === 0) {
13489
- await this.emitSettled(rid, fetched.checksum, "skipped");
13656
+ await this.vectorStore.deleteResourceVectors(resourceId(rid));
13657
+ await this.emitSettled(rid, fetched.checksum, "skipped", "empty");
13490
13658
  continue;
13491
13659
  }
13492
13660
  const entityTypes = await this.resolveEntityTypes(rid);
13493
- resourceData.push({ rid: resourceId(rid), chunks, checksum: fetched.checksum, entityTypes });
13661
+ resourceData.push({ rid: resourceId(rid), chunks, checksum: fetched.checksum, entityTypes, machineRead: fetched.machineRead });
13494
13662
  allChunks.push(...chunks);
13495
13663
  }
13496
13664
  if (allChunks.length === 0) return events.length;
13497
13665
  const allEmbeddings = await this.embeddingProvider.embedBatch(allChunks);
13498
13666
  let offset = 0;
13499
- for (const { rid, chunks, checksum, entityTypes } of resourceData) {
13667
+ for (const { rid, chunks, checksum, entityTypes, machineRead } of resourceData) {
13500
13668
  const embeddingChunks = chunks.map((t, i) => ({
13501
13669
  chunkIndex: i,
13502
13670
  text: t,
13503
13671
  embedding: allEmbeddings[offset + i]
13504
13672
  }));
13505
- await this.vectorStore.upsertResourceVectors(rid, embeddingChunks, checksum, entityTypes);
13673
+ await this.vectorStore.upsertResourceVectors(rid, embeddingChunks, checksum, entityTypes, machineRead);
13506
13674
  await this.emitSettled(String(rid), checksum, "indexed");
13507
13675
  this.logger.info("Batch-indexed resource", { resourceId: String(rid), chunks: chunks.length });
13508
13676
  offset += chunks.length;
@@ -13575,26 +13743,23 @@ var Smelter = class _Smelter {
13575
13743
  }
13576
13744
  this._reconcileState = { phase: "running" };
13577
13745
  try {
13578
- const [indexedResources, indexedAnnotations] = await Promise.all([
13746
+ const [indexedResources, indexedAnnotations, anchoredKeys] = await Promise.all([
13579
13747
  this.vectorStore.listResourceStamps(),
13580
- this.vectorStore.listAnnotationIds()
13748
+ this.vectorStore.listAnnotationIds(),
13749
+ // The artifact store's would-hit keys — one bulk read, never a probe
13750
+ // per resource (PERSIST-ANCHORS P0). Keys are resource ids today;
13751
+ // P1 rekeys the store by content checksum and this lookup moves
13752
+ // with it.
13753
+ this.content.listAnchoredTextKeys().then((keys) => new Set(keys))
13581
13754
  ]);
13582
13755
  const resources = await this.listAllResources();
13583
13756
  this.logger.info("Reconcile started", {
13584
13757
  indexedResources: indexedResources.size,
13585
13758
  indexedAnnotations: indexedAnnotations.size,
13759
+ anchoredArtifacts: anchoredKeys.size,
13586
13760
  liveResources: resources.length
13587
13761
  });
13588
- const embeddable = /* @__PURE__ */ new Map();
13589
- for (const resource of resources) {
13590
- const mediaType = getPrimaryMediaType(resource);
13591
- if (resource["@id"] && mediaType && textExtractionOf(mediaType) === "decode") {
13592
- embeddable.set(resource["@id"], {
13593
- checksum: getPrimaryRepresentation(resource)?.checksum,
13594
- entityTypes: getResourceEntityTypes(resource)
13595
- });
13596
- }
13597
- }
13762
+ const embeddable = this.classifyEmbeddable(resources);
13598
13763
  const work = [];
13599
13764
  for (const rid of indexedResources.keys()) {
13600
13765
  if (!embeddable.has(rid)) work.push({ type: "smelt:purge", resourceId: rid, payload: {} });
@@ -13608,6 +13773,9 @@ var Smelter = class _Smelter {
13608
13773
  } else if (!sameStringSet(indexed.entityTypes, catalog.entityTypes)) {
13609
13774
  work.push({ type: "smelt:restamp", resourceId: rid, payload: {} });
13610
13775
  }
13776
+ if (indexed && catalog.checksum !== void 0 && indexed.contentChecksum === catalog.checksum && catalog.yieldsGeometry && !anchoredKeys.has(catalog.checksum)) {
13777
+ work.push({ type: "smelt:reanchor", resourceId: rid, payload: {} });
13778
+ }
13611
13779
  }
13612
13780
  const liveAnnotationIds = /* @__PURE__ */ new Set();
13613
13781
  for (const resource of resources) {
@@ -13636,9 +13804,12 @@ var Smelter = class _Smelter {
13636
13804
  const summary = {
13637
13805
  resourcesEmbedded: work.filter((w) => w.type === "smelt:embed").length,
13638
13806
  resourcesRestamped: work.filter((w) => w.type === "smelt:restamp").length,
13807
+ resourcesReanchored: work.filter((w) => w.type === "smelt:reanchor").length,
13639
13808
  resourceVectorsDeleted: work.filter((w) => w.type === "smelt:purge").length,
13640
13809
  annotationsEmbedded: work.filter((w) => w.type === "smelt:embed-annotation").length,
13641
- annotationVectorsDeleted: work.filter((w) => w.type === "smelt:purge-annotation").length
13810
+ annotationVectorsDeleted: work.filter((w) => w.type === "smelt:purge-annotation").length,
13811
+ resourcesEligible: embeddable.size,
13812
+ resourcesIndexed: (await this.vectorStore.listResourceStamps()).size
13642
13813
  };
13643
13814
  this._reconcileState = { phase: "done", summary };
13644
13815
  this.logger.info("Reconcile complete", { ...summary });
@@ -13657,16 +13828,106 @@ var Smelter = class _Smelter {
13657
13828
  * completion. The pipeline ticks `noteWorkDone` for every consumed work
13658
13829
  * item (success or failure — failures are logged like any live event), so
13659
13830
  * each wave's waiter resolves exactly when its items have been processed.
13831
+ *
13832
+ * Serialized through `drainChain`: there is ONE waiter slot, and the
13833
+ * planners that drain (reconcile, `smelt:rebuild-anchors`) must not
13834
+ * interleave — a rebuild command arriving mid-reconcile waits its turn.
13835
+ *
13836
+ * @returns how many of THESE items failed — the rebuild command's
13837
+ * partial-failure accounting (failure detail is in the logs).
13660
13838
  */
13661
13839
  async drain(work) {
13662
- for (let i = 0; i < work.length; i += _Smelter.RECONCILE_WAVE) {
13663
- const wave = work.slice(i, i + _Smelter.RECONCILE_WAVE);
13664
- const done = new Promise((resolve2) => {
13665
- this.workWaiter = { target: this.workDone + wave.length, resolve: resolve2 };
13666
- });
13667
- for (const item of wave) this.eventSubject.next(item);
13668
- await done;
13840
+ let failures = 0;
13841
+ const run = this.drainChain.then(async () => {
13842
+ const failedBefore = this.workFailed;
13843
+ for (let i = 0; i < work.length; i += _Smelter.RECONCILE_WAVE) {
13844
+ const wave = work.slice(i, i + _Smelter.RECONCILE_WAVE);
13845
+ const done = new Promise((resolve2) => {
13846
+ this.workWaiter = { target: this.workDone + wave.length, resolve: resolve2 };
13847
+ });
13848
+ for (const item of wave) this.eventSubject.next(item);
13849
+ await done;
13850
+ }
13851
+ failures = this.workFailed - failedBefore;
13852
+ });
13853
+ this.drainChain = run.then(() => void 0, () => void 0);
13854
+ await run;
13855
+ return failures;
13856
+ }
13857
+ /**
13858
+ * `smelt:rebuild-anchors` — the operator's explicit re-derivation of
13859
+ * anchored-text artifacts (PERSIST-ANCHORS P0), shaped after
13860
+ * `weave:rebuild`: optionally scoped, strictly serialized (concatMap on
13861
+ * the command stream + the drain chain), correlated ok/failed replies,
13862
+ * and partial completion FAILS — a rebuild that quietly skipped resources
13863
+ * would present exactly like a document with no text, which is the #845
13864
+ * failure mode wearing different clothes.
13865
+ *
13866
+ * Never destructive: nothing is deleted first, stale entries are simply
13867
+ * overwritten (the W5-frames lesson — a rebuild that clears before it
13868
+ * re-derives turns a partial failure into a loss). Re-anchoring makes
13869
+ * zero embedding calls; work items ride the normal per-resource lanes,
13870
+ * so a rebuild can never interleave with live processing of the same
13871
+ * resource (S1/S2).
13872
+ */
13873
+ async rebuildAnchors(command) {
13874
+ const { correlationId, resourceId: resourceId10 } = command;
13875
+ try {
13876
+ let work;
13877
+ if (resourceId10) {
13878
+ work = [{ type: "smelt:reanchor", resourceId: resourceId10, payload: {} }];
13879
+ } else {
13880
+ const resources = await this.listAllResources();
13881
+ work = [...this.classifyEmbeddable(resources)].filter(([, catalog]) => catalog.yieldsGeometry).map(([rid]) => ({ type: "smelt:reanchor", resourceId: rid, payload: {} }));
13882
+ }
13883
+ this.logger.info("Anchored-text rebuild started", { scoped: resourceId10 ?? null, resources: work.length });
13884
+ const failed = await this.drain(work);
13885
+ if (failed > 0) {
13886
+ await this.bus.emit("smelt:rebuild-anchors-failed", {
13887
+ ...correlationId ? { correlationId } : {},
13888
+ message: `${failed} of ${work.length} resources failed to re-anchor \u2014 see smelter logs`
13889
+ });
13890
+ return;
13891
+ }
13892
+ await this.bus.emit("smelt:rebuild-anchors-ok", correlationId ? { correlationId } : {});
13893
+ this.logger.info("Anchored-text rebuild complete", { scoped: resourceId10 ?? null, resources: work.length });
13894
+ } catch (error) {
13895
+ this.logger.error("Anchored-text rebuild failed", { error: errField(error) });
13896
+ try {
13897
+ await this.bus.emit("smelt:rebuild-anchors-failed", {
13898
+ ...correlationId ? { correlationId } : {},
13899
+ message: error instanceof Error ? error.message : String(error)
13900
+ });
13901
+ } catch (emitError) {
13902
+ this.logger.warn("Failed to emit smelt:rebuild-anchors-failed", { error: errField(emitError) });
13903
+ }
13904
+ }
13905
+ }
13906
+ /**
13907
+ * Embeddable live resources, each with the catalog's claims: the primary
13908
+ * representation's checksum (the bytes the smelter would read), the
13909
+ * current entity-type set (the discriminator the stamps must carry), and
13910
+ * whether the media type's extractor derives geometry (whether an
13911
+ * anchored-text artifact should exist). Embeddable ⇔ an extractor exists
13912
+ * for the media type's strategy — the same registry the live fetch
13913
+ * resolves, and `yieldsGeometry` is declared on the extractor itself, so
13914
+ * every gate here and the live fetch's behavior are twins by construction.
13915
+ * Shared by `reconcile()` and the `smelt:rebuild-anchors` planner.
13916
+ */
13917
+ classifyEmbeddable(resources) {
13918
+ const embeddable = /* @__PURE__ */ new Map();
13919
+ for (const resource of resources) {
13920
+ const mediaType = getPrimaryMediaType(resource);
13921
+ const extractor = mediaType ? EXTRACTORS[textExtractionOf(mediaType)] : null;
13922
+ if (resource["@id"] && extractor) {
13923
+ embeddable.set(resource["@id"], {
13924
+ checksum: getPrimaryRepresentation(resource)?.checksum,
13925
+ entityTypes: getResourceEntityTypes(resource),
13926
+ yieldsGeometry: extractor.yieldsGeometry
13927
+ });
13928
+ }
13669
13929
  }
13930
+ return embeddable;
13670
13931
  }
13671
13932
  /** Page through `browse:resources-requested` until the catalog is exhausted. */
13672
13933
  async listAllResources() {
@@ -13697,6 +13958,7 @@ var SMELTER_CHANNELS = [
13697
13958
  "mark:entity-tag-added",
13698
13959
  "mark:entity-tag-removed"
13699
13960
  ];
13961
+ var SMELTER_COMMAND_CHANNELS = ["smelt:rebuild-anchors"];
13700
13962
  function createSmelterActorStateUnit(options) {
13701
13963
  const { bus } = options;
13702
13964
  let started = false;
@@ -13711,12 +13973,14 @@ function createSmelterActorStateUnit(options) {
13711
13973
  )
13712
13974
  )
13713
13975
  );
13976
+ const rebuildAnchors$ = bus.on$("smelt:rebuild-anchors");
13714
13977
  return {
13715
13978
  events$,
13979
+ rebuildAnchors$,
13716
13980
  start: () => {
13717
13981
  if (started) return;
13718
13982
  started = true;
13719
- bus.addChannels?.([...SMELTER_CHANNELS]);
13983
+ bus.addChannels?.([...SMELTER_CHANNELS, ...SMELTER_COMMAND_CHANNELS]);
13720
13984
  },
13721
13985
  dispose: () => {
13722
13986
  started = false;