create-byan-agent 2.9.5 → 2.9.6
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/install/bin/create-byan-agent-v2.js +15 -1
- package/install/lib/staging-consent.js +149 -0
- package/install/templates/.claude/hooks/stage-to-byan.js +119 -0
- package/install/templates/.claude/settings.json +4 -0
- package/install/templates/.github/extensions/byan-staging/extension.mjs +169 -0
- package/install/templates/.github/extensions/byan-staging/package.json +8 -0
- package/package.json +1 -1
- package/src/staging/staging.js +394 -0
|
@@ -16,6 +16,7 @@ const { generateProjectAgentsDoc } = require('../lib/project-agents-generator');
|
|
|
16
16
|
const { launchPhase2Chat, generateDefaultConfig } = require('../lib/phase2-chat');
|
|
17
17
|
const { setupByanWebIntegration } = require('../lib/byan-web-integration');
|
|
18
18
|
const { setupClaudeNative } = require('../lib/claude-native-setup');
|
|
19
|
+
const { setupStagingConsent } = require('../lib/staging-consent');
|
|
19
20
|
|
|
20
21
|
const BYAN_VERSION = require('../package.json').version;
|
|
21
22
|
|
|
@@ -1355,11 +1356,24 @@ async function install() {
|
|
|
1355
1356
|
if (needsClaude || needsCopilot) {
|
|
1356
1357
|
console.log();
|
|
1357
1358
|
console.log(chalk.cyan('byan_web integration (optional — service payant)'));
|
|
1359
|
+
let byanWebResult = { configured: false };
|
|
1358
1360
|
try {
|
|
1359
|
-
await setupByanWebIntegration(projectRoot);
|
|
1361
|
+
byanWebResult = await setupByanWebIntegration(projectRoot);
|
|
1360
1362
|
} catch (error) {
|
|
1361
1363
|
console.log(chalk.yellow(` ⚠ byan_web setup skipped: ${error.message}`));
|
|
1362
1364
|
}
|
|
1365
|
+
|
|
1366
|
+
if (byanWebResult && byanWebResult.configured) {
|
|
1367
|
+
console.log();
|
|
1368
|
+
console.log(chalk.cyan('byan_web memory-sync opt-in (consent)'));
|
|
1369
|
+
try {
|
|
1370
|
+
await setupStagingConsent(projectRoot, {
|
|
1371
|
+
byanWebConfigured: true,
|
|
1372
|
+
});
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
console.log(chalk.yellow(` ⚠ memory-sync setup skipped: ${error.message}`));
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1363
1377
|
}
|
|
1364
1378
|
|
|
1365
1379
|
// Step 8: Create config.yaml
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installer step — BYAN memory-sync opt-in + consent (SM5).
|
|
3
|
+
*
|
|
4
|
+
* Shown during create-byan-agent. Only prompts when the user already
|
|
5
|
+
* provided a byan_web URL + token (via setupByanWebIntegration), since
|
|
6
|
+
* memory-sync without credentials is a no-op.
|
|
7
|
+
*
|
|
8
|
+
* On opt-in, writes :
|
|
9
|
+
* _byan/config.yaml → memory_sync: { enabled: true }
|
|
10
|
+
* OR loadbalancer.yaml if _byan/config.yaml not present
|
|
11
|
+
*
|
|
12
|
+
* Prints a clear consent notice listing what gets sent and how to
|
|
13
|
+
* disable. The user must type "oui" / "yes" to enable — no default to
|
|
14
|
+
* true.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs-extra');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const yaml = require('js-yaml');
|
|
20
|
+
const inquirer = require('inquirer');
|
|
21
|
+
const chalk = require('chalk');
|
|
22
|
+
|
|
23
|
+
const CONSENT_NOTICE = [
|
|
24
|
+
'',
|
|
25
|
+
chalk.yellow.bold('BYAN memory-sync — consent requis'),
|
|
26
|
+
'',
|
|
27
|
+
'Si vous activez cette option, apres chaque interaction avec',
|
|
28
|
+
'Claude Code ou Copilot CLI, BYAN envoie automatiquement a votre',
|
|
29
|
+
'instance byan_web les elements suivants :',
|
|
30
|
+
'',
|
|
31
|
+
' - messages utilisateur (prompts)',
|
|
32
|
+
' - reponses assistant',
|
|
33
|
+
' - chemins de fichiers modifies',
|
|
34
|
+
' - sessionId et timestamp',
|
|
35
|
+
'',
|
|
36
|
+
'Filtrage applique AVANT envoi :',
|
|
37
|
+
' - chit-chat (moins de 50 caracteres) -> ignore',
|
|
38
|
+
' - doublons (hash SHA256 du contenu) -> ignore',
|
|
39
|
+
' - categories : fact | decision | blocker | artifact',
|
|
40
|
+
'',
|
|
41
|
+
'Les donnees sont stockees dans VOTRE instance byan_web',
|
|
42
|
+
'(pas de tierce partie). Le token JWT identifie l auteur.',
|
|
43
|
+
'',
|
|
44
|
+
chalk.cyan('Desactiver plus tard :'),
|
|
45
|
+
' - editer _byan/config.yaml -> memory_sync: { enabled: false }',
|
|
46
|
+
' - OU invoquer le skill -> /byan-no-stage pour un turn',
|
|
47
|
+
'',
|
|
48
|
+
].join('\n');
|
|
49
|
+
|
|
50
|
+
async function promptConsent({ skipPrompts, defaultAnswer } = {}) {
|
|
51
|
+
if (skipPrompts) return { enabled: defaultAnswer === true };
|
|
52
|
+
|
|
53
|
+
console.log(CONSENT_NOTICE);
|
|
54
|
+
|
|
55
|
+
const { enable } = await inquirer.prompt([
|
|
56
|
+
{
|
|
57
|
+
type: 'confirm',
|
|
58
|
+
name: 'enable',
|
|
59
|
+
message:
|
|
60
|
+
'Activer la synchronisation automatique de vos conversations vers byan_web ?',
|
|
61
|
+
default: false,
|
|
62
|
+
},
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
return { enabled: enable };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function configPaths(projectRoot) {
|
|
69
|
+
return {
|
|
70
|
+
byanConfig: path.join(projectRoot, '_byan', 'config.yaml'),
|
|
71
|
+
lbConfig: path.join(projectRoot, 'loadbalancer.yaml'),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function writeMemorySyncFlag(projectRoot, enabled) {
|
|
76
|
+
const { byanConfig, lbConfig } = configPaths(projectRoot);
|
|
77
|
+
|
|
78
|
+
// Prefer _byan/config.yaml (BYAN primary config). Fall back to
|
|
79
|
+
// loadbalancer.yaml only if _byan/config.yaml is missing AND
|
|
80
|
+
// loadbalancer.yaml already exists.
|
|
81
|
+
let target;
|
|
82
|
+
if (await fs.pathExists(byanConfig)) {
|
|
83
|
+
target = byanConfig;
|
|
84
|
+
} else if (await fs.pathExists(lbConfig)) {
|
|
85
|
+
target = lbConfig;
|
|
86
|
+
} else {
|
|
87
|
+
target = byanConfig;
|
|
88
|
+
}
|
|
89
|
+
await fs.ensureDir(path.dirname(target));
|
|
90
|
+
|
|
91
|
+
let doc = {};
|
|
92
|
+
if (await fs.pathExists(target)) {
|
|
93
|
+
try {
|
|
94
|
+
doc = yaml.load(await fs.readFile(target, 'utf8')) || {};
|
|
95
|
+
} catch {
|
|
96
|
+
doc = {};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
doc.memory_sync = { ...(doc.memory_sync || {}), enabled: enabled === true };
|
|
100
|
+
|
|
101
|
+
await fs.writeFile(target, yaml.dump(doc), 'utf8');
|
|
102
|
+
return target;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function setupStagingConsent(projectRoot, options = {}) {
|
|
106
|
+
const tokenPresent = options.byanWebConfigured === true;
|
|
107
|
+
if (!tokenPresent) {
|
|
108
|
+
if (!options.quiet) {
|
|
109
|
+
console.log(
|
|
110
|
+
chalk.gray(' i memory-sync skipped (no byan_web token configured)')
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return { configured: false, reason: 'no_token' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const { enabled } = await promptConsent({
|
|
117
|
+
skipPrompts: options.skipPrompts === true,
|
|
118
|
+
defaultAnswer: options.presetEnabled === true,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const target = await writeMemorySyncFlag(projectRoot, enabled);
|
|
122
|
+
|
|
123
|
+
if (!options.quiet) {
|
|
124
|
+
if (enabled) {
|
|
125
|
+
console.log(chalk.green(' OK memory-sync ENABLED in ' + path.relative(projectRoot, target)));
|
|
126
|
+
console.log(
|
|
127
|
+
chalk.gray(
|
|
128
|
+
' a chaque fin de turn, votre hook Stop (Claude) et votre'
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
console.log(
|
|
132
|
+
chalk.gray(
|
|
133
|
+
' extension Copilot staging enverront les memoires a byan_web.'
|
|
134
|
+
)
|
|
135
|
+
);
|
|
136
|
+
} else {
|
|
137
|
+
console.log(chalk.gray(' i memory-sync left DISABLED (opt-in declined)'));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { configured: true, enabled, configPath: target };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
setupStagingConsent,
|
|
146
|
+
writeMemorySyncFlag,
|
|
147
|
+
promptConsent,
|
|
148
|
+
CONSENT_NOTICE,
|
|
149
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Stop hook — stage the ending turn to byan_web memory (SM1b).
|
|
4
|
+
*
|
|
5
|
+
* Reads the Stop payload on stdin, extracts user + assistant messages,
|
|
6
|
+
* delegates to src/staging/staging.js processTurn().
|
|
7
|
+
*
|
|
8
|
+
* Config source (first present wins) :
|
|
9
|
+
* - .claude/settings.local.json env.BYAN_API_URL / BYAN_API_TOKEN
|
|
10
|
+
* - process.env.BYAN_API_URL / BYAN_API_TOKEN
|
|
11
|
+
* - loadbalancer.yaml or _byan/config.yaml memory_sync: section
|
|
12
|
+
*
|
|
13
|
+
* Never blocks : this hook always exits 0 with continue:true. Failures
|
|
14
|
+
* are queued locally for retry, not surfaced to the user mid-turn.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
21
|
+
|
|
22
|
+
function readStdin() {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
if (process.stdin.isTTY) return resolve('');
|
|
25
|
+
let data = '';
|
|
26
|
+
process.stdin.on('data', (c) => (data += c));
|
|
27
|
+
process.stdin.on('end', () => resolve(data));
|
|
28
|
+
process.stdin.on('error', () => resolve(data));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readSettingsEnv() {
|
|
33
|
+
const p = path.join(projectDir, '.claude', 'settings.local.json');
|
|
34
|
+
if (!fs.existsSync(p)) return {};
|
|
35
|
+
try {
|
|
36
|
+
const j = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
37
|
+
return j.env || {};
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readMemorySyncConfig() {
|
|
44
|
+
const paths = [
|
|
45
|
+
path.join(projectDir, 'loadbalancer.yaml'),
|
|
46
|
+
path.join(projectDir, '_byan', 'config.yaml'),
|
|
47
|
+
];
|
|
48
|
+
for (const p of paths) {
|
|
49
|
+
if (!fs.existsSync(p)) continue;
|
|
50
|
+
try {
|
|
51
|
+
const yaml = require('js-yaml');
|
|
52
|
+
const doc = yaml.load(fs.readFileSync(p, 'utf8'));
|
|
53
|
+
if (doc && doc.memory_sync) return doc.memory_sync;
|
|
54
|
+
} catch {
|
|
55
|
+
// fall through
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildConfig() {
|
|
62
|
+
const settingsEnv = readSettingsEnv();
|
|
63
|
+
const apiUrl = settingsEnv.BYAN_API_URL || process.env.BYAN_API_URL || null;
|
|
64
|
+
const apiToken = settingsEnv.BYAN_API_TOKEN || process.env.BYAN_API_TOKEN || null;
|
|
65
|
+
const memorySync = readMemorySyncConfig() || {};
|
|
66
|
+
return {
|
|
67
|
+
byan_api_url: apiUrl,
|
|
68
|
+
byan_api_token: apiToken,
|
|
69
|
+
memory_sync: memorySync,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function extractTurn(payload) {
|
|
74
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
75
|
+
|
|
76
|
+
const transcript = payload.transcript || payload.messages;
|
|
77
|
+
if (!Array.isArray(transcript)) return null;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
sessionId: payload.session_id || payload.sessionId || null,
|
|
81
|
+
messages: transcript
|
|
82
|
+
.filter((m) => m && (m.role === 'user' || m.role === 'assistant'))
|
|
83
|
+
.slice(-4),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
(async () => {
|
|
88
|
+
const raw = await readStdin();
|
|
89
|
+
let payload = {};
|
|
90
|
+
try {
|
|
91
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
92
|
+
} catch {
|
|
93
|
+
payload = {};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const config = buildConfig();
|
|
97
|
+
const turn = extractTurn(payload);
|
|
98
|
+
|
|
99
|
+
if (!turn) {
|
|
100
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
101
|
+
process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const { processTurn } = require(path.join(projectDir, 'src', 'staging', 'staging.js'));
|
|
106
|
+
await processTurn({
|
|
107
|
+
turn,
|
|
108
|
+
cliSource: 'claude-code',
|
|
109
|
+
config,
|
|
110
|
+
projectRoot: projectDir,
|
|
111
|
+
flushNow: true,
|
|
112
|
+
});
|
|
113
|
+
} catch {
|
|
114
|
+
// staging must never break the session
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
118
|
+
process.exit(0);
|
|
119
|
+
})();
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copilot CLI extension — BYAN staging (SM1c).
|
|
3
|
+
*
|
|
4
|
+
* Attaches to the current Copilot session via joinSession() and
|
|
5
|
+
* triggers the BYAN staging pipeline at the end of each assistant
|
|
6
|
+
* turn. Delegates to src/staging/staging.js so Claude Code and Copilot
|
|
7
|
+
* CLI share the exact same extract / filter / dedup / queue / flush
|
|
8
|
+
* logic.
|
|
9
|
+
*
|
|
10
|
+
* How it's discovered : Copilot CLI scans .github/extensions/
|
|
11
|
+
* (project) and the user's copilot config for subdirectories
|
|
12
|
+
* containing extension.mjs. This file is auto-launched as a child
|
|
13
|
+
* process, gets @github/copilot-sdk on its module path, and calls
|
|
14
|
+
* joinSession with the hook registration below.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { joinSession } from '@github/copilot-sdk/extension';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
|
|
24
|
+
const EXTENSION_DIR = path.dirname(new URL(import.meta.url).pathname);
|
|
25
|
+
const PROJECT_ROOT =
|
|
26
|
+
process.env.BYAN_PROJECT_ROOT ||
|
|
27
|
+
process.env.CLAUDE_PROJECT_DIR ||
|
|
28
|
+
findProjectRoot(EXTENSION_DIR);
|
|
29
|
+
|
|
30
|
+
function findProjectRoot(startDir) {
|
|
31
|
+
// Walk up until we find a package.json or .git — else cwd
|
|
32
|
+
let dir = startDir;
|
|
33
|
+
for (let i = 0; i < 6; i++) {
|
|
34
|
+
if (
|
|
35
|
+
fs.existsSync(path.join(dir, 'package.json')) ||
|
|
36
|
+
fs.existsSync(path.join(dir, '.git'))
|
|
37
|
+
) {
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
const parent = path.dirname(dir);
|
|
41
|
+
if (parent === dir) break;
|
|
42
|
+
dir = parent;
|
|
43
|
+
}
|
|
44
|
+
return process.cwd();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function loadStaging() {
|
|
48
|
+
try {
|
|
49
|
+
return require(path.join(PROJECT_ROOT, 'src', 'staging', 'staging.js'));
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readSettingsEnv() {
|
|
56
|
+
const p = path.join(PROJECT_ROOT, '.claude', 'settings.local.json');
|
|
57
|
+
if (!fs.existsSync(p)) return {};
|
|
58
|
+
try {
|
|
59
|
+
const j = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
60
|
+
return j.env || {};
|
|
61
|
+
} catch {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readMemorySyncConfig() {
|
|
67
|
+
const paths = [
|
|
68
|
+
path.join(PROJECT_ROOT, 'loadbalancer.yaml'),
|
|
69
|
+
path.join(PROJECT_ROOT, '_byan', 'config.yaml'),
|
|
70
|
+
];
|
|
71
|
+
for (const p of paths) {
|
|
72
|
+
if (!fs.existsSync(p)) continue;
|
|
73
|
+
try {
|
|
74
|
+
const yaml = require('js-yaml');
|
|
75
|
+
const doc = yaml.load(fs.readFileSync(p, 'utf8'));
|
|
76
|
+
if (doc && doc.memory_sync) return doc.memory_sync;
|
|
77
|
+
} catch {
|
|
78
|
+
// fall through
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildConfig() {
|
|
85
|
+
const envFile = readSettingsEnv();
|
|
86
|
+
return {
|
|
87
|
+
byan_api_url: envFile.BYAN_API_URL || process.env.BYAN_API_URL || null,
|
|
88
|
+
byan_api_token: envFile.BYAN_API_TOKEN || process.env.BYAN_API_TOKEN || null,
|
|
89
|
+
memory_sync: readMemorySyncConfig() || {},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Per-turn buffer of recent messages/tool calls keyed by sessionId.
|
|
94
|
+
const turnBuffer = new Map();
|
|
95
|
+
|
|
96
|
+
function bufferFor(sessionId) {
|
|
97
|
+
const key = sessionId || 'default';
|
|
98
|
+
if (!turnBuffer.has(key)) {
|
|
99
|
+
turnBuffer.set(key, { userMessages: [], assistantMessages: [], toolCalls: [] });
|
|
100
|
+
}
|
|
101
|
+
return turnBuffer.get(key);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function clearBuffer(sessionId) {
|
|
105
|
+
turnBuffer.delete(sessionId || 'default');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const session = await joinSession({
|
|
109
|
+
hooks: {
|
|
110
|
+
onSessionStart: async () => {
|
|
111
|
+
await session.log('BYAN staging extension loaded', { ephemeral: true });
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
onUserPromptSubmitted: async (input) => {
|
|
115
|
+
const buf = bufferFor(input.sessionId);
|
|
116
|
+
buf.userMessages.push({ role: 'user', content: input.prompt });
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
onPreToolUse: async (input) => {
|
|
120
|
+
const buf = bufferFor(input.sessionId);
|
|
121
|
+
buf.toolCalls.push({
|
|
122
|
+
name: input.toolName,
|
|
123
|
+
input: input.toolArgs || {},
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
onPostToolUse: async (input) => {
|
|
128
|
+
const buf = bufferFor(input.sessionId);
|
|
129
|
+
// If the last tool call has a text result, treat it as assistant output
|
|
130
|
+
if (input.toolResult && typeof input.toolResult.content === 'string') {
|
|
131
|
+
buf.assistantMessages.push({
|
|
132
|
+
role: 'assistant',
|
|
133
|
+
content: input.toolResult.content,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
onSessionEnd: async (input) => {
|
|
139
|
+
const staging = loadStaging();
|
|
140
|
+
if (!staging) {
|
|
141
|
+
clearBuffer(input?.sessionId);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const buf = bufferFor(input?.sessionId);
|
|
146
|
+
const turn = {
|
|
147
|
+
sessionId: input?.sessionId || null,
|
|
148
|
+
messages: [...buf.userMessages, ...buf.assistantMessages],
|
|
149
|
+
toolCalls: buf.toolCalls,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await staging.processTurn({
|
|
154
|
+
turn,
|
|
155
|
+
cliSource: 'copilot-cli',
|
|
156
|
+
config: buildConfig(),
|
|
157
|
+
projectRoot: PROJECT_ROOT,
|
|
158
|
+
flushNow: true,
|
|
159
|
+
});
|
|
160
|
+
} catch {
|
|
161
|
+
// never block session end
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
clearBuffer(input?.sessionId);
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
tools: [],
|
|
169
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@byan/copilot-staging-extension",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Copilot CLI extension that stages conversation knowledge to byan_web /api/memory via the shared BYAN staging core.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "extension.mjs",
|
|
7
|
+
"private": true
|
|
8
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-byan-agent",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.6",
|
|
4
4
|
"description": "BYAN v2.8 - Intelligent AI agent creator with ELO trust system + scientific fact-check + Hermes universal dispatcher + native Claude Code integration (hooks, skills, MCP server). Multi-platform (Copilot CLI, Claude Code, Codex). Merise Agile + TDD + 64 Mantras. ~54% LLM cost savings.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BYAN staging core — extract / filter / dedup / queue / flush conversation
|
|
3
|
+
* knowledge from any supported CLI (claude-code, copilot-cli, codex) to a
|
|
4
|
+
* byan_web instance via POST /api/memory.
|
|
5
|
+
*
|
|
6
|
+
* Usage from a Claude Code Stop hook :
|
|
7
|
+
* const { processTurn } = require('./staging');
|
|
8
|
+
* await processTurn({ turn, cliSource: 'claude-code', config, projectRoot });
|
|
9
|
+
*
|
|
10
|
+
* Usage from a Copilot CLI extension.mjs :
|
|
11
|
+
* import { processTurn } from '<repo>/src/staging/staging.js';
|
|
12
|
+
* await processTurn({ turn, cliSource: 'copilot-cli', config, projectRoot });
|
|
13
|
+
*
|
|
14
|
+
* Contract :
|
|
15
|
+
* - processTurn() is idempotent (dedup by content hash)
|
|
16
|
+
* - never throws — errors go to the retry queue
|
|
17
|
+
* - if enabled=false, it's a pure no-op (zero bytes sent)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
const { execSync } = require('child_process');
|
|
24
|
+
|
|
25
|
+
const QUEUE_FILENAME = 'staging-queue.jsonl';
|
|
26
|
+
const SEEN_FILENAME = 'staging-seen.json';
|
|
27
|
+
const STAGING_DIR = path.join('_byan-output', 'staging');
|
|
28
|
+
|
|
29
|
+
// Patterns considered "chit-chat" — skipped by the triage filter.
|
|
30
|
+
const CHITCHAT_PATTERNS = [
|
|
31
|
+
/^(hi|hello|ok|thanks|merci|salut|bye|lol|yep|nope)[!. ]*$/i,
|
|
32
|
+
/^(y|yes|n|no|go|stop)$/i,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const MIN_CONTENT_CHARS = 50;
|
|
36
|
+
|
|
37
|
+
function resolveRoot(projectRoot) {
|
|
38
|
+
return projectRoot || process.env.CLAUDE_PROJECT_DIR || process.env.BYAN_PROJECT_ROOT || process.cwd();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stagingDir(projectRoot) {
|
|
42
|
+
return path.join(resolveRoot(projectRoot), STAGING_DIR);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function queuePath(projectRoot) {
|
|
46
|
+
return path.join(stagingDir(projectRoot), QUEUE_FILENAME);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function seenPath(projectRoot) {
|
|
50
|
+
return path.join(stagingDir(projectRoot), SEEN_FILENAME);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ensureDir(dir) {
|
|
54
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sha256(s) {
|
|
58
|
+
return crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Enablement + config
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function isEnabled(config) {
|
|
66
|
+
if (!config || typeof config !== 'object') return false;
|
|
67
|
+
const ms = config.memory_sync || config.memorySync;
|
|
68
|
+
if (!ms) return false;
|
|
69
|
+
return ms.enabled === true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function apiUrl(config) {
|
|
73
|
+
if (!config) return null;
|
|
74
|
+
return config.byan_api_url || config.BYAN_API_URL || process.env.BYAN_API_URL || null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function apiToken(config) {
|
|
78
|
+
if (!config) return process.env.BYAN_API_TOKEN || null;
|
|
79
|
+
return config.byan_api_token || config.BYAN_API_TOKEN || process.env.BYAN_API_TOKEN || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Extract — normalize a turn payload into a memory entry draft
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
function extractUserText(turn) {
|
|
87
|
+
if (!turn) return '';
|
|
88
|
+
if (typeof turn.userMessage === 'string') return turn.userMessage;
|
|
89
|
+
if (typeof turn.prompt === 'string') return turn.prompt;
|
|
90
|
+
if (Array.isArray(turn.messages)) {
|
|
91
|
+
const u = [...turn.messages].reverse().find((m) => m && m.role === 'user');
|
|
92
|
+
if (u && typeof u.content === 'string') return u.content;
|
|
93
|
+
}
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function extractAssistantText(turn) {
|
|
98
|
+
if (!turn) return '';
|
|
99
|
+
if (typeof turn.assistantMessage === 'string') return turn.assistantMessage;
|
|
100
|
+
if (Array.isArray(turn.messages)) {
|
|
101
|
+
const a = [...turn.messages].reverse().find((m) => m && m.role === 'assistant');
|
|
102
|
+
if (a) {
|
|
103
|
+
if (typeof a.content === 'string') return a.content;
|
|
104
|
+
if (Array.isArray(a.content)) {
|
|
105
|
+
return a.content
|
|
106
|
+
.map((c) => (c && typeof c === 'object' && c.text ? c.text : ''))
|
|
107
|
+
.join(' ')
|
|
108
|
+
.trim();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function extractFilesTouched(turn) {
|
|
116
|
+
if (!turn) return [];
|
|
117
|
+
if (Array.isArray(turn.filesTouched)) return turn.filesTouched.filter(Boolean);
|
|
118
|
+
if (Array.isArray(turn.toolCalls)) {
|
|
119
|
+
const files = [];
|
|
120
|
+
for (const tc of turn.toolCalls) {
|
|
121
|
+
const p = tc?.input?.file_path || tc?.args?.file_path || tc?.input?.path;
|
|
122
|
+
if (p && typeof p === 'string') files.push(p);
|
|
123
|
+
}
|
|
124
|
+
return files;
|
|
125
|
+
}
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function classify(content, turn) {
|
|
130
|
+
const c = String(content || '').toLowerCase();
|
|
131
|
+
if (/\b(decid(e|ed|ing)|choix|trade-?off|architecture)\b/i.test(c)) return 'decision';
|
|
132
|
+
if (/\b(bug|error|fail|broken|bloque|blocked|can't|impossible)\b/i.test(c)) return 'blocker';
|
|
133
|
+
const files = extractFilesTouched(turn);
|
|
134
|
+
if (files.length > 0) return 'artifact';
|
|
135
|
+
return 'fact';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function extract({ turn, cliSource }) {
|
|
139
|
+
const user = extractUserText(turn);
|
|
140
|
+
const assistant = extractAssistantText(turn);
|
|
141
|
+
const filesTouched = extractFilesTouched(turn);
|
|
142
|
+
const content = [user, assistant].filter(Boolean).join('\n\n').trim();
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
cliSource: cliSource || 'unknown',
|
|
146
|
+
sessionId: turn?.sessionId || null,
|
|
147
|
+
category: classify(content, turn),
|
|
148
|
+
content,
|
|
149
|
+
metadata: {
|
|
150
|
+
userMessageLen: user.length,
|
|
151
|
+
assistantMessageLen: assistant.length,
|
|
152
|
+
filesTouched,
|
|
153
|
+
timestamp: new Date().toISOString(),
|
|
154
|
+
},
|
|
155
|
+
pinned: false,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Filter — triage chit-chat
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
function shouldKeep(entry) {
|
|
164
|
+
if (!entry || typeof entry.content !== 'string') return false;
|
|
165
|
+
if (entry.content.length < MIN_CONTENT_CHARS) return false;
|
|
166
|
+
|
|
167
|
+
const trimmed = entry.content.trim();
|
|
168
|
+
for (const re of CHITCHAT_PATTERNS) {
|
|
169
|
+
if (re.test(trimmed)) return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Must have at least one of : files touched, substantive content, or decision keywords
|
|
173
|
+
if (entry.metadata && Array.isArray(entry.metadata.filesTouched) && entry.metadata.filesTouched.length > 0) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
// Otherwise require reasonable content length
|
|
177
|
+
return trimmed.length >= MIN_CONTENT_CHARS * 2;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Dedup — hash-based, persisted
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
function readSeen(projectRoot) {
|
|
185
|
+
const p = seenPath(projectRoot);
|
|
186
|
+
if (!fs.existsSync(p)) return { hashes: [] };
|
|
187
|
+
try {
|
|
188
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
189
|
+
} catch {
|
|
190
|
+
return { hashes: [] };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function writeSeen(projectRoot, seen) {
|
|
195
|
+
ensureDir(stagingDir(projectRoot));
|
|
196
|
+
// Keep only last 500 hashes to cap disk use
|
|
197
|
+
const trimmed = { hashes: seen.hashes.slice(-500) };
|
|
198
|
+
fs.writeFileSync(seenPath(projectRoot), JSON.stringify(trimmed));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isDuplicate(entry, projectRoot) {
|
|
202
|
+
const h = sha256(entry.content);
|
|
203
|
+
const seen = readSeen(projectRoot);
|
|
204
|
+
return seen.hashes.includes(h);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function markSeen(entry, projectRoot) {
|
|
208
|
+
const h = sha256(entry.content);
|
|
209
|
+
const seen = readSeen(projectRoot);
|
|
210
|
+
if (!seen.hashes.includes(h)) {
|
|
211
|
+
seen.hashes.push(h);
|
|
212
|
+
writeSeen(projectRoot, seen);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Queue — local append-only, flushed by flush()
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
function enqueue(entry, projectRoot) {
|
|
221
|
+
ensureDir(stagingDir(projectRoot));
|
|
222
|
+
const p = queuePath(projectRoot);
|
|
223
|
+
const line = JSON.stringify({
|
|
224
|
+
...entry,
|
|
225
|
+
enqueued_at: new Date().toISOString(),
|
|
226
|
+
attempts: 0,
|
|
227
|
+
});
|
|
228
|
+
fs.appendFileSync(p, line + '\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function readQueue(projectRoot) {
|
|
232
|
+
const p = queuePath(projectRoot);
|
|
233
|
+
if (!fs.existsSync(p)) return [];
|
|
234
|
+
return fs
|
|
235
|
+
.readFileSync(p, 'utf8')
|
|
236
|
+
.split('\n')
|
|
237
|
+
.filter(Boolean)
|
|
238
|
+
.map((line) => {
|
|
239
|
+
try {
|
|
240
|
+
return JSON.parse(line);
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
.filter(Boolean);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function writeQueue(projectRoot, entries) {
|
|
249
|
+
const p = queuePath(projectRoot);
|
|
250
|
+
if (entries.length === 0) {
|
|
251
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
fs.writeFileSync(
|
|
255
|
+
p,
|
|
256
|
+
entries.map((e) => JSON.stringify(e)).join('\n') + '\n'
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Project ID — derived from git remote or cwd
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
function detectProjectId(projectRoot) {
|
|
265
|
+
const root = resolveRoot(projectRoot);
|
|
266
|
+
try {
|
|
267
|
+
const url = execSync('git remote get-url origin', {
|
|
268
|
+
cwd: root,
|
|
269
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
270
|
+
encoding: 'utf8',
|
|
271
|
+
}).trim();
|
|
272
|
+
if (url) return sha256(url).slice(0, 16);
|
|
273
|
+
} catch {
|
|
274
|
+
// no git remote, fall through
|
|
275
|
+
}
|
|
276
|
+
return sha256(root).slice(0, 16);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Flush — POST queued entries to /api/memory with retry
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
async function postEntry({ entry, url, token, projectId }) {
|
|
284
|
+
const body = {
|
|
285
|
+
projectId,
|
|
286
|
+
sessionId: entry.sessionId,
|
|
287
|
+
cliSource: entry.cliSource,
|
|
288
|
+
category: entry.category,
|
|
289
|
+
content: entry.content,
|
|
290
|
+
metadata: entry.metadata,
|
|
291
|
+
pinned: entry.pinned === true,
|
|
292
|
+
};
|
|
293
|
+
const res = await fetch(`${url.replace(/\/$/, '')}/api/memory`, {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
headers: {
|
|
296
|
+
'Content-Type': 'application/json',
|
|
297
|
+
Authorization: `Bearer ${token}`,
|
|
298
|
+
},
|
|
299
|
+
body: JSON.stringify(body),
|
|
300
|
+
});
|
|
301
|
+
if (!res.ok) {
|
|
302
|
+
const text = await res.text().catch(() => '');
|
|
303
|
+
const err = new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
|
|
304
|
+
err.status = res.status;
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
return res.json().catch(() => ({}));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function flush({ config, projectRoot, maxAttempts = 5 } = {}) {
|
|
311
|
+
const url = apiUrl(config);
|
|
312
|
+
const token = apiToken(config);
|
|
313
|
+
if (!url || !token) {
|
|
314
|
+
return { flushed: 0, requeued: 0, dropped: 0, reason: 'missing url or token' };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const projectId = detectProjectId(projectRoot);
|
|
318
|
+
const queue = readQueue(projectRoot);
|
|
319
|
+
if (queue.length === 0) return { flushed: 0, requeued: 0, dropped: 0 };
|
|
320
|
+
|
|
321
|
+
let flushed = 0;
|
|
322
|
+
const remaining = [];
|
|
323
|
+
let dropped = 0;
|
|
324
|
+
|
|
325
|
+
for (const entry of queue) {
|
|
326
|
+
try {
|
|
327
|
+
await postEntry({ entry, url, token, projectId });
|
|
328
|
+
flushed += 1;
|
|
329
|
+
} catch (err) {
|
|
330
|
+
const attempts = (entry.attempts || 0) + 1;
|
|
331
|
+
if (attempts >= maxAttempts) {
|
|
332
|
+
dropped += 1;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
remaining.push({ ...entry, attempts, last_error: err.message });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
writeQueue(projectRoot, remaining);
|
|
340
|
+
|
|
341
|
+
return { flushed, requeued: remaining.length, dropped };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Orchestration — the single entry point used by both hooks/extensions
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
async function processTurn({ turn, cliSource, config, projectRoot, flushNow = true } = {}) {
|
|
349
|
+
if (!isEnabled(config)) {
|
|
350
|
+
return { skipped: 'disabled' };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const entry = extract({ turn, cliSource });
|
|
354
|
+
|
|
355
|
+
if (!shouldKeep(entry)) {
|
|
356
|
+
return { skipped: 'filtered', category: entry.category };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (isDuplicate(entry, projectRoot)) {
|
|
360
|
+
return { skipped: 'duplicate', category: entry.category };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
enqueue(entry, projectRoot);
|
|
364
|
+
markSeen(entry, projectRoot);
|
|
365
|
+
|
|
366
|
+
if (!flushNow) {
|
|
367
|
+
return { queued: true, flushed: 0, category: entry.category };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const result = await flush({ config, projectRoot });
|
|
371
|
+
return { queued: true, ...result, category: entry.category };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
module.exports = {
|
|
375
|
+
processTurn,
|
|
376
|
+
extract,
|
|
377
|
+
shouldKeep,
|
|
378
|
+
isEnabled,
|
|
379
|
+
isDuplicate,
|
|
380
|
+
markSeen,
|
|
381
|
+
enqueue,
|
|
382
|
+
readQueue,
|
|
383
|
+
writeQueue,
|
|
384
|
+
flush,
|
|
385
|
+
detectProjectId,
|
|
386
|
+
sha256,
|
|
387
|
+
classify,
|
|
388
|
+
queuePath,
|
|
389
|
+
seenPath,
|
|
390
|
+
STAGING_DIR,
|
|
391
|
+
QUEUE_FILENAME,
|
|
392
|
+
SEEN_FILENAME,
|
|
393
|
+
MIN_CONTENT_CHARS,
|
|
394
|
+
};
|