@aiwg/cli 2026.8.7 → 2026.8.10

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.
Files changed (76) hide show
  1. package/README.md +23 -8
  2. package/THIRD_PARTY_NOTICES.md +35 -0
  3. package/agentic/code/providers/capability-matrix.yaml +3 -3
  4. package/bin/aiwg.mjs +125 -0
  5. package/dist/src/artifacts/backends/graphology-backend.js +4 -3
  6. package/dist/src/artifacts/backends/sqlite-backend.js +4 -5
  7. package/dist/src/artifacts/cli.js +2 -2
  8. package/dist/src/artifacts/corpus-tools/cli.js +27 -0
  9. package/dist/src/artifacts/corpus-tools/profile-embed.js +3 -2
  10. package/dist/src/artifacts/corpus-tools/retrieval-lab.js +356 -0
  11. package/dist/src/artifacts/discover-facets.js +2 -2
  12. package/dist/src/artifacts/embedding-index.js +9 -8
  13. package/dist/src/artifacts/graph-backend.js +2 -2
  14. package/dist/src/artifacts/move.js +40 -2
  15. package/dist/src/artifacts/query-engine.js +21 -7
  16. package/dist/src/artifacts/repair.js +47 -0
  17. package/dist/src/artifacts/types.js +3 -3
  18. package/dist/src/cli/command-log.js +2 -2
  19. package/dist/src/cli/handlers/artifacts.js +50 -1
  20. package/dist/src/cli/handlers/cost-report.js +71 -0
  21. package/dist/src/cli/handlers/evidence.js +78 -0
  22. package/dist/src/cli/handlers/help.js +9 -0
  23. package/dist/src/cli/handlers/index.js +8 -3
  24. package/dist/src/cli/handlers/local-executor.js +4 -3
  25. package/dist/src/cli/handlers/refresh.js +20 -8
  26. package/dist/src/cli/handlers/regenerate.js +22 -12
  27. package/dist/src/cli/handlers/serve.js +15 -36
  28. package/dist/src/cli/handlers/sessions.js +5 -4
  29. package/dist/src/cli/handlers/setup-manifest.js +8 -1
  30. package/dist/src/cli/handlers/subcommands.js +30 -0
  31. package/dist/src/cli/handlers/use.js +272 -89
  32. package/dist/src/cli/handlers/utilities.js +149 -0
  33. package/dist/src/cli/handlers/workspace.js +10 -0
  34. package/dist/src/cli/help-generator.js +2 -1
  35. package/dist/src/cli/regenerate-selector.js +94 -0
  36. package/dist/src/cli/router.js +4 -1
  37. package/dist/src/cli/services/deployment-verification.js +596 -0
  38. package/dist/src/cli/skill-usage.js +2 -2
  39. package/dist/src/cli/workflow-orchestrator.js +1 -1
  40. package/dist/src/cli/workspace-signals.js +2 -2
  41. package/dist/src/config/aiwg-config.js +54 -27
  42. package/dist/src/config/cli.js +3 -3
  43. package/dist/src/config/project-artifacts-health.js +2 -0
  44. package/dist/src/config/project-artifacts-health.mjs +123 -0
  45. package/dist/src/config/project-artifacts-runtime.mjs +16 -0
  46. package/dist/src/config/project-artifacts.js +2 -1
  47. package/dist/src/cost/fleet-report.js +329 -0
  48. package/dist/src/evidence/bundle.js +256 -0
  49. package/dist/src/extensions/commands/definitions.js +77 -25
  50. package/dist/src/extensions/deployment-registration.js +6 -4
  51. package/dist/src/extensions/project-local-doctor.js +10 -6
  52. package/dist/src/extensions/project-local-gitignore.js +8 -4
  53. package/dist/src/extensions/project-quickref.js +197 -10
  54. package/dist/src/features/catalog.js +26 -0
  55. package/dist/src/features/cli.js +1 -3
  56. package/dist/src/features/runtime.js +17 -1
  57. package/dist/src/issues/cli.js +91 -7
  58. package/dist/src/mcp/server.mjs +1 -1
  59. package/dist/src/ops/registry.js +2 -2
  60. package/dist/src/policy/authorization.js +2 -2
  61. package/dist/src/providers/capability-matrix.yaml +3 -3
  62. package/dist/src/providers/provider-definitions.js +7 -5
  63. package/dist/src/providers/provider-definitions.mjs +1 -1
  64. package/dist/src/serve/pty-bridge.js +2 -8
  65. package/dist/src/serve/screen-reader.js +3 -6
  66. package/dist/src/smiths/context-pipeline/aiwg-md.js +2 -2
  67. package/dist/src/smiths/context-pipeline/finalization.js +18 -5
  68. package/dist/src/smiths/context-pipeline/generator.js +2 -2
  69. package/dist/src/smiths/context-pipeline/workspace-context.js +16 -17
  70. package/package.json +2 -1
  71. package/tools/agents/deploy-agents.mjs +10 -11
  72. package/tools/agents/providers/base.mjs +47 -5
  73. package/tools/agents/providers/openclaw.mjs +5 -2
  74. package/tools/agents/providers/windsurf.mjs +13 -24
  75. package/tools/plugin/package-plugins.mjs +53 -0
  76. 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
- * Defaults to `<project>/.aiwg/aiwg.config`; honors `AIWG_ARTIFACTS_PATH` so
662
- * projects can rename or relocate the AIWG artifact directory.
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 projectAiwgPath(projectDir, CONFIG_FILENAME);
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 filePath = getConfigPath(projectDir);
688
+ const localPath = getConfigPath(projectDir);
689
+ const artifactPath = projectAiwgPath(projectDir, CONFIG_FILENAME);
690
+ let filePath = localPath;
689
691
  try {
690
- await access(filePath);
692
+ await access(localPath);
691
693
  }
692
694
  catch {
693
- return null;
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, creating the resolved AIWG artifact directory if needed.
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 dir = resolveProjectAiwgDir(projectDir);
733
- await mkdir(dir, { recursive: true });
734
- const filePath = join(dir, CONFIG_FILENAME);
735
- // Atomic write: emit to a temp sibling, fsync-ish via rename. Prevents a
736
- // crash or kill mid-write from corrupting the config file. Rename is
737
- // atomic on POSIX and on NTFS when both paths are on the same volume.
738
- // The random suffix avoids collisions if two concurrent writers run.
739
- const tmpPath = `${filePath}.${randomBytes(6).toString('hex')}.tmp`;
740
- try {
741
- await writeFile(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
742
- await rename(tmpPath, filePath);
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 unlink(tmpPath);
756
+ await writeFile(tmpPath, content, 'utf-8');
757
+ await rename(tmpPath, filePath);
748
758
  }
749
- catch {
750
- /* ignore */
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
  /**
@@ -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 { projectAiwgPath, resolveProjectAiwgDir } from './project-artifacts.js';
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: ${projectAiwgPath(projectDir, 'aiwg.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: ${projectAiwgPath(projectDir, 'aiwg.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,2 @@
1
+ export { PROJECT_CONTROL_PLANE_FILES, auditProjectArtifactHealth, } from './project-artifacts-health.mjs';
2
+ //# sourceMappingURL=project-artifacts-health.js.map
@@ -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