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.
- windcode/__init__.py +6 -0
- windcode/__main__.py +4 -0
- windcode/auth/__init__.py +11 -0
- windcode/auth/store.py +90 -0
- windcode/cli.py +181 -0
- windcode/config/__init__.py +41 -0
- windcode/config/loader.py +117 -0
- windcode/config/models.py +203 -0
- windcode/config/writer.py +74 -0
- windcode/context/__init__.py +18 -0
- windcode/context/compactor.py +72 -0
- windcode/context/estimator.py +89 -0
- windcode/context/truncation.py +87 -0
- windcode/domain/__init__.py +1 -0
- windcode/domain/errors.py +33 -0
- windcode/domain/events.py +597 -0
- windcode/domain/messages.py +226 -0
- windcode/domain/models.py +73 -0
- windcode/domain/subagents.py +263 -0
- windcode/domain/tools.py +55 -0
- windcode/extensions/__init__.py +27 -0
- windcode/extensions/commands.py +25 -0
- windcode/extensions/discovery.py +139 -0
- windcode/extensions/events.py +58 -0
- windcode/extensions/hooks/__init__.py +4 -0
- windcode/extensions/hooks/dispatcher.py +107 -0
- windcode/extensions/hooks/executor.py +49 -0
- windcode/extensions/hooks/loader.py +101 -0
- windcode/extensions/hooks/models.py +109 -0
- windcode/extensions/mcp/__init__.py +10 -0
- windcode/extensions/mcp/adapter.py +101 -0
- windcode/extensions/mcp/catalog.py +153 -0
- windcode/extensions/mcp/client.py +273 -0
- windcode/extensions/mcp/runtime.py +144 -0
- windcode/extensions/mcp/tools.py +570 -0
- windcode/extensions/models.py +168 -0
- windcode/extensions/paths.py +71 -0
- windcode/extensions/plugins/__init__.py +3 -0
- windcode/extensions/plugins/installer.py +93 -0
- windcode/extensions/plugins/manifest.py +166 -0
- windcode/extensions/runtime.py +366 -0
- windcode/extensions/service.py +437 -0
- windcode/extensions/skills/__init__.py +3 -0
- windcode/extensions/skills/loader.py +67 -0
- windcode/extensions/skills/parser.py +57 -0
- windcode/extensions/skills/tools.py +213 -0
- windcode/extensions/snapshot.py +57 -0
- windcode/extensions/state.py +158 -0
- windcode/instructions/__init__.py +8 -0
- windcode/instructions/loader.py +50 -0
- windcode/memory/__init__.py +57 -0
- windcode/memory/extraction.py +83 -0
- windcode/memory/models.py +218 -0
- windcode/memory/refiner.py +189 -0
- windcode/memory/security.py +23 -0
- windcode/memory/service.py +179 -0
- windcode/memory/store.py +362 -0
- windcode/observability/__init__.py +4 -0
- windcode/observability/redaction.py +95 -0
- windcode/observability/trace.py +115 -0
- windcode/policy/__init__.py +22 -0
- windcode/policy/engine.py +137 -0
- windcode/policy/models.py +69 -0
- windcode/providers/__init__.py +29 -0
- windcode/providers/_utils.py +19 -0
- windcode/providers/anthropic.py +215 -0
- windcode/providers/base.py +46 -0
- windcode/providers/catalog.py +119 -0
- windcode/providers/errors.py +96 -0
- windcode/providers/openai_compat.py +231 -0
- windcode/providers/openai_responses.py +189 -0
- windcode/providers/registry.py +105 -0
- windcode/runtime/__init__.py +15 -0
- windcode/runtime/control.py +89 -0
- windcode/runtime/event_bus.py +67 -0
- windcode/runtime/loop.py +510 -0
- windcode/runtime/prompts.py +132 -0
- windcode/runtime/report.py +64 -0
- windcode/runtime/retry.py +73 -0
- windcode/runtime/scheduler.py +194 -0
- windcode/runtime/subagents/__init__.py +28 -0
- windcode/runtime/subagents/approvals.py +88 -0
- windcode/runtime/subagents/budgets.py +79 -0
- windcode/runtime/subagents/coordinator.py +714 -0
- windcode/runtime/subagents/factory.py +311 -0
- windcode/runtime/subagents/roles.py +74 -0
- windcode/runtime/subagents/verification.py +53 -0
- windcode/sandbox/__init__.py +3 -0
- windcode/sandbox/bwrap.py +79 -0
- windcode/sdk.py +1259 -0
- windcode/sessions/__init__.py +23 -0
- windcode/sessions/artifacts.py +69 -0
- windcode/sessions/models.py +104 -0
- windcode/sessions/store.py +167 -0
- windcode/sessions/tree.py +35 -0
- windcode/tools/__init__.py +10 -0
- windcode/tools/apply_patch.py +191 -0
- windcode/tools/ask_user.py +58 -0
- windcode/tools/builtins.py +44 -0
- windcode/tools/edit_file.py +54 -0
- windcode/tools/filesystem.py +77 -0
- windcode/tools/glob.py +35 -0
- windcode/tools/grep.py +66 -0
- windcode/tools/memory.py +400 -0
- windcode/tools/read_file.py +54 -0
- windcode/tools/registry.py +120 -0
- windcode/tools/shell.py +159 -0
- windcode/tools/subagents/__init__.py +31 -0
- windcode/tools/subagents/cancel.py +35 -0
- windcode/tools/subagents/integrate.py +54 -0
- windcode/tools/subagents/list.py +46 -0
- windcode/tools/subagents/spawn.py +86 -0
- windcode/tools/subagents/wait.py +74 -0
- windcode/tools/write_file.py +61 -0
- windcode/tui/__init__.py +3 -0
- windcode/tui/app.py +1015 -0
- windcode/tui/commands.py +90 -0
- windcode/tui/permission_display.py +24 -0
- windcode/tui/styles.tcss +591 -0
- windcode/tui/widgets/__init__.py +31 -0
- windcode/tui/widgets/approval.py +98 -0
- windcode/tui/widgets/command_menu.py +90 -0
- windcode/tui/widgets/extensions.py +48 -0
- windcode/tui/widgets/input.py +110 -0
- windcode/tui/widgets/memory.py +143 -0
- windcode/tui/widgets/messages.py +299 -0
- windcode/tui/widgets/models.py +565 -0
- windcode/tui/widgets/question.py +46 -0
- windcode/tui/widgets/sessions.py +68 -0
- windcode/tui/widgets/status.py +46 -0
- windcode/tui/widgets/subagents.py +195 -0
- windcode/tui/widgets/tools.py +66 -0
- windcode/tui/widgets/welcome.py +149 -0
- windcode/types.py +80 -0
- windcode/worktrees/__init__.py +24 -0
- windcode/worktrees/git.py +92 -0
- windcode/worktrees/manager.py +265 -0
- windcode/worktrees/models.py +62 -0
- windcode-0.1.0.dist-info/METADATA +207 -0
- windcode-0.1.0.dist-info/RECORD +143 -0
- windcode-0.1.0.dist-info/WHEEL +4 -0
- windcode-0.1.0.dist-info/entry_points.txt +2 -0
- windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
windcode/memory/store.py
ADDED
|
@@ -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,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
|
+
]
|