synap 0.5.2 → 0.6.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.
@@ -147,6 +147,33 @@ synap log a1b2c3d4 "Completed first draft" --inherit-tags
147
147
  - `--inherit-tags`: Copy tags from parent entry
148
148
  - `--json`: JSON output
149
149
 
150
+ #### `synap batch-add`
151
+ Add multiple entries in one operation.
152
+
153
+ ```bash
154
+ # From file
155
+ synap batch-add --file entries.json
156
+
157
+ # From stdin (pipe)
158
+ echo '[{"content":"Task 1","type":"todo"},{"content":"Task 2","type":"todo"}]' | synap batch-add
159
+
160
+ # Dry run
161
+ synap batch-add --file entries.json --dry-run
162
+ ```
163
+
164
+ **Input format (JSON array):**
165
+ ```json
166
+ [
167
+ {"content": "First entry", "type": "idea"},
168
+ {"content": "Second entry", "type": "todo", "priority": 1, "tags": ["work"]}
169
+ ]
170
+ ```
171
+
172
+ **Options**:
173
+ - `--file <path>`: Read from JSON file
174
+ - `--dry-run`: Preview what would be added
175
+ - `--json`: JSON output
176
+
150
177
  ### Query Commands
151
178
 
152
179
  #### `synap list`
@@ -390,6 +417,24 @@ When user is dumping thoughts rapidly:
390
417
 
391
418
  **Smart status defaulting**: When capturing with priority set, the CLI auto-promotes to `active` status (skipping triage). When adding entries with full metadata (priority, tags, due), there's no need to manually set status—the entry is already triaged.
392
419
 
420
+ ### Grouping Detection Pattern
421
+
422
+ After capture sessions, detect opportunities to group related entries:
423
+
424
+ | Signal | Action |
425
+ |--------|--------|
426
+ | 3+ entries with same tag in one session | Suggest parent project with that tag as context |
427
+ | 3+ entries mentioning same keyword/topic | Suggest linking as related or creating parent |
428
+ | User mentions "for the X project" multiple times | Proactively suggest creating/linking to X project |
429
+
430
+ **Grouping workflow:**
431
+ 1. After capture, analyze recent additions: `synap list --since 1h --json`
432
+ 2. Group by common tags or detect semantic similarity
433
+ 3. If grouping detected, propose: "These 4 entries seem related to [topic]. Create a parent project?"
434
+ 4. On confirmation:
435
+ - `synap add "[Topic] Project" --type project --tags "topic"`
436
+ - For each child: `synap link <child-id> <project-id> --as-parent`
437
+
393
438
  ## Classification Rules
394
439
 
395
440
  ### Type Detection Heuristics
@@ -429,6 +474,7 @@ When user is dumping thoughts rapidly:
429
474
  - If P1 todos exist, suggest `synap focus`.
430
475
  - If many stale active items exist, suggest a weekly review.
431
476
  - If preferences specify cadence, follow it by default.
477
+ - If 3+ entries added with common tags/context, suggest grouping under a parent project (see Grouping Detection Pattern).
432
478
 
433
479
  ## Batch Processing Protocols
434
480
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synap",
3
- "version": "0.5.2",
3
+ "version": "0.6.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
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  const { program } = require('commander');
9
+ const fs = require('fs');
9
10
  const pkg = require('../package.json');
10
11
 
11
12
  // Storage and utility modules
@@ -381,6 +382,101 @@ async function main() {
381
382
  }
382
383
  });
383
384
 
385
+ program
386
+ .command('batch-add')
387
+ .description('Add multiple entries from JSON stdin or file')
388
+ .option('--file <path>', 'Read entries from JSON file')
389
+ .option('--dry-run', 'Preview what would be added')
390
+ .option('--json', 'Output as JSON')
391
+ .action(async (options) => {
392
+ let entriesData;
393
+
394
+ if (options.file) {
395
+ // Read from file
396
+ if (!fs.existsSync(options.file)) {
397
+ if (options.json) {
398
+ console.log(JSON.stringify({ success: false, error: `File not found: ${options.file}`, code: 'FILE_NOT_FOUND' }));
399
+ } else {
400
+ console.error(chalk.red(`File not found: ${options.file}`));
401
+ }
402
+ process.exit(1);
403
+ }
404
+ const content = fs.readFileSync(options.file, 'utf8');
405
+ entriesData = JSON.parse(content);
406
+ } else {
407
+ // Read from stdin
408
+ const chunks = [];
409
+ process.stdin.setEncoding('utf8');
410
+ for await (const chunk of process.stdin) {
411
+ chunks.push(chunk);
412
+ }
413
+ const input = chunks.join('');
414
+ if (!input.trim()) {
415
+ if (options.json) {
416
+ console.log(JSON.stringify({ success: false, error: 'No input provided. Pipe JSON array to stdin or use --file', code: 'NO_INPUT' }));
417
+ } else {
418
+ console.error(chalk.red('No input provided. Pipe JSON array to stdin or use --file'));
419
+ }
420
+ process.exit(1);
421
+ }
422
+ entriesData = JSON.parse(input);
423
+ }
424
+
425
+ // Normalize to array
426
+ if (!Array.isArray(entriesData)) {
427
+ entriesData = entriesData.entries || [entriesData];
428
+ }
429
+
430
+ if (entriesData.length === 0) {
431
+ if (options.json) {
432
+ console.log(JSON.stringify({ success: true, count: 0, entries: [] }));
433
+ } else {
434
+ console.log(chalk.gray('No entries to add'));
435
+ }
436
+ return;
437
+ }
438
+
439
+ // Merge config tags with each entry's tags
440
+ entriesData = entriesData.map(e => ({
441
+ ...e,
442
+ tags: [...new Set([...(config.defaultTags || []), ...(e.tags || [])])],
443
+ source: e.source || 'cli'
444
+ }));
445
+
446
+ if (options.dryRun) {
447
+ if (options.json) {
448
+ console.log(JSON.stringify({ success: true, dryRun: true, count: entriesData.length, entries: entriesData }));
449
+ } else {
450
+ console.log(chalk.yellow(`Would add ${entriesData.length} entries:`));
451
+ for (const entry of entriesData) {
452
+ console.log(` [${entry.type || 'idea'}] ${entry.content?.slice(0, 50)}${entry.content?.length > 50 ? '...' : ''}`);
453
+ }
454
+ }
455
+ return;
456
+ }
457
+
458
+ try {
459
+ const created = await storage.addEntries(entriesData);
460
+
461
+ if (options.json) {
462
+ console.log(JSON.stringify({ success: true, count: created.length, entries: created }, null, 2));
463
+ } else {
464
+ console.log(chalk.green(`Added ${created.length} entries`));
465
+ for (const entry of created) {
466
+ const shortId = entry.id.slice(0, 8);
467
+ console.log(` ${chalk.blue(shortId)} [${entry.type}] ${entry.title || entry.content.slice(0, 40)}`);
468
+ }
469
+ }
470
+ } catch (err) {
471
+ if (options.json) {
472
+ console.log(JSON.stringify({ success: false, error: err.message }));
473
+ } else {
474
+ console.error(chalk.red(err.message));
475
+ }
476
+ process.exit(1);
477
+ }
478
+ });
479
+
384
480
  // ============================================
385
481
  // QUERY COMMANDS
386
482
  // ============================================
@@ -1216,7 +1312,10 @@ async function main() {
1216
1312
  console.log(` ${type.padEnd(12)} ${count}`);
1217
1313
  }
1218
1314
  console.log('');
1219
- console.log(` High Priority (P1): ${stats.highPriority}`);
1315
+ const p1Display = stats.highPriority === stats.highPriorityActive
1316
+ ? `${stats.highPriority}`
1317
+ : `${stats.highPriority} (${stats.highPriorityActive} active)`;
1318
+ console.log(` High Priority (P1): ${p1Display}`);
1220
1319
  console.log(` Created this week: ${stats.createdThisWeek}`);
1221
1320
  console.log(` Updated today: ${stats.updatedToday}`);
1222
1321
 
package/src/storage.js CHANGED
@@ -357,6 +357,65 @@ async function addEntry(options) {
357
357
  return entry;
358
358
  }
359
359
 
360
+ /**
361
+ * Add multiple entries in a single batch
362
+ * @param {Array} entriesData - Array of entry options
363
+ * @returns {Array} - Array of created entries
364
+ */
365
+ async function addEntries(entriesData) {
366
+ const data = loadEntries();
367
+ const created = [];
368
+ const now = new Date().toISOString();
369
+
370
+ for (const options of entriesData) {
371
+ // Resolve partial parent ID to full ID
372
+ let parentId = options.parent;
373
+ if (parentId) {
374
+ const parentEntry = data.entries.find(e => e.id.startsWith(parentId));
375
+ if (parentEntry) {
376
+ parentId = parentEntry.id;
377
+ }
378
+ }
379
+
380
+ // Parse due date if provided
381
+ let dueDate = undefined;
382
+ const dueInput = typeof options.due === 'string' ? options.due.trim() : options.due;
383
+ if (dueInput) {
384
+ dueDate = parseDate(dueInput);
385
+ if (!dueDate) {
386
+ throw new Error(`Invalid due date: ${options.due}`);
387
+ }
388
+ }
389
+
390
+ const entry = {
391
+ id: uuidv4(),
392
+ content: options.content,
393
+ title: options.title || extractTitle(options.content),
394
+ type: VALID_TYPES.includes(options.type) ? options.type : 'idea',
395
+ status: VALID_STATUSES.includes(options.status) ? options.status : (options.priority ? 'active' : 'raw'),
396
+ priority: options.priority && [1, 2, 3].includes(options.priority) ? options.priority : undefined,
397
+ tags: options.tags || [],
398
+ parent: parentId || undefined,
399
+ related: [],
400
+ due: dueDate,
401
+ createdAt: now,
402
+ updatedAt: now,
403
+ source: options.source || 'cli'
404
+ };
405
+
406
+ // Clean up undefined fields
407
+ Object.keys(entry).forEach(key => {
408
+ if (entry[key] === undefined) delete entry[key];
409
+ });
410
+
411
+ data.entries.push(entry);
412
+ created.push(entry);
413
+ }
414
+
415
+ saveEntries(data);
416
+ return created;
417
+ }
418
+
360
419
  /**
361
420
  * Extract title from content (first line, max 60 chars)
362
421
  */
@@ -764,6 +823,7 @@ async function getStats() {
764
823
  byStatus: {},
765
824
  byType: {},
766
825
  highPriority: 0,
826
+ highPriorityActive: 0,
767
827
  createdThisWeek: 0,
768
828
  updatedToday: 0
769
829
  };
@@ -780,7 +840,12 @@ async function getStats() {
780
840
  stats.byType[entry.type] = (stats.byType[entry.type] || 0) + 1;
781
841
 
782
842
  // High priority
783
- if (entry.priority === 1) stats.highPriority++;
843
+ if (entry.priority === 1) {
844
+ stats.highPriority++;
845
+ if (entry.status === 'raw' || entry.status === 'active') {
846
+ stats.highPriorityActive++;
847
+ }
848
+ }
784
849
 
785
850
  // Created this week
786
851
  if (new Date(entry.createdAt) >= oneWeekAgo) stats.createdThisWeek++;
@@ -932,6 +997,7 @@ async function importEntries(entries, options = {}) {
932
997
 
933
998
  module.exports = {
934
999
  addEntry,
1000
+ addEntries,
935
1001
  getEntry,
936
1002
  getEntriesByIds,
937
1003
  getChildren,