mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
feat(context): suggest skills at prompt time from a generation-bound index
This commit is contained in:
1 parent
d0e2736df2
commit
9ac68d3713
13 files changed
+337
-21
No files matched your search
@@ -62,6 +62,28 @@ An agent can call the resolver at task boundaries and read the returned context.
|
||||
|
||||
The task call sends the query and selected reference content on standard input to `codex exec -` or `claude --print`, with no added task permissions or hook overrides. Current-provider launches inherit the provider process environment. An isolated native launch passes only the pinned home paths, `PATH`, a fixed locale, a private temporary directory, and required Windows system root; caller credentials, proxy settings, runtime injection and unrelated secrets are excluded. Its timeout is 90 seconds after a proposal or 120 seconds without one, uses an uncatchable termination signal, and captures at most 1 MiB. Dry run reports the pending proposal without a provider call. A zero provider exit code records process completion; task success and native skill invocation remain unverified. Routine interactive turns outside this launcher do not gain automatic routing.
|
||||
|
||||
## Prompt suggestions (opt-in hook)
|
||||
|
||||
Ordinary interactive turns can receive advisory skill suggestions from a UserPromptSubmit hook. The hook prints up to three skill IDs with one-line descriptions and the `resolve` command to load one; it never returns a skill body, changes the saved mode or selection, or grants authority. Loading still goes through `resolve`, which re-verifies canonical sources.
|
||||
|
||||
`ecc profile routing-index --state-root <store>` writes a metadata index for the current managed generation: the entries the resolver could suggest (not excluded, admissible without explicit selection), their curated triggers and precomputed retrieval vectors. Building refuses a store whose generation no longer matches the canonical sources. A small pointer named by the generation binds the index to the state receipt; the entries file is named by the SHA-256 of its bytes. A `set`, `mode` or `rollback` retires the index until it is rebuilt, and the hook then stays silent with a stderr diagnostic. `--dry-run` reports whether the current index exists.
|
||||
|
||||
The hook is inert unless `ECC_CONTEXT_STATE_ROOT` names the store, and it is not registered by default. It is also silent in manual mode, for slash commands, prompts under 12 characters and malformed input, and fails open when its 150 ms budget (`ECC_CONTEXT_SUGGEST_BUDGET_MS`) is spent. An optional async SessionStart hook builds a missing index. To opt in, add to Claude settings, with `<ecc-root>` the ECC installation:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": { "ECC_CONTEXT_STATE_ROOT": "/absolute/dedicated/profile-store" },
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [{ "hooks": [{ "type": "command",
|
||||
"command": "node \"<ecc-root>/scripts/hooks/run-with-flags.js\" user-prompt:context-suggestions scripts/hooks/context-prompt-suggestions.js" }] }],
|
||||
"SessionStart": [{ "hooks": [{ "type": "command", "async": true, "timeout": 30,
|
||||
"command": "node \"<ecc-root>/scripts/hooks/run-with-flags.js\" session:context-routing-index scripts/hooks/context-routing-index.js" }] }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Both hooks honor `ECC_HOOKS_ENABLED`, `ECC_HOOK_PROFILE` and `ECC_DISABLED_HOOKS` through `run-with-flags`.
|
||||
|
||||
## Isolated native Codex generations
|
||||
|
||||
`prepare-native` registers the managed carrier in a fresh ECC-owned home, verifies exact discovery through the allowlisted Codex 0.154.0 or 0.155.1 binary, and only then selects that native generation. It writes a bounded `AGENTS.md` bootstrap bound to the installed CLI source, managed roots, carrier, executable and receipt. It copies no credentials or user configuration and never rewrites the user's provider home. `native-status` checks the recorded generation, executable fingerprint, bootstrap source identity and managed-store binding. A launch pins that verified binary instead of resolving a different executable from PATH. Explicit preparation can refresh a changed executable or installed-source binding while preserving the prior generation and receipts.
|
||||
@@ -80,7 +102,7 @@ Real execution requires an explicit flag and provider authentication. Codex uses
|
||||
|
||||
## Community integration
|
||||
|
||||
Jeffrey Montoya's [#2788](https://github.com/affaan-m/ECC/pull/2788) informed whole-tree staging, ownership receipts and reversible generations. LovePlayCode's [#2844](https://github.com/affaan-m/ECC/pull/2844) informed deterministic grouping and explicit exclusion. Jeffrey's [#2945](https://github.com/affaan-m/ECC/pull/2945) informed bounded ID/description ranking and deterministic ties. Canonical source digests replace independent routing-cache authority. [#2740](https://github.com/affaan-m/ECC/pull/2740) remains aligned with native context meters and truthful measurement labels.
|
||||
Jeffrey Montoya's [#2788](https://github.com/affaan-m/ECC/pull/2788) informed whole-tree staging, ownership receipts and reversible generations. LovePlayCode's [#2844](https://github.com/affaan-m/ECC/pull/2844) informed deterministic grouping and explicit exclusion. Jeffrey's [#2945](https://github.com/affaan-m/ECC/pull/2945) informed bounded ID/description ranking, deterministic ties and the suggest-only prompt hook. Canonical source digests replace independent routing-cache authority. [#2740](https://github.com/affaan-m/ECC/pull/2740) remains aligned with native context meters and truthful measurement labels.
|
||||
|
||||
These are attributed adaptations of concepts; contributor commits have not been silently relabeled as our implementation. Source PR disposition remains separate.
|
||||
|
||||
|
||||
@@ -97,4 +97,13 @@ The 77-prompt routing corpus from #2945 (52 direct, 25 paraphrased) found one wr
|
||||
- Corpus result: zero loads outside the expected set; 19 implicit admissions become 17. The other dropped admission, `skill:cost-tracking`, now defers to the bounded proposal instead of loading.
|
||||
- The existing nine-query auto/agent corpus, fallback, launch, evaluation and retrieval suites pass unchanged.
|
||||
|
||||
## Prompt suggestions
|
||||
|
||||
The suggest-only prompt hook from #2945 now reads a generation-bound metadata index instead of its own catalog cache.
|
||||
|
||||
- RED: routing-index and hook cases fail before the modules exist. GREEN adds a stored-vector key to the entry shape, a stale-source refusal and the session-start builder; all 16 cases pass, with a retrieval case proving stored vectors rank identically to computed ones.
|
||||
- The resolver path took 300 to 500 ms per prompt from a source checkout, almost all in rehashing 293 skills and 584 files. Loading Ajv lazily and storing vectors moved the hook's own work to about 95 to 110 ms on a Full Claude index (276 entries, 1.8 MB); wrapper invocations measured 140 to 180 ms against a 71 ms disabled baseline on Linux x64.
|
||||
- Selection, retrieval, profile CLI and hook-wrapper suites pass unchanged.
|
||||
- No interactive Claude or Codex session loaded the hook; suggestion quality is bounded by the resolver ranking measured above.
|
||||
|
||||
These boundaries keep the shipped behavior distinct from the M1 release gate. Authenticated outcome observations, a complete Tier 2 disk diff, live-install migration, other-provider activation, whole-context token truth and release defaults remain unverified until their explicit prerequisites are available.
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* UserPromptSubmit hook: suggest up to three skills from the managed profile's
|
||||
* routing index. Suggest-only; it loads no skill body, changes no selection,
|
||||
* and grants nothing. Opt in by setting ECC_CONTEXT_STATE_ROOT to the store
|
||||
* used with `ecc profile set`, and build the index with
|
||||
* `ecc profile routing-index --state-root <store>`.
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
const MIN_PROMPT_LENGTH = 12;
|
||||
const MAX_PROMPT_BYTES = 8192;
|
||||
const DEFAULT_BUDGET_MS = 150;
|
||||
|
||||
function budgetMs(env) {
|
||||
const value = Number(env.ECC_CONTEXT_SUGGEST_BUDGET_MS);
|
||||
return Number.isFinite(value) && value >= 0 ? Math.min(value, 1000) : DEFAULT_BUDGET_MS;
|
||||
}
|
||||
|
||||
function promptFrom(rawInput) {
|
||||
try {
|
||||
const input = JSON.parse(rawInput);
|
||||
return typeof input?.prompt === 'string' ? input.prompt : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function message(stateRoot, suggestions) {
|
||||
return ['ECC context suggestions (advisory; nothing was loaded):',
|
||||
...suggestions.map(item => `- ${item.id}: ${item.description}`),
|
||||
`To load one, run ecc profile resolve --state-root ${JSON.stringify(stateRoot)} --task-input - --load with its ID in explicitIds or proposedIds.`,
|
||||
''].join('\n');
|
||||
}
|
||||
|
||||
function run(rawInput, _context = {}, env = process.env) {
|
||||
const started = Date.now();
|
||||
const stateRoot = env.ECC_CONTEXT_STATE_ROOT;
|
||||
if (!stateRoot || !path.isAbsolute(stateRoot) || path.resolve(stateRoot) !== stateRoot) return '';
|
||||
const prompt = promptFrom(rawInput);
|
||||
if (!prompt || prompt.trim().length < MIN_PROMPT_LENGTH || prompt.trimStart().startsWith('/')
|
||||
|| Buffer.byteLength(prompt) > MAX_PROMPT_BYTES) return '';
|
||||
const budget = budgetMs(env);
|
||||
try {
|
||||
const routing = require('../lib/context-routing-index');
|
||||
if (Date.now() - started >= budget) return '';
|
||||
const index = routing.readRoutingIndex(stateRoot);
|
||||
if (!index) {
|
||||
return { stdout: '', stderr: '[ContextSuggest] No routing index for the current profile; run ecc profile routing-index --state-root <store>.' };
|
||||
}
|
||||
if (index.selectionMode === 'manual' || Date.now() - started >= budget) return '';
|
||||
const suggestions = routing.suggestContext(index, prompt);
|
||||
if (!suggestions.length || Date.now() - started > budget) return '';
|
||||
return message(stateRoot, suggestions);
|
||||
} catch (error) {
|
||||
return { stdout: '', stderr: `[ContextSuggest] ${String(error.message).replace(/[^\x20-\x7E]/g, '?')}` };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* SessionStart hook (register with "async": true): build the routing index
|
||||
* for ECC_CONTEXT_STATE_ROOT when the current managed generation has none.
|
||||
* Reads canonical skill sources; writes only inside the managed store.
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
function run(_rawInput, _context = {}, env = process.env) {
|
||||
const stateRoot = env.ECC_CONTEXT_STATE_ROOT;
|
||||
if (!stateRoot || !path.isAbsolute(stateRoot) || path.resolve(stateRoot) !== stateRoot) return '';
|
||||
try {
|
||||
const routing = require('../lib/context-routing-index');
|
||||
if (routing.routingIndexStatus(stateRoot).status === 'current') return '';
|
||||
const written = routing.writeRoutingIndex({ stateRoot });
|
||||
return { stdout: '', stderr: `[ContextSuggest] Routing index built with ${written.entries} entries.` };
|
||||
} catch (error) {
|
||||
return { stdout: '', stderr: `[ContextSuggest] ${String(error.message).replace(/[^\x20-\x7E]/g, '?')}` };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
@@ -5,7 +5,7 @@ const fs = require('node:fs');
|
||||
const { createSourceReader } = require('./context-profile-support');
|
||||
|
||||
const NATIVE_COMMANDS = ['prepare-native', 'native-status', 'native-rollback', 'native-recover'];
|
||||
const COMMANDS = ['start', 'resolve', 'run', 'set', 'mode', 'status', 'rollback', 'recover', ...NATIVE_COMMANDS];
|
||||
const COMMANDS = ['start', 'resolve', 'run', 'set', 'mode', 'status', 'rollback', 'recover', 'routing-index', ...NATIVE_COMMANDS];
|
||||
const VALUE_FLAGS = ['--task-input', '--previous', '--expected-digest', '--state-root', '--expected-revision',
|
||||
'--target', '--selection', '--include', '--exclude', '--native-root'];
|
||||
|
||||
@@ -136,6 +136,11 @@ function execute(options) {
|
||||
}
|
||||
return { selection: resolveTaskContext(input) };
|
||||
}
|
||||
if (options.command === 'routing-index') {
|
||||
const routing = require('./context-routing-index');
|
||||
return { routing: options.dryRun ? routing.routingIndexStatus(options['state-root'])
|
||||
: routing.writeRoutingIndex({ stateRoot: options['state-root'] }) };
|
||||
}
|
||||
const store = require('./context-profile-store');
|
||||
const common = { stateRoot: options['state-root'],
|
||||
...(options['expected-revision'] === undefined ? {} : { expectedRevision: Number(options['expected-revision']) }) };
|
||||
@@ -165,6 +170,7 @@ function run(argv) {
|
||||
summary: options.command === 'start' ? 'Opt-in interactive Codex uses the verified isolated generation and inherited terminal. Context selection remains advisory.'
|
||||
: options.command === 'run' ? 'Task launch uses selected context and the provider configuration. Inspect the launch result.'
|
||||
: options.command === 'resolve' ? 'Task context resolved within the selected profile.'
|
||||
: options.command === 'routing-index' ? 'Routing index inspected. Prompt suggestions stay advisory; resolve verifies sources before loading.'
|
||||
: 'Managed profile generation inspected. Native activation is a separate provider boundary.',
|
||||
activation: value.selection?.activation || 'unobserved', next_actions: [], artifacts: [], ...value };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Ajv = require('ajv');
|
||||
const { SUPPORTED_INSTALL_TARGETS } = require('./install-manifests');
|
||||
|
||||
const DEFAULT_REPO_ROOT = path.resolve(__dirname, '../..');
|
||||
@@ -187,6 +186,8 @@ const schemaValidators = new Map();
|
||||
function validateSchema(value, schemaName) {
|
||||
if (!schemaValidators.has(schemaName)) {
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(DEFAULT_REPO_ROOT, 'schemas', schemaName), 'utf8'));
|
||||
// Loaded on first validation: prompt-time readers never validate schemas.
|
||||
const Ajv = require('ajv');
|
||||
schemaValidators.set(schemaName, new Ajv({ allErrors: true, strict: true }).compile(schema));
|
||||
}
|
||||
const validate = schemaValidators.get(schemaName);
|
||||
|
||||
@@ -84,9 +84,27 @@ function fieldTokens(entry, field) {
|
||||
return tokenize(`${entry.ownerModuleId || ''} ${entry.packId || ''}`);
|
||||
}
|
||||
|
||||
// A precomputed dense leg as sorted [dimension, weight] pairs. JSON preserves
|
||||
// the doubles exactly, so a stored vector ranks identically to a computed one.
|
||||
function sparseDense(entry) {
|
||||
const vector = denseVector([fieldTokens(entry, 'name'), fieldTokens(entry, 'description')]);
|
||||
return vector.flatMap((value, dimension) => value === 0 ? [] : [[dimension, value]]);
|
||||
}
|
||||
|
||||
function expandDense(pairs) {
|
||||
const vector = new Array(DENSE_DIM).fill(0);
|
||||
for (const pair of pairs) {
|
||||
if (!Array.isArray(pair) || pair.length !== 2 || !Number.isInteger(pair[0]) || pair[0] < 0 || pair[0] >= DENSE_DIM
|
||||
|| !Number.isFinite(pair[1])) throw new Error('Invalid precomputed dense vector');
|
||||
vector[pair[0]] = pair[1];
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
|
||||
/** Build a reusable retrieval index over registry-shaped entries. Entries may
|
||||
* carry a `triggers` array (from the checked-in skill-triggers manifest) that
|
||||
* is weighted between name and description. */
|
||||
* is weighted between name and description, and a `dense` array from
|
||||
* sparseDense to skip recomputing the n-gram leg. */
|
||||
function buildRetrievalIndex(entries) {
|
||||
const documents = entries.map(entry => {
|
||||
const fields = {};
|
||||
@@ -102,7 +120,7 @@ function buildRetrievalIndex(entries) {
|
||||
}
|
||||
}
|
||||
return { entry, fields, weighted, docLength,
|
||||
dense: denseVector([fields.name, fields.description]),
|
||||
dense: entry.dense ? expandDense(entry.dense) : denseVector([fields.name, fields.description]),
|
||||
aliases: [...new Set([entry.id.slice('skill:'.length), entry.name].filter(Boolean).map(normalizedName))] };
|
||||
});
|
||||
const documentFrequency = new Map();
|
||||
@@ -184,5 +202,5 @@ function searchRetrieval(index, query, { limit = 5 } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { buildRetrievalIndex, searchRetrieval, tokenize,
|
||||
module.exports = { buildRetrievalIndex, searchRetrieval, sparseDense, tokenize,
|
||||
internals: { denseVector, dot, DENSE_ADMIT_COSINE, DENSE_DIM } };
|
||||
@@ -0,0 +1,117 @@
|
||||
'use strict';
|
||||
|
||||
// A metadata-only routing index for prompt-time suggestions. Each index is
|
||||
// named by the managed generation it was built for and bound to that state's
|
||||
// receipt, so a store change retires it. It never authorizes loading: the
|
||||
// resolver re-verifies canonical sources before returning any skill body.
|
||||
const path = require('node:path');
|
||||
const io = require('./context-profile-store-fs');
|
||||
const { buildRetrievalIndex, searchRetrieval, sparseDense } = require('./context-retrieval');
|
||||
const { DEFAULT_REPO_ROOT, digestObject, stableStringify } = require('./context-profile-support');
|
||||
|
||||
const SCHEMA = 'ecc.context-routing-index.v1';
|
||||
const POINTER_SCHEMA = 'ecc.context-routing-pointer.v1';
|
||||
const DIGEST = /^[a-f0-9]{64}$/;
|
||||
const MAX_SUGGESTIONS = 3;
|
||||
const MAX_DESCRIPTION = 120;
|
||||
|
||||
function checkedRoot(stateRoot) {
|
||||
if (typeof stateRoot !== 'string' || !path.isAbsolute(stateRoot) || path.resolve(stateRoot) !== stateRoot) {
|
||||
throw new Error('stateRoot must be an absolute managed store directory');
|
||||
}
|
||||
return stateRoot;
|
||||
}
|
||||
|
||||
// Reads only the ownership marker, state pointer and its receipt: enough to
|
||||
// bind an index to the current generation without rehashing the payload.
|
||||
function currentBinding(stateRoot) {
|
||||
const root = checkedRoot(stateRoot);
|
||||
const marker = { schemaVersion: 'ecc.context-store.v1', destinationDigest: digestObject({ root }) };
|
||||
const markerFile = path.join(root, 'store.json');
|
||||
if (!io.inspect(markerFile, true).stat || stableStringify(io.readJson(markerFile)) !== stableStringify(marker)) {
|
||||
throw new Error('Directory is not an owned ECC managed store');
|
||||
}
|
||||
const state = io.readJson(path.join(root, 'state.json'));
|
||||
if (state.schemaVersion !== 'ecc.context-store-state.v1' || !DIGEST.test(state.receiptDigest) || !DIGEST.test(state.generationDigest)) {
|
||||
throw new Error('Managed state integrity mismatch');
|
||||
}
|
||||
const receipt = io.readJson(path.join(root, 'receipts', `${state.receiptDigest}.json`));
|
||||
if (digestObject(receipt) !== state.receiptDigest || receipt.generationDigest !== state.generationDigest) {
|
||||
throw new Error('Managed receipt and state integrity mismatch');
|
||||
}
|
||||
return { root, generationDigest: state.generationDigest, receiptDigest: state.receiptDigest };
|
||||
}
|
||||
|
||||
// routing/<generation>.json is a small pointer bound to the state receipt; the
|
||||
// entries live in routing/indexes/<sha256 of bytes>.json so readers verify them
|
||||
// by hashing raw bytes instead of re-serializing a large object.
|
||||
const pointerPath = (root, generationDigest) => path.join(root, 'routing', `${generationDigest}.json`);
|
||||
const entriesPath = (root, digest) => path.join(root, 'routing', 'indexes', `${digest}.json`);
|
||||
|
||||
/** Build the index for the configured, recovered store. Reads canonical sources. */
|
||||
function writeRoutingIndex({ stateRoot, repoRoot = DEFAULT_REPO_ROOT } = {}) {
|
||||
const root = checkedRoot(stateRoot);
|
||||
const status = require('./context-profile-store').getStoreStatus({ stateRoot: root });
|
||||
if (!status.configured || status.recoveryRequired) throw new Error('Routing index requires a configured, recovered managed store');
|
||||
const binding = currentBinding(root);
|
||||
if (binding.receiptDigest !== status.receiptDigest) throw new Error('Managed state changed during routing index build');
|
||||
const carrier = require('./context-carriers').planContextCarrier({ repoRoot, profileId: status.profileId, target: status.target,
|
||||
selectionMode: status.selectionMode, include: status.include, exclude: status.exclude });
|
||||
if (carrier.carrierDigest !== status.carrierDigest) throw new Error('Stored profile source is stale; set the current generation before indexing');
|
||||
const { routingEntries } = require('./context-selection');
|
||||
const routing = routingEntries({ repoRoot, profileId: status.profileId, target: status.target,
|
||||
selectionMode: status.selectionMode, include: status.include, exclude: status.exclude });
|
||||
const bytes = io.jsonBytes({ schemaVersion: SCHEMA, generationDigest: binding.generationDigest, receiptDigest: binding.receiptDigest,
|
||||
...routing, entries: routing.entries.map(entry => ({ ...entry, dense: sparseDense(entry) })) });
|
||||
const digest = io.hash(bytes);
|
||||
const file = entriesPath(root, digest);
|
||||
io.mkdir(path.join(root, 'routing')); io.mkdir(path.dirname(file));
|
||||
if (!io.inspect(file, true).stat) io.writeExclusive(file, bytes);
|
||||
else if (!io.read(file).equals(bytes)) throw new Error('Routing index content changed under its digest');
|
||||
if (currentBinding(root).receiptDigest !== binding.receiptDigest) throw new Error('Managed state changed during routing index build');
|
||||
const pointer = { schemaVersion: POINTER_SCHEMA, generationDigest: binding.generationDigest,
|
||||
receiptDigest: binding.receiptDigest, indexDigest: digest, bytes: bytes.length };
|
||||
io.atomicJson(pointerPath(root, binding.generationDigest), { ...pointer, pointerDigest: digestObject(pointer) });
|
||||
return { status: 'written', path: file, generationDigest: binding.generationDigest, entries: routing.entries.length };
|
||||
}
|
||||
|
||||
/** Return the index for the current generation, null when none was built. */
|
||||
function readRoutingIndex(stateRoot) {
|
||||
const binding = currentBinding(stateRoot);
|
||||
const file = pointerPath(binding.root, binding.generationDigest);
|
||||
if (!io.inspect(path.dirname(file), true).stat || !io.inspect(file, true).stat) return null;
|
||||
const invalid = () => new Error('Routing index integrity mismatch; rebuild it with ecc profile routing-index');
|
||||
const { pointerDigest, ...pointer } = io.readJson(file);
|
||||
if (pointer.schemaVersion !== POINTER_SCHEMA || digestObject(pointer) !== pointerDigest || !DIGEST.test(pointer.indexDigest || '')
|
||||
|| pointer.generationDigest !== binding.generationDigest || pointer.receiptDigest !== binding.receiptDigest) throw invalid();
|
||||
const bytes = io.read(entriesPath(binding.root, pointer.indexDigest));
|
||||
if (bytes.length !== pointer.bytes || io.hash(bytes) !== pointer.indexDigest) throw invalid();
|
||||
const index = JSON.parse(bytes.toString('utf8'));
|
||||
if (index.schemaVersion !== SCHEMA || index.generationDigest !== binding.generationDigest
|
||||
|| index.receiptDigest !== binding.receiptDigest || !Array.isArray(index.entries)) throw invalid();
|
||||
return index;
|
||||
}
|
||||
|
||||
function routingIndexStatus(stateRoot) {
|
||||
const binding = currentBinding(stateRoot);
|
||||
const index = readRoutingIndex(stateRoot);
|
||||
return { status: index ? 'current' : 'missing', path: pointerPath(binding.root, binding.generationDigest),
|
||||
generationDigest: binding.generationDigest, entries: index ? index.entries.length : 0 };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARACTERS = new RegExp('[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]+', 'g');
|
||||
const singleLine = text => String(text).replace(CONTROL_CHARACTERS, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
/** Rank index entries for a prompt. IDs and one-line descriptions only. */
|
||||
function suggestContext(index, prompt, { limit = MAX_SUGGESTIONS } = {}) {
|
||||
return searchRetrieval(buildRetrievalIndex(index.entries), prompt, { limit: Math.min(limit, MAX_SUGGESTIONS) })
|
||||
.filter(candidate => /^skill:[a-z0-9]+(?:-[a-z0-9]+)*$/.test(candidate.id))
|
||||
.map(candidate => {
|
||||
const description = singleLine(candidate.description);
|
||||
return { id: candidate.id,
|
||||
description: description.length > MAX_DESCRIPTION ? `${description.slice(0, MAX_DESCRIPTION - 1)}…` : description };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { readRoutingIndex, routingIndexStatus, suggestContext, writeRoutingIndex };
|
||||
@@ -140,6 +140,39 @@ function validatePrevious(previous) {
|
||||
}
|
||||
}
|
||||
|
||||
function admissibility(byId, excluded, reader) {
|
||||
return id => {
|
||||
try {
|
||||
const closure = selectedClosure([id], new Set(), byId, excluded, reader);
|
||||
readSelected(closure, byId, reader);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Only known admission denials remove a suggestion. Source drift and
|
||||
// malformed policy still fail closed instead of disappearing from view.
|
||||
if (/manual-only|requires native authority|is excluded|exceeds the skill limit|32000-byte budget|not UTF-8 text/.test(error.message)) return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Entries the resolver could suggest for a profile: not excluded and admissible
|
||||
* without explicit selection. Metadata only; bodies are never returned. */
|
||||
function routingEntries({ repoRoot = DEFAULT_REPO_ROOT, profileId = 'lean@1', target = 'codex',
|
||||
selectionMode = 'auto', include = [], exclude = [] } = {}) {
|
||||
const plan = compileContextProfile({ repoRoot, profileId, target, selectionMode, include, exclude });
|
||||
const registry = loadContextRegistry({ repoRoot });
|
||||
const { triggers } = loadSkillTriggers({ repoRoot });
|
||||
if (registry.registryDigest !== plan.registryDigest) throw new Error('Registry changed during routing index build');
|
||||
const byId = new Map(registry.entries.map(entry => [entry.id, entry]));
|
||||
const excluded = new Set(plan.excludedIds);
|
||||
const admissible = admissibility(byId, excluded, createSourceReader(repoRoot));
|
||||
const entries = registry.entries.filter(entry => !excluded.has(entry.id) && admissible(entry.id))
|
||||
.map(entry => ({ id: entry.id, name: entry.name, description: entry.description,
|
||||
ownerModuleId: entry.ownerModuleId, packId: entry.packId, ...(triggers[entry.id] ? { triggers: triggers[entry.id] } : {}) }));
|
||||
return { profileId: plan.profileId, target, selectionMode, planDigest: plan.planDigest, registryDigest: registry.registryDigest,
|
||||
routingPolicyVersion: ROUTING_POLICY_VERSION, triggersDigest: digestObject(triggers), entries };
|
||||
}
|
||||
|
||||
/** Pure task-scoped resolver. Returned context never invokes a native skill or changes permissions. */
|
||||
function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'lean@1', target = 'codex',
|
||||
selectionMode = 'auto', include = [], exclude = [], load = false, previous = null, expectedDigest = null } = {}) {
|
||||
@@ -164,18 +197,7 @@ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'l
|
||||
queryDigest: digestObject(task.query || '') });
|
||||
const reused = Boolean(previous && previous.bindingDigest === bindingDigest && !task.noWorkflow
|
||||
&& ['selected', 'none'].includes(previous.decision) && !explicitIds.length && !proposedIds.length);
|
||||
const admissible = id => {
|
||||
try {
|
||||
const closure = selectedClosure([id], new Set(), byId, excluded, reader);
|
||||
readSelected(closure, byId, reader);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Only known admission denials remove a suggestion. Source drift and
|
||||
// malformed policy still fail closed instead of disappearing from view.
|
||||
if (/manual-only|requires native authority|is excluded|exceeds the skill limit|32000-byte budget|not UTF-8 text/.test(error.message)) return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const admissible = admissibility(byId, excluded, reader);
|
||||
const { candidates } = task.noWorkflow || selectionMode === 'manual' || reused
|
||||
? { candidates: [] } : candidatesFor(task.query || '', registry.entries, excluded, admissible, triggers);
|
||||
// Auto admission: free-text routing loads the ranked top skill only on
|
||||
@@ -277,4 +299,4 @@ function resolveDeclinedFallback(options, selection) {
|
||||
receipt: { ...receiptValue, receiptDigest: digestObject(receiptValue) } };
|
||||
}
|
||||
|
||||
module.exports = { resolveTaskContext, resolveDeclinedFallback };
|
||||
module.exports = { resolveTaskContext, resolveDeclinedFallback, routingEntries };
|
||||
@@ -35,6 +35,7 @@ Experimental managed profiles and bounded task context:
|
||||
ecc profile mode auto|manual|suggest --state-root <directory> [--dry-run] [--json]
|
||||
ecc profile rollback --state-root <directory> [--expected-revision N] [--json]
|
||||
ecc profile recover --state-root <directory> [--json]
|
||||
ecc profile routing-index --state-root <directory> [--dry-run] [--json]
|
||||
ecc profile start --state-root <directory> --native-root <directory> [--dry-run]
|
||||
ecc profile prepare-native --state-root <directory> --native-root <dedicated-directory> [--dry-run] [--json]
|
||||
ecc profile native-status --state-root <directory> --native-root <directory> [--json]
|
||||
@@ -46,6 +47,7 @@ and reads a receipt-bound isolated AGENTS bootstrap. Authenticate separately in
|
||||
Set stages owned generations; provider discovery is verified separately.
|
||||
Resolve returns context only with --load; suggest and --dry-run never return skill bodies.
|
||||
Use resolve --state-root <directory> to honor the saved base, mode and exclusions.
|
||||
Routing-index writes the metadata index read by the opt-in prompt suggestion hook; rebuild it after set, mode or rollback.
|
||||
Run with --native-root to use a verified isolated Codex generation. Existing sessions are unchanged.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ test('a missing index is a diagnostic, never an error or a rebuild', () => fixtu
|
||||
test('a tampered index or exhausted budget fails open to no suggestion', () => fixture(({ stateRoot, env }) => {
|
||||
assert.equal(stdout(hook.run(PROMPT, {}, { ...env, ECC_CONTEXT_SUGGEST_BUDGET_MS: '0' })), '');
|
||||
const directory = path.join(stateRoot, 'routing');
|
||||
const file = path.join(directory, fs.readdirSync(directory)[0]);
|
||||
const file = path.join(directory, fs.readdirSync(directory).find(name => name.endsWith('.json')));
|
||||
fs.writeFileSync(file, '{"schemaVersion":"ecc.context-routing-index.v1"}');
|
||||
const result = hook.run(PROMPT, {}, env);
|
||||
assert.equal(stdout(result), '');
|
||||
@@ -76,3 +76,17 @@ test('the hook runs through run-with-flags and returns its text on stdout', () =
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /skill:feature/);
|
||||
}));
|
||||
|
||||
test('the session-start builder indexes the current generation once and stays silent when disabled', () => {
|
||||
const builder = require('../../scripts/hooks/context-routing-index');
|
||||
const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-routing-builder-'));
|
||||
const stateRoot = path.join(parent, 'managed');
|
||||
try {
|
||||
assert.equal(builder.run('{}', {}, {}), '');
|
||||
store.applyStore({ stateRoot, target: 'claude' });
|
||||
const env = { ECC_CONTEXT_STATE_ROOT: stateRoot };
|
||||
assert.match(builder.run('{}', {}, env).stderr, /built/);
|
||||
assert.equal(routing.routingIndexStatus(stateRoot).status, 'current');
|
||||
assert.equal(builder.run('{}', {}, env), '');
|
||||
} finally { fs.rmSync(parent, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -105,3 +105,14 @@ test('actual registry: every candidate carries matched terms and a fused score',
|
||||
assert.equal(candidate.description, candidate.description.slice(0, 2048));
|
||||
}
|
||||
});
|
||||
|
||||
test('precomputed dense vectors rank identically to computed ones', () => {
|
||||
const { sparseDense } = require('../../scripts/lib/context-retrieval');
|
||||
const { entries } = loadContextRegistry({ repoRoot: DEFAULT_REPO_ROOT });
|
||||
const computed = buildRetrievalIndex(entries);
|
||||
const stored = buildRetrievalIndex(JSON.parse(JSON.stringify(entries.map(entry => ({ ...entry, dense: sparseDense(entry) })))));
|
||||
for (const query of ['review a postgres migration that adds an indexed column', 'navigate faster', 'two services own one record']) {
|
||||
assert.deepEqual(searchRetrieval(stored, query, { limit: 15 }), searchRetrieval(computed, query, { limit: 15 }));
|
||||
}
|
||||
assert.throws(() => buildRetrievalIndex([{ ...entries[0], dense: [[2048, 1]] }]), /dense vector/);
|
||||
});
|
||||
@@ -30,7 +30,7 @@ test('the index is bound to the current managed generation and holds only admiss
|
||||
const ids = index.entries.map(entry => entry.id);
|
||||
assert.ok(ids.includes('skill:feature'));
|
||||
assert.equal(ids.includes('skill:shared'), false);
|
||||
assert.deepEqual(Object.keys(index.entries[0]).sort(), ['description', 'id', 'name', 'ownerModuleId', 'packId']);
|
||||
assert.deepEqual(Object.keys(index.entries[0]).sort(), ['dense', 'description', 'id', 'name', 'ownerModuleId', 'packId']);
|
||||
if (process.platform !== 'win32') assert.equal(fs.statSync(written.path).mode & 0o077, 0);
|
||||
}));
|
||||
|
||||
@@ -98,3 +98,9 @@ test('the profile CLI writes and inspects the index for a stored profile', () =>
|
||||
assert.equal(JSON.parse(cli('routing-index', '--state-root', stateRoot, '--dry-run').stdout).routing.status, 'current');
|
||||
} finally { fs.rmSync(parent, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('an index is never built from sources that differ from the stored generation', () => fixture(({ repoRoot, stateRoot }) => {
|
||||
store.applyStore({ repoRoot, stateRoot, target: 'claude' });
|
||||
assert.throws(() => routing.writeRoutingIndex({ stateRoot }), /stale/);
|
||||
assert.equal(fs.existsSync(path.join(stateRoot, 'routing')), false);
|
||||
}));
|
||||
Reference in new issue
Block a user