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.
@@ -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
+ })();
@@ -45,6 +45,10 @@
45
45
  {
46
46
  "type": "command",
47
47
  "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fd-response-check.js"
48
+ },
49
+ {
50
+ "type": "command",
51
+ "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stage-to-byan.js"
48
52
  }
49
53
  ]
50
54
  }
@@ -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.5",
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
+ };