neotoma 0.22.2 → 0.23.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 (50) hide show
  1. package/dist/actions.d.ts.map +1 -1
  2. package/dist/actions.js +49 -50
  3. package/dist/actions.js.map +1 -1
  4. package/dist/config.d.ts +19 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +79 -3
  7. package/dist/config.js.map +1 -1
  8. package/dist/docs/developer/mcp/compact_instructions.md +2 -0
  9. package/dist/docs/developer/mcp/instructions.md +30 -3
  10. package/dist/docs/developer/mcp/proxy.md +6 -4
  11. package/dist/docs/operations/configuration.md +54 -42
  12. package/dist/docs/specs/MCP_SPEC.md +1 -1
  13. package/dist/docs/subsystems/conflict_resolution.md +11 -6
  14. package/dist/docs/subsystems/observation_architecture.md +2 -2
  15. package/dist/docs/subsystems/reducer.md +29 -3
  16. package/dist/docs/subsystems/schema_registry.md +11 -0
  17. package/dist/docs/testing/automated_test_catalog.md +13 -6
  18. package/dist/mcp_http_session.d.ts +51 -0
  19. package/dist/mcp_http_session.d.ts.map +1 -0
  20. package/dist/mcp_http_session.js +173 -0
  21. package/dist/mcp_http_session.js.map +1 -0
  22. package/dist/reducers/observation_reducer.d.ts.map +1 -1
  23. package/dist/reducers/observation_reducer.js +24 -6
  24. package/dist/reducers/observation_reducer.js.map +1 -1
  25. package/dist/server.d.ts +7 -2
  26. package/dist/server.d.ts.map +1 -1
  27. package/dist/server.js +117 -10
  28. package/dist/server.js.map +1 -1
  29. package/dist/services/field_canonicalization.d.ts +1 -0
  30. package/dist/services/field_canonicalization.d.ts.map +1 -1
  31. package/dist/services/field_canonicalization.js +15 -9
  32. package/dist/services/field_canonicalization.js.map +1 -1
  33. package/dist/services/interpretation.d.ts +2 -0
  34. package/dist/services/interpretation.d.ts.map +1 -1
  35. package/dist/services/interpretation.js +2 -1
  36. package/dist/services/interpretation.js.map +1 -1
  37. package/dist/services/schema_definitions.d.ts.map +1 -1
  38. package/dist/services/schema_definitions.js +108 -0
  39. package/dist/services/schema_definitions.js.map +1 -1
  40. package/dist/services/schema_registry.d.ts +1 -0
  41. package/dist/services/schema_registry.d.ts.map +1 -1
  42. package/dist/services/schema_registry.js.map +1 -1
  43. package/dist/services/skills/instance_skills.d.ts +137 -0
  44. package/dist/services/skills/instance_skills.d.ts.map +1 -0
  45. package/dist/services/skills/instance_skills.js +381 -0
  46. package/dist/services/skills/instance_skills.js.map +1 -0
  47. package/dist/tool_definitions.js +1 -1
  48. package/dist/tool_definitions.js.map +1 -1
  49. package/docs/developer/mcp/instructions.md +30 -3
  50. package/package.json +2 -2
package/dist/server.js CHANGED
@@ -23,6 +23,7 @@ import { prepareEntitySnapshotWithEmbedding, upsertEntitySnapshotWithEmbedding,
23
23
  import { getLatestFromRegistry, isUpdateAvailable, formatUpgradeCommand } from "./version_check.js";
24
24
  import { buildSessionInfo, resolveConfiguredSessionOrigin, } from "./services/session_info.js";
25
25
  import { getActiveStandingRulesResult } from "./services/standing_rules.js";
26
+ import { getInstanceSkillsResult, renderInstanceSkillsSection, } from "./services/skills/instance_skills.js";
26
27
  import { AttributionPolicyError } from "./services/attribution_policy.js";
27
28
  import { OverridePolicyViolationError } from "./services/override_validation.js";
28
29
  import { CursorError } from "./services/entity_cursor.js";
@@ -207,8 +208,13 @@ export class NeotomaServer {
207
208
  * separate discovery round-trip.
208
209
  *
209
210
  * Returns an empty array when the `skills/` directory is absent (e.g. in
210
- * development checkouts that have not yet staged skill assets, or in test
211
- * environments with stripped package layouts).
211
+ * development checkouts that have not yet staged skill assets, in test
212
+ * environments with stripped package layouts, or on a hosted instance whose
213
+ * skills live in the graph rather than on disk).
214
+ *
215
+ * This is only one of two skill sources. Graph-stored `skill` entities are
216
+ * read separately by `getInstanceSkills()` and unioned with this list in
217
+ * {@link buildAuthenticatedInitializeResponse} (issue #2046).
212
218
  */
213
219
  getAvailableSkills() {
214
220
  const roots = [config.projectRoot, resolveNeotomaPackageRoot()];
@@ -544,7 +550,9 @@ export class NeotomaServer {
544
550
  catch (err) {
545
551
  logger.warn(`[instance_policy] instructions render skipped: ${err.message}`);
546
552
  }
547
- const instructions = composeClientInstructions(baseInstructions, policySection);
553
+ // Deferred until the instance-skills section is rendered below, so both
554
+ // appended sections compose through the same helper rather than one going
555
+ // through it and the other being concatenated on afterwards.
548
556
  // Load standing rules for the authenticated user and inject them so agents
549
557
  // apply them from the first turn of the session (issue #184).
550
558
  let standingRules = [];
@@ -558,7 +566,47 @@ export class NeotomaServer {
558
566
  standingRules = result.rules;
559
567
  standingRulesLookupFailed = result.lookup_failed;
560
568
  }
561
- const availableSkills = this.getAvailableSkills();
569
+ // Skills reach agents two ways: mirrored to a local skills directory, or
570
+ // stored in the graph as `skill` rows. A hosted instance has only the
571
+ // latter, so the filesystem scan alone leaves MCP-only clients unable to
572
+ // discover any skill the instance holds (issue #2046). Union both sources
573
+ // so either deployment shape surfaces its skills.
574
+ //
575
+ // Gated by `config.mcpInstanceSkillHints`, which defaults ON: an operator
576
+ // who wants the section gone sets NEOTOMA_MCP_INSTANCE_SKILL_HINTS=0, and
577
+ // the lookup is then skipped entirely rather than performed and discarded,
578
+ // leaving the instructions byte-identical to what they were before this
579
+ // feature existed.
580
+ //
581
+ // As with standing rules, an empty list is ambiguous: it means either "no
582
+ // skill rows" or "the read failed". Carry the distinction so a broken read
583
+ // is never presented to an agent as a confident "this instance has none".
584
+ let instanceSkills = [];
585
+ let instanceSkillsLookupFailed = false;
586
+ if (this.authenticatedUserId && config.mcpInstanceSkillHints) {
587
+ const result = await getInstanceSkillsResult(this.authenticatedUserId);
588
+ instanceSkills = result.skills;
589
+ instanceSkillsLookupFailed = result.lookup_failed;
590
+ }
591
+ const filesystemSkills = this.getAvailableSkills();
592
+ const availableSkills = Array.from(new Set([...filesystemSkills, ...instanceSkills.map((s) => s.name)])).sort();
593
+ // An instance with no skill rows is a complete no-op: the renderer returns
594
+ // null and the instructions block is left exactly as it was.
595
+ //
596
+ // A FAILED read is not a no-op, though. The agent reads prose, not
597
+ // serverInfo, so the unavailable state is stated here too — otherwise the
598
+ // only signal lives in a field the consumer of the instruction block never
599
+ // looks at, and the failure stays effectively silent where it matters.
600
+ const skillsSection = instanceSkillsLookupFailed
601
+ ? "[INSTANCE SKILLS]\nThis instance's graph-stored skills could not be read for this session, so " +
602
+ "the list below (if any) is incomplete. Do NOT tell the user this instance has no skills. " +
603
+ "Treat its skills as unknown, retry on a later connection, and check the server logs for " +
604
+ "the `[instance_skills]` warning if this persists."
605
+ : renderInstanceSkillsSection(instanceSkills, config.mcpCompactInstructions);
606
+ // Nested rather than variadic: `composeClientInstructions` takes one
607
+ // section, and each call no-ops on an empty one, so an instance with no
608
+ // policy and no skill rows gets byte-identical instructions to before.
609
+ const instructions = composeClientInstructions(composeClientInstructions(baseInstructions, policySection), skillsSection);
562
610
  return {
563
611
  protocolVersion: "2025-11-25",
564
612
  capabilities: {
@@ -582,6 +630,12 @@ export class NeotomaServer {
582
630
  }
583
631
  : {}),
584
632
  available_skills: availableSkills,
633
+ ...(instanceSkillsLookupFailed
634
+ ? {
635
+ skills_unavailable: true,
636
+ skills_note: "Instance-stored skills could not be read for this session. An empty or filesystem-only available_skills here does NOT mean this instance has no skills — treat the instance's skills as unknown rather than absent, and say so rather than telling the user none exist.",
637
+ }
638
+ : {}),
585
639
  },
586
640
  },
587
641
  instructions,
@@ -4480,15 +4534,67 @@ export class NeotomaServer {
4480
4534
  `Use a unique idempotency_key for each distinct write.`);
4481
4535
  throw mismatchErr;
4482
4536
  }
4483
- const { data: existingObservations, error: obsError } = await db
4537
+ // Combined stores with interpretation.source_ref=unstructured write
4538
+ // observations against the unstructured source, while the structured
4539
+ // JSON source still owns the idempotency key. Exact replay must look
4540
+ // up observations on the interpretation source when one was selected,
4541
+ // then resolve the entities this request stored — sibling provenance
4542
+ // rows (e.g. audio_asset) and other interpretations of the same bytes
4543
+ // must not displace them.
4544
+ const observationSourceId = options.interpretationSourceId ??
4545
+ (typeof interpretation?.source_id === "string" ? interpretation.source_id : undefined) ??
4546
+ existingSource.id;
4547
+ const requestedEntities = entities.map((entity) => {
4548
+ const raw = { ...entity };
4549
+ const entityType = (typeof raw.entity_type === "string" && raw.entity_type) ||
4550
+ (typeof raw.type === "string" && raw.type) ||
4551
+ "";
4552
+ delete raw.entity_type;
4553
+ delete raw.type;
4554
+ return { entityType, fields: raw };
4555
+ });
4556
+ const requestedEntityTypes = new Set(requestedEntities.map((entity) => entity.entityType).filter(Boolean));
4557
+ const { data: existingObservationRows, error: obsError } = await db
4484
4558
  .from("observations")
4485
- .select("id, entity_id, entity_type")
4486
- .eq("source_id", existingSource.id)
4559
+ .select("id, entity_id, entity_type, fields, idempotency_key")
4560
+ .eq("source_id", observationSourceId)
4487
4561
  .eq("user_id", userId);
4488
4562
  if (obsError) {
4489
4563
  throw new Error(`Failed to fetch existing observations: ${obsError.message}`);
4490
4564
  }
4491
- const existingEntityIds = existingObservations?.map((obs) => obs.entity_id) ?? [];
4565
+ const { getSnapshot: getSnapshotForReplay } = await import("./services/snapshot_computation.js");
4566
+ let existingObservations = existingObservationRows ?? [];
4567
+ if (observationSourceId !== existingSource.id && requestedEntityTypes.size > 0) {
4568
+ // Shared unstructured sources can host multiple same-typed
4569
+ // interpretations (plus siblings like audio_asset). Match against the
4570
+ // observation's stored fields / idempotency_key — never the current
4571
+ // entity snapshot, which drifts after correct() and used to fall open
4572
+ // to every same-typed row on the source (PR #2393 QA).
4573
+ const typeFiltered = existingObservations.filter((obs) => requestedEntityTypes.has(obs.entity_type));
4574
+ const keyMatched = typeFiltered.filter((obs) => typeof obs.idempotency_key === "string" && obs.idempotency_key === idempotencyKey);
4575
+ const fieldMatched = [];
4576
+ for (const obs of typeFiltered) {
4577
+ const obsFields = obs.fields && typeof obs.fields === "object"
4578
+ ? obs.fields
4579
+ : null;
4580
+ if (!obsFields)
4581
+ continue;
4582
+ const matches = requestedEntities.some((requested) => {
4583
+ if (requested.entityType && requested.entityType !== obs.entity_type) {
4584
+ return false;
4585
+ }
4586
+ return Object.entries(requested.fields).every(([key, value]) => obsFields[key] === value);
4587
+ });
4588
+ if (matches)
4589
+ fieldMatched.push(obs);
4590
+ }
4591
+ const resolved = keyMatched.length > 0 ? keyMatched : fieldMatched;
4592
+ if (resolved.length === 0 && typeFiltered.length >= 1) {
4593
+ throw new McpError(ErrorCode.InvalidParams, `ERR_IDEMPOTENCY_AMBIGUOUS: idempotency_key "${idempotencyKey}" matched a structured source whose interpretation source has ${typeFiltered.length} same-typed observation(s), but none match the requested fields or idempotency_key. Refusing to return sibling interpretations.`);
4594
+ }
4595
+ existingObservations = resolved;
4596
+ }
4597
+ const existingEntityIds = existingObservations.map((obs) => obs.entity_id) ?? [];
4492
4598
  const relatedData = await this.getRelatedEntitiesAndRelationships(existingEntityIds);
4493
4599
  const { data: fragmentRows } = await db
4494
4600
  .from("raw_fragments")
@@ -4506,7 +4612,6 @@ export class NeotomaServer {
4506
4612
  // one. No new observation is written, so we read the persisted snapshot
4507
4613
  // directly. Mark each entry `deduplicated: true` so callers can tell a
4508
4614
  // replay from a fresh write.
4509
- const { getSnapshot: getSnapshotForReplay } = await import("./services/snapshot_computation.js");
4510
4615
  const replaySnapshotByEntityId = new Map();
4511
4616
  for (const replayEntityId of new Set(existingEntityIds)) {
4512
4617
  try {
@@ -4521,7 +4626,7 @@ export class NeotomaServer {
4521
4626
  }
4522
4627
  return this.buildTextResponse({
4523
4628
  source_id: existingSource.id,
4524
- entities: existingObservations?.map((obs) => ({
4629
+ entities: existingObservations.map((obs) => ({
4525
4630
  entity_id: obs.entity_id,
4526
4631
  entity_type: obs.entity_type,
4527
4632
  observation_id: obs.id,
@@ -4579,6 +4684,7 @@ export class NeotomaServer {
4579
4684
  userId,
4580
4685
  sourceId: resolvedInterpretationSourceId,
4581
4686
  extractedData: entities,
4687
+ idempotencyKey,
4582
4688
  config: {
4583
4689
  provider: "agent",
4584
4690
  model_id: "unknown",
@@ -5010,6 +5116,7 @@ export class NeotomaServer {
5010
5116
  user_id: userId,
5011
5117
  identity_basis: resolverTrace.identityBasis,
5012
5118
  identity_rule: resolverTrace.identityRule,
5119
+ ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
5013
5120
  ...(Object.keys(structuredAttribution).length > 0
5014
5121
  ? { provenance: structuredAttribution }
5015
5122
  : {}),