abstractcode 0.2.0__py3-none-any.whl → 0.3.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.
abstractcode/recall.py ADDED
@@ -0,0 +1,384 @@
1
+ """AbstractCode recall helpers (no LLM required).
2
+
3
+ AbstractCode is a host UX; recall should stay consistent with runtime-owned
4
+ contracts. This module provides:
5
+ - lightweight argument parsing for `/recall`
6
+ - a thin execution helper that uses AbstractRuntime's ActiveContextPolicy
7
+
8
+ The goal is testability without requiring an LLM provider to be reachable.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from abstractruntime.memory import ActiveContextPolicy, TimeRange
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class RecallRequest:
21
+ since: Optional[str] = None
22
+ until: Optional[str] = None
23
+ tags: Dict[str, Any] = None # type: ignore[assignment]
24
+ tags_mode: str = "all" # all|any
25
+ users: List[str] = None # type: ignore[assignment]
26
+ locations: List[str] = None # type: ignore[assignment]
27
+ query: Optional[str] = None
28
+ limit: int = 10
29
+ into_context: bool = False
30
+ placement: str = "after_summary"
31
+ show: bool = False
32
+ scope: str = "run" # run|session|global|all
33
+
34
+ def __post_init__(self) -> None:
35
+ object.__setattr__(self, "tags", dict(self.tags or {}))
36
+
37
+ raw_mode = str(getattr(self, "tags_mode", "") or "").strip().lower() or "all"
38
+ if raw_mode in ("and",):
39
+ raw_mode = "all"
40
+ if raw_mode in ("or",):
41
+ raw_mode = "any"
42
+ if raw_mode not in ("all", "any"):
43
+ raw_mode = "all"
44
+ object.__setattr__(self, "tags_mode", raw_mode)
45
+
46
+ def _norm_list(values: Any) -> List[str]:
47
+ if not isinstance(values, list):
48
+ return []
49
+ out: List[str] = []
50
+ for v in values:
51
+ if isinstance(v, str) and v.strip():
52
+ out.append(v.strip())
53
+ # preserve order but dedup (case-insensitive)
54
+ seen: set[str] = set()
55
+ deduped: List[str] = []
56
+ for s in out:
57
+ key = s.lower()
58
+ if key in seen:
59
+ continue
60
+ seen.add(key)
61
+ deduped.append(s)
62
+ return deduped
63
+
64
+ object.__setattr__(self, "users", _norm_list(getattr(self, "users", None)))
65
+ object.__setattr__(self, "locations", _norm_list(getattr(self, "locations", None)))
66
+
67
+ raw_scope = str(getattr(self, "scope", "") or "").strip().lower() or "run"
68
+ if raw_scope not in ("run", "session", "global", "all"):
69
+ raw_scope = "run"
70
+ object.__setattr__(self, "scope", raw_scope)
71
+
72
+
73
+ def parse_recall_args(raw: str) -> RecallRequest:
74
+ """Parse `/recall` arguments.
75
+
76
+ Supported flags:
77
+ - `--since ISO`
78
+ - `--until ISO`
79
+ - `--tag k=v` (repeatable)
80
+ - `--tags-mode all|any` (default all; AND/OR across tag keys)
81
+ - `--user NAME` (repeatable; alias: --users)
82
+ - `--location LOC` (repeatable; alias: --locations)
83
+ - `--q text` (if absent, remaining args become query)
84
+ - `--limit N`
85
+ - `--into-context`
86
+ - `--placement after_summary|after_system|end`
87
+ - `--show` (show full note content for memory_note matches)
88
+ - `--scope run|session|global|all`
89
+ """
90
+ import shlex
91
+
92
+ try:
93
+ parts = shlex.split(raw) if raw else []
94
+ except ValueError:
95
+ parts = raw.split() if raw else []
96
+
97
+ since: Optional[str] = None
98
+ until: Optional[str] = None
99
+ tags: Dict[str, Any] = {}
100
+ tags_mode = "all"
101
+ users: List[str] = []
102
+ locations: List[str] = []
103
+ query: Optional[str] = None
104
+ limit = 10
105
+ into_context = False
106
+ placement = "after_summary"
107
+ show = False
108
+ scope = "run"
109
+
110
+ leftovers: list[str] = []
111
+ i = 0
112
+ while i < len(parts):
113
+ p = parts[i]
114
+ if p in ("--since", "--from"):
115
+ if i + 1 >= len(parts):
116
+ raise ValueError("--since requires an ISO timestamp")
117
+ since = str(parts[i + 1])
118
+ i += 2
119
+ continue
120
+ if p in ("--until", "--to"):
121
+ if i + 1 >= len(parts):
122
+ raise ValueError("--until requires an ISO timestamp")
123
+ until = str(parts[i + 1])
124
+ i += 2
125
+ continue
126
+ if p in ("--tag", "--tags"):
127
+ if i + 1 >= len(parts):
128
+ raise ValueError("--tag requires k=v")
129
+ kv = str(parts[i + 1])
130
+ if "=" not in kv:
131
+ raise ValueError("--tag requires k=v")
132
+ k, v = kv.split("=", 1)
133
+ k = k.strip()
134
+ v = v.strip()
135
+ if not k or not v:
136
+ raise ValueError("--tag requires non-empty k=v")
137
+ if k != "kind":
138
+ prev = tags.get(k)
139
+ if prev is None:
140
+ tags[k] = v
141
+ elif isinstance(prev, str):
142
+ if prev != v:
143
+ tags[k] = [prev, v]
144
+ elif isinstance(prev, list):
145
+ if v not in prev:
146
+ prev.append(v)
147
+ i += 2
148
+ continue
149
+ if p in ("--tags-mode", "--tag-mode", "--tags-op", "--tag-op"):
150
+ if i + 1 >= len(parts):
151
+ raise ValueError("--tags-mode requires all|any")
152
+ mode = str(parts[i + 1]).strip().lower()
153
+ if mode in ("and",):
154
+ mode = "all"
155
+ if mode in ("or",):
156
+ mode = "any"
157
+ if mode not in ("all", "any"):
158
+ raise ValueError("--tags-mode must be all|any")
159
+ tags_mode = mode
160
+ i += 2
161
+ continue
162
+ if p in ("--user", "--users"):
163
+ if i + 1 >= len(parts):
164
+ raise ValueError("--user requires a name")
165
+ raw_user = str(parts[i + 1]).strip()
166
+ if raw_user:
167
+ for seg in raw_user.split(","):
168
+ s = seg.strip()
169
+ if s:
170
+ users.append(s)
171
+ i += 2
172
+ continue
173
+ if p in ("--location", "--locations"):
174
+ if i + 1 >= len(parts):
175
+ raise ValueError("--location requires a value")
176
+ raw_loc = str(parts[i + 1]).strip()
177
+ if raw_loc:
178
+ for seg in raw_loc.split(","):
179
+ s = seg.strip()
180
+ if s:
181
+ locations.append(s)
182
+ i += 2
183
+ continue
184
+ if p in ("--q", "--query"):
185
+ if i + 1 >= len(parts):
186
+ raise ValueError("--q requires a query string")
187
+
188
+ # Consume tokens until the next flag, so `--q player dies` works
189
+ # without requiring quotes.
190
+ j = i + 1
191
+ buf: list[str] = []
192
+ while j < len(parts) and not str(parts[j]).startswith("--"):
193
+ buf.append(str(parts[j]))
194
+ j += 1
195
+ query = " ".join([x for x in buf if x]).strip() or None
196
+ i = j
197
+ continue
198
+ if p == "--limit":
199
+ if i + 1 >= len(parts):
200
+ raise ValueError("--limit requires a number")
201
+ try:
202
+ limit = int(parts[i + 1])
203
+ except Exception:
204
+ raise ValueError("--limit requires a number") from None
205
+ if limit < 1:
206
+ limit = 1
207
+ i += 2
208
+ continue
209
+ if p == "--into-context":
210
+ into_context = True
211
+ i += 1
212
+ continue
213
+ if p == "--show":
214
+ show = True
215
+ i += 1
216
+ continue
217
+ if p == "--scope":
218
+ if i + 1 >= len(parts):
219
+ raise ValueError("--scope requires a value")
220
+ scope = str(parts[i + 1]).strip().lower() or "run"
221
+ if scope not in ("run", "session", "global", "all"):
222
+ raise ValueError("--scope must be run|session|global|all")
223
+ i += 2
224
+ continue
225
+ if p == "--placement":
226
+ if i + 1 >= len(parts):
227
+ raise ValueError("--placement requires a value")
228
+ placement = str(parts[i + 1]).strip()
229
+ if placement not in ("after_summary", "after_system", "end"):
230
+ raise ValueError("--placement must be after_summary|after_system|end")
231
+ i += 2
232
+ continue
233
+ if p.startswith("--"):
234
+ raise ValueError(f"Unknown flag: {p}")
235
+
236
+ leftovers.append(str(p))
237
+ i += 1
238
+
239
+ if query is None and leftovers:
240
+ query = " ".join([p for p in leftovers if p]).strip() or None
241
+
242
+ def _validate_iso(value: Optional[str], *, flag: str) -> None:
243
+ if value is None:
244
+ return
245
+ import datetime as _dt
246
+
247
+ v = str(value).strip()
248
+ if not v:
249
+ return
250
+ if v.endswith("Z"):
251
+ v = v[:-1] + "+00:00"
252
+ try:
253
+ _dt.datetime.fromisoformat(v)
254
+ except Exception as e:
255
+ raise ValueError(f"{flag} must be ISO8601 (got: {value})") from e
256
+
257
+ _validate_iso(since, flag="--since")
258
+ _validate_iso(until, flag="--until")
259
+
260
+ return RecallRequest(
261
+ since=since,
262
+ until=until,
263
+ tags=tags,
264
+ tags_mode=tags_mode,
265
+ users=users,
266
+ locations=locations,
267
+ query=query,
268
+ limit=limit,
269
+ into_context=into_context,
270
+ placement=placement,
271
+ show=show,
272
+ scope=scope,
273
+ )
274
+
275
+
276
+ def execute_recall(
277
+ *,
278
+ run_id: str,
279
+ run_store: Any,
280
+ artifact_store: Any,
281
+ request: RecallRequest,
282
+ ) -> Dict[str, Any]:
283
+ """Execute a recall request against a run.
284
+
285
+ Returns:
286
+ dict with keys:
287
+ - matches: list[dict]
288
+ - rehydration: dict | None
289
+ """
290
+ policy = ActiveContextPolicy(run_store=run_store, artifact_store=artifact_store)
291
+
292
+ def _resolve_session_root_id(start_run_id: str) -> str:
293
+ cur_id = str(start_run_id or "").strip()
294
+ seen: set[str] = set()
295
+ while cur_id and cur_id not in seen:
296
+ seen.add(cur_id)
297
+ st = run_store.load(cur_id)
298
+ if st is None:
299
+ return cur_id
300
+ parent = getattr(st, "parent_run_id", None)
301
+ if not isinstance(parent, str) or not parent.strip():
302
+ return cur_id
303
+ cur_id = parent.strip()
304
+ return str(start_run_id or "").strip()
305
+
306
+ def _global_memory_run_id() -> str:
307
+ import os
308
+ import re
309
+
310
+ rid = str(os.environ.get("ABSTRACTRUNTIME_GLOBAL_MEMORY_RUN_ID") or "").strip()
311
+ if rid and re.match(r"^[a-zA-Z0-9_-]+$", rid):
312
+ return rid
313
+ return "global_memory"
314
+
315
+ time_range: Optional[TimeRange] = None
316
+ if request.since or request.until:
317
+ time_range = TimeRange(start=request.since, end=request.until)
318
+
319
+ scope = str(request.scope or "run").strip().lower() or "run"
320
+ run_ids: list[str] = []
321
+ if scope == "run":
322
+ run_ids = [run_id]
323
+ elif scope == "session":
324
+ run_ids = [_resolve_session_root_id(run_id)]
325
+ elif scope == "global":
326
+ run_ids = [_global_memory_run_id()]
327
+ else: # all
328
+ root_id = _resolve_session_root_id(run_id)
329
+ global_id = _global_memory_run_id()
330
+ # Deterministic order; dedup.
331
+ seen_ids: set[str] = set()
332
+ for rid in (run_id, root_id, global_id):
333
+ if rid and rid not in seen_ids:
334
+ seen_ids.add(rid)
335
+ run_ids.append(rid)
336
+
337
+ matches: list[dict[str, Any]] = []
338
+ seen_artifacts: set[str] = set()
339
+ for rid in run_ids:
340
+ # Skip missing runs (e.g. global memory not created yet).
341
+ st = run_store.load(rid)
342
+ if st is None:
343
+ continue
344
+ part = policy.filter_spans(
345
+ rid,
346
+ time_range=time_range,
347
+ tags=(request.tags or None),
348
+ tags_mode=request.tags_mode,
349
+ authors=(request.users or None),
350
+ locations=(request.locations or None),
351
+ query=request.query,
352
+ limit=int(request.limit),
353
+ )
354
+ for s in part:
355
+ if not isinstance(s, dict):
356
+ continue
357
+ aid = str(s.get("artifact_id") or "").strip()
358
+ if not aid or aid in seen_artifacts:
359
+ continue
360
+ seen_artifacts.add(aid)
361
+ annotated = dict(s)
362
+ annotated["owner_run_id"] = rid
363
+ matches.append(annotated)
364
+
365
+ rehydration: Optional[Dict[str, Any]] = None
366
+ if request.into_context:
367
+ # Rehydrate the selected span(s) into active context. This is deterministic and persists
368
+ # the updated run state. Notes are rehydrated as a synthetic message.
369
+ span_ids: list[str] = []
370
+ for s in matches:
371
+ if not isinstance(s, dict):
372
+ continue
373
+ aid = s.get("artifact_id")
374
+ if isinstance(aid, str) and aid:
375
+ span_ids.append(aid)
376
+ if span_ids:
377
+ rehydration = policy.rehydrate_into_context(
378
+ run_id,
379
+ span_ids=span_ids,
380
+ placement=request.placement,
381
+ dedup_by="message_id",
382
+ )
383
+
384
+ return {"matches": matches, "rehydration": rehydration}
@@ -0,0 +1,184 @@
1
+ """AbstractCode remember helpers (no LLM required).
2
+
3
+ AbstractCode is a host UX; "remember" should be implemented via runtime-owned
4
+ memory primitives so behavior stays consistent across hosts.
5
+
6
+ This module provides:
7
+ - lightweight argument parsing for `/memorize`
8
+ - an execution helper that stores a runtime `MEMORY_NOTE` targeting a run
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Any, Dict, Optional
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class RememberRequest:
19
+ note: str
20
+ tags: Dict[str, str]
21
+ span_id: Optional[str] = None
22
+ last_span: bool = False
23
+ last_messages: int = 6
24
+ scope: str = "run" # run|session|global
25
+
26
+ def __post_init__(self) -> None:
27
+ object.__setattr__(self, "note", str(self.note or "").strip())
28
+ object.__setattr__(self, "tags", dict(self.tags or {}))
29
+ raw_scope = str(getattr(self, "scope", "") or "").strip().lower() or "run"
30
+ if raw_scope not in ("run", "session", "global"):
31
+ raw_scope = "run"
32
+ object.__setattr__(self, "scope", raw_scope)
33
+
34
+
35
+ def parse_remember_args(raw: str) -> RememberRequest:
36
+ """Parse `/memorize` arguments.
37
+
38
+ Syntax:
39
+ /memorize <note text> [--tag k=v ...] [--span <span_id>] [--last-span] [--last N] [--scope run|session|global]
40
+
41
+ Notes:
42
+ - Note text may be quoted, but quoting is optional (we treat all non-flag tokens as note text).
43
+ - Tags are JSON-safe `str -> str` and `kind=...` is ignored (reserved).
44
+ """
45
+ import shlex
46
+
47
+ try:
48
+ parts = shlex.split(raw) if raw else []
49
+ except ValueError:
50
+ parts = raw.split() if raw else []
51
+
52
+ tags: Dict[str, str] = {}
53
+ span_id: Optional[str] = None
54
+ last_span = False
55
+ last_messages = 6
56
+ scope = "run"
57
+ note_parts: list[str] = []
58
+
59
+ i = 0
60
+ while i < len(parts):
61
+ p = str(parts[i])
62
+ if p in ("--tag", "--tags"):
63
+ if i + 1 >= len(parts):
64
+ raise ValueError("--tag requires k=v")
65
+ kv = str(parts[i + 1])
66
+ if "=" not in kv:
67
+ raise ValueError("--tag requires k=v")
68
+ k, v = kv.split("=", 1)
69
+ key = k.strip()
70
+ val = v.strip()
71
+ if not key or not val:
72
+ raise ValueError("--tag requires non-empty k=v")
73
+ if key != "kind":
74
+ tags[key] = val
75
+ i += 2
76
+ continue
77
+ if p == "--span":
78
+ if i + 1 >= len(parts):
79
+ raise ValueError("--span requires a span_id")
80
+ span_id = str(parts[i + 1]).strip() or None
81
+ i += 2
82
+ continue
83
+ if p == "--last-span":
84
+ last_span = True
85
+ i += 1
86
+ continue
87
+ if p == "--last":
88
+ if i + 1 >= len(parts):
89
+ raise ValueError("--last requires a number")
90
+ try:
91
+ last_messages = int(parts[i + 1])
92
+ except Exception as e:
93
+ raise ValueError("--last requires a number") from e
94
+ if last_messages < 0:
95
+ last_messages = 0
96
+ i += 2
97
+ continue
98
+ if p == "--scope":
99
+ if i + 1 >= len(parts):
100
+ raise ValueError("--scope requires a value")
101
+ scope = str(parts[i + 1]).strip().lower() or "run"
102
+ if scope not in ("run", "session", "global"):
103
+ raise ValueError("--scope must be run|session|global")
104
+ i += 2
105
+ continue
106
+ if p.startswith("--"):
107
+ raise ValueError(f"Unknown flag: {p}")
108
+
109
+ note_parts.append(p)
110
+ i += 1
111
+
112
+ note = " ".join([p for p in note_parts if p]).strip()
113
+ if not note:
114
+ raise ValueError("note text is required")
115
+
116
+ return RememberRequest(note=note, tags=tags, span_id=span_id, last_span=last_span, last_messages=last_messages, scope=scope)
117
+
118
+
119
+ def store_memory_note(
120
+ *,
121
+ runtime: Any,
122
+ target_run_id: str,
123
+ note: str,
124
+ tags: Dict[str, str],
125
+ sources: Dict[str, Any],
126
+ actor_id: Optional[str],
127
+ session_id: Optional[str],
128
+ call_id: str = "memorize",
129
+ scope: str = "run",
130
+ ) -> Dict[str, Any]:
131
+ """Store a runtime memory note targeting `target_run_id`.
132
+
133
+ This is implemented as a tiny child workflow that emits `EffectType.MEMORY_NOTE`
134
+ with `payload.target_run_id`, so the runtime stores the note on the target run.
135
+ """
136
+ from abstractruntime import Effect, EffectType, StepPlan, WorkflowSpec
137
+ from abstractruntime.core.models import RunStatus
138
+
139
+ payload: Dict[str, Any] = {
140
+ "target_run_id": str(target_run_id),
141
+ "note": str(note or ""),
142
+ "tags": dict(tags or {}),
143
+ "sources": dict(sources or {}),
144
+ "scope": str(scope or "run"),
145
+ "tool_name": "remember_note",
146
+ "call_id": str(call_id or "remember"),
147
+ }
148
+
149
+ def remember_node(run, ctx) -> StepPlan:
150
+ return StepPlan(
151
+ node_id="remember",
152
+ effect=Effect(type=EffectType.MEMORY_NOTE, payload=payload, result_key="_temp.remember"),
153
+ next_node="done",
154
+ )
155
+
156
+ def done_node(run, ctx) -> StepPlan:
157
+ temp = run.vars.get("_temp")
158
+ if not isinstance(temp, dict):
159
+ temp = {}
160
+ return StepPlan(node_id="done", complete_output={"result": temp.get("remember")})
161
+
162
+ wf = WorkflowSpec(
163
+ workflow_id="abstractcode_remember_command",
164
+ entry_node="remember",
165
+ nodes={"remember": remember_node, "done": done_node},
166
+ )
167
+
168
+ remember_run_id = runtime.start(
169
+ workflow=wf,
170
+ vars={"context": {}, "scratchpad": {}, "_runtime": {}, "_temp": {}, "_limits": {}},
171
+ actor_id=actor_id,
172
+ session_id=session_id,
173
+ parent_run_id=str(target_run_id),
174
+ )
175
+ st = runtime.tick(workflow=wf, run_id=remember_run_id, max_steps=50)
176
+ if st.status != RunStatus.COMPLETED:
177
+ raise RuntimeError(st.error or "remember_note failed")
178
+
179
+ out = st.output or {}
180
+ result = out.get("result") if isinstance(out, dict) else None
181
+ if not isinstance(result, dict):
182
+ return {"remember_run_id": remember_run_id, "result": result}
183
+ return {"remember_run_id": remember_run_id, **result}
184
+