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.
Files changed (68) hide show
  1. openwiki_py/__init__.py +1 -0
  2. openwiki_py/__main__.py +4 -0
  3. openwiki_py/agent/docs_only_backend.py +66 -0
  4. openwiki_py/agent/frontmatter.py +295 -0
  5. openwiki_py/agent/index_middleware.py +409 -0
  6. openwiki_py/agent/prompt.py +471 -0
  7. openwiki_py/agent/providers.py +69 -0
  8. openwiki_py/agent/runtime.py +333 -0
  9. openwiki_py/agent/skills.py +50 -0
  10. openwiki_py/agent/stream_adapter.py +123 -0
  11. openwiki_py/agent/types.py +63 -0
  12. openwiki_py/agent/utils.py +351 -0
  13. openwiki_py/auth/configure.py +167 -0
  14. openwiki_py/auth/ngrok.py +184 -0
  15. openwiki_py/auth/oauth.py +414 -0
  16. openwiki_py/auth/providers.py +106 -0
  17. openwiki_py/auth/tokens.py +136 -0
  18. openwiki_py/cli.py +197 -0
  19. openwiki_py/code_mode.py +143 -0
  20. openwiki_py/commands.py +579 -0
  21. openwiki_py/connectors/io.py +89 -0
  22. openwiki_py/connectors/mcp_client.py +450 -0
  23. openwiki_py/connectors/mcp_runtime.py +213 -0
  24. openwiki_py/connectors/registry.py +63 -0
  25. openwiki_py/connectors/sources/git_repo.py +140 -0
  26. openwiki_py/connectors/sources/google.py +283 -0
  27. openwiki_py/connectors/sources/hackernews.py +183 -0
  28. openwiki_py/connectors/sources/mcp.py +164 -0
  29. openwiki_py/connectors/sources/slack.py +421 -0
  30. openwiki_py/connectors/sources/web_search.py +192 -0
  31. openwiki_py/connectors/sources/x.py +289 -0
  32. openwiki_py/connectors/tools.py +223 -0
  33. openwiki_py/constants.py +441 -0
  34. openwiki_py/credentials.py +136 -0
  35. openwiki_py/data/skills/migrate-wiki-to-okf/SKILL.md +44 -0
  36. openwiki_py/data/skills/write-connector/SKILL.md +45 -0
  37. openwiki_py/diagnostics.py +112 -0
  38. openwiki_py/env.py +410 -0
  39. openwiki_py/fs_errors.py +11 -0
  40. openwiki_py/ingestion.py +366 -0
  41. openwiki_py/onboarding.py +282 -0
  42. openwiki_py/openwiki_home.py +72 -0
  43. openwiki_py/schedules.py +228 -0
  44. openwiki_py/startup.py +67 -0
  45. openwiki_py/telemetry/__init__.py +23 -0
  46. openwiki_py/telemetry/client.py +35 -0
  47. openwiki_py/telemetry/config.py +23 -0
  48. openwiki_py/telemetry/errors.py +47 -0
  49. openwiki_py/telemetry/gates.py +48 -0
  50. openwiki_py/telemetry/install_id.py +42 -0
  51. openwiki_py/telemetry/record_run_safe.py +28 -0
  52. openwiki_py/telemetry/senders.py +101 -0
  53. openwiki_py/tui/app.py +427 -0
  54. openwiki_py/tui/chat.py +33 -0
  55. openwiki_py/tui/menus.py +46 -0
  56. openwiki_py/tui/onboarding/ingestion.py +226 -0
  57. openwiki_py/tui/onboarding/oauth.py +121 -0
  58. openwiki_py/tui/onboarding/screens.py +331 -0
  59. openwiki_py/tui/print_mode.py +62 -0
  60. openwiki_py/tui/run_log.py +16 -0
  61. openwiki_py/tui/slash.py +31 -0
  62. openwiki_py/tui/widgets.py +34 -0
  63. openwiki_py/tui/workers.py +11 -0
  64. openwiki_py-0.1.0.dist-info/METADATA +158 -0
  65. openwiki_py-0.1.0.dist-info/RECORD +68 -0
  66. openwiki_py-0.1.0.dist-info/WHEEL +4 -0
  67. openwiki_py-0.1.0.dist-info/entry_points.txt +2 -0
  68. openwiki_py-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from openwiki_py.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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