auroq-os 1.0.0
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/.auroq-core/constitution.md +153 -0
- package/.auroq-core/core/synapse/context/context-builder.js +34 -0
- package/.auroq-core/core/synapse/context/context-tracker.js +198 -0
- package/.auroq-core/core/synapse/diagnostics/collectors/consistency-collector.js +168 -0
- package/.auroq-core/core/synapse/diagnostics/collectors/hook-collector.js +129 -0
- package/.auroq-core/core/synapse/diagnostics/collectors/manifest-collector.js +82 -0
- package/.auroq-core/core/synapse/diagnostics/collectors/output-analyzer.js +134 -0
- package/.auroq-core/core/synapse/diagnostics/collectors/pipeline-collector.js +75 -0
- package/.auroq-core/core/synapse/diagnostics/collectors/quality-collector.js +252 -0
- package/.auroq-core/core/synapse/diagnostics/collectors/relevance-matrix.js +174 -0
- package/.auroq-core/core/synapse/diagnostics/collectors/safe-read-json.js +31 -0
- package/.auroq-core/core/synapse/diagnostics/collectors/session-collector.js +102 -0
- package/.auroq-core/core/synapse/diagnostics/collectors/timing-collector.js +126 -0
- package/.auroq-core/core/synapse/diagnostics/collectors/uap-collector.js +83 -0
- package/.auroq-core/core/synapse/diagnostics/report-formatter.js +484 -0
- package/.auroq-core/core/synapse/diagnostics/synapse-diagnostics.js +95 -0
- package/.auroq-core/core/synapse/domain/domain-loader.js +322 -0
- package/.auroq-core/core/synapse/engine.js +400 -0
- package/.auroq-core/core/synapse/layers/l0-constitution.js +80 -0
- package/.auroq-core/core/synapse/layers/l1-global.js +102 -0
- package/.auroq-core/core/synapse/layers/l2-agent.js +94 -0
- package/.auroq-core/core/synapse/layers/l3-workflow.js +94 -0
- package/.auroq-core/core/synapse/layers/l4-task.js +83 -0
- package/.auroq-core/core/synapse/layers/l5-squad.js +244 -0
- package/.auroq-core/core/synapse/layers/l6-keyword.js +154 -0
- package/.auroq-core/core/synapse/layers/l7-star-command.js +169 -0
- package/.auroq-core/core/synapse/layers/layer-processor.js +82 -0
- package/.auroq-core/core/synapse/memory/memory-bridge.js +220 -0
- package/.auroq-core/core/synapse/memory/synapse-memory-provider.js +201 -0
- package/.auroq-core/core/synapse/output/formatter.js +561 -0
- package/.auroq-core/core/synapse/runtime/hook-runtime.js +98 -0
- package/.auroq-core/core/synapse/scripts/generate-constitution.js +204 -0
- package/.auroq-core/core/synapse/session/session-manager.js +404 -0
- package/.auroq-core/core/synapse/utils/atomic-write.js +79 -0
- package/.auroq-core/core/synapse/utils/paths.js +57 -0
- package/.auroq-core/core/synapse/utils/tokens.js +25 -0
- package/.auroq-core/core-config.yaml +80 -0
- package/.auroq-core/development/sistema-gestao-projetos.md +193 -0
- package/.auroq-core/development/sistema-memoria.md +157 -0
- package/.auroq-core/dna-operacional.md +134 -0
- package/.claude/CLAUDE.md +399 -0
- package/.claude/commands/AuroqOS/agents/forge.md +374 -0
- package/.claude/commands/AuroqOS/agents/ops.md +277 -0
- package/.claude/commands/companion.md +7 -0
- package/.claude/hooks/precompact-session-digest.cjs +46 -0
- package/.claude/hooks/synapse-engine.cjs +99 -0
- package/.claude/rules/agent-authority.md +79 -0
- package/.claude/rules/agent-handoff.md +97 -0
- package/.claude/rules/commit-inteligente.md +86 -0
- package/.claude/rules/dna-operacional.md +53 -0
- package/.claude/rules/evolucao-incremental.md +43 -0
- package/.claude/rules/mcp-usage.md +38 -0
- package/.claude/rules/memoria-inteligente.md +65 -0
- package/.claude/rules/project-tracker.md +49 -0
- package/.claude/rules/tool-response-filtering.md +57 -0
- package/.claude/settings.json +1 -0
- package/.claude/settings.local.json +33 -0
- package/.env.example +22 -0
- package/.gitignore +23 -0
- package/.synapse/constitution +21 -0
- package/.synapse/context +8 -0
- package/.synapse/global +10 -0
- package/.synapse/manifest +35 -0
- package/README.md +67 -0
- package/agents/companion/agents/companion.md +306 -0
- package/agents/companion/data/contexto-dinamico.md +23 -0
- package/agents/companion/data/demandas-backlog.md +19 -0
- package/agents/companion/data/log-decisoes.md +11 -0
- package/agents/companion/data/padroes-observados.md +10 -0
- package/agents/companion/knowledge/companion-foundation.md +49 -0
- package/agents/companion/knowledge/expert-essencial.md +30 -0
- package/agents/companion/knowledge/modus-operandi.md +161 -0
- package/agents/companion/tasks/new-project.md +111 -0
- package/agents/companion/tasks/save-memory.md +73 -0
- package/agents/companion/tasks/start.md +136 -0
- package/agents/companion/tasks/weekly-review.md +82 -0
- package/bin/auroq-os.js +346 -0
- package/business/campanhas/_template/BRIEFING.md +45 -0
- package/business/campanhas/_template/CHECKLIST.md +29 -0
- package/business/campanhas/_template/DIARIO.md +15 -0
- package/business/campanhas/_template/RETROVISOR.md +34 -0
- package/business/cockpit.md +72 -0
- package/business/templates/tracker-template.md +71 -0
- package/docs/knowledge/expert-business/posicionamento.md +27 -0
- package/docs/knowledge/expert-business/publico-alvo.md +39 -0
- package/docs/knowledge/expert-mind/historia.md +27 -0
- package/docs/knowledge/expert-mind/identidade.md +23 -0
- package/docs/knowledge/expert-mind/tom-de-voz.md +30 -0
- package/docs/knowledge/expert-mind/valores.md +32 -0
- package/package.json +26 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constitution Generator
|
|
3
|
+
*
|
|
4
|
+
* Reads .auroq-core/constitution.md and generates .synapse/constitution
|
|
5
|
+
* in KEY=VALUE format for the SYNAPSE Context Engine L0 layer.
|
|
6
|
+
*
|
|
7
|
+
* Usage: node .auroq-core/core/synapse/scripts/generate-constitution.js
|
|
8
|
+
*
|
|
9
|
+
* @module core/synapse/scripts/generate-constitution
|
|
10
|
+
* @version 1.0.0
|
|
11
|
+
* @created Story SYN-8 - Domain Content Files
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Roman numeral to Arabic number map (I-X)
|
|
19
|
+
*/
|
|
20
|
+
const ROMAN_TO_ARABIC = {
|
|
21
|
+
'I': 1, 'II': 2, 'III': 3, 'IV': 4, 'V': 5,
|
|
22
|
+
'VI': 6, 'VII': 7, 'VIII': 8, 'IX': 9, 'X': 10,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Clean markdown formatting from text
|
|
27
|
+
* Removes backticks and trims whitespace
|
|
28
|
+
*
|
|
29
|
+
* @param {string} text - Text with possible markdown formatting
|
|
30
|
+
* @returns {string} Cleaned text
|
|
31
|
+
*/
|
|
32
|
+
function cleanText(text) {
|
|
33
|
+
return text.replace(/`/g, '').trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse constitution.md and extract articles with their rules
|
|
38
|
+
*
|
|
39
|
+
* @param {string} content - Raw markdown content of constitution.md
|
|
40
|
+
* @returns {Array<{number: number, roman: string, title: string, severity: string, rules: string[]}>}
|
|
41
|
+
*/
|
|
42
|
+
function parseConstitution(content) {
|
|
43
|
+
if (!content || typeof content !== 'string') {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const articles = [];
|
|
48
|
+
|
|
49
|
+
// Match article headers: ### I. Title (SEVERITY)
|
|
50
|
+
const articleRegex = /^### ([IVXLC]+)\.\s+(.+?)\s*\(([^)]+)\)\s*$/gm;
|
|
51
|
+
|
|
52
|
+
let match;
|
|
53
|
+
const articlePositions = [];
|
|
54
|
+
|
|
55
|
+
while ((match = articleRegex.exec(content)) !== null) {
|
|
56
|
+
articlePositions.push({
|
|
57
|
+
roman: match[1],
|
|
58
|
+
title: match[2].trim(),
|
|
59
|
+
severity: match[3].trim(),
|
|
60
|
+
startIndex: match.index,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < articlePositions.length; i++) {
|
|
65
|
+
const start = articlePositions[i].startIndex;
|
|
66
|
+
const end = i + 1 < articlePositions.length
|
|
67
|
+
? articlePositions[i + 1].startIndex
|
|
68
|
+
: content.indexOf('## Governance', start) !== -1
|
|
69
|
+
? content.indexOf('## Governance', start)
|
|
70
|
+
: content.length;
|
|
71
|
+
|
|
72
|
+
const articleContent = content.substring(start, end);
|
|
73
|
+
const rules = extractRules(articleContent);
|
|
74
|
+
|
|
75
|
+
const num = ROMAN_TO_ARABIC[articlePositions[i].roman];
|
|
76
|
+
if (!num) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
articles.push({
|
|
81
|
+
number: num,
|
|
82
|
+
roman: articlePositions[i].roman,
|
|
83
|
+
title: articlePositions[i].title,
|
|
84
|
+
severity: articlePositions[i].severity,
|
|
85
|
+
rules,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return articles;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract rules from article content
|
|
94
|
+
* Matches bullet points starting with MUST:, MUST NOT:, SHOULD:, SHOULD NOT:, EXCEPTION:
|
|
95
|
+
*
|
|
96
|
+
* @param {string} articleContent - Markdown content of a single article
|
|
97
|
+
* @returns {string[]} Array of rule strings
|
|
98
|
+
*/
|
|
99
|
+
function extractRules(articleContent) {
|
|
100
|
+
const rules = [];
|
|
101
|
+
const lines = articleContent.split('\n');
|
|
102
|
+
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
const trimmed = line.trim();
|
|
105
|
+
const ruleMatch = trimmed.match(/^-\s+(MUST(?:\s+NOT)?|SHOULD(?:\s+NOT)?|EXCEPTION):\s+(.+)$/);
|
|
106
|
+
if (ruleMatch) {
|
|
107
|
+
rules.push(`${ruleMatch[1]}: ${cleanText(ruleMatch[2])}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return rules;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Generate KEY=VALUE constitution content from parsed articles
|
|
116
|
+
*
|
|
117
|
+
* @param {Array<{number: number, roman: string, title: string, severity: string, rules: string[]}>} articles
|
|
118
|
+
* @returns {string} KEY=VALUE formatted content
|
|
119
|
+
*/
|
|
120
|
+
function generateConstitution(articles) {
|
|
121
|
+
const lines = [
|
|
122
|
+
'# SYNAPSE Constitution Domain (L0)',
|
|
123
|
+
'# Auto-generated from .auroq-core/constitution.md',
|
|
124
|
+
'# DO NOT EDIT MANUALLY — re-run generate-constitution.js',
|
|
125
|
+
'',
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
for (const article of articles) {
|
|
129
|
+
lines.push(`# Article ${article.roman}: ${article.title} (${article.severity})`);
|
|
130
|
+
|
|
131
|
+
// Rule 0: title + severity summary
|
|
132
|
+
lines.push(`CONSTITUTION_RULE_ART${article.number}_0=${article.title} (${article.severity})`);
|
|
133
|
+
|
|
134
|
+
// Subsequent rules from bullet points
|
|
135
|
+
for (let i = 0; i < article.rules.length; i++) {
|
|
136
|
+
lines.push(`CONSTITUTION_RULE_ART${article.number}_${i + 1}=${article.rules[i]}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
lines.push('');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return lines.join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Main entry point: read constitution.md, generate .synapse/constitution
|
|
147
|
+
*
|
|
148
|
+
* @param {object} [options] - Override paths for testing
|
|
149
|
+
* @param {string} [options.projectRoot] - Project root directory
|
|
150
|
+
* @param {string} [options.constitutionPath] - Path to constitution.md
|
|
151
|
+
* @param {string} [options.outputPath] - Path to output file
|
|
152
|
+
* @returns {{success: boolean, articles?: number, rules?: number, outputPath?: string, error?: string}}
|
|
153
|
+
*/
|
|
154
|
+
function main(options = {}) {
|
|
155
|
+
const projectRoot = options.projectRoot || path.resolve(__dirname, '..', '..', '..', '..');
|
|
156
|
+
const constitutionPath = options.constitutionPath || path.join(projectRoot, '.auroq-core', 'constitution.md');
|
|
157
|
+
const outputPath = options.outputPath || path.join(projectRoot, '.synapse', 'constitution');
|
|
158
|
+
|
|
159
|
+
// Read source
|
|
160
|
+
let content;
|
|
161
|
+
try {
|
|
162
|
+
content = fs.readFileSync(constitutionPath, 'utf8');
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error.code === 'ENOENT') {
|
|
165
|
+
console.error(`Constitution not found: ${constitutionPath}`);
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
return { success: false, error: 'Constitution file not found' };
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Parse articles
|
|
173
|
+
const articles = parseConstitution(content);
|
|
174
|
+
|
|
175
|
+
if (articles.length === 0) {
|
|
176
|
+
console.error('No articles found in constitution.md');
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return { success: false, error: 'No articles found' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Generate output
|
|
182
|
+
const output = generateConstitution(articles);
|
|
183
|
+
|
|
184
|
+
// Ensure output directory exists
|
|
185
|
+
const outputDir = path.dirname(outputPath);
|
|
186
|
+
if (!fs.existsSync(outputDir)) {
|
|
187
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Write output (idempotent — overwrites cleanly)
|
|
191
|
+
fs.writeFileSync(outputPath, output, 'utf8');
|
|
192
|
+
|
|
193
|
+
const totalRules = articles.reduce((sum, a) => sum + a.rules.length + 1, 0);
|
|
194
|
+
console.log(`Constitution generated: ${articles.length} articles, ${totalRules} rules`);
|
|
195
|
+
|
|
196
|
+
return { success: true, articles: articles.length, rules: totalRules, outputPath };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Run if called directly
|
|
200
|
+
if (require.main === module) {
|
|
201
|
+
main();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { parseConstitution, extractRules, generateConstitution, cleanText, main, ROMAN_TO_ARABIC };
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SYNAPSE Session Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages session state (active agent, workflow, task, squad) persisted as JSON.
|
|
5
|
+
* Sessions are stored in `.synapse/sessions/{uuid}.json` using schema v2.0.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - CRUD operations for sessions
|
|
9
|
+
* - Stale session cleanup (>24h inactive)
|
|
10
|
+
* - Auto-title generation from first meaningful prompt
|
|
11
|
+
* - Gitignore creation for sessions/ and cache/
|
|
12
|
+
*
|
|
13
|
+
* @module core/synapse/session/session-manager
|
|
14
|
+
* @version 1.0.0
|
|
15
|
+
* @created Story SYN-2 - Session Manager
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { atomicWriteSync } = require('../utils/atomic-write');
|
|
21
|
+
|
|
22
|
+
const SCHEMA_VERSION = '2.0';
|
|
23
|
+
const DEFAULT_MAX_AGE_HOURS = 24;
|
|
24
|
+
const MAX_TITLE_LENGTH = 50;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build the default session object conforming to schema v2.0
|
|
28
|
+
*
|
|
29
|
+
* @param {string} sessionId - UUID from Claude Code
|
|
30
|
+
* @param {string} cwd - Working directory
|
|
31
|
+
* @returns {object} Session object with schema v2.0 defaults
|
|
32
|
+
*/
|
|
33
|
+
function buildDefaultSession(sessionId, cwd) {
|
|
34
|
+
const now = new Date().toISOString();
|
|
35
|
+
const label = path.basename(cwd);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
uuid: sessionId,
|
|
39
|
+
schema_version: SCHEMA_VERSION,
|
|
40
|
+
started: now,
|
|
41
|
+
last_activity: now,
|
|
42
|
+
cwd,
|
|
43
|
+
label,
|
|
44
|
+
title: null,
|
|
45
|
+
prompt_count: 0,
|
|
46
|
+
active_agent: { id: null, activated_at: null, activation_quality: null },
|
|
47
|
+
active_workflow: null,
|
|
48
|
+
active_squad: null,
|
|
49
|
+
active_task: null,
|
|
50
|
+
context: { last_bracket: 'FRESH', last_tokens_used: 0, last_context_percent: 100 },
|
|
51
|
+
overrides: {},
|
|
52
|
+
history: { star_commands_used: [], domains_loaded_last: [], agents_activated: [] },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve sessions directory path from a base .synapse/ path
|
|
58
|
+
*
|
|
59
|
+
* @param {string} sessionsDir - Path to sessions directory
|
|
60
|
+
* @returns {string} Resolved sessions directory path
|
|
61
|
+
*/
|
|
62
|
+
function resolveSessionFile(sessionId, sessionsDir) {
|
|
63
|
+
// Sanitize sessionId to prevent path traversal
|
|
64
|
+
if (typeof sessionId !== 'string' || sessionId.includes('..') || sessionId.includes('/') || sessionId.includes('\\')) {
|
|
65
|
+
throw new Error('[synapse:session] Invalid sessionId: contains path separators or traversal');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const filePath = path.join(sessionsDir, `${sessionId}.json`);
|
|
69
|
+
const resolved = path.resolve(filePath);
|
|
70
|
+
const resolvedDir = path.resolve(sessionsDir);
|
|
71
|
+
|
|
72
|
+
if (!resolved.startsWith(resolvedDir + path.sep) && resolved !== resolvedDir) {
|
|
73
|
+
throw new Error('[synapse:session] Invalid sessionId: resolved path escapes sessions directory');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return filePath;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Ensure a directory exists, creating it recursively if needed
|
|
81
|
+
*
|
|
82
|
+
* @param {string} dirPath - Directory path to ensure
|
|
83
|
+
*/
|
|
84
|
+
function ensureDir(dirPath) {
|
|
85
|
+
if (!fs.existsSync(dirPath)) {
|
|
86
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Ensure .synapse/.gitignore exists with required entries
|
|
92
|
+
*
|
|
93
|
+
* @param {string} synapsePath - Path to .synapse/ directory
|
|
94
|
+
*/
|
|
95
|
+
function ensureGitignore(synapsePath) {
|
|
96
|
+
const gitignorePath = path.join(synapsePath, '.gitignore');
|
|
97
|
+
|
|
98
|
+
if (fs.existsSync(gitignorePath)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const content = [
|
|
103
|
+
'# SYNAPSE runtime data (auto-generated)',
|
|
104
|
+
'sessions/',
|
|
105
|
+
'cache/',
|
|
106
|
+
'',
|
|
107
|
+
].join('\n');
|
|
108
|
+
|
|
109
|
+
ensureDir(synapsePath);
|
|
110
|
+
fs.writeFileSync(gitignorePath, content, 'utf8');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Create a new session
|
|
115
|
+
*
|
|
116
|
+
* Creates the session JSON file with schema v2.0 defaults.
|
|
117
|
+
* Auto-creates the sessions/ directory and .gitignore if they don't exist.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} sessionId - UUID from Claude Code
|
|
120
|
+
* @param {string} cwd - Working directory
|
|
121
|
+
* @param {string} [sessionsDir] - Sessions directory (default: cwd/.synapse/sessions)
|
|
122
|
+
* @returns {object} Created session object
|
|
123
|
+
*/
|
|
124
|
+
function createSession(sessionId, cwd, sessionsDir) {
|
|
125
|
+
const dir = sessionsDir || path.join(cwd, '.synapse', 'sessions');
|
|
126
|
+
ensureDir(dir);
|
|
127
|
+
|
|
128
|
+
// Ensure .gitignore exists in .synapse/
|
|
129
|
+
const synapsePath = path.dirname(dir);
|
|
130
|
+
ensureGitignore(synapsePath);
|
|
131
|
+
|
|
132
|
+
const session = buildDefaultSession(sessionId, cwd);
|
|
133
|
+
const filePath = resolveSessionFile(sessionId, dir);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
atomicWriteSync(filePath, JSON.stringify(session, null, 2));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') {
|
|
139
|
+
console.error(`[synapse:session] Error: Permission denied creating session ${sessionId}`);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return session;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Load an existing session
|
|
150
|
+
*
|
|
151
|
+
* @param {string} sessionId - Session UUID
|
|
152
|
+
* @param {string} sessionsDir - Path to sessions directory
|
|
153
|
+
* @returns {object|null} Session object, or null if not found or corrupted
|
|
154
|
+
*/
|
|
155
|
+
function loadSession(sessionId, sessionsDir) {
|
|
156
|
+
const filePath = resolveSessionFile(sessionId, sessionsDir);
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
if (!fs.existsSync(filePath)) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
164
|
+
const session = JSON.parse(raw);
|
|
165
|
+
|
|
166
|
+
if (session.schema_version !== SCHEMA_VERSION) {
|
|
167
|
+
console.warn(
|
|
168
|
+
`[synapse:session] Warning: Session ${sessionId} has schema_version "${session.schema_version}", expected "${SCHEMA_VERSION}"`,
|
|
169
|
+
);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return session;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error instanceof SyntaxError) {
|
|
176
|
+
console.warn(`[synapse:session] Warning: Corrupted JSON for session ${sessionId}`);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') {
|
|
180
|
+
console.error(`[synapse:session] Error: Permission denied reading session ${sessionId}`);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Update an existing session with partial merge
|
|
189
|
+
*
|
|
190
|
+
* Deep-merges the updates into the existing session.
|
|
191
|
+
* Increments prompt_count and updates last_activity on every call.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} sessionId - Session UUID
|
|
194
|
+
* @param {string} sessionsDir - Path to sessions directory
|
|
195
|
+
* @param {object} updates - Partial updates to merge
|
|
196
|
+
* @returns {object|null} Updated session, or null if session not found
|
|
197
|
+
*/
|
|
198
|
+
function updateSession(sessionId, sessionsDir, updates) {
|
|
199
|
+
const session = loadSession(sessionId, sessionsDir);
|
|
200
|
+
|
|
201
|
+
if (!session) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Shallow merge top-level fields
|
|
206
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
207
|
+
if (key === 'history' && typeof value === 'object' && value !== null) {
|
|
208
|
+
// Merge history arrays by appending unique values
|
|
209
|
+
session.history = mergeHistory(session.history, value);
|
|
210
|
+
} else if (key === 'overrides' && typeof value === 'object' && value !== null) {
|
|
211
|
+
// Merge overrides
|
|
212
|
+
Object.assign(session.overrides, value);
|
|
213
|
+
} else if (key === 'context' && typeof value === 'object' && value !== null) {
|
|
214
|
+
// Merge context
|
|
215
|
+
Object.assign(session.context, value);
|
|
216
|
+
} else if (key === 'active_agent' && typeof value === 'object' && value !== null) {
|
|
217
|
+
// Replace active_agent entirely
|
|
218
|
+
session.active_agent = value;
|
|
219
|
+
} else {
|
|
220
|
+
session[key] = value;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Always increment prompt_count and update last_activity
|
|
225
|
+
session.prompt_count += 1;
|
|
226
|
+
session.last_activity = new Date().toISOString();
|
|
227
|
+
|
|
228
|
+
const filePath = resolveSessionFile(sessionId, sessionsDir);
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
atomicWriteSync(filePath, JSON.stringify(session, null, 2));
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') {
|
|
234
|
+
console.error(`[synapse:session] Error: Permission denied writing session ${sessionId}`);
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return session;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Merge history objects by appending unique values to arrays
|
|
245
|
+
*
|
|
246
|
+
* @param {object} existing - Existing history
|
|
247
|
+
* @param {object} incoming - Incoming history updates
|
|
248
|
+
* @returns {object} Merged history
|
|
249
|
+
*/
|
|
250
|
+
function mergeHistory(existing, incoming) {
|
|
251
|
+
const merged = { ...existing };
|
|
252
|
+
|
|
253
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
254
|
+
if (Array.isArray(value) && Array.isArray(merged[key])) {
|
|
255
|
+
const combined = [...merged[key]];
|
|
256
|
+
for (const item of value) {
|
|
257
|
+
if (!combined.includes(item)) {
|
|
258
|
+
combined.push(item);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
merged[key] = combined;
|
|
262
|
+
} else {
|
|
263
|
+
merged[key] = value;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return merged;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Delete a session
|
|
272
|
+
*
|
|
273
|
+
* @param {string} sessionId - Session UUID
|
|
274
|
+
* @param {string} sessionsDir - Path to sessions directory
|
|
275
|
+
* @returns {boolean} True if deleted, false if not found
|
|
276
|
+
*/
|
|
277
|
+
function deleteSession(sessionId, sessionsDir) {
|
|
278
|
+
const filePath = resolveSessionFile(sessionId, sessionsDir);
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
if (!fs.existsSync(filePath)) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
fs.unlinkSync(filePath);
|
|
286
|
+
return true;
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') {
|
|
289
|
+
console.error(`[synapse:session] Error: Permission denied deleting session ${sessionId}`);
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Clean stale sessions (inactive for more than maxAgeHours)
|
|
298
|
+
*
|
|
299
|
+
* Reads each session file individually to avoid loading all into memory.
|
|
300
|
+
* Does not fail if sessions directory doesn't exist.
|
|
301
|
+
*
|
|
302
|
+
* @param {string} sessionsDir - Path to sessions directory
|
|
303
|
+
* @param {number} [maxAgeHours=24] - Maximum inactivity threshold in hours
|
|
304
|
+
* @returns {number} Count of removed sessions
|
|
305
|
+
*/
|
|
306
|
+
function cleanStaleSessions(sessionsDir, maxAgeHours = DEFAULT_MAX_AGE_HOURS) {
|
|
307
|
+
ensureDir(sessionsDir);
|
|
308
|
+
|
|
309
|
+
let removedCount = 0;
|
|
310
|
+
const cutoffMs = maxAgeHours * 60 * 60 * 1000;
|
|
311
|
+
const now = Date.now();
|
|
312
|
+
|
|
313
|
+
let files;
|
|
314
|
+
try {
|
|
315
|
+
files = fs.readdirSync(sessionsDir);
|
|
316
|
+
} catch (_error) {
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for (const file of files) {
|
|
321
|
+
if (!file.endsWith('.json')) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const filePath = path.join(sessionsDir, file);
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
329
|
+
const session = JSON.parse(raw);
|
|
330
|
+
|
|
331
|
+
if (session.last_activity) {
|
|
332
|
+
const lastActivity = new Date(session.last_activity).getTime();
|
|
333
|
+
const age = now - lastActivity;
|
|
334
|
+
|
|
335
|
+
if (age > cutoffMs) {
|
|
336
|
+
fs.unlinkSync(filePath);
|
|
337
|
+
removedCount++;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} catch (_error) {
|
|
341
|
+
// Skip corrupted files during cleanup
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return removedCount;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Generate a title from a user prompt
|
|
351
|
+
*
|
|
352
|
+
* Extracts a meaningful title (max 50 chars) from the first significant prompt.
|
|
353
|
+
* Ignores *commands and single-word prompts.
|
|
354
|
+
*
|
|
355
|
+
* @param {string} prompt - User prompt text
|
|
356
|
+
* @returns {string|null} Generated title, or null if prompt is not title-worthy
|
|
357
|
+
*/
|
|
358
|
+
function generateTitle(prompt) {
|
|
359
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const trimmed = prompt.trim();
|
|
364
|
+
|
|
365
|
+
// Ignore *commands
|
|
366
|
+
if (trimmed.startsWith('*')) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Ignore single words
|
|
371
|
+
if (!trimmed.includes(' ')) {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Ignore very short prompts (less than 3 chars)
|
|
376
|
+
if (trimmed.length < 3) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Truncate to max length, respecting word boundaries
|
|
381
|
+
if (trimmed.length <= MAX_TITLE_LENGTH) {
|
|
382
|
+
return trimmed;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const truncated = trimmed.substring(0, MAX_TITLE_LENGTH);
|
|
386
|
+
const lastSpace = truncated.lastIndexOf(' ');
|
|
387
|
+
|
|
388
|
+
if (lastSpace > 0) {
|
|
389
|
+
return truncated.substring(0, lastSpace);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return truncated;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
module.exports = {
|
|
396
|
+
createSession,
|
|
397
|
+
loadSession,
|
|
398
|
+
updateSession,
|
|
399
|
+
deleteSession,
|
|
400
|
+
cleanStaleSessions,
|
|
401
|
+
generateTitle,
|
|
402
|
+
ensureGitignore,
|
|
403
|
+
SCHEMA_VERSION,
|
|
404
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic Write Utility
|
|
3
|
+
*
|
|
4
|
+
* Writes files atomically using write-to-tmp + rename pattern.
|
|
5
|
+
* Prevents file corruption on unexpected exit (crash, kill, power loss).
|
|
6
|
+
*
|
|
7
|
+
* Pattern:
|
|
8
|
+
* 1. Write data to {filePath}.tmp.{pid}
|
|
9
|
+
* 2. On Windows: unlink target if exists (rename won't overwrite)
|
|
10
|
+
* 3. Rename tmp → target (atomic on POSIX, near-atomic on Windows)
|
|
11
|
+
* 4. On failure: clean up tmp file
|
|
12
|
+
*
|
|
13
|
+
* @module core/synapse/utils/atomic-write
|
|
14
|
+
* @version 1.0.0
|
|
15
|
+
* @created Story NOG-12 - State Persistence Hardening
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Write data to a file atomically.
|
|
27
|
+
*
|
|
28
|
+
* Writes to a temporary file first, then renames to the target path.
|
|
29
|
+
* If the process crashes between write and rename, the original file
|
|
30
|
+
* remains intact and the orphaned .tmp file is harmless.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} filePath - Target file path
|
|
33
|
+
* @param {string} data - Data to write
|
|
34
|
+
* @param {string} [encoding='utf8'] - File encoding
|
|
35
|
+
* @throws {Error} If write or rename fails (original file preserved)
|
|
36
|
+
*/
|
|
37
|
+
function atomicWriteSync(filePath, data, encoding = 'utf8') {
|
|
38
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
// Ensure parent directory exists
|
|
42
|
+
const dir = path.dirname(filePath);
|
|
43
|
+
if (!fs.existsSync(dir)) {
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Step 1: Write to temporary file
|
|
48
|
+
fs.writeFileSync(tmpPath, data, encoding);
|
|
49
|
+
|
|
50
|
+
// Step 2: On Windows, unlink target first (rename won't overwrite)
|
|
51
|
+
if (IS_WINDOWS) {
|
|
52
|
+
try {
|
|
53
|
+
fs.unlinkSync(filePath);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.code !== 'ENOENT') {
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
// ENOENT = target doesn't exist yet, that's fine
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Step 3: Atomic rename
|
|
63
|
+
fs.renameSync(tmpPath, filePath);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// Clean up tmp file on failure
|
|
66
|
+
try {
|
|
67
|
+
fs.unlinkSync(tmpPath);
|
|
68
|
+
} catch (_cleanupErr) {
|
|
69
|
+
// Ignore cleanup errors
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.error(`[atomic-write] Failed to write ${filePath}: ${error.message}`);
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
atomicWriteSync,
|
|
79
|
+
};
|