hanuscode 1.0.0__py3-none-any.whl

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.
Files changed (93) hide show
  1. hanus/__init__.py +5 -0
  2. hanus/__main__.py +10 -0
  3. hanus/action_handlers.py +76 -0
  4. hanus/action_parser.py +82 -0
  5. hanus/agent_runner.py +1445 -0
  6. hanus/analysis/__init__.py +5 -0
  7. hanus/analysis/debt.py +702 -0
  8. hanus/analysis/dependencies.py +475 -0
  9. hanus/cache/__init__.py +5 -0
  10. hanus/cache/response_cache.py +560 -0
  11. hanus/config.py +401 -0
  12. hanus/connectors/__init__.py +19 -0
  13. hanus/connectors/base.py +114 -0
  14. hanus/connectors/claude_connector.py +146 -0
  15. hanus/connectors/gemini_connector.py +141 -0
  16. hanus/connectors/glm_connector.py +160 -0
  17. hanus/connectors/ollama_connector.py +174 -0
  18. hanus/connectors/openai_connector.py +122 -0
  19. hanus/connectors/registry.py +26 -0
  20. hanus/context/__init__.py +7 -0
  21. hanus/context/manager.py +837 -0
  22. hanus/context/selective.py +626 -0
  23. hanus/error_recovery/__init__.py +5 -0
  24. hanus/error_recovery/auto_fix.py +605 -0
  25. hanus/hooks/__init__.py +5 -0
  26. hanus/hooks/manager.py +247 -0
  27. hanus/instincts/__init__.py +44 -0
  28. hanus/instincts/cli.py +372 -0
  29. hanus/instincts/detector.py +281 -0
  30. hanus/instincts/evolver.py +361 -0
  31. hanus/instincts/manager.py +343 -0
  32. hanus/instincts/types.py +253 -0
  33. hanus/logger.py +81 -0
  34. hanus/memory/__init__.py +8 -0
  35. hanus/memory/manager.py +265 -0
  36. hanus/memory/types.py +119 -0
  37. hanus/monitor.py +341 -0
  38. hanus/parallel/__init__.py +5 -0
  39. hanus/parallel/executor.py +300 -0
  40. hanus/permissions.py +182 -0
  41. hanus/plan/__init__.py +8 -0
  42. hanus/plan/mode.py +267 -0
  43. hanus/plan/models.py +152 -0
  44. hanus/plugin_manager.py +754 -0
  45. hanus/plugin_registry.py +391 -0
  46. hanus/plugins/__init__.py +1 -0
  47. hanus/plugins/arena.py +630 -0
  48. hanus/plugins/code_review.py +123 -0
  49. hanus/plugins/cortex.py +1750 -0
  50. hanus/plugins/deps_check.py +27 -0
  51. hanus/plugins/git_ops.py +33 -0
  52. hanus/plugins/metasploit.py +530 -0
  53. hanus/plugins/notes.py +583 -0
  54. hanus/plugins/search_code.py +59 -0
  55. hanus/plugins/searchsploit.py +495 -0
  56. hanus/plugins/strategist.py +175 -0
  57. hanus/plugins/webui.py +5200 -0
  58. hanus/profiles.py +479 -0
  59. hanus/profiles_builtin/__init__.py +0 -0
  60. hanus/profiles_builtin/architect/profile.yaml +12 -0
  61. hanus/profiles_builtin/architect/system_prompt.txt +71 -0
  62. hanus/profiles_builtin/deep/profile.yaml +12 -0
  63. hanus/profiles_builtin/deep/system_prompt.txt +66 -0
  64. hanus/profiles_builtin/developer/__init__.py +0 -0
  65. hanus/profiles_builtin/developer/profile.yaml +9 -0
  66. hanus/profiles_builtin/developer/system_prompt.txt +176 -0
  67. hanus/profiles_builtin/speed/profile.yaml +12 -0
  68. hanus/profiles_builtin/speed/system_prompt.txt +51 -0
  69. hanus/project_tools.py +177 -0
  70. hanus/query_engine.py +1594 -0
  71. hanus/rules/__init__.py +237 -0
  72. hanus/search/__init__.py +5 -0
  73. hanus/search/semantic.py +596 -0
  74. hanus/session_manager.py +547 -0
  75. hanus/skill_manager.py +702 -0
  76. hanus/skills/__init__.py +4 -0
  77. hanus/subagent/__init__.py +8 -0
  78. hanus/subagent/agents/__init__.py +253 -0
  79. hanus/subagent/manager.py +309 -0
  80. hanus/subagent/types.py +266 -0
  81. hanus/suggestions/__init__.py +5 -0
  82. hanus/suggestions/proactive.py +451 -0
  83. hanus/tasks/__init__.py +8 -0
  84. hanus/tasks/manager.py +330 -0
  85. hanus/tasks/models.py +106 -0
  86. hanus/terminal_prompt.py +166 -0
  87. hanus/tools.py +1849 -0
  88. hanus/ui.py +939 -0
  89. hanuscode-1.0.0.dist-info/METADATA +1151 -0
  90. hanuscode-1.0.0.dist-info/RECORD +93 -0
  91. hanuscode-1.0.0.dist-info/WHEEL +5 -0
  92. hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
  93. hanuscode-1.0.0.dist-info/top_level.txt +1 -0
hanus/plugins/notes.py ADDED
@@ -0,0 +1,583 @@
1
+ # plugins/notes.py — Knowledge management with bidirectional links
2
+ """
3
+ Plugin for bidirectional note linking, tags, and knowledge graph.
4
+
5
+ Commands:
6
+ /notes new <title> — Create a new note
7
+ /notes edit <title> — Edit a note
8
+ /notes view <title> — View a note with backlinks
9
+ /notes delete <title> — Delete a note
10
+ /notes list — List all notes
11
+ /notes search <query> — Search notes by content
12
+ /notes tags — List all tags
13
+ /notes tag <tag> — Notes with specific tag
14
+ /notes links <title> — Show outgoing links from note
15
+ /notes backlinks <title> — Show notes linking to this note
16
+ /notes graph — Show knowledge graph
17
+ /notes graph <title> — Graph centered on note
18
+ """
19
+ import re
20
+ import json
21
+ from pathlib import Path
22
+ from datetime import datetime
23
+ from typing import Dict, List, Set, Optional, Tuple
24
+
25
+ NAME = "notes"
26
+ DESCRIPTION = "Knowledge management with bidirectional links"
27
+ USAGE = "new|edit|view|delete|list|search|tags|tag|links|backlinks|graph <args>"
28
+
29
+ AGENT_DOC = """
30
+ Knowledge management plugin with bidirectional linking.
31
+
32
+ ## Commands:
33
+ - new <title> [content] — Create note with optional content
34
+ - edit <title> — Append content to note (use --replace to replace)
35
+ - view <title> — View note with backlinks
36
+ - delete <title> — Delete a note
37
+ - list — List all notes
38
+ - search <query> — Full-text search in notes
39
+ - tags — List all tags used
40
+ - tag <tag> — Show notes with specific tag
41
+ - links <title> — Show outgoing [[links]] from note
42
+ - backlinks <title> — Show notes linking to this note
43
+ - graph — Display knowledge graph connections
44
+ - graph <title> — Graph centered on specific note
45
+
46
+ ## Features:
47
+ - Wiki-style [[links]] between notes
48
+ - Backlinks (which notes reference this note)
49
+ - #tags for categorization
50
+ - Knowledge graph visualization
51
+ - Full-text search
52
+
53
+ ## Link Syntax:
54
+ - [[Note Title]] — Link to another note
55
+ - [[Note Title|display text]] — Link with custom display text
56
+ - #tag — Tag in note content
57
+
58
+ Examples:
59
+ /notes new "Project Ideas" "Ideas for future projects #ideas"
60
+ /notes edit "Project Ideas" "New idea: [[AI Assistant]]"
61
+ /notes view "Project Ideas"
62
+ /notes graph "Project Ideas"
63
+ /obsidian view "Project Ideas"
64
+ /obsidian graph "Project Ideas"
65
+ """
66
+
67
+ NOTES_DIR = Path.home() / ".hanus" / "notes"
68
+ INDEX_FILE = NOTES_DIR / "_index.json"
69
+
70
+ # Regex patterns
71
+ LINK_PATTERN = re.compile(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]')
72
+ TAG_PATTERN = re.compile(r'#([\w\-]+)')
73
+
74
+
75
+ def _ensure_notes_dir():
76
+ """Ensure notes directory exists."""
77
+ NOTES_DIR.mkdir(parents=True, exist_ok=True)
78
+
79
+
80
+ def _normalize_title(title: str) -> str:
81
+ """Normalize note title for filename."""
82
+ # Remove invalid chars, lowercase, replace spaces with underscores
83
+ clean = re.sub(r'[<>:"/\\|?*]', '', title)
84
+ return clean.strip().lower().replace(' ', '_')
85
+
86
+
87
+ def _get_note_path(title: str) -> Path:
88
+ """Get path for a note."""
89
+ return NOTES_DIR / f"{_normalize_title(title)}.md"
90
+
91
+
92
+ def _get_all_notes() -> Dict[str, dict]:
93
+ """Load all notes with metadata."""
94
+ _ensure_notes_dir()
95
+ notes = {}
96
+ for path in NOTES_DIR.glob("*.md"):
97
+ if path.name.startswith("_"):
98
+ continue
99
+ try:
100
+ content = path.read_text(encoding="utf-8")
101
+ title = path.stem.replace('_', ' ').title()
102
+ links = set(LINK_PATTERN.findall(content))
103
+ tags = set(TAG_PATTERN.findall(content))
104
+ notes[title.lower()] = {
105
+ "title": title,
106
+ "path": path,
107
+ "content": content,
108
+ "links": links,
109
+ "tags": tags,
110
+ "modified": datetime.fromtimestamp(path.stat().st_mtime)
111
+ }
112
+ except Exception:
113
+ continue
114
+ return notes
115
+
116
+
117
+ def _get_backlinks(title: str, notes: Dict[str, dict]) -> Set[str]:
118
+ """Get all notes that link to this note."""
119
+ backlinks = set()
120
+ title_lower = title.lower()
121
+ for note_title, note in notes.items():
122
+ if note_title == title_lower:
123
+ continue
124
+ # Check if this note links to the target
125
+ for link in note["links"]:
126
+ if link.lower() == title_lower:
127
+ backlinks.add(note["title"])
128
+ return backlinks
129
+
130
+
131
+ def _build_graph(notes: Dict[str, dict]) -> Dict:
132
+ """Build knowledge graph."""
133
+ graph = {"nodes": [], "edges": []}
134
+ node_ids = {}
135
+
136
+ for title, note in notes.items():
137
+ node_id = len(graph["nodes"])
138
+ node_ids[title] = node_id
139
+ graph["nodes"].append({
140
+ "id": node_id,
141
+ "title": note["title"],
142
+ "tags": list(note["tags"]),
143
+ "modified": note["modified"].isoformat() if isinstance(note["modified"], datetime) else note["modified"]
144
+ })
145
+
146
+ added_edges = set()
147
+ for title, note in notes.items():
148
+ for link in note["links"]:
149
+ link_lower = link.lower()
150
+ if link_lower in node_ids and title != link_lower:
151
+ edge = (min(node_ids[title], node_ids[link_lower]),
152
+ max(node_ids[title], node_ids[link_lower]))
153
+ if edge not in added_edges:
154
+ graph["edges"].append({
155
+ "source": node_ids[title],
156
+ "target": node_ids[link_lower]
157
+ })
158
+ added_edges.add(edge)
159
+
160
+ return graph
161
+
162
+
163
+ def run(args: str = "") -> str:
164
+ """Run obsidian command."""
165
+ if not args.strip():
166
+ return _cmd_list("")
167
+
168
+ parts = args.strip().split(maxsplit=1)
169
+ cmd = parts[0].lower()
170
+ arg = parts[1] if len(parts) > 1 else ""
171
+
172
+ commands = {
173
+ "new": _cmd_new,
174
+ "create": _cmd_new,
175
+ "edit": _cmd_edit,
176
+ "append": _cmd_edit,
177
+ "view": _cmd_view,
178
+ "show": _cmd_view,
179
+ "read": _cmd_view,
180
+ "delete": _cmd_delete,
181
+ "remove": _cmd_delete,
182
+ "rm": _cmd_delete,
183
+ "list": _cmd_list,
184
+ "ls": _cmd_list,
185
+ "search": _cmd_search,
186
+ "find": _cmd_search,
187
+ "tags": _cmd_tags,
188
+ "tag": _cmd_tag,
189
+ "links": _cmd_links,
190
+ "backlinks": _cmd_backlinks,
191
+ "refs": _cmd_backlinks,
192
+ "graph": _cmd_graph,
193
+ "connect": _cmd_graph,
194
+ }
195
+
196
+ handler = commands.get(cmd)
197
+ if not handler:
198
+ return f"Unknown command: {cmd}\nCommands: new, edit, view, delete, list, search, tags, tag, links, backlinks, graph"
199
+
200
+ return handler(arg)
201
+
202
+
203
+ def _cmd_new(args: str) -> str:
204
+ """Create a new note."""
205
+ _ensure_notes_dir()
206
+
207
+ parts = args.strip().split(maxsplit=1)
208
+ if not parts:
209
+ return "Usage: /obsidian new <title> [content]"
210
+
211
+ title = parts[0].strip('"\'')
212
+ content = parts[1] if len(parts) > 1 else ""
213
+
214
+ # Add timestamp if no content
215
+ if not content:
216
+ content = f"# {title}\n\nCreated: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n"
217
+
218
+ path = _get_note_path(title)
219
+
220
+ if path.exists():
221
+ return f"Note '{title}' already exists. Use /obsidian edit to modify."
222
+
223
+ path.write_text(content, encoding="utf-8")
224
+
225
+ # Extract and show info
226
+ links = LINK_PATTERN.findall(content)
227
+ tags = TAG_PATTERN.findall(content)
228
+
229
+ result = [f"Created note: {title}"]
230
+ if tags:
231
+ result.append(f"Tags: {', '.join('#' + t for t in tags)}")
232
+ if links:
233
+ result.append(f"Links: {', '.join('[[' + l + ']]' for l in links)}")
234
+
235
+ return "\n".join(result)
236
+
237
+
238
+ def _cmd_edit(args: str) -> str:
239
+ """Edit a note (append or replace)."""
240
+ _ensure_notes_dir()
241
+
242
+ replace_mode = "--replace" in args
243
+ args = args.replace("--replace", "").strip()
244
+
245
+ parts = args.strip().split(maxsplit=1)
246
+ if len(parts) < 2:
247
+ return "Usage: /obsidian edit <title> <content> [--replace]"
248
+
249
+ title = parts[0].strip('"\'')
250
+ new_content = parts[1]
251
+
252
+ path = _get_note_path(title)
253
+
254
+ if not path.exists():
255
+ return f"Note '{title}' not found. Use /obsidian new to create it."
256
+
257
+ if replace_mode:
258
+ path.write_text(new_content, encoding="utf-8")
259
+ return f"Note '{title}' replaced."
260
+ else:
261
+ current = path.read_text(encoding="utf-8")
262
+ path.write_text(current + "\n\n" + new_content, encoding="utf-8")
263
+ return f"Appended to note '{title}'."
264
+
265
+
266
+ def _cmd_view(args: str) -> str:
267
+ """View a note with backlinks."""
268
+ if not args.strip():
269
+ return "Usage: /obsidian view <title>"
270
+
271
+ title = args.strip().strip('"\'')
272
+ path = _get_note_path(title)
273
+
274
+ if not path.exists():
275
+ # Try fuzzy match
276
+ notes = _get_all_notes()
277
+ matches = [n for n in notes if title.lower() in n]
278
+ if matches:
279
+ return f"Note not found. Similar: {', '.join(notes[m]['title'] for m in matches[:5])}"
280
+ return f"Note '{title}' not found."
281
+
282
+ content = path.read_text(encoding="utf-8")
283
+ all_notes = _get_all_notes()
284
+ backlinks = _get_backlinks(title, all_notes)
285
+
286
+ result = [f"--- {title} ---", content]
287
+
288
+ if backlinks:
289
+ result.append(f"\n--- Backlinks ({len(backlinks)}) ---")
290
+ result.append(", ".join(f"[[{bl}]]" for bl in sorted(backlinks)))
291
+
292
+ # Show linked notes status
293
+ links = set(LINK_PATTERN.findall(content))
294
+ if links:
295
+ broken = [l for l in links if l.lower() not in all_notes]
296
+ if broken:
297
+ result.append(f"\n--- Broken Links ---")
298
+ result.append(", ".join(f"[[{b}]]?" for b in broken))
299
+
300
+ return "\n".join(result)
301
+
302
+
303
+ def _cmd_delete(args: str) -> str:
304
+ """Delete a note."""
305
+ if not args.strip():
306
+ return "Usage: /obsidian delete <title>"
307
+
308
+ title = args.strip().strip('"\'')
309
+ path = _get_note_path(title)
310
+
311
+ if not path.exists():
312
+ return f"Note '{title}' not found."
313
+
314
+ # Check backlinks
315
+ all_notes = _get_all_notes()
316
+ backlinks = _get_backlinks(title, all_notes)
317
+
318
+ path.unlink()
319
+
320
+ result = [f"Deleted note: {title}"]
321
+ if backlinks:
322
+ result.append(f"Warning: {len(backlinks)} notes still link to this: {', '.join(backlinks)}")
323
+
324
+ return "\n".join(result)
325
+
326
+
327
+ def _cmd_list(args: str) -> str:
328
+ """List all notes."""
329
+ notes = _get_all_notes()
330
+
331
+ if not notes:
332
+ return "No notes found. Create one with /obsidian new <title>"
333
+
334
+ lines = [f"Notes ({len(notes)}):"]
335
+ for title, note in sorted(notes.items()):
336
+ tags = f" [{', '.join('#' + t for t in note['tags'])}]" if note['tags'] else ""
337
+ links_count = len(note['links'])
338
+ backlinks = len(_get_backlinks(note['title'], notes))
339
+ lines.append(f" • {note['title']}{tags}")
340
+ lines.append(f" {links_count} links, {backlinks} backlinks")
341
+
342
+ return "\n".join(lines)
343
+
344
+
345
+ def _cmd_search(args: str) -> str:
346
+ """Search notes by content."""
347
+ if not args.strip():
348
+ return "Usage: /obsidian search <query>"
349
+
350
+ query = args.strip().lower()
351
+ notes = _get_all_notes()
352
+
353
+ results = []
354
+ for title, note in notes.items():
355
+ if query in note['content'].lower():
356
+ # Find context around match
357
+ lines = note['content'].lower().split('\n')
358
+ matches = []
359
+ for i, line in enumerate(lines):
360
+ if query in line:
361
+ start = max(0, i - 1)
362
+ end = min(len(lines), i + 2)
363
+ context = '\n'.join(lines[start:end])
364
+ matches.append(context)
365
+ if len(matches) >= 3:
366
+ break
367
+ results.append((note['title'], matches[:3]))
368
+
369
+ if not results:
370
+ return f"No notes found matching '{query}'"
371
+
372
+ lines = [f"Search results for '{query}' ({len(results)} notes):"]
373
+ for title, matches in results:
374
+ lines.append(f"\n--- {title} ---")
375
+ for m in matches:
376
+ lines.append(m)
377
+
378
+ return "\n".join(lines)
379
+
380
+
381
+ def _cmd_tags(args: str) -> str:
382
+ """List all tags."""
383
+ notes = _get_all_notes()
384
+
385
+ tag_counts: Dict[str, int] = {}
386
+ for note in notes.values():
387
+ for tag in note['tags']:
388
+ tag_counts[tag] = tag_counts.get(tag, 0) + 1
389
+
390
+ if not tag_counts:
391
+ return "No tags found. Add #tags to your notes."
392
+
393
+ lines = ["Tags:"]
394
+ for tag, count in sorted(tag_counts.items(), key=lambda x: (-x[1], x[0])):
395
+ lines.append(f" #{tag} ({count} notes)")
396
+
397
+ return "\n".join(lines)
398
+
399
+
400
+ def _cmd_tag(args: str) -> str:
401
+ """Show notes with specific tag."""
402
+ if not args.strip():
403
+ return "Usage: /obsidian tag <tagname>"
404
+
405
+ tag = args.strip().lstrip('#').lower()
406
+ notes = _get_all_notes()
407
+
408
+ matching = []
409
+ for note in notes.values():
410
+ if tag in [t.lower() for t in note['tags']]:
411
+ matching.append(note['title'])
412
+
413
+ if not matching:
414
+ return f"No notes with tag #{tag}"
415
+
416
+ return f"Notes with #{tag} ({len(matching)}):\n" + "\n".join(f" • {t}" for t in sorted(matching))
417
+
418
+
419
+ def _cmd_links(args: str) -> str:
420
+ """Show outgoing links from a note."""
421
+ if not args.strip():
422
+ return "Usage: /obsidian links <title>"
423
+
424
+ title = args.strip().strip('"\'')
425
+ notes = _get_all_notes()
426
+ note = notes.get(title.lower())
427
+
428
+ if not note:
429
+ return f"Note '{title}' not found."
430
+
431
+ links = note['links']
432
+ if not links:
433
+ return f"Note '{title}' has no outgoing links."
434
+
435
+ lines = [f"Links from '{title}' ({len(links)}):"]
436
+ for link in sorted(links):
437
+ status = "" if link.lower() in notes else " (broken)"
438
+ lines.append(f" [[{link}]]{status}")
439
+
440
+ return "\n".join(lines)
441
+
442
+
443
+ def _cmd_backlinks(args: str) -> str:
444
+ """Show notes linking to a note."""
445
+ if not args.strip():
446
+ return "Usage: /obsidian backlinks <title>"
447
+
448
+ title = args.strip().strip('"\'')
449
+ notes = _get_all_notes()
450
+
451
+ if title.lower() not in notes:
452
+ return f"Note '{title}' not found."
453
+
454
+ backlinks = _get_backlinks(title, notes)
455
+
456
+ if not backlinks:
457
+ return f"No notes link to '{title}'"
458
+
459
+ return f"Backlinks to '{title}' ({len(backlinks)}):\n" + "\n".join(f" • [[{bl}]]" for bl in sorted(backlinks))
460
+
461
+
462
+ def _cmd_graph(args: str) -> str:
463
+ """Display knowledge graph."""
464
+ notes = _get_all_notes()
465
+
466
+ if not notes:
467
+ return "No notes to graph."
468
+
469
+ if args.strip():
470
+ # Graph centered on specific note
471
+ center = args.strip().strip('"\'').lower()
472
+ if center not in notes:
473
+ return f"Note '{args.strip()}' not found."
474
+
475
+ # Get connected notes (2 hops)
476
+ connected = {center}
477
+ for link in notes[center]['links']:
478
+ if link.lower() in notes:
479
+ connected.add(link.lower())
480
+ # Second hop
481
+ for l2 in notes[link.lower()]['links']:
482
+ if l2.lower() in notes:
483
+ connected.add(l2.lower())
484
+
485
+ # Add backlinks
486
+ for title in list(connected):
487
+ for bl in _get_backlinks(title, notes):
488
+ connected.add(bl.lower())
489
+
490
+ # Filter notes to connected only
491
+ notes = {k: v for k, v in notes.items() if k in connected}
492
+
493
+ graph = _build_graph(notes)
494
+
495
+ # ASCII graph visualization
496
+ lines = ["Knowledge Graph:"]
497
+ lines.append("")
498
+
499
+ for node in sorted(graph["nodes"], key=lambda x: x["title"]):
500
+ title = node["title"]
501
+ links_out = [e for e in graph["edges"] if e["source"] == node["id"]]
502
+ links_in = [e for e in graph["edges"] if e["target"] == node["id"]]
503
+
504
+ tags = f" [{', '.join('#' + t for t in node['tags'])}]" if node['tags'] else ""
505
+ lines.append(f" [{title}]{tags}")
506
+
507
+ if links_out:
508
+ targets = [graph["nodes"][e["target"]]["title"] for e in links_out]
509
+ lines.append(f" → {', '.join(targets)}")
510
+ if links_in:
511
+ sources = [graph["nodes"][e["source"]]["title"] for e in links_in]
512
+ lines.append(f" ← {', '.join(sources)}")
513
+ lines.append("")
514
+
515
+ # Stats
516
+ lines.append(f"Stats: {len(graph['nodes'])} notes, {len(graph['edges'])} connections")
517
+
518
+ return "\n".join(lines)
519
+
520
+
521
+ # Tool interface for agent integration
522
+ def create_note(title: str, content: str = "") -> str:
523
+ """Create a new note. Returns status message."""
524
+ return _cmd_new(f'"{title}" {content}'.strip())
525
+
526
+
527
+ def append_to_note(title: str, content: str) -> str:
528
+ """Append content to a note. Returns status message."""
529
+ return _cmd_edit(f'"{title}" {content}')
530
+
531
+
532
+ def get_note(title: str) -> Optional[str]:
533
+ """Get note content. Returns None if not found."""
534
+ path = _get_note_path(title)
535
+ if path.exists():
536
+ return path.read_text(encoding="utf-8")
537
+ return None
538
+
539
+
540
+ def search_notes(query: str) -> List[Dict]:
541
+ """Search notes. Returns list of {title, content, matches}."""
542
+ notes = _get_all_notes()
543
+ query_lower = query.lower()
544
+ results = []
545
+
546
+ for note in notes.values():
547
+ if query_lower in note["content"].lower():
548
+ results.append({
549
+ "title": note["title"],
550
+ "content": note["content"],
551
+ "tags": list(note["tags"]),
552
+ "links": list(note["links"])
553
+ })
554
+
555
+ return results
556
+
557
+
558
+ def get_linked_notes(title: str) -> Tuple[Set[str], Set[str]]:
559
+ """Get outgoing links and backlinks for a note."""
560
+ notes = _get_all_notes()
561
+ title_lower = title.lower()
562
+
563
+ if title_lower not in notes:
564
+ return set(), set()
565
+
566
+ outgoing = notes[title_lower]["links"]
567
+ backlinks = _get_backlinks(title, notes)
568
+
569
+ return outgoing, backlinks
570
+
571
+
572
+ def get_all_tags() -> Dict[str, List[str]]:
573
+ """Get all tags with notes that use them."""
574
+ notes = _get_all_notes()
575
+ tag_notes: Dict[str, List[str]] = {}
576
+
577
+ for note in notes.values():
578
+ for tag in note["tags"]:
579
+ if tag not in tag_notes:
580
+ tag_notes[tag] = []
581
+ tag_notes[tag].append(note["title"])
582
+
583
+ return tag_notes
@@ -0,0 +1,59 @@
1
+ # plugins/search_code.py — Búsqueda avanzada en código
2
+ import re
3
+ from pathlib import Path
4
+
5
+ NAME = "search_code"
6
+ DESCRIPTION = "Busca texto o regex en archivos del proyecto"
7
+ USAGE = "<término> [--ext .py] [--regex]"
8
+ AGENT_DOC = (
9
+ "Busca texto o regex en el código. "
10
+ "Ej: 'def mi_func', 'TODO --ext .py', 'import requests --regex'. "
11
+ "Retorna archivo, línea y contexto."
12
+ )
13
+
14
+ IGNORE = {".git","__pycache__","venv",".venv","node_modules","dist","build",".mypy_cache"}
15
+ EXTS = {".py",".js",".ts",".jsx",".tsx",".md",".txt",".yaml",".yml",".toml",
16
+ ".json",".ini",".cfg",".html",".css",".go",".rs",".java",".rb",".sh",".c",".cpp",".h"}
17
+ MAX = 60
18
+
19
+
20
+ def run(args: str = "") -> str:
21
+ if not args.strip():
22
+ return "Uso: search_code <término> [--ext .py] [--regex]"
23
+ use_regex = "--regex" in args
24
+ args = args.replace("--regex","").strip()
25
+ ext_m = re.search(r"--ext\s+(\S+)", args)
26
+ ext_filter = None
27
+ if ext_m:
28
+ ext_filter = ext_m.group(1)
29
+ args = args.replace(ext_m.group(0),"").strip()
30
+ term = args.strip()
31
+ if not term:
32
+ return "Indica un término de búsqueda."
33
+ root = Path(".").resolve()
34
+ found = []
35
+ for path in _files(root, ext_filter):
36
+ try:
37
+ text = path.read_text(encoding="utf-8", errors="replace")
38
+ except Exception:
39
+ continue
40
+ for i, line in enumerate(text.splitlines(), 1):
41
+ hit = bool(re.search(term, line)) if use_regex else term.lower() in line.lower()
42
+ if hit:
43
+ found.append(f"{path.relative_to(root)}:{i} {line.strip()}")
44
+ if len(found) >= MAX: break
45
+ if len(found) >= MAX: break
46
+ if not found:
47
+ return f"Sin resultados para '{term}'."
48
+ plus = "+" if len(found) == MAX else ""
49
+ return f"Resultados para '{term}' ({len(found)}{plus}):\n" + "\n".join(found)
50
+
51
+
52
+ def _files(root: Path, ext_filter):
53
+ for item in root.rglob("*"):
54
+ if not item.is_file(): continue
55
+ if any(p in item.parts for p in IGNORE): continue
56
+ if ext_filter:
57
+ if item.suffix.lower() != ext_filter.lower(): continue
58
+ elif item.suffix.lower() not in EXTS: continue
59
+ yield item