@pyxmate/memory 0.45.0 → 1.0.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.
@@ -1,8 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- mergeExtractedEntities,
4
- normalizeGraphLabel
5
- } from "../chunk-KSTI4M52.mjs";
3
+ PYX_MEMORY_INSTRUCTIONS,
4
+ SEARCH_ENUMERATION_CONCEPT_DESC,
5
+ SEARCH_LIMIT_DESC,
6
+ STORE_ENTITIES_DESC,
7
+ STORE_EVENT_TIME_DESC,
8
+ STORE_RELATIONSHIPS_DESC,
9
+ STORE_TOOL_DESC,
10
+ STORE_TRIPLES_DESC,
11
+ STRUCTURE_GRAPH_PROMPT_DESC,
12
+ buildDesignGuide,
13
+ buildGraphStructuringPrompt
14
+ } from "../chunk-XHEVB23R.mjs";
15
+ import {
16
+ normalizeGraphLabel,
17
+ normalizeNameKey
18
+ } from "../chunk-X6AYWXW7.mjs";
6
19
 
7
20
  // src/cli/exit-codes.ts
8
21
  var EXIT = {
@@ -339,7 +352,7 @@ async function promptMasked(label) {
339
352
  `);
340
353
  return value;
341
354
  }
342
- return new Promise((resolve2, reject) => {
355
+ return new Promise((resolve3, reject) => {
343
356
  const previousEncoding = stdin.readableEncoding;
344
357
  stdin.setEncoding("utf8");
345
358
  stdin.setRawMode(true);
@@ -357,7 +370,7 @@ async function promptMasked(label) {
357
370
  if (ch === "\n" || ch === "\r") {
358
371
  cleanup();
359
372
  stdout.write("\n");
360
- resolve2(buf.trim());
373
+ resolve3(buf.trim());
361
374
  return;
362
375
  }
363
376
  if (ch === "") {
@@ -554,49 +567,33 @@ function createReadCredentials(providerFactory) {
554
567
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
555
568
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
556
569
 
557
- // src/mcp/instructions.ts
558
- var PYX_MEMORY_INSTRUCTIONS = `Use pyx-memory to search durable project/user memory before assuming prior decisions, and to store concise facts after corrections, bug fixes, design decisions, integration discoveries, gotchas, explicit preferences, or "remember this" requests. Store decisions, not deliberation. Include topic and project. Pass eventTime (ISO-8601, when the fact happened or took effect) for any fact that can change or go stale \u2014 job/status changes, decisions that supersede earlier ones, dated events; recency ordering, dated ("as of") queries, and stale-vs-current conflict resolution all key off it. Search effort is retrieval depth: quick=strongest, deep=everything including archived/superseded. Use reinforce after memories were actually used, so they surface in quick/medium effort tiers. Use lineage for the history of how a fact changed; pass subject+relation for graph lineage or entryId for a superseded chain. Use record_correction when the user corrects a mistake you made (what was wrong, what to do instead, when it applies); call fetch_applicable_corrections before a task to retrieve the corrections that match it, then decide which to follow \u2014 pyx never auto-applies them. When a question names a time \u2014 explicit or relative ("last year", "\uB450 \uB2EC \uC804") \u2014 resolve it to an absolute ISO-8601 timestamp yourself and pass it as search anchorTime: results then rank by proximity to this time instead of now, without excluding anything. For count/list questions about a category, pass \`enumerationConcept\` = the language-agnostic/global category phrase (e.g. "fitness classes" / "\uC6B4\uB3D9 \uC218\uC5C5"); the caller-supplied hint is resolved by the embedding model, which covers a broad range of languages. pyx also auto-detects English/Korean count phrasing as a best-effort fallback when the hint is omitted. When content names people, organizations, tools, places, events, or key concepts, you (the caller) must extract and pass both entities and relationships \u2014 the server does not auto-extract them. Edges matter as much as nodes: without relationships the graph cannot connect related memories. For countable categories the user may later enumerate ("how many fitness classes / streaming services / pets do I have?"), add a canonical category CONCEPT node and an IS_A edge from each member to it. A language/synonym variant gives zero-cost exact-match resolution only when it is also a memberful CONCEPT node with member IS_A edges; there is no query-time alias\u2192canonical resolver. pyx resolves count/list category hints at query time via strict embedding over memberful CONCEPT nodes. Use file ingest for documents/images worth persisting; images require a description.`;
559
-
560
- // src/mcp/sampling.ts
561
- function createSamplingClient(server) {
562
- function isAvailable() {
563
- const caps = server.server.getClientCapabilities();
564
- return Boolean(caps?.sampling);
565
- }
566
- return {
567
- isAvailable,
568
- async complete(prompt, opts) {
569
- if (!isAvailable()) {
570
- throw new Error(
571
- "MCP sampling unavailable: the connected client did not advertise the `sampling` capability. Either supply entities/relationships explicitly or set extractEntities:false on the request."
572
- );
573
- }
574
- const result = await server.server.createMessage(
575
- {
576
- messages: [{ role: "user", content: { type: "text", text: prompt } }],
577
- maxTokens: opts?.maxTokens ?? 2048
578
- },
579
- opts?.signal ? { signal: opts.signal } : void 0
580
- );
581
- const content = result.content;
582
- if (content && typeof content === "object" && !Array.isArray(content)) {
583
- const block = content;
584
- if (block.type === "text" && typeof block.text === "string") return block.text;
585
- }
586
- if (Array.isArray(content)) {
587
- for (const block of content) {
588
- if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
589
- return block.text;
590
- }
570
+ // src/mcp/prompts/index.ts
571
+ import { z } from "zod";
572
+ var structureGraphPrompt = {
573
+ name: "structure_graph",
574
+ description: STRUCTURE_GRAPH_PROMPT_DESC,
575
+ register(server) {
576
+ server.registerPrompt(
577
+ "structure_graph",
578
+ {
579
+ title: "Structure content into a knowledge graph",
580
+ description: STRUCTURE_GRAPH_PROMPT_DESC,
581
+ argsSchema: {
582
+ content: z.string().min(1).describe("The memory content to extract graph fields from.")
591
583
  }
592
- }
593
- throw new Error("MCP sampling returned no text content");
594
- }
595
- };
596
- }
584
+ },
585
+ ({ content }) => ({
586
+ messages: [
587
+ { role: "user", content: { type: "text", text: buildGraphStructuringPrompt(content) } }
588
+ ]
589
+ })
590
+ );
591
+ }
592
+ };
593
+ var ALL_PROMPTS = [structureGraphPrompt];
597
594
 
598
595
  // src/mcp/tools/corrections.ts
599
- import { z as z2 } from "zod";
596
+ import { z as z3 } from "zod";
600
597
 
601
598
  // src/mcp/http-client.ts
602
599
  var DEFAULT_TIMEOUT_MS = 15e3;
@@ -703,22 +700,22 @@ function extractEnvelopeError(parsed) {
703
700
  }
704
701
 
705
702
  // src/mcp/tools/scopes.ts
706
- import { z } from "zod";
703
+ import { z as z2 } from "zod";
707
704
  var scopeShape = {
708
- tenantId: z.string().optional().describe("Sent as X-Tenant-Id for multi-tenant isolation."),
709
- userId: z.string().optional().describe("Sent as X-User-Id."),
710
- teamId: z.string().optional().describe("Sent as X-Team-Id."),
711
- callerAccessLevel: z.enum(["public", "internal", "secret"]).optional().describe("Sent as X-Caller-Access-Level for sensitivity filtering.")
705
+ tenantId: z2.string().optional().describe("Sent as X-Tenant-Id for multi-tenant isolation."),
706
+ userId: z2.string().optional().describe("Sent as X-User-Id."),
707
+ teamId: z2.string().optional().describe("Sent as X-Team-Id."),
708
+ callerAccessLevel: z2.enum(["public", "internal", "secret"]).optional().describe("Sent as X-Caller-Access-Level for sensitivity filtering.")
712
709
  };
713
710
 
714
711
  // src/mcp/tools/corrections.ts
715
712
  var recordInputShape = {
716
- namespaceId: z2.string().min(1).optional().describe("Namespace id to record into. Omit in single-tenant deployments (namespace-free)."),
717
- whatWasWrong: z2.string().min(1).describe("What the agent did wrong."),
718
- whatToDoInstead: z2.string().min(1).describe("The corrective instruction to follow instead."),
719
- appliesWhen: z2.string().min(1).describe("When this correction applies \u2014 the task context future tasks are matched against."),
720
- project: z2.string().optional().describe("Optional project scope; omit to apply to all projects."),
721
- taskShape: z2.string().optional().describe("Optional task-shape hint stored for provenance."),
713
+ namespaceId: z3.string().min(1).optional().describe("Namespace id to record into. Omit in single-tenant deployments (namespace-free)."),
714
+ whatWasWrong: z3.string().min(1).describe("What the agent did wrong."),
715
+ whatToDoInstead: z3.string().min(1).describe("The corrective instruction to follow instead."),
716
+ appliesWhen: z3.string().min(1).describe("When this correction applies \u2014 the task context future tasks are matched against."),
717
+ project: z3.string().optional().describe("Optional project scope; omit to apply to all projects."),
718
+ taskShape: z3.string().optional().describe("Optional task-shape hint stored for provenance."),
722
719
  ...scopeShape
723
720
  };
724
721
  var recordCorrectionTool = {
@@ -751,10 +748,10 @@ var recordCorrectionTool = {
751
748
  }
752
749
  };
753
750
  var fetchInputShape = {
754
- namespaceId: z2.string().min(1).optional().describe("Namespace id whose corrections to fetch. Omit in single-tenant deployments."),
755
- taskShape: z2.string().min(1).describe("Shape of the task about to run \u2014 scored against each correction."),
756
- project: z2.string().optional().describe("Optional project filter."),
757
- limit: z2.number().int().positive().optional().describe("Max corrections to return (cap 5)."),
751
+ namespaceId: z3.string().min(1).optional().describe("Namespace id whose corrections to fetch. Omit in single-tenant deployments."),
752
+ taskShape: z3.string().min(1).describe("Shape of the task about to run \u2014 scored against each correction."),
753
+ project: z3.string().optional().describe("Optional project filter."),
754
+ limit: z3.number().int().positive().optional().describe("Max corrections to return (cap 5)."),
758
755
  ...scopeShape
759
756
  };
760
757
  var fetchApplicableCorrectionsTool = {
@@ -786,10 +783,10 @@ var fetchApplicableCorrectionsTool = {
786
783
  };
787
784
 
788
785
  // src/mcp/tools/delete.ts
789
- import { z as z3 } from "zod";
786
+ import { z as z4 } from "zod";
790
787
  var inputShape = {
791
- id: z3.string().min(1).describe("Required memory entry id to delete."),
792
- reason: z3.string().min(1).describe("Required reason for deletion (audit)."),
788
+ id: z4.string().min(1).describe("Required memory entry id to delete."),
789
+ reason: z4.string().min(1).describe("Required reason for deletion (audit)."),
793
790
  ...scopeShape
794
791
  };
795
792
  var deleteMemoryTool = {
@@ -816,9 +813,9 @@ var deleteMemoryTool = {
816
813
  };
817
814
 
818
815
  // src/mcp/tools/get.ts
819
- import { z as z4 } from "zod";
816
+ import { z as z5 } from "zod";
820
817
  var inputShape2 = {
821
- id: z4.string().min(1).describe("Required memory entry id."),
818
+ id: z5.string().min(1).describe("Required memory entry id."),
822
819
  ...scopeShape
823
820
  };
824
821
  var getMemoryTool = {
@@ -846,17 +843,17 @@ var getMemoryTool = {
846
843
  // src/mcp/tools/ingest.ts
847
844
  import { readFile, stat } from "fs/promises";
848
845
  import { basename, extname, isAbsolute, resolve } from "path";
849
- import { z as z5 } from "zod";
846
+ import { z as z6 } from "zod";
850
847
  var IMAGE_EXT = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".svg"]);
851
848
  var MAX_BYTES = 50 * 1024 * 1024;
852
849
  var inputShape3 = {
853
- path: z5.string().min(1).describe(
850
+ path: z6.string().min(1).describe(
854
851
  "Local file path readable by the pyx-mem process. Uploaded as multipart `file`. Images require `description`; documents auto-extract text."
855
852
  ),
856
- description: z5.string().optional().describe(
853
+ description: z6.string().optional().describe(
857
854
  "REQUIRED for images so the entry is semantically searchable. Optional for documents with extractable text."
858
855
  ),
859
- namespaceId: z5.string().optional().describe("Optional ReBAC namespace for entries created from this file."),
856
+ namespaceId: z6.string().optional().describe("Optional ReBAC namespace for entries created from this file."),
860
857
  ...scopeShape
861
858
  };
862
859
  var ingestMemoryFileTool = {
@@ -911,16 +908,16 @@ var ingestMemoryFileTool = {
911
908
  };
912
909
 
913
910
  // src/mcp/tools/lineage.ts
914
- import { z as z6 } from "zod";
911
+ import { z as z7 } from "zod";
915
912
  var inputShape4 = {
916
- subject: z6.string().optional().describe("Graph subject to trace, used with relation for graph lineage."),
917
- relation: z6.string().optional().describe("Graph relation to trace for the subject."),
918
- entryId: z6.string().optional().describe("Memory entry id to trace through its supersededBy chain."),
919
- asOf: z6.string().optional().describe("Only include lineage versions ingested by this ISO-8601 time."),
920
- eventTimeStart: z6.string().optional().describe("Inclusive event-time start (ISO-8601)."),
921
- eventTimeEnd: z6.string().optional().describe("Inclusive event-time end (ISO-8601)."),
922
- beforeValue: z6.string().optional().describe("Stop before the lineage version with this value."),
923
- limit: z6.number().int().positive().optional().describe("Maximum lineage versions to return."),
913
+ subject: z7.string().optional().describe("Graph subject to trace, used with relation for graph lineage."),
914
+ relation: z7.string().optional().describe("Graph relation to trace for the subject."),
915
+ entryId: z7.string().optional().describe("Memory entry id to trace through its supersededBy chain."),
916
+ asOf: z7.string().optional().describe("Only include lineage versions ingested by this ISO-8601 time."),
917
+ eventTimeStart: z7.string().optional().describe("Inclusive event-time start (ISO-8601)."),
918
+ eventTimeEnd: z7.string().optional().describe("Inclusive event-time end (ISO-8601)."),
919
+ beforeValue: z7.string().optional().describe("Stop before the lineage version with this value."),
920
+ limit: z7.number().int().positive().optional().describe("Maximum lineage versions to return."),
924
921
  ...scopeShape
925
922
  };
926
923
  var lineageTool = {
@@ -956,16 +953,16 @@ var lineageTool = {
956
953
  };
957
954
 
958
955
  // src/mcp/tools/list.ts
959
- import { z as z7 } from "zod";
956
+ import { z as z8 } from "zod";
960
957
  var inputShape5 = {
961
- mode: z7.enum(["entries", "log"]).optional().describe(
958
+ mode: z8.enum(["entries", "log"]).optional().describe(
962
959
  "Listing mode. `entries` (default) returns paginated entries by filter; `log` returns the chronological memory log."
963
960
  ),
964
- page: z7.number().int().min(1).optional().describe("1-based page index for entries mode."),
965
- limit: z7.number().int().min(1).max(200).optional().describe("Page size; server clamps."),
966
- type: z7.enum(["short-term", "long-term", "working", "episodic", "summary"]).optional().describe("Filter by memory type."),
967
- agentId: z7.string().optional().describe("Filter to memories from this agentId."),
968
- since: z7.string().optional().describe("ISO-8601 lower bound for log mode."),
961
+ page: z8.number().int().min(1).optional().describe("1-based page index for entries mode."),
962
+ limit: z8.number().int().min(1).max(200).optional().describe("Page size; server clamps."),
963
+ type: z8.enum(["short-term", "long-term", "working", "episodic", "summary"]).optional().describe("Filter by memory type."),
964
+ agentId: z8.string().optional().describe("Filter to memories from this agentId."),
965
+ since: z8.string().optional().describe("ISO-8601 lower bound for log mode."),
969
966
  ...scopeShape
970
967
  };
971
968
  var listMemoriesTool = {
@@ -1000,9 +997,9 @@ var listMemoriesTool = {
1000
997
  };
1001
998
 
1002
999
  // src/mcp/tools/profile.ts
1003
- import { z as z8 } from "zod";
1000
+ import { z as z9 } from "zod";
1004
1001
  var getInputShape = {
1005
- namespaceId: z8.string().min(1).describe("Namespace id whose user-profile to fetch."),
1002
+ namespaceId: z9.string().min(1).describe("Namespace id whose user-profile to fetch."),
1006
1003
  ...scopeShape
1007
1004
  };
1008
1005
  var getUserProfileTool = {
@@ -1028,8 +1025,8 @@ var getUserProfileTool = {
1028
1025
  }
1029
1026
  };
1030
1027
  var upsertInputShape = {
1031
- namespaceId: z8.string().min(1).describe("Namespace id to upsert the user-profile into."),
1032
- content: z8.string().min(1).describe("Full freeform profile content (UTF-8, \u22648192 bytes; server enforces the cap)."),
1028
+ namespaceId: z9.string().min(1).describe("Namespace id to upsert the user-profile into."),
1029
+ content: z9.string().min(1).describe("Full freeform profile content (UTF-8, \u22648192 bytes; server enforces the cap)."),
1033
1030
  ...scopeShape
1034
1031
  };
1035
1032
  var upsertUserProfileTool = {
@@ -1056,11 +1053,11 @@ var upsertUserProfileTool = {
1056
1053
  };
1057
1054
 
1058
1055
  // src/mcp/tools/reinforce.ts
1059
- import { z as z9 } from "zod";
1056
+ import { z as z10 } from "zod";
1060
1057
  var inputShape6 = {
1061
- entryIds: z9.array(z9.string().min(1)).min(1).describe("Memory entry ids that were actually used."),
1062
- signal: z9.enum(["context_included", "cited", "explicit_positive"]).describe("Reinforcement signal: context_included < cited < explicit_positive."),
1063
- at: z9.union([z9.string(), z9.number()]).optional().describe("Recall time as epoch-ms or full ISO-8601 timestamp with timezone."),
1058
+ entryIds: z10.array(z10.string().min(1)).min(1).describe("Memory entry ids that were actually used."),
1059
+ signal: z10.enum(["context_included", "cited", "explicit_positive"]).describe("Reinforcement signal: context_included < cited < explicit_positive."),
1060
+ at: z10.union([z10.string(), z10.number()]).optional().describe("Recall time as epoch-ms or full ISO-8601 timestamp with timezone."),
1064
1061
  ...scopeShape
1065
1062
  };
1066
1063
  var reinforceTool = {
@@ -1087,31 +1084,27 @@ var reinforceTool = {
1087
1084
  };
1088
1085
 
1089
1086
  // src/mcp/tools/search.ts
1090
- import { z as z10 } from "zod";
1087
+ import { z as z11 } from "zod";
1091
1088
  var inputShape7 = {
1092
- query: z10.string().min(1).describe("Required natural-language search text."),
1093
- limit: z10.number().int().min(1).max(100).optional().describe(
1094
- "Target result count; server clamps to 1\u2013100. Default 10. The hybrid strategy may widen count/list searches up to 30 for set-completeness recall when the caller passes `enumerationConcept`; English/Korean marker auto-detect is a best-effort fallback."
1095
- ),
1096
- strategy: z10.enum(["naive", "graph", "hybrid"]).optional().describe(
1089
+ query: z11.string().min(1).describe("Required natural-language search text."),
1090
+ limit: z11.number().int().min(1).max(100).optional().describe(SEARCH_LIMIT_DESC),
1091
+ strategy: z11.enum(["naive", "graph", "hybrid"]).optional().describe(
1097
1092
  "RAG strategy. Defaults to `hybrid` (cross-encoder reranking, multi-entity decomposition, confidence scoring) and is sent explicitly when omitted; pass `naive` for a lighter vector-only search or `graph` for graph-augmented retrieval."
1098
1093
  ),
1099
- effort: z10.enum(["quick", "medium", "deep"]).optional().describe(
1094
+ effort: z11.enum(["quick", "medium", "deep"]).optional().describe(
1100
1095
  "Retrieval depth: quick=strongest, medium=default-depth, deep=everything including archived/superseded."
1101
1096
  ),
1102
- type: z10.enum(["short-term", "long-term", "working", "episodic", "summary"]).optional().describe("Filter by memory type."),
1103
- agentId: z10.string().optional().describe("Filter to memories stored for this agentId."),
1104
- abstentionThreshold: z10.number().min(0).max(1).optional().describe("Enable confidence scoring; abstain when confidence falls below this value (0\u20131)."),
1105
- eventTimeStart: z10.string().optional().describe("Inclusive event-time start (ISO-8601); must be paired with eventTimeEnd."),
1106
- eventTimeEnd: z10.string().optional().describe("Inclusive event-time end (ISO-8601); must be paired with eventTimeStart."),
1107
- asOf: z10.string().optional().describe("Only include memories ingested before this ISO-8601 timestamp."),
1108
- anchorTime: z10.string().optional().describe(
1097
+ type: z11.enum(["short-term", "long-term", "working", "episodic", "summary"]).optional().describe("Filter by memory type."),
1098
+ agentId: z11.string().optional().describe("Filter to memories stored for this agentId."),
1099
+ abstentionThreshold: z11.number().min(0).max(1).optional().describe("Enable confidence scoring; abstain when confidence falls below this value (0\u20131)."),
1100
+ eventTimeStart: z11.string().optional().describe("Inclusive event-time start (ISO-8601); must be paired with eventTimeEnd."),
1101
+ eventTimeEnd: z11.string().optional().describe("Inclusive event-time end (ISO-8601); must be paired with eventTimeStart."),
1102
+ asOf: z11.string().optional().describe("Only include memories ingested before this ISO-8601 timestamp."),
1103
+ anchorTime: z11.string().optional().describe(
1109
1104
  'Soft recency ANCHOR (ISO-8601) \u2014 ranks results by proximity to this time instead of now; never excludes anything. When the question names a relative time ("two months ago", "last year", "3\uB144 \uC804"), resolve it against the current date yourself and pass the absolute timestamp here; prefer this over eventTimeStart/End unless a strict window is required, because hard filters drop last-known-before facts.'
1110
1105
  ),
1111
- enumerationConcept: z10.string().trim().min(1).optional().describe(
1112
- 'For count/list questions about a category, pass the language-agnostic/global category phrase here (for example, "fitness classes" or "\uC6B4\uB3D9 \uC218\uC5C5"). The embedding model resolves the caller-supplied hint across a broad range of languages.'
1113
- ),
1114
- enableRerank: z10.boolean().optional().describe(
1106
+ enumerationConcept: z11.string().trim().min(1).optional().describe(SEARCH_ENUMERATION_CONCEPT_DESC),
1107
+ enableRerank: z11.boolean().optional().describe(
1115
1108
  "Opt into multilingual cross-encoder reranking (hybrid strategy only). Sharply improves Korean/cross-lingual ordering at higher latency; leave off for the fast default path."
1116
1109
  ),
1117
1110
  ...scopeShape
@@ -1188,67 +1181,8 @@ var statusTool = {
1188
1181
 
1189
1182
  // src/mcp/tools/store.ts
1190
1183
  import { z as z12 } from "zod";
1191
-
1192
- // src/mcp/extraction-prompt.ts
1193
- import { z as z11 } from "zod";
1194
- var ENTITY_TYPES = ["PERSON", "ORGANIZATION", "CONCEPT", "TOOL", "LOCATION", "EVENT"];
1195
- var RELATION_TYPES = [
1196
- "USES",
1197
- "OWNS",
1198
- "DEPENDS_ON",
1199
- "RELATED_TO",
1200
- "CREATED_BY",
1201
- "PART_OF",
1202
- "IS_A",
1203
- "WORKS_AT",
1204
- "LOCATED_IN"
1205
- ];
1206
- var ExtractionSchema = z11.object({
1207
- entities: z11.array(
1208
- z11.object({
1209
- name: z11.string().min(1),
1210
- type: z11.string().min(1).transform((value) => normalizeGraphLabel(value, "CONCEPT"))
1211
- })
1212
- ),
1213
- relations: z11.array(
1214
- z11.object({
1215
- source: z11.string().min(1),
1216
- target: z11.string().min(1),
1217
- type: z11.string().min(1).transform((value) => normalizeGraphLabel(value, "RELATED_TO"))
1218
- })
1219
- )
1220
- });
1221
- function buildExtractionPrompt(content) {
1222
- return [
1223
- "Extract graph facts as JSON only. No prose, no fences, no commentary.",
1224
- `Schema: {"entities":[{"name":string,"type":EntityType}],"relations":[{"source":string,"target":string,"type":RelationType}]}.`,
1225
- `Prefer EntityType values when applicable: ${ENTITY_TYPES.join(", ")}.`,
1226
- `Prefer RelationType values when applicable: ${RELATION_TYPES.join(", ")}.`,
1227
- "Emergent domain-specific labels are allowed; use uppercase words separated by underscores.",
1228
- "Include only entities/relations explicitly named or strongly implied in the content. Empty arrays are valid.",
1229
- `Content: ${content}`
1230
- ].join("\n");
1231
- }
1232
- function parseExtractionResponse(raw) {
1233
- let parsed;
1234
- try {
1235
- parsed = JSON.parse(raw);
1236
- } catch (cause) {
1237
- throw new Error(
1238
- `MCP sampling returned non-JSON text (first 120 chars: ${raw.slice(0, 120).replace(/\n/g, "\\n")})`,
1239
- { cause: cause instanceof Error ? cause : void 0 }
1240
- );
1241
- }
1242
- const result = ExtractionSchema.safeParse(parsed);
1243
- if (!result.success) {
1244
- throw new Error(`MCP sampling response failed schema validation: ${result.error.message}`);
1245
- }
1246
- return result.data;
1247
- }
1248
-
1249
- // src/mcp/tools/store.ts
1250
1184
  var entityTypes = ["PERSON", "ORGANIZATION", "CONCEPT", "TOOL", "LOCATION", "EVENT"];
1251
- var relationshipTypes = [
1185
+ var preferredRelationshipTypes = [
1252
1186
  "USES",
1253
1187
  "OWNS",
1254
1188
  "DEPENDS_ON",
@@ -1259,7 +1193,12 @@ var relationshipTypes = [
1259
1193
  "WORKS_AT",
1260
1194
  "LOCATED_IN"
1261
1195
  ];
1196
+ var RELATIONSHIP_TYPE_DESC = `Relationship type \u2014 freeform label; preferred: ${preferredRelationshipTypes.join(", ")}.`;
1262
1197
  var storeTargets = ["sqlite", "vector", "graph"];
1198
+ var entityShape = z12.object({
1199
+ name: z12.string().min(1).describe("Entity name as referenced in content."),
1200
+ type: z12.enum(entityTypes).describe("Entity type.")
1201
+ });
1263
1202
  var inputShape9 = {
1264
1203
  content: z12.string().min(1).describe("Concise factual statement to persist; decision, not deliberation."),
1265
1204
  topic: z12.string().min(1).describe("Required metadata.topic for retrieval grouping."),
@@ -1269,71 +1208,81 @@ var inputShape9 = {
1269
1208
  "Storage targets. Include 'graph' when you provide entities/relationships or want zero graph write counts reported."
1270
1209
  ),
1271
1210
  importance: z12.number().int().min(1).max(10).optional().describe("Importance 1\u201310."),
1272
- eventTime: z12.string().optional().describe(
1273
- 'ISO-8601 time the fact happened or took effect (bi-temporal). Pass for any fact that can change or go stale \u2014 recency ordering, dated ("as of") queries, and stale-vs-current conflict resolution key off it.'
1274
- ),
1211
+ eventTime: z12.string().optional().describe(STORE_EVENT_TIME_DESC),
1275
1212
  source: z12.string().optional().describe("Free-form origin (filename, URL, conversation id)."),
1276
1213
  agentId: z12.string().optional().describe("Agent identifier stored alongside the entry."),
1277
1214
  sessionId: z12.string().optional().describe("Session identifier for grouping."),
1278
1215
  parentId: z12.string().optional().describe("Parent memory entry id (hierarchical)."),
1279
- entities: z12.array(
1280
- z12.object({
1281
- name: z12.string().min(1).describe("Entity name as referenced in content."),
1282
- type: z12.enum(entityTypes).describe("Entity type.")
1283
- })
1284
- ).optional().describe(
1285
- "Named entities mentioned by the content. The caller LLM must extract and pass these when the content names people, organizations, tools, places, events, or key concepts; the server does not auto-extract them."
1286
- ),
1216
+ entities: z12.array(entityShape).optional().describe(STORE_ENTITIES_DESC),
1287
1217
  relationships: z12.array(
1288
1218
  z12.object({
1289
1219
  source: z12.string().min(1).describe("Source entity name (must appear in entities array)."),
1290
1220
  target: z12.string().min(1).describe("Target entity name (must appear in entities array)."),
1291
- type: z12.enum(relationshipTypes).describe("Relationship type.")
1221
+ type: z12.string().min(1).describe(RELATIONSHIP_TYPE_DESC)
1292
1222
  })
1293
- ).optional().describe(
1294
- 'Edges between entities; source and target must be entity names from this request. Relationships matter as much as entities because graph traversal needs edges to connect related memories. For countable categories the user may later enumerate ("how many fitness classes / streaming services / pets do I have?"), add a canonical category CONCEPT node and an IS_A edge from each member to it. A language/synonym variant gives zero-cost exact-match resolution only when it is also a memberful CONCEPT node with member IS_A edges; there is no query-time alias\u2192canonical resolver. pyx resolves un-enumerated count-noun variants at query time via strict embedding over memberful CONCEPT nodes.'
1295
- ),
1223
+ ).optional().describe(STORE_RELATIONSHIPS_DESC),
1224
+ triples: z12.array(
1225
+ z12.object({
1226
+ subject: entityShape.describe("Subject entity (source node)."),
1227
+ relation: z12.string().min(1).describe(RELATIONSHIP_TYPE_DESC),
1228
+ object: entityShape.describe("Object entity (target node).")
1229
+ })
1230
+ ).optional().describe(STORE_TRIPLES_DESC),
1296
1231
  extractEntities: z12.boolean().optional().describe(
1297
- "Override extraction: false skips caller-side extraction; true requires caller entities or MCP sampling and errors loudly if neither is available."
1232
+ "Override extraction: false skips extraction; true asks the server-side extraction brain to run and errors loudly when none is configured."
1233
+ ),
1234
+ entitiesOnly: z12.boolean().optional().describe(
1235
+ "Set true to deliberately store entities with no relationships (e.g. a single concept). Otherwise a graph store with 2+ entities and 0 relationships is refused so isolated nodes do not accumulate."
1298
1236
  ),
1299
1237
  ...scopeShape
1300
1238
  };
1239
+ function materializeGraphInput(args) {
1240
+ const entities = /* @__PURE__ */ new Map();
1241
+ const addEntity = (e) => {
1242
+ const key = `${normalizeNameKey(e.name)}|${e.type}`;
1243
+ if (!entities.has(key)) entities.set(key, e);
1244
+ };
1245
+ const relationships = /* @__PURE__ */ new Map();
1246
+ const addRel = (rel) => {
1247
+ const key = `${normalizeNameKey(rel.source)}|${normalizeNameKey(rel.target)}|${normalizeGraphLabel(rel.type, "RELATED_TO")}`;
1248
+ if (!relationships.has(key)) relationships.set(key, rel);
1249
+ };
1250
+ for (const e of args.entities ?? []) addEntity(e);
1251
+ for (const r of args.relationships ?? []) addRel(r);
1252
+ for (const t of args.triples ?? []) {
1253
+ addEntity(t.subject);
1254
+ addEntity(t.object);
1255
+ addRel({ source: t.subject.name, target: t.object.name, type: t.relation });
1256
+ }
1257
+ return { entities: [...entities.values()], relationships: [...relationships.values()] };
1258
+ }
1301
1259
  var storeMemoryTool = {
1302
1260
  name: "store_memory",
1303
1261
  config: {
1304
1262
  title: "Store pyx-memory entry",
1305
- description: "Store one concise factual memory with required topic and project metadata. When content names people, organizations, tools, places, events, or key concepts, the caller LLM must extract and pass entities and relationships; the server does not auto-extract them. Relationships (edges) matter as much as entities because graph traversal needs edges to connect related memories. Entity-free memories are valid; omit graph data or set extractEntities:false when there are no graph facts.",
1263
+ description: STORE_TOOL_DESC,
1306
1264
  inputSchema: inputShape9,
1307
1265
  annotations: { readOnlyHint: false, idempotentHint: false, openWorldHint: true }
1308
1266
  },
1309
- handler: (deps) => async (raw, ctx) => {
1267
+ handler: (deps) => async (raw) => {
1310
1268
  const args = raw;
1269
+ const { entities, relationships } = materializeGraphInput(args);
1270
+ const graphTargeted = !args.targets || args.targets.includes("graph");
1271
+ const entityNameKeys = new Set(entities.map((e) => normalizeNameKey(e.name)));
1272
+ const resolvableRelationships = relationships.filter((r) => {
1273
+ const source = normalizeNameKey(r.source);
1274
+ const target = normalizeNameKey(r.target);
1275
+ return source !== target && entityNameKeys.has(source) && entityNameKeys.has(target);
1276
+ }).length;
1277
+ if (graphTargeted && entities.length >= 2 && resolvableRelationships === 0 && !args.entitiesOnly && args.extractEntities !== true) {
1278
+ return mcpText(
1279
+ `GRAPH_RELATIONSHIPS_REQUIRED: this store_memory call declares ${entities.length} entities but no relationship that connects them. Isolated nodes will not connect into the knowledge graph. Re-call store_memory with a \`relationships\` array (or \`triples\`) linking the entities (each { source, target, type }; source/target must be entity names from this call), OR set \`entitiesOnly: true\` if these entities are intentionally unconnected. Do not retry unchanged. If your MCP host exposes prompts, the \`structure_graph\` prompt produces the relationship/triple fields to add.`,
1280
+ true
1281
+ );
1282
+ }
1311
1283
  const creds = await deps.readCredentials();
1312
1284
  if (!creds.ok) return creds.result;
1313
1285
  const http = createHttpClient(creds.credentials, deps.fetchImpl);
1314
- let entities = args.entities;
1315
- let relationships = args.relationships;
1316
- const samplingAvailable = deps.samplingClient?.isAvailable() ?? false;
1317
- const optedOut = args.extractEntities === false;
1318
- const forced = args.extractEntities === true;
1319
- const graphTargeted = args.targets?.includes("graph") ?? true;
1320
- const hasCallerEntities = (entities?.length ?? 0) > 0;
1321
- if (forced && graphTargeted && !samplingAvailable && !hasCallerEntities) {
1322
- throw new Error(
1323
- "extractEntities=true requested but the connected MCP client did not advertise the sampling capability. Pass entities/relationships explicitly, set extractEntities:false, or connect a sampling-capable client."
1324
- );
1325
- }
1326
- if (graphTargeted && deps.samplingClient && samplingAvailable && !optedOut && !hasCallerEntities) {
1327
- const prompt = buildExtractionPrompt(args.content);
1328
- const completion = await deps.samplingClient.complete(
1329
- prompt,
1330
- ctx?.signal ? { signal: ctx.signal } : void 0
1331
- );
1332
- const extracted = parseExtractionResponse(completion);
1333
- const merged = mergeExtractedEntities(args.entities, args.relationships, extracted);
1334
- entities = merged.entities;
1335
- relationships = merged.relationships;
1336
- }
1337
1286
  const body = {
1338
1287
  content: args.content,
1339
1288
  type: args.type ?? "long-term",
@@ -1348,9 +1297,9 @@ var storeMemoryTool = {
1348
1297
  agentId: args.agentId,
1349
1298
  sessionId: args.sessionId,
1350
1299
  parentId: args.parentId,
1351
- entities,
1352
- relationships,
1353
- ...args.extractEntities === false ? { extractEntities: false } : {}
1300
+ entities: entities.length > 0 ? entities : void 0,
1301
+ relationships: relationships.length > 0 ? relationships : void 0,
1302
+ ...args.extractEntities !== void 0 ? { extractEntities: args.extractEntities } : {}
1354
1303
  };
1355
1304
  const res = await http.requestJson({
1356
1305
  method: "POST",
@@ -1391,7 +1340,7 @@ var summarizeMemoryEntityTool = {
1391
1340
  const res2 = await http.requestJson({
1392
1341
  method: "POST",
1393
1342
  path: "/api/memory/synthesis/entity",
1394
- body: { entityName: args.entityName, entityType: args.entityType },
1343
+ body: { name: args.entityName, entityType: args.entityType },
1395
1344
  scope: args
1396
1345
  });
1397
1346
  return res2.ok ? mcpJson(res2.data) : res2.result;
@@ -1399,7 +1348,7 @@ var summarizeMemoryEntityTool = {
1399
1348
  const res = await http.requestJson({
1400
1349
  method: "GET",
1401
1350
  path: "/api/memory/synthesis/entity",
1402
- query: { entityName: args.entityName, entityType: args.entityType },
1351
+ query: { name: args.entityName, entityType: args.entityType },
1403
1352
  scope: args
1404
1353
  });
1405
1354
  return res.ok ? mcpJson(res.data) : res.result;
@@ -1428,17 +1377,15 @@ var ALL_TOOL_NAMES = ALL_TOOLS.map((t) => t.name);
1428
1377
  // src/mcp/server.ts
1429
1378
  async function runMcpServer(opts) {
1430
1379
  const fetchImpl = opts.fetchImpl ?? fetch;
1431
- const version = opts.version ?? (true ? "0.45.0" : "0.0.0-dev");
1380
+ const version = opts.version ?? (true ? "1.0.0" : "0.0.0-dev");
1432
1381
  const server = new McpServer(
1433
1382
  { name: "pyx-memory", version },
1434
- { instructions: PYX_MEMORY_INSTRUCTIONS, capabilities: { tools: {} } }
1383
+ { instructions: PYX_MEMORY_INSTRUCTIONS, capabilities: { tools: {}, prompts: {} } }
1435
1384
  );
1436
- const samplingClient = createSamplingClient(server);
1437
1385
  for (const tool of ALL_TOOLS) {
1438
1386
  const handle = tool.handler({
1439
1387
  readCredentials: opts.readCredentials,
1440
- fetchImpl,
1441
- samplingClient
1388
+ fetchImpl
1442
1389
  });
1443
1390
  server.registerTool(
1444
1391
  tool.name,
@@ -1446,9 +1393,12 @@ async function runMcpServer(opts) {
1446
1393
  async (args, extra) => handle(args, { signal: extra?.signal })
1447
1394
  );
1448
1395
  }
1396
+ for (const prompt of ALL_PROMPTS) {
1397
+ prompt.register(server);
1398
+ }
1449
1399
  const transport = new StdioServerTransport();
1450
- const closed = new Promise((resolve2) => {
1451
- transport.onclose = () => resolve2();
1400
+ const closed = new Promise((resolve3) => {
1401
+ transport.onclose = () => resolve3();
1452
1402
  });
1453
1403
  await server.connect(transport);
1454
1404
  await closed;
@@ -1457,9 +1407,9 @@ async function runMcpServer(opts) {
1457
1407
  // src/cli/commands/mcp.ts
1458
1408
  async function mcpCommand() {
1459
1409
  const readCredentials = createReadCredentials(() => getDefaultKeychain());
1460
- const onStdinEnd = new Promise((resolve2) => {
1461
- process.stdin.once("end", resolve2);
1462
- process.stdin.once("close", resolve2);
1410
+ const onStdinEnd = new Promise((resolve3) => {
1411
+ process.stdin.once("end", resolve3);
1412
+ process.stdin.once("close", resolve3);
1463
1413
  });
1464
1414
  try {
1465
1415
  await Promise.race([runMcpServer({ readCredentials }), onStdinEnd]);
@@ -1615,7 +1565,7 @@ function mcpInstallClaudeCodeCommand(opts = {}) {
1615
1565
  process.stdout.write(
1616
1566
  `Installed pyx-memory MCP server in Claude Code (scope: ${scope}).
1617
1567
  Restart Claude Code to make the tools available. No API key was written to .mcp.json \u2014 credentials live in the OS credential store.
1618
- To populate the knowledge graph, pass entities and relationships when you call store_memory \u2014 the server does not auto-extract them. Sampling-capable MCP clients can fill them from your own LLM; Claude Code does not advertise sampling, so extract and pass them yourself.
1568
+ To populate the knowledge graph, YOU pass entities and relationships (or triples) \u2014 a multi-entity store needs at least one connecting edge or it is refused. The server does not extract for you unless a self-host operator configured a BYO extraction endpoint (then store_memory can forward extractEntities:true). Images require caller-provided descriptions/hooks.
1619
1569
  `
1620
1570
  );
1621
1571
  return EXIT.OK;
@@ -1919,6 +1869,183 @@ function writeJsonAndReport(filePath, agentLabel, opts = {}) {
1919
1869
  return result.exitCode;
1920
1870
  }
1921
1871
 
1872
+ // src/cli/commands/scaffold.ts
1873
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
1874
+ import { basename as basename2, join as join2, resolve as resolve2 } from "path";
1875
+ var SERVER_IMAGE = "ghcr.io/pyx-corp/pyx-memory-v1:latest";
1876
+ var DOCKER_COMPOSE = `services:
1877
+ pyx-memory:
1878
+ image: ${SERVER_IMAGE}
1879
+ ports:
1880
+ - "7822:7822"
1881
+ volumes:
1882
+ - pyx-memory-data:/data
1883
+ environment:
1884
+ DATA_DIR: "\${DATA_DIR:-/data}"
1885
+ API_KEY: "\${API_KEY:-}"
1886
+ ADMIN_API_KEY: "\${ADMIN_API_KEY:-}"
1887
+ TENANT_MODE: "\${TENANT_MODE:-single}"
1888
+ NEO4J_URL: "\${NEO4J_URL:-bolt://neo4j:7687}"
1889
+ NEO4J_USERNAME: "\${NEO4J_USERNAME:-neo4j}"
1890
+ NEO4J_PASSWORD: "\${NEO4J_PASSWORD:?Set NEO4J_PASSWORD in .env or the shell.}"
1891
+ EMBEDDING_PROVIDER: "\${EMBEDDING_PROVIDER:-local}"
1892
+ EMBEDDING_ENDPOINT: "\${EMBEDDING_ENDPOINT:-}"
1893
+ EMBEDDING_DIMENSIONS: "\${EMBEDDING_DIMENSIONS:-}"
1894
+ # Graph is built by the CALLING AGENT by default (it passes entities +
1895
+ # relationships / triples) \u2014 the server runs no LLM. To opt into
1896
+ # server-side BYO extraction, set EXTRACTION_PROVIDER=http and supply
1897
+ # EXTRACTION_ENDPOINT in .env.
1898
+ EXTRACTION_PROVIDER: "\${EXTRACTION_PROVIDER:-none}"
1899
+ EXTRACTION_ENDPOINT: "\${EXTRACTION_ENDPOINT:-}"
1900
+ EXTRACTION_API_KEY: "\${EXTRACTION_API_KEY:-}"
1901
+ EXTRACTION_MODEL: "\${EXTRACTION_MODEL:-}"
1902
+ ENCRYPTION_KEY: "\${ENCRYPTION_KEY:-}"
1903
+ depends_on:
1904
+ neo4j:
1905
+ condition: service_healthy
1906
+
1907
+ neo4j:
1908
+ image: neo4j:5-community
1909
+ ports:
1910
+ - "7474:7474"
1911
+ - "7687:7687"
1912
+ volumes:
1913
+ - pyx-memory-neo4j-data:/data
1914
+ - pyx-memory-neo4j-logs:/logs
1915
+ environment:
1916
+ NEO4J_AUTH: "neo4j/\${NEO4J_PASSWORD:?Set NEO4J_PASSWORD in .env or the shell.}"
1917
+ NEO4J_PLUGINS: '["apoc"]'
1918
+ healthcheck:
1919
+ test: ["CMD", "neo4j", "status"]
1920
+ interval: 10s
1921
+ timeout: 10s
1922
+ retries: 10
1923
+
1924
+ volumes:
1925
+ pyx-memory-data:
1926
+ pyx-memory-neo4j-data:
1927
+ pyx-memory-neo4j-logs:
1928
+ `;
1929
+ var ENV_EXAMPLE = `# pyx-memory operator config
1930
+ # Copy this file to .env, then fill the required values before running Docker Compose.
1931
+
1932
+ # OPTIONAL. Graph is built by the calling agent by default (the server runs no LLM).
1933
+ # Set to http ONLY to opt into server-side BYO entity/relationship extraction.
1934
+ EXTRACTION_PROVIDER=none
1935
+
1936
+ # REQUIRED only when EXTRACTION_PROVIDER=http. HTTP chat/completions-compatible base URL.
1937
+ # Leave empty for the default agent-owns-the-graph setup.
1938
+ EXTRACTION_ENDPOINT=
1939
+
1940
+ # OPTIONAL. Bearer token sent to EXTRACTION_ENDPOINT when your extraction service requires auth.
1941
+ EXTRACTION_API_KEY=
1942
+
1943
+ # OPTIONAL. Model identifier sent to the extraction service.
1944
+ EXTRACTION_MODEL=
1945
+
1946
+ # OPTIONAL for local embeddings. Set to http only when using a remote embedding service.
1947
+ EMBEDDING_PROVIDER=local
1948
+
1949
+ # REQUIRED only when EMBEDDING_PROVIDER=http. Base URL for the HTTP embedding service.
1950
+ EMBEDDING_ENDPOINT=
1951
+
1952
+ # REQUIRED only when EMBEDDING_PROVIDER=http. Must match the remote embedding vector width.
1953
+ # Example for embeddinggemma: 768.
1954
+ EMBEDDING_DIMENSIONS=
1955
+
1956
+ # RECOMMENDED for any protected or production deployment. Empty means unauthenticated local access.
1957
+ API_KEY=
1958
+
1959
+ # OPTIONAL. Destructive/admin operations use this; when empty, the server falls back to API_KEY.
1960
+ ADMIN_API_KEY=
1961
+
1962
+ # REQUIRED only when SENSITIVITY_POLICY=encrypt is configured on the server.
1963
+ # 32 bytes as 64 hex chars or 44 base64 chars.
1964
+ ENCRYPTION_KEY=
1965
+
1966
+ # REQUIRED by the scaffolded container.
1967
+ DATA_DIR=/data
1968
+
1969
+ # REQUIRED. Use single for one app/tenant; use multi only behind authenticated tenant routing.
1970
+ TENANT_MODE=single
1971
+
1972
+ # REQUIRED for graph storage in this compose stack.
1973
+ NEO4J_URL=bolt://neo4j:7687
1974
+
1975
+ # OPTIONAL. Defaults to neo4j when unset.
1976
+ NEO4J_USERNAME=neo4j
1977
+
1978
+ # REQUIRED by docker-compose.yml for the Neo4j service and pyx-memory graph connection.
1979
+ NEO4J_PASSWORD=
1980
+ `;
1981
+ var MEMORY_TS = `import { createPyxMemory } from '@pyxmate/memory';
1982
+
1983
+ // Reads PYX_MEMORY_URL and PYX_MEMORY_API_KEY by default.
1984
+ export const memory = createPyxMemory();
1985
+ `;
1986
+ function resolveTarget(args) {
1987
+ const cwd = args.cwd ?? process.cwd();
1988
+ if (args.name === true) {
1989
+ process.stderr.write("Error: --name requires a directory name.\n");
1990
+ return null;
1991
+ }
1992
+ const trimmedName = typeof args.name === "string" ? args.name.trim() : "";
1993
+ if (typeof args.name === "string" && trimmedName.length === 0) {
1994
+ process.stderr.write("Error: --name requires a non-empty directory name.\n");
1995
+ return null;
1996
+ }
1997
+ const targetDir = trimmedName ? resolve2(cwd, trimmedName) : cwd;
1998
+ const appName = trimmedName || basename2(resolve2(cwd));
1999
+ return { targetDir, appName };
2000
+ }
2001
+ function buildFiles(targetDir, appName) {
2002
+ return [
2003
+ { path: join2(targetDir, "docker-compose.yml"), contents: DOCKER_COMPOSE },
2004
+ { path: join2(targetDir, ".env.example"), contents: ENV_EXAMPLE },
2005
+ { path: join2(targetDir, "memory.ts"), contents: MEMORY_TS },
2006
+ {
2007
+ path: join2(targetDir, "PYX_MEMORY_DESIGN_GUIDE.md"),
2008
+ contents: buildDesignGuide({ appName })
2009
+ }
2010
+ ];
2011
+ }
2012
+ function scaffoldCommand(args = {}) {
2013
+ const target = resolveTarget(args);
2014
+ if (target === null) return EXIT.USAGE;
2015
+ if (existsSync2(target.targetDir) && !statSync(target.targetDir).isDirectory()) {
2016
+ process.stderr.write(`Error: target exists and is not a directory: ${target.targetDir}
2017
+ `);
2018
+ return EXIT.USAGE;
2019
+ }
2020
+ mkdirSync2(target.targetDir, { recursive: true });
2021
+ const created = [];
2022
+ const skipped = [];
2023
+ for (const file of buildFiles(target.targetDir, target.appName)) {
2024
+ const relativePath = basename2(file.path);
2025
+ if (existsSync2(file.path)) {
2026
+ skipped.push(relativePath);
2027
+ continue;
2028
+ }
2029
+ writeFileSync2(file.path, file.contents, "utf8");
2030
+ created.push(relativePath);
2031
+ }
2032
+ process.stdout.write(
2033
+ [
2034
+ `pyx-memory scaffold: ${target.targetDir}`,
2035
+ `created: ${created.length > 0 ? created.join(", ") : "(none)"}`,
2036
+ `skipped: ${skipped.length > 0 ? skipped.join(", ") : "(none)"}`,
2037
+ "",
2038
+ "Next steps:",
2039
+ "1. Copy .env.example to .env and set NEO4J_PASSWORD (graph is built by the calling agent by default \u2014 set EXTRACTION_* only to opt into server-side BYO extraction).",
2040
+ "2. Set API_KEY before using this beyond local development.",
2041
+ "3. Run: docker compose up -d",
2042
+ "4. Set PYX_MEMORY_URL=http://localhost:7822 and PYX_MEMORY_API_KEY to match API_KEY.",
2043
+ ""
2044
+ ].join("\n")
2045
+ );
2046
+ return EXIT.OK;
2047
+ }
2048
+
1922
2049
  // src/cli/commands/status.ts
1923
2050
  async function statusCommand(opts = {}) {
1924
2051
  const provider = opts.keychain ?? getDefaultKeychain();
@@ -1973,6 +2100,7 @@ Commands:
1973
2100
  status [--json] Show endpoint, key presence, MCP config status.
1974
2101
  logout Delete stored pyx-memory credentials.
1975
2102
  doctor [--json] Diagnose keychain, credentials, backend, MCP startup.
2103
+ scaffold [--name <dir>] Generate Docker, env, SDK, and memory design-guide starter files.
1976
2104
  mcp Start stdio MCP server.
1977
2105
  mcp install <target> [--scope user|local|project]
1978
2106
  Install pyx-memory MCP config for your AI agent.
@@ -2089,6 +2217,8 @@ async function main() {
2089
2217
  return logoutCommand();
2090
2218
  case "doctor":
2091
2219
  return doctorCommand({ json: parsed.flags.json === true });
2220
+ case "scaffold":
2221
+ return scaffoldCommand({ name: parsed.flags.name });
2092
2222
  case "mcp":
2093
2223
  return runMcpCommand(parsed);
2094
2224
  default: