windcode 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 (143) hide show
  1. windcode/__init__.py +6 -0
  2. windcode/__main__.py +4 -0
  3. windcode/auth/__init__.py +11 -0
  4. windcode/auth/store.py +90 -0
  5. windcode/cli.py +181 -0
  6. windcode/config/__init__.py +41 -0
  7. windcode/config/loader.py +117 -0
  8. windcode/config/models.py +203 -0
  9. windcode/config/writer.py +74 -0
  10. windcode/context/__init__.py +18 -0
  11. windcode/context/compactor.py +72 -0
  12. windcode/context/estimator.py +89 -0
  13. windcode/context/truncation.py +87 -0
  14. windcode/domain/__init__.py +1 -0
  15. windcode/domain/errors.py +33 -0
  16. windcode/domain/events.py +597 -0
  17. windcode/domain/messages.py +226 -0
  18. windcode/domain/models.py +73 -0
  19. windcode/domain/subagents.py +263 -0
  20. windcode/domain/tools.py +55 -0
  21. windcode/extensions/__init__.py +27 -0
  22. windcode/extensions/commands.py +25 -0
  23. windcode/extensions/discovery.py +139 -0
  24. windcode/extensions/events.py +58 -0
  25. windcode/extensions/hooks/__init__.py +4 -0
  26. windcode/extensions/hooks/dispatcher.py +107 -0
  27. windcode/extensions/hooks/executor.py +49 -0
  28. windcode/extensions/hooks/loader.py +101 -0
  29. windcode/extensions/hooks/models.py +109 -0
  30. windcode/extensions/mcp/__init__.py +10 -0
  31. windcode/extensions/mcp/adapter.py +101 -0
  32. windcode/extensions/mcp/catalog.py +153 -0
  33. windcode/extensions/mcp/client.py +273 -0
  34. windcode/extensions/mcp/runtime.py +144 -0
  35. windcode/extensions/mcp/tools.py +570 -0
  36. windcode/extensions/models.py +168 -0
  37. windcode/extensions/paths.py +71 -0
  38. windcode/extensions/plugins/__init__.py +3 -0
  39. windcode/extensions/plugins/installer.py +93 -0
  40. windcode/extensions/plugins/manifest.py +166 -0
  41. windcode/extensions/runtime.py +366 -0
  42. windcode/extensions/service.py +437 -0
  43. windcode/extensions/skills/__init__.py +3 -0
  44. windcode/extensions/skills/loader.py +67 -0
  45. windcode/extensions/skills/parser.py +57 -0
  46. windcode/extensions/skills/tools.py +213 -0
  47. windcode/extensions/snapshot.py +57 -0
  48. windcode/extensions/state.py +158 -0
  49. windcode/instructions/__init__.py +8 -0
  50. windcode/instructions/loader.py +50 -0
  51. windcode/memory/__init__.py +57 -0
  52. windcode/memory/extraction.py +83 -0
  53. windcode/memory/models.py +218 -0
  54. windcode/memory/refiner.py +189 -0
  55. windcode/memory/security.py +23 -0
  56. windcode/memory/service.py +179 -0
  57. windcode/memory/store.py +362 -0
  58. windcode/observability/__init__.py +4 -0
  59. windcode/observability/redaction.py +95 -0
  60. windcode/observability/trace.py +115 -0
  61. windcode/policy/__init__.py +22 -0
  62. windcode/policy/engine.py +137 -0
  63. windcode/policy/models.py +69 -0
  64. windcode/providers/__init__.py +29 -0
  65. windcode/providers/_utils.py +19 -0
  66. windcode/providers/anthropic.py +215 -0
  67. windcode/providers/base.py +46 -0
  68. windcode/providers/catalog.py +119 -0
  69. windcode/providers/errors.py +96 -0
  70. windcode/providers/openai_compat.py +231 -0
  71. windcode/providers/openai_responses.py +189 -0
  72. windcode/providers/registry.py +105 -0
  73. windcode/runtime/__init__.py +15 -0
  74. windcode/runtime/control.py +89 -0
  75. windcode/runtime/event_bus.py +67 -0
  76. windcode/runtime/loop.py +510 -0
  77. windcode/runtime/prompts.py +132 -0
  78. windcode/runtime/report.py +64 -0
  79. windcode/runtime/retry.py +73 -0
  80. windcode/runtime/scheduler.py +194 -0
  81. windcode/runtime/subagents/__init__.py +28 -0
  82. windcode/runtime/subagents/approvals.py +88 -0
  83. windcode/runtime/subagents/budgets.py +79 -0
  84. windcode/runtime/subagents/coordinator.py +714 -0
  85. windcode/runtime/subagents/factory.py +311 -0
  86. windcode/runtime/subagents/roles.py +74 -0
  87. windcode/runtime/subagents/verification.py +53 -0
  88. windcode/sandbox/__init__.py +3 -0
  89. windcode/sandbox/bwrap.py +79 -0
  90. windcode/sdk.py +1259 -0
  91. windcode/sessions/__init__.py +23 -0
  92. windcode/sessions/artifacts.py +69 -0
  93. windcode/sessions/models.py +104 -0
  94. windcode/sessions/store.py +167 -0
  95. windcode/sessions/tree.py +35 -0
  96. windcode/tools/__init__.py +10 -0
  97. windcode/tools/apply_patch.py +191 -0
  98. windcode/tools/ask_user.py +58 -0
  99. windcode/tools/builtins.py +44 -0
  100. windcode/tools/edit_file.py +54 -0
  101. windcode/tools/filesystem.py +77 -0
  102. windcode/tools/glob.py +35 -0
  103. windcode/tools/grep.py +66 -0
  104. windcode/tools/memory.py +400 -0
  105. windcode/tools/read_file.py +54 -0
  106. windcode/tools/registry.py +120 -0
  107. windcode/tools/shell.py +159 -0
  108. windcode/tools/subagents/__init__.py +31 -0
  109. windcode/tools/subagents/cancel.py +35 -0
  110. windcode/tools/subagents/integrate.py +54 -0
  111. windcode/tools/subagents/list.py +46 -0
  112. windcode/tools/subagents/spawn.py +86 -0
  113. windcode/tools/subagents/wait.py +74 -0
  114. windcode/tools/write_file.py +61 -0
  115. windcode/tui/__init__.py +3 -0
  116. windcode/tui/app.py +1015 -0
  117. windcode/tui/commands.py +90 -0
  118. windcode/tui/permission_display.py +24 -0
  119. windcode/tui/styles.tcss +591 -0
  120. windcode/tui/widgets/__init__.py +31 -0
  121. windcode/tui/widgets/approval.py +98 -0
  122. windcode/tui/widgets/command_menu.py +90 -0
  123. windcode/tui/widgets/extensions.py +48 -0
  124. windcode/tui/widgets/input.py +110 -0
  125. windcode/tui/widgets/memory.py +143 -0
  126. windcode/tui/widgets/messages.py +299 -0
  127. windcode/tui/widgets/models.py +565 -0
  128. windcode/tui/widgets/question.py +46 -0
  129. windcode/tui/widgets/sessions.py +68 -0
  130. windcode/tui/widgets/status.py +46 -0
  131. windcode/tui/widgets/subagents.py +195 -0
  132. windcode/tui/widgets/tools.py +66 -0
  133. windcode/tui/widgets/welcome.py +149 -0
  134. windcode/types.py +80 -0
  135. windcode/worktrees/__init__.py +24 -0
  136. windcode/worktrees/git.py +92 -0
  137. windcode/worktrees/manager.py +265 -0
  138. windcode/worktrees/models.py +62 -0
  139. windcode-0.1.0.dist-info/METADATA +207 -0
  140. windcode-0.1.0.dist-info/RECORD +143 -0
  141. windcode-0.1.0.dist-info/WHEEL +4 -0
  142. windcode-0.1.0.dist-info/entry_points.txt +2 -0
  143. windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,362 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import re
6
+ import sqlite3
7
+ from dataclasses import replace
8
+ from pathlib import Path
9
+ from typing import Any, cast
10
+ from uuid import uuid4
11
+
12
+ import yaml
13
+
14
+ from windcode.memory.models import (
15
+ MemoryActivation,
16
+ MemoryKind,
17
+ MemoryRecord,
18
+ MemoryScope,
19
+ MemorySearchResult,
20
+ MemoryStatus,
21
+ utc_now,
22
+ )
23
+ from windcode.memory.security import validate_memory_text
24
+
25
+ SCHEMA_VERSION = 2
26
+ _FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL)
27
+
28
+
29
+ def project_identifier(workspace: Path) -> str:
30
+ normalized = str(workspace.expanduser().resolve())
31
+ return hashlib.sha256(normalized.encode()).hexdigest()[:24]
32
+
33
+
34
+ class MemoryStore:
35
+ """Markdown source of truth with a disposable SQLite FTS index."""
36
+
37
+ def __init__(self, root: Path) -> None:
38
+ self.root = root.expanduser().resolve()
39
+ self.records_dir = self.root / "records"
40
+ self.index_path = self.root / "index.sqlite3"
41
+ self.records_dir.mkdir(parents=True, exist_ok=True)
42
+ self._initialize()
43
+
44
+ def _connect(self) -> sqlite3.Connection:
45
+ connection = sqlite3.connect(self.index_path, timeout=10)
46
+ connection.row_factory = sqlite3.Row
47
+ connection.execute("PRAGMA journal_mode=WAL")
48
+ connection.execute("PRAGMA foreign_keys=ON")
49
+ return connection
50
+
51
+ def _initialize(self) -> None:
52
+ try:
53
+ with self._connect() as connection:
54
+ version = int(connection.execute("PRAGMA user_version").fetchone()[0])
55
+ if version not in (0, SCHEMA_VERSION):
56
+ self.index_path.unlink(missing_ok=True)
57
+ for suffix in ("-wal", "-shm"):
58
+ Path(f"{self.index_path}{suffix}").unlink(missing_ok=True)
59
+ self._create_schema()
60
+ self.rebuild()
61
+ return
62
+ self._create_schema()
63
+ except sqlite3.DatabaseError:
64
+ corrupt = self.index_path.with_suffix(f".corrupt-{uuid4().hex}")
65
+ self.index_path.replace(corrupt)
66
+ for suffix in ("-wal", "-shm"):
67
+ Path(f"{self.index_path}{suffix}").unlink(missing_ok=True)
68
+ self._create_schema()
69
+ self.rebuild()
70
+
71
+ def _create_schema(self) -> None:
72
+ with self._connect() as connection:
73
+ connection.executescript(
74
+ """
75
+ CREATE TABLE IF NOT EXISTS memories (
76
+ memory_id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE,
77
+ kind TEXT NOT NULL, scope TEXT NOT NULL, project_id TEXT,
78
+ status TEXT NOT NULL, activation TEXT NOT NULL, priority INTEGER NOT NULL,
79
+ title TEXT NOT NULL, summary TEXT NOT NULL,
80
+ confidence REAL NOT NULL, version INTEGER NOT NULL,
81
+ updated_at TEXT NOT NULL
82
+ );
83
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
84
+ memory_id UNINDEXED, title, summary, body, tags, evidence,
85
+ tokenize='unicode61'
86
+ );
87
+ PRAGMA user_version=2;
88
+ """
89
+ )
90
+
91
+ def _path(self, record: MemoryRecord) -> Path:
92
+ return self.records_dir / record.scope.value / record.kind.value / f"{record.memory_id}.md"
93
+
94
+ @staticmethod
95
+ def _serialize(record: MemoryRecord) -> str:
96
+ metadata = record.to_dict()
97
+ body = str(metadata.pop("body"))
98
+ frontmatter = yaml.safe_dump(metadata, allow_unicode=True, sort_keys=True).strip()
99
+ return f"---\n{frontmatter}\n---\n\n{body}\n"
100
+
101
+ @staticmethod
102
+ def _parse(content: str) -> MemoryRecord:
103
+ match = _FRONTMATTER.match(content)
104
+ if match is None:
105
+ raise ValueError("memory file is missing YAML frontmatter")
106
+ raw = yaml.safe_load(match.group(1))
107
+ if not isinstance(raw, dict):
108
+ raise ValueError("memory frontmatter must be an object")
109
+ value = {str(key): item for key, item in cast(dict[object, object], raw).items()}
110
+ value["body"] = content[match.end() :].strip()
111
+ return MemoryRecord.from_dict(value)
112
+
113
+ def _write(self, record: MemoryRecord) -> Path:
114
+ path = self._path(record)
115
+ path.parent.mkdir(parents=True, exist_ok=True)
116
+ temporary = path.with_suffix(f".tmp-{uuid4().hex}")
117
+ data = self._serialize(record)
118
+ try:
119
+ with temporary.open("w", encoding="utf-8") as stream:
120
+ stream.write(data)
121
+ stream.flush()
122
+ os.fsync(stream.fileno())
123
+ temporary.replace(path)
124
+ finally:
125
+ temporary.unlink(missing_ok=True)
126
+ return path
127
+
128
+ def _index(self, connection: sqlite3.Connection, record: MemoryRecord, path: Path) -> None:
129
+ connection.execute("DELETE FROM memories_fts WHERE memory_id = ?", (record.memory_id,))
130
+ connection.execute(
131
+ """INSERT OR REPLACE INTO memories
132
+ (memory_id,path,kind,scope,project_id,status,activation,priority,title,summary,
133
+ confidence,version,updated_at)
134
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
135
+ (
136
+ record.memory_id,
137
+ str(path.relative_to(self.root)),
138
+ record.kind.value,
139
+ record.scope.value,
140
+ record.project_id,
141
+ record.status.value,
142
+ record.activation.value,
143
+ record.priority,
144
+ record.title,
145
+ record.summary,
146
+ record.confidence,
147
+ record.version,
148
+ record.updated_at.isoformat(),
149
+ ),
150
+ )
151
+ connection.execute(
152
+ "INSERT INTO memories_fts VALUES (?,?,?,?,?,?)",
153
+ (
154
+ record.memory_id,
155
+ record.title,
156
+ record.summary,
157
+ record.body,
158
+ " ".join(record.tags),
159
+ " ".join(record.evidence),
160
+ ),
161
+ )
162
+
163
+ def save(self, record: MemoryRecord) -> MemoryRecord:
164
+ if not record.title or not record.summary or not record.body:
165
+ raise ValueError("memory title, summary, and body are required")
166
+ if record.scope is MemoryScope.PROJECT and not record.project_id:
167
+ raise ValueError("project-scoped memory requires project_id")
168
+ if not 0 <= record.priority <= 100:
169
+ raise ValueError("memory priority must be between 0 and 100")
170
+ validate_memory_text(record.title, record.summary, record.body, *record.evidence)
171
+ path = self._write(record)
172
+ with self._connect() as connection:
173
+ self._index(connection, record, path)
174
+ return record
175
+
176
+ def get(self, memory_id: str) -> MemoryRecord:
177
+ with self._connect() as connection:
178
+ row = connection.execute(
179
+ "SELECT path FROM memories WHERE memory_id = ?", (memory_id,)
180
+ ).fetchone()
181
+ if row is None:
182
+ raise KeyError(memory_id)
183
+ path = (self.root / str(row["path"])).resolve()
184
+ if not path.is_relative_to(self.records_dir):
185
+ raise ValueError("memory index contains an unsafe path")
186
+ return self._parse(path.read_text(encoding="utf-8"))
187
+
188
+ def list(
189
+ self,
190
+ *,
191
+ status: MemoryStatus | None = None,
192
+ project_id: str | None = None,
193
+ activation: MemoryActivation | None = None,
194
+ ) -> tuple[MemoryRecord, ...]:
195
+ clauses: list[str] = []
196
+ values: list[str] = []
197
+ if status is not None:
198
+ clauses.append("status = ?")
199
+ values.append(status.value)
200
+ if project_id is not None:
201
+ clauses.append("(scope = 'user' OR project_id = ?)")
202
+ values.append(project_id)
203
+ if activation is not None:
204
+ clauses.append("activation = ?")
205
+ values.append(activation.value)
206
+ where = " WHERE " + " AND ".join(clauses) if clauses else ""
207
+ with self._connect() as connection:
208
+ rows = connection.execute(
209
+ f"SELECT memory_id FROM memories{where} ORDER BY updated_at DESC", values
210
+ ).fetchall()
211
+ return tuple(self.get(str(row["memory_id"])) for row in rows)
212
+
213
+ def search(
214
+ self,
215
+ query: str,
216
+ *,
217
+ project_id: str | None = None,
218
+ limit: int = 5,
219
+ statuses: tuple[MemoryStatus, ...] = (MemoryStatus.ACTIVE,),
220
+ kind: MemoryKind | None = None,
221
+ scope: MemoryScope | None = None,
222
+ activation: MemoryActivation | None = None,
223
+ ) -> tuple[MemorySearchResult, ...]:
224
+ if not query.strip() or limit <= 0:
225
+ return ()
226
+ tokens = tuple(re.findall(r"[\w-]+", query.casefold(), flags=re.UNICODE))[:32]
227
+ if not tokens:
228
+ return ()
229
+ fts_query = " OR ".join(f'"{token.replace(chr(34), chr(34) * 2)}"' for token in tokens)
230
+ status_values = tuple(status.value for status in statuses)
231
+ placeholders = ",".join("?" for _ in status_values)
232
+ filters = [
233
+ f"m.status IN ({placeholders})",
234
+ "(m.scope = 'user' OR m.project_id = ?)",
235
+ ]
236
+ filter_values: list[str | int] = [*status_values, project_id or ""]
237
+ if kind is not None:
238
+ filters.append("m.kind = ?")
239
+ filter_values.append(kind.value)
240
+ if scope is not None:
241
+ filters.append("m.scope = ?")
242
+ filter_values.append(scope.value)
243
+ if activation is not None:
244
+ filters.append("m.activation = ?")
245
+ filter_values.append(activation.value)
246
+ sql = f"""
247
+ SELECT m.memory_id, bm25(memories_fts) AS rank, m.confidence
248
+ FROM memories_fts JOIN memories m USING(memory_id)
249
+ WHERE memories_fts MATCH ? AND {" AND ".join(filters)}
250
+ ORDER BY rank ASC, m.confidence DESC, m.updated_at DESC LIMIT ?
251
+ """
252
+ try:
253
+ with self._connect() as connection:
254
+ rows = connection.execute(sql, (fts_query, *filter_values, limit)).fetchall()
255
+ except sqlite3.OperationalError:
256
+ rows = ()
257
+ indexed = tuple(
258
+ MemorySearchResult(self.get(str(row["memory_id"])), float(-row["rank"])) for row in rows
259
+ )
260
+ if len(indexed) >= limit:
261
+ return indexed
262
+
263
+ # unicode61 does not segment unspaced CJK sentences reliably. A bounded
264
+ # lexical supplement lets “我喜欢什么” recall “我喜欢 Python”.
265
+ selected = {item.record.memory_id for item in indexed}
266
+ query_terms = self._lexical_terms(query)
267
+ supplemental: list[MemorySearchResult] = []
268
+ for record in self.list(project_id=project_id):
269
+ if (
270
+ record.memory_id in selected
271
+ or record.status not in statuses
272
+ or (kind is not None and record.kind is not kind)
273
+ or (scope is not None and record.scope is not scope)
274
+ or (activation is not None and record.activation is not activation)
275
+ ):
276
+ continue
277
+ content_terms = self._lexical_terms(
278
+ " ".join((record.title, record.summary, record.body, *record.tags))
279
+ )
280
+ overlap = query_terms & content_terms
281
+ if not overlap:
282
+ continue
283
+ score = len(overlap) / max(len(query_terms), 1) + record.confidence * 0.1
284
+ supplemental.append(MemorySearchResult(record, score))
285
+ supplemental.sort(key=lambda item: (item.score, item.record.updated_at), reverse=True)
286
+ return (*indexed, *supplemental[: limit - len(indexed)])
287
+
288
+ @staticmethod
289
+ def _lexical_terms(text: str) -> set[str]:
290
+ normalized = text.casefold()
291
+ terms = set(re.findall(r"[a-z0-9_-]{2,}", normalized))
292
+ for run in re.findall(r"[\u3400-\u9fff]+", normalized):
293
+ terms.update(run[index : index + 2] for index in range(max(0, len(run) - 1)))
294
+ return terms
295
+
296
+ def transition(self, memory_id: str, status: MemoryStatus) -> MemoryRecord:
297
+ record = self.get(memory_id).transition(status)
298
+ return self.save(record)
299
+
300
+ def update(self, memory_id: str, **changes: Any) -> MemoryRecord:
301
+ current = self.get(memory_id)
302
+ allowed = {
303
+ "title",
304
+ "summary",
305
+ "body",
306
+ "tags",
307
+ "evidence",
308
+ "confidence",
309
+ "activation",
310
+ "priority",
311
+ }
312
+ if unknown := set(changes) - allowed:
313
+ raise ValueError(f"unsupported memory fields: {', '.join(sorted(unknown))}")
314
+ updated = replace(current, **changes, version=current.version + 1, updated_at=utc_now())
315
+ return self.save(updated)
316
+
317
+ def delete(self, memory_id: str) -> None:
318
+ record = self.get(memory_id)
319
+ path = self._path(record)
320
+ with self._connect() as connection:
321
+ connection.execute("DELETE FROM memories_fts WHERE memory_id = ?", (memory_id,))
322
+ connection.execute("DELETE FROM memories WHERE memory_id = ?", (memory_id,))
323
+ path.unlink(missing_ok=True)
324
+
325
+ def rebuild(self) -> int:
326
+ records: list[tuple[MemoryRecord, Path]] = []
327
+ for path in sorted(self.records_dir.rglob("*.md")):
328
+ if path.is_symlink() or not path.is_file():
329
+ continue
330
+ if not path.resolve().is_relative_to(self.records_dir):
331
+ continue
332
+ records.append((self._parse(path.read_text(encoding="utf-8")), path))
333
+ with self._connect() as connection:
334
+ connection.execute("DELETE FROM memories_fts")
335
+ connection.execute("DELETE FROM memories")
336
+ for record, path in records:
337
+ self._index(connection, record, path)
338
+ return len(records)
339
+
340
+ def export_project(self, project_id: str, destination: Path) -> tuple[Path, ...]:
341
+ destination = destination.expanduser().resolve()
342
+ destination.mkdir(parents=True, exist_ok=True)
343
+ exported: list[Path] = []
344
+ for record in self.list(status=MemoryStatus.ACTIVE, project_id=project_id):
345
+ if record.scope is not MemoryScope.PROJECT:
346
+ continue
347
+ target = destination / f"{record.memory_id}.md"
348
+ target.write_text(self._serialize(record), encoding="utf-8")
349
+ exported.append(target)
350
+ return tuple(exported)
351
+
352
+ def record_outcome(self, memory_id: str, *, success: bool) -> MemoryRecord:
353
+ current = self.get(memory_id)
354
+ updated = replace(
355
+ current,
356
+ success_count=current.success_count + int(success),
357
+ failure_count=current.failure_count + int(not success),
358
+ last_verified_at=utc_now(),
359
+ version=current.version + 1,
360
+ updated_at=utc_now(),
361
+ )
362
+ return self.save(updated)
@@ -0,0 +1,4 @@
1
+ from windcode.observability.redaction import REDACTED, DynamicRedactor, redact
2
+ from windcode.observability.trace import TraceStore
3
+
4
+ __all__ = ["REDACTED", "DynamicRedactor", "TraceStore", "redact"]
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import threading
5
+ from collections.abc import Iterable, Mapping
6
+ from typing import Any, cast
7
+
8
+ REDACTED = "[REDACTED]"
9
+ _SENSITIVE_KEYS = {
10
+ "authorization",
11
+ "proxy_authorization",
12
+ "api_key",
13
+ "apikey",
14
+ "access_token",
15
+ "refresh_token",
16
+ "password",
17
+ "secret",
18
+ "credential",
19
+ "credentials",
20
+ }
21
+
22
+
23
+ def _normalize_key(key: object) -> str:
24
+ return re.sub(r"[^a-z0-9]+", "_", str(key).casefold()).strip("_")
25
+
26
+
27
+ def _is_sensitive_key(key: object) -> bool:
28
+ normalized = _normalize_key(key)
29
+ return normalized in _SENSITIVE_KEYS or normalized.endswith(
30
+ ("_api_key", "_access_token", "_refresh_token", "_password", "_secret")
31
+ )
32
+
33
+
34
+ def _redact_string(value: str, secrets: tuple[str, ...]) -> str:
35
+ redacted = value
36
+ for secret in secrets:
37
+ if secret:
38
+ redacted = redacted.replace(secret, REDACTED)
39
+ return redacted
40
+
41
+
42
+ def redact(value: Any, *, secrets: Iterable[str] = ()) -> Any:
43
+ """Return a recursively redacted copy without mutating the input."""
44
+ secret_values = tuple(secret for secret in secrets if secret)
45
+
46
+ def visit(item: object) -> object:
47
+ if isinstance(item, Mapping):
48
+ mapping = cast(Mapping[object, object], item)
49
+ return {
50
+ str(key): REDACTED if _is_sensitive_key(key) else visit(child)
51
+ for key, child in mapping.items()
52
+ }
53
+ if isinstance(item, tuple):
54
+ sequence = cast(tuple[object, ...], item)
55
+ return tuple(visit(child) for child in sequence)
56
+ if isinstance(item, list):
57
+ sequence = cast(list[object], item)
58
+ return [visit(child) for child in sequence]
59
+ if isinstance(item, set):
60
+ sequence = cast(set[object], item)
61
+ return {visit(child) for child in sequence}
62
+ if isinstance(item, str):
63
+ return _redact_string(item, secret_values)
64
+ return item
65
+
66
+ return visit(cast(object, value))
67
+
68
+
69
+ class DynamicRedactor:
70
+ """Run-scoped sensitive value registry with explicit cleanup."""
71
+
72
+ def __init__(self) -> None:
73
+ self._secrets: set[str] = set()
74
+ self._lock = threading.Lock()
75
+
76
+ def register(self, secret: str) -> None:
77
+ if not secret:
78
+ return
79
+ with self._lock:
80
+ self._secrets.add(secret)
81
+
82
+ def redact(self, value: Any) -> Any:
83
+ with self._lock:
84
+ secrets = tuple(sorted(self._secrets, key=len, reverse=True))
85
+ return redact(value, secrets=secrets)
86
+
87
+ def clear(self) -> None:
88
+ with self._lock:
89
+ self._secrets.clear()
90
+
91
+ def __enter__(self) -> DynamicRedactor:
92
+ return self
93
+
94
+ def __exit__(self, *_: object) -> None:
95
+ self.clear()
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from collections.abc import Iterable, Mapping
6
+ from datetime import UTC, datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ from platformdirs import user_state_path
11
+
12
+ from windcode.domain.events import AgentEvent, AgentEventType, event_to_dict
13
+ from windcode.observability.redaction import redact
14
+
15
+ _TRANSIENT_EVENT_KINDS = frozenset(
16
+ {"text_delta", "reasoning_status", "tool_progress", "subagent_progress"}
17
+ )
18
+
19
+
20
+ class TraceStore:
21
+ def __init__(
22
+ self,
23
+ run_id: str,
24
+ *,
25
+ root: Path | None = None,
26
+ secrets: Iterable[str] = (),
27
+ enabled: bool = True,
28
+ include_tool_arguments: bool = False,
29
+ include_transient_events: bool = False,
30
+ retention_days: int = 14,
31
+ max_total_mb: int = 100,
32
+ ) -> None:
33
+ self.run_id = run_id
34
+ self.root = (root or user_state_path("windcode") / "traces").expanduser().resolve()
35
+ self.path = self.root / f"{run_id}.jsonl"
36
+ self.secrets = tuple(secrets)
37
+ self.enabled = enabled
38
+ self.include_tool_arguments = include_tool_arguments
39
+ self.include_transient_events = include_transient_events
40
+ self.retention_days = retention_days
41
+ self.max_total_bytes = max_total_mb * 1024 * 1024
42
+ if self.enabled:
43
+ self.root.mkdir(parents=True, exist_ok=True)
44
+ self._prune()
45
+
46
+ def _prune(self) -> None:
47
+ traces = [path for path in self.root.glob("*.jsonl") if path.is_file()]
48
+ cutoff = datetime.now(UTC).timestamp() - timedelta(days=self.retention_days).total_seconds()
49
+ for path in traces:
50
+ if path.stat().st_mtime < cutoff:
51
+ path.unlink(missing_ok=True)
52
+
53
+ remaining = sorted(
54
+ (path for path in traces if path.exists()), key=lambda path: path.stat().st_mtime
55
+ )
56
+ total_bytes = sum(path.stat().st_size for path in remaining)
57
+ for path in remaining:
58
+ if total_bytes <= self.max_total_bytes:
59
+ break
60
+ size = path.stat().st_size
61
+ path.unlink(missing_ok=True)
62
+ total_bytes -= size
63
+
64
+ def _prepare_payload(self, payload: Mapping[str, Any]) -> dict[str, Any]:
65
+ prepared = dict(payload)
66
+ if not self.include_tool_arguments:
67
+ prepared.pop("arguments", None)
68
+ nested = prepared.get("payload")
69
+ if isinstance(nested, dict):
70
+ nested_copy = cast(dict[str, Any], nested).copy()
71
+ nested_copy.pop("arguments", None)
72
+ prepared["payload"] = nested_copy
73
+ return cast(dict[str, Any], redact(prepared, secrets=self.secrets))
74
+
75
+ def write(
76
+ self,
77
+ event: AgentEventType | Mapping[str, Any],
78
+ *,
79
+ elapsed_seconds: float | None = None,
80
+ error_category: str | None = None,
81
+ durable: bool = False,
82
+ ) -> dict[str, Any]:
83
+ if isinstance(event, AgentEvent):
84
+ payload = event_to_dict(event)
85
+ session_id = event.session_id
86
+ run_id = event.run_id
87
+ else:
88
+ payload = dict(event)
89
+ session_id = str(payload.get("session_id", ""))
90
+ run_id = str(payload.get("run_id", self.run_id))
91
+
92
+ record: dict[str, Any] = {
93
+ "timestamp": datetime.now(UTC).isoformat(),
94
+ "run_id": run_id,
95
+ "session_id": session_id,
96
+ "event": self._prepare_payload(payload),
97
+ }
98
+ if elapsed_seconds is not None:
99
+ record["elapsed_seconds"] = elapsed_seconds
100
+ if error_category is not None:
101
+ record["error_category"] = error_category
102
+
103
+ kind = str(payload.get("kind", ""))
104
+ if not self.enabled or (
105
+ not self.include_transient_events and kind in _TRANSIENT_EVENT_KINDS
106
+ ):
107
+ return record
108
+
109
+ line = json.dumps(record, ensure_ascii=True, sort_keys=True) + "\n"
110
+ with self.path.open("a", encoding="utf-8") as stream:
111
+ stream.write(line)
112
+ stream.flush()
113
+ if durable:
114
+ os.fsync(stream.fileno())
115
+ return record
@@ -0,0 +1,22 @@
1
+ from windcode.policy.engine import PolicyEngine, assess_risk
2
+ from windcode.policy.models import (
3
+ ApprovalChoice,
4
+ PermissionMode,
5
+ PolicyAction,
6
+ PolicyDecision,
7
+ PolicyRequest,
8
+ RiskLevel,
9
+ summarize_policy_arguments,
10
+ )
11
+
12
+ __all__ = [
13
+ "ApprovalChoice",
14
+ "PermissionMode",
15
+ "PolicyAction",
16
+ "PolicyDecision",
17
+ "PolicyEngine",
18
+ "PolicyRequest",
19
+ "RiskLevel",
20
+ "assess_risk",
21
+ "summarize_policy_arguments",
22
+ ]