@shadowforge0/aquifer-memory 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/README.md +66 -0
- package/aquifer.config.example.json +19 -0
- package/consumers/cli.js +192 -12
- package/consumers/codex-active-checkpoint.js +186 -0
- package/consumers/codex-current-memory.js +106 -0
- package/consumers/codex-handoff.js +442 -3
- package/consumers/codex.js +164 -107
- package/consumers/mcp.js +144 -6
- package/consumers/shared/config.js +60 -1
- package/consumers/shared/factory.js +10 -3
- package/core/aquifer.js +351 -840
- package/core/backends/capabilities.js +89 -0
- package/core/backends/local.js +430 -0
- package/core/legacy-bootstrap.js +140 -0
- package/core/mcp-manifest.js +66 -2
- package/core/memory-promotion.js +157 -26
- package/core/memory-recall.js +341 -22
- package/core/memory-records.js +128 -8
- package/core/memory-serving.js +132 -0
- package/core/postgres-migrations.js +533 -0
- package/core/public-session-filter.js +40 -0
- package/core/recall-runtime.js +115 -0
- package/core/scope-attribution.js +279 -0
- package/core/session-checkpoint-producer.js +412 -0
- package/core/session-checkpoints.js +432 -0
- package/core/session-finalization.js +82 -1
- package/core/storage-checkpoints.js +546 -0
- package/core/storage.js +121 -8
- package/docs/setup.md +22 -0
- package/package.json +8 -4
- package/schema/014-v1-checkpoint-runs.sql +349 -0
- package/schema/015-v1-evidence-items.sql +92 -0
- package/schema/016-v1-evidence-ref-multi-item.sql +19 -0
- package/schema/017-v1-memory-record-embeddings.sql +25 -0
- package/schema/018-v1-finalization-candidate-envelope.sql +39 -0
- package/scripts/codex-checkpoint-commands.js +464 -0
- package/scripts/codex-checkpoint-runtime.js +520 -0
- package/scripts/codex-recovery.js +105 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const PUBLIC_PLACEHOLDER_SUMMARY_PATTERN = '(空測試會話|測試會話無實質內容|x 字元填充|placeholder)';
|
|
4
|
+
const PUBLIC_PLACEHOLDER_SUMMARY_RE = new RegExp(PUBLIC_PLACEHOLDER_SUMMARY_PATTERN, 'i');
|
|
5
|
+
|
|
6
|
+
function publicPlaceholderSummarySql(alias = 'ss') {
|
|
7
|
+
return `(
|
|
8
|
+
COALESCE(${alias}.summary_text, '') ~* '${PUBLIC_PLACEHOLDER_SUMMARY_PATTERN}'
|
|
9
|
+
OR COALESCE(${alias}.structured_summary::text, '') ~* '${PUBLIC_PLACEHOLDER_SUMMARY_PATTERN}'
|
|
10
|
+
)`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isPublicPlaceholderSessionMaterial(row = {}) {
|
|
14
|
+
const summaryText = String(row.summary_text || row.summaryText || '').trim();
|
|
15
|
+
if (summaryText && PUBLIC_PLACEHOLDER_SUMMARY_RE.test(summaryText)) return true;
|
|
16
|
+
|
|
17
|
+
const structuredSummary = row.structured_summary ?? row.structuredSummary ?? null;
|
|
18
|
+
if (structuredSummary === null || structuredSummary === undefined) return false;
|
|
19
|
+
|
|
20
|
+
if (typeof structuredSummary === 'string') {
|
|
21
|
+
return PUBLIC_PLACEHOLDER_SUMMARY_RE.test(structuredSummary);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
return PUBLIC_PLACEHOLDER_SUMMARY_RE.test(JSON.stringify(structuredSummary));
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function filterPublicPlaceholderSessionRows(rows = []) {
|
|
32
|
+
return rows.filter(row => !isPublicPlaceholderSessionMaterial(row));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = {
|
|
36
|
+
PUBLIC_PLACEHOLDER_SUMMARY_PATTERN,
|
|
37
|
+
filterPublicPlaceholderSessionRows,
|
|
38
|
+
isPublicPlaceholderSessionMaterial,
|
|
39
|
+
publicPlaceholderSummarySql,
|
|
40
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createEmbedder } = require('../pipeline/embed');
|
|
4
|
+
|
|
5
|
+
function buildRerankDocument(row, maxChars) {
|
|
6
|
+
const ss = row.structured_summary || null;
|
|
7
|
+
const parts = [];
|
|
8
|
+
if (ss) {
|
|
9
|
+
if (ss.title) parts.push(String(ss.title).trim());
|
|
10
|
+
if (ss.overview) parts.push(String(ss.overview).trim());
|
|
11
|
+
if (Array.isArray(ss.topics)) {
|
|
12
|
+
const topics = ss.topics
|
|
13
|
+
.map(t => typeof t === 'string' ? t : (t && t.name ? `${t.name}${t.summary ? ': ' + t.summary : ''}` : ''))
|
|
14
|
+
.filter(Boolean).join(' / ');
|
|
15
|
+
if (topics) parts.push(topics);
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(ss.decisions)) {
|
|
18
|
+
const decisions = ss.decisions
|
|
19
|
+
.map(d => typeof d === 'string' ? d : (d && d.decision ? d.decision : ''))
|
|
20
|
+
.filter(Boolean).join(' / ');
|
|
21
|
+
if (decisions) parts.push(`Decisions: ${decisions}`);
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(ss.open_loops)) {
|
|
24
|
+
const loops = ss.open_loops
|
|
25
|
+
.map(l => typeof l === 'string' ? l : (l && l.item ? l.item : ''))
|
|
26
|
+
.filter(Boolean).join(' / ');
|
|
27
|
+
if (loops) parts.push(`Open loops: ${loops}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!parts.length) {
|
|
31
|
+
const bare = (row.summary_text || row.summary_snippet || '').trim();
|
|
32
|
+
if (bare) parts.push(bare);
|
|
33
|
+
}
|
|
34
|
+
const turn = (row.matched_turn_text || '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
if (turn) {
|
|
36
|
+
const joined = parts.join(' \n ');
|
|
37
|
+
if (!joined.includes(turn)) parts.push(`Matched turn: ${turn}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let text = parts.join('\n\n').replace(/[ \t]+/g, ' ').trim();
|
|
41
|
+
if (text.length > maxChars) text = text.slice(0, maxChars);
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveEmbedFn(embedConfig, env) {
|
|
46
|
+
if (embedConfig && typeof embedConfig.fn === 'function') {
|
|
47
|
+
return embedConfig.fn;
|
|
48
|
+
}
|
|
49
|
+
if (embedConfig && embedConfig.provider) {
|
|
50
|
+
const embedder = createEmbedder(embedConfig);
|
|
51
|
+
return (texts) => embedder.embedBatch(texts);
|
|
52
|
+
}
|
|
53
|
+
const provider = env.EMBED_PROVIDER;
|
|
54
|
+
if (!provider) return null;
|
|
55
|
+
|
|
56
|
+
const opts = { provider };
|
|
57
|
+
if (provider === 'ollama') {
|
|
58
|
+
opts.ollamaUrl = env.OLLAMA_URL || env.AQUIFER_EMBED_BASE_URL || 'http://localhost:11434';
|
|
59
|
+
opts.model = env.AQUIFER_EMBED_MODEL || 'bge-m3';
|
|
60
|
+
} else if (provider === 'openai') {
|
|
61
|
+
opts.openaiApiKey = env.OPENAI_API_KEY;
|
|
62
|
+
if (!opts.openaiApiKey) {
|
|
63
|
+
throw new Error('EMBED_PROVIDER=openai requires OPENAI_API_KEY');
|
|
64
|
+
}
|
|
65
|
+
opts.openaiModel = env.AQUIFER_EMBED_MODEL || 'text-embedding-3-small';
|
|
66
|
+
if (env.AQUIFER_EMBED_DIM) opts.openaiDimensions = Number(env.AQUIFER_EMBED_DIM);
|
|
67
|
+
} else {
|
|
68
|
+
throw new Error(`EMBED_PROVIDER=${provider} not supported by autodetect (use 'ollama' or 'openai', or pass config.embed.fn explicitly)`);
|
|
69
|
+
}
|
|
70
|
+
const embedder = createEmbedder(opts);
|
|
71
|
+
return (texts) => embedder.embedBatch(texts);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function shouldAutoRerank({ query, mode, ranked, hasEntities, autoTrigger }) {
|
|
75
|
+
if (!autoTrigger.enabled) return { apply: false, reason: 'auto_disabled' };
|
|
76
|
+
|
|
77
|
+
if (hasEntities && autoTrigger.alwaysWhenEntities) {
|
|
78
|
+
return { apply: true, reason: 'entities_present' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const len = ranked.length;
|
|
82
|
+
if (len < autoTrigger.minResults) return { apply: false, reason: 'too_few_results' };
|
|
83
|
+
if (len > autoTrigger.maxResults) return { apply: false, reason: 'too_many_results' };
|
|
84
|
+
|
|
85
|
+
const q = String(query || '').trim();
|
|
86
|
+
const tokenCount = q.split(/\s+/).filter(Boolean).length;
|
|
87
|
+
if (q.length < autoTrigger.minQueryChars && tokenCount < autoTrigger.minQueryTokens) {
|
|
88
|
+
return { apply: false, reason: 'query_too_short' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (mode === 'fts') {
|
|
92
|
+
if (len > autoTrigger.ftsMinResults) return { apply: true, reason: 'fts_wide_shortlist' };
|
|
93
|
+
return { apply: false, reason: 'fts_shortlist_too_narrow' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!autoTrigger.modes.includes(mode)) {
|
|
97
|
+
return { apply: false, reason: 'mode_not_in_autotrigger_modes' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (len >= 2) {
|
|
101
|
+
const s0 = ranked[0]?._score ?? 0;
|
|
102
|
+
const s1 = ranked[1]?._score ?? 0;
|
|
103
|
+
if (s0 - s1 <= autoTrigger.maxTopScoreGap) {
|
|
104
|
+
return { apply: true, reason: 'top_score_gap_close' };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { apply: false, reason: 'top_score_gap_wide' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
buildRerankDocument,
|
|
113
|
+
resolveEmbedFn,
|
|
114
|
+
shouldAutoRerank,
|
|
115
|
+
};
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const SLOT_ORDER = ['host', 'workspace', 'project', 'repo', 'session', 'task'];
|
|
6
|
+
const PROMOTABLE_SLOT_IDS = new Set(['host', 'workspace', 'project', 'repo']);
|
|
7
|
+
const GENERIC_KEYS = new Set([
|
|
8
|
+
'',
|
|
9
|
+
'default',
|
|
10
|
+
'global',
|
|
11
|
+
'main',
|
|
12
|
+
'na',
|
|
13
|
+
'n/a',
|
|
14
|
+
'none',
|
|
15
|
+
'null',
|
|
16
|
+
'unknown',
|
|
17
|
+
'unset',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function normalizeText(value) {
|
|
21
|
+
if (value === undefined || value === null) return null;
|
|
22
|
+
const text = String(value).trim();
|
|
23
|
+
return text ? text : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function collapseWhitespace(value) {
|
|
27
|
+
const text = normalizeText(value);
|
|
28
|
+
return text ? text.replace(/\s+/g, ' ') : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function slugify(value) {
|
|
32
|
+
const text = normalizeText(value);
|
|
33
|
+
if (!text) return null;
|
|
34
|
+
const slug = text
|
|
35
|
+
.normalize('NFKD')
|
|
36
|
+
.replace(/[^\w\s:/.-]+/g, ' ')
|
|
37
|
+
.trim()
|
|
38
|
+
.replace(/\s+/g, '-')
|
|
39
|
+
.replace(/-+/g, '-')
|
|
40
|
+
.replace(/^-|-$/g, '')
|
|
41
|
+
.toLowerCase();
|
|
42
|
+
return slug || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isGenericKey(value) {
|
|
46
|
+
const key = slugify(value) || String(value || '').trim().toLowerCase();
|
|
47
|
+
return GENERIC_KEYS.has(key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toFactObject(value, aliases = []) {
|
|
51
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
|
52
|
+
const text = normalizeText(value);
|
|
53
|
+
if (!text) return null;
|
|
54
|
+
const key = aliases[0] || 'value';
|
|
55
|
+
return { [key]: text };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pickValue(fact, keys = []) {
|
|
59
|
+
if (!fact) return null;
|
|
60
|
+
for (const key of keys) {
|
|
61
|
+
const value = normalizeText(fact[key]);
|
|
62
|
+
if (value) return value;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function pickLabel(fact, fallback) {
|
|
68
|
+
return collapseWhitespace(pickValue(fact, ['label', 'title', 'name', 'displayName']) || fallback);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizePrefixedScope(scopeKey, expectedPrefix) {
|
|
72
|
+
const raw = normalizeText(scopeKey);
|
|
73
|
+
if (!raw) return null;
|
|
74
|
+
const match = raw.match(/^([a-z_]+):(.*)$/i);
|
|
75
|
+
if (!match) return null;
|
|
76
|
+
const [, prefix, rest] = match;
|
|
77
|
+
if (prefix !== expectedPrefix) return null;
|
|
78
|
+
const body = normalizeText(rest);
|
|
79
|
+
if (!body) return null;
|
|
80
|
+
return `${expectedPrefix}:${body}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizePathScope(prefix, rawPath) {
|
|
84
|
+
const value = normalizeText(rawPath);
|
|
85
|
+
if (!value) return null;
|
|
86
|
+
return `${prefix}:${path.resolve(value)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildHostScope(rawFact) {
|
|
90
|
+
const fact = toFactObject(rawFact, ['host']);
|
|
91
|
+
if (!fact) return null;
|
|
92
|
+
const fromScopeKey = normalizePrefixedScope(pickValue(fact, ['scopeKey', 'scope_key']), 'host_runtime');
|
|
93
|
+
const key = fromScopeKey
|
|
94
|
+
|| (() => {
|
|
95
|
+
const value = pickValue(fact, ['key', 'id', 'host', 'runtime', 'source', 'name', 'label']);
|
|
96
|
+
if (!value || isGenericKey(value)) return null;
|
|
97
|
+
const slug = slugify(value);
|
|
98
|
+
return slug ? `host_runtime:${slug}` : null;
|
|
99
|
+
})();
|
|
100
|
+
if (!key) return null;
|
|
101
|
+
return {
|
|
102
|
+
id: 'host',
|
|
103
|
+
slot: 'host',
|
|
104
|
+
scopeKind: 'host_runtime',
|
|
105
|
+
scopeKey: key,
|
|
106
|
+
label: pickLabel(fact, key.slice('host_runtime:'.length)),
|
|
107
|
+
raw: fact,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildWorkspaceScope(rawFact) {
|
|
112
|
+
const fact = toFactObject(rawFact, ['path']);
|
|
113
|
+
if (!fact) return null;
|
|
114
|
+
const fromScopeKey = normalizePrefixedScope(pickValue(fact, ['scopeKey', 'scope_key']), 'workspace');
|
|
115
|
+
const key = fromScopeKey || normalizePathScope('workspace', pickValue(fact, ['path', 'workspacePath', 'root', 'id']));
|
|
116
|
+
if (!key) return null;
|
|
117
|
+
const scopePath = key.slice('workspace:'.length);
|
|
118
|
+
return {
|
|
119
|
+
id: 'workspace',
|
|
120
|
+
slot: 'workspace',
|
|
121
|
+
scopeKind: 'workspace',
|
|
122
|
+
scopeKey: key,
|
|
123
|
+
label: pickLabel(fact, scopePath),
|
|
124
|
+
raw: fact,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildProjectScope(rawFact) {
|
|
129
|
+
const fact = toFactObject(rawFact, ['key']);
|
|
130
|
+
if (!fact) return null;
|
|
131
|
+
const fromScopeKey = normalizePrefixedScope(pickValue(fact, ['scopeKey', 'scope_key']), 'project');
|
|
132
|
+
const key = fromScopeKey
|
|
133
|
+
|| (() => {
|
|
134
|
+
const value = pickValue(fact, ['key', 'slug', 'projectKey', 'projectSlug', 'id', 'name', 'label']);
|
|
135
|
+
if (!value || isGenericKey(value)) return null;
|
|
136
|
+
const slug = slugify(value);
|
|
137
|
+
return slug ? `project:${slug}` : null;
|
|
138
|
+
})();
|
|
139
|
+
if (!key) return null;
|
|
140
|
+
return {
|
|
141
|
+
id: 'project',
|
|
142
|
+
slot: 'project',
|
|
143
|
+
scopeKind: 'project',
|
|
144
|
+
scopeKey: key,
|
|
145
|
+
label: pickLabel(fact, key.slice('project:'.length)),
|
|
146
|
+
raw: fact,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildRepoScope(rawFact) {
|
|
151
|
+
const fact = toFactObject(rawFact, ['path']);
|
|
152
|
+
if (!fact) return null;
|
|
153
|
+
const fromScopeKey = normalizePrefixedScope(pickValue(fact, ['scopeKey', 'scope_key']), 'repo');
|
|
154
|
+
const key = fromScopeKey || normalizePathScope('repo', pickValue(fact, ['path', 'repoPath', 'root', 'repoRoot']));
|
|
155
|
+
if (!key) return null;
|
|
156
|
+
const repoPath = key.slice('repo:'.length);
|
|
157
|
+
return {
|
|
158
|
+
id: 'repo',
|
|
159
|
+
slot: 'repo',
|
|
160
|
+
scopeKind: 'repo',
|
|
161
|
+
scopeKey: key,
|
|
162
|
+
label: pickLabel(fact, path.basename(repoPath) || repoPath),
|
|
163
|
+
raw: fact,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildSessionScope(rawFact) {
|
|
168
|
+
const fact = toFactObject(rawFact, ['id']);
|
|
169
|
+
if (!fact) return null;
|
|
170
|
+
const fromScopeKey = normalizePrefixedScope(pickValue(fact, ['scopeKey', 'scope_key']), 'session');
|
|
171
|
+
const key = fromScopeKey
|
|
172
|
+
|| (() => {
|
|
173
|
+
const value = pickValue(fact, ['id', 'key', 'sessionId', 'sessionKey']);
|
|
174
|
+
return value ? `session:${value}` : null;
|
|
175
|
+
})();
|
|
176
|
+
if (!key) return null;
|
|
177
|
+
return {
|
|
178
|
+
id: 'session',
|
|
179
|
+
slot: 'session',
|
|
180
|
+
scopeKind: 'session',
|
|
181
|
+
scopeKey: key,
|
|
182
|
+
label: pickLabel(fact, key.slice('session:'.length)),
|
|
183
|
+
raw: fact,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function buildTaskScope(rawFact) {
|
|
188
|
+
const fact = toFactObject(rawFact, ['id']);
|
|
189
|
+
if (!fact) return null;
|
|
190
|
+
const fromScopeKey = normalizePrefixedScope(pickValue(fact, ['scopeKey', 'scope_key']), 'task');
|
|
191
|
+
const key = fromScopeKey
|
|
192
|
+
|| (() => {
|
|
193
|
+
const value = pickValue(fact, ['id', 'key', 'taskId', 'taskKey']);
|
|
194
|
+
return value ? `task:${value}` : null;
|
|
195
|
+
})();
|
|
196
|
+
if (!key) return null;
|
|
197
|
+
return {
|
|
198
|
+
id: 'task',
|
|
199
|
+
slot: 'task',
|
|
200
|
+
scopeKind: 'task',
|
|
201
|
+
scopeKey: key,
|
|
202
|
+
label: pickLabel(fact, key.slice('task:'.length)),
|
|
203
|
+
raw: fact,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function buildScopeForSlot(slotId, input) {
|
|
208
|
+
switch (slotId) {
|
|
209
|
+
case 'host':
|
|
210
|
+
return buildHostScope(input.host || input.hostRuntime || input.source);
|
|
211
|
+
case 'workspace':
|
|
212
|
+
return buildWorkspaceScope(input.workspace || input.workspacePath);
|
|
213
|
+
case 'project':
|
|
214
|
+
return buildProjectScope(input.project || input.projectKey || input.projectSlug);
|
|
215
|
+
case 'repo':
|
|
216
|
+
return buildRepoScope(input.repo || input.repoPath);
|
|
217
|
+
case 'session':
|
|
218
|
+
return buildSessionScope(input.session || input.sessionId || input.sessionKey);
|
|
219
|
+
case 'task':
|
|
220
|
+
return buildTaskScope(input.task || input.taskId || input.taskKey);
|
|
221
|
+
default:
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function allowedScopeKeysForSlots(scopes) {
|
|
227
|
+
const seen = new Set(['global']);
|
|
228
|
+
const active = ['global'];
|
|
229
|
+
return scopes.map((scope) => {
|
|
230
|
+
if (PROMOTABLE_SLOT_IDS.has(scope.id) && !seen.has(scope.scopeKey)) {
|
|
231
|
+
seen.add(scope.scopeKey);
|
|
232
|
+
active.push(scope.scopeKey);
|
|
233
|
+
}
|
|
234
|
+
return active.slice();
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function buildScopeEnvelope(input = {}) {
|
|
239
|
+
const scopes = [];
|
|
240
|
+
const seenIds = new Set();
|
|
241
|
+
|
|
242
|
+
for (const slotId of SLOT_ORDER) {
|
|
243
|
+
const scope = buildScopeForSlot(slotId, input);
|
|
244
|
+
if (!scope || seenIds.has(scope.id)) continue;
|
|
245
|
+
seenIds.add(scope.id);
|
|
246
|
+
scopes.push(scope);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const slotAllowedScopeKeys = allowedScopeKeysForSlots(scopes);
|
|
250
|
+
const allowedScopeKeys = slotAllowedScopeKeys[slotAllowedScopeKeys.length - 1] || ['global'];
|
|
251
|
+
const promotableScopes = scopes.filter(scope => PROMOTABLE_SLOT_IDS.has(scope.id));
|
|
252
|
+
const activeScope = promotableScopes[promotableScopes.length - 1] || null;
|
|
253
|
+
const slots = scopes.map((scope, index) => ({
|
|
254
|
+
...scope,
|
|
255
|
+
promotable: PROMOTABLE_SLOT_IDS.has(scope.id),
|
|
256
|
+
allowedScopeKeys: slotAllowedScopeKeys[index],
|
|
257
|
+
}));
|
|
258
|
+
const scopeById = Object.fromEntries(slots.map(scope => [scope.id, scope]));
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
policyVersion: 'scope_envelope_v1',
|
|
262
|
+
activeSlotId: activeScope ? activeScope.id : 'global',
|
|
263
|
+
activeScopeKey: activeScope ? activeScope.scopeKey : 'global',
|
|
264
|
+
allowedScopeKeys,
|
|
265
|
+
slots,
|
|
266
|
+
scopeById,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function getScopeByEnvelopeId(envelope, id) {
|
|
271
|
+
const scope = envelope && envelope.scopeById ? envelope.scopeById[id] : null;
|
|
272
|
+
if (!scope) throw new Error(`Unknown scope envelope id: ${id}`);
|
|
273
|
+
return scope;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
module.exports = {
|
|
277
|
+
buildScopeEnvelope,
|
|
278
|
+
getScopeByEnvelopeId,
|
|
279
|
+
};
|