@aiwg/cli 2026.8.8 → 2026.8.11
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 +23 -8
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/agentic/code/providers/capability-matrix.yaml +3 -3
- package/bin/aiwg.mjs +125 -0
- package/dist/src/agents/packaged-agent-inventory.js +37 -1
- package/dist/src/artifacts/backends/graphology-backend.js +4 -3
- package/dist/src/artifacts/backends/sqlite-backend.js +4 -5
- package/dist/src/artifacts/cli.js +2 -2
- package/dist/src/artifacts/corpus-tools/cli.js +27 -0
- package/dist/src/artifacts/corpus-tools/profile-embed.js +3 -2
- package/dist/src/artifacts/corpus-tools/retrieval-lab.js +356 -0
- package/dist/src/artifacts/discover-facets.js +2 -2
- package/dist/src/artifacts/embedding-index.js +9 -8
- package/dist/src/artifacts/fortemi-core-sync.js +23 -8
- package/dist/src/artifacts/graph-backend.js +2 -2
- package/dist/src/artifacts/query-engine.js +21 -7
- package/dist/src/artifacts/repair.js +47 -0
- package/dist/src/artifacts/types.js +3 -3
- package/dist/src/cli/command-log.js +2 -2
- package/dist/src/cli/handlers/artifacts.js +50 -1
- package/dist/src/cli/handlers/cost-report.js +71 -0
- package/dist/src/cli/handlers/evidence.js +78 -0
- package/dist/src/cli/handlers/help.js +9 -0
- package/dist/src/cli/handlers/index.js +8 -3
- package/dist/src/cli/handlers/local-executor.js +4 -3
- package/dist/src/cli/handlers/refresh.js +63 -17
- package/dist/src/cli/handlers/regenerate.js +3 -3
- package/dist/src/cli/handlers/serve.js +15 -36
- package/dist/src/cli/handlers/setup-manifest.js +8 -1
- package/dist/src/cli/handlers/use.js +272 -70
- package/dist/src/cli/handlers/utilities.js +149 -0
- package/dist/src/cli/handlers/workspace.js +10 -0
- package/dist/src/cli/help-generator.js +2 -1
- package/dist/src/cli/router.js +4 -1
- package/dist/src/cli/services/deployment-verification.js +596 -0
- package/dist/src/cli/skill-usage.js +2 -2
- package/dist/src/cli/workflow-orchestrator.js +1 -1
- package/dist/src/cli/workspace-signals.js +2 -2
- package/dist/src/config/aiwg-config.js +54 -27
- package/dist/src/config/cli.js +3 -3
- package/dist/src/config/project-artifacts-health.js +2 -0
- package/dist/src/config/project-artifacts-health.mjs +123 -0
- package/dist/src/config/project-artifacts-runtime.mjs +16 -0
- package/dist/src/config/project-artifacts.js +2 -1
- package/dist/src/cost/fleet-report.js +329 -0
- package/dist/src/evidence/bundle.js +256 -0
- package/dist/src/extensions/commands/definitions.js +77 -25
- package/dist/src/extensions/deployment-registration.js +6 -4
- package/dist/src/features/catalog.js +26 -0
- package/dist/src/features/cli.js +1 -3
- package/dist/src/features/runtime.js +17 -1
- package/dist/src/issues/cli.js +91 -7
- package/dist/src/mcp/server.mjs +1 -1
- package/dist/src/ops/registry.js +2 -2
- package/dist/src/policy/authorization.js +2 -2
- package/dist/src/providers/capability-matrix.yaml +3 -3
- package/dist/src/providers/provider-definitions.js +7 -5
- package/dist/src/providers/provider-definitions.mjs +1 -1
- package/dist/src/serve/pty-bridge.js +2 -8
- package/dist/src/serve/screen-reader.js +3 -6
- package/dist/src/smiths/context-pipeline/aiwg-md.js +2 -2
- package/dist/src/smiths/context-pipeline/finalization.js +18 -5
- package/dist/src/smiths/context-pipeline/generator.js +2 -2
- package/dist/src/smiths/context-pipeline/workspace-context.js +16 -17
- package/package.json +2 -1
- package/tools/agents/deploy-agents.mjs +10 -11
- package/tools/agents/providers/base.mjs +61 -7
- package/tools/agents/providers/openclaw.mjs +5 -2
- package/tools/agents/providers/windsurf.mjs +13 -24
- package/tools/skills/deploy-skills-codex.mjs +21 -5
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { readFile, writeFile, mkdir, access, readdir, rename, unlink } from 'fs/promises';
|
|
12
12
|
import { createHash, randomBytes } from 'crypto';
|
|
13
|
-
import { resolve, join, isAbsolute } from 'path';
|
|
13
|
+
import { resolve, join, dirname, isAbsolute } from 'path';
|
|
14
14
|
import { normalizeNamedCaptures } from '../artifacts/index-builder.js';
|
|
15
15
|
import { getProviderDefinition, getProviderKernelSkillPath, PROVIDER_IDS, resolveProviderPathValue, } from '../providers/provider-definitions.js';
|
|
16
16
|
import { validateAuthorization, } from '../policy/authorization.js';
|
|
17
|
-
import { projectAiwgPath, resolveProjectAiwgDir } from './project-artifacts.js';
|
|
17
|
+
import { projectAiwgPath, projectControlPath, resolveProjectAiwgDir, } from './project-artifacts.js';
|
|
18
18
|
import { defaultThreatAssessmentConfig, validateThreatAssessmentConfig, } from '../security/threat-assessment-config.js';
|
|
19
19
|
const CONFIG_FILENAME = 'aiwg.config';
|
|
20
20
|
/**
|
|
@@ -658,11 +658,11 @@ export function emptyConfig(providers = ['claude']) {
|
|
|
658
658
|
/**
|
|
659
659
|
* Resolve path to the project-level AIWG config.
|
|
660
660
|
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
661
|
+
* The config is part of the repository-local control plane. Relocating the
|
|
662
|
+
* artifact corpus does not relocate this path.
|
|
663
663
|
*/
|
|
664
664
|
export function getConfigPath(projectDir) {
|
|
665
|
-
return
|
|
665
|
+
return projectControlPath(projectDir, CONFIG_FILENAME);
|
|
666
666
|
}
|
|
667
667
|
/**
|
|
668
668
|
* Resolve the project directory for a handler invocation.
|
|
@@ -685,12 +685,22 @@ export function getProjectDir(ctx, args = []) {
|
|
|
685
685
|
* Returns null if the file does not exist.
|
|
686
686
|
*/
|
|
687
687
|
export async function readAiwgConfig(projectDir) {
|
|
688
|
-
const
|
|
688
|
+
const localPath = getConfigPath(projectDir);
|
|
689
|
+
const artifactPath = projectAiwgPath(projectDir, CONFIG_FILENAME);
|
|
690
|
+
let filePath = localPath;
|
|
689
691
|
try {
|
|
690
|
-
await access(
|
|
692
|
+
await access(localPath);
|
|
691
693
|
}
|
|
692
694
|
catch {
|
|
693
|
-
|
|
695
|
+
if (artifactPath === localPath)
|
|
696
|
+
return null;
|
|
697
|
+
try {
|
|
698
|
+
await access(artifactPath);
|
|
699
|
+
filePath = artifactPath;
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
694
704
|
}
|
|
695
705
|
const content = await readFile(filePath, 'utf-8');
|
|
696
706
|
const parsed = JSON.parse(content);
|
|
@@ -722,34 +732,51 @@ export async function readAiwgConfig(projectDir) {
|
|
|
722
732
|
return parsed;
|
|
723
733
|
}
|
|
724
734
|
/**
|
|
725
|
-
* Write aiwg.config
|
|
735
|
+
* Write aiwg.config to the repository-local control plane. When a reachable
|
|
736
|
+
* external corpus also carries the compatibility control copy, keep it in
|
|
737
|
+
* sync so split-root health remains deterministic.
|
|
726
738
|
*/
|
|
727
739
|
export async function writeAiwgConfig(projectDir, config) {
|
|
728
740
|
const threatAssessmentErrors = validateThreatAssessmentConfig(config.security?.threatAssessment);
|
|
729
741
|
if (threatAssessmentErrors.length > 0) {
|
|
730
742
|
throw new Error(`Invalid .aiwg/aiwg.config:\n${threatAssessmentErrors.join('\n')}`);
|
|
731
743
|
}
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
}
|
|
744
|
-
catch (err) {
|
|
745
|
-
// Best-effort cleanup of the temp file on failure, ignoring ENOENT.
|
|
744
|
+
const localPath = getConfigPath(projectDir);
|
|
745
|
+
const artifactDir = resolveProjectAiwgDir(projectDir);
|
|
746
|
+
const artifactPath = join(artifactDir, CONFIG_FILENAME);
|
|
747
|
+
const content = JSON.stringify(config, null, 2) + '\n';
|
|
748
|
+
const writeAtomic = async (filePath) => {
|
|
749
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
750
|
+
// Atomic write: emit to a temp sibling, fsync-ish via rename. Prevents a
|
|
751
|
+
// crash or kill mid-write from corrupting the config file. Rename is
|
|
752
|
+
// atomic on POSIX and on NTFS when both paths are on the same volume.
|
|
753
|
+
// The random suffix avoids collisions if two concurrent writers run.
|
|
754
|
+
const tmpPath = `${filePath}.${randomBytes(6).toString('hex')}.tmp`;
|
|
746
755
|
try {
|
|
747
|
-
await
|
|
756
|
+
await writeFile(tmpPath, content, 'utf-8');
|
|
757
|
+
await rename(tmpPath, filePath);
|
|
748
758
|
}
|
|
749
|
-
catch {
|
|
750
|
-
|
|
759
|
+
catch (err) {
|
|
760
|
+
// Best-effort cleanup of the temp file on failure, ignoring ENOENT.
|
|
761
|
+
try {
|
|
762
|
+
await unlink(tmpPath);
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
/* ignore */
|
|
766
|
+
}
|
|
767
|
+
throw err;
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
await writeAtomic(localPath);
|
|
771
|
+
if (artifactPath !== localPath) {
|
|
772
|
+
try {
|
|
773
|
+
await access(artifactDir);
|
|
774
|
+
await writeAtomic(artifactPath);
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
if (error.code !== 'ENOENT')
|
|
778
|
+
throw error;
|
|
751
779
|
}
|
|
752
|
-
throw err;
|
|
753
780
|
}
|
|
754
781
|
}
|
|
755
782
|
/**
|
package/dist/src/config/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'url';
|
|
|
21
21
|
import path from 'path';
|
|
22
22
|
import { UserConfig } from './user-config.js';
|
|
23
23
|
import { AiwgError, EXIT_CODES } from '../cli/errors.js';
|
|
24
|
-
import {
|
|
24
|
+
import { projectControlPath, resolveProjectAiwgDir } from './project-artifacts.js';
|
|
25
25
|
const _scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
26
26
|
/**
|
|
27
27
|
* Main CLI entry point for `aiwg config <subcommand> [args]`
|
|
@@ -417,7 +417,7 @@ async function handleProjectValidate(args) {
|
|
|
417
417
|
? validateIssueLabels(cfg.issues)
|
|
418
418
|
: resolveIssueLabels(undefined, 'local').diagnostics;
|
|
419
419
|
const diagnostics = [...indexErrors, ...externalLinkErrors, ...labelDiagnostics];
|
|
420
|
-
console.log(`Project config: ${
|
|
420
|
+
console.log(`Project config: ${projectControlPath(projectDir, 'aiwg.config')}`);
|
|
421
421
|
console.log(`Artifact root: ${resolveProjectAiwgDir(projectDir)}\n`);
|
|
422
422
|
if (diagnostics.length === 0) {
|
|
423
423
|
console.log('✓ Project config valid');
|
|
@@ -608,7 +608,7 @@ For project-level config: aiwg config show --project [--json]
|
|
|
608
608
|
return;
|
|
609
609
|
}
|
|
610
610
|
// Human-readable view
|
|
611
|
-
console.log(`Project config: ${
|
|
611
|
+
console.log(`Project config: ${projectControlPath(projectDir, 'aiwg.config')}`);
|
|
612
612
|
console.log(`Artifact root: ${resolveProjectAiwgDir(projectDir)}\n`);
|
|
613
613
|
console.log(`Schema version: ${cfg.version}`);
|
|
614
614
|
console.log(`Providers: ${cfg.providers.join(', ') || '(none)'}`);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic health audit for projects whose artifact corpus is external.
|
|
3
|
+
*
|
|
4
|
+
* The repository-local `.aiwg` directory is the control plane. The configured
|
|
5
|
+
* artifact root is the corpus. This audit deliberately does not mutate either.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_PROJECT_AIWG_DIR,
|
|
12
|
+
PROJECT_AIWG_LOCATION_FILE,
|
|
13
|
+
readProjectArtifactLocation,
|
|
14
|
+
resolveProjectAiwgDir,
|
|
15
|
+
} from './project-artifacts-runtime.mjs';
|
|
16
|
+
|
|
17
|
+
export const PROJECT_CONTROL_PLANE_FILES = Object.freeze([
|
|
18
|
+
'AIWG.md',
|
|
19
|
+
'aiwg.config',
|
|
20
|
+
join('frameworks', 'registry.json'),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const ARTIFACT_ENV_KEYS = [
|
|
24
|
+
'AIWG_ARTIFACTS_PATH',
|
|
25
|
+
'AIWG_PROJECT_ARTIFACTS_PATH',
|
|
26
|
+
'AIWG_PROJECT_AIWG_DIR',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function configuredSource(projectDir, env) {
|
|
30
|
+
for (const key of ARTIFACT_ENV_KEYS) {
|
|
31
|
+
if (typeof env[key] === 'string' && env[key].trim()) return { kind: 'environment', key };
|
|
32
|
+
}
|
|
33
|
+
if (readProjectArtifactLocation(projectDir)) return { kind: 'pointer', path: join(projectDir, PROJECT_AIWG_LOCATION_FILE) };
|
|
34
|
+
return { kind: 'default' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function listFiles(root) {
|
|
38
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) return [];
|
|
39
|
+
const files = [];
|
|
40
|
+
const visit = (dir) => {
|
|
41
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
42
|
+
const absolute = join(dir, entry.name);
|
|
43
|
+
if (entry.isDirectory()) visit(absolute);
|
|
44
|
+
else if (entry.isFile()) files.push(relative(root, absolute));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
visit(root);
|
|
48
|
+
return files.sort();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sameFile(left, right) {
|
|
52
|
+
return existsSync(left) && existsSync(right) && readFileSync(left).equals(readFileSync(right));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function auditProjectArtifactHealth(projectDir, env = process.env) {
|
|
56
|
+
const root = resolve(projectDir);
|
|
57
|
+
const localRoot = join(root, DEFAULT_PROJECT_AIWG_DIR);
|
|
58
|
+
const artifactRoot = resolveProjectAiwgDir(root, env);
|
|
59
|
+
const source = configuredSource(root, env);
|
|
60
|
+
const externalConfigured = resolve(localRoot) !== resolve(artifactRoot);
|
|
61
|
+
const externalReachable = existsSync(artifactRoot) && statSync(artifactRoot).isDirectory();
|
|
62
|
+
|
|
63
|
+
const controls = PROJECT_CONTROL_PLANE_FILES.map((file) => {
|
|
64
|
+
const localPath = join(localRoot, file);
|
|
65
|
+
const externalPath = join(artifactRoot, file);
|
|
66
|
+
const local = existsSync(localPath);
|
|
67
|
+
const external = externalReachable && existsSync(externalPath);
|
|
68
|
+
return { file: file.split(sep).join('/'), local, external, identical: local && external ? sameFile(localPath, externalPath) : null };
|
|
69
|
+
});
|
|
70
|
+
const missingLocal = controls.filter((item) => !item.local).map((item) => item.file);
|
|
71
|
+
const divergentControl = controls.filter((item) => item.identical === false).map((item) => item.file);
|
|
72
|
+
const localPayload = listFiles(localRoot).filter((file) => !PROJECT_CONTROL_PLANE_FILES.includes(file));
|
|
73
|
+
const divergentPayload = externalReachable
|
|
74
|
+
? localPayload.filter((file) => !sameFile(join(localRoot, file), join(artifactRoot, file)))
|
|
75
|
+
: [];
|
|
76
|
+
|
|
77
|
+
let classification = 'local';
|
|
78
|
+
let severity = 'ok';
|
|
79
|
+
let repairable = false;
|
|
80
|
+
let action = null;
|
|
81
|
+
|
|
82
|
+
if (externalConfigured) {
|
|
83
|
+
if (!externalReachable) {
|
|
84
|
+
classification = 'degraded-offline';
|
|
85
|
+
severity = missingLocal.length ? 'error' : 'warning';
|
|
86
|
+
action = 'Reconnect or attach the external corpus, then run `aiwg artifacts repair --dry-run`.';
|
|
87
|
+
} else if (missingLocal.length) {
|
|
88
|
+
classification = 'legacy-missing-control-plane';
|
|
89
|
+
severity = 'error';
|
|
90
|
+
repairable = controls.filter((item) => !item.local).every((item) => item.external);
|
|
91
|
+
action = 'Run `aiwg artifacts repair --dry-run`, then `aiwg artifacts repair --apply` after reviewing the plan.';
|
|
92
|
+
} else if (divergentControl.length || divergentPayload.length) {
|
|
93
|
+
classification = 'duplicated-divergent';
|
|
94
|
+
severity = 'error';
|
|
95
|
+
action = 'Reconcile the reported local and external files manually; no automatic repair will overwrite divergent content.';
|
|
96
|
+
} else if (localPayload.length) {
|
|
97
|
+
classification = 'duplicated-identical';
|
|
98
|
+
severity = 'warning';
|
|
99
|
+
repairable = true;
|
|
100
|
+
action = 'Run `aiwg artifacts repair --dry-run`, then `aiwg artifacts repair --apply` to remove identical local corpus duplicates.';
|
|
101
|
+
} else {
|
|
102
|
+
classification = 'healthy-split-root';
|
|
103
|
+
severity = 'ok';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
classification,
|
|
109
|
+
severity,
|
|
110
|
+
external_configured: externalConfigured,
|
|
111
|
+
source,
|
|
112
|
+
local_control_root: localRoot,
|
|
113
|
+
artifact_root: artifactRoot,
|
|
114
|
+
external_reachable: externalReachable,
|
|
115
|
+
control_files: controls,
|
|
116
|
+
missing_local_control_files: missingLocal,
|
|
117
|
+
divergent_control_files: divergentControl,
|
|
118
|
+
duplicated_local_corpus_files: localPayload.map((file) => file.split(sep).join('/')),
|
|
119
|
+
divergent_local_corpus_files: divergentPayload.map((file) => file.split(sep).join('/')),
|
|
120
|
+
repairable,
|
|
121
|
+
action,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -63,6 +63,22 @@ export function resolveProjectAiwgDir(projectDir, env = process.env) {
|
|
|
63
63
|
return resolve(projectDir, DEFAULT_PROJECT_AIWG_DIR);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the repository-local AIWG control plane.
|
|
68
|
+
*
|
|
69
|
+
* `.aiwg-location` and the artifact path environment variables relocate the
|
|
70
|
+
* potentially large artifact corpus. They do not relocate the small control
|
|
71
|
+
* plane required to open and operate the repository while that corpus is
|
|
72
|
+
* offline.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveProjectControlDir(projectDir) {
|
|
75
|
+
return resolve(projectDir, DEFAULT_PROJECT_AIWG_DIR);
|
|
76
|
+
}
|
|
77
|
+
|
|
66
78
|
export function projectAiwgPath(projectDir, ...segments) {
|
|
67
79
|
return join(resolveProjectAiwgDir(projectDir), ...segments);
|
|
68
80
|
}
|
|
81
|
+
|
|
82
|
+
export function projectControlPath(projectDir, ...segments) {
|
|
83
|
+
return join(resolveProjectControlDir(projectDir), ...segments);
|
|
84
|
+
}
|
|
@@ -5,5 +5,6 @@
|
|
|
5
5
|
* override for the project-local `.aiwg/` artifact root. Keep that contract
|
|
6
6
|
* centralized so callers do not hardcode `<project>/.aiwg`.
|
|
7
7
|
*/
|
|
8
|
-
export { AIWG_ARTIFACTS_PATH_ENV, DEFAULT_PROJECT_AIWG_DIR, PROJECT_AIWG_LOCATION_FILE, expandProjectArtifactPath, parseProjectArtifactLocation, projectAiwgPath, readProjectArtifactLocation, resolveProjectAiwgDir, } from './project-artifacts-runtime.mjs';
|
|
8
|
+
export { AIWG_ARTIFACTS_PATH_ENV, DEFAULT_PROJECT_AIWG_DIR, PROJECT_AIWG_LOCATION_FILE, expandProjectArtifactPath, parseProjectArtifactLocation, projectAiwgPath, projectControlPath, readProjectArtifactLocation, resolveProjectAiwgDir, resolveProjectControlDir, } from './project-artifacts-runtime.mjs';
|
|
9
|
+
export { PROJECT_CONTROL_PLANE_FILES, auditProjectArtifactHealth, } from './project-artifacts-health.mjs';
|
|
9
10
|
//# sourceMappingURL=project-artifacts.js.map
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter fleet spend observation and local activity correlation.
|
|
3
|
+
*
|
|
4
|
+
* Secrets are resolved only from the process environment or the user's
|
|
5
|
+
* ~/.config/aiwg/keys directory. Fleet configuration contains references,
|
|
6
|
+
* never credential values.
|
|
7
|
+
*
|
|
8
|
+
* @issue #1187
|
|
9
|
+
*/
|
|
10
|
+
import { promises as fs } from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { load as loadYaml } from 'js-yaml';
|
|
14
|
+
const OPENROUTER_API = 'https://openrouter.ai/api/v1';
|
|
15
|
+
const KEY_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
16
|
+
const SECRET_FIELD_PATTERN = /(?:api[_-]?key|token|secret|credential|authorization)/i;
|
|
17
|
+
const GENERATION_ID_PATTERN = /(?:generation(?:_id|-id)?)[=:]\s*(gen-[A-Za-z0-9_-]+)/i;
|
|
18
|
+
const TAG_PATTERN = (name) => new RegExp(`${name}[=:]\\s*([A-Za-z0-9._/-]+)`, 'i');
|
|
19
|
+
export class FleetConfigMissingError extends Error {
|
|
20
|
+
configPath;
|
|
21
|
+
constructor(configPath) {
|
|
22
|
+
super(`No fleet config found at ${configPath}. Create it with a top-level fleet list; `
|
|
23
|
+
+ 'store only key_ref values there, and place credentials in ~/.config/aiwg/keys/ or AIWG_OPENROUTER_KEY_* environment variables.');
|
|
24
|
+
this.configPath = configPath;
|
|
25
|
+
this.name = 'FleetConfigMissingError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function hasEmbeddedSecret(value) {
|
|
29
|
+
if (typeof value === 'string')
|
|
30
|
+
return /^sk-or-/i.test(value.trim());
|
|
31
|
+
if (Array.isArray(value))
|
|
32
|
+
return value.some(hasEmbeddedSecret);
|
|
33
|
+
if (!value || typeof value !== 'object')
|
|
34
|
+
return false;
|
|
35
|
+
return Object.entries(value).some(([key, child]) => ((key !== 'key_ref' && SECRET_FIELD_PATTERN.test(key)) || hasEmbeddedSecret(child)));
|
|
36
|
+
}
|
|
37
|
+
function validateFleetConfig(value) {
|
|
38
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
39
|
+
throw new Error('Fleet config must be a YAML object.');
|
|
40
|
+
}
|
|
41
|
+
if (hasEmbeddedSecret(value)) {
|
|
42
|
+
throw new Error('Fleet config contains a credential-like field or value; store only key_ref values.');
|
|
43
|
+
}
|
|
44
|
+
const candidate = value;
|
|
45
|
+
if (candidate.provider && candidate.provider !== 'openrouter') {
|
|
46
|
+
throw new Error(`Unsupported fleet provider '${candidate.provider}'. Only openrouter is currently supported.`);
|
|
47
|
+
}
|
|
48
|
+
if (!Array.isArray(candidate.fleet) || candidate.fleet.length === 0) {
|
|
49
|
+
throw new Error('Fleet config must contain a non-empty fleet list.');
|
|
50
|
+
}
|
|
51
|
+
const seenBots = new Set();
|
|
52
|
+
const seenKeyRefs = new Set();
|
|
53
|
+
const fleet = candidate.fleet.map((raw, index) => {
|
|
54
|
+
if (!raw || typeof raw !== 'object')
|
|
55
|
+
throw new Error(`fleet[${index}] must be an object.`);
|
|
56
|
+
const entry = raw;
|
|
57
|
+
if (!entry.bot?.trim() || !entry.machine?.trim() || !entry.key_ref?.trim()) {
|
|
58
|
+
throw new Error(`fleet[${index}] requires bot, machine, and key_ref.`);
|
|
59
|
+
}
|
|
60
|
+
if (!KEY_REF_PATTERN.test(entry.key_ref)) {
|
|
61
|
+
throw new Error(`fleet[${index}].key_ref contains unsupported characters.`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof entry.monthly_cap !== 'number' || !Number.isFinite(entry.monthly_cap) || entry.monthly_cap < 0) {
|
|
64
|
+
throw new Error(`fleet[${index}].monthly_cap must be a non-negative number.`);
|
|
65
|
+
}
|
|
66
|
+
if (seenBots.has(entry.bot))
|
|
67
|
+
throw new Error(`Duplicate fleet bot '${entry.bot}'.`);
|
|
68
|
+
if (seenKeyRefs.has(entry.key_ref))
|
|
69
|
+
throw new Error(`Duplicate fleet key_ref '${entry.key_ref}'.`);
|
|
70
|
+
seenBots.add(entry.bot);
|
|
71
|
+
seenKeyRefs.add(entry.key_ref);
|
|
72
|
+
return {
|
|
73
|
+
bot: entry.bot.trim(),
|
|
74
|
+
machine: entry.machine.trim(),
|
|
75
|
+
key_ref: entry.key_ref,
|
|
76
|
+
monthly_cap: entry.monthly_cap,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
provider: 'openrouter',
|
|
81
|
+
...(typeof candidate.activity_log === 'string' ? { activity_log: candidate.activity_log } : {}),
|
|
82
|
+
fleet,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export async function loadFleetConfig(configPath) {
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = await fs.readFile(configPath, 'utf8');
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error.code === 'ENOENT')
|
|
92
|
+
throw new FleetConfigMissingError(configPath);
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
return validateFleetConfig(loadYaml(raw));
|
|
96
|
+
}
|
|
97
|
+
export function keyEnvironmentName(keyRef) {
|
|
98
|
+
return `AIWG_OPENROUTER_KEY_${keyRef.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
|
|
99
|
+
}
|
|
100
|
+
export async function loadFleetKey(keyRef, options = {}) {
|
|
101
|
+
if (!KEY_REF_PATTERN.test(keyRef))
|
|
102
|
+
throw new Error('Invalid key_ref.');
|
|
103
|
+
const env = options.env ?? process.env;
|
|
104
|
+
const environmentName = keyEnvironmentName(keyRef);
|
|
105
|
+
const environmentValue = env[environmentName]?.trim();
|
|
106
|
+
if (environmentValue)
|
|
107
|
+
return environmentValue;
|
|
108
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
109
|
+
const keyDirectory = path.resolve(homeDir, '.config', 'aiwg', 'keys');
|
|
110
|
+
const keyPath = path.resolve(keyDirectory, keyRef);
|
|
111
|
+
if (path.dirname(keyPath) !== keyDirectory)
|
|
112
|
+
throw new Error(`Invalid key_ref '${keyRef}'.`);
|
|
113
|
+
let directoryStat;
|
|
114
|
+
try {
|
|
115
|
+
directoryStat = await fs.lstat(keyDirectory);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error.code === 'ENOENT') {
|
|
119
|
+
throw new Error(`No secure credential directory exists for key_ref '${keyRef}'; use ${environmentName} or create ~/.config/aiwg/keys with mode 0700.`);
|
|
120
|
+
}
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
|
|
124
|
+
throw new Error('The AIWG credential directory must be a regular directory, not a symlink.');
|
|
125
|
+
}
|
|
126
|
+
if ((directoryStat.mode & 0o077) !== 0) {
|
|
127
|
+
throw new Error('The AIWG credential directory must not be accessible by group or other users.');
|
|
128
|
+
}
|
|
129
|
+
if (typeof process.getuid === 'function' && directoryStat.uid !== process.getuid()) {
|
|
130
|
+
throw new Error('The AIWG credential directory must be owned by the current user.');
|
|
131
|
+
}
|
|
132
|
+
let stat;
|
|
133
|
+
try {
|
|
134
|
+
stat = await fs.lstat(keyPath);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error.code === 'ENOENT') {
|
|
138
|
+
throw new Error(`No credential found for key_ref '${keyRef}' in the secure key directory or ${environmentName}.`);
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
143
|
+
throw new Error(`Credential for key_ref '${keyRef}' must be a regular file, not a symlink.`);
|
|
144
|
+
if ((stat.mode & 0o077) !== 0)
|
|
145
|
+
throw new Error(`Credential file for key_ref '${keyRef}' must not be accessible by group or other users.`);
|
|
146
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
147
|
+
throw new Error(`Credential file for key_ref '${keyRef}' must be owned by the current user.`);
|
|
148
|
+
}
|
|
149
|
+
const value = (await fs.readFile(keyPath, 'utf8')).trim();
|
|
150
|
+
if (!value)
|
|
151
|
+
throw new Error(`Credential file for key_ref '${keyRef}' is empty.`);
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
function parseActivityLog(raw) {
|
|
155
|
+
const entries = [];
|
|
156
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
157
|
+
const generationId = line.match(GENERATION_ID_PATTERN)?.[1];
|
|
158
|
+
if (!generationId)
|
|
159
|
+
continue;
|
|
160
|
+
const bot = line.match(TAG_PATTERN('bot'))?.[1];
|
|
161
|
+
const session = line.match(TAG_PATTERN('session'))?.[1] ?? generationId;
|
|
162
|
+
entries.push({ id: generationId, ...(bot ? { bot } : {}), session });
|
|
163
|
+
}
|
|
164
|
+
return entries;
|
|
165
|
+
}
|
|
166
|
+
async function readActivityLog(file) {
|
|
167
|
+
try {
|
|
168
|
+
return parseActivityLog(await fs.readFile(file, 'utf8'));
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error.code === 'ENOENT')
|
|
172
|
+
return [];
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function requestSignal(parent) {
|
|
177
|
+
const timeout = AbortSignal.timeout(15_000);
|
|
178
|
+
return parent ? AbortSignal.any([parent, timeout]) : timeout;
|
|
179
|
+
}
|
|
180
|
+
async function openRouterGet(endpoint, key, options) {
|
|
181
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
182
|
+
const response = await fetchImpl(`${options.apiBaseUrl ?? OPENROUTER_API}${endpoint}`, {
|
|
183
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
184
|
+
signal: requestSignal(options.signal),
|
|
185
|
+
});
|
|
186
|
+
if (!response.ok)
|
|
187
|
+
throw new Error(`OpenRouter request failed with status ${response.status}.`);
|
|
188
|
+
const payload = await response.json();
|
|
189
|
+
if (!payload || typeof payload !== 'object' || !payload.data)
|
|
190
|
+
throw new Error('OpenRouter returned an invalid response.');
|
|
191
|
+
return payload.data;
|
|
192
|
+
}
|
|
193
|
+
function correlateGenerations(records) {
|
|
194
|
+
const sessions = new Map();
|
|
195
|
+
const breakdown = {};
|
|
196
|
+
for (const { activity, data } of records) {
|
|
197
|
+
const cost = Number(data.total_cost ?? data.usage ?? 0);
|
|
198
|
+
const current = sessions.get(activity.session) ?? { session: activity.session, cost: 0, generations: 0 };
|
|
199
|
+
current.cost += Number.isFinite(cost) ? cost : 0;
|
|
200
|
+
current.generations += 1;
|
|
201
|
+
sessions.set(activity.session, current);
|
|
202
|
+
const tier = data.service_tier || data.model || 'unknown';
|
|
203
|
+
breakdown[tier] = (breakdown[tier] ?? 0) + (Number.isFinite(cost) ? cost : 0);
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
top_sessions: [...sessions.values()].sort((a, b) => b.cost - a.cost || a.session.localeCompare(b.session)).slice(0, 3),
|
|
207
|
+
model_tier_breakdown: Object.fromEntries(Object.entries(breakdown).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async function reportBot(entry, activity, options) {
|
|
211
|
+
try {
|
|
212
|
+
const key = await loadFleetKey(entry.key_ref, options);
|
|
213
|
+
const keyData = await openRouterGet('/key', key, options);
|
|
214
|
+
const spend = Number(keyData.usage_monthly ?? keyData.usage ?? 0);
|
|
215
|
+
if (!Number.isFinite(spend) || spend < 0)
|
|
216
|
+
throw new Error('OpenRouter returned invalid monthly usage.');
|
|
217
|
+
const matching = activity.filter(item => item.bot === entry.bot || (!item.bot && options.fleet?.length === 1));
|
|
218
|
+
const unique = [...new Map(matching.map(item => [item.id, item])).values()].slice(0, 100);
|
|
219
|
+
const generationResults = await Promise.allSettled(unique.map(async (item) => ({
|
|
220
|
+
activity: item,
|
|
221
|
+
data: await openRouterGet(`/generation?id=${encodeURIComponent(item.id)}`, key, options),
|
|
222
|
+
})));
|
|
223
|
+
const now = options.now ?? new Date();
|
|
224
|
+
const monthStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1);
|
|
225
|
+
const monthEnd = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
|
|
226
|
+
const generations = generationResults
|
|
227
|
+
.filter((result) => result.status === 'fulfilled')
|
|
228
|
+
.map(result => result.value)
|
|
229
|
+
.filter(({ data }) => {
|
|
230
|
+
if (!data.created_at)
|
|
231
|
+
return true;
|
|
232
|
+
const created = Date.parse(data.created_at);
|
|
233
|
+
return Number.isFinite(created) && created >= monthStart && created < monthEnd;
|
|
234
|
+
});
|
|
235
|
+
const correlated = correlateGenerations(generations);
|
|
236
|
+
const apiMonthlyCap = keyData.limit_reset === 'monthly' && typeof keyData.limit === 'number' ? keyData.limit : 0;
|
|
237
|
+
const cap = entry.monthly_cap > 0 ? entry.monthly_cap : apiMonthlyCap;
|
|
238
|
+
const percent = cap > 0 ? (spend / cap) * 100 : null;
|
|
239
|
+
const anomalies = [];
|
|
240
|
+
if (generationResults.some(result => result.status === 'rejected'))
|
|
241
|
+
anomalies.push('generation-correlation-partial');
|
|
242
|
+
if (percent !== null && percent >= 100)
|
|
243
|
+
anomalies.push('cap-exceeded');
|
|
244
|
+
else if (percent !== null && percent >= 80)
|
|
245
|
+
anomalies.push('cap-near-limit');
|
|
246
|
+
if (correlated.top_sessions[0] && spend > 0 && correlated.top_sessions[0].cost / spend >= 0.5) {
|
|
247
|
+
anomalies.push('single-session-spike');
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
bot: entry.bot,
|
|
251
|
+
machine: entry.machine,
|
|
252
|
+
spend_mtd: spend,
|
|
253
|
+
cap,
|
|
254
|
+
percent_used: percent,
|
|
255
|
+
...correlated,
|
|
256
|
+
anomalies,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
return {
|
|
261
|
+
bot: entry.bot,
|
|
262
|
+
machine: entry.machine,
|
|
263
|
+
spend_mtd: null,
|
|
264
|
+
cap: entry.monthly_cap,
|
|
265
|
+
percent_used: null,
|
|
266
|
+
top_sessions: [],
|
|
267
|
+
model_tier_breakdown: {},
|
|
268
|
+
anomalies: ['observation-error'],
|
|
269
|
+
error: error instanceof Error ? error.message : String(error),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
export async function generateFleetSpendReport(options) {
|
|
274
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
275
|
+
let fleet = options.fleet;
|
|
276
|
+
let configuredActivityLog;
|
|
277
|
+
if (!fleet) {
|
|
278
|
+
const configPath = options.configPath ?? path.join(homeDir, '.config', 'aiwg', 'fleet.yaml');
|
|
279
|
+
const config = await loadFleetConfig(configPath);
|
|
280
|
+
fleet = config.fleet;
|
|
281
|
+
configuredActivityLog = config.activity_log;
|
|
282
|
+
}
|
|
283
|
+
const activityLog = path.resolve(options.cwd, options.activityLog ?? configuredActivityLog ?? '.aiwg/activity.log');
|
|
284
|
+
const activity = await readActivityLog(activityLog);
|
|
285
|
+
const bots = await Promise.all(fleet.map(entry => reportBot(entry, activity, { ...options, homeDir })));
|
|
286
|
+
return {
|
|
287
|
+
source: 'openrouter',
|
|
288
|
+
observed_at: (options.now ?? new Date()).toISOString(),
|
|
289
|
+
enforcement: 'openrouter',
|
|
290
|
+
activity_log: activityLog,
|
|
291
|
+
bots,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function money(value) {
|
|
295
|
+
return value === null ? 'n/a' : `$${value.toFixed(2)}`;
|
|
296
|
+
}
|
|
297
|
+
export function formatFleetSpendReport(report) {
|
|
298
|
+
const headers = ['bot', 'machine', 'spend MTD', 'cap', '% used', 'top-3 expensive sessions'];
|
|
299
|
+
const rows = report.bots.map(bot => [
|
|
300
|
+
bot.bot,
|
|
301
|
+
bot.machine,
|
|
302
|
+
money(bot.spend_mtd),
|
|
303
|
+
money(bot.cap),
|
|
304
|
+
bot.percent_used === null ? 'n/a' : `${bot.percent_used.toFixed(1)}%`,
|
|
305
|
+
bot.top_sessions.length
|
|
306
|
+
? bot.top_sessions.map(session => `${session.session} (${money(session.cost)})`).join(', ')
|
|
307
|
+
: '—',
|
|
308
|
+
]);
|
|
309
|
+
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map(row => row[index].length)));
|
|
310
|
+
const line = (cells) => cells.map((cell, index) => cell.padEnd(widths[index])).join(' | ');
|
|
311
|
+
const output = [
|
|
312
|
+
'OpenRouter Fleet Cost Report',
|
|
313
|
+
`Observed: ${report.observed_at}`,
|
|
314
|
+
'AIWG observes and correlates spend; OpenRouter enforces all key limits and caps.',
|
|
315
|
+
'',
|
|
316
|
+
line(headers),
|
|
317
|
+
widths.map(width => '-'.repeat(width)).join('-|-'),
|
|
318
|
+
...rows.map(line),
|
|
319
|
+
];
|
|
320
|
+
for (const bot of report.bots) {
|
|
321
|
+
if (bot.anomalies.length)
|
|
322
|
+
output.push(`! ${bot.bot}: ${bot.anomalies.join(', ')}${bot.error ? ` (${bot.error})` : ''}`);
|
|
323
|
+
const breakdown = Object.entries(bot.model_tier_breakdown);
|
|
324
|
+
if (breakdown.length)
|
|
325
|
+
output.push(` ${bot.bot} model/tier: ${breakdown.map(([name, cost]) => `${name} ${money(cost)}`).join(', ')}`);
|
|
326
|
+
}
|
|
327
|
+
return output.join('\n');
|
|
328
|
+
}
|
|
329
|
+
//# sourceMappingURL=fleet-report.js.map
|