clawsouls 0.13.7 → 0.14.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/dist/bin/clawsouls.js +11 -0
- package/dist/commands/import.d.ts +9 -0
- package/dist/commands/import.js +218 -0
- package/dist/import/classifier.d.ts +15 -0
- package/dist/import/classifier.js +156 -0
- package/dist/import/converter.d.ts +24 -0
- package/dist/import/converter.js +84 -0
- package/dist/import/parser.d.ts +20 -0
- package/dist/import/parser.js +79 -0
- package/dist/import/soul-json-generator.d.ts +28 -0
- package/dist/import/soul-json-generator.js +133 -0
- package/package.json +1 -1
package/dist/bin/clawsouls.js
CHANGED
|
@@ -14,6 +14,7 @@ import { soulscanCommand } from '../commands/soulscan.js';
|
|
|
14
14
|
import { scanCommand as localScanCommand } from '../commands/scan.js';
|
|
15
15
|
import { platformCommand } from '../commands/platform.js';
|
|
16
16
|
import { exportCommand } from '../commands/export.js';
|
|
17
|
+
import { importCommand } from '../commands/import.js';
|
|
17
18
|
import { syncInitCommand, syncPushCommand, syncPullCommand, syncStatusCommand, syncExportKeyCommand, syncImportKeyCommand } from '../commands/sync.js';
|
|
18
19
|
import { versionBumpCommand } from '../commands/version.js';
|
|
19
20
|
import { diffCommand } from '../commands/diff.js';
|
|
@@ -119,6 +120,16 @@ program
|
|
|
119
120
|
.option('-o, --output <path>', 'Output file path')
|
|
120
121
|
.option('-d, --dir <path>', 'Source soul directory (default: active workspace)')
|
|
121
122
|
.action((format, opts) => exportCommand(format, opts));
|
|
123
|
+
program
|
|
124
|
+
.command('import <format>')
|
|
125
|
+
.description('Import persona from other formats (claude-md, system-prompt, cursorrules)')
|
|
126
|
+
.option('-i, --input <path>', 'Source file path (default: auto-detect)')
|
|
127
|
+
.option('-o, --output <path>', 'Output directory (default: ./)')
|
|
128
|
+
.option('--dry-run', 'Preview classification without writing files')
|
|
129
|
+
.option('--smart', 'Use LLM-assisted classification (requires API key)')
|
|
130
|
+
.option('--name <name>', 'Soul name for soul.json')
|
|
131
|
+
.option('-f, --force', 'Overwrite existing files')
|
|
132
|
+
.action((format, opts) => importCommand(format, opts));
|
|
122
133
|
// ─── Sync Commands ───────────────────────────────────────
|
|
123
134
|
const sync = program
|
|
124
135
|
.command('sync')
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, statSync } from 'fs';
|
|
3
|
+
import { join, resolve, basename } from 'path';
|
|
4
|
+
import { parseMarkdown } from '../import/parser.js';
|
|
5
|
+
import { classifySections } from '../import/classifier.js';
|
|
6
|
+
import { convertSections, estimateSize } from '../import/converter.js';
|
|
7
|
+
import { generateSoulJson } from '../import/soul-json-generator.js';
|
|
8
|
+
const SUPPORTED_FORMATS = ['claude-md', 'system-prompt', 'cursorrules', 'windsurfrules'];
|
|
9
|
+
/**
|
|
10
|
+
* Auto-detect input file based on format.
|
|
11
|
+
*/
|
|
12
|
+
function autoDetectInput(format, cwd) {
|
|
13
|
+
switch (format) {
|
|
14
|
+
case 'claude-md':
|
|
15
|
+
for (const candidate of ['CLAUDE.md', 'claude.md']) {
|
|
16
|
+
const p = join(cwd, candidate);
|
|
17
|
+
if (existsSync(p))
|
|
18
|
+
return p;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
case 'cursorrules':
|
|
22
|
+
for (const candidate of ['.cursorrules', '.cursor/rules/persona.md', '.cursor/rules/main.md']) {
|
|
23
|
+
const p = join(cwd, candidate);
|
|
24
|
+
if (existsSync(p))
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
case 'windsurfrules':
|
|
29
|
+
for (const candidate of ['.windsurfrules']) {
|
|
30
|
+
const p = join(cwd, candidate);
|
|
31
|
+
if (existsSync(p))
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
case 'system-prompt':
|
|
36
|
+
for (const candidate of ['system-prompt.txt', 'system_prompt.txt', 'prompt.txt']) {
|
|
37
|
+
const p = join(cwd, candidate);
|
|
38
|
+
if (existsSync(p))
|
|
39
|
+
return p;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
default:
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* For system-prompt format: wrap entire content in a single SOUL.md section.
|
|
48
|
+
*/
|
|
49
|
+
function importAsSystemPrompt(content) {
|
|
50
|
+
const sections = parseMarkdown(content);
|
|
51
|
+
if (sections.length === 0) {
|
|
52
|
+
return [{
|
|
53
|
+
heading: '',
|
|
54
|
+
content,
|
|
55
|
+
level: 0,
|
|
56
|
+
lineStart: 0,
|
|
57
|
+
target: 'SOUL.md',
|
|
58
|
+
confidence: 80,
|
|
59
|
+
reason: 'system-prompt: entire content',
|
|
60
|
+
}];
|
|
61
|
+
}
|
|
62
|
+
// Classify normally but bump all to SOUL.md
|
|
63
|
+
return classifySections(sections).map(s => ({
|
|
64
|
+
...s,
|
|
65
|
+
target: 'SOUL.md',
|
|
66
|
+
confidence: 70,
|
|
67
|
+
reason: 'system-prompt format: routed to SOUL.md',
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
function formatBytes(n) {
|
|
71
|
+
if (n < 1024)
|
|
72
|
+
return `${n} B`;
|
|
73
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
74
|
+
}
|
|
75
|
+
function confidenceColor(n) {
|
|
76
|
+
if (n >= 80)
|
|
77
|
+
return chalk.green(`${n}%`);
|
|
78
|
+
if (n >= 60)
|
|
79
|
+
return chalk.yellow(`${n}%`);
|
|
80
|
+
return chalk.red(`${n}%`) + chalk.dim(' ⚠ low confidence');
|
|
81
|
+
}
|
|
82
|
+
function targetColor(target) {
|
|
83
|
+
switch (target) {
|
|
84
|
+
case 'SOUL.md': return chalk.magenta(target);
|
|
85
|
+
case 'IDENTITY.md': return chalk.cyan(target);
|
|
86
|
+
case 'AGENTS.md': return chalk.blue(target);
|
|
87
|
+
case 'STYLE.md': return chalk.yellow(target);
|
|
88
|
+
case 'HEARTBEAT.md': return chalk.green(target);
|
|
89
|
+
case 'RULES.md': return chalk.red(target);
|
|
90
|
+
case 'TOOLS.md': return chalk.gray(target);
|
|
91
|
+
case 'SKIP': return chalk.dim('SKIP');
|
|
92
|
+
default: return target;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function importCommand(format, options) {
|
|
96
|
+
if (!SUPPORTED_FORMATS.includes(format)) {
|
|
97
|
+
console.error(chalk.red(`Unknown format: "${format}"`));
|
|
98
|
+
console.error(`Supported formats: ${SUPPORTED_FORMATS.join(', ')}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
const cwd = process.cwd();
|
|
102
|
+
const outputDir = resolve(options.output || cwd);
|
|
103
|
+
// Resolve input file
|
|
104
|
+
let inputPath;
|
|
105
|
+
if (options.input) {
|
|
106
|
+
inputPath = resolve(options.input);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
const detected = autoDetectInput(format, cwd);
|
|
110
|
+
if (!detected) {
|
|
111
|
+
console.error(chalk.red(`Could not auto-detect input file for format "${format}"`));
|
|
112
|
+
console.error(`Use --input <path> to specify the file.`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
inputPath = detected;
|
|
116
|
+
}
|
|
117
|
+
if (!existsSync(inputPath)) {
|
|
118
|
+
console.error(chalk.red(`File not found: ${inputPath}`));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
const content = readFileSync(inputPath, 'utf-8');
|
|
122
|
+
const fileSize = statSync(inputPath).size;
|
|
123
|
+
// Parse
|
|
124
|
+
const rawSections = parseMarkdown(content);
|
|
125
|
+
console.log('');
|
|
126
|
+
console.log(chalk.bold(`Analyzing: ${chalk.cyan(basename(inputPath))} `) +
|
|
127
|
+
chalk.dim(`(${formatBytes(fileSize)}, ${rawSections.length} section${rawSections.length !== 1 ? 's' : ''})`));
|
|
128
|
+
console.log('');
|
|
129
|
+
// Classify
|
|
130
|
+
let classified = format === 'system-prompt'
|
|
131
|
+
? importAsSystemPrompt(content)
|
|
132
|
+
: classifySections(rawSections);
|
|
133
|
+
if (options.smart) {
|
|
134
|
+
console.log(chalk.dim(' --smart: LLM-assisted classification not yet implemented, using rule-based.'));
|
|
135
|
+
}
|
|
136
|
+
// Group for summary
|
|
137
|
+
const grouped = new Map();
|
|
138
|
+
for (const s of classified) {
|
|
139
|
+
const arr = grouped.get(s.target) || [];
|
|
140
|
+
arr.push(s);
|
|
141
|
+
grouped.set(s.target, arr);
|
|
142
|
+
}
|
|
143
|
+
// Print classification table
|
|
144
|
+
console.log(chalk.bold('Classification Results:'));
|
|
145
|
+
for (const s of classified) {
|
|
146
|
+
const heading = s.heading
|
|
147
|
+
? s.heading.slice(0, 45).padEnd(46)
|
|
148
|
+
: chalk.dim('(no heading)'.padEnd(46));
|
|
149
|
+
const arrow = chalk.dim('→');
|
|
150
|
+
const target = targetColor(s.target).padEnd(20);
|
|
151
|
+
const conf = confidenceColor(s.confidence);
|
|
152
|
+
console.log(` ${heading} ${arrow} ${target} (confidence: ${conf})`);
|
|
153
|
+
}
|
|
154
|
+
console.log('');
|
|
155
|
+
console.log(chalk.bold('Files to create:'));
|
|
156
|
+
const allTargets = ['SOUL.md', 'IDENTITY.md', 'AGENTS.md', 'STYLE.md', 'HEARTBEAT.md', 'RULES.md', 'TOOLS.md'];
|
|
157
|
+
let hasLowConfidence = false;
|
|
158
|
+
for (const target of allTargets) {
|
|
159
|
+
const sections = grouped.get(target);
|
|
160
|
+
if (!sections || sections.length === 0)
|
|
161
|
+
continue;
|
|
162
|
+
const sizeStr = formatBytes(estimateSize(sections));
|
|
163
|
+
const count = sections.length;
|
|
164
|
+
const isExtras = ['RULES.md', 'TOOLS.md'].includes(target);
|
|
165
|
+
const label = isExtras ? `extras/${target}` : target;
|
|
166
|
+
const lowConf = sections.some(s => s.confidence < 60);
|
|
167
|
+
if (lowConf)
|
|
168
|
+
hasLowConfidence = true;
|
|
169
|
+
console.log(` ${targetColor(target).padEnd(22)} ${String(count).padStart(2)} section${count !== 1 ? 's' : ' '} (~${sizeStr})`);
|
|
170
|
+
}
|
|
171
|
+
console.log(` ${'soul.json'.padEnd(22)} auto-generated`);
|
|
172
|
+
const skipCount = (grouped.get('SKIP') || []).length;
|
|
173
|
+
const lowConfCount = classified.filter(s => s.confidence < 60 && s.target !== 'SKIP').length;
|
|
174
|
+
console.log('');
|
|
175
|
+
if (skipCount > 0) {
|
|
176
|
+
console.log(chalk.dim(`Skipped: ${skipCount} section${skipCount !== 1 ? 's' : ''} (memory/config, not persona)`));
|
|
177
|
+
}
|
|
178
|
+
if (lowConfCount > 0) {
|
|
179
|
+
console.log(chalk.yellow(`Low confidence: ${lowConfCount} section${lowConfCount !== 1 ? 's' : ''} — use --smart for LLM-assisted classification`));
|
|
180
|
+
}
|
|
181
|
+
if (options.dryRun) {
|
|
182
|
+
console.log('');
|
|
183
|
+
console.log(chalk.dim('Dry run — no files written. Remove --dry-run to create files.'));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
console.log('');
|
|
187
|
+
// Write files
|
|
188
|
+
const result = convertSections(classified, {
|
|
189
|
+
outputDir,
|
|
190
|
+
force: options.force,
|
|
191
|
+
dryRun: false,
|
|
192
|
+
});
|
|
193
|
+
// Write soul.json
|
|
194
|
+
const soulJson = generateSoulJson(classified, { name: options.name });
|
|
195
|
+
const soulJsonPath = join(outputDir, 'soul.json');
|
|
196
|
+
if (!existsSync(soulJsonPath) || options.force) {
|
|
197
|
+
writeFileSync(soulJsonPath, JSON.stringify(soulJson, null, 2) + '\n', 'utf-8');
|
|
198
|
+
result.written.push(soulJsonPath);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
result.existing.push(soulJsonPath);
|
|
202
|
+
}
|
|
203
|
+
// Summary
|
|
204
|
+
console.log(chalk.bold('Summary:'));
|
|
205
|
+
for (const f of result.written) {
|
|
206
|
+
console.log(` ${chalk.green('✓')} ${f}`);
|
|
207
|
+
}
|
|
208
|
+
if (result.existing.length > 0) {
|
|
209
|
+
console.log('');
|
|
210
|
+
console.log(chalk.yellow(` Skipped (already exist — use --force to overwrite):`));
|
|
211
|
+
for (const f of result.existing) {
|
|
212
|
+
console.log(chalk.dim(` ${f}`));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
console.log('');
|
|
216
|
+
console.log(chalk.green(`Done! Soul Spec files written to: ${chalk.bold(outputDir)}`));
|
|
217
|
+
console.log(chalk.dim(' Run `clawsouls validate` to check the generated package.'));
|
|
218
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule-based classifier for Soul Spec section routing.
|
|
3
|
+
* Classifies parsed markdown sections into target Soul Spec files.
|
|
4
|
+
*/
|
|
5
|
+
import { Section } from './parser.js';
|
|
6
|
+
export type SoulTarget = 'SOUL.md' | 'IDENTITY.md' | 'AGENTS.md' | 'STYLE.md' | 'HEARTBEAT.md' | 'RULES.md' | 'TOOLS.md' | 'SKIP';
|
|
7
|
+
export interface ClassifiedSection extends Section {
|
|
8
|
+
target: SoulTarget;
|
|
9
|
+
confidence: number;
|
|
10
|
+
reason: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Classify all sections and return enriched array.
|
|
14
|
+
*/
|
|
15
|
+
export declare function classifySections(sections: Section[]): ClassifiedSection[];
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule-based classifier for Soul Spec section routing.
|
|
3
|
+
* Classifies parsed markdown sections into target Soul Spec files.
|
|
4
|
+
*/
|
|
5
|
+
import { headingText } from './parser.js';
|
|
6
|
+
const CLASSIFICATION_RULES = [
|
|
7
|
+
{
|
|
8
|
+
target: 'SOUL.md',
|
|
9
|
+
keywords: [
|
|
10
|
+
'personality', 'character', 'traits', 'values', 'principles',
|
|
11
|
+
'who you are', 'core beliefs', 'philosophy', 'soul', 'persona',
|
|
12
|
+
'성격', '원칙', '가치관', '철학', '핵심',
|
|
13
|
+
],
|
|
14
|
+
weight: 1.0,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
target: 'IDENTITY.md',
|
|
18
|
+
keywords: [
|
|
19
|
+
'name', 'identity', 'background', 'role', 'creature', 'species',
|
|
20
|
+
'who am i', 'i am', 'about me',
|
|
21
|
+
'이름', '정체성', '역할', '배경', '자기소개',
|
|
22
|
+
],
|
|
23
|
+
weight: 1.0,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
target: 'AGENTS.md',
|
|
27
|
+
keywords: [
|
|
28
|
+
'workflow', 'work style', 'safety', 'agents', 'protocol', 'procedure',
|
|
29
|
+
'rules', 'guidelines', 'constraints', 'boundaries', 'limits', 'behavior',
|
|
30
|
+
'process', 'steps', 'checklist', 'bootstrap', 'session start',
|
|
31
|
+
'워크플로우', '작업', '안전', '규칙', '절차', '가이드', '제약',
|
|
32
|
+
],
|
|
33
|
+
weight: 1.0,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
target: 'STYLE.md',
|
|
37
|
+
keywords: [
|
|
38
|
+
'style', 'tone', 'voice', 'communication', 'language', 'writing',
|
|
39
|
+
'speak', 'response format', 'format', 'output', 'formatting',
|
|
40
|
+
'말투', '스타일', '어조', '언어', '커뮤니케이션', '표현',
|
|
41
|
+
],
|
|
42
|
+
weight: 1.0,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
target: 'HEARTBEAT.md',
|
|
46
|
+
keywords: [
|
|
47
|
+
'heartbeat', 'periodic', 'cron', 'autonomous', 'check-in', 'schedule',
|
|
48
|
+
'interval', 'background', 'monitoring', 'health check', 'self-check',
|
|
49
|
+
'자율', '점검', '주기', '헬스체크', '배경', '크론',
|
|
50
|
+
],
|
|
51
|
+
weight: 1.2, // slightly higher — heartbeat is very distinct
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
target: 'RULES.md',
|
|
55
|
+
keywords: [
|
|
56
|
+
'do not', "don't", 'never', 'always', 'must', 'forbidden', 'prohibited',
|
|
57
|
+
'restriction', 'constraint', 'boundary',
|
|
58
|
+
'금지', '절대', '반드시', '제한',
|
|
59
|
+
],
|
|
60
|
+
weight: 0.9,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
target: 'TOOLS.md',
|
|
64
|
+
keywords: [
|
|
65
|
+
'tools', 'permissions', 'allowed', 'access', 'capabilities', 'mcp',
|
|
66
|
+
'function', 'api', 'integration',
|
|
67
|
+
'도구', '권한', '허용', '접근', '기능',
|
|
68
|
+
],
|
|
69
|
+
weight: 0.9,
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
/**
|
|
73
|
+
* Keywords that signal a section should be SKIPPED (not persona content).
|
|
74
|
+
* These are meta/config sections, not character/persona.
|
|
75
|
+
*/
|
|
76
|
+
const SKIP_KEYWORDS = [
|
|
77
|
+
'memory', 'context window', 'session', 'bootstrap', 'token',
|
|
78
|
+
'oh-my-claudecode', 'omc', 'mcp tools', 'hooks', 'state files',
|
|
79
|
+
'notepad', 'compaction', 'worktree',
|
|
80
|
+
'메모리', '컨텍스트', '세션', '부트스트랩',
|
|
81
|
+
];
|
|
82
|
+
/**
|
|
83
|
+
* Check if a section should be skipped based on heading or content.
|
|
84
|
+
*/
|
|
85
|
+
function shouldSkip(heading, content) {
|
|
86
|
+
const lower = (heading + ' ' + content.slice(0, 300)).toLowerCase();
|
|
87
|
+
for (const kw of SKIP_KEYWORDS) {
|
|
88
|
+
if (lower.includes(kw.toLowerCase())) {
|
|
89
|
+
return { skip: true, confidence: 85 };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { skip: false, confidence: 0 };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Score a section against a rule based on keyword matches in heading + content preview.
|
|
96
|
+
*/
|
|
97
|
+
function scoreSection(heading, content, rule) {
|
|
98
|
+
const text = (heading + ' ' + content.slice(0, 500)).toLowerCase();
|
|
99
|
+
let matches = 0;
|
|
100
|
+
let totalWeight = 0;
|
|
101
|
+
for (const kw of rule.keywords) {
|
|
102
|
+
if (text.includes(kw.toLowerCase())) {
|
|
103
|
+
matches++;
|
|
104
|
+
totalWeight += kw.split(' ').length > 1 ? 2 : 1; // multi-word keywords score higher
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (matches === 0)
|
|
108
|
+
return 0;
|
|
109
|
+
// Normalize: max score is 100
|
|
110
|
+
const rawScore = (totalWeight / rule.keywords.length) * 100 * rule.weight;
|
|
111
|
+
return Math.min(rawScore, 100);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Classify a single section.
|
|
115
|
+
*/
|
|
116
|
+
function classifySection(section) {
|
|
117
|
+
const htxt = headingText(section).toLowerCase();
|
|
118
|
+
const content = section.content;
|
|
119
|
+
// Skip check first
|
|
120
|
+
const skipResult = shouldSkip(htxt, content);
|
|
121
|
+
if (skipResult.skip) {
|
|
122
|
+
return { target: 'SKIP', confidence: skipResult.confidence, reason: 'memory/config content' };
|
|
123
|
+
}
|
|
124
|
+
// Score against all rules
|
|
125
|
+
const scores = [];
|
|
126
|
+
for (const rule of CLASSIFICATION_RULES) {
|
|
127
|
+
const score = scoreSection(htxt, content, rule);
|
|
128
|
+
if (score > 0) {
|
|
129
|
+
scores.push({ target: rule.target, score });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (scores.length === 0) {
|
|
133
|
+
// No match — fallback to SOUL.md with low confidence
|
|
134
|
+
return { target: 'SOUL.md', confidence: 30, reason: 'no keyword match — defaulting to SOUL.md' };
|
|
135
|
+
}
|
|
136
|
+
// Pick highest scoring
|
|
137
|
+
scores.sort((a, b) => b.score - a.score);
|
|
138
|
+
const best = scores[0];
|
|
139
|
+
const confidence = Math.round(Math.min(best.score + 20, 100)); // add base boost
|
|
140
|
+
const matchedRule = CLASSIFICATION_RULES.find(r => r.target === best.target);
|
|
141
|
+
const matchedKws = matchedRule.keywords.filter(kw => (htxt + ' ' + content.slice(0, 500).toLowerCase()).includes(kw.toLowerCase())).slice(0, 3);
|
|
142
|
+
return {
|
|
143
|
+
target: best.target,
|
|
144
|
+
confidence,
|
|
145
|
+
reason: `keyword match: ${matchedKws.join(', ')}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Classify all sections and return enriched array.
|
|
150
|
+
*/
|
|
151
|
+
export function classifySections(sections) {
|
|
152
|
+
return sections.map(section => {
|
|
153
|
+
const { target, confidence, reason } = classifySection(section);
|
|
154
|
+
return { ...section, target, confidence, reason };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converter: takes classified sections and writes Soul Spec files.
|
|
3
|
+
*/
|
|
4
|
+
import { ClassifiedSection } from './classifier.js';
|
|
5
|
+
interface ConvertOptions {
|
|
6
|
+
outputDir: string;
|
|
7
|
+
force?: boolean;
|
|
8
|
+
dryRun?: boolean;
|
|
9
|
+
}
|
|
10
|
+
interface ConvertResult {
|
|
11
|
+
written: string[];
|
|
12
|
+
skipped: string[];
|
|
13
|
+
existing: string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Write Soul Spec files from classified sections.
|
|
17
|
+
* Returns a summary of what was written/skipped.
|
|
18
|
+
*/
|
|
19
|
+
export declare function convertSections(sections: ClassifiedSection[], options: ConvertOptions): ConvertResult;
|
|
20
|
+
/**
|
|
21
|
+
* Estimate byte size of content for a group of sections.
|
|
22
|
+
*/
|
|
23
|
+
export declare function estimateSize(sections: ClassifiedSection[]): number;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converter: takes classified sections and writes Soul Spec files.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
const CORE_TARGETS = ['SOUL.md', 'IDENTITY.md', 'AGENTS.md', 'STYLE.md', 'HEARTBEAT.md'];
|
|
7
|
+
const EXTRAS_TARGETS = ['RULES.md', 'TOOLS.md'];
|
|
8
|
+
/**
|
|
9
|
+
* Group classified sections by their target file.
|
|
10
|
+
*/
|
|
11
|
+
function groupByTarget(sections) {
|
|
12
|
+
const map = new Map();
|
|
13
|
+
for (const section of sections) {
|
|
14
|
+
if (section.target === 'SKIP')
|
|
15
|
+
continue;
|
|
16
|
+
const arr = map.get(section.target) || [];
|
|
17
|
+
arr.push(section);
|
|
18
|
+
map.set(section.target, arr);
|
|
19
|
+
}
|
|
20
|
+
return map;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Build the markdown content for a Soul Spec file from its sections.
|
|
24
|
+
*/
|
|
25
|
+
function buildFileContent(target, sections) {
|
|
26
|
+
const lines = [];
|
|
27
|
+
// File header comment
|
|
28
|
+
lines.push(`<!-- Generated by clawsouls import -->`);
|
|
29
|
+
lines.push('');
|
|
30
|
+
for (const section of sections) {
|
|
31
|
+
if (section.heading) {
|
|
32
|
+
lines.push(section.heading);
|
|
33
|
+
lines.push('');
|
|
34
|
+
}
|
|
35
|
+
if (section.content) {
|
|
36
|
+
lines.push(section.content);
|
|
37
|
+
lines.push('');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return lines.join('\n').trimEnd() + '\n';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Write Soul Spec files from classified sections.
|
|
44
|
+
* Returns a summary of what was written/skipped.
|
|
45
|
+
*/
|
|
46
|
+
export function convertSections(sections, options) {
|
|
47
|
+
const { outputDir, force = false, dryRun = false } = options;
|
|
48
|
+
const grouped = groupByTarget(sections);
|
|
49
|
+
const result = { written: [], skipped: [], existing: [] };
|
|
50
|
+
// Ensure output directory exists
|
|
51
|
+
if (!dryRun && !existsSync(outputDir)) {
|
|
52
|
+
mkdirSync(outputDir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
// Write core target files
|
|
55
|
+
for (const target of [...CORE_TARGETS, ...EXTRAS_TARGETS]) {
|
|
56
|
+
const targetSections = grouped.get(target);
|
|
57
|
+
if (!targetSections || targetSections.length === 0) {
|
|
58
|
+
result.skipped.push(target);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const isExtras = EXTRAS_TARGETS.includes(target);
|
|
62
|
+
const fileDir = isExtras ? join(outputDir, 'extras') : outputDir;
|
|
63
|
+
const filePath = join(fileDir, target);
|
|
64
|
+
if (existsSync(filePath) && !force) {
|
|
65
|
+
result.existing.push(filePath);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const content = buildFileContent(target, targetSections);
|
|
69
|
+
if (!dryRun) {
|
|
70
|
+
if (!existsSync(fileDir)) {
|
|
71
|
+
mkdirSync(fileDir, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
74
|
+
}
|
|
75
|
+
result.written.push(filePath);
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Estimate byte size of content for a group of sections.
|
|
81
|
+
*/
|
|
82
|
+
export function estimateSize(sections) {
|
|
83
|
+
return sections.reduce((acc, s) => acc + s.heading.length + s.content.length + 2, 0);
|
|
84
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown section parser for clawsouls import
|
|
3
|
+
* Splits a markdown file into sections by heading level,
|
|
4
|
+
* while correctly handling code blocks (headings inside ``` are ignored).
|
|
5
|
+
*/
|
|
6
|
+
export interface Section {
|
|
7
|
+
heading: string;
|
|
8
|
+
content: string;
|
|
9
|
+
level: number;
|
|
10
|
+
lineStart: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Parse markdown text into sections split on headings.
|
|
14
|
+
* Headings inside fenced code blocks are not treated as section boundaries.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseMarkdown(text: string): Section[];
|
|
17
|
+
/**
|
|
18
|
+
* Extract the text of a heading (without # marks and leading/trailing whitespace).
|
|
19
|
+
*/
|
|
20
|
+
export declare function headingText(section: Section): string;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown section parser for clawsouls import
|
|
3
|
+
* Splits a markdown file into sections by heading level,
|
|
4
|
+
* while correctly handling code blocks (headings inside ``` are ignored).
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Parse markdown text into sections split on headings.
|
|
8
|
+
* Headings inside fenced code blocks are not treated as section boundaries.
|
|
9
|
+
*/
|
|
10
|
+
export function parseMarkdown(text) {
|
|
11
|
+
const lines = text.split('\n');
|
|
12
|
+
const sections = [];
|
|
13
|
+
let inCodeBlock = false;
|
|
14
|
+
let codeBlockFence = '';
|
|
15
|
+
// Collect heading positions first
|
|
16
|
+
const headingLines = [];
|
|
17
|
+
for (let i = 0; i < lines.length; i++) {
|
|
18
|
+
const line = lines[i];
|
|
19
|
+
// Detect fenced code block boundaries (``` or ~~~)
|
|
20
|
+
const fenceMatch = line.match(/^(`{3,}|~{3,})/);
|
|
21
|
+
if (fenceMatch) {
|
|
22
|
+
if (!inCodeBlock) {
|
|
23
|
+
inCodeBlock = true;
|
|
24
|
+
codeBlockFence = fenceMatch[1][0]; // ` or ~
|
|
25
|
+
}
|
|
26
|
+
else if (line.trimEnd().startsWith(codeBlockFence.repeat(3))) {
|
|
27
|
+
inCodeBlock = false;
|
|
28
|
+
codeBlockFence = '';
|
|
29
|
+
}
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (inCodeBlock)
|
|
33
|
+
continue;
|
|
34
|
+
// ATX heading match (# through ######)
|
|
35
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
36
|
+
if (headingMatch) {
|
|
37
|
+
headingLines.push({
|
|
38
|
+
index: i,
|
|
39
|
+
level: headingMatch[1].length,
|
|
40
|
+
heading: line,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (headingLines.length === 0) {
|
|
45
|
+
// No headings — entire content is one section
|
|
46
|
+
const content = text.trim();
|
|
47
|
+
if (content) {
|
|
48
|
+
sections.push({
|
|
49
|
+
heading: '',
|
|
50
|
+
content,
|
|
51
|
+
level: 0,
|
|
52
|
+
lineStart: 0,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return sections;
|
|
56
|
+
}
|
|
57
|
+
for (let i = 0; i < headingLines.length; i++) {
|
|
58
|
+
const { index, level, heading } = headingLines[i];
|
|
59
|
+
const nextIndex = i + 1 < headingLines.length ? headingLines[i + 1].index : lines.length;
|
|
60
|
+
const contentLines = lines.slice(index + 1, nextIndex);
|
|
61
|
+
// trim trailing blank lines
|
|
62
|
+
while (contentLines.length > 0 && contentLines[contentLines.length - 1].trim() === '') {
|
|
63
|
+
contentLines.pop();
|
|
64
|
+
}
|
|
65
|
+
sections.push({
|
|
66
|
+
heading,
|
|
67
|
+
content: contentLines.join('\n'),
|
|
68
|
+
level,
|
|
69
|
+
lineStart: index,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return sections;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Extract the text of a heading (without # marks and leading/trailing whitespace).
|
|
76
|
+
*/
|
|
77
|
+
export function headingText(section) {
|
|
78
|
+
return section.heading.replace(/^#{1,6}\s+/, '').trim();
|
|
79
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* soul.json generator for clawsouls import.
|
|
3
|
+
* Extracts metadata from classified sections and produces a v0.6 soul.json.
|
|
4
|
+
*/
|
|
5
|
+
import { ClassifiedSection } from './classifier.js';
|
|
6
|
+
interface SoulAuthor {
|
|
7
|
+
name: string;
|
|
8
|
+
}
|
|
9
|
+
interface SoulJson {
|
|
10
|
+
specVersion: string;
|
|
11
|
+
name: string;
|
|
12
|
+
displayName: string;
|
|
13
|
+
version: string;
|
|
14
|
+
description: string;
|
|
15
|
+
author: SoulAuthor;
|
|
16
|
+
category: string;
|
|
17
|
+
tags: string[];
|
|
18
|
+
type: string;
|
|
19
|
+
}
|
|
20
|
+
export interface SoulJsonOptions {
|
|
21
|
+
name?: string;
|
|
22
|
+
author?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Generate a soul.json object from classified sections.
|
|
26
|
+
*/
|
|
27
|
+
export declare function generateSoulJson(sections: ClassifiedSection[], opts?: SoulJsonOptions): SoulJson;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* soul.json generator for clawsouls import.
|
|
3
|
+
* Extracts metadata from classified sections and produces a v0.6 soul.json.
|
|
4
|
+
*/
|
|
5
|
+
import { headingText } from './parser.js';
|
|
6
|
+
/**
|
|
7
|
+
* Convert a display name to a URL-safe slug.
|
|
8
|
+
*/
|
|
9
|
+
function slugify(text) {
|
|
10
|
+
return text
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/[^\w\s-]/g, '')
|
|
14
|
+
.replace(/[\s_-]+/g, '-')
|
|
15
|
+
.replace(/^-+|-+$/g, '');
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Extract the first non-empty paragraph from section content.
|
|
19
|
+
*/
|
|
20
|
+
function extractFirstParagraph(content) {
|
|
21
|
+
const lines = content.split('\n');
|
|
22
|
+
const paragraphLines = [];
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
const trimmed = line.trim();
|
|
25
|
+
if (trimmed === '') {
|
|
26
|
+
if (paragraphLines.length > 0)
|
|
27
|
+
break;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
// Skip headings and list items for description
|
|
31
|
+
if (trimmed.startsWith('#') || trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
|
32
|
+
if (paragraphLines.length > 0)
|
|
33
|
+
break;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
paragraphLines.push(trimmed);
|
|
37
|
+
}
|
|
38
|
+
return paragraphLines.join(' ').slice(0, 200);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Extract a display name from the soul sections.
|
|
42
|
+
* Priority: H1 heading (level 1) > IDENTITY.md name field > SOUL.md heading > fallback.
|
|
43
|
+
*/
|
|
44
|
+
function extractDisplayName(sections, fallbackName) {
|
|
45
|
+
// Try the top-level H1 first (document title like "# Aria — AI Assistant")
|
|
46
|
+
const h1Section = sections.find(s => s.level === 1);
|
|
47
|
+
if (h1Section) {
|
|
48
|
+
const ht = headingText(h1Section);
|
|
49
|
+
if (ht && ht.length < 80)
|
|
50
|
+
return ht;
|
|
51
|
+
}
|
|
52
|
+
// Try IDENTITY.md — look for a name: field in content
|
|
53
|
+
const identitySection = sections.find(s => s.target === 'IDENTITY.md');
|
|
54
|
+
if (identitySection) {
|
|
55
|
+
const nameLine = identitySection.content
|
|
56
|
+
.split('\n')
|
|
57
|
+
.find(l => /\bname\b.*:/i.test(l) || /^name:/i.test(l.trim()));
|
|
58
|
+
if (nameLine) {
|
|
59
|
+
const val = nameLine.replace(/.*:\s*/, '').trim().replace(/[`*_]/g, '');
|
|
60
|
+
if (val && val.length < 50)
|
|
61
|
+
return val;
|
|
62
|
+
}
|
|
63
|
+
// Use heading if it's meaningful
|
|
64
|
+
const ht = headingText(identitySection);
|
|
65
|
+
if (ht && !['Identity', 'IDENTITY', 'Identity & Background'].includes(ht))
|
|
66
|
+
return ht;
|
|
67
|
+
}
|
|
68
|
+
// Try SOUL.md heading
|
|
69
|
+
const soulSection = sections.find(s => s.target === 'SOUL.md');
|
|
70
|
+
if (soulSection) {
|
|
71
|
+
const ht = headingText(soulSection);
|
|
72
|
+
if (ht && !['Persona', 'Soul', 'SOUL', 'Personality', 'Personality & Values'].includes(ht))
|
|
73
|
+
return ht;
|
|
74
|
+
}
|
|
75
|
+
return fallbackName || 'Imported Soul';
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Extract keyword tags from all sections.
|
|
79
|
+
*/
|
|
80
|
+
function extractTags(sections) {
|
|
81
|
+
const tags = new Set();
|
|
82
|
+
const targets = sections.map(s => s.target).filter(t => t !== 'SKIP');
|
|
83
|
+
for (const t of targets) {
|
|
84
|
+
const base = t.replace('.md', '').toLowerCase();
|
|
85
|
+
if (base !== 'skip')
|
|
86
|
+
tags.add(base);
|
|
87
|
+
}
|
|
88
|
+
// Add a few keyword-based tags from SOUL.md content
|
|
89
|
+
const soulSection = sections.find(s => s.target === 'SOUL.md');
|
|
90
|
+
if (soulSection) {
|
|
91
|
+
const interestingWords = soulSection.content
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.match(/\b(creative|helpful|assistant|developer|coding|design|writer|analyst|researcher|friendly|professional)\b/g);
|
|
94
|
+
if (interestingWords) {
|
|
95
|
+
for (const w of interestingWords.slice(0, 3))
|
|
96
|
+
tags.add(w);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
tags.add('imported');
|
|
100
|
+
return Array.from(tags).slice(0, 8);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Generate a soul.json object from classified sections.
|
|
104
|
+
*/
|
|
105
|
+
export function generateSoulJson(sections, opts = {}) {
|
|
106
|
+
const displayName = extractDisplayName(sections, opts.name);
|
|
107
|
+
const name = slugify(displayName);
|
|
108
|
+
// Description: first paragraph from SOUL.md, or IDENTITY.md
|
|
109
|
+
let description = '';
|
|
110
|
+
const soulSection = sections.find(s => s.target === 'SOUL.md');
|
|
111
|
+
if (soulSection?.content) {
|
|
112
|
+
description = extractFirstParagraph(soulSection.content);
|
|
113
|
+
}
|
|
114
|
+
if (!description) {
|
|
115
|
+
const identitySection = sections.find(s => s.target === 'IDENTITY.md');
|
|
116
|
+
if (identitySection?.content) {
|
|
117
|
+
description = extractFirstParagraph(identitySection.content);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!description)
|
|
121
|
+
description = 'Imported from external format';
|
|
122
|
+
return {
|
|
123
|
+
specVersion: '0.6',
|
|
124
|
+
name: name || 'imported-soul',
|
|
125
|
+
displayName,
|
|
126
|
+
version: '0.1.0',
|
|
127
|
+
description,
|
|
128
|
+
author: { name: opts.author || 'Unknown' },
|
|
129
|
+
category: 'imported',
|
|
130
|
+
tags: extractTags(sections),
|
|
131
|
+
type: 'persona',
|
|
132
|
+
};
|
|
133
|
+
}
|