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.
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +49 -50
- package/dist/actions.js.map +1 -1
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +79 -3
- package/dist/config.js.map +1 -1
- package/dist/docs/developer/mcp/compact_instructions.md +2 -0
- package/dist/docs/developer/mcp/instructions.md +30 -3
- package/dist/docs/developer/mcp/proxy.md +6 -4
- package/dist/docs/operations/configuration.md +54 -42
- package/dist/docs/specs/MCP_SPEC.md +1 -1
- package/dist/docs/subsystems/conflict_resolution.md +11 -6
- package/dist/docs/subsystems/observation_architecture.md +2 -2
- package/dist/docs/subsystems/reducer.md +29 -3
- package/dist/docs/subsystems/schema_registry.md +11 -0
- package/dist/docs/testing/automated_test_catalog.md +13 -6
- package/dist/mcp_http_session.d.ts +51 -0
- package/dist/mcp_http_session.d.ts.map +1 -0
- package/dist/mcp_http_session.js +173 -0
- package/dist/mcp_http_session.js.map +1 -0
- package/dist/reducers/observation_reducer.d.ts.map +1 -1
- package/dist/reducers/observation_reducer.js +24 -6
- package/dist/reducers/observation_reducer.js.map +1 -1
- package/dist/server.d.ts +7 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +117 -10
- package/dist/server.js.map +1 -1
- package/dist/services/field_canonicalization.d.ts +1 -0
- package/dist/services/field_canonicalization.d.ts.map +1 -1
- package/dist/services/field_canonicalization.js +15 -9
- package/dist/services/field_canonicalization.js.map +1 -1
- package/dist/services/interpretation.d.ts +2 -0
- package/dist/services/interpretation.d.ts.map +1 -1
- package/dist/services/interpretation.js +2 -1
- package/dist/services/interpretation.js.map +1 -1
- package/dist/services/schema_definitions.d.ts.map +1 -1
- package/dist/services/schema_definitions.js +108 -0
- package/dist/services/schema_definitions.js.map +1 -1
- package/dist/services/schema_registry.d.ts +1 -0
- package/dist/services/schema_registry.d.ts.map +1 -1
- package/dist/services/schema_registry.js.map +1 -1
- package/dist/services/skills/instance_skills.d.ts +137 -0
- package/dist/services/skills/instance_skills.d.ts.map +1 -0
- package/dist/services/skills/instance_skills.js +381 -0
- package/dist/services/skills/instance_skills.js.map +1 -0
- package/dist/tool_definitions.js +1 -1
- package/dist/tool_definitions.js.map +1 -1
- package/docs/developer/mcp/instructions.md +30 -3
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
|
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
|
|
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
|
: {}),
|