archgraph-argo 0.19.1 → 0.20.1
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/README.md +7 -1
- package/argo/.env.example +140 -0
- package/argo/scripts/graph-rag/defaultSemanticRetrieval.js +115 -5
- package/argo/scripts/graph-rag/hybridRetrieval.js +114 -0
- package/argo/scripts/graph-rag/liveEmbeddingProviderConfig.js +25 -1
- package/argo/scripts/graph-rag/mutationEmbeddingVectorLifecycle.js +1 -0
- package/argo/scripts/graph-rag/rerankRetrieval.js +150 -0
- package/argo/scripts/graph-rag/semantic-persistence/productionSemanticBackfill.js +3 -1
- package/argo/scripts/graph-rag/semantic-persistence/productionSemanticNeo4jAdapter.js +14 -0
- package/install-argo.ps1 +10 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -7,6 +7,10 @@ An architecture-graph driven framework for Agentic Engineering.
|
|
|
7
7
|
ArchGraph builds a **unified language** that puts harness design and target product design into
|
|
8
8
|
**one model** — so you get a single view to work and observe, and real control over your agents.
|
|
9
9
|
|
|
10
|
+
It doubles as a **long-term memory for coding agents**: an ArchiMate 3.2 intent graph exposed through
|
|
11
|
+
a single read/write MCP interface. Writes are deduplicated, so the graph stays clean and semantic
|
|
12
|
+
recall stays precise. See the [home page](https://archgraph.org/) for the full capability set.
|
|
13
|
+
|
|
10
14
|

|
|
11
15
|
|
|
12
16
|
## Architecture
|
|
@@ -71,7 +75,9 @@ After installing, open your project and start a coding agent. It will:
|
|
|
71
75
|
|
|
72
76
|
1. locate the architecture element behind the task before changing anything,
|
|
73
77
|
2. arm itself with that element's Skills and Rules,
|
|
74
|
-
3. work test-first (GIVEN-WHEN-THEN), and trace every commit back to the graph
|
|
78
|
+
3. work test-first (GIVEN-WHEN-THEN), and trace every commit back to the graph,
|
|
79
|
+
4. reuse an existing element, relationship, or view instead of creating a duplicate — the write path
|
|
80
|
+
deduplicates by identity and flags a semantically near element of the same type.
|
|
75
81
|
|
|
76
82
|
The intent architecture graph — modelled in **ArchiMate 3.2** — is the single source of truth.
|
|
77
83
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# ArchGraph (archgraph-argo) environment configuration — EXAMPLE.
|
|
3
|
+
#
|
|
4
|
+
# Copy this file to the live env file and fill in real values:
|
|
5
|
+
# Windows %USERPROFILE%\.argo\.env
|
|
6
|
+
# Linux/macOS ~/.argo/.env
|
|
7
|
+
# The live file is git-ignored and MUST stay untracked with a restricted ACL;
|
|
8
|
+
# this example is committed and contains no secrets.
|
|
9
|
+
#
|
|
10
|
+
# PART 1 keys are the ONLY keys accepted inside the .env file (any unknown key
|
|
11
|
+
# makes the secret-file preflight reject the whole file). PART 2 keys are
|
|
12
|
+
# host/process-level only — set them in the host/MCP launch config or shell, NOT
|
|
13
|
+
# here. Empty values below are placeholders.
|
|
14
|
+
# =============================================================================
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# -----------------------------------------------------------------------------
|
|
18
|
+
# PART 1 — .env file keys
|
|
19
|
+
# -----------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
# --- Embedding provider (required) — powers vector semantic retrieval -------
|
|
22
|
+
# OpenAI-compatible embedding endpoint base URL (no trailing slash).
|
|
23
|
+
ARGO_EMBEDDING_BASE_URL=
|
|
24
|
+
# Embedding model id (e.g. qwen3.7-text-embedding).
|
|
25
|
+
ARGO_EMBEDDING_MODEL=
|
|
26
|
+
# Provider label recorded in evidence; also the rerank fallback provider.
|
|
27
|
+
ARGO_EMBEDDING_PROVIDER=
|
|
28
|
+
# Model version / qualification label (recorded evidence only).
|
|
29
|
+
ARGO_EMBEDDING_MODEL_VERSION=
|
|
30
|
+
# Embedding vector dimension; must match the model (current profiles: 1536).
|
|
31
|
+
ARGO_EMBEDDING_DIMENSIONS=
|
|
32
|
+
|
|
33
|
+
# --- Neo4j (required) — structural projection + vector/full-text store ------
|
|
34
|
+
# Neo4j connection URI (e.g. neo4j://127.0.0.1:7687).
|
|
35
|
+
ARGO_NEO4J_DATABASE_URL=
|
|
36
|
+
# Neo4j username.
|
|
37
|
+
ARGO_NEO4J_DATABASE_USERNAME=
|
|
38
|
+
# Neo4j password. SECRET: value must come from the untracked, ACL-restricted
|
|
39
|
+
# .env (or direct process injection) only — never commit it.
|
|
40
|
+
ARGO_NEO4J_DATABASE_PASSWORD=
|
|
41
|
+
# Optional: override the Neo4j database name. Default = sanitized repository
|
|
42
|
+
# folder name (e.g. repo "archgraph" -> database "archgraph").
|
|
43
|
+
ARGO_NEO4J_DATABASE=
|
|
44
|
+
|
|
45
|
+
# --- Secrets (required) ------------------------------------------------------
|
|
46
|
+
# API key for the embedding endpoint above. SECRET; also the fallback key for
|
|
47
|
+
# the reranker when ARGO_RERANK_API_KEY is not set. Never commit it.
|
|
48
|
+
QWEN_KEY=
|
|
49
|
+
|
|
50
|
+
# --- Semantic retrieval tuning (optional; safe defaults shown) --------------
|
|
51
|
+
# Similarity threshold, memory purposes (recall-oriented). Default 0.55.
|
|
52
|
+
ARGO_SEMANTIC_MEMORY_THRESHOLD=
|
|
53
|
+
# Memory threshold override for the Element channel. Default 0.55.
|
|
54
|
+
ARGO_SEMANTIC_MEMORY_THRESHOLD_ELEMENT=
|
|
55
|
+
# Memory threshold override for the ArchitectureRelationship channel. Default 0.55.
|
|
56
|
+
ARGO_SEMANTIC_MEMORY_THRESHOLD_RELATIONSHIP=
|
|
57
|
+
# Memory threshold override for the View channel. Default 0.55.
|
|
58
|
+
ARGO_SEMANTIC_MEMORY_THRESHOLD_VIEW=
|
|
59
|
+
# Similarity threshold, audit purpose (precision-oriented). Default 0.8.
|
|
60
|
+
ARGO_SEMANTIC_AUDIT_THRESHOLD=
|
|
61
|
+
# Audit threshold override for the Element channel. Default 0.8.
|
|
62
|
+
ARGO_SEMANTIC_AUDIT_THRESHOLD_ELEMENT=
|
|
63
|
+
# Audit threshold override for the ArchitectureRelationship channel. Default 0.8.
|
|
64
|
+
ARGO_SEMANTIC_AUDIT_THRESHOLD_RELATIONSHIP=
|
|
65
|
+
# Audit threshold override for the View channel. Default 0.8.
|
|
66
|
+
ARGO_SEMANTIC_AUDIT_THRESHOLD_VIEW=
|
|
67
|
+
# Bound on returned candidates per retrieval. Default 8.
|
|
68
|
+
ARGO_SEMANTIC_TOP_K=
|
|
69
|
+
|
|
70
|
+
# --- Hybrid retrieval (vector + lexical BM25 via RRF; optional) -------------
|
|
71
|
+
# Master switch. "1" enables hybrid fusion; unset/"0" = vector-only (default off).
|
|
72
|
+
ARGO_SEMANTIC_HYBRID=
|
|
73
|
+
# Weight of the vector channel in the RRF fusion. Default 3.
|
|
74
|
+
ARGO_SEMANTIC_HYBRID_VECTOR_WEIGHT=
|
|
75
|
+
# Weight of the lexical (full-text) channel in the RRF fusion. Default 1.
|
|
76
|
+
ARGO_SEMANTIC_HYBRID_LEXICAL_WEIGHT=
|
|
77
|
+
# RRF smoothing constant k. Default 60.
|
|
78
|
+
ARGO_SEMANTIC_HYBRID_RRF_K=
|
|
79
|
+
# Candidate pool size pulled per channel before fusion. Default 16.
|
|
80
|
+
ARGO_SEMANTIC_HYBRID_TOP_K=
|
|
81
|
+
|
|
82
|
+
# --- LLM rerank (second-stage reordering; optional) -------------------------
|
|
83
|
+
# Master switch. "1" enables rerank; unset/"0" = off (default off). Fail-open:
|
|
84
|
+
# any error/timeout keeps the original order.
|
|
85
|
+
ARGO_SEMANTIC_RERANK=
|
|
86
|
+
# Rerank model when using the embedding provider fallback. Default qwen-turbo.
|
|
87
|
+
ARGO_SEMANTIC_RERANK_MODEL=
|
|
88
|
+
# Candidate pool size offered to the reranker. Default 20.
|
|
89
|
+
ARGO_SEMANTIC_RERANK_POOL=
|
|
90
|
+
# Max ids the reranker may return. Default 8.
|
|
91
|
+
ARGO_SEMANTIC_RERANK_RETURN=
|
|
92
|
+
# Per-request rerank timeout in ms (AbortController). Default 8000.
|
|
93
|
+
ARGO_SEMANTIC_RERANK_TIMEOUT_MS=
|
|
94
|
+
# Dedicated rerank provider (optional). When unset, rerank falls back to the
|
|
95
|
+
# embedding provider above. Use these to point rerank at another provider/model
|
|
96
|
+
# (e.g. DeepSeek: base https://api.deepseek.com, model deepseek-flash).
|
|
97
|
+
ARGO_RERANK_BASE_URL=
|
|
98
|
+
# Dedicated rerank API key. SECRET. Falls back to QWEN_KEY when unset.
|
|
99
|
+
ARGO_RERANK_API_KEY=
|
|
100
|
+
# Dedicated rerank provider label (informational; defaults to the embedding provider label).
|
|
101
|
+
ARGO_RERANK_PROVIDER=
|
|
102
|
+
# Dedicated rerank model id (overrides ARGO_SEMANTIC_RERANK_MODEL).
|
|
103
|
+
ARGO_RERANK_MODEL=
|
|
104
|
+
|
|
105
|
+
# --- Live end-to-end opt-ins (optional; normally unset) ---------------------
|
|
106
|
+
# "1" allows the live embedding-provider E2E to hit the real network.
|
|
107
|
+
ARGO_LIVE_PROVIDER_E2E=
|
|
108
|
+
# "1" allows the live W3.1 mutation-vector E2E to hit the real network.
|
|
109
|
+
ARGO_W31_LIVE_MUTATION_VECTOR_E2E=
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# -----------------------------------------------------------------------------
|
|
113
|
+
# PART 2 — host / process-level only (do NOT put these in .env)
|
|
114
|
+
# Set in the host or MCP launch configuration (mcp.json / opencode.json env,
|
|
115
|
+
# dsh plugin, or the shell), never in the .env file.
|
|
116
|
+
# -----------------------------------------------------------------------------
|
|
117
|
+
# Point at a non-default env file path.
|
|
118
|
+
# ARGO_ENV_FILE=
|
|
119
|
+
# Pin the workspace/repository root the MCP server serves.
|
|
120
|
+
# ARGO_REPO_ROOT=
|
|
121
|
+
# Path to the Enterprise Architect model file (.qea) to project to/from.
|
|
122
|
+
# ARGO_EA_QEA=
|
|
123
|
+
# Semicolon-separated roots for multi-workspace hosts (DSH plugin).
|
|
124
|
+
# ARGO_WORKSPACE_ROOTS=
|
|
125
|
+
# Explicit path to the argo MCP server entry script (DSH plugin).
|
|
126
|
+
# ARGO_SERVER_PATH=
|
|
127
|
+
# URL of the graph-mcp HTTP bridge.
|
|
128
|
+
# GRAPH_MCP_URL=
|
|
129
|
+
# "1" enables verbose EA <-> .qea sync debug logging.
|
|
130
|
+
# EA_QEA_DEBUG=
|
|
131
|
+
# Architecture test runner timeout in ms.
|
|
132
|
+
# ARGO_TEST_TIMEOUT_MS=
|
|
133
|
+
# "1" prints the full mutation response for debugging.
|
|
134
|
+
# ARGO_MCP_MUTATION_RESPONSE_DEBUG=
|
|
135
|
+
# "0" disables the pre-write semantic dedup advisory (default: enabled).
|
|
136
|
+
# ARGO_MCP_SEMANTIC_DEDUP=
|
|
137
|
+
# Similarity threshold for the semantic dedup advisory. Default 0.85.
|
|
138
|
+
# ARGO_MCP_SEMANTIC_DEDUP_THRESHOLD=
|
|
139
|
+
# Alias of ARGO_MCP_SEMANTIC_DEDUP_THRESHOLD (takes precedence when set).
|
|
140
|
+
# ARGO_SEMANTIC_DEDUP_THRESHOLD=
|
|
@@ -14,6 +14,21 @@ const {
|
|
|
14
14
|
getWorkspaceRoot,
|
|
15
15
|
resolveArgoPath,
|
|
16
16
|
} = require('../argo-paths.js');
|
|
17
|
+
const {
|
|
18
|
+
isHybridEnabled,
|
|
19
|
+
hybridTopK,
|
|
20
|
+
hybridWeights,
|
|
21
|
+
rrfK,
|
|
22
|
+
fuseChannelSeeds,
|
|
23
|
+
sanitizeFulltextQuery,
|
|
24
|
+
} = require('./hybridRetrieval.js');
|
|
25
|
+
const {
|
|
26
|
+
isRerankEnabled,
|
|
27
|
+
rerankConfig,
|
|
28
|
+
resolveRerankProvider,
|
|
29
|
+
rerankCandidates,
|
|
30
|
+
applyRerankOrder,
|
|
31
|
+
} = require('./rerankRetrieval.js');
|
|
17
32
|
|
|
18
33
|
const APPROVED_SOURCE_KEYS = Object.freeze([
|
|
19
34
|
'ARGO_EMBEDDING_BASE_URL',
|
|
@@ -75,6 +90,20 @@ const VECTOR_QUERY_CYPHER_SCOPED = [
|
|
|
75
90
|
'RETURN properties(node) AS record, score',
|
|
76
91
|
'ORDER BY score DESC',
|
|
77
92
|
].join('\n');
|
|
93
|
+
const LEXICAL_QUERY_CYPHER = [
|
|
94
|
+
'CALL db.index.fulltext.queryNodes($indexName, $queryText, { limit: $topK })',
|
|
95
|
+
'YIELD node, score',
|
|
96
|
+
'WHERE node.channel = $channel',
|
|
97
|
+
'RETURN properties(node) AS record, score',
|
|
98
|
+
'ORDER BY score DESC',
|
|
99
|
+
].join('\n');
|
|
100
|
+
const LEXICAL_QUERY_CYPHER_SCOPED = [
|
|
101
|
+
'CALL db.index.fulltext.queryNodes($indexName, $queryText, { limit: $topK })',
|
|
102
|
+
'YIELD node, score',
|
|
103
|
+
'WHERE node.channel = $channel AND node.canonicalIdentity IN $canonicalIdentities',
|
|
104
|
+
'RETURN properties(node) AS record, score',
|
|
105
|
+
'ORDER BY score DESC',
|
|
106
|
+
].join('\n');
|
|
78
107
|
const READINESS_QUERY_CYPHER = [
|
|
79
108
|
'MATCH (readiness:ArgoProductionSemanticReadiness {identity: $identity})',
|
|
80
109
|
'RETURN properties(readiness) AS readiness',
|
|
@@ -206,18 +235,49 @@ async function executeWpP2Retrieval({
|
|
|
206
235
|
const purpose = request && typeof request.purpose === 'string' ? request.purpose : '';
|
|
207
236
|
const strict = AUDIT_PURPOSES.has(purpose);
|
|
208
237
|
const topK = Number.isInteger(request.topK) && request.topK > 0 ? request.topK : resolveTopK();
|
|
238
|
+
const scoped = Array.isArray(canonicalIdentities) && canonicalIdentities.length > 0;
|
|
239
|
+
const hybrid = isHybridEnabled();
|
|
240
|
+
const lexicalTopK = hybridTopK();
|
|
241
|
+
const fusionK = rrfK();
|
|
242
|
+
const fusionWeights = hybridWeights();
|
|
243
|
+
const rerank = isRerankEnabled();
|
|
244
|
+
const rerankOptions = rerankConfig();
|
|
245
|
+
const rerankProvider = rerank ? resolveRerankProvider(configurationEvidence.configuration) : null;
|
|
246
|
+
// Rerank needs a larger candidate pool than the final top-K.
|
|
247
|
+
const pool = rerank ? Math.max(topK, rerankOptions.poolSize) : topK;
|
|
209
248
|
const seedsByType = {};
|
|
210
249
|
for (const channel of CHANNELS) {
|
|
211
|
-
|
|
250
|
+
const vectorSeeds = await exhaustChannel({
|
|
212
251
|
channel,
|
|
213
252
|
neo4jDriver: composition.neo4jDriver,
|
|
214
253
|
vector,
|
|
215
254
|
threshold: strict ? auditThresholdFor(channel) : memoryThresholdFor(channel),
|
|
216
|
-
maxSeeds:
|
|
217
|
-
...(
|
|
218
|
-
? { canonicalIdentities }
|
|
219
|
-
: {}),
|
|
255
|
+
maxSeeds: pool,
|
|
256
|
+
...(scoped ? { canonicalIdentities } : {}),
|
|
220
257
|
});
|
|
258
|
+
let seeds = vectorSeeds;
|
|
259
|
+
if (hybrid) {
|
|
260
|
+
const lexicalSeeds = await exhaustLexicalChannel({
|
|
261
|
+
channel,
|
|
262
|
+
neo4jDriver: composition.neo4jDriver,
|
|
263
|
+
queryText: request.intent,
|
|
264
|
+
maxSeeds: lexicalTopK,
|
|
265
|
+
...(scoped ? { canonicalIdentities } : {}),
|
|
266
|
+
});
|
|
267
|
+
seeds = fuseChannelSeeds({ vectorSeeds, lexicalSeeds, k: fusionK, limit: Math.max(pool, lexicalTopK), weights: fusionWeights });
|
|
268
|
+
}
|
|
269
|
+
if (rerank && seeds.length > 1) {
|
|
270
|
+
const ordered = await rerankCandidates({
|
|
271
|
+
query: request.intent,
|
|
272
|
+
candidates: seeds,
|
|
273
|
+
provider: rerankProvider,
|
|
274
|
+
transport: composition.transport,
|
|
275
|
+
maxReturn: rerankOptions.maxReturn,
|
|
276
|
+
});
|
|
277
|
+
// fail-open: a null/empty order keeps the original ordering
|
|
278
|
+
seeds = applyRerankOrder(seeds, ordered, topK);
|
|
279
|
+
}
|
|
280
|
+
seedsByType[channel.key] = seeds;
|
|
221
281
|
}
|
|
222
282
|
return completeSemanticResult({
|
|
223
283
|
request: completeRequest,
|
|
@@ -303,6 +363,9 @@ async function executeProductionNeo4jOperation(configuration, operation) {
|
|
|
303
363
|
...record.get('record'),
|
|
304
364
|
score: numberValue(record.get('score')),
|
|
305
365
|
}));
|
|
366
|
+
if (operation.kind === 'semantic-lexical-query') {
|
|
367
|
+
return { records };
|
|
368
|
+
}
|
|
306
369
|
const offset = operation.parameters.offset;
|
|
307
370
|
const windowSize = operation.parameters.windowSize;
|
|
308
371
|
const returnedCount = Math.max(0, records.length - offset);
|
|
@@ -709,6 +772,53 @@ async function exhaustChannel({
|
|
|
709
772
|
return Object.freeze(accepted);
|
|
710
773
|
}
|
|
711
774
|
|
|
775
|
+
// Lexical (full-text/BM25) sibling of exhaustChannel. Additive and fail-open:
|
|
776
|
+
// it returns [] when the query text is blank, when the full-text index is
|
|
777
|
+
// missing, or on any error, so hybrid retrieval can never break the baseline.
|
|
778
|
+
async function exhaustLexicalChannel({
|
|
779
|
+
channel,
|
|
780
|
+
neo4jDriver,
|
|
781
|
+
queryText,
|
|
782
|
+
maxSeeds,
|
|
783
|
+
canonicalIdentities,
|
|
784
|
+
}) {
|
|
785
|
+
if (typeof queryText !== 'string' || queryText.trim() === '') {
|
|
786
|
+
return Object.freeze([]);
|
|
787
|
+
}
|
|
788
|
+
const safeQuery = sanitizeFulltextQuery(queryText);
|
|
789
|
+
if (safeQuery === '') {
|
|
790
|
+
return Object.freeze([]);
|
|
791
|
+
}
|
|
792
|
+
const scoped = Array.isArray(canonicalIdentities) && canonicalIdentities.length > 0;
|
|
793
|
+
const scopedCanonicalIdentities = scoped
|
|
794
|
+
? scopeCanonicalIdentitiesForChannel(canonicalIdentities, channel)
|
|
795
|
+
: canonicalIdentities;
|
|
796
|
+
const effectiveMax = Number.isInteger(maxSeeds) && maxSeeds > 0 ? maxSeeds : INITIAL_WINDOW_SIZE;
|
|
797
|
+
const indexName = `${channel.indexName}_fulltext`;
|
|
798
|
+
try {
|
|
799
|
+
const result = await neo4jDriver.execute(Object.freeze({
|
|
800
|
+
kind: 'semantic-lexical-query',
|
|
801
|
+
channel: channel.channel,
|
|
802
|
+
indexName,
|
|
803
|
+
cypher: scoped ? LEXICAL_QUERY_CYPHER_SCOPED : LEXICAL_QUERY_CYPHER,
|
|
804
|
+
parameters: Object.freeze({
|
|
805
|
+
indexName,
|
|
806
|
+
channel: channel.channel,
|
|
807
|
+
queryText: safeQuery,
|
|
808
|
+
topK: effectiveMax,
|
|
809
|
+
...(scoped ? { canonicalIdentities: scopedCanonicalIdentities } : {}),
|
|
810
|
+
}),
|
|
811
|
+
}));
|
|
812
|
+
const records = Array.isArray(result && result.records) ? result.records : [];
|
|
813
|
+
return Object.freeze(records
|
|
814
|
+
.map(raw => normalizeVectorRecord(raw, channel))
|
|
815
|
+
.filter(Boolean)
|
|
816
|
+
.slice(0, effectiveMax));
|
|
817
|
+
} catch {
|
|
818
|
+
return Object.freeze([]);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
712
822
|
// Scope identities come from the canonical graph as BARE ids (e.g.
|
|
713
823
|
// "memory-eval-bench-wp-001"), but the semantic vector records store their
|
|
714
824
|
// canonicalIdentity with a channel prefix (e.g. "Element:memory-eval-bench-wp-001").
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Hybrid retrieval fusion (P2). Pure, dependency-free helpers so the fusion
|
|
4
|
+
// logic is fully unit-testable and the production retrieval path can stay
|
|
5
|
+
// additive: when hybrid is OFF nothing here is invoked.
|
|
6
|
+
//
|
|
7
|
+
// Design:
|
|
8
|
+
// - vector (dense) and lexical (full-text/BM25) ranked lists are fused with
|
|
9
|
+
// Reciprocal Rank Fusion: score(id) = sum over lists of 1/(k + rank).
|
|
10
|
+
// - RRF needs no score normalization between the two very different score
|
|
11
|
+
// scales, and guarantees the union is retained (recall can only increase).
|
|
12
|
+
|
|
13
|
+
const DEFAULT_RRF_K = 60;
|
|
14
|
+
const DEFAULT_HYBRID_TOP_K = 16;
|
|
15
|
+
// Vector-first weighting: the dense channel is the stronger signal on this
|
|
16
|
+
// graph, so lexical only nudges the ranking and rescues misses.
|
|
17
|
+
const DEFAULT_VECTOR_WEIGHT = 3;
|
|
18
|
+
const DEFAULT_LEXICAL_WEIGHT = 1;
|
|
19
|
+
|
|
20
|
+
function isHybridEnabled(env = process.env) {
|
|
21
|
+
return String((env && env.ARGO_SEMANTIC_HYBRID) || '') === '1';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hybridWeights(env = process.env) {
|
|
25
|
+
const vector = Number(env && env.ARGO_SEMANTIC_HYBRID_VECTOR_WEIGHT);
|
|
26
|
+
const lexical = Number(env && env.ARGO_SEMANTIC_HYBRID_LEXICAL_WEIGHT);
|
|
27
|
+
return [
|
|
28
|
+
Number.isFinite(vector) && vector >= 0 ? vector : DEFAULT_VECTOR_WEIGHT,
|
|
29
|
+
Number.isFinite(lexical) && lexical >= 0 ? lexical : DEFAULT_LEXICAL_WEIGHT,
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hybridTopK(env = process.env) {
|
|
34
|
+
const value = Number(env && env.ARGO_SEMANTIC_HYBRID_TOP_K);
|
|
35
|
+
return Number.isInteger(value) && value > 0 ? value : DEFAULT_HYBRID_TOP_K;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function rrfK(env = process.env) {
|
|
39
|
+
const value = Number(env && env.ARGO_SEMANTIC_HYBRID_RRF_K);
|
|
40
|
+
return Number.isFinite(value) && value > 0 ? value : DEFAULT_RRF_K;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Lucene full-text queries reject raw punctuation (e.g. "A --(Flow)--> B") and
|
|
44
|
+
// blow the maxClauseCount on very long text (long Chinese descriptions become
|
|
45
|
+
// hundreds of single-character clauses). Truncate first, then escape the
|
|
46
|
+
// reserved query-syntax characters so any intent/name/statement is literal.
|
|
47
|
+
// Returns '' for blank input (caller skips).
|
|
48
|
+
const MAX_FULLTEXT_QUERY_LENGTH = 256;
|
|
49
|
+
|
|
50
|
+
function sanitizeFulltextQuery(text) {
|
|
51
|
+
const value = String(text === undefined || text === null ? '' : text)
|
|
52
|
+
.trim()
|
|
53
|
+
.slice(0, MAX_FULLTEXT_QUERY_LENGTH);
|
|
54
|
+
if (!value) return '';
|
|
55
|
+
return value
|
|
56
|
+
.replace(/&&/g, '\\&\\&')
|
|
57
|
+
.replace(/\|\|/g, '\\|\\|')
|
|
58
|
+
.replace(/([+\-!(){}[\]^"~*?:\\/])/g, '\\$1');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fuse ranked record lists by id (canonicalIdentity). Each list is expected to be
|
|
62
|
+
// already sorted best-first. Returns records carrying the fused `score`, with
|
|
63
|
+
// the first-seen record object preserved (so downstream keeps its fields).
|
|
64
|
+
function rrfFuse(lists, options = {}) {
|
|
65
|
+
const k = Number.isFinite(options.k) && options.k > 0 ? options.k : DEFAULT_RRF_K;
|
|
66
|
+
const weights = Array.isArray(options.weights) ? options.weights : [];
|
|
67
|
+
const byId = new Map();
|
|
68
|
+
(Array.isArray(lists) ? lists : []).forEach((list, listIndex) => {
|
|
69
|
+
const weight = Number.isFinite(weights[listIndex]) && weights[listIndex] >= 0 ? weights[listIndex] : 1;
|
|
70
|
+
(Array.isArray(list) ? list : []).forEach((record, rank) => {
|
|
71
|
+
if (!record || !record.id) return;
|
|
72
|
+
const entry = byId.get(record.id) || { id: record.id, record, score: 0, matchedLists: 0 };
|
|
73
|
+
entry.score += weight / (k + rank + 1);
|
|
74
|
+
entry.matchedLists += 1;
|
|
75
|
+
if (!entry.record) entry.record = record;
|
|
76
|
+
byId.set(record.id, entry);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
return [...byId.values()]
|
|
80
|
+
.sort((left, right) => (right.score - left.score) || String(left.id).localeCompare(String(right.id)))
|
|
81
|
+
.map(entry => Object.freeze({
|
|
82
|
+
...entry.record,
|
|
83
|
+
id: entry.id,
|
|
84
|
+
canonicalIdentity: entry.id,
|
|
85
|
+
score: entry.score,
|
|
86
|
+
rrfScore: entry.score,
|
|
87
|
+
matchedLists: entry.matchedLists,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fuse the two per-channel seed lists. With an empty lexical list this preserves
|
|
92
|
+
// the vector ordering (RRF on a single list is order-preserving) — which is what
|
|
93
|
+
// makes "hybrid ON but no lexical hit" behave like the vector-only baseline.
|
|
94
|
+
function fuseChannelSeeds({ vectorSeeds = [], lexicalSeeds = [], k = DEFAULT_RRF_K, limit, weights } = {}) {
|
|
95
|
+
const fused = rrfFuse([
|
|
96
|
+
Array.isArray(vectorSeeds) ? vectorSeeds.filter(Boolean) : [],
|
|
97
|
+
Array.isArray(lexicalSeeds) ? lexicalSeeds.filter(Boolean) : [],
|
|
98
|
+
], { k, ...(Array.isArray(weights) ? { weights } : {}) });
|
|
99
|
+
return Number.isInteger(limit) && limit > 0 ? fused.slice(0, limit) : fused;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
DEFAULT_RRF_K,
|
|
104
|
+
DEFAULT_HYBRID_TOP_K,
|
|
105
|
+
DEFAULT_VECTOR_WEIGHT,
|
|
106
|
+
DEFAULT_LEXICAL_WEIGHT,
|
|
107
|
+
isHybridEnabled,
|
|
108
|
+
hybridTopK,
|
|
109
|
+
hybridWeights,
|
|
110
|
+
rrfK,
|
|
111
|
+
rrfFuse,
|
|
112
|
+
fuseChannelSeeds,
|
|
113
|
+
sanitizeFulltextQuery,
|
|
114
|
+
};
|
|
@@ -34,6 +34,24 @@ const RETRIEVAL_TUNING_KEYS = Object.freeze([
|
|
|
34
34
|
'ARGO_SEMANTIC_AUDIT_THRESHOLD_RELATIONSHIP',
|
|
35
35
|
'ARGO_SEMANTIC_AUDIT_THRESHOLD_VIEW',
|
|
36
36
|
'ARGO_SEMANTIC_TOP_K',
|
|
37
|
+
// Hybrid (vector + lexical RRF) retrieval (P2). Off unless
|
|
38
|
+
// ARGO_SEMANTIC_HYBRID=1; weights/k/top-K tune the fusion.
|
|
39
|
+
'ARGO_SEMANTIC_HYBRID',
|
|
40
|
+
'ARGO_SEMANTIC_HYBRID_VECTOR_WEIGHT',
|
|
41
|
+
'ARGO_SEMANTIC_HYBRID_LEXICAL_WEIGHT',
|
|
42
|
+
'ARGO_SEMANTIC_HYBRID_RRF_K',
|
|
43
|
+
'ARGO_SEMANTIC_HYBRID_TOP_K',
|
|
44
|
+
// Rerank (P3) tuning + optional dedicated rerank provider. When the provider
|
|
45
|
+
// keys are unset the reranker falls back to the embedding provider (qwen).
|
|
46
|
+
'ARGO_SEMANTIC_RERANK',
|
|
47
|
+
'ARGO_SEMANTIC_RERANK_MODEL',
|
|
48
|
+
'ARGO_SEMANTIC_RERANK_POOL',
|
|
49
|
+
'ARGO_SEMANTIC_RERANK_RETURN',
|
|
50
|
+
'ARGO_SEMANTIC_RERANK_TIMEOUT_MS',
|
|
51
|
+
'ARGO_RERANK_BASE_URL',
|
|
52
|
+
'ARGO_RERANK_API_KEY',
|
|
53
|
+
'ARGO_RERANK_PROVIDER',
|
|
54
|
+
'ARGO_RERANK_MODEL',
|
|
37
55
|
]);
|
|
38
56
|
const OPT_IN_KEYS = Object.freeze({
|
|
39
57
|
ARGO_LIVE_PROVIDER_E2E: 'LIVE_PROVIDER_E2E_OPT_IN_REQUIRED',
|
|
@@ -47,7 +65,7 @@ const READABLE_KEYS = Object.freeze([
|
|
|
47
65
|
]);
|
|
48
66
|
const LEGACY_KEYS = Object.freeze(['ARGO_NEO4J_URI', 'ARGO_NEO4J_USERNAME', 'ARGO_NEO4J_PASSWORD']);
|
|
49
67
|
const PROHIBITED_RUNTIME_FIELD_KEYS = Object.freeze(['neo4jUri', 'embeddingCredential']);
|
|
50
|
-
const SECRET_KEYS = new Set(['ARGO_NEO4J_DATABASE_PASSWORD', 'QWEN_KEY']);
|
|
68
|
+
const SECRET_KEYS = new Set(['ARGO_NEO4J_DATABASE_PASSWORD', 'QWEN_KEY', 'ARGO_RERANK_API_KEY']);
|
|
51
69
|
const APPROVED = Object.freeze({
|
|
52
70
|
ARGO_EMBEDDING_BASE_URL: 'https://llm-clids9mqc5o1mbvb.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
|
|
53
71
|
ARGO_EMBEDDING_MODEL: 'qwen3.7-text-embedding',
|
|
@@ -514,4 +532,10 @@ module.exports = {
|
|
|
514
532
|
resolveApprovedLiveConfiguration,
|
|
515
533
|
withApprovedLiveConfigurationTestComposition,
|
|
516
534
|
posixModeIsSecretSafe,
|
|
535
|
+
// The authoritative set of keys an approved `.env` file may carry. The
|
|
536
|
+
// committed `.env.example` must document exactly this set (see
|
|
537
|
+
// tests/env-example.test.js); SECRET_KEYS marks the subset that must be
|
|
538
|
+
// preflighted as secrets.
|
|
539
|
+
ENV_FILE_KEYS: READABLE_KEYS,
|
|
540
|
+
ENV_FILE_SECRET_KEYS: Object.freeze(Array.from(SECRET_KEYS)),
|
|
517
541
|
};
|
|
@@ -531,6 +531,7 @@ function buildPersistentWork(canonicalWrite, configuration, versions) {
|
|
|
531
531
|
contentVersion: `content:${fingerprint(content)}`,
|
|
532
532
|
indexVersion: `index:${fingerprint({ objectId, content, canonicalVersion: versions.canonicalVersion })}`,
|
|
533
533
|
content,
|
|
534
|
+
searchText: content,
|
|
534
535
|
}));
|
|
535
536
|
} else {
|
|
536
537
|
tombstones.push(Object.freeze(base));
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Two-stage rerank (P3): after the (vector, or hybrid) candidate pool is
|
|
4
|
+
// retrieved, an LLM listwise reranker reorders it by relevance to the query.
|
|
5
|
+
// Additive and fail-open: when disabled nothing runs; any error returns null so
|
|
6
|
+
// the caller keeps the original ordering. Pure helpers are exported for tests.
|
|
7
|
+
|
|
8
|
+
const DEFAULT_RERANK_MODEL = 'qwen-turbo';
|
|
9
|
+
const DEFAULT_RERANK_POOL = 20;
|
|
10
|
+
const DEFAULT_RERANK_RETURN = 8;
|
|
11
|
+
const DEFAULT_RERANK_TIMEOUT_MS = 8000;
|
|
12
|
+
|
|
13
|
+
function rerankTimeoutMs(env = process.env) {
|
|
14
|
+
const value = Number(env && env.ARGO_SEMANTIC_RERANK_TIMEOUT_MS);
|
|
15
|
+
return Number.isFinite(value) && value > 0 ? value : DEFAULT_RERANK_TIMEOUT_MS;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isRerankEnabled(env = process.env) {
|
|
19
|
+
return String((env && env.ARGO_SEMANTIC_RERANK) || '') === '1';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function firstNonEmpty(...values) {
|
|
23
|
+
for (const value of values) {
|
|
24
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim();
|
|
25
|
+
}
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Rerank provider resolution, DECOUPLED from the embedding provider:
|
|
30
|
+
// - dedicated keys (ARGO_RERANK_BASE_URL / ARGO_RERANK_API_KEY / ARGO_RERANK_PROVIDER
|
|
31
|
+
// / ARGO_RERANK_MODEL) win when set, so a different provider/model can be used;
|
|
32
|
+
// - otherwise it falls back to the embedding configuration (qwen by default).
|
|
33
|
+
function resolveRerankProvider(configuration = {}, env = process.env) {
|
|
34
|
+
const baseUrl = firstNonEmpty(env.ARGO_RERANK_BASE_URL, configuration.embeddingBaseUrl);
|
|
35
|
+
const apiKey = firstNonEmpty(env.ARGO_RERANK_API_KEY, configuration.qwenKey);
|
|
36
|
+
const model = firstNonEmpty(env.ARGO_RERANK_MODEL, env.ARGO_SEMANTIC_RERANK_MODEL, DEFAULT_RERANK_MODEL);
|
|
37
|
+
const provider = firstNonEmpty(env.ARGO_RERANK_PROVIDER, configuration.embeddingProvider, 'qwen');
|
|
38
|
+
return { baseUrl, apiKey, model, provider };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rerankConfig(env = process.env) {
|
|
42
|
+
const model = (env && typeof env.ARGO_SEMANTIC_RERANK_MODEL === 'string' && env.ARGO_SEMANTIC_RERANK_MODEL.trim())
|
|
43
|
+
? env.ARGO_SEMANTIC_RERANK_MODEL.trim()
|
|
44
|
+
: DEFAULT_RERANK_MODEL;
|
|
45
|
+
const pool = Number(env && env.ARGO_SEMANTIC_RERANK_POOL);
|
|
46
|
+
const maxReturn = Number(env && env.ARGO_SEMANTIC_RERANK_RETURN);
|
|
47
|
+
return {
|
|
48
|
+
model,
|
|
49
|
+
poolSize: Number.isInteger(pool) && pool > 0 ? pool : DEFAULT_RERANK_POOL,
|
|
50
|
+
maxReturn: Number.isInteger(maxReturn) && maxReturn > 0 ? maxReturn : DEFAULT_RERANK_RETURN,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function candidateText(record) {
|
|
55
|
+
const raw = record && (record.searchText || record.description || record.name || '');
|
|
56
|
+
return String(raw).replace(/\s+/g, ' ').slice(0, 160);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Accept the canonical id or its bare/suffix form (models often drop the
|
|
60
|
+
// channel prefix), dedupe, ignore anything not in the candidate set.
|
|
61
|
+
function parseRerankOrder(content, candidateIds) {
|
|
62
|
+
let parsed;
|
|
63
|
+
try { parsed = JSON.parse(content); } catch { return []; }
|
|
64
|
+
const raw = Array.isArray(parsed && parsed.order) ? parsed.order : [];
|
|
65
|
+
const byKey = new Map();
|
|
66
|
+
for (const id of candidateIds) {
|
|
67
|
+
const text = String(id);
|
|
68
|
+
byKey.set(text, id);
|
|
69
|
+
byKey.set(text.split(':').slice(1).join(':'), id);
|
|
70
|
+
byKey.set(text.split(':').pop(), id);
|
|
71
|
+
}
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const order = [];
|
|
74
|
+
for (const value of raw) {
|
|
75
|
+
const canonical = byKey.get(String(value));
|
|
76
|
+
if (canonical && !seen.has(canonical)) {
|
|
77
|
+
seen.add(canonical);
|
|
78
|
+
order.push(canonical);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return order;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Reorder seeds by the model's order, then append any seeds the model omitted
|
|
85
|
+
// (nothing is lost), bounded by `limit`.
|
|
86
|
+
function applyRerankOrder(seeds, orderedIds, limit) {
|
|
87
|
+
const list = Array.isArray(seeds) ? seeds.filter(Boolean) : [];
|
|
88
|
+
const byId = new Map(list.map(seed => [seed.id, seed]));
|
|
89
|
+
const out = [];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
for (const id of (Array.isArray(orderedIds) ? orderedIds : [])) {
|
|
92
|
+
const seed = byId.get(id);
|
|
93
|
+
if (seed && !seen.has(id)) { seen.add(id); out.push(seed); }
|
|
94
|
+
}
|
|
95
|
+
for (const seed of list) {
|
|
96
|
+
if (!seen.has(seed.id)) { seen.add(seed.id); out.push(seed); }
|
|
97
|
+
}
|
|
98
|
+
return Number.isInteger(limit) && limit > 0 ? out.slice(0, limit) : out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function rerankCandidates({ query, candidates, provider, transport, maxReturn, timeoutMs } = {}) {
|
|
102
|
+
const list = Array.isArray(candidates) ? candidates.filter(candidate => candidate && candidate.id) : [];
|
|
103
|
+
if (list.length < 2 || typeof query !== 'string' || query.trim() === '') return null;
|
|
104
|
+
if (!provider || !transport || typeof transport.request !== 'function') return null;
|
|
105
|
+
const baseUrl = typeof provider.baseUrl === 'string' ? provider.baseUrl : '';
|
|
106
|
+
const apiKey = typeof provider.apiKey === 'string' ? provider.apiKey : '';
|
|
107
|
+
if (baseUrl === '' || apiKey === '') return null;
|
|
108
|
+
const model = provider.model || DEFAULT_RERANK_MODEL;
|
|
109
|
+
const body = {
|
|
110
|
+
model,
|
|
111
|
+
temperature: 0,
|
|
112
|
+
response_format: { type: 'json_object' },
|
|
113
|
+
messages: [
|
|
114
|
+
{ role: 'system', content: 'You rank architecture elements by relevance to a query. Return ONLY JSON {"order":[ids best-first]} using only the candidate ids.' },
|
|
115
|
+
{ role: 'user', content: `Query: ${query}\n\nCandidates (id\\ttext):\n${list.map(candidate => `${candidate.id}\t${candidateText(candidate)}`).join('\n')}\n\nReturn up to ${Math.min(maxReturn || DEFAULT_RERANK_RETURN, list.length)} ids best-first.` },
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
119
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs || rerankTimeoutMs()) : null;
|
|
120
|
+
try {
|
|
121
|
+
const response = await transport.request(`${baseUrl}/chat/completions`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify(body),
|
|
125
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
126
|
+
});
|
|
127
|
+
if (!response || response.ok !== true || typeof response.json !== 'function') return null;
|
|
128
|
+
const payload = await response.json();
|
|
129
|
+
const content = payload && payload.choices && payload.choices[0] && payload.choices[0].message && payload.choices[0].message.content;
|
|
130
|
+
return parseRerankOrder(content, list.map(candidate => candidate.id));
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
} finally {
|
|
134
|
+
if (timer) clearTimeout(timer);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
DEFAULT_RERANK_MODEL,
|
|
140
|
+
DEFAULT_RERANK_POOL,
|
|
141
|
+
DEFAULT_RERANK_RETURN,
|
|
142
|
+
DEFAULT_RERANK_TIMEOUT_MS,
|
|
143
|
+
isRerankEnabled,
|
|
144
|
+
rerankConfig,
|
|
145
|
+
rerankTimeoutMs,
|
|
146
|
+
resolveRerankProvider,
|
|
147
|
+
parseRerankOrder,
|
|
148
|
+
applyRerankOrder,
|
|
149
|
+
rerankCandidates,
|
|
150
|
+
};
|
|
@@ -154,8 +154,9 @@ async function processChannel(options) {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
function buildSemanticRecord(record, vector, options) {
|
|
157
|
+
const searchText = buildSemanticRecordText(record.channel, record.canonicalObject);
|
|
157
158
|
const contentHash = crypto.createHash('sha256')
|
|
158
|
-
.update(
|
|
159
|
+
.update(searchText)
|
|
159
160
|
.digest('hex');
|
|
160
161
|
return Object.freeze({
|
|
161
162
|
canonicalIdentity: record.canonicalIdentity,
|
|
@@ -167,6 +168,7 @@ function buildSemanticRecord(record, vector, options) {
|
|
|
167
168
|
model: options.qualification.model,
|
|
168
169
|
modelVersion: options.qualification.version,
|
|
169
170
|
dimensions: options.qualification.dimensions,
|
|
171
|
+
searchText,
|
|
170
172
|
vector: Object.freeze([...vector]),
|
|
171
173
|
});
|
|
172
174
|
}
|
|
@@ -30,6 +30,7 @@ function createProductionSemanticNeo4jAdapter(dependencies = {}) {
|
|
|
30
30
|
}
|
|
31
31
|
return withSession(driver, dependencies.configuration, async session => {
|
|
32
32
|
await ensureVectorIndexes(session);
|
|
33
|
+
await ensureFulltextIndexes(session);
|
|
33
34
|
const results = [];
|
|
34
35
|
for (const [channel, definition] of Object.entries(CHANNEL_INDEXES)) {
|
|
35
36
|
const channelRecords = records.filter(record => record.channel === channel).map(cloneRecord);
|
|
@@ -121,6 +122,19 @@ async function ensureVectorIndexes(session) {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
async function ensureFulltextIndexes(session) {
|
|
126
|
+
for (const definition of Object.values(CHANNEL_INDEXES)) {
|
|
127
|
+
await executeWrite(
|
|
128
|
+
session,
|
|
129
|
+
[
|
|
130
|
+
`CREATE FULLTEXT INDEX ${definition.indexName}_fulltext IF NOT EXISTS`,
|
|
131
|
+
`FOR (semantic:${definition.label}) ON EACH [semantic.searchText]`,
|
|
132
|
+
].join('\n'),
|
|
133
|
+
{},
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
124
138
|
async function withSession(driver, configuration, action) {
|
|
125
139
|
const database = configuration && configuration.neo4jDatabase;
|
|
126
140
|
const session = driver.session(database === undefined ? undefined : { database });
|
package/install-argo.ps1
CHANGED
|
@@ -1008,6 +1008,16 @@ if ($SkipEnv) {
|
|
|
1008
1008
|
$lines += "$key=$value"
|
|
1009
1009
|
}
|
|
1010
1010
|
|
|
1011
|
+
# Preserve any extra keys already present in .env but not part of the
|
|
1012
|
+
# interactive prompt list (e.g. semantic retrieval tuning: hybrid/rerank/
|
|
1013
|
+
# thresholds). Without this a re-deploy would silently drop them. They are
|
|
1014
|
+
# documented in argo/.env.example.
|
|
1015
|
+
foreach ($key in ($existing.Keys | Sort-Object)) {
|
|
1016
|
+
if ($envKeys -notcontains $key) {
|
|
1017
|
+
$lines += "$key=$($existing[$key])"
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1011
1021
|
[System.IO.File]::WriteAllLines(
|
|
1012
1022
|
$envPath,
|
|
1013
1023
|
$lines,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "archgraph-argo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
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": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"argo/skills/ea-human-reconcile",
|
|
18
18
|
"argo/rules",
|
|
19
19
|
"argo/package.json",
|
|
20
|
+
"argo/.env.example",
|
|
20
21
|
"vendor",
|
|
21
22
|
"install-argo.ps1",
|
|
22
23
|
"bin",
|