archgraph-argo 0.10.31 → 0.10.33

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.
@@ -66,6 +66,7 @@ const SYSTEM_ARCHITECTURE_TOOL_NAMES = new Set([
66
66
  'removeArchitectureView',
67
67
  'generateArchitectureDiffPlantuml',
68
68
  'queryNeo4jGraph',
69
+ 'memory_search',
69
70
  ]);
70
71
 
71
72
  const TOOLS = [
@@ -36,7 +36,7 @@ const APPROVED_PROFILE = Object.freeze({
36
36
  model: 'qwen3.7-text-embedding',
37
37
  provider: 'alibaba-cloud-model-studio-openai-compatible-cn-beijing',
38
38
  version: 'qualification-2026-07-25',
39
- dimensions: 1024,
39
+ dimensions: 1536,
40
40
  });
41
41
  const CHANNELS = Object.freeze([
42
42
  Object.freeze({
@@ -80,7 +80,37 @@ const READINESS_QUERY_CYPHER = [
80
80
  'RETURN properties(readiness) AS readiness',
81
81
  ].join('\n');
82
82
  const INITIAL_WINDOW_SIZE = 2;
83
+ // Purpose-aware, env-configurable similarity thresholds + bounded top-K recall.
84
+ // Audit keeps the strict threshold (precision); memory-retrieval purposes use a
85
+ // looser threshold so relevant-but-paraphrased memory is recalled, while results
86
+ // are bounded to ARGO_SEMANTIC_TOP_K so noise stays bounded. Thresholds are
87
+ // overridable per channel via ARGO_SEMANTIC_{MEMORY|AUDIT}_THRESHOLD_<CHANNEL>.
88
+ const AUDIT_PURPOSES = new Set(['audit']);
89
+ const DEFAULT_MEMORY_THRESHOLD = 0.55;
90
+ const DEFAULT_AUDIT_THRESHOLD = 0.8;
91
+ const DEFAULT_TOP_K = 8;
83
92
  const SELECTED_VIEW_ID = 'semprod-wp2-default-retrieval-readiness';
93
+
94
+ function envNumber(key, fallback) {
95
+ const value = Number(process.env[key]);
96
+ return Number.isFinite(value) ? value : fallback;
97
+ }
98
+ function memoryThresholdFor(channel) {
99
+ return envNumber(
100
+ `ARGO_SEMANTIC_MEMORY_THRESHOLD_${channel.channel.toUpperCase()}`,
101
+ envNumber('ARGO_SEMANTIC_MEMORY_THRESHOLD', DEFAULT_MEMORY_THRESHOLD),
102
+ );
103
+ }
104
+ function auditThresholdFor(channel) {
105
+ return envNumber(
106
+ `ARGO_SEMANTIC_AUDIT_THRESHOLD_${channel.channel.toUpperCase()}`,
107
+ envNumber('ARGO_SEMANTIC_AUDIT_THRESHOLD', DEFAULT_AUDIT_THRESHOLD),
108
+ );
109
+ }
110
+ function resolveTopK() {
111
+ const k = envNumber('ARGO_SEMANTIC_TOP_K', DEFAULT_TOP_K);
112
+ return Number.isInteger(k) && k > 0 ? k : DEFAULT_TOP_K;
113
+ }
84
114
  const testCompositionStorage = new AsyncLocalStorage();
85
115
 
86
116
  function createDefaultSemanticRetrieval(dependencies = {}) {
@@ -173,12 +203,17 @@ async function executeWpP2Retrieval({
173
203
  });
174
204
  const vector = await provider.embed(request.intent);
175
205
  requireQualifiedVector(vector);
206
+ const purpose = request && typeof request.purpose === 'string' ? request.purpose : '';
207
+ const strict = AUDIT_PURPOSES.has(purpose);
208
+ const topK = resolveTopK();
176
209
  const seedsByType = {};
177
210
  for (const channel of CHANNELS) {
178
211
  seedsByType[channel.key] = await exhaustChannel({
179
212
  channel,
180
213
  neo4jDriver: composition.neo4jDriver,
181
214
  vector,
215
+ threshold: strict ? auditThresholdFor(channel) : memoryThresholdFor(channel),
216
+ maxSeeds: topK,
182
217
  ...(Array.isArray(canonicalIdentities) && canonicalIdentities.length > 0
183
218
  ? { canonicalIdentities }
184
219
  : {}),
@@ -620,12 +655,24 @@ function publicReadinessOutcome(alignment) {
620
655
  };
621
656
  }
622
657
 
623
- async function exhaustChannel({ channel, neo4jDriver, vector, canonicalIdentities }) {
658
+ async function exhaustChannel({
659
+ channel,
660
+ neo4jDriver,
661
+ vector,
662
+ canonicalIdentities,
663
+ threshold,
664
+ maxSeeds,
665
+ }) {
624
666
  const scoped = Array.isArray(canonicalIdentities) && canonicalIdentities.length > 0;
667
+ const scopedCanonicalIdentities = scoped
668
+ ? scopeCanonicalIdentitiesForChannel(canonicalIdentities, channel)
669
+ : canonicalIdentities;
625
670
  const accepted = [];
626
671
  const seen = new Set();
672
+ const effectiveThreshold = typeof threshold === 'number' ? threshold : channel.threshold;
673
+ const effectiveMax = Number.isInteger(maxSeeds) && maxSeeds > 0 ? maxSeeds : Number.POSITIVE_INFINITY;
627
674
  let offset = 0;
628
- while (true) {
675
+ while (accepted.length < effectiveMax) {
629
676
  const parameters = Object.freeze({
630
677
  indexName: channel.indexName,
631
678
  channel: channel.channel,
@@ -633,7 +680,7 @@ async function exhaustChannel({ channel, neo4jDriver, vector, canonicalIdentitie
633
680
  windowSize: INITIAL_WINDOW_SIZE,
634
681
  topK: offset + INITIAL_WINDOW_SIZE,
635
682
  vector,
636
- ...(scoped ? { canonicalIdentities } : {}),
683
+ ...(scoped ? { canonicalIdentities: scopedCanonicalIdentities } : {}),
637
684
  });
638
685
  const result = await neo4jDriver.execute(Object.freeze({
639
686
  kind: 'semantic-vector-window-query',
@@ -645,8 +692,9 @@ async function exhaustChannel({ channel, neo4jDriver, vector, canonicalIdentitie
645
692
  const records = Array.isArray(result && result.records) ? result.records : [];
646
693
  const newlyVisible = records.slice(offset);
647
694
  for (const raw of newlyVisible) {
695
+ if (accepted.length >= effectiveMax) break;
648
696
  const record = normalizeVectorRecord(raw, channel);
649
- if (record && record.score >= channel.threshold && !seen.has(record.id)) {
697
+ if (record && record.score >= effectiveThreshold && !seen.has(record.id)) {
650
698
  seen.add(record.id);
651
699
  accepted.push(Object.freeze(record));
652
700
  }
@@ -661,6 +709,22 @@ async function exhaustChannel({ channel, neo4jDriver, vector, canonicalIdentitie
661
709
  return Object.freeze(accepted);
662
710
  }
663
711
 
712
+ // Scope identities come from the canonical graph as BARE ids (e.g.
713
+ // "memory-eval-bench-wp-001"), but the semantic vector records store their
714
+ // canonicalIdentity with a channel prefix (e.g. "Element:memory-eval-bench-wp-001").
715
+ // Normalise the scope to the channel-prefixed form so the scoped Cypher
716
+ // `node.canonicalIdentity IN $canonicalIdentities` actually matches. A bare id
717
+ // that already carries a prefix (this or another channel's) is left as-is: it
718
+ // simply won't match records of this channel, which is the correct no-op.
719
+ function scopeCanonicalIdentitiesForChannel(identities, channel) {
720
+ if (!Array.isArray(identities)) return identities;
721
+ const prefix = `${channel.objectType}:`;
722
+ return identities.map(id => {
723
+ const text = String(id);
724
+ return text.includes(':') ? text : `${prefix}${text}`;
725
+ });
726
+ }
727
+
664
728
  function normalizeVectorRecord(raw, channel) {
665
729
  if (!raw || typeof raw !== 'object') return undefined;
666
730
  const id = raw.canonicalIdentity || raw.id || raw.objectId;
@@ -979,4 +1043,9 @@ function safeError(category) {
979
1043
  module.exports = {
980
1044
  createDefaultSemanticRetrieval,
981
1045
  withDefaultSemanticRetrievalTestComposition,
1046
+ memoryThresholdFor,
1047
+ auditThresholdFor,
1048
+ resolveTopK,
1049
+ scopeCanonicalIdentitiesForChannel,
1050
+ AUDIT_PURPOSES,
982
1051
  };
@@ -46,7 +46,7 @@ function createLiveEmbeddingIndexGate(dependencies = {}) {
46
46
  requireInput(input);
47
47
  const vector = await client.embed(input.input);
48
48
  if (
49
- vector.length !== 1024
49
+ vector.length !== 1536
50
50
  || vector.some(value => typeof value !== 'number' || !Number.isFinite(value))
51
51
  ) {
52
52
  throw safeError('LIVE_PROVIDER_RESPONSE_INVALID');
@@ -79,7 +79,7 @@ function requireApprovedConfiguration(configuration, qualification) {
79
79
  || qualification.model !== configuration.embeddingModel
80
80
  || qualification.version !== configuration.embeddingModelVersion
81
81
  || qualification.dimensions !== configuration.embeddingDimensions
82
- || qualification.dimensions !== 1024
82
+ || qualification.dimensions !== 1536
83
83
  ) {
84
84
  throw safeError('LIVE_PROVIDER_INDEX_WRITE_PROHIBITED');
85
85
  }
@@ -64,11 +64,11 @@ async function createApprovedNeo4jBoundary({ configuration, neo4j, logger }) {
64
64
  return result.records.map(record => normalizeNeo4jValue(record.get('evidence')));
65
65
  },
66
66
  async queryVectorEvidence(runId, vector, canonicalIdentities) {
67
- if (!Array.isArray(vector) || vector.length !== 1024 || !Array.isArray(canonicalIdentities)) {
67
+ if (!Array.isArray(vector) || vector.length !== 1536 || !Array.isArray(canonicalIdentities)) {
68
68
  throw safeError('LIVE_PROVIDER_OPERATION_FAILED');
69
69
  }
70
70
  await query(
71
- 'CREATE VECTOR INDEX argo_live_embedding_vector IF NOT EXISTS FOR (e:ArgoLiveEmbeddingEvidence) ON (e.vector) OPTIONS { indexConfig: { `vector.dimensions`: 1024, `vector.similarity_function`: "cosine" } }',
71
+ 'CREATE VECTOR INDEX argo_live_embedding_vector IF NOT EXISTS FOR (e:ArgoLiveEmbeddingEvidence) ON (e.vector) OPTIONS { indexConfig: { `vector.dimensions`: 1536, `vector.similarity_function`: "cosine" } }',
72
72
  {},
73
73
  );
74
74
  const result = await query(
@@ -21,6 +21,20 @@ const CONFIG_KEYS = Object.freeze([
21
21
  const OPTIONAL_CONFIG_KEYS = Object.freeze([
22
22
  'ARGO_NEO4J_DATABASE',
23
23
  ]);
24
+ // Semantic-retrieval tuning knobs (purpose-aware thresholds + bounded top-K).
25
+ // They are read by defaultSemanticRetrieval from process.env and whitelisted here
26
+ // so an approved env file may carry them without tripping SECRET_FILE_UNKNOWN_KEY.
27
+ const RETRIEVAL_TUNING_KEYS = Object.freeze([
28
+ 'ARGO_SEMANTIC_MEMORY_THRESHOLD',
29
+ 'ARGO_SEMANTIC_MEMORY_THRESHOLD_ELEMENT',
30
+ 'ARGO_SEMANTIC_MEMORY_THRESHOLD_RELATIONSHIP',
31
+ 'ARGO_SEMANTIC_MEMORY_THRESHOLD_VIEW',
32
+ 'ARGO_SEMANTIC_AUDIT_THRESHOLD',
33
+ 'ARGO_SEMANTIC_AUDIT_THRESHOLD_ELEMENT',
34
+ 'ARGO_SEMANTIC_AUDIT_THRESHOLD_RELATIONSHIP',
35
+ 'ARGO_SEMANTIC_AUDIT_THRESHOLD_VIEW',
36
+ 'ARGO_SEMANTIC_TOP_K',
37
+ ]);
24
38
  const OPT_IN_KEYS = Object.freeze({
25
39
  ARGO_LIVE_PROVIDER_E2E: 'LIVE_PROVIDER_E2E_OPT_IN_REQUIRED',
26
40
  ARGO_W31_LIVE_MUTATION_VECTOR_E2E: 'W31_MUTATION_VECTOR_E2E_OPT_IN_REQUIRED',
@@ -28,6 +42,7 @@ const OPT_IN_KEYS = Object.freeze({
28
42
  const READABLE_KEYS = Object.freeze([
29
43
  ...CONFIG_KEYS,
30
44
  ...OPTIONAL_CONFIG_KEYS,
45
+ ...RETRIEVAL_TUNING_KEYS,
31
46
  ...Object.keys(OPT_IN_KEYS),
32
47
  ]);
33
48
  const LEGACY_KEYS = Object.freeze(['ARGO_NEO4J_URI', 'ARGO_NEO4J_USERNAME', 'ARGO_NEO4J_PASSWORD']);
@@ -38,7 +53,7 @@ const APPROVED = Object.freeze({
38
53
  ARGO_EMBEDDING_MODEL: 'qwen3.7-text-embedding',
39
54
  ARGO_EMBEDDING_PROVIDER: 'alibaba-cloud-model-studio-openai-compatible-cn-beijing',
40
55
  ARGO_EMBEDDING_MODEL_VERSION: 'qualification-2026-07-25',
41
- ARGO_EMBEDDING_DIMENSIONS: '1024',
56
+ ARGO_EMBEDDING_DIMENSIONS: '1536',
42
57
  });
43
58
  const issuedAdapters = new WeakSet();
44
59
  const issuedTraces = new WeakSet();
@@ -167,7 +182,7 @@ async function resolveTrusted({
167
182
  embeddingModel: normalized.ARGO_EMBEDDING_MODEL,
168
183
  embeddingProvider: normalized.ARGO_EMBEDDING_PROVIDER,
169
184
  embeddingModelVersion: normalized.ARGO_EMBEDDING_MODEL_VERSION,
170
- embeddingDimensions: 1024,
185
+ embeddingDimensions: 1536,
171
186
  neo4jDatabaseUrl: normalized.ARGO_NEO4J_DATABASE_URL,
172
187
  neo4jDatabaseUsername: normalized.ARGO_NEO4J_DATABASE_USERNAME,
173
188
  neo4jDatabasePassword: normalized.ARGO_NEO4J_DATABASE_PASSWORD,
@@ -342,13 +357,15 @@ function preflightFile({ canonicalFilePath, configuredFilePath, filesystem, adap
342
357
  const insideRepository = adapters.systemMetadata.isSecretFileInsideGitRepository().status === 0;
343
358
  ignored = insideRepository ? adapters.systemMetadata.isSecretFileIgnored() : true;
344
359
  tracked = insideRepository ? adapters.systemMetadata.isSecretFileTracked() : false;
345
- const identityResult = adapters.systemMetadata.readCurrentIdentity();
346
- const aclResult = adapters.systemMetadata.readSecretFileAcl();
347
- aclEvidence = {
348
- status: aclResult.status,
349
- stdout: aclResult.stdout,
350
- identity: identityResult.status === 0 ? identityResult.stdout.trim() : '',
351
- };
360
+ if (process.platform === 'win32') {
361
+ const identityResult = adapters.systemMetadata.readCurrentIdentity();
362
+ const aclResult = adapters.systemMetadata.readSecretFileAcl();
363
+ aclEvidence = {
364
+ status: aclResult.status,
365
+ stdout: aclResult.stdout,
366
+ identity: identityResult.status === 0 ? identityResult.stdout.trim() : '',
367
+ };
368
+ }
352
369
  } else {
353
370
  ignored = adapters.git.isIgnored(configuredFilePath);
354
371
  tracked = adapters.git.isTracked(configuredFilePath);
@@ -356,7 +373,11 @@ function preflightFile({ canonicalFilePath, configuredFilePath, filesystem, adap
356
373
  }
357
374
  if (tracked) throw safeError('SECRET_FILE_TRACKED');
358
375
  if (!ignored) throw safeError('SECRET_FILE_NOT_IGNORED');
359
- validateAcl(aclEvidence);
376
+ if (process.platform === 'win32') {
377
+ validateAcl(aclEvidence);
378
+ } else {
379
+ validatePosixSecretFileAcl(configuredFilePath, filesystem);
380
+ }
360
381
  }
361
382
 
362
383
  function validateAcl(result) {
@@ -389,6 +410,20 @@ function parseAcl(output) {
389
410
  return result;
390
411
  }
391
412
 
413
+ function posixModeIsSecretSafe(mode) {
414
+ return (mode & 0o077) === 0 && (mode & 0o600) === 0o600;
415
+ }
416
+
417
+ function validatePosixSecretFileAcl(configuredFilePath, filesystem) {
418
+ const stat = filesystem.lstatSync(configuredFilePath);
419
+ if (!posixModeIsSecretSafe(stat.mode)) {
420
+ throw safeError('SECRET_FILE_ACL_UNSAFE');
421
+ }
422
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
423
+ throw safeError('SECRET_FILE_ACL_UNSAFE');
424
+ }
425
+ }
426
+
392
427
  function productionSourceBehavior(repositoryRoot) {
393
428
  return Object.freeze({
394
429
  expectedFilePath: getArgoEnvPath(),
@@ -478,4 +513,5 @@ function safeError(category) {
478
513
  module.exports = {
479
514
  resolveApprovedLiveConfiguration,
480
515
  withApprovedLiveConfigurationTestComposition,
516
+ posixModeIsSecretSafe,
481
517
  };
@@ -1143,7 +1143,7 @@ function createSyntheticVectorTransport() {
1143
1143
  return {
1144
1144
  data: [
1145
1145
  {
1146
- embedding: Array.from({ length: 1024 }, (_, index) => index / 2048),
1146
+ embedding: Array.from({ length: 1536 }, (_, index) => index / 2048),
1147
1147
  },
1148
1148
  ],
1149
1149
  };
@@ -15,6 +15,8 @@ function createProductionSemanticBackfill(dependencies = {}) {
15
15
  requireBoundary(dependencies.structuralProjection, 'requireComplete', 'structuralProjection');
16
16
  requireBoundary(dependencies.embeddingProvider, 'embedBatch', 'embeddingProvider');
17
17
  requireBoundary(dependencies.projectionStore, 'upsertRecords', 'projectionStore');
18
+ requireBoundary(dependencies.projectionStore, 'readRecords', 'projectionStore');
19
+ requireBoundary(dependencies.projectionStore, 'deleteTombstones', 'projectionStore');
18
20
  requireBoundary(dependencies.checkpointStore, 'readCheckpoint', 'checkpointStore');
19
21
  requireBoundary(dependencies.checkpointStore, 'writeCheckpoint', 'checkpointStore');
20
22
  const batchSize = dependencies.batchSize;
@@ -55,6 +57,11 @@ function createProductionSemanticBackfill(dependencies = {}) {
55
57
  checkpointStore: dependencies.checkpointStore,
56
58
  });
57
59
  }
60
+ const reconciled = await reconcileRemovals({
61
+ snapshot,
62
+ canonicalVersion,
63
+ projectionStore: dependencies.projectionStore,
64
+ });
58
65
  const aligned = CHANNELS.every(channel => (
59
66
  channels[channel].status === 'complete'
60
67
  && channels[channel].canonicalVersion === canonicalVersion
@@ -64,6 +71,7 @@ function createProductionSemanticBackfill(dependencies = {}) {
64
71
  canonicalVersion,
65
72
  alignmentState: aligned ? 'Aligned' : 'Updating',
66
73
  channels: Object.freeze(channels),
74
+ reconciled,
67
75
  });
68
76
  },
69
77
  });
@@ -204,6 +212,39 @@ function mergeFailures(existing, current) {
204
212
  return [...merged.values()];
205
213
  }
206
214
 
215
+ // Full-reconciliation removal pass: after all channels are upserted, delete any
216
+ // persisted semantic record whose canonical identity no longer exists in the
217
+ // local JSON snapshot. This guarantees argo-init rebuilds a semantic projection
218
+ // that is exactly consistent with the canonical graph (no stale vectors).
219
+ async function reconcileRemovals({ snapshot, canonicalVersion, projectionStore }) {
220
+ const currentByChannel = new Map();
221
+ for (const channel of CHANNELS) {
222
+ const source = CHANNEL_SOURCES[channel];
223
+ const identities = new Set(
224
+ (snapshot[source.property] || []).map(record => `${channel}:${source.identity(record)}`),
225
+ );
226
+ currentByChannel.set(channel, identities);
227
+ }
228
+ const stored = await projectionStore.readRecords();
229
+ const allStored = Array.isArray(stored) ? stored : [];
230
+ const stale = allStored.filter(record => {
231
+ const identities = currentByChannel.get(record.channel);
232
+ return identities && !identities.has(record.canonicalIdentity);
233
+ });
234
+ const tombstones = stale.map(record => Object.freeze({
235
+ canonicalIdentity: record.canonicalIdentity,
236
+ channel: record.channel,
237
+ canonicalVersion: record.canonicalVersion || canonicalVersion,
238
+ }));
239
+ if (tombstones.length > 0) {
240
+ await projectionStore.deleteTombstones(tombstones);
241
+ }
242
+ return Object.freeze({
243
+ scanned: allStored.length,
244
+ tombstoned: tombstones.length,
245
+ });
246
+ }
247
+
207
248
  function requireBoundary(boundary, method, name) {
208
249
  if (!boundary || typeof boundary[method] !== 'function') {
209
250
  throw new TypeError(`${name}.${method} is required`);
@@ -90,14 +90,31 @@ function createProductionSemanticNeo4jAdapter(dependencies = {}) {
90
90
  });
91
91
  }
92
92
 
93
+ const EMBEDDING_DIMENSIONS = 1536;
94
+
93
95
  async function ensureVectorIndexes(session) {
94
96
  for (const definition of Object.values(CHANNEL_INDEXES)) {
97
+ // CREATE ... IF NOT EXISTS cannot change an existing index's dimensions, so
98
+ // when the approved embedding dimension changes (e.g. 1024 -> 1536) drop the
99
+ // stale index first and recreate it at the approved dimension.
100
+ const existing = await executeRead(
101
+ session,
102
+ 'SHOW INDEXES YIELD name, type, options WHERE type = \'VECTOR\' AND name = $name RETURN name, options',
103
+ { name: definition.indexName },
104
+ );
105
+ if (existing.records.length > 0) {
106
+ const options = existing.records[0].get('options');
107
+ const dim = options && options.indexConfig && options.indexConfig['vector.dimensions'];
108
+ if (dim !== EMBEDDING_DIMENSIONS) {
109
+ await executeWrite(session, `DROP INDEX ${definition.indexName} IF EXISTS`, {});
110
+ }
111
+ }
95
112
  await executeWrite(
96
113
  session,
97
114
  [
98
115
  `CREATE VECTOR INDEX ${definition.indexName} IF NOT EXISTS`,
99
116
  `FOR (semantic:${definition.label}) ON (semantic.vector)`,
100
- 'OPTIONS { indexConfig: { `vector.dimensions`: 1024, `vector.similarity_function`: "cosine" } }',
117
+ `OPTIONS { indexConfig: { \`vector.dimensions\`: ${EMBEDDING_DIMENSIONS}, \`vector.similarity_function\`: "cosine" } }`,
101
118
  ].join('\n'),
102
119
  {},
103
120
  );
@@ -128,7 +128,7 @@ const W31_APPROVED_PROFILE = Object.freeze({
128
128
  baseUrl: 'https://llm-clids9mqc5o1mbvb.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
129
129
  model: 'qwen3.7-text-embedding',
130
130
  version: 'qualification-2026-07-25',
131
- dimensions: 1024,
131
+ dimensions: 1536,
132
132
  source: 'explicit-human-approval',
133
133
  });
134
134
 
@@ -406,6 +406,20 @@ const TOOLS = [
406
406
  additionalProperties: false,
407
407
  },
408
408
  },
409
+ {
410
+ name: 'memory_search',
411
+ description: 'Semantic memory search: retrieve the user\'s memory by natural-language query. Runs embedding-based semantic retrieval (memory-retrieval purpose) over the intent graph and returns the top-k relevant memory items (id, name, type, similarity score, and a description excerpt with its full length). Each hit is a lightweight card: to keep the tool result compact, description is truncated to max_desc_len characters (default 800) and the full text length is reported as description_length. To read the full text of a hit, call getIntentElementContext with its id. Agents SHOULD call this tool to look up the user\'s memory before answering a question about the user.',
412
+ inputSchema: {
413
+ type: 'object',
414
+ properties: {
415
+ query: { type: 'string', description: 'Natural-language query describing the user memory to retrieve.' },
416
+ top_k: { type: 'integer', description: 'Optional max number of hits to return (default 8).' },
417
+ max_desc_len: { type: 'integer', description: 'Optional max characters of description to return per hit. Default 800. Use 0 for the full description, or -1 to omit description and return only the length.' },
418
+ architecturePath: { type: 'string', description: `Default: ${DEFAULT_GRAPH_PATH}` },
419
+ },
420
+ additionalProperties: false,
421
+ },
422
+ },
409
423
  ];
410
424
 
411
425
  // Every tool accepts an optional per-call `workspaceRoot` (absolute path,
@@ -2188,9 +2202,91 @@ async function callTool(name, args = {}, dependencies = undefined) {
2188
2202
  return queryNeo4jGraphTool(args);
2189
2203
  }
2190
2204
 
2205
+ if (name === 'memory_search') {
2206
+ return memorySearchTool(args, dependencies);
2207
+ }
2208
+
2191
2209
  throw new Error(`Unknown tool: ${name}`);
2192
2210
  }
2193
2211
 
2212
+ // memory_search: natural-language semantic memory retrieval. Reuses the
2213
+ // memory-retrieval purpose (implementation-design) of the semantic journey
2214
+ // (same path as getSystemArchitecture, so readiness/closure behave identically)
2215
+ // and reshapes the retrieved elements into a memory-oriented hit list with
2216
+ // content + similarity score, so an agent can look up the user's memory in one
2217
+ // call.
2218
+ async function memorySearchTool(args = {}, dependencies = undefined) {
2219
+ const query = typeof args.query === 'string' ? args.query.trim() : '';
2220
+ if (!query) {
2221
+ return { status: 'failed', error: { category: 'MEMORY_QUERY_REQUIRED', message: 'query is required' } };
2222
+ }
2223
+ const topK = Number.isInteger(args.top_k) && args.top_k > 0 ? args.top_k : 8;
2224
+ // max_desc_len: default 800 (compact excerpt). 0 = full description;
2225
+ // -1 = omit description entirely and return only the length. Keeps the tool
2226
+ // result compact (production memory cards, not full session dumps).
2227
+ let maxDescLen = 800;
2228
+ if (Number.isInteger(args.max_desc_len) && args.max_desc_len !== 800) {
2229
+ maxDescLen = args.max_desc_len;
2230
+ }
2231
+ const context = await loadContext(args);
2232
+ let retrieved;
2233
+ try {
2234
+ const journey = await resolveSemanticOperatorJourney(dependencies);
2235
+ retrieved = await journey.query({ purpose: 'implementation-design', intent: query });
2236
+ } catch (error) {
2237
+ return {
2238
+ status: 'failed',
2239
+ error: {
2240
+ category: error && error.category ? error.category : 'MEMORY_RETRIEVAL_FAILED',
2241
+ message: error && error.message ? error.message : 'Memory retrieval failed',
2242
+ },
2243
+ };
2244
+ }
2245
+ const source = retrieved && (retrieved.result || retrieved.document) || retrieved;
2246
+ const subset = buildCanonicalSemanticDocumentSubset(source, context.document);
2247
+ if (subset.status !== 'passed') {
2248
+ return { status: 'failed', error: subset.error || { category: 'MEMORY_RETRIEVAL_FAILED' } };
2249
+ }
2250
+ const hits = (Array.isArray(subset.document && subset.document.elements) ? subset.document.elements : [])
2251
+ .filter(element => element && typeof element.semanticScore === 'number')
2252
+ .sort((left, right) => right.semanticScore - left.semanticScore)
2253
+ .slice(0, topK)
2254
+ .map(element => Object.freeze(memoryHitCard(element, maxDescLen)));
2255
+ return {
2256
+ status: 'passed',
2257
+ query,
2258
+ max_desc_len: maxDescLen,
2259
+ fullTextHint: 'To read the full text of a hit, call getIntentElementContext with its id (elementId).',
2260
+ hits: Object.freeze(hits),
2261
+ };
2262
+ }
2263
+
2264
+ // Build one compact memory hit: id/name/type/score + an excerpt of the
2265
+ // description bounded by maxDescLen, plus the full text length so the caller
2266
+ // knows how much content exists and can decide whether to expand.
2267
+ function memoryHitCard(element, maxDescLen) {
2268
+ const description = typeof element.description === 'string' ? element.description : '';
2269
+ const descriptionLength = description.length;
2270
+ let excerpt = '';
2271
+ if (maxDescLen !== -1) {
2272
+ excerpt = maxDescLen === 0 ? description : description.slice(0, maxDescLen);
2273
+ }
2274
+ const card = {
2275
+ id: element.id,
2276
+ name: element.name,
2277
+ type: element.type,
2278
+ score: element.semanticScore,
2279
+ description_length: descriptionLength,
2280
+ };
2281
+ if (maxDescLen !== -1) {
2282
+ card.description = excerpt;
2283
+ if (maxDescLen > 0 && descriptionLength > maxDescLen) {
2284
+ card.truncated = true;
2285
+ }
2286
+ }
2287
+ return card;
2288
+ }
2289
+
2194
2290
  async function queryNeo4jGraphTool(args = {}) {
2195
2291
  const architecturePath = args.architecturePath || DEFAULT_GRAPH_PATH;
2196
2292
  const workspaceRoot = resolveWorkspaceRoot(args);
@@ -2780,15 +2876,52 @@ function buildCanonicalSemanticDocumentSubset(source, canonicalDocument = undefi
2780
2876
  }
2781
2877
  }
2782
2878
 
2879
+ // Attach the semantic similarity score to each retrieved object so callers can
2880
+ // judge relevance (top-K bounded recall may include borderline neighbours).
2881
+ const semanticScoreById = new Map();
2882
+ const seedsByType = evidence.seedsByType || {};
2883
+ for (const channelSeeds of Object.values(seedsByType)) {
2884
+ for (const seed of Array.isArray(channelSeeds) ? channelSeeds : []) {
2885
+ if (!seed || !seed.id) continue;
2886
+ const numericScore = Number(seed.score);
2887
+ if (!Number.isFinite(numericScore)) continue;
2888
+ const bareId = String(seed.id).replace(/^[^:]*:/, '');
2889
+ if (bareId && (!semanticScoreById.has(bareId) || numericScore > semanticScoreById.get(bareId))) {
2890
+ semanticScoreById.set(bareId, numericScore);
2891
+ }
2892
+ }
2893
+ }
2894
+ // buildCanonicalSemanticDocumentSubset may run a second time over a prior
2895
+ // subset document (which no longer carries seedsByType); carry scores forward
2896
+ // from evidence elements that already expose semanticScore.
2897
+ for (const item of evidenceElements) {
2898
+ if (!item) continue;
2899
+ const numericScore = Number(item.semanticScore);
2900
+ if (!Number.isFinite(numericScore)) continue;
2901
+ const key = String(item.id !== undefined ? item.id : item.view_id);
2902
+ if (key && (!semanticScoreById.has(key) || numericScore > semanticScoreById.get(key))) {
2903
+ semanticScoreById.set(key, numericScore);
2904
+ }
2905
+ }
2906
+ const withSemanticScore = item => {
2907
+ if (!item) return item;
2908
+ const score = item.id !== undefined
2909
+ ? semanticScoreById.get(String(item.id))
2910
+ : semanticScoreById.get(String(item.view_id));
2911
+ return typeof score === 'number' ? Object.freeze({ ...item, semanticScore: score }) : item;
2912
+ };
2783
2913
  const elements = [...elementIds]
2784
2914
  .map(id => canonicalElementById.get(id))
2785
- .filter(Boolean);
2915
+ .filter(Boolean)
2916
+ .map(withSemanticScore);
2786
2917
  const relationships = [...relationshipIds]
2787
2918
  .map(id => canonicalRelationshipById.get(id))
2788
- .filter(Boolean);
2919
+ .filter(Boolean)
2920
+ .map(withSemanticScore);
2789
2921
  const views = [...viewIds]
2790
2922
  .map(id => canonicalViewById.get(id))
2791
- .filter(Boolean);
2923
+ .filter(Boolean)
2924
+ .map(withSemanticScore);
2792
2925
 
2793
2926
  return {
2794
2927
  status: 'passed',
@@ -3483,6 +3616,7 @@ module.exports = {
3483
3616
  handleRequest,
3484
3617
  loadContext,
3485
3618
  main,
3619
+ memoryHitCard,
3486
3620
  resolveSemanticScope,
3487
3621
  validateDocument,
3488
3622
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archgraph-argo",
3
- "version": "0.10.31",
3
+ "version": "0.10.33",
4
4
  "description": "Deploy the ArchGraph ARGO toolchain, skills, and rules (schema, scripts, argo-init skill, global rule) with one command.",
5
5
  "license": "MIT",
6
6
  "bin": {