synap 0.6.1 → 0.8.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.
@@ -36,11 +36,40 @@ When assisting users with their synap entries:
36
36
 
37
37
  ## User Preferences (Memory)
38
38
 
39
- synap stores long-term user preferences at `~/.config/synap/user-preferences.md`.
39
+ synap stores user data in a configurable data directory (default: `~/.config/synap/`).
40
+
41
+ **Data files (syncable):**
42
+ - `entries.json` - Active entries
43
+ - `archive.json` - Archived entries
44
+ - `user-preferences.md` - Agent memory / user preferences
45
+
46
+ **Config files (local only):**
47
+ - `config.json` - Settings (including `dataDir` for custom data location)
48
+ - `deletion-log.json` - Audit trail for restore
49
+
50
+ ### Custom Data Directory (for sync)
51
+
52
+ Users can point synap to a custom folder (e.g., a git repo for multi-device sync):
53
+
54
+ ```bash
55
+ synap config dataDir ~/synap-data
56
+ # Or during setup:
57
+ synap setup
58
+ ```
59
+
60
+ When custom `dataDir` is set:
61
+ - Data files go to the custom location
62
+ - Config stays in `~/.config/synap/`
63
+ - User can sync data folder via git, Dropbox, iCloud, etc.
64
+
65
+ ### Preferences Operations
40
66
 
41
67
  - Read preferences at the start of a session when present.
42
- - Append stable, reusable preferences with `synap preferences --append "## Section" "..."`.
43
- - Avoid overwriting user-written content; prefer section-based appends.
68
+ - Prefer idempotent updates with `synap preferences set --section "Tag Meanings" --entry "#urgent = must do today"`.
69
+ - Remove entries with `synap preferences remove --section tags --match "urgent"`.
70
+ - List entries with `synap preferences list --section tags --json`.
71
+ - `synap preferences --append "## Section" "..."` is still supported for raw appends.
72
+ - Avoid overwriting user-written content; prefer section-based updates.
44
73
 
45
74
  ## Operating Modes
46
75
 
@@ -79,6 +108,7 @@ Detect user intent and respond appropriately:
79
108
  | Get stats | `synap stats` |
80
109
  | Setup wizard | `synap setup` |
81
110
  | Edit preferences | `synap preferences --edit` |
111
+ | Set preference | `synap preferences set --section tags --entry "#urgent = must do today"` |
82
112
 
83
113
  ## Pre-flight Check
84
114
 
@@ -373,8 +403,47 @@ synap import backup.json --merge # Update existing + add new
373
403
  synap import backup.json --skip-existing # Only add new
374
404
  ```
375
405
 
406
+ #### `synap config`
407
+ View or update configuration.
408
+
409
+ ```bash
410
+ synap config # Show all settings + paths
411
+ synap config dataDir # Show data directory
412
+ synap config dataDir ~/synap-data # Set custom data directory
413
+ synap config --reset # Reset to defaults
414
+ ```
415
+
376
416
  ## Workflow Patterns
377
417
 
418
+ ### Multi-Device Sync Setup
419
+
420
+ For users who want to sync their synap across devices:
421
+
422
+ 1. **Set custom data directory:**
423
+ ```bash
424
+ synap config dataDir ~/synap-data
425
+ ```
426
+
427
+ 2. **Initialize git (optional):**
428
+ ```bash
429
+ cd ~/synap-data
430
+ git init
431
+ git remote add origin git@github.com:user/synap-data.git
432
+ ```
433
+
434
+ 3. **Daily sync workflow:**
435
+ ```bash
436
+ cd ~/synap-data && git pull # Start of day
437
+ # ... use synap normally ...
438
+ cd ~/synap-data && git add . && git commit -m "sync" && git push # End of day
439
+ ```
440
+
441
+ 4. **On a new device:**
442
+ ```bash
443
+ git clone git@github.com:user/synap-data.git ~/synap-data
444
+ synap config dataDir ~/synap-data
445
+ ```
446
+
378
447
  ### Daily Review
379
448
 
380
449
  Run this each morning to get oriented:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synap",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "A CLI for externalizing your working memory",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -1749,7 +1749,16 @@ async function main() {
1749
1749
  // PREFERENCES COMMANDS
1750
1750
  // ============================================
1751
1751
 
1752
- program
1752
+ const respondPreferencesError = (options, message, code = 'INVALID_ARGS') => {
1753
+ if (options.json) {
1754
+ console.log(JSON.stringify({ success: false, error: message, code }));
1755
+ } else {
1756
+ console.error(chalk.red(message));
1757
+ }
1758
+ process.exit(1);
1759
+ };
1760
+
1761
+ const preferencesCommand = program
1753
1762
  .command('preferences')
1754
1763
  .description('View or update user preferences')
1755
1764
  .option('--edit', 'Open preferences in $EDITOR')
@@ -1761,22 +1770,13 @@ async function main() {
1761
1770
  const hasAppend = Array.isArray(options.append);
1762
1771
  const activeFlags = [options.edit, options.reset, hasAppend].filter(Boolean).length;
1763
1772
 
1764
- const respondError = (message, code = 'INVALID_ARGS') => {
1765
- if (options.json) {
1766
- console.log(JSON.stringify({ success: false, error: message, code }));
1767
- } else {
1768
- console.error(chalk.red(message));
1769
- }
1770
- process.exit(1);
1771
- };
1772
-
1773
1773
  if (activeFlags > 1) {
1774
- respondError('Use only one of --edit, --reset, or --append');
1774
+ respondPreferencesError(options, 'Use only one of --edit, --reset, or --append');
1775
1775
  }
1776
1776
  try {
1777
1777
  if (options.edit) {
1778
1778
  if (options.json) {
1779
- respondError('Cannot use --json with --edit', 'INVALID_MODE');
1779
+ respondPreferencesError(options, 'Cannot use --json with --edit', 'INVALID_MODE');
1780
1780
  }
1781
1781
 
1782
1782
  preferences.loadPreferences();
@@ -1832,7 +1832,7 @@ async function main() {
1832
1832
  if (hasAppend) {
1833
1833
  const values = options.append || [];
1834
1834
  if (values.length < 2) {
1835
- respondError('Usage: synap preferences --append "## Section" "Text to append"');
1835
+ respondPreferencesError(options, 'Usage: synap preferences --append "## Section" "Text to append"');
1836
1836
  }
1837
1837
 
1838
1838
  const [section, ...textParts] = values;
@@ -1865,7 +1865,160 @@ async function main() {
1865
1865
  console.log(content);
1866
1866
  }
1867
1867
  } catch (err) {
1868
- respondError(err.message || 'Failed to update preferences', 'PREFERENCES_ERROR');
1868
+ respondPreferencesError(options, err.message || 'Failed to update preferences', 'PREFERENCES_ERROR');
1869
+ }
1870
+ });
1871
+
1872
+ preferencesCommand
1873
+ .command('set')
1874
+ .description('Add a preference entry to a section')
1875
+ .option('--section <section>', 'Section name or alias')
1876
+ .option('--entry <entry>', 'Entry text')
1877
+ .option('--json', 'Output as JSON')
1878
+ .action((options) => {
1879
+ if (!options.section || !options.entry) {
1880
+ respondPreferencesError(
1881
+ options,
1882
+ 'Usage: synap preferences set --section "tags" --entry "text to add"'
1883
+ );
1884
+ }
1885
+
1886
+ try {
1887
+ const result = preferences.setEntry(options.section, options.entry);
1888
+ const payload = { ...result, path: preferences.getPreferencesPath() };
1889
+
1890
+ if (options.json) {
1891
+ console.log(JSON.stringify(payload, null, 2));
1892
+ } else if (result.added) {
1893
+ console.log(chalk.green(`Added entry to ${result.section}`));
1894
+ } else {
1895
+ console.log(chalk.yellow(`Entry already exists in ${result.section}`));
1896
+ }
1897
+ } catch (err) {
1898
+ respondPreferencesError(options, err.message || 'Failed to set preferences', 'PREFERENCES_ERROR');
1899
+ }
1900
+ });
1901
+
1902
+ preferencesCommand
1903
+ .command('remove')
1904
+ .description('Remove preference entries from a section')
1905
+ .option('--section <section>', 'Section name or alias')
1906
+ .option('--match <pattern>', 'Match text (case-insensitive substring)')
1907
+ .option('--entry <entry>', 'Exact entry text')
1908
+ .option('--json', 'Output as JSON')
1909
+ .action((options) => {
1910
+ if (!options.section) {
1911
+ respondPreferencesError(
1912
+ options,
1913
+ 'Usage: synap preferences remove --section "tags" --match "urgent"'
1914
+ );
1915
+ }
1916
+ if (options.match && options.entry) {
1917
+ respondPreferencesError(options, 'Use only one of --match or --entry');
1918
+ }
1919
+ if (!options.match && !options.entry) {
1920
+ respondPreferencesError(
1921
+ options,
1922
+ 'Usage: synap preferences remove --section "tags" --match "urgent"'
1923
+ );
1924
+ }
1925
+
1926
+ try {
1927
+ const result = preferences.removeFromSection(options.section, {
1928
+ match: options.match,
1929
+ entry: options.entry
1930
+ });
1931
+ const payload = { ...result, path: preferences.getPreferencesPath() };
1932
+
1933
+ if (options.json) {
1934
+ console.log(JSON.stringify(payload, null, 2));
1935
+ } else if (result.removed) {
1936
+ console.log(chalk.green(`Removed ${result.count} entr${result.count === 1 ? 'y' : 'ies'} from ${result.section}`));
1937
+ } else {
1938
+ console.log(chalk.yellow(`No entries matched in ${result.section}`));
1939
+ }
1940
+ } catch (err) {
1941
+ respondPreferencesError(options, err.message || 'Failed to remove preferences', 'PREFERENCES_ERROR');
1942
+ }
1943
+ });
1944
+
1945
+ preferencesCommand
1946
+ .command('list')
1947
+ .description('List preference entries')
1948
+ .option('--section <section>', 'Section name or alias')
1949
+ .option('--json', 'Output as JSON')
1950
+ .action((options) => {
1951
+ try {
1952
+ if (options.section) {
1953
+ const section = preferences.resolveSection(options.section);
1954
+ const entries = preferences.getEntriesInSection(section);
1955
+ const payload = { section, entries, count: entries.length };
1956
+
1957
+ if (options.json) {
1958
+ console.log(JSON.stringify(payload, null, 2));
1959
+ } else if (entries.length === 0) {
1960
+ console.log(chalk.gray(`No entries found in ${section}`));
1961
+ } else {
1962
+ console.log(chalk.bold(section));
1963
+ entries.forEach((entry) => {
1964
+ console.log(`- ${entry}`);
1965
+ });
1966
+ }
1967
+ return;
1968
+ }
1969
+
1970
+ const content = preferences.loadPreferences();
1971
+ const lines = content.split(/\r?\n/);
1972
+ const sections = [];
1973
+ let current = null;
1974
+
1975
+ for (const line of lines) {
1976
+ const headingMatch = line.match(/^(#{1,6})\s*(.+?)\s*$/);
1977
+ if (headingMatch) {
1978
+ const level = headingMatch[1].length;
1979
+ const name = headingMatch[2].trim();
1980
+
1981
+ if (level === 2) {
1982
+ current = { section: name, entries: [] };
1983
+ sections.push(current);
1984
+ } else if (level === 1) {
1985
+ current = null;
1986
+ }
1987
+ continue;
1988
+ }
1989
+
1990
+ if (!current) {
1991
+ continue;
1992
+ }
1993
+
1994
+ const trimmed = line.trim();
1995
+ if (!trimmed || trimmed.startsWith('<!--') || /^(#{1,6})\s+/.test(trimmed)) {
1996
+ continue;
1997
+ }
1998
+
1999
+ current.entries.push(trimmed);
2000
+ }
2001
+
2002
+ const populatedSections = sections.filter(section => section.entries.length > 0);
2003
+ const totalEntries = populatedSections.reduce((sum, section) => sum + section.entries.length, 0);
2004
+
2005
+ if (options.json) {
2006
+ console.log(JSON.stringify({ sections: populatedSections, count: totalEntries }, null, 2));
2007
+ } else if (populatedSections.length === 0) {
2008
+ console.log(chalk.gray('No preference entries found'));
2009
+ } else {
2010
+ populatedSections.forEach((section, index) => {
2011
+ if (index > 0) {
2012
+ console.log('');
2013
+ }
2014
+ console.log(chalk.bold(section.section));
2015
+ section.entries.forEach((entry) => {
2016
+ console.log(`- ${entry}`);
2017
+ });
2018
+ });
2019
+ }
2020
+ } catch (err) {
2021
+ respondPreferencesError(options, err.message || 'Failed to list preferences', 'PREFERENCES_ERROR');
1869
2022
  }
1870
2023
  });
1871
2024
 
@@ -1879,6 +2032,8 @@ async function main() {
1879
2032
  .option('--json', 'Output as JSON')
1880
2033
  .action(async (options) => {
1881
2034
  const fs = require('fs');
2035
+ const os = require('os');
2036
+ const path = require('path');
1882
2037
 
1883
2038
  let hasEntries = false;
1884
2039
  if (fs.existsSync(storage.ENTRIES_FILE)) {
@@ -1890,16 +2045,9 @@ async function main() {
1890
2045
  }
1891
2046
  }
1892
2047
 
1893
- let createdEntry = null;
1894
- if (!hasEntries) {
1895
- createdEntry = await storage.addEntry({
1896
- content: 'My first thought',
1897
- type: config.defaultType || 'idea',
1898
- source: 'setup'
1899
- });
1900
- }
1901
-
1902
2048
  let skillResult = { prompted: false };
2049
+ let dataLocationResult = { configured: false };
2050
+
1903
2051
  if (!options.json) {
1904
2052
  console.log('');
1905
2053
  console.log(chalk.bold('Welcome to synap!\n'));
@@ -1908,19 +2056,93 @@ async function main() {
1908
2056
 
1909
2057
  console.log('Let\'s get you set up:\n');
1910
2058
 
1911
- console.log('[1/3] Quick capture test...');
1912
- if (createdEntry) {
2059
+ // Step 1: Data location (only ask if not already configured)
2060
+ const currentConfig = storage.loadConfig();
2061
+ console.log('[1/4] Data location...');
2062
+
2063
+ if (currentConfig.dataDir) {
2064
+ console.log(` ${chalk.gray('Already configured:')} ${storage.DATA_DIR}`);
2065
+ dataLocationResult.configured = true;
2066
+ dataLocationResult.path = storage.DATA_DIR;
2067
+ } else {
2068
+ const { select, input } = await import('@inquirer/prompts');
2069
+ const dataChoice = await select({
2070
+ message: 'Where should synap store your data?',
2071
+ choices: [
2072
+ {
2073
+ name: `Default (${path.join(os.homedir(), '.config', 'synap')})`,
2074
+ value: 'default',
2075
+ description: 'Recommended for most users'
2076
+ },
2077
+ {
2078
+ name: 'Custom path (for git sync, Dropbox, iCloud)',
2079
+ value: 'custom',
2080
+ description: 'Choose a custom folder for syncing across devices'
2081
+ }
2082
+ ]
2083
+ });
2084
+
2085
+ if (dataChoice === 'custom') {
2086
+ const defaultSuggestion = path.join(os.homedir(), 'synap-data');
2087
+ const customPath = await input({
2088
+ message: 'Enter path for synap data:',
2089
+ default: defaultSuggestion,
2090
+ validate: (val) => {
2091
+ if (!val.trim()) return 'Path is required';
2092
+ const expanded = val.replace(/^~/, os.homedir());
2093
+ const parentDir = path.dirname(expanded);
2094
+ if (!fs.existsSync(parentDir)) {
2095
+ return `Parent directory does not exist: ${parentDir}`;
2096
+ }
2097
+ return true;
2098
+ }
2099
+ });
2100
+
2101
+ const expanded = customPath.replace(/^~/, os.homedir());
2102
+
2103
+ // Create directory if needed
2104
+ if (!fs.existsSync(expanded)) {
2105
+ fs.mkdirSync(expanded, { recursive: true });
2106
+ }
2107
+
2108
+ // Save to config
2109
+ currentConfig.dataDir = customPath;
2110
+ storage.saveConfig(currentConfig);
2111
+ storage.refreshDataDir();
2112
+
2113
+ console.log(` ${chalk.green('✓')} Data directory set to: ${expanded}`);
2114
+ dataLocationResult.configured = true;
2115
+ dataLocationResult.path = expanded;
2116
+ dataLocationResult.custom = true;
2117
+ } else {
2118
+ console.log(` ${chalk.green('✓')} Using default location: ${storage.DATA_DIR}`);
2119
+ dataLocationResult.configured = true;
2120
+ dataLocationResult.path = storage.DATA_DIR;
2121
+ }
2122
+ }
2123
+
2124
+ // Step 2: Quick capture test
2125
+ console.log('\n[2/4] Quick capture test...');
2126
+ let createdEntry = null;
2127
+ if (!hasEntries) {
2128
+ createdEntry = await storage.addEntry({
2129
+ content: 'My first thought',
2130
+ type: config.defaultType || 'idea',
2131
+ source: 'setup'
2132
+ });
1913
2133
  console.log(` synap add "My first thought"`);
1914
2134
  console.log(` ${chalk.green('✓')} Created entry ${createdEntry.id.slice(0, 8)}`);
1915
2135
  } else {
1916
2136
  console.log(` ${chalk.gray('Existing entries detected, skipping.')}`);
1917
2137
  }
1918
2138
 
1919
- console.log('\n[2/3] Configuration...');
2139
+ // Step 3: Configuration
2140
+ console.log('\n[3/4] Configuration...');
1920
2141
  console.log(` Default type: ${chalk.cyan(config.defaultType || 'idea')}`);
1921
2142
  console.log(` Change with: ${chalk.cyan('synap config defaultType todo')}`);
1922
2143
 
1923
- console.log('\n[3/3] Claude Code Integration');
2144
+ // Step 4: Claude Code Integration
2145
+ console.log('\n[4/4] Claude Code Integration');
1924
2146
  const { confirm } = await import('@inquirer/prompts');
1925
2147
  const shouldInstall = await confirm({
1926
2148
  message: 'Install Claude skill for AI assistance?',
@@ -1952,10 +2174,26 @@ async function main() {
1952
2174
  console.log(` ${chalk.cyan('synap todo "Something to do"')}`);
1953
2175
  console.log(` ${chalk.cyan('synap focus')}`);
1954
2176
  console.log(` ${chalk.cyan('synap review daily')}`);
2177
+
2178
+ if (dataLocationResult.custom) {
2179
+ console.log(chalk.bold('\n📁 Sync tip:'));
2180
+ console.log(` Your data is stored in: ${chalk.cyan(dataLocationResult.path)}`);
2181
+ console.log(` To sync with git: ${chalk.gray('cd ' + dataLocationResult.path + ' && git init')}`);
2182
+ }
1955
2183
  console.log('');
1956
2184
  return;
1957
2185
  }
1958
2186
 
2187
+ // JSON mode - minimal interaction
2188
+ let createdEntry = null;
2189
+ if (!hasEntries) {
2190
+ createdEntry = await storage.addEntry({
2191
+ content: 'My first thought',
2192
+ type: config.defaultType || 'idea',
2193
+ source: 'setup'
2194
+ });
2195
+ }
2196
+
1959
2197
  console.log(JSON.stringify({
1960
2198
  success: true,
1961
2199
  mode: hasEntries ? 'existing' : 'first-run',
@@ -1996,7 +2234,15 @@ async function main() {
1996
2234
  // No key: show all config
1997
2235
  if (!key) {
1998
2236
  if (options.json) {
1999
- console.log(JSON.stringify({ success: true, config: currentConfig, defaults }, null, 2));
2237
+ console.log(JSON.stringify({
2238
+ success: true,
2239
+ config: currentConfig,
2240
+ defaults,
2241
+ paths: {
2242
+ configDir: storage.CONFIG_DIR,
2243
+ dataDir: storage.DATA_DIR
2244
+ }
2245
+ }, null, 2));
2000
2246
  } else {
2001
2247
  console.log(chalk.bold('Configuration:\n'));
2002
2248
  for (const [k, v] of Object.entries(currentConfig)) {
@@ -2005,6 +2251,9 @@ async function main() {
2005
2251
  const displayValue = Array.isArray(v) ? v.join(', ') || '(none)' : (v === null ? '(null)' : v);
2006
2252
  console.log(` ${chalk.cyan(k)}: ${displayValue}${defaultNote}`);
2007
2253
  }
2254
+ console.log(chalk.bold('\nPaths:'));
2255
+ console.log(` ${chalk.cyan('configDir')}: ${storage.CONFIG_DIR}`);
2256
+ console.log(` ${chalk.cyan('dataDir')}: ${storage.DATA_DIR}`);
2008
2257
  console.log(chalk.gray('\nUse: synap config <key> <value> to set a value'));
2009
2258
  }
2010
2259
  return;
@@ -2048,16 +2297,42 @@ async function main() {
2048
2297
  parsedValue = value.split(',').map(t => t.trim()).filter(Boolean);
2049
2298
  } else if (key === 'editor' && (value === 'null' || value === '')) {
2050
2299
  parsedValue = null;
2300
+ } else if (key === 'dataDir') {
2301
+ // Handle dataDir specially
2302
+ if (value === 'null' || value === '') {
2303
+ parsedValue = null;
2304
+ } else {
2305
+ // Expand ~ but keep the ~ in storage for portability
2306
+ const expanded = value.replace(/^~/, require('os').homedir());
2307
+ // Create directory if it doesn't exist
2308
+ if (!fs.existsSync(expanded)) {
2309
+ fs.mkdirSync(expanded, { recursive: true });
2310
+ if (!options.json) {
2311
+ console.log(chalk.gray(`Created directory: ${expanded}`));
2312
+ }
2313
+ }
2314
+ // Store the original value (with ~ if used)
2315
+ parsedValue = value;
2316
+ }
2051
2317
  }
2052
2318
 
2053
2319
  currentConfig[key] = parsedValue;
2054
2320
  storage.saveConfig(currentConfig);
2055
2321
 
2322
+ // If dataDir changed, refresh the storage paths
2323
+ if (key === 'dataDir') {
2324
+ storage.refreshDataDir();
2325
+ }
2326
+
2056
2327
  if (options.json) {
2057
2328
  console.log(JSON.stringify({ success: true, key, value: parsedValue }));
2058
2329
  } else {
2059
2330
  const displayValue = Array.isArray(parsedValue) ? parsedValue.join(', ') : parsedValue;
2060
2331
  console.log(chalk.green(`Set ${key} = ${displayValue}`));
2332
+ if (key === 'dataDir') {
2333
+ console.log(chalk.gray(`Data will be stored in: ${storage.DATA_DIR}`));
2334
+ console.log(chalk.yellow('\nNote: Restart synap for changes to take full effect.'));
2335
+ }
2061
2336
  }
2062
2337
  });
2063
2338
 
@@ -1,21 +1,27 @@
1
1
  /**
2
2
  * deletion-log.js - Audit logging for deleted entries, enables restore
3
+ *
4
+ * The deletion log is stored in DATA_DIR (syncable across devices)
3
5
  */
4
6
 
5
7
  const fs = require('fs');
6
8
  const path = require('path');
7
- const os = require('os');
9
+ const storage = require('./storage');
8
10
 
9
- // Storage directory
10
- const CONFIG_DIR = process.env.SYNAP_DIR || path.join(os.homedir(), '.config', 'synap');
11
- const DELETION_LOG_FILE = path.join(CONFIG_DIR, 'deletion-log.json');
11
+ /**
12
+ * Get deletion log file path (dynamic, based on DATA_DIR)
13
+ */
14
+ function getDeletionLogPath() {
15
+ return path.join(storage.DATA_DIR, 'deletion-log.json');
16
+ }
12
17
 
13
18
  /**
14
- * Ensure config directory exists
19
+ * Ensure data directory exists
15
20
  */
16
- function ensureConfigDir() {
17
- if (!fs.existsSync(CONFIG_DIR)) {
18
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
21
+ function ensureDataDir() {
22
+ const dataDir = storage.DATA_DIR;
23
+ if (!fs.existsSync(dataDir)) {
24
+ fs.mkdirSync(dataDir, { recursive: true });
19
25
  }
20
26
  }
21
27
 
@@ -32,12 +38,13 @@ function atomicWriteSync(filePath, data) {
32
38
  * Load deletion log
33
39
  */
34
40
  function loadLog() {
35
- ensureConfigDir();
36
- if (!fs.existsSync(DELETION_LOG_FILE)) {
41
+ ensureDataDir();
42
+ const logFile = getDeletionLogPath();
43
+ if (!fs.existsSync(logFile)) {
37
44
  return [];
38
45
  }
39
46
  try {
40
- return JSON.parse(fs.readFileSync(DELETION_LOG_FILE, 'utf8'));
47
+ return JSON.parse(fs.readFileSync(logFile, 'utf8'));
41
48
  } catch {
42
49
  return [];
43
50
  }
@@ -47,8 +54,8 @@ function loadLog() {
47
54
  * Save deletion log
48
55
  */
49
56
  function saveLog(log) {
50
- ensureConfigDir();
51
- atomicWriteSync(DELETION_LOG_FILE, log);
57
+ ensureDataDir();
58
+ atomicWriteSync(getDeletionLogPath(), log);
52
59
  }
53
60
 
54
61
  /**
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * preferences.js - User preferences storage and helpers
3
+ *
4
+ * User preferences are stored in DATA_DIR (syncable) not CONFIG_DIR
3
5
  */
4
6
 
5
7
  const fs = require('fs');
@@ -7,17 +9,35 @@ const path = require('path');
7
9
  const storage = require('./storage');
8
10
 
9
11
  const TEMPLATE_PATH = path.join(__dirname, 'templates', 'user-preferences-template.md');
10
- const PREFERENCES_FILE = path.join(storage.CONFIG_DIR, 'user-preferences.md');
11
12
  const MAX_LINES = 500;
13
+ const SECTION_ALIASES = new Map([
14
+ ['about', 'About Me'],
15
+ ['me', 'About Me'],
16
+ ['projects', 'Important Projects'],
17
+ ['important', 'Important Projects'],
18
+ ['tags', 'Tag Meanings'],
19
+ ['tag', 'Tag Meanings'],
20
+ ['review', 'Review Preferences'],
21
+ ['behavior', 'Behavioral Preferences'],
22
+ ['behavioral', 'Behavioral Preferences']
23
+ ]);
12
24
 
13
- function ensureConfigDir() {
14
- if (!fs.existsSync(storage.CONFIG_DIR)) {
15
- fs.mkdirSync(storage.CONFIG_DIR, { recursive: true });
25
+ /**
26
+ * Get current preferences file path (dynamic, based on DATA_DIR)
27
+ */
28
+ function getPreferencesFilePath() {
29
+ return path.join(storage.DATA_DIR, 'user-preferences.md');
30
+ }
31
+
32
+ function ensureDataDir() {
33
+ const dataDir = storage.DATA_DIR;
34
+ if (!fs.existsSync(dataDir)) {
35
+ fs.mkdirSync(dataDir, { recursive: true });
16
36
  }
17
37
  }
18
38
 
19
39
  function getPreferencesPath() {
20
- return PREFERENCES_FILE;
40
+ return getPreferencesFilePath();
21
41
  }
22
42
 
23
43
  function readTemplate() {
@@ -51,22 +71,24 @@ function savePreferences(content) {
51
71
  throw new Error(validation.error);
52
72
  }
53
73
 
54
- ensureConfigDir();
55
- const tmpPath = `${PREFERENCES_FILE}.tmp`;
74
+ ensureDataDir();
75
+ const preferencesFile = getPreferencesFilePath();
76
+ const tmpPath = `${preferencesFile}.tmp`;
56
77
  fs.writeFileSync(tmpPath, content, 'utf8');
57
- fs.renameSync(tmpPath, PREFERENCES_FILE);
78
+ fs.renameSync(tmpPath, preferencesFile);
58
79
  return content;
59
80
  }
60
81
 
61
82
  function loadPreferences() {
62
- ensureConfigDir();
63
- if (!fs.existsSync(PREFERENCES_FILE)) {
83
+ ensureDataDir();
84
+ const preferencesFile = getPreferencesFilePath();
85
+ if (!fs.existsSync(preferencesFile)) {
64
86
  const template = readTemplate();
65
87
  savePreferences(template);
66
88
  return template;
67
89
  }
68
90
 
69
- const content = fs.readFileSync(PREFERENCES_FILE, 'utf8');
91
+ const content = fs.readFileSync(preferencesFile, 'utf8');
70
92
  const validation = validatePreferences(content);
71
93
  if (!validation.valid) {
72
94
  throw new Error(validation.error);
@@ -79,7 +101,16 @@ function resetPreferences() {
79
101
  return savePreferences(template);
80
102
  }
81
103
 
104
+ function resolveSection(input) {
105
+ const target = parseSectionTarget(input);
106
+ const key = target.name.toLowerCase();
107
+ return SECTION_ALIASES.get(key) || target.name;
108
+ }
109
+
82
110
  function parseSectionTarget(section) {
111
+ if (typeof section !== 'string') {
112
+ throw new Error('Section name is required');
113
+ }
83
114
  const trimmed = section.trim();
84
115
  if (!trimmed) {
85
116
  throw new Error('Section name is required');
@@ -93,9 +124,22 @@ function parseSectionTarget(section) {
93
124
  return { level: null, name: trimmed };
94
125
  }
95
126
 
127
+ function normalizeEntry(entry) {
128
+ return entry.trim();
129
+ }
130
+
131
+ function isCommentLine(line) {
132
+ const trimmed = line.trim();
133
+ return trimmed.startsWith('<!--');
134
+ }
135
+
136
+ function isHeadingLine(line) {
137
+ return /^(#{1,6})\s+/.test(line.trim());
138
+ }
139
+
96
140
  function findSection(lines, target) {
97
141
  for (let i = 0; i < lines.length; i += 1) {
98
- const match = lines[i].match(/^(#{1,6})\s*(.+?)\s*$/);
142
+ const match = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
99
143
  if (!match) {
100
144
  continue;
101
145
  }
@@ -113,6 +157,148 @@ function findSection(lines, target) {
113
157
  return null;
114
158
  }
115
159
 
160
+ function getSectionRange(lines, match) {
161
+ if (!match) {
162
+ return null;
163
+ }
164
+
165
+ let endIndex = lines.length;
166
+ for (let i = match.index + 1; i < lines.length; i += 1) {
167
+ const headingMatch = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
168
+ if (!headingMatch) {
169
+ continue;
170
+ }
171
+ const level = headingMatch[1].length;
172
+ if (level <= match.level) {
173
+ endIndex = i;
174
+ break;
175
+ }
176
+ }
177
+
178
+ return { start: match.index, end: endIndex };
179
+ }
180
+
181
+ function getEntriesInSection(section) {
182
+ const resolved = resolveSection(section);
183
+ const content = loadPreferences();
184
+ const lines = content.split(/\r?\n/);
185
+ const target = parseSectionTarget(resolved);
186
+ const match = findSection(lines, target);
187
+
188
+ if (!match) {
189
+ return [];
190
+ }
191
+
192
+ const range = getSectionRange(lines, match);
193
+ const entries = [];
194
+
195
+ for (let i = match.index + 1; i < range.end; i += 1) {
196
+ const line = lines[i];
197
+ const trimmed = line.trim();
198
+
199
+ if (!trimmed || isCommentLine(trimmed) || isHeadingLine(trimmed)) {
200
+ continue;
201
+ }
202
+
203
+ entries.push(trimmed);
204
+ }
205
+
206
+ return entries;
207
+ }
208
+
209
+ function setEntry(section, entry) {
210
+ if (typeof entry !== 'string' || !entry.trim()) {
211
+ throw new Error('Entry text is required');
212
+ }
213
+
214
+ const resolved = resolveSection(section);
215
+ const normalized = normalizeEntry(entry);
216
+ const entries = getEntriesInSection(resolved);
217
+ const existed = entries.some((existing) => existing === normalized);
218
+
219
+ if (existed) {
220
+ return {
221
+ added: false,
222
+ existed: true,
223
+ section: resolved,
224
+ entry: normalized
225
+ };
226
+ }
227
+
228
+ appendToSection(resolved, normalized);
229
+ return {
230
+ added: true,
231
+ existed: false,
232
+ section: resolved,
233
+ entry: normalized
234
+ };
235
+ }
236
+
237
+ function removeFromSection(section, { match, entry } = {}) {
238
+ const hasMatch = typeof match === 'string' && match.trim();
239
+ const hasEntry = typeof entry === 'string' && entry.trim();
240
+
241
+ if (!hasMatch && !hasEntry) {
242
+ throw new Error('Match or entry is required');
243
+ }
244
+ if (hasMatch && hasEntry) {
245
+ throw new Error('Use either match or entry, not both');
246
+ }
247
+
248
+ const resolved = resolveSection(section);
249
+ const content = loadPreferences();
250
+ const lines = content.split(/\r?\n/);
251
+ const target = parseSectionTarget(resolved);
252
+ const sectionMatch = findSection(lines, target);
253
+
254
+ if (!sectionMatch) {
255
+ return { removed: false, count: 0, entries: [], section: resolved };
256
+ }
257
+
258
+ const range = getSectionRange(lines, sectionMatch);
259
+ const sectionLines = lines.slice(sectionMatch.index + 1, range.end);
260
+ const removedEntries = [];
261
+ const normalizedEntry = hasEntry ? normalizeEntry(entry) : null;
262
+ const matchNeedle = hasMatch ? match.trim().toLowerCase() : null;
263
+
264
+ const updatedSectionLines = sectionLines.filter((line) => {
265
+ const trimmed = line.trim();
266
+
267
+ if (!trimmed || isCommentLine(trimmed) || isHeadingLine(trimmed)) {
268
+ return true;
269
+ }
270
+
271
+ const matches = normalizedEntry
272
+ ? trimmed === normalizedEntry
273
+ : trimmed.toLowerCase().includes(matchNeedle);
274
+
275
+ if (matches) {
276
+ removedEntries.push(trimmed);
277
+ return false;
278
+ }
279
+
280
+ return true;
281
+ });
282
+
283
+ if (removedEntries.length === 0) {
284
+ return { removed: false, count: 0, entries: [], section: resolved };
285
+ }
286
+
287
+ const updatedLines = [
288
+ ...lines.slice(0, sectionMatch.index + 1),
289
+ ...updatedSectionLines,
290
+ ...lines.slice(range.end)
291
+ ];
292
+
293
+ savePreferences(updatedLines.join('\n'));
294
+ return {
295
+ removed: true,
296
+ count: removedEntries.length,
297
+ entries: removedEntries,
298
+ section: resolved
299
+ };
300
+ }
301
+
116
302
  function appendToSection(section, text) {
117
303
  if (typeof text !== 'string' || !text.trim()) {
118
304
  throw new Error('Append text is required');
@@ -140,7 +326,7 @@ function appendToSection(section, text) {
140
326
 
141
327
  let insertIndex = lines.length;
142
328
  for (let i = match.index + 1; i < lines.length; i += 1) {
143
- const headingMatch = lines[i].match(/^(#{1,6})\s*(.+?)\s*$/);
329
+ const headingMatch = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
144
330
  if (!headingMatch) {
145
331
  continue;
146
332
  }
@@ -167,9 +353,13 @@ module.exports = {
167
353
  getPreferencesPath,
168
354
  loadPreferences,
169
355
  savePreferences,
356
+ resolveSection,
357
+ setEntry,
358
+ removeFromSection,
359
+ getEntriesInSection,
170
360
  appendToSection,
171
361
  resetPreferences,
172
362
  validatePreferences,
173
- PREFERENCES_FILE,
363
+ get PREFERENCES_FILE() { return getPreferencesFilePath(); },
174
364
  TEMPLATE_PATH
175
365
  };
package/src/storage.js CHANGED
@@ -7,16 +7,30 @@ const path = require('path');
7
7
  const os = require('os');
8
8
  const { v4: uuidv4 } = require('uuid');
9
9
 
10
- // Storage directory
11
- const CONFIG_DIR = process.env.SYNAP_DIR || path.join(os.homedir(), '.config', 'synap');
12
- const ENTRIES_FILE = path.join(CONFIG_DIR, 'entries.json');
13
- const ARCHIVE_FILE = path.join(CONFIG_DIR, 'archive.json');
14
- const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
10
+ // =============================================================================
11
+ // Directory Resolution (XDG-compliant with backward compatibility)
12
+ // =============================================================================
15
13
 
16
- // Valid types, statuses, and date formats
17
- const VALID_TYPES = ['idea', 'project', 'feature', 'todo', 'question', 'reference', 'note'];
18
- const VALID_STATUSES = ['raw', 'active', 'wip', 'someday', 'done', 'archived'];
19
- const VALID_DATE_FORMATS = ['relative', 'absolute', 'locale'];
14
+ /**
15
+ * Resolve the config directory path
16
+ * Priority:
17
+ * 1. SYNAP_CONFIG_DIR env var (explicit override for config only)
18
+ * 2. SYNAP_DIR env var (legacy - affects both config and data)
19
+ * 3. Default: ~/.config/synap
20
+ */
21
+ function resolveConfigDir() {
22
+ if (process.env.SYNAP_CONFIG_DIR) {
23
+ return process.env.SYNAP_CONFIG_DIR;
24
+ }
25
+ if (process.env.SYNAP_DIR) {
26
+ return process.env.SYNAP_DIR;
27
+ }
28
+ return path.join(os.homedir(), '.config', 'synap');
29
+ }
30
+
31
+ // Config directory - always local, not synced
32
+ const CONFIG_DIR = resolveConfigDir();
33
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
20
34
 
21
35
  /**
22
36
  * Ensure config directory exists
@@ -27,6 +41,90 @@ function ensureConfigDir() {
27
41
  }
28
42
  }
29
43
 
44
+ /**
45
+ * Load configuration (needed before resolving DATA_DIR)
46
+ */
47
+ function loadConfigRaw() {
48
+ ensureConfigDir();
49
+ if (!fs.existsSync(CONFIG_FILE)) {
50
+ return {};
51
+ }
52
+ try {
53
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Resolve the data directory path
61
+ * Priority:
62
+ * 1. SYNAP_DATA_DIR env var (explicit override for data only)
63
+ * 2. config.json -> dataDir setting
64
+ * 3. SYNAP_DIR env var (legacy - affects both config and data)
65
+ * 4. Default: ~/.local/share/synap (XDG compliant)
66
+ */
67
+ function resolveDataDir() {
68
+ // 1. Explicit env var override for data
69
+ if (process.env.SYNAP_DATA_DIR) {
70
+ return process.env.SYNAP_DATA_DIR;
71
+ }
72
+
73
+ // 2. Config file setting
74
+ const rawConfig = loadConfigRaw();
75
+ if (rawConfig.dataDir) {
76
+ // Expand ~ to home directory
77
+ const expanded = rawConfig.dataDir.replace(/^~/, os.homedir());
78
+ return expanded;
79
+ }
80
+
81
+ // 3. Legacy env var (affects both config and data)
82
+ if (process.env.SYNAP_DIR) {
83
+ return process.env.SYNAP_DIR;
84
+ }
85
+
86
+ // 4. Default: XDG-compliant data directory
87
+ return path.join(os.homedir(), '.local', 'share', 'synap');
88
+ }
89
+
90
+ // Resolve DATA_DIR at module load time
91
+ // This will be re-evaluated if config changes require restart
92
+ let DATA_DIR = resolveDataDir();
93
+
94
+ // Data files (syncable)
95
+ let ENTRIES_FILE = path.join(DATA_DIR, 'entries.json');
96
+ let ARCHIVE_FILE = path.join(DATA_DIR, 'archive.json');
97
+
98
+ /**
99
+ * Refresh data directory paths (call after config change)
100
+ */
101
+ function refreshDataDir() {
102
+ DATA_DIR = resolveDataDir();
103
+ ENTRIES_FILE = path.join(DATA_DIR, 'entries.json');
104
+ ARCHIVE_FILE = path.join(DATA_DIR, 'archive.json');
105
+ }
106
+
107
+ /**
108
+ * Get current data directory path
109
+ */
110
+ function getDataDir() {
111
+ return DATA_DIR;
112
+ }
113
+
114
+ /**
115
+ * Ensure data directory exists
116
+ */
117
+ function ensureDataDir() {
118
+ if (!fs.existsSync(DATA_DIR)) {
119
+ fs.mkdirSync(DATA_DIR, { recursive: true });
120
+ }
121
+ }
122
+
123
+ // Valid types, statuses, and date formats
124
+ const VALID_TYPES = ['idea', 'project', 'feature', 'todo', 'question', 'reference', 'note'];
125
+ const VALID_STATUSES = ['raw', 'active', 'wip', 'someday', 'done', 'archived'];
126
+ const VALID_DATE_FORMATS = ['relative', 'absolute', 'locale'];
127
+
30
128
  /**
31
129
  * Atomic file write - write to temp file then rename
32
130
  */
@@ -40,7 +138,7 @@ function atomicWriteSync(filePath, data) {
40
138
  * Load entries from file
41
139
  */
42
140
  function loadEntries() {
43
- ensureConfigDir();
141
+ ensureDataDir();
44
142
  if (!fs.existsSync(ENTRIES_FILE)) {
45
143
  return { version: 1, entries: [] };
46
144
  }
@@ -55,7 +153,7 @@ function loadEntries() {
55
153
  * Save entries to file
56
154
  */
57
155
  function saveEntries(data) {
58
- ensureConfigDir();
156
+ ensureDataDir();
59
157
  atomicWriteSync(ENTRIES_FILE, data);
60
158
  }
61
159
 
@@ -63,7 +161,7 @@ function saveEntries(data) {
63
161
  * Load archived entries
64
162
  */
65
163
  function loadArchive() {
66
- ensureConfigDir();
164
+ ensureDataDir();
67
165
  if (!fs.existsSync(ARCHIVE_FILE)) {
68
166
  return { version: 1, entries: [] };
69
167
  }
@@ -78,7 +176,7 @@ function loadArchive() {
78
176
  * Save archived entries
79
177
  */
80
178
  function saveArchive(data) {
81
- ensureConfigDir();
179
+ ensureDataDir();
82
180
  atomicWriteSync(ARCHIVE_FILE, data);
83
181
  }
84
182
 
@@ -91,7 +189,8 @@ function loadConfig() {
91
189
  defaultType: 'idea',
92
190
  defaultTags: [],
93
191
  editor: null, // Falls back to EDITOR env var in CLI
94
- dateFormat: 'relative'
192
+ dateFormat: 'relative',
193
+ dataDir: null // Custom data directory (null = use default)
95
194
  };
96
195
 
97
196
  if (!fs.existsSync(CONFIG_FILE)) {
@@ -123,6 +222,12 @@ function loadConfig() {
123
222
  .map(t => t.trim());
124
223
  }
125
224
 
225
+ // Validate dataDir if set
226
+ if (config.dataDir !== null && typeof config.dataDir !== 'string') {
227
+ console.warn(`Warning: Invalid dataDir in config. Ignoring.`);
228
+ config.dataDir = null;
229
+ }
230
+
126
231
  return config;
127
232
  } catch (err) {
128
233
  console.warn(`Warning: Could not parse config.json: ${err.message}`);
@@ -138,7 +243,8 @@ function getDefaultConfig() {
138
243
  defaultType: 'idea',
139
244
  defaultTags: [],
140
245
  editor: null,
141
- dateFormat: 'relative'
246
+ dateFormat: 'relative',
247
+ dataDir: null
142
248
  };
143
249
  }
144
250
 
@@ -170,6 +276,17 @@ function validateConfigValue(key, value) {
170
276
  case 'editor':
171
277
  // Any string or null is valid
172
278
  break;
279
+ case 'dataDir':
280
+ // Path validation - must be a valid path string or null
281
+ if (value !== null && value !== 'null' && typeof value === 'string') {
282
+ const expanded = value.replace(/^~/, os.homedir());
283
+ // Check if parent directory exists (we'll create the target if needed)
284
+ const parentDir = path.dirname(expanded);
285
+ if (!fs.existsSync(parentDir)) {
286
+ return { valid: false, error: `Parent directory does not exist: ${parentDir}` };
287
+ }
288
+ }
289
+ break;
173
290
  }
174
291
  return { valid: true };
175
292
  }
@@ -1018,9 +1135,12 @@ module.exports = {
1018
1135
  validateConfigValue,
1019
1136
  saveConfig,
1020
1137
  parseDate,
1138
+ refreshDataDir,
1139
+ getDataDir,
1021
1140
  CONFIG_DIR,
1022
- ENTRIES_FILE,
1023
- ARCHIVE_FILE,
1141
+ get DATA_DIR() { return DATA_DIR; },
1142
+ get ENTRIES_FILE() { return ENTRIES_FILE; },
1143
+ get ARCHIVE_FILE() { return ARCHIVE_FILE; },
1024
1144
  VALID_TYPES,
1025
1145
  VALID_STATUSES,
1026
1146
  VALID_DATE_FORMATS