strix-agent 0.1.1__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 (99) hide show
  1. strix/__init__.py +0 -0
  2. strix/agents/StrixAgent/__init__.py +4 -0
  3. strix/agents/StrixAgent/strix_agent.py +60 -0
  4. strix/agents/StrixAgent/system_prompt.jinja +504 -0
  5. strix/agents/__init__.py +10 -0
  6. strix/agents/base_agent.py +394 -0
  7. strix/agents/state.py +139 -0
  8. strix/cli/__init__.py +4 -0
  9. strix/cli/app.py +1124 -0
  10. strix/cli/assets/cli.tcss +680 -0
  11. strix/cli/main.py +542 -0
  12. strix/cli/tool_components/__init__.py +39 -0
  13. strix/cli/tool_components/agents_graph_renderer.py +129 -0
  14. strix/cli/tool_components/base_renderer.py +61 -0
  15. strix/cli/tool_components/browser_renderer.py +107 -0
  16. strix/cli/tool_components/file_edit_renderer.py +95 -0
  17. strix/cli/tool_components/finish_renderer.py +32 -0
  18. strix/cli/tool_components/notes_renderer.py +108 -0
  19. strix/cli/tool_components/proxy_renderer.py +255 -0
  20. strix/cli/tool_components/python_renderer.py +34 -0
  21. strix/cli/tool_components/registry.py +72 -0
  22. strix/cli/tool_components/reporting_renderer.py +53 -0
  23. strix/cli/tool_components/scan_info_renderer.py +58 -0
  24. strix/cli/tool_components/terminal_renderer.py +99 -0
  25. strix/cli/tool_components/thinking_renderer.py +29 -0
  26. strix/cli/tool_components/user_message_renderer.py +43 -0
  27. strix/cli/tool_components/web_search_renderer.py +28 -0
  28. strix/cli/tracer.py +308 -0
  29. strix/llm/__init__.py +14 -0
  30. strix/llm/config.py +19 -0
  31. strix/llm/llm.py +310 -0
  32. strix/llm/memory_compressor.py +206 -0
  33. strix/llm/request_queue.py +63 -0
  34. strix/llm/utils.py +84 -0
  35. strix/prompts/__init__.py +113 -0
  36. strix/prompts/coordination/root_agent.jinja +41 -0
  37. strix/prompts/vulnerabilities/authentication_jwt.jinja +129 -0
  38. strix/prompts/vulnerabilities/business_logic.jinja +143 -0
  39. strix/prompts/vulnerabilities/csrf.jinja +168 -0
  40. strix/prompts/vulnerabilities/idor.jinja +164 -0
  41. strix/prompts/vulnerabilities/race_conditions.jinja +194 -0
  42. strix/prompts/vulnerabilities/rce.jinja +222 -0
  43. strix/prompts/vulnerabilities/sql_injection.jinja +216 -0
  44. strix/prompts/vulnerabilities/ssrf.jinja +168 -0
  45. strix/prompts/vulnerabilities/xss.jinja +221 -0
  46. strix/prompts/vulnerabilities/xxe.jinja +276 -0
  47. strix/runtime/__init__.py +19 -0
  48. strix/runtime/docker_runtime.py +298 -0
  49. strix/runtime/runtime.py +25 -0
  50. strix/runtime/tool_server.py +97 -0
  51. strix/tools/__init__.py +64 -0
  52. strix/tools/agents_graph/__init__.py +16 -0
  53. strix/tools/agents_graph/agents_graph_actions.py +610 -0
  54. strix/tools/agents_graph/agents_graph_actions_schema.xml +223 -0
  55. strix/tools/argument_parser.py +120 -0
  56. strix/tools/browser/__init__.py +4 -0
  57. strix/tools/browser/browser_actions.py +236 -0
  58. strix/tools/browser/browser_actions_schema.xml +183 -0
  59. strix/tools/browser/browser_instance.py +533 -0
  60. strix/tools/browser/tab_manager.py +342 -0
  61. strix/tools/executor.py +302 -0
  62. strix/tools/file_edit/__init__.py +4 -0
  63. strix/tools/file_edit/file_edit_actions.py +141 -0
  64. strix/tools/file_edit/file_edit_actions_schema.xml +128 -0
  65. strix/tools/finish/__init__.py +4 -0
  66. strix/tools/finish/finish_actions.py +167 -0
  67. strix/tools/finish/finish_actions_schema.xml +45 -0
  68. strix/tools/notes/__init__.py +14 -0
  69. strix/tools/notes/notes_actions.py +191 -0
  70. strix/tools/notes/notes_actions_schema.xml +150 -0
  71. strix/tools/proxy/__init__.py +20 -0
  72. strix/tools/proxy/proxy_actions.py +101 -0
  73. strix/tools/proxy/proxy_actions_schema.xml +267 -0
  74. strix/tools/proxy/proxy_manager.py +785 -0
  75. strix/tools/python/__init__.py +4 -0
  76. strix/tools/python/python_actions.py +47 -0
  77. strix/tools/python/python_actions_schema.xml +131 -0
  78. strix/tools/python/python_instance.py +172 -0
  79. strix/tools/python/python_manager.py +131 -0
  80. strix/tools/registry.py +196 -0
  81. strix/tools/reporting/__init__.py +6 -0
  82. strix/tools/reporting/reporting_actions.py +63 -0
  83. strix/tools/reporting/reporting_actions_schema.xml +30 -0
  84. strix/tools/terminal/__init__.py +4 -0
  85. strix/tools/terminal/terminal_actions.py +53 -0
  86. strix/tools/terminal/terminal_actions_schema.xml +114 -0
  87. strix/tools/terminal/terminal_instance.py +231 -0
  88. strix/tools/terminal/terminal_manager.py +191 -0
  89. strix/tools/thinking/__init__.py +4 -0
  90. strix/tools/thinking/thinking_actions.py +18 -0
  91. strix/tools/thinking/thinking_actions_schema.xml +52 -0
  92. strix/tools/web_search/__init__.py +4 -0
  93. strix/tools/web_search/web_search_actions.py +80 -0
  94. strix/tools/web_search/web_search_actions_schema.xml +83 -0
  95. strix_agent-0.1.1.dist-info/LICENSE +201 -0
  96. strix_agent-0.1.1.dist-info/METADATA +200 -0
  97. strix_agent-0.1.1.dist-info/RECORD +99 -0
  98. strix_agent-0.1.1.dist-info/WHEEL +4 -0
  99. strix_agent-0.1.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,141 @@
1
+ import json
2
+ import re
3
+ from pathlib import Path
4
+ from typing import Any, cast
5
+
6
+ from openhands_aci import file_editor
7
+ from openhands_aci.utils.shell import run_shell_cmd
8
+
9
+ from strix.tools.registry import register_tool
10
+
11
+
12
+ def _parse_file_editor_output(output: str) -> dict[str, Any]:
13
+ try:
14
+ pattern = r"<oh_aci_output_[^>]+>\n(.*?)\n</oh_aci_output_[^>]+>"
15
+ match = re.search(pattern, output, re.DOTALL)
16
+
17
+ if match:
18
+ json_str = match.group(1)
19
+ data = json.loads(json_str)
20
+ return cast("dict[str, Any]", data)
21
+ return {"output": output, "error": None}
22
+ except (json.JSONDecodeError, AttributeError):
23
+ return {"output": output, "error": None}
24
+
25
+
26
+ @register_tool
27
+ def str_replace_editor(
28
+ command: str,
29
+ path: str,
30
+ file_text: str | None = None,
31
+ view_range: list[int] | None = None,
32
+ old_str: str | None = None,
33
+ new_str: str | None = None,
34
+ insert_line: int | None = None,
35
+ ) -> dict[str, Any]:
36
+ try:
37
+ path_obj = Path(path)
38
+ if not path_obj.is_absolute():
39
+ path = str(Path("/workspace") / path_obj)
40
+
41
+ result = file_editor(
42
+ command=command,
43
+ path=path,
44
+ file_text=file_text,
45
+ view_range=view_range,
46
+ old_str=old_str,
47
+ new_str=new_str,
48
+ insert_line=insert_line,
49
+ )
50
+
51
+ parsed = _parse_file_editor_output(result)
52
+
53
+ if parsed.get("error"):
54
+ return {"error": parsed["error"]}
55
+
56
+ return {"content": parsed.get("output", result)}
57
+
58
+ except (OSError, ValueError) as e:
59
+ return {"error": f"Error in {command} operation: {e!s}"}
60
+
61
+
62
+ @register_tool
63
+ def list_files(
64
+ path: str,
65
+ recursive: bool = False,
66
+ ) -> dict[str, Any]:
67
+ try:
68
+ path_obj = Path(path)
69
+ if not path_obj.is_absolute():
70
+ path = str(Path("/workspace") / path_obj)
71
+ path_obj = Path(path)
72
+
73
+ if not path_obj.exists():
74
+ return {"error": f"Directory not found: {path}"}
75
+
76
+ if not path_obj.is_dir():
77
+ return {"error": f"Path is not a directory: {path}"}
78
+
79
+ cmd = f"find '{path}' -type f -o -type d | head -500" if recursive else f"ls -1a '{path}'"
80
+
81
+ exit_code, stdout, stderr = run_shell_cmd(cmd)
82
+
83
+ if exit_code != 0:
84
+ return {"error": f"Error listing directory: {stderr}"}
85
+
86
+ items = stdout.strip().split("\n") if stdout.strip() else []
87
+
88
+ files = []
89
+ dirs = []
90
+
91
+ for item in items:
92
+ item_path = item if recursive else str(Path(path) / item)
93
+ item_path_obj = Path(item_path)
94
+
95
+ if item_path_obj.is_file():
96
+ files.append(item)
97
+ elif item_path_obj.is_dir():
98
+ dirs.append(item)
99
+
100
+ return {
101
+ "files": sorted(files),
102
+ "directories": sorted(dirs),
103
+ "total_files": len(files),
104
+ "total_dirs": len(dirs),
105
+ "path": path,
106
+ "recursive": recursive,
107
+ }
108
+
109
+ except (OSError, ValueError) as e:
110
+ return {"error": f"Error listing directory: {e!s}"}
111
+
112
+
113
+ @register_tool
114
+ def search_files(
115
+ path: str,
116
+ regex: str,
117
+ file_pattern: str = "*",
118
+ ) -> dict[str, Any]:
119
+ try:
120
+ path_obj = Path(path)
121
+ if not path_obj.is_absolute():
122
+ path = str(Path("/workspace") / path_obj)
123
+
124
+ if not Path(path).exists():
125
+ return {"error": f"Directory not found: {path}"}
126
+
127
+ escaped_regex = regex.replace("'", "'\"'\"'")
128
+
129
+ cmd = f"rg --line-number --glob '{file_pattern}' '{escaped_regex}' '{path}'"
130
+
131
+ exit_code, stdout, stderr = run_shell_cmd(cmd)
132
+
133
+ if exit_code not in {0, 1}:
134
+ return {"error": f"Error searching files: {stderr}"}
135
+ return {"output": stdout if stdout else "No matches found"}
136
+
137
+ except (OSError, ValueError) as e:
138
+ return {"error": f"Error searching files: {e!s}"}
139
+
140
+
141
+ # ruff: noqa: TRY300
@@ -0,0 +1,128 @@
1
+ <tools>
2
+ <tool name="list_files">
3
+ <description>List files and directories within the specified directory.</description>
4
+ <parameters>
5
+ <parameter name="path" type="string" required="true">
6
+ <description>Directory path to list</description>
7
+ </parameter>
8
+ <parameter name="recursive" type="boolean" required="false">
9
+ <description>Whether to list files recursively</description>
10
+ </parameter>
11
+ </parameters>
12
+ <returns type="Dict[str, Any]">
13
+ <description>Response containing: - files: List of files and directories - total_files: Total number of files found - total_dirs: Total number of directories found</description>
14
+ </returns>
15
+ <notes>
16
+ - Lists contents alphabetically
17
+ - Returns maximum 500 results to avoid overwhelming output
18
+ </notes>
19
+ <examples>
20
+ # List directory contents
21
+ <function=list_files>
22
+ <parameter=path>/home/user/project/src</parameter>
23
+ </function>
24
+
25
+ # Recursive listing
26
+ <function=list_files>
27
+ <parameter=path>/home/user/project/src</parameter>
28
+ <parameter=recursive>true</parameter>
29
+ </function>
30
+ </examples>
31
+ </tool>
32
+ <tool name="search_files">
33
+ <description>Perform a regex search across files in a directory.</description>
34
+ <parameters>
35
+ <parameter name="path" type="string" required="true">
36
+ <description>Directory path to search</description>
37
+ </parameter>
38
+ <parameter name="regex" type="string" required="true">
39
+ <description>Regular expression pattern to search for</description>
40
+ </parameter>
41
+ <parameter name="file_pattern" type="string" required="false">
42
+ <description>File pattern to filter (e.g., "*.py", "*.js")</description>
43
+ </parameter>
44
+ </parameters>
45
+ <returns type="Dict[str, Any]">
46
+ <description>Response containing: - output: The search results as a string</description>
47
+ </returns>
48
+ <notes>
49
+ - Searches recursively through subdirectories
50
+ - Uses ripgrep for fast searching
51
+ </notes>
52
+ <examples>
53
+ # Search Python files for a pattern
54
+ <function=search_files>
55
+ <parameter=path>/home/user/project/src</parameter>
56
+ <parameter=regex>def\s+process_data</parameter>
57
+ <parameter=file_pattern>*.py</parameter>
58
+ </function>
59
+ </examples>
60
+ </tool>
61
+ <tool name="str_replace_editor">
62
+ <description>A text editor tool for viewing, creating and editing files.</description>
63
+ <parameters>
64
+ <parameter name="command" type="string" required="true">
65
+ <description>Editor command to execute</description>
66
+ </parameter>
67
+ <parameter name="path" type="string" required="true">
68
+ <description>Path to the file to edit</description>
69
+ </parameter>
70
+ <parameter name="file_text" type="string" required="false">
71
+ <description>Required parameter of create command, with the content of the file to be created</description>
72
+ </parameter>
73
+ <parameter name="view_range" type="string" required="false">
74
+ <description>Optional parameter of view command when path points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting [start_line, -1] shows all lines from start_line to the end of the file</description>
75
+ </parameter>
76
+ <parameter name="old_str" type="string" required="false">
77
+ <description>Required parameter of str_replace command containing the string in path to replace</description>
78
+ </parameter>
79
+ <parameter name="new_str" type="string" required="false">
80
+ <description>Optional parameter of str_replace command containing the new string (if not given, no string will be added). Required parameter of insert command containing the string to insert</description>
81
+ </parameter>
82
+ <parameter name="insert_line" type="string" required="false">
83
+ <description>Required parameter of insert command. The new_str will be inserted AFTER the line insert_line of path</description>
84
+ </parameter>
85
+ </parameters>
86
+ <returns type="Dict[str, Any]">
87
+ <description>Response containing the result of the operation</description>
88
+ </returns>
89
+ <notes>
90
+ Command details:
91
+ - view: Show file contents, optionally with line range
92
+ - create: Create a new file with given content
93
+ - str_replace: Replace old_str with new_str in file
94
+ - insert: Insert new_str after the specified line number
95
+ - undo_edit: Revert the last edit made to the file
96
+ </notes>
97
+ <examples>
98
+ # View a file
99
+ <function=str_replace_editor>
100
+ <parameter=command>view</parameter>
101
+ <parameter=path>/home/user/project/file.py</parameter>
102
+ </function>
103
+
104
+ # Create a file
105
+ <function=str_replace_editor>
106
+ <parameter=command>create</parameter>
107
+ <parameter=path>/home/user/project/new_file.py</parameter>
108
+ <parameter=file_text>print("Hello World")</parameter>
109
+ </function>
110
+
111
+ # Replace text in file
112
+ <function=str_replace_editor>
113
+ <parameter=command>str_replace</parameter>
114
+ <parameter=path>/home/user/project/file.py</parameter>
115
+ <parameter=old_str>old_function()</parameter>
116
+ <parameter=new_str>new_function()</parameter>
117
+ </function>
118
+
119
+ # Insert text after line 10
120
+ <function=str_replace_editor>
121
+ <parameter=command>insert</parameter>
122
+ <parameter=path>/home/user/project/file.py</parameter>
123
+ <parameter=insert_line>10</parameter>
124
+ <parameter=new_str>print("Inserted line")</parameter>
125
+ </function>
126
+ </examples>
127
+ </tool>
128
+ </tools>
@@ -0,0 +1,4 @@
1
+ from .finish_actions import finish_scan
2
+
3
+
4
+ __all__ = ["finish_scan"]
@@ -0,0 +1,167 @@
1
+ from typing import Any
2
+
3
+ from strix.tools.registry import register_tool
4
+
5
+
6
+ def _validate_root_agent(agent_state: Any) -> dict[str, Any] | None:
7
+ if (
8
+ agent_state is not None
9
+ and hasattr(agent_state, "parent_id")
10
+ and agent_state.parent_id is not None
11
+ ):
12
+ return {
13
+ "success": False,
14
+ "message": (
15
+ "This tool can only be used by the root/main agent. "
16
+ "Subagents must use agent_finish instead."
17
+ ),
18
+ }
19
+ return None
20
+
21
+
22
+ def _validate_content(content: str) -> dict[str, Any] | None:
23
+ if not content or not content.strip():
24
+ return {"success": False, "message": "Content cannot be empty"}
25
+ return None
26
+
27
+
28
+ def _check_active_agents() -> dict[str, Any] | None:
29
+ try:
30
+ from strix.tools.agents_graph.agents_graph_actions import _agent_graph
31
+
32
+ running_agents = []
33
+ stopping_agents = []
34
+
35
+ for agent_id, node in _agent_graph.get("nodes", {}).items():
36
+ status = node.get("status", "")
37
+ if status == "running":
38
+ running_agents.append(
39
+ {
40
+ "id": agent_id,
41
+ "name": node.get("name", "Unknown"),
42
+ "task": node.get("task", "No task description"),
43
+ }
44
+ )
45
+ elif status == "stopping":
46
+ stopping_agents.append(
47
+ {
48
+ "id": agent_id,
49
+ "name": node.get("name", "Unknown"),
50
+ }
51
+ )
52
+
53
+ if running_agents or stopping_agents:
54
+ message_parts = ["Cannot finish scan while agents are still active:"]
55
+
56
+ if running_agents:
57
+ message_parts.append("\n\nRunning agents:")
58
+ message_parts.extend(
59
+ [
60
+ f" - {agent['name']} ({agent['id']}): {agent['task']}"
61
+ for agent in running_agents
62
+ ]
63
+ )
64
+
65
+ if stopping_agents:
66
+ message_parts.append("\n\nStopping agents:")
67
+ message_parts.extend(
68
+ [f" - {agent['name']} ({agent['id']})" for agent in stopping_agents]
69
+ )
70
+
71
+ message_parts.extend(
72
+ [
73
+ "\n\nSuggested actions:",
74
+ "1. Use wait_for_message to wait for all agents to complete",
75
+ "2. Send messages to agents asking them to finish if urgent",
76
+ "3. Use view_agent_graph to monitor agent status",
77
+ ]
78
+ )
79
+
80
+ return {
81
+ "success": False,
82
+ "message": "\n".join(message_parts),
83
+ "active_agents": {
84
+ "running": len(running_agents),
85
+ "stopping": len(stopping_agents),
86
+ "details": {
87
+ "running": running_agents,
88
+ "stopping": stopping_agents,
89
+ },
90
+ },
91
+ }
92
+
93
+ except ImportError:
94
+ import logging
95
+
96
+ logging.warning("Could not check agent graph status - agents_graph module unavailable")
97
+
98
+ return None
99
+
100
+
101
+ def _finalize_with_tracer(content: str, success: bool) -> dict[str, Any]:
102
+ try:
103
+ from strix.cli.tracer import get_global_tracer
104
+
105
+ tracer = get_global_tracer()
106
+ if tracer:
107
+ tracer.set_final_scan_result(
108
+ content=content.strip(),
109
+ success=success,
110
+ )
111
+
112
+ return {
113
+ "success": True,
114
+ "scan_completed": True,
115
+ "message": "Scan completed successfully"
116
+ if success
117
+ else "Scan completed with errors",
118
+ "vulnerabilities_found": len(tracer.vulnerability_reports),
119
+ }
120
+
121
+ import logging
122
+
123
+ logging.warning("Global tracer not available - final scan result not stored")
124
+
125
+ return { # noqa: TRY300
126
+ "success": True,
127
+ "scan_completed": True,
128
+ "message": "Scan completed successfully (not persisted)"
129
+ if success
130
+ else "Scan completed with errors (not persisted)",
131
+ "warning": "Final result could not be persisted - tracer unavailable",
132
+ }
133
+
134
+ except ImportError:
135
+ return {
136
+ "success": True,
137
+ "scan_completed": True,
138
+ "message": "Scan completed successfully (not persisted)"
139
+ if success
140
+ else "Scan completed with errors (not persisted)",
141
+ "warning": "Final result could not be persisted - tracer module unavailable",
142
+ }
143
+
144
+
145
+ @register_tool(sandbox_execution=False)
146
+ def finish_scan(
147
+ content: str,
148
+ success: bool = True,
149
+ agent_state: Any = None,
150
+ ) -> dict[str, Any]:
151
+ try:
152
+ validation_error = _validate_root_agent(agent_state)
153
+ if validation_error:
154
+ return validation_error
155
+
156
+ validation_error = _validate_content(content)
157
+ if validation_error:
158
+ return validation_error
159
+
160
+ active_agents_error = _check_active_agents()
161
+ if active_agents_error:
162
+ return active_agents_error
163
+
164
+ return _finalize_with_tracer(content, success)
165
+
166
+ except (ValueError, TypeError, KeyError) as e:
167
+ return {"success": False, "message": f"Failed to complete scan: {e!s}"}
@@ -0,0 +1,45 @@
1
+ <tools>
2
+ <tool name="finish_scan">
3
+ <description>Complete the main security scan and generate final report.
4
+
5
+ IMPORTANT: This tool can ONLY be used by the root/main agent.
6
+ Subagents must use agent_finish from agents_graph tool instead.
7
+
8
+ IMPORTANT: This tool will NOT allow finishing if any agents are still running or stopping.
9
+ You must wait for all agents to complete before using this tool.
10
+
11
+ This tool MUST be called at the very end of the security assessment to:
12
+ - Verify all agents have completed their tasks
13
+ - Generate the final comprehensive scan report
14
+ - Mark the entire scan as completed
15
+ - Stop the agent from running
16
+
17
+ Use this tool when:
18
+ - You are the main/root agent conducting the security assessment
19
+ - ALL subagents have completed their tasks (no agents are "running" or "stopping")
20
+ - You have completed all testing phases
21
+ - You are ready to conclude the entire security assessment
22
+
23
+ IMPORTANT: Calling this tool multiple times will OVERWRITE any previous scan report.
24
+ Make sure you include ALL findings and details in a single comprehensive report.
25
+
26
+ If agents are still running, this tool will:
27
+ - Show you which agents are still active
28
+ - Suggest using wait_for_message to wait for completion
29
+ - Suggest messaging agents if immediate completion is needed
30
+
31
+ Put ALL details in the content - methodology, tools used, vulnerability counts, key findings, recommendations,
32
+ compliance notes, risk assessments, next steps, etc. Be comprehensive and include everything relevant.</description>
33
+ <parameters>
34
+ <parameter name="content" type="string" required="true">
35
+ <description>Complete scan report including executive summary, methodology, findings, vulnerability details, recommendations, compliance notes, risk assessment, and conclusions. Include everything relevant to the assessment.</description>
36
+ </parameter>
37
+ <parameter name="success" type="boolean" required="false">
38
+ <description>Whether the scan completed successfully without critical errors</description>
39
+ </parameter>
40
+ </parameters>
41
+ <returns type="Dict[str, Any]">
42
+ <description>Response containing success status and completion message. If agents are still running, returns details about active agents and suggested actions.</description>
43
+ </returns>
44
+ </tool>
45
+ </tools>
@@ -0,0 +1,14 @@
1
+ from .notes_actions import (
2
+ create_note,
3
+ delete_note,
4
+ list_notes,
5
+ update_note,
6
+ )
7
+
8
+
9
+ __all__ = [
10
+ "create_note",
11
+ "delete_note",
12
+ "list_notes",
13
+ "update_note",
14
+ ]
@@ -0,0 +1,191 @@
1
+ import uuid
2
+ from datetime import UTC, datetime
3
+ from typing import Any
4
+
5
+ from strix.tools.registry import register_tool
6
+
7
+
8
+ _notes_storage: dict[str, dict[str, Any]] = {}
9
+
10
+
11
+ def _filter_notes(
12
+ category: str | None = None,
13
+ tags: list[str] | None = None,
14
+ priority: str | None = None,
15
+ search_query: str | None = None,
16
+ ) -> list[dict[str, Any]]:
17
+ filtered_notes = []
18
+
19
+ for note_id, note in _notes_storage.items():
20
+ if category and note.get("category") != category:
21
+ continue
22
+
23
+ if priority and note.get("priority") != priority:
24
+ continue
25
+
26
+ if tags:
27
+ note_tags = note.get("tags", [])
28
+ if not any(tag in note_tags for tag in tags):
29
+ continue
30
+
31
+ if search_query:
32
+ search_lower = search_query.lower()
33
+ title_match = search_lower in note.get("title", "").lower()
34
+ content_match = search_lower in note.get("content", "").lower()
35
+ if not (title_match or content_match):
36
+ continue
37
+
38
+ note_with_id = note.copy()
39
+ note_with_id["note_id"] = note_id
40
+ filtered_notes.append(note_with_id)
41
+
42
+ filtered_notes.sort(key=lambda x: x.get("created_at", ""), reverse=True)
43
+ return filtered_notes
44
+
45
+
46
+ @register_tool
47
+ def create_note(
48
+ title: str,
49
+ content: str,
50
+ category: str = "general",
51
+ tags: list[str] | None = None,
52
+ priority: str = "normal",
53
+ ) -> dict[str, Any]:
54
+ try:
55
+ if not title or not title.strip():
56
+ return {"success": False, "error": "Title cannot be empty", "note_id": None}
57
+
58
+ if not content or not content.strip():
59
+ return {"success": False, "error": "Content cannot be empty", "note_id": None}
60
+
61
+ valid_categories = ["general", "findings", "methodology", "todo", "questions", "plan"]
62
+ if category not in valid_categories:
63
+ return {
64
+ "success": False,
65
+ "error": f"Invalid category. Must be one of: {', '.join(valid_categories)}",
66
+ "note_id": None,
67
+ }
68
+
69
+ valid_priorities = ["low", "normal", "high", "urgent"]
70
+ if priority not in valid_priorities:
71
+ return {
72
+ "success": False,
73
+ "error": f"Invalid priority. Must be one of: {', '.join(valid_priorities)}",
74
+ "note_id": None,
75
+ }
76
+
77
+ note_id = str(uuid.uuid4())[:5]
78
+ timestamp = datetime.now(UTC).isoformat()
79
+
80
+ note = {
81
+ "title": title.strip(),
82
+ "content": content.strip(),
83
+ "category": category,
84
+ "tags": tags or [],
85
+ "priority": priority,
86
+ "created_at": timestamp,
87
+ "updated_at": timestamp,
88
+ }
89
+
90
+ _notes_storage[note_id] = note
91
+
92
+ except (ValueError, TypeError) as e:
93
+ return {"success": False, "error": f"Failed to create note: {e}", "note_id": None}
94
+ else:
95
+ return {
96
+ "success": True,
97
+ "note_id": note_id,
98
+ "message": f"Note '{title}' created successfully",
99
+ }
100
+
101
+
102
+ @register_tool
103
+ def list_notes(
104
+ category: str | None = None,
105
+ tags: list[str] | None = None,
106
+ priority: str | None = None,
107
+ search: str | None = None,
108
+ ) -> dict[str, Any]:
109
+ try:
110
+ filtered_notes = _filter_notes(
111
+ category=category, tags=tags, priority=priority, search_query=search
112
+ )
113
+
114
+ return {
115
+ "success": True,
116
+ "notes": filtered_notes,
117
+ "total_count": len(filtered_notes),
118
+ }
119
+
120
+ except (ValueError, TypeError) as e:
121
+ return {
122
+ "success": False,
123
+ "error": f"Failed to list notes: {e}",
124
+ "notes": [],
125
+ "total_count": 0,
126
+ }
127
+
128
+
129
+ @register_tool
130
+ def update_note(
131
+ note_id: str,
132
+ title: str | None = None,
133
+ content: str | None = None,
134
+ tags: list[str] | None = None,
135
+ priority: str | None = None,
136
+ ) -> dict[str, Any]:
137
+ try:
138
+ if note_id not in _notes_storage:
139
+ return {"success": False, "error": f"Note with ID '{note_id}' not found"}
140
+
141
+ note = _notes_storage[note_id]
142
+
143
+ if title is not None:
144
+ if not title.strip():
145
+ return {"success": False, "error": "Title cannot be empty"}
146
+ note["title"] = title.strip()
147
+
148
+ if content is not None:
149
+ if not content.strip():
150
+ return {"success": False, "error": "Content cannot be empty"}
151
+ note["content"] = content.strip()
152
+
153
+ if tags is not None:
154
+ note["tags"] = tags
155
+
156
+ if priority is not None:
157
+ valid_priorities = ["low", "normal", "high", "urgent"]
158
+ if priority not in valid_priorities:
159
+ return {
160
+ "success": False,
161
+ "error": f"Invalid priority. Must be one of: {', '.join(valid_priorities)}",
162
+ }
163
+ note["priority"] = priority
164
+
165
+ note["updated_at"] = datetime.now(UTC).isoformat()
166
+
167
+ return {
168
+ "success": True,
169
+ "message": f"Note '{note['title']}' updated successfully",
170
+ }
171
+
172
+ except (ValueError, TypeError) as e:
173
+ return {"success": False, "error": f"Failed to update note: {e}"}
174
+
175
+
176
+ @register_tool
177
+ def delete_note(note_id: str) -> dict[str, Any]:
178
+ try:
179
+ if note_id not in _notes_storage:
180
+ return {"success": False, "error": f"Note with ID '{note_id}' not found"}
181
+
182
+ note_title = _notes_storage[note_id]["title"]
183
+ del _notes_storage[note_id]
184
+
185
+ except (ValueError, TypeError) as e:
186
+ return {"success": False, "error": f"Failed to delete note: {e}"}
187
+ else:
188
+ return {
189
+ "success": True,
190
+ "message": f"Note '{note_title}' deleted successfully",
191
+ }