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.
- hanus/__init__.py +5 -0
- hanus/__main__.py +10 -0
- hanus/action_handlers.py +76 -0
- hanus/action_parser.py +82 -0
- hanus/agent_runner.py +1445 -0
- hanus/analysis/__init__.py +5 -0
- hanus/analysis/debt.py +702 -0
- hanus/analysis/dependencies.py +475 -0
- hanus/cache/__init__.py +5 -0
- hanus/cache/response_cache.py +560 -0
- hanus/config.py +401 -0
- hanus/connectors/__init__.py +19 -0
- hanus/connectors/base.py +114 -0
- hanus/connectors/claude_connector.py +146 -0
- hanus/connectors/gemini_connector.py +141 -0
- hanus/connectors/glm_connector.py +160 -0
- hanus/connectors/ollama_connector.py +174 -0
- hanus/connectors/openai_connector.py +122 -0
- hanus/connectors/registry.py +26 -0
- hanus/context/__init__.py +7 -0
- hanus/context/manager.py +837 -0
- hanus/context/selective.py +626 -0
- hanus/error_recovery/__init__.py +5 -0
- hanus/error_recovery/auto_fix.py +605 -0
- hanus/hooks/__init__.py +5 -0
- hanus/hooks/manager.py +247 -0
- hanus/instincts/__init__.py +44 -0
- hanus/instincts/cli.py +372 -0
- hanus/instincts/detector.py +281 -0
- hanus/instincts/evolver.py +361 -0
- hanus/instincts/manager.py +343 -0
- hanus/instincts/types.py +253 -0
- hanus/logger.py +81 -0
- hanus/memory/__init__.py +8 -0
- hanus/memory/manager.py +265 -0
- hanus/memory/types.py +119 -0
- hanus/monitor.py +341 -0
- hanus/parallel/__init__.py +5 -0
- hanus/parallel/executor.py +300 -0
- hanus/permissions.py +182 -0
- hanus/plan/__init__.py +8 -0
- hanus/plan/mode.py +267 -0
- hanus/plan/models.py +152 -0
- hanus/plugin_manager.py +754 -0
- hanus/plugin_registry.py +391 -0
- hanus/plugins/__init__.py +1 -0
- hanus/plugins/arena.py +630 -0
- hanus/plugins/code_review.py +123 -0
- hanus/plugins/cortex.py +1750 -0
- hanus/plugins/deps_check.py +27 -0
- hanus/plugins/git_ops.py +33 -0
- hanus/plugins/metasploit.py +530 -0
- hanus/plugins/notes.py +583 -0
- hanus/plugins/search_code.py +59 -0
- hanus/plugins/searchsploit.py +495 -0
- hanus/plugins/strategist.py +175 -0
- hanus/plugins/webui.py +5200 -0
- hanus/profiles.py +479 -0
- hanus/profiles_builtin/__init__.py +0 -0
- hanus/profiles_builtin/architect/profile.yaml +12 -0
- hanus/profiles_builtin/architect/system_prompt.txt +71 -0
- hanus/profiles_builtin/deep/profile.yaml +12 -0
- hanus/profiles_builtin/deep/system_prompt.txt +66 -0
- hanus/profiles_builtin/developer/__init__.py +0 -0
- hanus/profiles_builtin/developer/profile.yaml +9 -0
- hanus/profiles_builtin/developer/system_prompt.txt +176 -0
- hanus/profiles_builtin/speed/profile.yaml +12 -0
- hanus/profiles_builtin/speed/system_prompt.txt +51 -0
- hanus/project_tools.py +177 -0
- hanus/query_engine.py +1594 -0
- hanus/rules/__init__.py +237 -0
- hanus/search/__init__.py +5 -0
- hanus/search/semantic.py +596 -0
- hanus/session_manager.py +547 -0
- hanus/skill_manager.py +702 -0
- hanus/skills/__init__.py +4 -0
- hanus/subagent/__init__.py +8 -0
- hanus/subagent/agents/__init__.py +253 -0
- hanus/subagent/manager.py +309 -0
- hanus/subagent/types.py +266 -0
- hanus/suggestions/__init__.py +5 -0
- hanus/suggestions/proactive.py +451 -0
- hanus/tasks/__init__.py +8 -0
- hanus/tasks/manager.py +330 -0
- hanus/tasks/models.py +106 -0
- hanus/terminal_prompt.py +166 -0
- hanus/tools.py +1849 -0
- hanus/ui.py +939 -0
- hanuscode-1.0.0.dist-info/METADATA +1151 -0
- hanuscode-1.0.0.dist-info/RECORD +93 -0
- hanuscode-1.0.0.dist-info/WHEEL +5 -0
- hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|