openwiki-py 0.1.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.
- openwiki_py/__init__.py +1 -0
- openwiki_py/__main__.py +4 -0
- openwiki_py/agent/docs_only_backend.py +66 -0
- openwiki_py/agent/frontmatter.py +295 -0
- openwiki_py/agent/index_middleware.py +409 -0
- openwiki_py/agent/prompt.py +471 -0
- openwiki_py/agent/providers.py +69 -0
- openwiki_py/agent/runtime.py +333 -0
- openwiki_py/agent/skills.py +50 -0
- openwiki_py/agent/stream_adapter.py +123 -0
- openwiki_py/agent/types.py +63 -0
- openwiki_py/agent/utils.py +351 -0
- openwiki_py/auth/configure.py +167 -0
- openwiki_py/auth/ngrok.py +184 -0
- openwiki_py/auth/oauth.py +414 -0
- openwiki_py/auth/providers.py +106 -0
- openwiki_py/auth/tokens.py +136 -0
- openwiki_py/cli.py +197 -0
- openwiki_py/code_mode.py +143 -0
- openwiki_py/commands.py +579 -0
- openwiki_py/connectors/io.py +89 -0
- openwiki_py/connectors/mcp_client.py +450 -0
- openwiki_py/connectors/mcp_runtime.py +213 -0
- openwiki_py/connectors/registry.py +63 -0
- openwiki_py/connectors/sources/git_repo.py +140 -0
- openwiki_py/connectors/sources/google.py +283 -0
- openwiki_py/connectors/sources/hackernews.py +183 -0
- openwiki_py/connectors/sources/mcp.py +164 -0
- openwiki_py/connectors/sources/slack.py +421 -0
- openwiki_py/connectors/sources/web_search.py +192 -0
- openwiki_py/connectors/sources/x.py +289 -0
- openwiki_py/connectors/tools.py +223 -0
- openwiki_py/constants.py +441 -0
- openwiki_py/credentials.py +136 -0
- openwiki_py/data/skills/migrate-wiki-to-okf/SKILL.md +44 -0
- openwiki_py/data/skills/write-connector/SKILL.md +45 -0
- openwiki_py/diagnostics.py +112 -0
- openwiki_py/env.py +410 -0
- openwiki_py/fs_errors.py +11 -0
- openwiki_py/ingestion.py +366 -0
- openwiki_py/onboarding.py +282 -0
- openwiki_py/openwiki_home.py +72 -0
- openwiki_py/schedules.py +228 -0
- openwiki_py/startup.py +67 -0
- openwiki_py/telemetry/__init__.py +23 -0
- openwiki_py/telemetry/client.py +35 -0
- openwiki_py/telemetry/config.py +23 -0
- openwiki_py/telemetry/errors.py +47 -0
- openwiki_py/telemetry/gates.py +48 -0
- openwiki_py/telemetry/install_id.py +42 -0
- openwiki_py/telemetry/record_run_safe.py +28 -0
- openwiki_py/telemetry/senders.py +101 -0
- openwiki_py/tui/app.py +427 -0
- openwiki_py/tui/chat.py +33 -0
- openwiki_py/tui/menus.py +46 -0
- openwiki_py/tui/onboarding/ingestion.py +226 -0
- openwiki_py/tui/onboarding/oauth.py +121 -0
- openwiki_py/tui/onboarding/screens.py +331 -0
- openwiki_py/tui/print_mode.py +62 -0
- openwiki_py/tui/run_log.py +16 -0
- openwiki_py/tui/slash.py +31 -0
- openwiki_py/tui/widgets.py +34 -0
- openwiki_py/tui/workers.py +11 -0
- openwiki_py-0.1.0.dist-info/METADATA +158 -0
- openwiki_py-0.1.0.dist-info/RECORD +68 -0
- openwiki_py-0.1.0.dist-info/WHEEL +4 -0
- openwiki_py-0.1.0.dist-info/entry_points.txt +2 -0
- openwiki_py-0.1.0.dist-info/licenses/LICENSE +21 -0
openwiki_py/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
openwiki_py/__main__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
from deepagents.backends import LocalShellBackend
|
|
3
|
+
from deepagents.backends.protocol import WriteResult, EditResult
|
|
4
|
+
from openwiki_py.constants import OPEN_WIKI_DIR
|
|
5
|
+
|
|
6
|
+
MUTATION_PATH_METADATA_KEY = "openwikiMutationPath"
|
|
7
|
+
|
|
8
|
+
class OpenWikiLocalShellBackend(LocalShellBackend):
|
|
9
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
10
|
+
docs_only = kwargs.pop("docsOnly", kwargs.pop("docs_only", False))
|
|
11
|
+
output_mode = kwargs.pop("outputMode", kwargs.pop("output_mode", "repository"))
|
|
12
|
+
|
|
13
|
+
# Map camelCase to snake_case for deepagents LocalShellBackend
|
|
14
|
+
if "rootDir" in kwargs:
|
|
15
|
+
kwargs["root_dir"] = kwargs.pop("rootDir")
|
|
16
|
+
if "virtualMode" in kwargs:
|
|
17
|
+
kwargs["virtual_mode"] = kwargs.pop("virtualMode")
|
|
18
|
+
|
|
19
|
+
super().__init__(*args, **kwargs)
|
|
20
|
+
self.docs_only = docs_only
|
|
21
|
+
self.output_mode = output_mode
|
|
22
|
+
|
|
23
|
+
def write(self, file_path: str, content: str) -> WriteResult:
|
|
24
|
+
error = self.get_docs_only_write_error(file_path)
|
|
25
|
+
if error:
|
|
26
|
+
return WriteResult(error=error)
|
|
27
|
+
return mark_mutation(super().write(file_path, content), file_path)
|
|
28
|
+
|
|
29
|
+
def edit(
|
|
30
|
+
self,
|
|
31
|
+
file_path: str,
|
|
32
|
+
old_string: str,
|
|
33
|
+
new_string: str,
|
|
34
|
+
replace_all: bool = False,
|
|
35
|
+
) -> EditResult:
|
|
36
|
+
error = self.get_docs_only_write_error(file_path)
|
|
37
|
+
if error:
|
|
38
|
+
return EditResult(error=error)
|
|
39
|
+
return mark_mutation(
|
|
40
|
+
super().edit(file_path, old_string, new_string, replace_all),
|
|
41
|
+
file_path,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def get_docs_only_write_error(self, file_path: str) -> Optional[str]:
|
|
45
|
+
if (
|
|
46
|
+
not self.docs_only
|
|
47
|
+
or self.output_mode == "local-wiki"
|
|
48
|
+
or is_openwiki_docs_path(file_path)
|
|
49
|
+
):
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
return f"OpenWiki repository init/update runs may only write under /{OPEN_WIKI_DIR}/. Refused path: {file_path}"
|
|
53
|
+
|
|
54
|
+
def mark_mutation(result: Any, file_path: str) -> Any:
|
|
55
|
+
if hasattr(result, "error") and not result.error:
|
|
56
|
+
if not hasattr(result, "metadata") or result.metadata is None:
|
|
57
|
+
result.metadata = {}
|
|
58
|
+
result.metadata[MUTATION_PATH_METADATA_KEY] = getattr(result, "path", None) or file_path
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
def is_openwiki_docs_path(file_path: str) -> bool:
|
|
62
|
+
normalized = file_path.strip().replace("\\", "/")
|
|
63
|
+
virtual_path = normalized.lstrip("/")
|
|
64
|
+
return (
|
|
65
|
+
virtual_path == OPEN_WIKI_DIR or virtual_path.startswith(f"{OPEN_WIKI_DIR}/")
|
|
66
|
+
)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import yaml
|
|
4
|
+
import asyncio
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
from langchain_core.messages import ToolMessage
|
|
7
|
+
from openwiki_py.constants import OPEN_WIKI_DIR
|
|
8
|
+
from openwiki_py.agent.types import OpenWikiOutputMode
|
|
9
|
+
from openwiki_py.agent.docs_only_backend import MUTATION_PATH_METADATA_KEY
|
|
10
|
+
|
|
11
|
+
OKF_STRING_FIELDS = [
|
|
12
|
+
"type",
|
|
13
|
+
"title",
|
|
14
|
+
"description",
|
|
15
|
+
"resource",
|
|
16
|
+
"timestamp",
|
|
17
|
+
]
|
|
18
|
+
OKF_RESERVED_FILES = {"index.md", "log.md"}
|
|
19
|
+
WRITE_TOOLS = {"write_file", "edit_file"}
|
|
20
|
+
|
|
21
|
+
class UniqueKeyLoader(yaml.SafeLoader):
|
|
22
|
+
def construct_mapping(self, node: Any, deep: bool = False) -> Any:
|
|
23
|
+
mapping: Dict[Any, Any] = {}
|
|
24
|
+
for key_node, value_node in node.value:
|
|
25
|
+
key = self.construct_object(key_node, deep=deep)
|
|
26
|
+
if key in mapping:
|
|
27
|
+
raise yaml.constructor.ConstructorError(
|
|
28
|
+
"while constructing a mapping",
|
|
29
|
+
node.start_mark,
|
|
30
|
+
f"found duplicate key {key}",
|
|
31
|
+
key_node.start_mark
|
|
32
|
+
)
|
|
33
|
+
value = self.construct_object(value_node, deep=deep)
|
|
34
|
+
mapping[key] = value
|
|
35
|
+
return super().construct_mapping(node, deep=deep)
|
|
36
|
+
|
|
37
|
+
def validate_okf_frontmatter(content: str) -> Dict[str, Any]:
|
|
38
|
+
lines = content.splitlines()
|
|
39
|
+
if not lines or lines[0] != "---":
|
|
40
|
+
return invalid(
|
|
41
|
+
"missing_opening_delimiter",
|
|
42
|
+
"File must begin with `---`.",
|
|
43
|
+
1,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
closing_line = lines.index("---", 1)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return invalid(
|
|
50
|
+
"missing_closing_delimiter",
|
|
51
|
+
"Opening front matter has no closing `---` delimiter.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
yaml_content = "\n".join(lines[1:closing_line])
|
|
56
|
+
fields = yaml.load(yaml_content, Loader=UniqueKeyLoader)
|
|
57
|
+
except yaml.YAMLError as error:
|
|
58
|
+
msg = str(error)
|
|
59
|
+
if hasattr(error, "problem_mark") and error.problem_mark is not None:
|
|
60
|
+
# Shift 0-indexed line number in frontmatter to 1-indexed original file line
|
|
61
|
+
# Line 0 in frontmatter is Line 2 in file.
|
|
62
|
+
msg = re.sub(
|
|
63
|
+
r"\bline (\d+)\b",
|
|
64
|
+
lambda m: f"line {int(m.group(1)) + 1}",
|
|
65
|
+
msg,
|
|
66
|
+
)
|
|
67
|
+
return invalid("invalid_yaml", msg)
|
|
68
|
+
|
|
69
|
+
if not isinstance(fields, dict):
|
|
70
|
+
return invalid("invalid_yaml_root", "Front matter must be a YAML mapping.")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Ensure list helper methods support issues list
|
|
76
|
+
issues_list: List[Dict[str, Any]] = []
|
|
77
|
+
|
|
78
|
+
if "type" not in fields:
|
|
79
|
+
issues_list.append(issue("missing_type", "Required field `type` is missing."))
|
|
80
|
+
|
|
81
|
+
for field in OKF_STRING_FIELDS:
|
|
82
|
+
if field in fields:
|
|
83
|
+
val = fields[field]
|
|
84
|
+
if not isinstance(val, str) or not val.strip():
|
|
85
|
+
issues_list.append(
|
|
86
|
+
issue(
|
|
87
|
+
f"invalid_{field}",
|
|
88
|
+
f"Field `{field}` must be a non-empty string.",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if "tags" in fields:
|
|
93
|
+
tags = fields["tags"]
|
|
94
|
+
if not isinstance(tags, list) or any(not isinstance(tag, str) or not tag.strip() for tag in tags):
|
|
95
|
+
issues_list.append(
|
|
96
|
+
issue(
|
|
97
|
+
"invalid_tags",
|
|
98
|
+
"Field `tags` must be a YAML list of non-empty strings.",
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return {"valid": True} if not issues_list else {"issues": issues_list, "valid": False}
|
|
103
|
+
|
|
104
|
+
async def add_frontmatter_warning(
|
|
105
|
+
result: Any,
|
|
106
|
+
backend: Any,
|
|
107
|
+
output_mode: OpenWikiOutputMode,
|
|
108
|
+
tool_name: str,
|
|
109
|
+
) -> Any:
|
|
110
|
+
if tool_name not in WRITE_TOOLS:
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
mutations = []
|
|
114
|
+
for msg in get_tool_messages(result):
|
|
115
|
+
metadata = getattr(msg, "metadata", None)
|
|
116
|
+
if isinstance(metadata, dict):
|
|
117
|
+
path_val = metadata.get(MUTATION_PATH_METADATA_KEY)
|
|
118
|
+
if isinstance(path_val, str) and is_wiki_markdown_path(path_val, output_mode):
|
|
119
|
+
mutations.append((msg, path_val))
|
|
120
|
+
|
|
121
|
+
if not mutations:
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
for msg, path_val in mutations:
|
|
125
|
+
validation = await validate_persisted_file(backend, path_val)
|
|
126
|
+
if not validation["valid"]:
|
|
127
|
+
warning = format_warning(path_val, validation["issues"])
|
|
128
|
+
if isinstance(msg.content, str):
|
|
129
|
+
msg.content = f"{msg.content}\n\n{warning}"
|
|
130
|
+
elif isinstance(msg.content, list):
|
|
131
|
+
msg.content.append({"text": warning, "type": "text"})
|
|
132
|
+
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
def add_frontmatter_warning_sync(
|
|
136
|
+
result: Any,
|
|
137
|
+
backend: Any,
|
|
138
|
+
output_mode: OpenWikiOutputMode,
|
|
139
|
+
tool_name: str,
|
|
140
|
+
) -> Any:
|
|
141
|
+
if tool_name not in WRITE_TOOLS:
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
mutations = []
|
|
145
|
+
for msg in get_tool_messages(result):
|
|
146
|
+
metadata = getattr(msg, "metadata", None)
|
|
147
|
+
if isinstance(metadata, dict):
|
|
148
|
+
path_val = metadata.get(MUTATION_PATH_METADATA_KEY)
|
|
149
|
+
if isinstance(path_val, str) and is_wiki_markdown_path(path_val, output_mode):
|
|
150
|
+
mutations.append((msg, path_val))
|
|
151
|
+
|
|
152
|
+
if not mutations:
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
for msg, path_val in mutations:
|
|
156
|
+
validation = validate_persisted_file_sync(backend, path_val)
|
|
157
|
+
if not validation["valid"]:
|
|
158
|
+
warning = format_warning(path_val, validation["issues"])
|
|
159
|
+
if isinstance(msg.content, str):
|
|
160
|
+
msg.content = f"{msg.content}\n\n{warning}"
|
|
161
|
+
elif isinstance(msg.content, list):
|
|
162
|
+
msg.content.append({"text": warning, "type": "text"})
|
|
163
|
+
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
async def validate_persisted_file(
|
|
167
|
+
backend: Any,
|
|
168
|
+
file_path: str,
|
|
169
|
+
) -> Dict[str, Any]:
|
|
170
|
+
content = None
|
|
171
|
+
error = None
|
|
172
|
+
|
|
173
|
+
if hasattr(backend, "read_raw"):
|
|
174
|
+
res = backend.read_raw(file_path)
|
|
175
|
+
if asyncio.iscoroutine(res):
|
|
176
|
+
res = await res
|
|
177
|
+
data = getattr(res, "data", None) or res.get("data")
|
|
178
|
+
content = data.get("content") if data else None
|
|
179
|
+
error = getattr(res, "error", None) or res.get("error")
|
|
180
|
+
elif hasattr(backend, "aread"):
|
|
181
|
+
res = await backend.aread(file_path, limit=1000000)
|
|
182
|
+
fd = getattr(res, "file_data", None)
|
|
183
|
+
content = fd.get("content") if fd else None
|
|
184
|
+
error = getattr(res, "error", None)
|
|
185
|
+
elif hasattr(backend, "read"):
|
|
186
|
+
res = backend.read(file_path, limit=1000000)
|
|
187
|
+
fd = getattr(res, "file_data", None)
|
|
188
|
+
content = fd.get("content") if fd else None
|
|
189
|
+
error = getattr(res, "error", None)
|
|
190
|
+
else:
|
|
191
|
+
return invalid("file_read_failed", "No read method on backend")
|
|
192
|
+
|
|
193
|
+
if error or content is None:
|
|
194
|
+
return invalid(
|
|
195
|
+
"file_read_failed",
|
|
196
|
+
f"Could not read the final Markdown text: {error or 'no text data'}."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if isinstance(content, list):
|
|
200
|
+
content = "\n".join(content)
|
|
201
|
+
|
|
202
|
+
return validate_okf_frontmatter(content)
|
|
203
|
+
|
|
204
|
+
def validate_persisted_file_sync(
|
|
205
|
+
backend: Any,
|
|
206
|
+
file_path: str,
|
|
207
|
+
) -> Dict[str, Any]:
|
|
208
|
+
content = None
|
|
209
|
+
error = None
|
|
210
|
+
|
|
211
|
+
if hasattr(backend, "read"):
|
|
212
|
+
res = backend.read(file_path, limit=1000000)
|
|
213
|
+
fd = getattr(res, "file_data", None)
|
|
214
|
+
content = fd.get("content") if fd else None
|
|
215
|
+
error = getattr(res, "error", None)
|
|
216
|
+
elif hasattr(backend, "read_raw"):
|
|
217
|
+
res = backend.read_raw(file_path)
|
|
218
|
+
data = getattr(res, "data", None) or res.get("data")
|
|
219
|
+
content = data.get("content") if data else None
|
|
220
|
+
error = getattr(res, "error", None) or res.get("error")
|
|
221
|
+
else:
|
|
222
|
+
return invalid("file_read_failed", "No read method on backend")
|
|
223
|
+
|
|
224
|
+
if error or content is None:
|
|
225
|
+
return invalid(
|
|
226
|
+
"file_read_failed",
|
|
227
|
+
f"Could not read the final Markdown text: {error or 'no text data'}."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if isinstance(content, list):
|
|
231
|
+
content = "\n".join(content)
|
|
232
|
+
|
|
233
|
+
return validate_okf_frontmatter(content)
|
|
234
|
+
|
|
235
|
+
def get_tool_messages(result: Any) -> List[ToolMessage]:
|
|
236
|
+
if isinstance(result, ToolMessage):
|
|
237
|
+
return [result]
|
|
238
|
+
if not isinstance(result, dict):
|
|
239
|
+
return []
|
|
240
|
+
update = result.get("update")
|
|
241
|
+
if isinstance(update, dict):
|
|
242
|
+
messages = update.get("messages")
|
|
243
|
+
if isinstance(messages, list):
|
|
244
|
+
return [msg for msg in messages if isinstance(msg, ToolMessage)]
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
def is_wiki_markdown_path(
|
|
248
|
+
file_path: str,
|
|
249
|
+
output_mode: OpenWikiOutputMode,
|
|
250
|
+
) -> bool:
|
|
251
|
+
# Normalize paths
|
|
252
|
+
normalized = file_path.strip().replace("\\", "/").replace("//", "/")
|
|
253
|
+
# Remove leading slashes
|
|
254
|
+
virtual_path = normalized.lstrip("/")
|
|
255
|
+
|
|
256
|
+
# Basename of virtual_path
|
|
257
|
+
base_name = os.path.basename(virtual_path).lower()
|
|
258
|
+
|
|
259
|
+
# Check extension
|
|
260
|
+
if not virtual_path.lower().endswith(".md"):
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
# Check if reserved file
|
|
264
|
+
if base_name in OKF_RESERVED_FILES:
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
# Check mode constraints
|
|
268
|
+
if output_mode == "local-wiki":
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
return virtual_path.startswith(f"{OPEN_WIKI_DIR}/")
|
|
272
|
+
|
|
273
|
+
def format_warning(path: str, issues: List[Dict[str, Any]]) -> str:
|
|
274
|
+
details = []
|
|
275
|
+
for issue_item in issues:
|
|
276
|
+
code = issue_item.get("code")
|
|
277
|
+
line = issue_item.get("line")
|
|
278
|
+
message = issue_item.get("message")
|
|
279
|
+
line_str = f" line {line}:" if line else ""
|
|
280
|
+
details.append(f"- [{code}]{line_str} {message}")
|
|
281
|
+
details_str = "\n".join(details)
|
|
282
|
+
return f"WARNING: YAML front matter was NOT formatted properly in `{path}`.\n{details_str}\nYou MUST correct this file's YAML front matter before continuing."
|
|
283
|
+
|
|
284
|
+
def invalid(
|
|
285
|
+
code: str,
|
|
286
|
+
message: str,
|
|
287
|
+
line: Optional[int] = None,
|
|
288
|
+
) -> Dict[str, Any]:
|
|
289
|
+
return {"issues": [issue(code, message, line)], "valid": False}
|
|
290
|
+
|
|
291
|
+
def issue(code: str, message: str, line: Optional[int] = None) -> Dict[str, Any]:
|
|
292
|
+
res: Dict[str, Any] = {"code": code, "message": message}
|
|
293
|
+
if line is not None:
|
|
294
|
+
res["line"] = line
|
|
295
|
+
return res
|