synap 0.6.0 → 0.7.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.
@@ -39,8 +39,11 @@ When assisting users with their synap entries:
39
39
  synap stores long-term user preferences at `~/.config/synap/user-preferences.md`.
40
40
 
41
41
  - 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.
42
+ - Prefer idempotent updates with `synap preferences set --section "Tag Meanings" --entry "#urgent = must do today"`.
43
+ - Remove entries with `synap preferences remove --section tags --match "urgent"`.
44
+ - List entries with `synap preferences list --section tags --json`.
45
+ - `synap preferences --append "## Section" "..."` is still supported for raw appends.
46
+ - Avoid overwriting user-written content; prefer section-based updates.
44
47
 
45
48
  ## Operating Modes
46
49
 
@@ -79,6 +82,7 @@ Detect user intent and respond appropriately:
79
82
  | Get stats | `synap stats` |
80
83
  | Setup wizard | `synap setup` |
81
84
  | Edit preferences | `synap preferences --edit` |
85
+ | Set preference | `synap preferences set --section tags --entry "#urgent = must do today"` |
82
86
 
83
87
  ## Pre-flight Check
84
88
 
@@ -435,6 +439,37 @@ After capture sessions, detect opportunities to group related entries:
435
439
  - `synap add "[Topic] Project" --type project --tags "topic"`
436
440
  - For each child: `synap link <child-id> <project-id> --as-parent`
437
441
 
442
+ ### Daily Tracking Pattern
443
+
444
+ For projects requiring ongoing progress logging (standups, journals, learning logs):
445
+
446
+ | Signal | Action |
447
+ |--------|--------|
448
+ | Project mentions "daily", "track progress", "standup" | Suggest daily tracking setup |
449
+ | User says "I need to log progress on X" | Explain `synap log` workflow |
450
+ | Project has `--tags daily-tracking` | Ask for today's update |
451
+
452
+ **Daily tracking workflow:**
453
+
454
+ 1. **Setup:** Create a project to track
455
+ ```bash
456
+ synap add "Learn Rust" --type project --tags "learning,daily-tracking"
457
+ ```
458
+
459
+ 2. **Daily logging:** Add timestamped progress
460
+ ```bash
461
+ synap log <project-id> "Completed chapter 3"
462
+ ```
463
+
464
+ 3. **Review progress:** View the log tree
465
+ ```bash
466
+ synap tree <project-id>
467
+ ```
468
+
469
+ **When to suggest:** User creates learning/progress project, mentions accountability, or asks about daily tracking.
470
+
471
+ **Do NOT auto-create** - always confirm with user first.
472
+
438
473
  ## Classification Rules
439
474
 
440
475
  ### Type Detection Heuristics
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synap",
3
- "version": "0.6.0",
3
+ "version": "0.7.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
 
@@ -9,6 +9,17 @@ const storage = require('./storage');
9
9
  const TEMPLATE_PATH = path.join(__dirname, 'templates', 'user-preferences-template.md');
10
10
  const PREFERENCES_FILE = path.join(storage.CONFIG_DIR, 'user-preferences.md');
11
11
  const MAX_LINES = 500;
12
+ const SECTION_ALIASES = new Map([
13
+ ['about', 'About Me'],
14
+ ['me', 'About Me'],
15
+ ['projects', 'Important Projects'],
16
+ ['important', 'Important Projects'],
17
+ ['tags', 'Tag Meanings'],
18
+ ['tag', 'Tag Meanings'],
19
+ ['review', 'Review Preferences'],
20
+ ['behavior', 'Behavioral Preferences'],
21
+ ['behavioral', 'Behavioral Preferences']
22
+ ]);
12
23
 
13
24
  function ensureConfigDir() {
14
25
  if (!fs.existsSync(storage.CONFIG_DIR)) {
@@ -79,7 +90,16 @@ function resetPreferences() {
79
90
  return savePreferences(template);
80
91
  }
81
92
 
93
+ function resolveSection(input) {
94
+ const target = parseSectionTarget(input);
95
+ const key = target.name.toLowerCase();
96
+ return SECTION_ALIASES.get(key) || target.name;
97
+ }
98
+
82
99
  function parseSectionTarget(section) {
100
+ if (typeof section !== 'string') {
101
+ throw new Error('Section name is required');
102
+ }
83
103
  const trimmed = section.trim();
84
104
  if (!trimmed) {
85
105
  throw new Error('Section name is required');
@@ -93,9 +113,22 @@ function parseSectionTarget(section) {
93
113
  return { level: null, name: trimmed };
94
114
  }
95
115
 
116
+ function normalizeEntry(entry) {
117
+ return entry.trim();
118
+ }
119
+
120
+ function isCommentLine(line) {
121
+ const trimmed = line.trim();
122
+ return trimmed.startsWith('<!--');
123
+ }
124
+
125
+ function isHeadingLine(line) {
126
+ return /^(#{1,6})\s+/.test(line.trim());
127
+ }
128
+
96
129
  function findSection(lines, target) {
97
130
  for (let i = 0; i < lines.length; i += 1) {
98
- const match = lines[i].match(/^(#{1,6})\s*(.+?)\s*$/);
131
+ const match = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
99
132
  if (!match) {
100
133
  continue;
101
134
  }
@@ -113,6 +146,148 @@ function findSection(lines, target) {
113
146
  return null;
114
147
  }
115
148
 
149
+ function getSectionRange(lines, match) {
150
+ if (!match) {
151
+ return null;
152
+ }
153
+
154
+ let endIndex = lines.length;
155
+ for (let i = match.index + 1; i < lines.length; i += 1) {
156
+ const headingMatch = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
157
+ if (!headingMatch) {
158
+ continue;
159
+ }
160
+ const level = headingMatch[1].length;
161
+ if (level <= match.level) {
162
+ endIndex = i;
163
+ break;
164
+ }
165
+ }
166
+
167
+ return { start: match.index, end: endIndex };
168
+ }
169
+
170
+ function getEntriesInSection(section) {
171
+ const resolved = resolveSection(section);
172
+ const content = loadPreferences();
173
+ const lines = content.split(/\r?\n/);
174
+ const target = parseSectionTarget(resolved);
175
+ const match = findSection(lines, target);
176
+
177
+ if (!match) {
178
+ return [];
179
+ }
180
+
181
+ const range = getSectionRange(lines, match);
182
+ const entries = [];
183
+
184
+ for (let i = match.index + 1; i < range.end; i += 1) {
185
+ const line = lines[i];
186
+ const trimmed = line.trim();
187
+
188
+ if (!trimmed || isCommentLine(trimmed) || isHeadingLine(trimmed)) {
189
+ continue;
190
+ }
191
+
192
+ entries.push(trimmed);
193
+ }
194
+
195
+ return entries;
196
+ }
197
+
198
+ function setEntry(section, entry) {
199
+ if (typeof entry !== 'string' || !entry.trim()) {
200
+ throw new Error('Entry text is required');
201
+ }
202
+
203
+ const resolved = resolveSection(section);
204
+ const normalized = normalizeEntry(entry);
205
+ const entries = getEntriesInSection(resolved);
206
+ const existed = entries.some((existing) => existing === normalized);
207
+
208
+ if (existed) {
209
+ return {
210
+ added: false,
211
+ existed: true,
212
+ section: resolved,
213
+ entry: normalized
214
+ };
215
+ }
216
+
217
+ appendToSection(resolved, normalized);
218
+ return {
219
+ added: true,
220
+ existed: false,
221
+ section: resolved,
222
+ entry: normalized
223
+ };
224
+ }
225
+
226
+ function removeFromSection(section, { match, entry } = {}) {
227
+ const hasMatch = typeof match === 'string' && match.trim();
228
+ const hasEntry = typeof entry === 'string' && entry.trim();
229
+
230
+ if (!hasMatch && !hasEntry) {
231
+ throw new Error('Match or entry is required');
232
+ }
233
+ if (hasMatch && hasEntry) {
234
+ throw new Error('Use either match or entry, not both');
235
+ }
236
+
237
+ const resolved = resolveSection(section);
238
+ const content = loadPreferences();
239
+ const lines = content.split(/\r?\n/);
240
+ const target = parseSectionTarget(resolved);
241
+ const sectionMatch = findSection(lines, target);
242
+
243
+ if (!sectionMatch) {
244
+ return { removed: false, count: 0, entries: [], section: resolved };
245
+ }
246
+
247
+ const range = getSectionRange(lines, sectionMatch);
248
+ const sectionLines = lines.slice(sectionMatch.index + 1, range.end);
249
+ const removedEntries = [];
250
+ const normalizedEntry = hasEntry ? normalizeEntry(entry) : null;
251
+ const matchNeedle = hasMatch ? match.trim().toLowerCase() : null;
252
+
253
+ const updatedSectionLines = sectionLines.filter((line) => {
254
+ const trimmed = line.trim();
255
+
256
+ if (!trimmed || isCommentLine(trimmed) || isHeadingLine(trimmed)) {
257
+ return true;
258
+ }
259
+
260
+ const matches = normalizedEntry
261
+ ? trimmed === normalizedEntry
262
+ : trimmed.toLowerCase().includes(matchNeedle);
263
+
264
+ if (matches) {
265
+ removedEntries.push(trimmed);
266
+ return false;
267
+ }
268
+
269
+ return true;
270
+ });
271
+
272
+ if (removedEntries.length === 0) {
273
+ return { removed: false, count: 0, entries: [], section: resolved };
274
+ }
275
+
276
+ const updatedLines = [
277
+ ...lines.slice(0, sectionMatch.index + 1),
278
+ ...updatedSectionLines,
279
+ ...lines.slice(range.end)
280
+ ];
281
+
282
+ savePreferences(updatedLines.join('\n'));
283
+ return {
284
+ removed: true,
285
+ count: removedEntries.length,
286
+ entries: removedEntries,
287
+ section: resolved
288
+ };
289
+ }
290
+
116
291
  function appendToSection(section, text) {
117
292
  if (typeof text !== 'string' || !text.trim()) {
118
293
  throw new Error('Append text is required');
@@ -140,7 +315,7 @@ function appendToSection(section, text) {
140
315
 
141
316
  let insertIndex = lines.length;
142
317
  for (let i = match.index + 1; i < lines.length; i += 1) {
143
- const headingMatch = lines[i].match(/^(#{1,6})\s*(.+?)\s*$/);
318
+ const headingMatch = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
144
319
  if (!headingMatch) {
145
320
  continue;
146
321
  }
@@ -167,6 +342,10 @@ module.exports = {
167
342
  getPreferencesPath,
168
343
  loadPreferences,
169
344
  savePreferences,
345
+ resolveSection,
346
+ setEntry,
347
+ removeFromSection,
348
+ getEntriesInSection,
170
349
  appendToSection,
171
350
  resetPreferences,
172
351
  validatePreferences,